#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パターンで、互換性のないクラスをつなぐ方法を解説します。