<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ПОВЕДІНКОВИЙ ПАТЕРН

КомандаCommand

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

Laravel Symfony складність 1 з 3 6 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Публікація, яку можна викликати лише з контролера

Роботодавець публікує вакансію кнопкою на сайті. Потім знадобилась публікація за розкладом о 9:00, масова публікація з адмінки й журнал, хто що опублікував. Уся логіка живе в методі контролера, тож планувальник і адмінка або копіюють її, або роблять HTTP-запит самі до себе.

app/Http/Controllers/VacancyController.php
1final class VacancyController
2{
3 public function publish(Request $request, Vacancy $vacancy): RedirectResponse
1Дію неможливо викликати без HTTP-запиту: дані приходять з Request, автор з сесії.
4 {
5 $this->authorize('publish', $vacancy);
6
7 $vacancy->update(['status' => 'published', 'published_at' => now()]);
8 $this->telegram->post($vacancy);
2Пост у Telegram іноді відповідає по 5 секунд, і роботодавець чекає разом з ним.
9 $this->search->index($vacancy);
10
11 Log::info('vacancy.published', ['id' => $vacancy->id, 'by' => $request->user()->id]);
3Журнал пишуть лише тут. Публікації за розкладом у ньому немає.
12
13 return back()->with('status', 'Вакансію опубліковано');
14 }
15}
02 · ІДЕЯ РІШЕННЯ

Дія як обʼєкт, виконання окремо

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
newnewdispatch()handle()пізнішеControllerScheduler:PublishVacancyCommandBusdispatch(command)PublishVacancyHandlerhandle(PublishVacancy)Черга
ControllerScheduler:PublishVacancyCommandBusdispatch(command)PublishVacancyHandlerhandle(PublishVacancy)Черга
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Controller створює PublishVacancy.
  • Scheduler створює PublishVacancy.
  • Дані йдуть від PublishVacancy до CommandBus.
  • CommandBus використовує PublishVacancyHandler (handle()).
  • CommandBus використовує Черга (пізніше).
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class VacancyController
2{
3 public function publish(Request $request, Vacancy $vacancy): RedirectResponse
4 {
5 $this->authorize('publish', $vacancy);
6
7 $vacancy->update(['status' => 'published', 'published_at' => now()]);
8 $this->telegram->post($vacancy);
9 $this->search->index($vacancy);
10
11 Log::info('vacancy.published', ['id' => $vacancy->id, 'by' => $request->user()->id]);
12
13 return back()->with('status', 'Вакансію опубліковано');
14 }
15}
1<?php
2
31final readonly class PublishVacancy
4{
5 public function __construct(public int $vacancyId, public string $by) {}
6}
7
82final class PublishVacancyHandler
9{
10 /** @param array<int, string> $vacancies */
11 public function __construct(private array $vacancies) {}
12
13 public function __invoke(PublishVacancy $command): void
14 {
15 echo " опубліковано «{$this->vacancies[$command->vacancyId]}», пост у Telegram\n";
16 }
17}
18
19final class CommandBus
20{
21 /** @var list<object> */
22 private array $queue = [];
23
24 /** @var list<string> */
25 public array $log = [];
26
27 /** @param array<class-string, callable> $handlers */
28 public function __construct(private array $handlers) {}
29
303 public function dispatch(object $command): void
31 {
32 $this->log[] = $command::class . ' від ' . $command->by;
33 ($this->handlers[$command::class])($command);
34 }
35
364 public function later(object $command): void
37 {
38 $this->queue[] = $command;
39 echo ' у черзі: ', count($this->queue), "\n";
40 }
41
42 public function work(): void
43 {
44 while ($command = array_shift($this->queue)) {
45 $this->dispatch($command);
46 }
47 }
48}
49
50$bus = new CommandBus([
51 PublishVacancy::class => new PublishVacancyHandler([7 => 'Senior Laravel Developer', 8 => 'PHP Team Lead']),
52]);
53
54echo "Кнопка на сайті:\n";
55$bus->dispatch(new PublishVacancy(7, by: 'olena@company.ua'));
56
57echo "Публікація за розкладом на 9:00:\n";
58$bus->later(new PublishVacancy(8, by: 'scheduler'));
59echo "О 9:00 воркер бере чергу:\n";
60$bus->work();
61
62echo "Журнал:\n ", implode("\n ", $bus->log), "\n";
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–6

    Команда несе лише дані: id вакансії й автора. Її легко серіалізувати й покласти в чергу.

  2. 2 рядки 8–17

    Обробник знає, як публікувати, і нічого не знає про HTTP, сесію чи розклад.

  3. 3 рядки 30–34

    Усі команди проходять через dispatch(), тому журнал пишеться в одному місці для будь-якого джерела.

  4. 4 рядки 36–47

    Команду можна не виконувати одразу: покласти в чергу, а воркер виконає її пізніше тим самим dispatch().

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Jobs і черги

Job це команда й обробник в одному класі: дані приходять у конструктор, дія живе в handle(), а залежності handle() контейнер підставляє сам. З ShouldQueue Laravel серіалізує job у чергу, і воркер виконує його окремо від запиту.

final class PublishVacancy implements ShouldQueue
{
    use Queueable;
 
    public function __construct(public int $vacancyId) {}
 
    public function handle(VacancyPublisher $publisher): void
    {
        $publisher->publish($this->vacancyId);
    }
}
 
PublishVacancy::dispatch($vacancy->id)->delay(now()->addHour());

Ви вже користуєтесь цим, коли запускаєте php artisan queue:work, і він виконує jobs, які поставили в чергу dispatch().

SYMFONY

Messenger

У Messenger команда й обробник розділені, як у класичному патерні. Повідомлення це простий клас з даними, обробник позначають #[AsMessageHandler], а Messenger звʼязує його з повідомленням за типом аргументу __invoke(). Код викликає лише MessageBusInterface::dispatch().

final readonly class PublishVacancy
{
    public function __construct(public int $vacancyId) {}
}
 
#[AsMessageHandler]
final class PublishVacancyHandler
{
    public function __invoke(PublishVacancy $command): void { /* ... */ }
}
 
$this->bus->dispatch(new PublishVacancy($vacancy->getId()));

Ви вже користуєтесь цим, коли вказуєте в messenger.yaml routing на транспорт async, і повідомлення йде в чергу замість виконання одразу.

✓ ЯК ЦЕ ТЕСТУВАТИ

Контролер перевіряйте так: чи поставив він потрібну команду з правильними даними. У Laravel для цього є Queue::fake(). Обробник тестуйте окремо, передаючи йому команду напряму.

it('queues the publication', function () {
    Queue::fake();
 
    $this->actingAs(employer())->post('/vacancies/7/publish');
 
    Queue::assertPushed(PublishVacancy::class, fn ($job) => $job->vacancyId === 7);
});
05 · КОЛИ НЕ ТРЕБА

Проста дія в одному місці

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

// перейменувати резюме: виклик методу простіший за команду
$resume->rename($request->validated('title'));
$resumes->save($resume);
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Обʼєкт можна передати комусь іншому: у шину, у чергу, у журнал. Метод виконується лише тут і зараз, а команду можна відкласти, повторити після збою чи запустити з консолі.

Команду серіалізують у чергу, і до виконання модель може змінитися. З id обробник дістане свіжі дані. Laravel із трейтом SerializesModels робить те саме сам: зберігає ключ моделі й дістає її заново перед handle().

Кожна команда отримує пару: PublishVacancy і UnpublishVacancy, або метод undo(), який знає, як повернути попередній стан. Виконані команди складають у журнал, і скасування бере останню. Для дій назовні, як пост у Telegram, скасуванням буде окрема дія: видалити пост.