Застосунок, який періодично агрегує дані, має двох різних споживачів: годинник, що має запускати роботу, і читача, який пізніше запитує результат. Поєднання обох в одному HTTP-запиті ускладнює інтерпретацію збоїв. Чи зазнав невдачі запит, чи зовнішній провайдер, чи обчислення взагалі не запускалося? Розділення тригера, обчислення, персистентності та читання робить кожну відповідь видимою.
Ця стаття описує таке розділення стандартними компонентами Symfony — Scheduler, Messenger, HttpClient і Doctrine — на навмисно узагальненому прикладі: щогодинного знімка зовнішніх ринкових даних, чия збережена історія віддається через API. Приклад ілюстративний, а не опис конкретної production-системи. Назви класів обрано для демонстрації відповідальності, а не для копіювання.
Конкретний ефект полягає в тому, що читання збереженої історії не запускає нове обчислення. Запланований обробник записує результати; HTTP-контролер їх читає. Доступність провайдера стає тоді властивістю шляху запису, а не кожного перегляду сторінки.
Спочатку перевірте версійні припущення
Symfony випускає нові мінорні версії двічі на рік, і кожен реліз має визначене вікно підтримки. Перш ніж копіювати приклад — включно з цим — перевірте сторінку релізів Symfony щодо підтримуваної версії та читайте документацію, що їй відповідає. Атрибути, назви транспортів і стандартні значення між релізами змінюються.
Обмеження версії в composer.json — це декларація, а не доказ того, що встановлено чи запущено. Перевіряйте lock-файл і розгорнуті пакети. Так само рекурентна задача, визначена в коді, не виконується сама: хтось має споживати розклад, про що йдеться в наступному розділі.
Порівняйте cron, Scheduler і роботу під час запиту
Системний cron, що викликає консольну команду Symfony, — розумний вибір для однієї короткої фіксованої періодичної задачі. Він не потребує довгоживучого споживача. Його межі операційні: розклад живе поза dependency injection застосунку, і кожен деплой має тримати зовнішній crontab узгодженим із релізом.
Symfony Scheduler визначає рекурентні повідомлення в коді застосунку, тож розклад версіонується поруч із обробником і його залежностями. Компроміс у тому, що має працювати споживач Messenger, перезапускатися після деплоїв і моніторитися. Визначення розкладу породжує повідомлення; воно не створює воркера. У контейнерному розгортанні споживач — зазвичай окремий процес чи репліка з власною політикою перезапуску, що є ще однією річчю для правильного налаштування порівняно із записом у crontab.
Обчислення під час HTTP-запиту — найпростіший тригер, але воно пов'язує час відповіді та успіх із зовнішнім провайдером. Воно може пасувати для явного оновлення на вимогу з чітким таймаутом. Це погане стандартне рішення для ендпоінта історії, чиє завдання — повертати вже існуючі записи.
Прослідкуйте заплановане повідомлення
У узагальненому прикладі провайдер оголошує щогодинне повідомлення. Це мінімальний, але повний патерн із документації Scheduler:
#[AsSchedule('default')]
final class SnapshotScheduleProvider implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::cron('0 * * * *', new StoreSnapshot()),
);
}
}
Cron-вираз видає StoreSnapshot на початку кожної години в транспорт scheduler_default. Опції на кшталт stateful() і processOnlyLastMissedRun() керують відновленням після простою споживача: вони вирішують, чи відтворюються пропущені запуски, а не чи можна відновити втрачені дані. Знімок, зроблений пізніше, усе одно використовує дані, доступні на момент обробки.
Обробник реєструється через #[AsMessageHandler] і делегує роботу прикладному сервісу. Делегування важливе: обробник має координувати, а отримання, агрегація й збереження живуть у тестованих співробітниках. Запис у лозі про завершення доводить лише те, що делегований виклик повернувся — саме тому шлях помилки заслуговує окремого розділу.
Зробіть збій видимим
Найважливіше проєктне рішення — що робить обробник, коли зовнішній провайдер зазнає невдачі. Повернення null із записом помилки виглядає обережним, але перетворює збій на звичайне повернене значення. Автоматичний retry Messenger реагує на винятки, а не на значення, тож проковтнута помилка не може бути повторена, а лог завершення може приховати порожній результат.
Виняток робить збій явним. Документація Messenger описує стандартну політику повторів і необов'язковий failure transport, який зберігає невдалі повідомлення для пізнішого перегляду командами messenger:failed:*. Failure transport потребує власного сховища — зазвичай Doctrine transport із таблицею — тож це свідоме рішення щодо розгортання, а не безкоштовна функція.
Повтори обмежені, і ідемпотентність усе одно важлива. Задача знімка, що запускається знову, має бути природно повторюваною — «зберегти поточну годину» ідемпотентно, якщо період є унікальним ключем — або перевіряти перед записом. Для рекурентних повідомлень scheduler наступний тік сам є повтором, що є простішою моделлю відновлення, ніж відтворення черги.
Поважайте точність на межі
Якщо збережене значення — гроші чи виміряна величина, арифметика має відповідати колонці. Колонка decimal(18,8) зберігає точні десяткові значення; множення кількостей і цін як PHP float вносить двійкове округлення ще до того, як значення досягне бази. Подальше перетворення float у рядок не відновлює втрачені цифри.
Практичне виправлення невелике: тримайте кількості й ціни як десяткові рядки, рахуйте розширенням BCMath і округлюйте один раз — на межі персистентності — до масштабу колонки. Серіалізуйте збережений десятковий запис як рядок у відповіді API, щоб вісім дробових цифр пережили JSON. Чи конвертує графік його через Number() — рішення презентації.
Тримайте межу HTTP чесною
З боку читання контролер має лише запитувати збережені рядки. Параметри запиту все одно потребують явної валідації: обмежене ціле для фільтра «останні N годин», дати ISO 8601 для діапазону, перевірка, що початок діапазону передує кінцю, і максимальна кількість результатів, щоб нефільтрований запит не міг витягти всю таблицю. Некоректний ввід має давати помилку клієнта, а не неперехоплений виняток.
Часові мітки заслуговують такої ж уваги. Якщо API додає до відформатованого часу суфікс Z, значення має дійсно бути в UTC — форматування саме не конвертує. Встановіть часовий пояс явно або зберігайте UTC під час запису, потім задокументуйте контракт. Та сама дисципліна стосується автентифікації: визначення маршруту нічого не каже про те, хто може його викликати, тож правила доступу належать конфігурації безпеки та чек-листу рев'ю.
Тестуйте з підставами, а не з мережею
Кожну межу можна тестувати без зовнішніх викликів. MockHttpClient із документації HttpClient повертає заздалегідь записані відповіді, тож тести можуть покривати коректну ціну, деформований payload і недоступність провайдера. Мокнутий entity manager або ізольована схема SQLite перевіряють, що невдале обчислення нічого не зберігає і що десятковий ввід виживає до восьми знаків.
Таке покриття цінніше за наскрізний тест проти живого провайдера, який не є ні детермінованим, ні безпечним для CI. Твердження мають цілитися в контракт: точний збережений рядок, виняток при збої, відхилені параметри запиту.
Практичний висновок і межі
Архітектурний урок — розділити запуск, обчислення, доступ до провайдера, персистентність і читання та зробити збій явним результатом, а не деталлю в лозі. Нічого з цього не вимагає мікросервісів — модульний моноліт із чіткими межами дає ту саму вигоду.
Не впроваджуйте механізм за замовчуванням. Одна коротка задача може лишитися на cron; простий контентний сайт не потребує нічого з цього. А коли результат фінансовий, трактуйте точність, повтори й контроль доступу як вимоги для специфікації, а не як поведінку для припущення. Для допомоги з обмеженою реалізацією дивіться розробку на Symfony.