
Préface
Mon site, que j'exploite comme un hobby, est destiné à stocker des pages d'accueil intéressantes et des sites personnels. Ce sujet m'a passionné dès le début de mon parcours en programmation, lorsque j'étais fasciné par la découverte de grands professionnels qui écrivent sur eux-mêmes, leurs passions et leurs projets. Cette habitude de les découvrir persiste encore aujourd'hui : presque sur chaque site commercial ou non, je continues de jeter un œil au pied de page à la recherche de liens vers les auteurs.
Réalisation de l'idée
La première version était simplement une page html sur mon site personnel, où je classais des liens avec des descriptions dans une liste ul. Après avoir accumulé une vingtaine de pages, j'ai commencé à penser que ce n'était pas très efficace et j'ai décidé d'essayer d'automatiser le processus. Sur stackoverflow, j'avais remarqué que beaucoup indiquaient leurs sites dans leurs profils, donc j'ai écrit un parseur en php qui parcourait simplement les profils, en commençant par le premier (les adresses sur SO sont encore de ce type : `\/users\/1`), extrayait des liens du tag requis et les sauvegardait dans SQLite.
On peut appeler cela la deuxième version : une collection de dizaines de milliers d'urls dans une table SQLite, qui a remplacé la liste statique en html. J'ai créé une recherche simple sur cette liste. Comme il n'y avait que des urls, la recherche était donc juste par elles.
À ce stade, j'ai laissé tomber le projet et y suis revenu après un long moment. Mon expérience professionnelle avait déjà dépassé trois ans et je sentais que je pouvais faire quelque chose de plus sérieux. De plus, il y avait un grand désir d'apprendre des technologies relativement nouvelles pour moi.
Version moderne
déployé dans docker, la base est passée à mongoDb, et depuis relativement peu de temps, Redis a été ajouté, qui était à l'origine seulement pour le caching. Comme base, l'un des micro-frameworks PHP est utilisé.
Le problème
De nouveaux sites sont ajoutés par une commande en ligne de commande, qui exécute les actions suivantes simultanément :
- Télécharge du contenu par URL
- Affiche un drapeau indiquant si HTTPS était disponible
- Sauvegarde l'entité du site web
- Conserve le HTML d'origine et les en-têtes dans l'historique d'« indexation »
- Analyse le contenu, extrait le Title et la Description
- Les données sont sauvegardées dans une collection séparée
C'était suffisant pour simplement stocker les sites et les afficher dans une liste :

Mais l'idée de tout indexer, catégoriser et classer automatiquement, tout en maintenant le tout à jour, était faiblement intégrée dans ce paradigme. Même l'ajout d'une méthode web pour ajouter des pages nécessitait une duplication de code et des blocages pour éviter un DDoS potentiel.
En effet, tout peut être fait de manière synchrone, et dans la méthode web, il suffit de sauvegarder l'URL afin qu'un démon monstrueux exécute toutes les tâches pour les URL de la liste. Mais même ici, le mot « file d'attente » s'impose. Si l'on intègre une file d'attente, on peut diviser toutes les tâches et les exécuter au moins de manière asynchrone.
Solution
Implanter des files d'attente et créer un système de traitement d'événements pour toutes les tâches. Il y a longtemps que je voulais essayer Redis Streams.
Utilisation de Redis Streams en PHP
Puisque mon framework ne fait pas partie des trois géants Symfony, Laravel, Yii, je cherchais donc une bibliothèque indépendante. Mais, comme il s'est avéré (à la première inspection), il est impossible de trouver des bibliothèques sérieuses distinctes. Tout ce qui est lié aux files d'attente est soit un petit projet avec 3 commits datant de cinq ans, soit lié à un framework.
J'entends beaucoup parler de Symfony comme fournisseur de composants utiles, et certains que j'utilise déjà. De plus, on peut également utiliser certaines choses de Laravel, par exemple leur ORM, sans la présence du framework lui-même.
symfony/messenger
Le premier candidat m'a tout de suite semblé parfait, et sans aucun doute, je l'ai installé. Mais il s'est avéré plus difficile de trouver des exemples d'utilisation en dehors de Symfony. Comment assembler un bus de message à partir d'une foule de classes avec des noms universels et sans signification, tout en utilisant Redis ?

La documentation sur le site officiel était assez détaillée, mais l'initialisation n'était décrite que pour Symfony à l'aide de leur YML favori et d'autres méthodes magiques pour ceux qui ne sont pas de Symfony. Je n'avais pas d'intérêt particulier dans le processus d'installation, surtout pendant les vacances de Nouvel An. Mais j'ai dû m'y atteler et cela a pris étonnamment longtemps.
Essayer de comprendre l'instanciation du système à partir des sources Symfony n'est pas une tâche triviale dans des délais serrés :

En fouillant dans tout cela et en essayant d'arranger les choses manuellement, j'ai conclu que je me débattais avec des béquilles et j'ai décidé d'essayer quelque chose d'autre.
illuminate/queue
Il s'est avéré que cette bibliothèque est étroitement liée à l'infrastructure Laravel et à de nombreuses autres dépendances, donc je n'ai pas passé beaucoup de temps dessus : je l'ai installée, l'ai examinée, ai vu les dépendances et l'ai supprimée.
yiisoft/yii2-queue
Ici, on supposait immédiatement à partir du nom qu'il y avait encore une forte dépendance à Yii2. J'ai utilisé cette bibliothèque et elle était correcte, mais je ne pensais pas qu'elle dépendait entièrement de Yii2.
Autres
Tout le reste que j'ai trouvé sur GitHub - des projets obsolètes et peu fiables sans étoiles, forks ni un grand nombre de commits.
Retour à symfony/messenger, détails techniques
J'ai dû comprendre cette bibliothèque et, après avoir dépensé un peu de temps, j'ai réussi. Il s'est avéré que tout est assez concis et simple. Pour instancier le bus, j'ai créé une petite fabrique, car j'avais prévu plusieurs bus avec des gestionnaires différents.

Quelques étapes seulement :
- Nous créons des gestionnaires de messages, qui doivent simplement être des callable
- Nous les enveloppons dans HandlerDescriptor (une classe de la bibliothèque)
- Ces « Descriptors » sont enveloppés dans une instance de HandlersLocator
- Nous ajoutons HandlersLocator à l'instance de MessageBus
- Nous passons à SendersLocator un ensemble `SenderInterface`, dans mon cas des instances des classes `RedisTransport`, qui sont configurées de manière évidente
- Nous ajoutons SendersLocator à l'instance de MessageBus
MessageBus a une méthode `->dispatch()`, qui recherche les gestionnaires correspondants dans HandlersLocator et leur transmet le message, en utilisant les `SenderInterface` appropriés pour l'envoi via le bus (flux Redis).
Dans la configuration du conteneur (dans ce cas, php-di), toute cette liaison peut être configurée ainsi :
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);
},
Ici, nous pouvons voir que dans SendersLocator, pour deux messages différents, nous avons attribué un « transport » différent, chacun d'eux ayant sa propre connexion aux flux correspondants.
J'ai créé un projet démo distinct montrant une application composée de trois démons communiquant entre eux via un tel bus : .
Mais je vais montrer comment un consommateur peut être conçu :
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();
Utilisation de cette infrastructure dans l'application
Après avoir mis en œuvre le bus dans mon backend, j'ai isolé des étapes distinctes de l'ancienne commande synchrone et créé des gestionnaires séparés, chacun ayant sa propre tâche.
Le pipeline d'ajout d'un nouveau site à la base de données est le suivant :

Et juste après cela, il m'est devenu beaucoup plus facile d'ajouter de nouvelles fonctionnalités, comme l'extraction et l'analyse de flux Rss. Étant donné que ce processus nécessite également du contenu source, le gestionnaire qui extrait les liens vers le rss, tout comme le WebsiteIndexHistoryPersistor, s'abonne au message « Content/HtmlContent », le traite et transmet le message requis à son propre pipeline.

En fin de compte, plusieurs démons ont été créés, chacun maintenant des connexions uniquement aux ressources nécessaires. Par exemple, le démon crawlers contient tous les gestionnaires qui nécessitent d'aller sur Internet pour obtenir du contenu, tandis que le démon persister maintient la connexion à la base de données.
Désormais, au lieu de faire des sélections dans la base de données, les identifiants requis après l'insertion par le persister sont simplement transmis via le bus à tous les gestionnaires concernés.
Source : habr.com
