Перейти к содержимому
Фёдор Башинский

Команда

7 min read
Обложка статьи «Команда»

Команда (Command) — это поведенческий паттерн проектирования, который превращает запрос в самостоятельный объект с методом execute(), отвязывая отправителя запроса от объекта, который его выполняет, — а заодно позволяет ставить запросы в очередь, логировать их и отменять.

Проблема

Панель управления подъёмником на диагностическом посту работает с самим подъёмником напрямую:

final class Lift
{
    public function raise(): void { /* поднять подъёмник */ }
    public function lower(): void { /* опустить подъёмник */ }
    public function stop(): void { /* остановить движение */ }
}
 
final class LiftControlPanel
{
    public function __construct(
        private readonly Lift $lift,
    ) {}
 
    public function pressButton(string $button): void
    {
        match ($button) {
            'raise' => $this->lift->raise(),
            'lower' => $this->lift->lower(),
            'stop' => $this->lift->stop(),
        };
    }
}

Появляются два новых требования по технике безопасности:

  1. Каждое нажатие кнопки нужно логировать — кто и когда поднимал или опускал подъёмник.
  2. На панели нужна кнопка «Отменить» — если оператор случайно нажал не ту кнопку, последнее действие должно откатываться одним нажатием.

Логирование добавляется без труда — в pressButton() после match можно дописать error_log(). Но с отменой сложнее. Панель должна знать, каким действием отменяется каждое из совершённых: подъём отменяется спуском, а вот чем отменить stop() — вопрос уже не такой очевидный. Эта логика начинает жить внутри LiftControlPanel, хотя панель — это UI-компонент, который должен только реагировать на нажатия, а не разбираться, как отменяется конкретное действие над подъёмником:

public function pressButton(string $button): void
{
    match ($button) {
        'raise' => $this->lift->raise(),
        'lower' => $this->lift->lower(),
        'stop' => $this->lift->stop(),
    };
 
    $this->lastButton = $button;
    error_log(sprintf('[panel] выполнено: %s', $button));
}
 
public function undoLast(): void
{
    match ($this->lastButton) {
        'raise' => $this->lift->lower(),
        'lower' => $this->lift->raise(),
        'stop' => null, // а что тут вообще отменять?
    };
}

Если завтра на посту появится второй управляемый механизм — например, стенд развал-схождения — те же самые логирование, история и отмена придётся заново реализовывать для его собственной панели, потому что вся эта логика жёстко привязана к Lift внутри LiftControlPanel.

Решение

Команда оборачивает каждое действие над получателем (Lift) в отдельный объект с общим интерфейсом. Панель перестаёт вызывать методы Lift напрямую — она работает только с командами:

interface Command
{
    public function execute(): void;
    public function undo(): void;
}

Каждое действие — конкретная команда, которая хранит ссылку на получателя и знает, как себя выполнить и как себя отменить:

final class RaiseLiftCommand implements Command
{
    public function __construct(
        private readonly Lift $lift,
    ) {}
 
    public function execute(): void
    {
        $this->lift->raise();
    }
 
    public function undo(): void
    {
        $this->lift->lower();
    }
}
 
final class LowerLiftCommand implements Command
{
    public function __construct(
        private readonly Lift $lift,
    ) {}
 
    public function execute(): void
    {
        $this->lift->lower();
    }
 
    public function undo(): void
    {
        $this->lift->raise();
    }
}

Панель хранит команды, привязанные к кнопкам, и историю выполненных команд — но ничего не знает про Lift:

final class LiftControlPanel
{
    /** @var array<string, Command> */
    private array $buttons = [];
 
    /** @var Command[] */
    private array $history = [];
 
    public function bind(string $button, Command $command): void
    {
        $this->buttons[$button] = $command;
    }
 
    public function pressButton(string $button): void
    {
        $command = $this->buttons[$button];
        $command->execute();
        $this->history[] = $command;
 
        error_log(sprintf('[panel] выполнено: %s', $button)); // одна точка логирования для любой команды
    }
 
    public function undoLast(): void
    {
        $command = array_pop($this->history);
        $command?->undo(); // панель не знает, что именно откатывается — это знает сама команда
    }
}

Сборка панели происходит на стороне клиента:

$lift = new Lift();
$panel = new LiftControlPanel();
 
$panel->bind('raise', new RaiseLiftCommand($lift));
$panel->bind('lower', new LowerLiftCommand($lift));
 
$panel->pressButton('raise'); // подъём, залогировано в истории
$panel->undoLast();           // откатит последнее действие — вызовет lower()

Логирование и история теперь работают для любой команды одинаково, а логика отмены переехала туда, где ей место, — в саму команду. Появится стенд развал-схождения — для него понадобятся свои конкретные команды, но не новая копия логирования и истории: LiftControlPanel можно использовать для управления чем угодно, что умеет прятаться за интерфейсом Command.

Из чего состоит паттерн

  • Команда (Command) — общий интерфейс с методом execute() (и, если нужна отмена, undo()).
  • Конкретная команда (RaiseLiftCommand, LowerLiftCommand) — хранит ссылку на получателя и параметры вызова, делегирует получателю фактическое выполнение.
  • Получатель (Lift) — объект, который выполняет реальную работу; о существовании команд не знает вообще.
  • Отправитель (LiftControlPanel) — хранит и запускает команды (по кнопке, по расписанию, из очереди), не зная, что конкретно каждая из них делает.
  • Клиент — создаёт конкретные команды, связывает их с получателем и передаёт отправителю.

Очередь и журнал операций — бесплатно

Как только запрос стал объектом, а не вызовом метода, с ним можно делать то, что с обычным вызовом не сделать: положить в массив, отправить по сети, выполнить позже. История в LiftControlPanel выше уже наглядно это показывает — $this->history это просто массив объектов Command, для которого отмена — лишь один из возможных способов использования.

Тем же массивом команд, поставленных в очередь, легко собрать сменное задание для мастера — выполнить несколько операций по порядку, одну за другой, и знать в любой момент, какая из них уже сделана:

final class MaintenanceQueue
{
    /** @var Command[] */
    private array $pending = [];
 
    public function enqueue(Command $command): void
    {
        $this->pending[] = $command;
    }
 
    public function runAll(): void
    {
        while ($command = array_shift($this->pending)) {
            $command->execute();
        }
    }
}

Ни MaintenanceQueue, ни LiftControlPanel не пришлось бы менять при появлении новых видов команд — обе работают с интерфейсом Command, а не с конкретными действиями.

Команда vs Стратегия

Структурно оба паттерна выглядят одинаково — интерфейс с одним методом и семейство классов, каждый со своей реализацией, — и оба используются для того, чтобы подставлять поведение объектом, а не условием. Разница в намерении. Стратегия подменяет один из взаимозаменяемых алгоритмов решения одной и той же задачи — например, разные способы расчёта скидки на ремонт, — и клиент выбирает конкретную стратегию исходя из условий, но не хранит историю выборов и не собирается их отменять. Команда представляет отдельный запрос на выполнение действия, у неё есть естественное понятие «когда» (сейчас, позже, по очереди) и «можно ли отменить», чего у стратегии просто нет — вопрос «отменить последнюю стратегию» не имеет смысла.

КомандаСтратегия
Что представляетКонкретный запрос на действиеОдин из взаимозаменяемых способов решить задачу
Отмена, очередь, журналЕстественная область примененияКак правило, не нужны
Кто выбирает реализациюОтправитель получает готовую команду от клиентаКлиент выбирает стратегию в момент использования

Когда применять

  • Объект, инициирующий действие (кнопка, пункт меню, элемент расписания), не должен знать деталей о том, кто и как это действие выполняет.
  • Нужна отмена операций, история выполненных действий или очередь отложенных запросов — как показано выше, всё это следует из того, что запрос стал объектом.
  • Одно и то же действие нужно запускать из разных мест (кнопка на панели, голосовая команда, автоматическое расписание) без дублирования кода вызова.

Плюсы и минусы

Команда разрывает прямую связь между отправителем запроса и его получателем — LiftControlPanel работает с любым Command, не зная о Lift вообще, — и превращает запрос в объект первого класса, который можно сохранить, поставить в очередь, залогировать или отменить единым способом для всех действий сразу.

Минус — на каждое отдельное действие заводится отдельный класс, и там, где раньше был один match с тремя ветками, теперь три небольших класса плюс интерфейс. Для панели с парой кнопок и без всякой потребности в отмене или очереди это может быть избыточным усложнением ради гибкости, которая не понадобится.

Итог

Команда нужна, когда запрос должен пережить момент своего создания — подождать в очереди, попасть в журнал или быть отменённым, — а не просто выполниться и исчезнуть, как обычный вызов метода. Она отвязывает того, кто инициирует действие, от того, кто его выполняет, ценой одного дополнительного класса на каждое отдельное действие.