Aplikacja, która okresowo agreguje dane, ma dwóch różnych konsumentów: zegar, który powinien uruchamiać pracę, i czytelnika, który później pyta o wynik. Połączenie obu w jeden request HTTP utrudnia interpretację awarii. Czy zawiódł request, zewnętrzny provider, czy obliczenie w ogóle nie zostało uruchomione? Rozdzielenie wyzwalacza, obliczenia, persystencji i odczytu czyni każdą odpowiedź widoczną.
Ten artykuł opisuje takie rozdzielenie standardowymi komponentami Symfony — Scheduler, Messenger, HttpClient i Doctrine — na celowo ogólnym przykładzie: godzinnej migawce zewnętrznych danych rynkowych, której zapisana historia jest serwowana przez API. Przykład jest ilustracyjny, a nie opisem konkretnego systemu produkcyjnego. Nazwy klas dobrano tak, by pokazać odpowiedzialność, a nie do kopiowania.
Konkretny efekt polega na tym, że odczyt zapisanej historii nie uruchamia nowego obliczenia. Zaplanowany handler zapisuje wyniki; kontroler HTTP je odczytuje. Dostępność providera staje się wtedy własnością ścieżki zapisu, a nie każdego wyświetlenia strony.
Najpierw sprawdź założenia wersji
Symfony publikuje nowe wersje minor dwa razy w roku, a każde wydanie ma określone okno wsparcia. Zanim skopiujesz przykład — w tym ten — sprawdź stronę wydań Symfony pod kątem utrzymywanej wersji i czytaj dokumentację jej odpowiadającą. Atrybuty, nazwy transportów i wartości domyślne zmieniają się między wydaniami.
Ograniczenie wersji w composer.json to deklaracja, a nie dowód na to, co jest zainstalowane lub uruchomione. Zweryfikuj lock file i wdrożone pakiety. Podobnie zadanie cykliczne zdefiniowane w kodzie nie wykonuje się samo: ktoś musi konsumować harmonogram, o czym mowa w następnej sekcji.
Porównaj cron, Scheduler i pracę w trakcie requestu
Systemowy cron wywołujący komendę konsoli Symfony to rozsądny wybór dla jednego krótkiego, stałego zadania okresowego. Nie wymaga długo działającego konsumenta. Jego granice są operacyjne: harmonogram żyje poza dependency injection aplikacji, a każdy deployment musi utrzymywać zewnętrzny crontab zgodny z wydaniem.
Symfony Scheduler definiuje powtarzalne wiadomości w kodzie aplikacji, więc harmonogram jest wersjonowany obok handlera i jego zależności. Kompromis polega na tym, że konsument Messengera musi działać, restartować się po deployach i być monitorowany. Zdefiniowanie harmonogramu produkuje wiadomości; nie tworzy workera. W wdrożeniu kontenerowym konsument to zwykle osobny proces lub replika z własną polityką restartów — o jedną rzecz więcej do poprawnego skonfigurowania niż wpis w crontab.
Obliczanie w trakcie requestu HTTP to najprostszy wyzwalacz, ale wiąże czas odpowiedzi i powodzenie z zewnętrznym providerem. Może pasować do jawnego odświeżania na żądanie z jasnym timeoutem. To kiepskie domyślne rozwiązanie dla endpointu historii, którego zadaniem jest zwracanie już istniejących rekordów.
Śledź zaplanowaną wiadomość
W ogólnym przykładzie provider deklaruje godzinną wiadomość. To minimalny, ale kompletny wzorzec z dokumentacji Scheduler:
#[AsSchedule('default')]
final class SnapshotScheduleProvider implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::cron('0 * * * *', new StoreSnapshot()),
);
}
}
Wyrażenie cron emituje StoreSnapshot na początku każdej godziny do transportu scheduler_default. Opcje takie jak stateful() i processOnlyLastMissedRun() sterują odtwarzaniem po przestoju konsumenta: decydują, czy pominięte uruchomienia są odtwarzane, a nie czy utracone dane da się zrekonstruować. Migawka wykonana później nadal używa danych dostępnych w chwili przetwarzania.
Handler jest rejestrowany przez #[AsMessageHandler] i deleguje do serwisu aplikacyjnego. Delegacja ma znaczenie: handler powinien koordynować, a pobieranie, agregacja i zapis żyją w testowalnych współpracownikach. Wpis w logu o zakończeniu dowodzi jedynie, że delegowane wywołanie powróciło — dlatego ścieżka błędu zasługuje na osobną sekcję.
Uczyń awarię widoczną
Najistotniejsza decyzja projektowa to to, co handler robi, gdy zewnętrzny provider zawodzi. Zwrócenie null z zalogowaniem błędu wygląda ostrożnie, ale zamienia awarię w zwykłą wartość zwracaną. Automatyczny retry Messengera reaguje na wyjątki, a nie na wartości, więc połknięty błąd nie może być ponowiony, a log zakończenia może ukryć pusty wynik.
Rzucenie wyjątku czyni awarię jawną. Dokumentacja Messengera opisuje domyślną politykę retry i opcjonalny failure transport, który zapisuje nieudane wiadomości do późniejszego przeglądu komendami messenger:failed:*. Failure transport wymaga własnego magazynu — zwykle transportu Doctrine z tabelą — więc to świadoma decyzja wdrożeniowa, a nie darmowa funkcja.
Retry są ograniczone, a idempotencja nadal ma znaczenie. Ponownie uruchomione zadanie migawki powinno być naturalnie powtarzalne — „zapisz bieżącą godzinę" jest idempotentne, gdy okres stanowi klucz unikalny — albo sprawdzać przed zapisem. Dla powtarzalnych wiadomości schedulera kolejny tick sam jest retry, co jest prostszym modelem odtwarzania niż odtwarzanie kolejki.
Szanuj precyzję na granicy
Jeśli zapisywana wartość to pieniądze lub wielkość mierzona, arytmetyka musi odpowiadać kolumnie. Kolumna decimal(18,8) przechowuje dokładne wartości dziesiętne; mnożenie ilości i cen jako PHP float wprowadza binarne zaokrąglenia, zanim wartość dotrze do bazy. Późniejsza konwersja float na string nie odzyskuje utraconych cyfr.
Praktyczna poprawka jest niewielka: trzymaj ilości i ceny jako stringi dziesiętne, licz rozszerzeniem BCMath i zaokrąglaj raz — na granicy persystencji — do skali kolumny. Serializuj zapisany decimal jako string w odpowiedzi API, aby osiem cyfr ułamkowych przetrwało JSON. To, czy wykres konwertuje go przez Number(), jest decyzją prezentacji.
Utrzymuj granicę HTTP uczciwą
Po stronie odczytu kontroler powinien jedynie odpytywać zapisane wiersze. Parametry query nadal wymagają jawnej walidacji: ograniczona liczba całkowita dla filtra „ostatnie N godzin", daty ISO 8601 dla zakresu, sprawdzenie, że początek zakresu poprzedza koniec, oraz maksymalna liczba wyników, aby request bez filtrów nie mógł przesłać całej tabeli. Nieprawidłowe dane powinny dawać błąd klienta, a nie nieprzechwycony wyjątek.
Timestampy zasługują na taką samą uwagę. Jeśli API dodaje do sformatowanego czasu sufiks Z, wartość musi faktycznie być w UTC — samo formatowanie jej nie konwertuje. Ustaw strefę czasową jawnie albo zapisuj UTC przy zapisie, a potem udokumentuj kontrakt. Ta sama dyscyplina dotyczy uwierzytelniania: definicja route nic nie mówi o tym, kto może ją wywołać, więc reguły dostępu należą do konfiguracji bezpieczeństwa i checklisty review.
Testuj z fake'ami, nie z siecią
Każdą granicę można testować bez wywołań zewnętrznych. MockHttpClient z dokumentacji HttpClient zwraca z góry zapisane odpowiedzi, więc testy mogą pokrywać poprawną cenę, zniekształcony payload i niedostępność providera. Zamockowany entity manager albo izolowany schemat SQLite weryfikują, że nieudane wycenienie niczego nie zapisuje, a wejście dziesiętne przetrwa do ośmiu miejsc.
Takie pokrycie jest cenniejsze niż test end-to-end na żywym providerze, który nie jest ani deterministyczny, ani bezpieczny jako zależność w CI. Asercje powinny celować w kontrakt: dokładny zapisany string, wyjątek przy awarii, odrzucone parametry query.
Praktyczny wniosek i granice
Lekcja architektoniczna to rozdzielenie wyzwalania, obliczania, dostępu do providera, persystencji i odczytu oraz uczynienie awarii jawnym wynikiem, a nie detalem w logu. Nic z tego nie wymaga mikroserwisów — modularny monolit z jasnymi granicami daje tę samą korzyść.
Nie wdrażaj tej machinerii domyślnie. Jedno krótkie zadanie może zostać na cronie; prosta strona z treścią nie potrzebuje nic z tego. A gdy wynik jest finansowy, traktuj precyzję, retry i kontrolę dostępu jako wymagania do wyspecyfikowania, a nie zachowanie do założenia. Po pomoc w ograniczonej implementacji zobacz development w Symfony.