Команда

Команда (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(),
};
}
}Появляются два новых требования по технике безопасности:
- Каждое нажатие кнопки нужно логировать — кто и когда поднимал или опускал подъёмник.
- На панели нужна кнопка «Отменить» — если оператор случайно нажал не ту кнопку, последнее действие должно откатываться одним нажатием.
Логирование добавляется без труда — в 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 с тремя ветками, теперь три небольших класса плюс интерфейс. Для панели с парой кнопок и без всякой потребности в отмене или очереди это может быть избыточным усложнением ради гибкости, которая не понадобится.
Итог
Команда нужна, когда запрос должен пережить момент своего создания — подождать в очереди, попасть в журнал или быть отменённым, — а не просто выполниться и исчезнуть, как обычный вызов метода. Она отвязывает того, кто инициирует действие, от того, кто его выполняет, ценой одного дополнительного класса на каждое отдельное действие.