Передмова
Мій сайт, яким я займаюся як хобі, призначений для зберігання цікавих домашніх сторінок та персональних сайтів. Ця тема стала цікавити мене на самому початку мого шляху в програмуванні, в той момент мене захоплювало знаходження великих професіоналів, які пишуть про себе, свої захоплення та проекти. Звичка відкривати їх для себе залишилася і зараз: майже на кожному комерційному та не дуже сайті я продовжую заглядати у футер у пошуках посилань на авторів.
Реалізація ідеї
Перша версія була просто html-сторінкою на моєму персональному сайті, де складав посилання з підписами в ul-список. Набравши за якийсь час 20 сторінок, я почав думати, що це не дуже ефективно і вирішив спробувати автоматизувати процес. На stackoverflow я помічав, що багато хто вказує сайти у своїх профілях, тому я написав парсер на php, який просто йшов по профілях, починаючи з першого (адреси на SO і до цього дня такого виду: `/users/1`), витягував посилання з потрібного тега і складав SQLite.
Це можна назвати другою версією: колекція з десятка тисяч урлів у SQLite табличці, яка замінила статичний список у html. У цьому списку я зробив простий пошук. Т.к. були тільки урли, то й пошук був просто з них.
На цьому етапі я закинув проект і повернувся до нього через довгий час. На цьому етапі досвід моєї роботи складав уже більше трьох років і я відчував, що можу зробити щось серйозніше. До того ж, було велике бажання освоювати відносно нові для себе технології.
Сучасна версія
проблема
Нові сайти додаються консольною командою, яка синхронно робить таке:
- Завантажує контент з URL
- Виставляє прапор про те, чи був HTTPS доступний
- Зберігає суть веб-сайту
- Вихідний HTML та заголовки зберігає в історію «індексування»
- Парсит контент, витягує Title та Description
- Дані зберігає в окрему колекцію
Цього було достатньо, щоб просто зберігати сайти та відображати їх у списку:
Але ідея все автоматично індексувати, категоризувати і ранжувати, тримаючи все в актуальному стані, у цю парадигму вкладалася слабо. Навіть просте додавання web-методу для додавання сторінок зажадало дублювання коду та блокувань для уникнення потенційного DDoS.
Взагалі, звичайно, все можна робити і синхронно, а в web-методі робити просто збереження УРЛу для того, щоб монструозний демон виконував усі завдання для УРЛів зі списку. Але все одно навіть тут напрошується слово черга. А якщо чергу впровадити, то можна всі завдання розділити та виконувати принаймні асинхронно.
Рішення
Впровадити черги і зробити event-driven систему обробки всіх завдань. І якраз давно хотілося спробувати Redis Streams.
Використання Redis streams у PHP
Т.к. Фреймворк у мене не з трійки гігантів Symfony, Laravel, Yii, то бібліотеку хотілося б знайти незалежну. Але як виявилося (при першому розгляді) — окремих серйозних бібліотек знайти неможливо. Все, що пов'язане з чергами, або є проектиком з трьох комітів п'ятирічної давності, або прив'язано до фреймворку.
Я чув про Symfony як про постачальника окремих корисних компонентів, до того ж деякі я вже використовую. А також від Laravel дещо теж можна використовувати, наприклад, їх ORM, без присутності самого фреймворку.
symfony/messenger
Перший кандидат відразу ж здався ідеальним і без жодних сумнівів я його встановив. Але нагуглити приклади використання поза Symfony виявилося складніше. Як зібрати з купи класів з універсальними назвами, що ні про що не говорять, шину для передачі повідомлень, та ще й на Redis?
Документація на офіційному сайті була досить докладною, але ініціалізація була описана тільки для Symfony за допомогою їхнього улюбленого YML та інших магічних методів для несимфоністів. Інтересу у самому процесі встановлення у мене не було, особливо у новорічні канікули. Але довелося займатись цим і несподівано довго.
Спроба розібратися з інстанціюванням системи за вихідними джерелами Symfony завдання теж не найтривіальніша для стислих термінів:
Поколупавшись у цьому всьому і спробувавши щось зробити руками, я дійшов висновку, що займаюся якимись милицями і вирішив спробувати щось ще.
illuminate/queue
Виявилося, що ця бібліотека намертво прив'язана до інфраструктури Laravel та купи інших залежностей, тому багато часу я на неї не витрачав: поставив, подивився, побачив залежності та видалив.
yiisoft/yii2-queue
Ну тут відразу передбачалося з назви, знову ж таки жорстка прив'язка до Yii2. Цією бібліотекою мені доводилося користуватись і вона була непоганою, але про те, що вона повністю залежить від Yii2 я не думав.
решта
Все інше, що я знаходив на гітхабі — ненадійні застарілі та занедбані проектики без зірок, форків та великої кількості комітів.
Повернення до symfony/messenger, технічні подробиці
Довелося розібратися з цією бібліотекою і, витративши якийсь час, я зміг. Виявилося, що все досить лаконічно та просто. Для інстанціювання шини зробив невелику фабрику, т.к. шин у мене передбачалося кілька і з різними обробниками.
Усього кілька кроків:
- Створюємо обробники повідомлень, які повинні бути просто callable
- Загортаємо їх у HandlerDescriptor (клас з бібліотеки)
- Ці «Дескриптори» загортаємо до інстансу HandlersLocator
- Додаємо HandlersLocator в інстанс MessageBus
- Передаємо в SendersLocator набір SenderInterface, в моєму випадку інстанси класів RedisTransport, які конфігуруються очевидним чином
- Додаємо SendersLocator в інстанс MessageBus
MessageBus має метод `->dispatch()`, який шукає відповідні обробники в HandlersLocator і передає повідомлення їм, користуючись відповідними `SenderInterface` для відправки через шину (Redis streams).
У конфігурації контейнера (в даному випадку php-di) вся ця зв'язка може бути законфігурована так:
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);
},
Тут видно, що в SendersLocator для двох різних повідомлень ми надали різний «транспорт», кожен з яких має свій коннект на відповідні стрими.
Я зробив окремий демо-проект, який демонструє додаток із трьох демонів, які спілкуються між собою за допомогою такої шини:
Але покажу як може бути влаштований консьюмер:
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();
Використання цієї інфраструктури у додатку
Реалізувавши шину у своєму бекенді, я виділив окремі ступені зі старої синхронної команди і зробив окремі хендлери, кожен з яких займається своєю справою.
Пайплайн додавання нового сайту до бази даних вийшов таким:
І відразу після цього мені стало набагато простіше додавати новий функціонал, наприклад, вилучення та парсинг Rss. Т.к. цей процес також вимагає вихідний контент, то хендлер-витягувач посилання на RSS також як і WebsiteIndexHistoryPersistor підписується на повідомлення «Content/HtmlContent», обробляє його і передає потрібне повідомлення по своєму пайплайну далі.
Зрештою вийшло кілька демонів, кожен із яких тримає підключення лише до потрібних ресурсів. Наприклад демон повзунки містить у собі всі обробники, які вимагають походу в інтернет за контентом, а демон зберігаються тримає підключення до бази даних.
Тепер замість селектів із бази даних, потрібні id після вставки persister'ом просто передаються через шину всім зацікавленим обробникам.
Джерело: habr.com