#09 デザインパターン入門

Command——操作をオブジェクトとしてカプセル化する

問題: 操作の履歴管理や取り消しが難しい

テキストエディタや設定ツールでは、「Ctrl+Z で元に戻す」機能が求められます。しかし操作を直接メソッドで呼び出していると、取り消し(Undo)が難しくなります。

// 問題のあるコード: 操作が散在していて履歴を管理できない
class TextEditor
{
    private string $text = '';

    public function insert(string $char, int $position): void
    {
        $this->text = substr($this->text, 0, $position) . $char . substr($this->text, $position);
        // どうやってUndoする? 操作の記録がない
    }

    public function delete(int $position): void
    {
        $this->text = substr($this->text, 0, $position) . substr($this->text, $position + 1);
        // Undoのために削除した文字を覚えておく仕組みがない
    }
}

パターン: Command

Commandパターンは、操作(メソッド呼び出し)をオブジェクトとしてカプセル化します。「何をするか」と「する対象(レシーバー)」をひとつのオブジェクトに閉じ込めることで、操作の保存・取り消し・再実行・キューイングが可能になります。

+----------------------------------+
|   Command <<interface>>          |
+----------------------------------+
| + execute(): void                |
| + undo(): void                   |
+----------------------------------+

     _________|___________
    |                     |
InsertCommand          DeleteCommand


+----------------------------------+
|   CommandHistory                 |
+----------------------------------+
| - history: array<Command>        |
+----------------------------------+
| + execute(cmd: Command): void    |
| + undo(): void                   |
| + redo(): void                   |
+----------------------------------+

PHP 8.x での実装(Undo付きテキストエディタ)

<?php

// Commandインターフェース
interface Command
{
    public function execute(): void;
    public function undo(): void;
}

// レシーバー(操作対象)
class Document
{
    private string $text = '';

    public function getText(): string
    {
        return $this->text;
    }

    public function insertAt(string $char, int $position): void
    {
        $this->text = substr($this->text, 0, $position) . $char . substr($this->text, $position);
    }

    public function deleteAt(int $position): string
    {
        $deleted    = $this->text[$position] ?? '';
        $this->text = substr($this->text, 0, $position) . substr($this->text, $position + 1);
        return $deleted;
    }
}

// 文字挿入コマンド
class InsertCommand implements Command
{
    public function __construct(
        private readonly Document $document,
        private readonly string $char,
        private readonly int $position,
    ) {}

    public function execute(): void
    {
        $this->document->insertAt($this->char, $this->position);
    }

    public function undo(): void
    {
        // 挿入の逆は削除
        $this->document->deleteAt($this->position);
    }
}

// 文字削除コマンド
class DeleteCommand implements Command
{
    private string $deletedChar = '';

    public function __construct(
        private readonly Document $document,
        private readonly int $position,
    ) {}

    public function execute(): void
    {
        // Undoのために削除した文字を保存
        $this->deletedChar = $this->document->deleteAt($this->position);
    }

    public function undo(): void
    {
        // 削除の逆は挿入
        $this->document->insertAt($this->deletedChar, $this->position);
    }
}

// コマンド履歴(InvokerかつMementoの役割)
class CommandHistory
{
    private array $history = [];
    private int $currentIndex = -1;

    public function execute(Command $command): void
    {
        $command->execute();

        // 現在位置より先の履歴を削除(Redo後に新しい操作をした場合)
        $this->history = array_slice($this->history, 0, $this->currentIndex + 1);
        $this->history[] = $command;
        $this->currentIndex++;
    }

    public function undo(): void
    {
        if ($this->currentIndex < 0) {
            echo "これ以上Undoできません\n";
            return;
        }

        $this->history[$this->currentIndex]->undo();
        $this->currentIndex--;
    }

    public function redo(): void
    {
        if ($this->currentIndex >= count($this->history) - 1) {
            echo "これ以上Redoできません\n";
            return;
        }

        $this->currentIndex++;
        $this->history[$this->currentIndex]->execute();
    }
}

使用例を見てみましょう。

$doc     = new Document();
$history = new CommandHistory();

// 文字を入力: "Hello"
$history->execute(new InsertCommand($doc, 'H', 0));
$history->execute(new InsertCommand($doc, 'e', 1));
$history->execute(new InsertCommand($doc, 'l', 2));
$history->execute(new InsertCommand($doc, 'l', 3));
$history->execute(new InsertCommand($doc, 'o', 4));
echo $doc->getText() . "\n"; // Hello

// Undo: 'o' を削除
$history->undo();
echo $doc->getText() . "\n"; // Hell

// Undo: 'l' を削除
$history->undo();
echo $doc->getText() . "\n"; // Hel

// Redo: 'l' を再入力
$history->redo();
echo $doc->getText() . "\n"; // Hell

コマンドキュー

Commandパターンの強みのひとつが「後で実行できる」点です。コマンドをキューに積んで順番に処理できます。

<?php

class CommandQueue
{
    private array $queue = [];

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

    public function processAll(): void
    {
        while (!empty($this->queue)) {
            $command = array_shift($this->queue);
            $command->execute();
        }
    }
}

// メール一括送信のコマンド
class SendEmailCommand implements Command
{
    public function __construct(
        private readonly string $to,
        private readonly string $subject,
        private readonly string $body,
    ) {}

    public function execute(): void
    {
        echo "メール送信: {$this->to} / {$this->subject}\n";
        // 実際のメール送信処理
    }

    public function undo(): void
    {
        // メールは送信済みなのでUndoは難しい
        // ログに「送信取り消し試行」を記録するなど
    }
}

$queue = new CommandQueue();
$queue->enqueue(new SendEmailCommand('alice@example.com', '請求書', '...'));
$queue->enqueue(new SendEmailCommand('bob@example.com', '請求書', '...'));
$queue->enqueue(new SendEmailCommand('carol@example.com', '請求書', '...'));

$queue->processAll();

LaravelのJobシステムとCommandパターン

LaravelのJobクラスはCommandパターンそのものです。「何をするか」をジョブオブジェクトにカプセル化し、キューに入れて非同期実行できます。

// app/Jobs/SendWelcomeEmail.php

namespace App\Jobs;

use App\Models\User;
use App\Mail\WelcomeMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private readonly User $user,
    ) {}

    // execute() に相当するメソッド
    public function handle(): void
    {
        Mail::to($this->user->email)->send(new WelcomeMail($this->user));
    }

    // 失敗時の処理(undo に相当)
    public function failed(\Throwable $exception): void
    {
        \Log::error("ウェルカムメール送信失敗: {$this->user->email}", [
            'error' => $exception->getMessage(),
        ]);
    }
}

// ディスパッチ(キューへの追加)
SendWelcomeEmail::dispatch($user);

// 遅延実行(5分後に実行)
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));

// 特定のキューに送る
SendWelcomeEmail::dispatch($user)->onQueue('emails');

LaravelにはCLIコマンドを定義する Artisan Command もあり、「コマンド」という名前が示すとおりCommandパターンの影響を受けています。

// app/Console/Commands/CleanupExpiredTokens.php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class CleanupExpiredTokens extends Command
{
    protected $signature = 'tokens:cleanup';
    protected $description = '有効期限切れのトークンを削除します';

    public function handle(): void
    {
        $count = \App\Models\Token::where('expired_at', '<', now())->delete();
        $this->info("{$count}件のトークンを削除しました");
    }
}

まとめ

  • Commandパターンは操作をオブジェクトとしてカプセル化する
  • 操作の保存・取り消し(Undo)・再実行(Redo)・キューイングが実現できる
  • LaravelのJobシステムはCommandパターンを非同期処理と組み合わせた実装
  • execute() / undo() のペアが基本構造

次回はAdapterパターンで、互換性のないクラスをつなぐ方法を解説します。