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)
- сериализовать и выполнять позже
Команды для админки
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, воркер десериализует и выполняет.
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 (чтение) |
|---|---|
CreateUserCommand | GetUserQuery |
ChangeEmailCommand | ListUsersQuery |
DeleteOrderCommand | GetOrderStatsQuery |
| Меняют состояние | Только читают |
| Могут быть в очереди | Обычно синхронные |
| Валидация + бизнес-правила | Простые SELECT-ы |
Если ты изучал трек CQRS - теперь видно, откуда растут ноги: Commands в CQRS - прямые потомки паттерна 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