Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

Prólogo

Mi sitio, que gestiono como un pasatiempo, está destinado a almacenar páginas de inicio interesantes y sitios web personales. Este tema comenzó a interesarme al principio de mi camino en la programación; en ese momento, me fascinaba encontrar a grandes profesionales que escriben sobre ellos mismos, sus pasiones y proyectos. La costumbre de descubrirlos se ha mantenido y ahora: casi en cada sitio comercial y otros, sigo mirando el pie de página en busca de enlaces a los autores.

Implementación de la idea

La primera versión era simplemente una página HTML en mi sitio personal, donde recopilaba enlaces con descripciones en una lista ul. Después de reunir unas 20 páginas en un tiempo, comencé a pensar que eso no era muy eficiente y decidí intentar automatizar el proceso. En stackoverflow noté que muchos indicaban sitios en sus perfiles, así que escribí un parser en PHP que simplemente recorría los perfiles, empezando por el primero (las direcciones en SO todavía son de este tipo: `\/users\/1`), extraía los enlaces del tag necesario y los almacenaba en SQLite.

Esto se puede llamar la segunda versión: una colección de decenas de miles de URLs en una tabla de SQLite que reemplazó la lista estática en HTML. Con esta lista hice una búsqueda simple. Dado que solo había URLs, la búsqueda también era solo por ellas.

En esta etapa abandoné el proyecto y volví a él después de mucho tiempo. En este punto, mi experiencia laboral ya era de más de tres años y sentía que podía hacer algo más serio. Además, tenía un gran deseo de aprender tecnologías relativamente nuevas para mí.

Versión moderna

Proyecto desplegado en Docker, la base de datos fue trasladada a MongoDB, y desde hace relativamente poco, se agregó Redis, que inicialmente fue solo para caché. Como base se utiliza uno de los microframeworks PHP.

Problema

Nuevos sitios se añaden con un comando en la consola, que hace lo siguiente de manera síncrona:

  • Descarga contenido de la URL
  • Establece un indicador de si estaba disponible HTTPS
  • Guarda la entidad del sitio web
  • El HTML original y los encabezados se guardan en el historial de 'indexación'
  • Parsea el contenido, extrae Title y Description
  • Los datos se guardan en una colección separada

Esto fue suficiente para simplemente almacenar sitios y mostrarlos en la lista:

Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

Sin embargo, la idea de indexar, categorizar y clasificar todo automáticamente, manteniendo todo actualizado, se ajustaba débilmente a esta nueva paradigma. Incluso la simple adición de un método web para agregar páginas requirió la duplicación de código y bloqueos para evitar un posible DDoS.

En general, por supuesto, se puede hacer todo de manera sincronizada, y en el método web simplemente guardar la URL para que un monstruoso demonio ejecute todas las tareas de las URL de la lista. Pero aun así, aquí también surge la palabra 'cola'. Y si se implementa una cola, se pueden dividir todas las tareas y ejecutarlas al menos de manera asincrónica.

Solución

Implementar colas y hacer un sistema de procesamiento de tareas basado en eventos. Además, desde hace tiempo quería probar Redis Streams.

Uso de Redis Streams en PHP

Dado que mi marco no es uno de los gigantes Symfony, Laravel o Yii, también quería encontrar una biblioteca independiente. Pero, como resultó (en un primer examen), no es posible encontrar bibliotecas serias y separadas. Todo lo relacionado con colas es o un proyecto de 3 commits de hace cinco años, o está atado a un marco.

He oído hablar de Symfony como proveedor de componentes útiles por separado, y además, ya utilizo algunos. Y también se puede utilizar algo de Laravel, como su ORM, sin la presencia del propio marco.

symfony/messenger

El primer candidato me pareció perfecto y sin ninguna duda lo instalé. Pero encontrar ejemplos de uso fuera de Symfony resultó ser más complicado. ¿Cómo armar un bus para la transferencia de mensajes a partir de un montón de clases con nombres universales y sin sentido, y aún así en Redis?

Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

La documentación en el sitio oficial era bastante detallada, pero la inicialización estaba descrita solo para Symfony usando su querido YML y otros métodos mágicos para quienes no son de Symfony. No tenía interés en el proceso de instalación en sí, especialmente durante las vacaciones de Año Nuevo. Pero tuve que ocuparme de esto y me llevó más tiempo del que esperaba.

Tratar de entender la instancia del sistema a partir de los mismos fuentes de Symfony tampoco es la tarea más trivial en poco tiempo:

Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

Después de hurgar en todo esto y tratar de hacer algo con mis propias manos, llegué a la conclusión de que estaba haciendo algún tipo de soluciones temporales y decidí probar algo más.

illuminate/queue

Resulta que esta biblioteca está profundamente conectada a la infraestructura de Laravel y a una serie de otras dependencias, así que no perdí mucho tiempo en ella: la instalé, la revisé, vi las dependencias y la eliminé.

yiisoft/yii2-queue

Aquí, desde el principio, se asumía por el nombre que había una fuerte dependencia a Yii2. He tenido que usar esta biblioteca y era bastante buena, pero no pensé que dependiera completamente de Yii2.

Otros

Todo lo demás que encontré en GitHub eran proyectos poco confiables, obsoletos y abandonados sin estrellas, forks o un gran número de commits.

Regreso a symfony/messenger, detalles técnicos

Tuve que entender esta biblioteca y, tras dedicar un tiempo extra, lo logré. Resulta que todo es bastante claro y sencillo. Para instanciar el bus, hice una pequeña fábrica, ya que se preveían varios buses con diferentes manejadores.

Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

Solo unos pocos pasos:

  • Creamos manejadores de mensajes, que deben ser simplemente ejecutables
  • Los envolvemos en HandlerDescriptor (una clase de la biblioteca)
  • Estos "descriptores" los envolvemos en una instancia de HandlersLocator
  • Añadimos HandlersLocator a una instancia de MessageBus
  • Pasamos a SendersLocator un conjunto de `SenderInterface`, en mi caso instancias de las clases `RedisTransport`, que se configuran de manera obvia
  • Añadimos SendersLocator a una instancia de MessageBus

MessageBus tiene un método `->dispatch()`, que busca los manejadores correspondientes en HandlersLocator y les pasa el mensaje utilizando los correspondientes `SenderInterface` para enviarlo a través del bus (streams de Redis).

En la configuración del contenedor (en este caso php-di), toda esta conexión puede configurarse así:

        CONTAINER_REDIS_TRANSPORT_SECRET => function (ContainerInterface $c) {
            return new RedisTransport(
                $c->get(CONTAINER_REDIS_STREAM_CONNECTION_SECRET),
                $c->get(CONTAINER_SERIALIZER))
            ;
        },
        CONTAINER_REDIS_TRANSPORT_LOG => function (ContainerInterface $c) {
            return new RedisTransport(
                $c->get(CONTAINER_REDIS_STREAM_CONNECTION_LOG),
                $c->get(CONTAINER_SERIALIZER))
            ;
        },
        CONTAINER_REDIS_STREAM_RECEIVER_SECRET => function (ContainerInterface $c) {
            return new RedisReceiver(
                $c->get(CONTAINER_REDIS_STREAM_CONNECTION_SECRET),
                $c->get(CONTAINER_SERIALIZER)
            );
        },
        CONTAINER_REDIS_STREAM_RECEIVER_LOG => function (ContainerInterface $c) {
            return new RedisReceiver(
                $c->get(CONTAINER_REDIS_STREAM_CONNECTION_LOG),
                $c->get(CONTAINER_SERIALIZER)
            );
        },
        CONTAINER_REDIS_STREAM_BUS => function (ContainerInterface $c) {
            $sendersLocator = new SendersLocator([
                AppMessagesSecretJsonMessages::class => [CONTAINER_REDIS_TRANSPORT_SECRET],
                AppMessagesDaemonLogMessage::class => [CONTAINER_REDIS_TRANSPORT_LOG],
            ], $c);
            $middleware[] = new SendMessageMiddleware($sendersLocator);

            return new MessageBus($middleware);
        },
        CONTAINER_REDIS_STREAM_CONNECTION_SECRET => function (ContainerInterface $c) {
            $host = 'bu-02-redis';
            $port = 6379;
            $dsn = "redis://$host:$port";
            $options = [
                'stream' => 'secret',
                'group' => 'default',
                'consumer' => 'default',
            ];

            return Connection::fromDsn($dsn, $options);
        },
        CONTAINER_REDIS_STREAM_CONNECTION_LOG => function (ContainerInterface $c) {
            $host = 'bu-02-redis';
            $port = 6379;
            $dsn = "redis://$host:$port";
            $options = [
                'stream' => 'log',
                'group' => 'default',
                'consumer' => 'default',
            ];

            return Connection::fromDsn($dsn, $options);
        },

Aquí se puede ver que en SendersLocator hemos asignado diferentes "transportes" para dos mensajes distintos, cada uno con su propia conexión a los flujos correspondientes.

He creado un proyecto demo separado que muestra una aplicación compuesta por tres demonios que se comunican entre sí mediante dicho bus: https://github.com/backend-university/products/tree/master/products/02-redis-streams-bus.

Pero mostraré cómo podría estar construido el consumidor:

use AppMessagesDaemonLogMessage;
use SymfonyComponentMessengerHandlerHandlerDescriptor;
use SymfonyComponentMessengerHandlerHandlersLocator;
use SymfonyComponentMessengerMessageBus;
use SymfonyComponentMessengerMiddlewareHandleMessageMiddleware;
use SymfonyComponentMessengerMiddlewareSendMessageMiddleware;
use SymfonyComponentMessengerTransportSenderSendersLocator;

require_once __DIR__ . '/../vendor/autoload.php';
/** @var PsrContainerContainerInterface $container *//
$container = require_once('config/container.php');

$handlers = [
    DaemonLogMessage::class => [
        new HandlerDescriptor(
            function (DaemonLogMessage $m) {
                error_log('DaemonLogHandler: message handled: / ' . $m->getMessage());
            },
            ['from_transport' => CONTAINER_REDIS_TRANSPORT_LOG]
        )
    ],
];
$middleware = [];
$middleware[] = new HandleMessageMiddleware(new HandlersLocator($handlers));
$sendersLocator = new SendersLocator(['*' => [CONTAINER_REDIS_TRANSPORT_LOG]], $container);
$middleware[] = new SendMessageMiddleware($sendersLocator);

$bus = new MessageBus($middleware);
$receivers = [
    CONTAINER_REDIS_TRANSPORT_LOG => $container->get(CONTAINER_REDIS_STREAM_RECEIVER_LOG),
];
$w = new SymfonyComponentMessengerWorker($receivers, $bus, $container->get(CONTAINER_EVENT_DISPATCHER));
$w->run();

Uso de esta infraestructura en la aplicación

Al implementar el bus en mi backend, destapé etapas individuales de la antigua tarea sincrónica y creé manejadores separados, cada uno de los cuales se ocupa de su tarea específica.

El pipeline para agregar un nuevo sitio a la base de datos quedó así:

Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

Y justo después de eso me resultó mucho más fácil añadir nuevas funcionalidades, por ejemplo, la extracción y el análisis de Rss. Dado que este proceso también requiere contenido original, el manejador que extrae enlaces de Rss, al igual que el WebsiteIndexHistoryPersistor, se suscribe al mensaje 'Content/HtmlContent', lo procesa y envía el mensaje necesario a su pipeline.

Traducción del backend de PHP a la arquitectura de Redis streams y selección de una biblioteca independiente de los frameworks.

Al final, resultaron ser varios demonios, cada uno conectado solo a los recursos necesarios. Por ejemplo, el demonio crawlers contiene todos los manejadores que necesitan ir a Internet por contenido, mientras que el demonio persister mantiene la conexión a la base de datos.

Ahora, en lugar de hacer selects desde la base de datos, los id necesarios después de que el persister inserta simplemente se envían a través del bus a todos los manejadores interesados.

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster