Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

Передмова

Мій сайт, яким я займаюся як хобі, призначений для зберігання цікавих домашніх сторінок та персональних сайтів. Ця тема стала цікавити мене на самому початку мого шляху в програмуванні, в той момент мене захоплювало знаходження великих професіоналів, які пишуть про себе, свої захоплення та проекти. Звичка відкривати їх для себе залишилася і зараз: майже на кожному комерційному та не дуже сайті я продовжую заглядати у футер у пошуках посилань на авторів.

Реалізація ідеї

Перша версія була просто html-сторінкою на моєму персональному сайті, де складав посилання з підписами в ul-список. Набравши за якийсь час 20 сторінок, я почав думати, що це не дуже ефективно і вирішив спробувати автоматизувати процес. На stackoverflow я помічав, що багато хто вказує сайти у своїх профілях, тому я написав парсер на php, який просто йшов по профілях, починаючи з першого (адреси на SO і до цього дня такого виду: `/users/1`), витягував посилання з потрібного тега і складав SQLite.

Це можна назвати другою версією: колекція з десятка тисяч урлів у SQLite табличці, яка замінила статичний список у html. У цьому списку я зробив простий пошук. Т.к. були тільки урли, то й пошук був просто з них.

На цьому етапі я закинув проект і повернувся до нього через довгий час. На цьому етапі досвід моєї роботи складав уже більше трьох років і я відчував, що можу зробити щось серйозніше. До того ж, було велике бажання освоювати відносно нові для себе технології.

Сучасна версія

Проект розгорнутий в докері, база перекладена mongoDb, і з відносно недавніх пір, додана редиска, яка спочатку була просто для кешування. Як основа використовується один з мікрофреймворків PHP.

проблема

Нові сайти додаються консольною командою, яка синхронно робить таке:

  • Завантажує контент з URL
  • Виставляє прапор про те, чи був HTTPS доступний
  • Зберігає суть веб-сайту
  • Вихідний HTML та заголовки зберігає в історію «індексування»
  • Парсит контент, витягує Title та Description
  • Дані зберігає в окрему колекцію

Цього було достатньо, щоб просто зберігати сайти та відображати їх у списку:

Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

Але ідея все автоматично індексувати, категоризувати і ранжувати, тримаючи все в актуальному стані, у цю парадигму вкладалася слабо. Навіть просте додавання web-методу для додавання сторінок зажадало дублювання коду та блокувань для уникнення потенційного DDoS.

Взагалі, звичайно, все можна робити і синхронно, а в web-методі робити просто збереження УРЛу для того, щоб монструозний демон виконував усі завдання для УРЛів зі списку. Але все одно навіть тут напрошується слово черга. А якщо чергу впровадити, то можна всі завдання розділити та виконувати принаймні асинхронно.

Рішення

Впровадити черги і зробити event-driven систему обробки всіх завдань. І якраз давно хотілося спробувати Redis Streams.

Використання Redis streams у PHP

Т.к. Фреймворк у мене не з трійки гігантів Symfony, Laravel, Yii, то бібліотеку хотілося б знайти незалежну. Але як виявилося (при першому розгляді) — окремих серйозних бібліотек знайти неможливо. Все, що пов'язане з чергами, або є проектиком з трьох комітів п'ятирічної давності, або прив'язано до фреймворку.

Я чув про Symfony як про постачальника окремих корисних компонентів, до того ж деякі я вже використовую. А також від Laravel дещо теж можна використовувати, наприклад, їх ORM, без присутності самого фреймворку.

symfony/messenger

Перший кандидат відразу ж здався ідеальним і без жодних сумнівів я його встановив. Але нагуглити приклади використання поза Symfony виявилося складніше. Як зібрати з купи класів з універсальними назвами, що ні про що не говорять, шину для передачі повідомлень, та ще й на Redis?

Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

Документація на офіційному сайті була досить докладною, але ініціалізація була описана тільки для Symfony за допомогою їхнього улюбленого YML та інших магічних методів для несимфоністів. Інтересу у самому процесі встановлення у мене не було, особливо у новорічні канікули. Але довелося займатись цим і несподівано довго.

Спроба розібратися з інстанціюванням системи за вихідними джерелами Symfony завдання теж не найтривіальніша для стислих термінів:

Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

Поколупавшись у цьому всьому і спробувавши щось зробити руками, я дійшов висновку, що займаюся якимись милицями і вирішив спробувати щось ще.

illuminate/queue

Виявилося, що ця бібліотека намертво прив'язана до інфраструктури Laravel та купи інших залежностей, тому багато часу я на неї не витрачав: поставив, подивився, побачив залежності та видалив.

yiisoft/yii2-queue

Ну тут відразу передбачалося з назви, знову ж таки жорстка прив'язка до Yii2. Цією бібліотекою мені доводилося користуватись і вона була непоганою, але про те, що вона повністю залежить від Yii2 я не думав.

решта

Все інше, що я знаходив на гітхабі — ненадійні застарілі та занедбані проектики без зірок, форків та великої кількості комітів.

Повернення до symfony/messenger, технічні подробиці

Довелося розібратися з цією бібліотекою і, витративши якийсь час, я зміг. Виявилося, що все досить лаконічно та просто. Для інстанціювання шини зробив невелику фабрику, т.к. шин у мене передбачалося кілька і з різними обробниками.

Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

Усього кілька кроків:

  • Створюємо обробники повідомлень, які повинні бути просто 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 для двох різних повідомлень ми надали різний «транспорт», кожен з яких має свій коннект на відповідні стрими.

Я зробив окремий демо-проект, який демонструє додаток із трьох демонів, які спілкуються між собою за допомогою такої шини: https://github.com/backend-university/products/tree/master/products/02-redis-streams-bus.

Але покажу як може бути влаштований консьюмер:

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();

Використання цієї інфраструктури у додатку

Реалізувавши шину у своєму бекенді, я виділив окремі ступені зі старої синхронної команди і зробив окремі хендлери, кожен з яких займається своєю справою.

Пайплайн додавання нового сайту до бази даних вийшов таким:

Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

І відразу після цього мені стало набагато простіше додавати новий функціонал, наприклад, вилучення та парсинг Rss. Т.к. цей процес також вимагає вихідний контент, то хендлер-витягувач посилання на RSS також як і WebsiteIndexHistoryPersistor підписується на повідомлення «Content/HtmlContent», обробляє його і передає потрібне повідомлення по своєму пайплайну далі.

Переклад PHP бекенда на шину Redis streams та вибір незалежної від фреймворків бібліотеки

Зрештою вийшло кілька демонів, кожен із яких тримає підключення лише до потрібних ресурсів. Наприклад демон повзунки містить у собі всі обробники, які вимагають походу в інтернет за контентом, а демон зберігаються тримає підключення до бази даних.

Тепер замість селектів із бази даних, потрібні id після вставки persister'ом просто передаються через шину всім зацікавленим обробникам.

Джерело: habr.com

Додати коментар або відгук