Następca CRON’a już jest!

Mowa w dzisiejszym wpisie o Symfony Scheduler. Jakże długo czekałem w świecie PHP na takie narzędzie, jakże długo zazdrościłem programistom Javy używającym w Java EE adnotacji @Schedule(dayOfWeek="Sun", hour="0"), dzięki której ich funkcja uruchamiała się w odpowiednim czasie. Tymczasem my musieliśmy kombinować z konfiguracją i całym tym boilerplate code w CRON-ie, który nigdy nie chciał współpracować po dobroci i ładnie integrować się z naszą aplikacją. Na szczęście to już przeszłość, ponieważ od Symfony 6.4 mamy do dyspozycji Symfony Scheduler. A co to takiego? To analogiczny do Javy planista, który również obsługuje adnotacje (atrybuty), dzięki czemu nasza klasa (handler) może być uruchamiana w sposób podobny do wykonywania jej przez unixowy CRON.

Jak zaimplementować to we własnym projekcie? Po pierwsze, musimy dodać tę bibliotekę:

composer require symfony/messenger symfony/scheduler

Widać tutaj, że potrzebujemy również Symfony Messenger, a to dlatego, że Scheduler jest właściwie nakładką na Messenger, która dostarcza własny interfejs. Zadania są w rzeczywistości wykonywane co sekundę przez worker Messenger, ale to planista decyduje, czy konkretne zadanie ma się rzeczywiście rozpocząć. Dlatego po instalacji należy skonfigurować uruchamianie workera, wydając polecenie:

bin/console messenger:consume -v scheduler_default

(default to nazwa domyślnej grupy zadań). Potrzebujemy jeszcze wiadomości, handlera i samej klasy definiującej częstotliwość uruchamiania się danej wiadomości. Może to wyglądać w ten sposób:

#[AsSchedule(name: 'default')]
class MySchedulerProvider implements ScheduleProviderInterface
{
        public function getSchedule(): Schedule
        {
        return (new Schedule())->add(
                RecurringMessage::every('3 days', new MyMessage())
                );
        }
}

Zatem nasza wiadomość MyMessage będzie wysyłana do swojego handlera co trzy dni. Można oczywiście nadawać wiele interwałów, a także korzystać z różnych kombinacji w określaniu odpowiedniego czasu (wspierana jest również składnia CRON-a). Polecam zapoznanie się z dokumentacją. Co jednak w przypadku, gdy dane zadanie musi zostać wykonane dwa lub więcej razy w tym samym momencie (bo wynika to z harmonogramu)? Jeśli mamy tylko jednego workera, sprawa jest prosta – zadania są uruchamiane jedno po drugim. W innym przypadku trzeba sięgnąć po mechanizm cache i Symfony Lock. Przykładowo:

(…)
public function getSchedule(): Schedule
{
        return (new Schedule())
                ->add(RecurringMessage::every('3 days', new MyMessage()))
                ->stateful($this->cache)
                ->lock($this->lockFactory->createLock('scheduler-default'));
}

Wcześniej oczywiście należy wstrzyknąć właściwości cache i lockFactory. Dzięki temu będziemy mieć pewność, że dane zadanie będzie wykonywane jedno po drugim.

Co z zadaniami, które mogą trwać naprawdę długo? Dobrą praktyką jest trzymanie się zasady, by zadania były krótkie w realizacji. Jeśli jednak jest to niemożliwe, można skorzystać z opcji przeniesienia (redispatch) zadania do kolejki asynchronicznej:

(new Schedule())->add(
        RecurringMessage::cron('15 4 */3 * *', new RedispatchMessage(new MyMessage(), 'async')))
);

Na koniec warto wspomnieć o jeszcze dwóch przydatnych opcjach: atrybucie AsPeriodicTask i poleceniu debug:scheduler. Atrybut AsPeriodicTask pozwala nam użyć polecenia lub dowolnej innej klasy jako zadania wykonywanego w zdefiniowanym przez ten atrybut interwale. Natomiast polecenie bin/console debug:scheduler dostarcza cennych wskazówek do debugowania, takich jak informacje o tym, kiedy i jakie zadania mają się wykonać. …Tak więc teraz pozostaje już tylko zdefiniować datę publikacji tego wpisu!

Wracam do blogowania!

Jak niektórzy mogli zauważyć, przez pewien czas blog był niedostępny, a to za sprawą problemów technicznych. Coś padło w głównej serwerowni u mojego dostawcy hostingu, następnie problem eskalował na konfigurację mojego serwera. W wyniku podjętej przeze mnie akcji ratunkowej, pomimo że operacja się udała – pacjent nie przeżył. Ta sytuacja zachęciła mnie do długo już planowanej, a przez brak czasu ciągle odkładanej decyzji o migracji bazy bloga do MongoDB. Dzisiejszy wpis będzie właśnie o tych moich potyczkach, zakończonych, jak widać, sukcesem.

MongoDB to dokumentowa baza danych, która zdobyła ogromną popularność. Tego typu bazy świetnie nadają się na platformy blogowe. Jednym z głównych powodów, dla których zdecydowałem się na migrację, jest elastyczność, jaką oferują dokumenty w kontekście zarządzania danymi. Tradycyjne relacyjne bazy danych, jak MySQL czy PostgreSQL, są oparte na sztywnych strukturach tabel, a bazy dokumentowe nie muszą posiadać schematu. Kolejną zaletą MongoDB jest jej skalowalność. W przypadku wzrostu ruchu na blogu baza może być łatwo skalowana horyzontalnie przez dodanie kolejnych węzłów, co sprawia, że jest idealna do obsługi aplikacji, które mogą szybko rosnąć. MongoDB jest również ceniona za wysoką wydajność w operacjach odczytu i zapisu. Dodatkowo, MongoDB posiada świetne wsparcie społeczności i dokumentację, dlatego też praca z nią na co dzień jest całkiem przyjemna.

Migracja bazy danych to dosyć skomplikowany proces. Na początek przeanalizowałem strukturę obecnej bazy i dostosowałem schemat danych do modelu dokumentowego. W przypadku MongoDB dane są przechowywane w kolekcjach jako dokumenty, więc musiałem przemyśleć, jak najlepiej zorganizować te dane, aby zachować logikę i spójność aplikacji. Tutaj należy kierować się ideą jednego pliku JSON, tzn. dane tak powinny być ułożone, aby przy użyciu jednego żądania otrzymać komplet danych. Nie obyło się bez zmian w samej aplikacji. Musiałem porzucić wykorzystywany system mapowania obiektowo-relacyjnego (Doctrine ORM) na rzecz systemu mapowania obiektowo-dokumentowego (Doctrine ODM). Nie była to zmiana czysto kosmetyczna – zmiana w nazwach adnotacji, lecz poznanie nowych sposobów reprezentacji danych. Przykładowo, adnotacja @EmbedOne umożliwia zagnieżdżenie jednego dokumentu w innym dokumencie. To też typowa właściwość dla tego paradygmatu baz NoSQL – pozwalamy na redundancję danych. Dlatego pojawienie się ponownie owego zagnieżdżonego dokumentu nie musi oznaczać błędu projektowego.

W trakcie importowania danych do MongoDB napotkałem na kilka problemów związanych z kompatybilnością niektórych typów danych czy związkami wiele do wielu. Choć posłużyłem się dostarczonymi z MongoDB narzędziami takimi jak mongoimport i mongodump, to samo przemapowanie danych relacyjnych do dokumentu musiałem zrobić własnoręcznie. W tym celu napisałem autorskie narzędzie eksportujące dane relacyjne i przy użyciu odpowiednich reguł mapowania związków relacyjnych czy typów danych uzyskałem format Binary JSON. Wprowadziłem także kilka usprawnień w kodzie aplikacji, aby w pełni wykorzystać możliwości MongoDB, takie jak indeksowanie i agregacje.

Monitorowanie wydajności serwera pokazało mniejsze zużycie zasobów w porównaniu do poprzedniego rozwiązania relacyjnego, ale może to być też wynikiem tego, że sporą część logiki z aplikacji usunąłem. Jest mniej używanych zapytań, a te, które są potrzebne, są tak skonstruowane, aby nie dociągać dodatkowych danych. Jak pisałem wcześniej, nie przejmuję się redundancją czy wynikającą z niej anomalią modyfikacji. Kluczowe jest, aby jak najszybciej wygenerowała się strona startowa czy strona reprezentująca konkretny wpis!

Migracja do MongoDB to dopiero początek. Teraz, gdy mam stabilną i wydajną bazę danych, mogę skupić się na wprowadzaniu nowych funkcji i ulepszeń na blogu. Planuję zintegrować bardziej zaawansowane funkcje wyszukiwania, wykorzystując możliwości agregacji MongoDB. Chcę także wdrożyć kolejne węzły replikacji, tak by w przypadku awarii jakiegoś serwera strona dalej była dostępna. Postaram się jeszcze wrócić do tego tematu na blogu. Tymczasem mam nadzieję, że nowa infrastruktura okaże się strzałem w dziesiątkę!

Server Sent Events (SSE) jako konkurencja do technologii WebSocket

Długo zbierałem się omówieniem tego tematu, głównie z powodu wciąż trwających przeze mnie testów technologi SSE. Pomimo, że w dalszym ciągu nie mam w pełni wyrobionego zdania to postanowiłem coś napisać i spróbować porównać obie technologie wymiany informacji między klientem a serwerem. Temat jest obszerny, mamy wiele sposobów na wykorzystanie tych rozwiązań, podobnie jak narzędzi, bibliotek czy już nawet frameworków, nie mówiąc o różnych edge casach im towarzyszących. Dlatego też tym wpisem chciałbym jedynie nakreślić ogólną problematykę i przedstawić mój przykład implementacji SSE w miejsce dotychczas używania WebSocket, zapraszam do lektury.

Co skłoniło mnie do porzucenia websocketów? Odpowiedź jest prosta, ich problematyczna natura. Niestabilność połączenia, wiele różnych sposobów na tak zwany „handshake”, niekompatybilność bibliotek czy samych przeglądarek. Momentami myślałem, że strzelam z armaty do muchy, zatem pomysł by uprościć komunikacje,uczynić ją jednokierunkową i wykorzystać ustrukturyzowane API EventSource wydaje się całkiem obiecujący.

Moje testy przeprowadziłem wspomagając się otwarto źródłowym oprogramowaniem Mercure. Stanowi on gotowe rozwiązanie do obsługi komunikatów, które serwer może wysłać do przeglądarki. Serwer łączy się zatem z Mercure Hub’em - aplikacją, która kolejkuje komunikaty od serwera i wysyła je do odpowiednich subskrybentów (klientów) danego tematu. Tworząc komunikat - wiadomość, określamy z jakim tematem jest ona związana. Co istotne, Mercure Hub potrafi buforować wiadomości i odesłać je do subskrybenta w kolejności nadania nawet jeśli klient połączy się po jakimś dłuższym czasie od pierwotnego czasu nadania wiadomości. Cała komunikacja może być szyfrowana i wymagać uwierzytelnienia. Możemy też określić jacy subskrybenci i nadawcy mają dostęp do danych tematów. Ciekawe, że Mercure dostarcza standard do implementacji hub’a, zatem pojawiły się implementacje nawet na procesory wykorzystane w Raspberry czy Qnap. By było jeszcze bardziej słodko Symfony posiada bundle integrujący się z naszym hubem. Ułatwia to nam publikowanie wiadomości i ich ewentualne debugowanie, a wszystko to przy minimalnej wymaganej konfiguracji. Większe wyzwanie stanowi część kliencka, jeśli planujemy uwierzytelnianie to musimy zrezygnować z wbudowanego w przeglądarki api Event Source i wykorzystać EventSourcePolyfill (co z kolei pozbawi nas wygodnego wbudowanego w przeglądarce debuggera komunikatów). Pamiętajmy, że technologia SSE jest komunikacją w jedną stronę, zatem po odebraniu wiadomości z serwera, gdy zajdzie konieczność otrzymania dodatkowych danych to musimy wykonać do serwera reguest XHR.

Projektem dla którego zdecydowałem się podmienić WebSockety na SSE stanowi prosta aplikacja do kasowania duplikatów zdjęć, w której pomocne jest monitorować zadania skanowania danych katalogów. Owe zadania są wykonywane równolegle przez workery, więc śledzenie ich pracy, aby miało jakiś sens, musi odbywać się w czasie rzeczywistym. Komunikat jaki serwer przesyła to informacja, jakie zadanie zmieniło swój status i w jakim procencie jest ono gotowe. Klient nie musi wysyłać do serwera dodatkowych żądań, oprócz wysłania zadania do kolejki czy gotowość na nasłuchiwanie tych statusów. W tej roli SSE wypada znakomicie. Co może stanowić problem? W moim przypadku ograniczenia w rozmiarze wiadomości, choć można przesłać cały obiekt zserializowany do formatu JSON to należy mieć na uwadzę parametry pracy danego Hub serwera. Warte zastanowienia jest czy wiadomość wysłana przez SSE powinna się ograniczać jedynie do typów prostych? Ja postanowiłem wysyłać jedynie identyfikatory zmienionych zadań, następnie klient wykonuje żądanie o dodatkowe informacje do serwera wówczas.

Jakie wady można zaobserwować po wdrożeniu SSE? Ograniczenia w formacie przesyłanych danych (tylko UTF-8), w liczbie dozwolonych równoległych połączeń (zależy od przeglądarki) czy jednokierunkowość komunikacji, ale jak dla mnie to ostatnie jest zaletą. Nie mniej w roli wysyłania powiadomień do klienta, to rozwiązanie sprawdza się znakomicie.

Wykrywanie duplikatów plików

Zabrałem się za porządki w swoich albumach zdjęciowych i szybko zauważyłem, że masa zdjęć się niepotrzebnie powtarza. By wykryć duplikaty potrzebowałem narzędzia, które przeskanuje dziesiątki gigabajtów plików. Niestety nie znalazłem żadnej takiej aplikacji, która to właściwie zrobi na moim serwerze plików QNAP. Dlatego postanowiłem napisać takie narzędzie, przy użyciu PHP i Symfony. Dodatkowo by trochę utrudnić sobie to zadanie użyłem mechanizmu kolejek, by skanowanie duplikatów mogło odbywać się równolegle przez kilka workerów, czyli wykorzystałem do tego Symfony Messenger.

Aplikacja składa się z trzech elementów: komendy pobierającej podstawową strukturę katalogów w celu odpowiedniego zakolejkowania zadań, obsługi skanowania i części analizującej zebrane wyniki. Zakolejkowanie zadań polega na połączeniu się z serwerem plików poprzez protokół smb (Windows / Samba) do wskazanego zasobu udostępniającego zdjęcia. Taki zasób składa się z kilku folderów / albumów, dla których zostanie stworzone osobne zadanie skanowania. Zadania skanowania zostają umieszczone na kolejce przez mechanizm wiadomości (Symfony Messenger) i w bazie danych, tutaj sqlite tak by wygodnie móc śledzić postęp analizy.

Skanowanie jest prostym zadaniem realizowanym przez worker, który odbiera wiadomość z adresem katalogu do analizy. Polega to na pobraniu wszystkich plików graficznych (mających odpowiednie rozszerzenie), w tym także składowanych w podkatalogach, a następnie wygenerowanie odpowiedniego hasha, przy użyciu algorytmu md5. Hash taki zostaje zapamiętany w bazie danych i stanowi rezultat skanowania.

Ostatnia część aplikacji, czyli analiza wyników sprowadza się do pobrania z bazy danych tych hashy, które występują częściej niż raz, a więc są duplikatami zdjęć (prosta komenda GROUP BY i HAVING). Komendę taką najlepiej wykonać gdy wszystkie zadania zostaną zakończone. Natomiast kasowania duplikatów pozostawiam już użytkownikowi.

Zachęcam do wypróbowania i podzielenia się opinią https://github.com/domino91/dduplicate

Subsrybowanie usług w Symfony

Jest wiele sposobów rejestrowania usług i dostępu do nich w Symfony Framework. W dzisiejszym wpisie przedstawię mój ulubiony oparty o tagi, ale przy wykorzystaniu dobrodziejstw z PHP 8 czyli atrybutów. Zanim jednak o TaggedLocator, który robi taką magię słów kilka o ServiceSubscriber.

ServiceSubscriber w Symfony służy do wstrzykiwania zależności w obiekty, które nie są usługami. Oznacza to, że ServiceSubscriber pozwala na wstrzyknięcie usług do obiektów, które same w sobie nie są usługami, ale wymagają dostępu do innych usług w systemie.

Aby skorzystać z ServiceSubscribera, klasa musi zaimplementować interfejs ServiceSubscriberInterface i zdefiniować metodę "getSubscribedServices", która zwraca tablicę z nazwami usług wymaganych przez klasę.

Przykładowo, jeśli klasa potrzebuje dostępu do obiektu EntityManager, można wstrzyknąć tę usługę, korzystając z metody get() z obiektu Kontenera Serwisów Symfony:

use Symfony\Contracts\Service\{ServiceSubscriberInterface, ServiceLocatorTrait};

class MyClass implements ServiceSubscriberInterface
{
    use ServiceLocatorTrait;

    public static function getSubscribedServices()
    {
        return [
            'doctrine.orm.entity_manager' => EntityManagerInterface::class,
        ];
    }

    public function myMethod()
    {
        $entityManager = $this->get('doctrine.orm.entity_manager');
        // ...
    }
}

Dzięki temu wstrzykiwaniu zależności za pomocą ServiceSubscribera klasa MyClass może skorzystać z usługi EntityManager bez potrzeby wstrzykiwania jej w konstruktorze lub metodzie setEntityManager().

Jeszcze inną motywacją w używaniu ServiceSubscribera jest sytuacja, gdy chcemy zainicjalizować tylko niektóre serwisy, bo wiemy że tylko one będą nam potrzebne. Takie zarządzanie może odbywać się więc "w locie", przez nasz serwis pełniący rolę w tym przypadku takiego menadżera / planisty / proxy.

Przejdźmy do TaggedLocator, bo to kolejny sposób na wstrzykiwanie zależności w Symfony, który jest podobny do ServiceSubscribera, ale zapewnia większą elastyczność i dostępność dla usług. W odróżnieniu od ServiceSubscribera, TaggedLocator pozwala na dynamiczne wstrzykiwanie wielu usług o określonym tagu, który jest definiowany w plikach konfiguracyjnych aplikacji. Dzięki temu TaggedLocator umożliwia wstrzykiwanie różnych usług w zależności od potrzeb i konfiguracji, co jest szczególnie przydatne w przypadku aplikacji z dużą liczbą usług.

Przykładowo, można zdefiniować kilka usług implementujących interfejs NotificationInterface, o różnych tagach:

# services.yaml
services:
    app.email_notification:
        class: App\Notification\EmailNotification
        tags: ['app.notification']

    app.sms_notification:
        class: App\Notification\SmsNotification
        tags: ['app.notification']

    app.push_notification:
        class: App\Notification\PushNotification
        tags: ['app.notification']

Następnie, można wstrzyknąć te usługi w innej klasie za pomocą TaggedLocatora:

use Symfony\Component\DependencyInjection\ServiceLocator;

class NotificationManager
{
    private $notificationLocator;

    public function __construct(ServiceLocator $notificationLocator)
    {
        $this->notificationLocator = $notificationLocator;
    }

    public function notify(string $message, string $type)
    {
        $notifications = $this->notificationLocator->getTaggedServices('app.notification');
        foreach ($notifications as $notification) {
            $notification->send($message, $type);
        }
    }
}

Wszystko fajnie, ale jak to uprościć by nie pisać tyle kodu? Tutaj przychodzą właśnie wspomniane atrybuty. Jeśli stworzymy wspólny interfejs dla notyfikacji, nie będziemy musieli ich tagować (tagiem może stać się interfejs). Przykładowo:

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag]
interface NotificationInterface
{
    public function send(string $message, string $type): void;
}

Wstrzyknięcie również możemy ułatwić sobie przez atrybuty. Albo wybieramy konkretny serwis wstrzykując sobie ServiceLocator w konstruktorze

#[TaggedLocator(NotificationInterface::class)]
        private ServiceLocator $notificationLocator,

albo bezpośrednio wstrzykujemy sobie wszystkie usługi:

use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;

class NotificationManager
{
    public function __construct( 
#[TaggedIterator(NotificationInterface::class)] private readonly iterable $notifications)
    {
    }

    public function notify(string $message, string $type)
    {
       foreach ($this->notifications as $notification) {
            $notification->send($message, $type);
        }
    }
}

normalnie czary ;-)...