Command: действия как объекты (и почему это удобно)

Command: действия как объекты (и почему это удобно)

«Действие как объект» звучит странно - пока не дойдёшь до очередей, отмены и логов аудита. Тогда Command становится очевидным.

Проблема

В админке есть несколько действий: переиндексация, рассылка, экспорт данных. Обработчик:

func HandleAdmin(action string) error {
    switch action {
    case "reindex":
        // 20 строк логики
    case "newsletter":
        // 30 строк логики
    case "export":
        // 25 строк логики
    default:
        return fmt.Errorf("unknown action: %s", action)
    }
    return nil
}
<?php
declare(strict_types=1);

function handleAdmin(string $action): void
{
    match ($action) {
        'reindex'    => /* 20 строк логики */ null,
        'newsletter' => /* 30 строк логики */ null,
        'export'     => /* 25 строк логики */ null,
        default      => throw new InvalidArgumentException("unknown action: $action"),
    };
}
async function handleAdmin(action) {
  switch (action) {
    case 'reindex':
      // 20 строк логики
      break;
    case 'newsletter':
      // 30 строк логики
      break;
    case 'export':
      // 25 строк логики
      break;
    default:
      throw new Error(`unknown action: ${action}`);
  }
}

Проблемы: один разрастающийся switch/match (нарушение OCP), нельзя ставить действия в очередь, нельзя откатить, нельзя логировать единообразно.

Решение: Command

Command - это действие, оформленное как объект. Вместо «вызвать функцию с параметрами» - создать структуру с методом Execute(). Это позволяет:

  • ставить в очередь
  • логировать единообразно
  • повторять (retry)
  • откатывать (undo)
  • сериализовать и выполнять позже

Producer → Queue → Invoker, обратной стрелкой - undo

Команды для админки

type Command interface {
    Execute() error
}

type ReindexCommand struct {
    indexName string
}

func (c ReindexCommand) Execute() error {
    log.Printf("reindexing %s...", c.indexName)
    // логика переиндексации
    return nil
}

type SendNewsletterCommand struct {
    templateID string
    recipients []string
}

func (c SendNewsletterCommand) Execute() error {
    log.Printf("sending newsletter %s to %d recipients",
        c.templateID, len(c.recipients))
    // логика рассылки
    return nil
}

Диспетчер выполняет любую команду:

func RunCommand(cmd Command) error {
    log.Printf("executing: %T", cmd)
    start := time.Now()
    err := cmd.Execute()
    log.Printf("done: %T, duration=%v, err=%v",
        cmd, time.Since(start), err)
    return err
}
interface Command
{
    public function execute(): void;
}

final class ReindexCommand implements Command
{
    public function __construct(
        private readonly string $indexName,
    ) {}

    public function execute(): void
    {
        // логика переиндексации
    }
}

final class SendNewsletterCommand implements Command
{
    public function __construct(
        private readonly string $templateId,
        private readonly array $recipients,
    ) {}

    public function execute(): void
    {
        // логика рассылки
    }
}

Диспетчер выполняет любую команду:

final class CommandRunner
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function run(Command $cmd): void
    {
        $name = $cmd::class;
        $this->logger->info("executing: $name");
        $t = microtime(true);
        try {
            $cmd->execute();
        } finally {
            $ms = (int) ((microtime(true) - $t) * 1000);
            $this->logger->info("done: $name, duration_ms=$ms");
        }
    }
}

В Symfony такой паттерн - основа Messenger: команда (Message) идёт через MessageBus, который сам логирует/ретраит/кладёт в очередь.

class ReindexCommand {
  #indexName;
  constructor(indexName) { this.#indexName = indexName; }
  async execute() {
    // логика переиндексации
  }
}

class SendNewsletterCommand {
  #templateId;
  #recipients;
  constructor(templateId, recipients) {
    this.#templateId = templateId;
    this.#recipients = recipients;
  }
  async execute() {
    // логика рассылки
  }
}

Диспетчер выполняет любую команду:

class CommandRunner {
  #logger;
  constructor(logger) { this.#logger = logger ?? console; }

  async run(cmd) {
    const name = cmd.constructor.name;
    this.#logger.info?.(`executing: ${name}`);
    const start = Date.now();
    try {
      await cmd.execute();
    } finally {
      this.#logger.info?.(`done: ${name}, duration_ms=${Date.now() - start}`);
    }
  }
}

const runner = new CommandRunner();
await runner.run(new ReindexCommand('users'));

Команда в JS часто умещается в обычный объект { type, payload, execute } - формальный класс берут, когда нужно несколько связанных методов (execute + undo) или приватное состояние. BullMQ / Bee-Queue для Node - это аналог Symfony Messenger: сериализуешь команду и кладёшь в Redis.

Command Queue: очередь задач

Команды можно складывать в очередь и выполнять последовательно:

type CommandQueue struct {
    queue []Command
    mu    sync.Mutex
}

func (q *CommandQueue) Add(cmd Command) {
    q.mu.Lock()
    defer q.mu.Unlock()
    q.queue = append(q.queue, cmd)
}

func (q *CommandQueue) ExecuteAll() error {
    q.mu.Lock()
    cmds := q.queue
    q.queue = nil
    q.mu.Unlock()

    for _, cmd := range cmds {
        if err := cmd.Execute(); err != nil {
            return fmt.Errorf("command %T failed: %w", cmd, err)
        }
    }
    return nil
}
<?php
declare(strict_types=1);

final class CommandQueue
{
    /** @var list<Command> */
    private array $queue = [];

    public function add(Command $cmd): void
    {
        $this->queue[] = $cmd;
    }

    public function executeAll(): void
    {
        $cmds = $this->queue;
        $this->queue = [];

        foreach ($cmds as $cmd) {
            try {
                $cmd->execute();
            } catch (Throwable $e) {
                throw new RuntimeException(
                    sprintf('command %s failed: %s', $cmd::class, $e->getMessage()),
                    previous: $e,
                );
            }
        }
    }
}
class CommandQueue {
  #queue = [];

  add(cmd) {
    this.#queue.push(cmd);
  }

  async executeAll() {
    const cmds = this.#queue;
    this.#queue = [];

    for (const cmd of cmds) {
      try {
        await cmd.execute();
      } catch (err) {
        throw new Error(`command ${cmd.constructor.name} failed: ${err?.message}`, { cause: err });
      }
    }
  }
}

Это основа для background jobs: сериализуешь команду (JSON), кладёшь в Redis/PostgreSQL, воркер десериализует и выполняет.

Ты можешь сериализовать команду (или её данные) и выполнить позже - почти готовый background job. Laravel Queues / Symfony Messenger (PHP), machinery (Go) - все построены на этой идее.

Undo/Redo: откат команд

Если команда знает, как себя отменить, получается Undo:

type UndoableCommand interface {
    Execute() error
    Undo() error
}

type ChangeEmailCommand struct {
    userRepo UserRepo
    userID   int64
    oldEmail string
    newEmail string
}

func (c *ChangeEmailCommand) Execute() error {
    user, _ := c.userRepo.Get(c.userID)
    c.oldEmail = user.Email  // запоминаем для Undo
    return c.userRepo.UpdateEmail(c.userID, c.newEmail)
}

func (c *ChangeEmailCommand) Undo() error {
    return c.userRepo.UpdateEmail(c.userID, c.oldEmail)
}
<?php
declare(strict_types=1);

interface UndoableCommand
{
    public function execute(): void;

    public function undo(): void;
}

final class ChangeEmailCommand implements UndoableCommand
{
    private ?string $oldEmail = null;

    public function __construct(
        private readonly UserRepository $userRepo,
        private readonly int $userId,
        private readonly string $newEmail,
    ) {}

    public function execute(): void
    {
        $user = $this->userRepo->get($this->userId);
        $this->oldEmail = $user->email; // запоминаем для Undo
        $this->userRepo->updateEmail($this->userId, $this->newEmail);
    }

    public function undo(): void
    {
        if ($this->oldEmail === null) {
            throw new LogicException('command was not executed');
        }
        $this->userRepo->updateEmail($this->userId, $this->oldEmail);
    }
}
class ChangeEmailCommand {
  #userRepo;
  #userId;
  #newEmail;
  #oldEmail = null;

  constructor(userRepo, userId, newEmail) {
    this.#userRepo = userRepo;
    this.#userId = userId;
    this.#newEmail = newEmail;
  }

  async execute() {
    const user = await this.#userRepo.get(this.#userId);
    this.#oldEmail = user.email; // запоминаем для Undo
    await this.#userRepo.updateEmail(this.#userId, this.#newEmail);
  }

  async undo() {
    if (this.#oldEmail === null) {
      throw new Error('command was not executed');
    }
    await this.#userRepo.updateEmail(this.#userId, this.#oldEmail);
  }
}

Менеджер хранит историю и может откатить:

type History struct {
    done []UndoableCommand
}

func (h *History) Execute(cmd UndoableCommand) error {
    if err := cmd.Execute(); err != nil {
        return err
    }
    h.done = append(h.done, cmd)
    return nil
}

func (h *History) Undo() error {
    if len(h.done) == 0 {
        return fmt.Errorf("nothing to undo")
    }
    last := h.done[len(h.done)-1]
    h.done = h.done[:len(h.done)-1]
    return last.Undo()
}
<?php
declare(strict_types=1);

final class History
{
    /** @var list<UndoableCommand> */
    private array $done = [];

    public function execute(UndoableCommand $cmd): void
    {
        $cmd->execute();
        $this->done[] = $cmd;
    }

    public function undo(): void
    {
        $last = array_pop($this->done);
        if ($last === null) {
            throw new RuntimeException('nothing to undo');
        }
        $last->undo();
    }
}
class History {
  #done = [];

  async execute(cmd) {
    await cmd.execute();
    this.#done.push(cmd);
  }

  async undo() {
    const last = this.#done.pop();
    if (!last) {
      throw new Error('nothing to undo');
    }
    await last.undo();
  }
}

Command в CLI-приложениях

Каждая субкоманда CLI - это Command:

// cobra-стиль
type CLICommand struct {
    Name string
    Run  func(args []string) error
}

commands := []CLICommand{
    {Name: "migrate", Run: runMigrations},
    {Name: "seed", Run: runSeeds},
    {Name: "serve", Run: startServer},
}
<?php
declare(strict_types=1);

// Symfony Console-стиль
abstract class CLICommand
{
    abstract public function name(): string;

    /** @param list<string> $args */
    abstract public function run(array $args): int;
}

final class MigrateCommand extends CLICommand
{
    public function name(): string
    {
        return 'migrate';
    }

    public function run(array $args): int
    {
        // запуск миграций
        return 0;
    }
}

$commands = [
    new MigrateCommand(),
    new SeedCommand(),
    new ServeCommand(),
];
// commander-стиль (Node.js)
class CLICommand {
  constructor(name, run) {
    this.name = name;
    this.run = run; // async (args) => void
  }
}

const commands = [
  new CLICommand('migrate', runMigrations),
  new CLICommand('seed', runSeeds),
  new CLICommand('serve', startServer),
];

Библиотеки cobra и urfave/cli (Go), Symfony Console (PHP), commander/yargs (Node.js) построены на этом паттерне: каждая команда - объект с именем, описанием и функцией выполнения.

Связь Command и CQRS

CQRS (Command Query Responsibility Segregation) использует паттерн Command для записи данных:

Command (запись)Query (чтение)
CreateUserCommandGetUserQuery
ChangeEmailCommandListUsersQuery
DeleteOrderCommandGetOrderStatsQuery
Меняют состояниеТолько читают
Могут быть в очередиОбычно синхронные
Валидация + бизнес-правилаПростые SELECT-ы

Если ты изучал трек CQRS - теперь видно, откуда растут ноги: Commands в CQRS - прямые потомки паттерна Command.

Не оборачивай каждый HTTP-handler в Command. Command полезен когда действие нужно ставить в очередь, логировать, повторять или откатывать. Простой CRUD-обработчик не нуждается в Command.

Когда НЕ использовать

Простые действия - если действие это один вызов функции без необходимости очередей, retry или undo, Command - лишний слой.

Нет очереди и отката - если ты не планируешь ставить команды в очередь или откатывать, обычная функция проще.

Overengineering - Command ради Command (один тип команды, один обработчик) добавляет сложности без пользы.

Связь с другими паттернами

Command + Observer - команда публикует событие после выполнения
Command + Memento - Memento сохраняет состояние для Undo
Command + Factory - фабрика создаёт нужную команду по параметрам
Command + Queue - команды в очереди = background jobs

Мини-задание

  • Сделай 2-3 команды с интерфейсом Execute() error и диспетчер, который выполняет их
  • Реализуй Undo: команда запоминает состояние «до» и умеет откатиться
  • Сделай CommandQueue - складывай команды и выполняй пакетно
  • Посмотри исходники cobra - как устроена структура Command

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.