
Vorwort
Meine Website, die ich als Hobby betreibe, dient der Sammlung interessanter persönlicher Seiten und Webseiten. Dieses Thema fand schon früh in meiner Programmierreise mein Interesse. Damals bewunderte ich große Profis, die über sich selbst, ihre Hobbys und Projekte schrieben. Diese Gewohnheit, sie zu entdecken, ist bis heute geblieben: Auf fast jeder kommerziellen sowie weniger bekannten Seite schaue ich weiterhin in den Footer auf der Suche nach Links zu den Autoren.
Ideenrealisierung
Die erste Version war einfach eine HTML-Seite auf meiner persönlichen Website, auf der ich Links mit Beschreibungen in einer ul-Liste sammelte. Nach einer gewissen Zeit, als ich etwa 20 Seiten gesammelt hatte, begann ich zu denken, dass dies nicht sehr effizient ist, und beschloss, den Prozess zu automatisieren. Auf StackOverflow bemerkte ich, dass viele ihre Webseiten in ihren Profilen angaben, also schrieb ich einen Parser in PHP, der einfach durch die Profile ging, beginnend mit dem ersten (die Links auf SO haben bis heute folgendes Format: `/users/1`), die Links aus dem gewünschten Tag extrahierte und in SQLite speicherte.
Man könnte dies als die zweite Version bezeichnen: eine Sammlung aus zehntausend URLs in einer SQLite-Tabelle, die die statische Liste in HTML ersetzt hat. Mit dieser Liste habe ich eine einfache Suche implementiert. Da es nur URLs gab, war die Suche entsprechend unkompliziert.
In dieser Phase habe ich das Projekt aufgegeben und kehrte nach längerer Zeit zurück. Zu diesem Zeitpunkt hatte ich bereits über drei Jahre Berufserfahrung und fühlte, dass ich etwas ernsthafteres angehen konnte. Außerdem hatte ich großes Verlangen, relativ neue Technologien zu erlernen.
Moderne Version
läuft in Docker, die Datenbank wurde auf MongoDB migriert, und seit relativ kurzer Zeit wurde Redis hinzugefügt, das zunächst nur für das Caching verwendet wurde. Als Basis dient einer der Microframeworks für PHP.
Das Problem
Neue Websites werden durch einen Konsolenbefehl hinzugefügt, der synchron Folgendes tut:
- Lädt den Inhalt über die URL herunter
- Setzt ein Flag darüber, ob HTTPS verfügbar war
- Speichert die Entität der Website
- Speichert den ursprünglichen HTML-Code und die Header in der Historie des 'Indexierens'
- Parst den Inhalt, extrahiert Title und Description
- Die Daten werden in einer separaten Sammlung gespeichert
Das war ausreichend, um die Websites einfach zu speichern und sie in einer Liste anzuzeigen:

Die Idee, alles automatisch zu indizieren, zu kategorisieren und zu bewerten sowie alles aktuell zu halten, passt nur bedingt in dieses Paradigma. Selbst das einfache Hinzufügen einer Webmethode zum Veröffentlichen von Seiten erforderte eine Code-Duplikation und sperrte Ressourcen, um potenzielle DDoS-Angriffe zu vermeiden.
Generell kann alles auch synchron erledigt werden, während in der Webmethode einfach die URL gespeichert wird, sodass das monströse Demon alle Aufgaben für die URLs aus der Liste durchführen kann. Dennoch drängt sich hier das Wort "Warteschlange" auf. Wenn wir eine Warteschlange implementieren, können wir alle Aufgaben aufteilen und zumindest asynchron ausführen.
Lösung
Implementieren Sie Warteschlangen und erstellen Sie ein ereignisgesteuertes System zur Verarbeitung aller Aufgaben. Ich wollte schon lange Redis Streams ausprobieren.
Verwendung von Redis Streams in PHP
Da mein Framework nicht zu den drei Giganten Symfony, Laravel oder Yii gehört, wollte ich eine unabhängige Bibliothek finden. Doch wie sich herausstellte (bei erster Betrachtung) sind ernsthafte, separate Bibliotheken nicht zu finden. Alles, was mit Warteschlangen zu tun hat, ist entweder ein kleines Projekt mit drei Commits aus vor fünf Jahren oder an ein Framework gebunden.
Ich habe viel über Symfony gehört, insbesondere als Anbieter nützlicher Komponenten, von denen ich einige bereits nutze. Auch die ORM von Laravel lässt sich ohne das Framework selbst verwenden.
symfony/messenger
Der erste Kandidat erschien mir sofort ideal und ohne Zweifel habe ich ihn installiert. Doch Beispiele für die Nutzung außerhalb von Symfony zu finden, stellte sich als schwieriger heraus. Wie kann man aus einer Vielzahl von Klassen mit allgemeinen, nichtssagenden Namen eine Nachrichtenübertragungs-Bus aufbauen, noch dazu mit Redis?

Die Dokumentation auf der offiziellen Webseite war ausreichend detailliert, aber die Initialisierung wurde nur für Symfony mit ihrem geliebten YML und anderen magischen Methoden beschrieben, die einem Nicht-Symfonisten nicht viel helfen. Ich hatte kein großes Interesse am Installationsprozess, besonders nicht während der Weihnachtsferien. Dennoch musste ich mich damit beschäftigen und es dauerte unerwartet lange.
Der Versuch, das System anhand der Symfony-Quellcodes zu instanziieren, ist auch keine triviale Aufgabe bei engen Zeitvorgaben:

Nachdem ich ein wenig in all dem gewühlt und versucht hatte, etwas manuell zu machen, kam ich zu dem Schluss, dass ich mir damit nur einige Probleme geschaffen hatte und entschied, etwas anderes auszuprobieren.
illuminate/queue
Es stellte sich heraus, dass diese Bibliothek eng an die Laravel-Infrastruktur und viele andere Abhängigkeiten gebunden ist. Daher habe ich nicht viel Zeit damit verbracht: installiert, angesehen, die Abhängigkeiten festgestellt und gelöscht.
yiisoft/yii2-queue
Hier war es bereits aus dem Namen abzuleiten, dass es eine enge Bindung an Yii2 gibt. Ich habe diese Bibliothek genutzt und sie war ganz gut, aber ich hätte nicht gedacht, dass sie völlig von Yii2 abhängt.
Die anderen
Alles andere, das ich auf GitHub gefunden habe, sind unzuverlässige, veraltete und verlassene Projekte ohne Sterne, Forks und eine große Anzahl von Commits.
Rückkehr zu symfony/messenger, technische Details
Ich musste mich mit dieser Bibliothek auseinandersetzen und, nachdem ich etwas Zeit investiert hatte, konnte ich es tun. Es stellte sich heraus, dass alles recht klar und einfach ist. Um die Bus-Instanz zu erstellen, habe ich eine kleine Fabrik gemacht, da ich mehrere Busse mit unterschiedlichen Handlern vorgesehen hatte.

Nur wenige Schritte:
- Wir erstellen Message-Handler, die einfach callable sein sollten.
- Wir verpacken sie in HandlerDescriptor (eine Klasse aus der Bibliothek).
- Diese 'Deskriptoren' verpacken wir in eine Instanz von HandlersLocator.
- Wir fügen HandlersLocator der Instanz von MessageBus hinzu.
- Wir übergeben dem SendersLocator die Sammlung `SenderInterface`, in meinem Fall Instanzen der Klassen `RedisTransport`, die offensichtlich konfiguriert werden.
- Wir fügen den SendersLocator der Instanz des MessageBus hinzu.
Der MessageBus verfügt über die Methode `->dispatch()`, die nach den entsprechenden Handlern im HandlersLocator sucht und die Nachricht an diese sendet, indem er die entsprechenden `SenderInterface` verwendet, um über den Bus (Redis Streams) zu senden.
In der Konfiguration des Containers (in diesem Fall php-di) kann diese gesamte Verknüpfung folgendermaßen konfiguriert werden:
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);
}
Hier sehen wir, dass wir in SendersLocator für zwei unterschiedliche Nachrichten verschiedene "Transportmittel" zugewiesen haben, die jeweils ihre eigene Verbindung zu den entsprechenden Streams haben.
Ich habe ein separates Demo-Projekt erstellt, das eine Anwendung aus drei Daemons demonstriert, die über einen solchen Bus miteinander kommunizieren: .
Aber ich zeige, wie ein Consumer aufgebaut sein könnte:
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();
Die Nutzung dieser Infrastruktur in der Anwendung
Nachdem ich eine Bus-Architektur in meinem Backend implementiert habe, habe ich bestimmte Schritte aus dem alten synchronen Team herausgefiltert und separate Handler erstellt, die jede ihre eigene Aufgabe erledigen.
Der Pipeline zum Hinzufügen einer neuen Website zur Datenbank sieht folgendermaßen aus:

Und sofort danach fiel es mir viel leichter, neue Funktionen hinzuzufügen, wie beispielsweise das Abrufen und Parsen von RSS. Da dieser Prozess ebenfalls den ursprünglichen Inhalt benötigt, abonniert der RSS-Extraktionshandler, ähnlich wie der WebsiteIndexHistoryPersistor, die Nachricht „Content/HtmlContent“, verarbeitet sie und leitet die benötigte Nachricht weiter in seiner Pipeline.

Letztendlich entstanden mehrere Daemons, von denen jeder nur Verbindungen zu den erforderlichen Ressourcen hält. Zum Beispiel der Daemon crawlers beinhaltet alle Handler, die einen Zugriff auf das Internet zur Beschaffung von Inhalten benötigen, während der Daemon persister die Verbindung zur Datenbank aufrechterhält.
Jetzt werden anstatt Abfragen aus der Datenbank die benötigten IDs nach der Einfügung durch den Persister einfach über den Bus an alle interessierten Handler weitergegeben.
Quelle: habr.com
