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

Template Method——アルゴリズムの骨格を定義する

問題: 「手順は同じだが、ステップの中身が違う」

CSVとExcelのインポート機能を作るとき、処理の大まかな流れは同じです。

  1. ファイルを開く
  2. データを行ごとにパース
  3. バリデーション
  4. DBへ保存
  5. ファイルを閉じる

しかしCSVとExcelではファイルの「開き方」「パース方法」が異なります。コードを全部コピーすると、手順5の「ファイルを閉じる」処理を修正したとき両方の修正が必要になります。

// 問題のあるコード: 処理の骨格が重複している
class CsvImporter
{
    public function import(string $path): void
    {
        $file = fopen($path, 'r');           // ①開く
        while ($row = fgetcsv($file)) {      // ②パース(CSV固有)
            $this->validate($row);           // ③バリデーション
            $this->save($row);               // ④保存
        }
        fclose($file);                       // ⑤閉じる
    }
}

class ExcelImporter
{
    public function import(string $path): void
    {
        $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
        $sheet  = $reader->load($path)->getActiveSheet(); // ①開く(Excel固有)
        foreach ($sheet->getRowIterator() as $row) {      // ②パース(Excel固有)
            $cells = iterator_to_array($row->getCellIterator());
            $data  = array_map(fn($c) => $c->getValue(), $cells);
            $this->validate($data);                        // ③バリデーション
            $this->save($data);                            // ④保存
        }
        // ⑤閉じる(Excelはガベージコレクションに任せる)
    }
}

パターン: Template Method

Template Methodパターンは、アルゴリズムの骨格を抽象クラスで定義し、可変部分のみを子クラスにオーバーライドさせるパターンです。「型(テンプレート)」と「中身」を分離します。

+----------------------------------+
|   DataImporter (abstract)        |
+----------------------------------+
| + import(path: string): void     |  ← テンプレートメソッド(final推奨)
|   - open(path)                   |
|   - parseRows(): array           |
|   - validate(row): void          |
|   - save(row): void              |
|   - close(): void                |
+----------------------------------+
| # open(path): void    (abstract) |  ← フック/抽象メソッド
| # parseRows(): array  (abstract) |
| # close(): void  (デフォルト実装)|
+----------------------------------+

     _________|___________
    |                     |
CsvImporter           ExcelImporter
(open, parseRowsを実装)

PHP 8.x での実装

<?php

// 抽象基底クラス(テンプレートを定義)
abstract class DataImporter
{
    private int $processedCount = 0;
    private int $errorCount     = 0;

    // テンプレートメソッド: アルゴリズムの骨格を定義。変更させない
    final public function import(string $filePath): array
    {
        $this->open($filePath);

        try {
            $rows = $this->parseRows();

            foreach ($rows as $row) {
                try {
                    $this->validate($row);
                    $this->save($row);
                    $this->processedCount++;
                } catch (\InvalidArgumentException $e) {
                    $this->errorCount++;
                    $this->handleRowError($row, $e);
                }
            }
        } finally {
            $this->close();
        }

        return [
            'processed' => $this->processedCount,
            'errors'    => $this->errorCount,
        ];
    }

    // サブクラスが実装しなければならない抽象メソッド
    abstract protected function open(string $filePath): void;
    abstract protected function parseRows(): array;

    // デフォルト実装を持つフックメソッド(オーバーライド任意)
    protected function validate(array $row): void
    {
        if (empty($row)) {
            throw new \InvalidArgumentException('空の行はスキップします');
        }
    }

    protected function close(): void
    {
        // デフォルトは何もしない(サブクラスで必要に応じてオーバーライド)
    }

    protected function handleRowError(array $row, \Throwable $e): void
    {
        // デフォルトはログ出力のみ(オーバーライドで通知等を追加できる)
        error_log("行処理エラー: " . $e->getMessage());
    }

    // サブクラスがオーバーライドする: 抽象として強制しない
    protected function save(array $row): void
    {
        // デフォルトの保存処理(DBへ)
        echo "保存: " . implode(', ', $row) . "\n";
    }
}

// CSVインポーター
class CsvImporter extends DataImporter
{
    private mixed $fileHandle = null;
    private array $rows       = [];

    protected function open(string $filePath): void
    {
        $this->fileHandle = fopen($filePath, 'r');
        if ($this->fileHandle === false) {
            throw new \RuntimeException("ファイルを開けません: {$filePath}");
        }
    }

    protected function parseRows(): array
    {
        $rows = [];
        // ヘッダー行をスキップ
        fgetcsv($this->fileHandle);

        while (($row = fgetcsv($this->fileHandle)) !== false) {
            $rows[] = $row;
        }
        return $rows;
    }

    protected function close(): void
    {
        if ($this->fileHandle !== null) {
            fclose($this->fileHandle);
        }
    }
}

// Excelインポーター
class ExcelImporter extends DataImporter
{
    private ?\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet = null;

    protected function open(string $filePath): void
    {
        $reader              = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
        $this->spreadsheet   = $reader->load($filePath);
    }

    protected function parseRows(): array
    {
        $sheet = $this->spreadsheet->getActiveSheet();
        $rows  = [];

        foreach ($sheet->getRowIterator(2) as $row) { // 2行目からスタート(ヘッダースキップ)
            $cells = iterator_to_array($row->getCellIterator());
            $rows[] = array_map(fn($c) => $c->getValue(), $cells);
        }

        return $rows;
    }

    // ExcelはGCに任せるのでcloseは不要(オーバーライドしない)
}

使う側のコードはシンプルです。

// CSVインポート
$csvImporter = new CsvImporter();
$result = $csvImporter->import('/path/to/users.csv');
echo "処理済み: {$result['processed']}, エラー: {$result['errors']}\n";

// Excelインポート(同じテンプレートで動く)
$excelImporter = new ExcelImporter();
$result = $excelImporter->import('/path/to/users.xlsx');

フレームワークのコア設計

LaravelのコントローラーやJobクラスもTemplate Methodパターンを使っています。

// Laravelのコントローラーの仕組み(簡略化)
abstract class Controller
{
    // フレームワークが呼び出すテンプレートメソッド
    public function callAction(string $method, array $parameters): mixed
    {
        $this->beforeAction();     // フック(子クラスでオーバーライド可)
        $result = $this->$method(...$parameters); // 子クラスのアクション
        $this->afterAction();      // フック
        return $result;
    }

    protected function beforeAction(): void {} // デフォルトは何もしない
    protected function afterAction(): void {}
}

// 子クラスはアクションだけ実装する
class UserController extends Controller
{
    public function index(): Response
    {
        return response()->json(User::all());
    }
}

LaravelのMailableクラスも同様です。

// app/Mail/WelcomeMail.php
use Illuminate\Mail\Mailable;

class WelcomeMail extends Mailable
{
    public function __construct(private readonly User $user) {}

    // build() がテンプレートメソッドの「実装部分」に相当
    public function build(): self
    {
        return $this->subject('ようこそ!')
                    ->view('emails.welcome')
                    ->with(['user' => $this->user]);
    }
}

StrategyパターンとTemplate Methodの使い分け

よく比較される両パターンの違いをまとめます。

比較項目Template MethodStrategy
実現手段継承(抽象クラス)委譲(インターフェース)
変更タイミングコンパイル時(クラスを選ぶ)実行時(オブジェクトを差し替え)
骨格の場所親クラスコンテキストクラス
結合度高め(継承依存)低め(インターフェース依存)
向いている場面「一部だけ違う処理」を統一したいアルゴリズム全体を実行時に切り替えたい
使い分けの目安:
- 「CSVとExcelで処理の骨格は同じ」→ Template Method(継承)
- 「支払い方法を実行時にカートの状態で切り替えたい」→ Strategy(委譲)

一般に、Template MethodよりStrategyの方が柔軟ですが、Template Methodは骨格の共有と強制力(final メソッド)が明確に書けます。


まとめ

  • Template Methodは「アルゴリズムの骨格」を抽象クラスで固め、可変部分を子クラスに委ねる
  • final メソッドで骨格の改変を禁止し、abstract で実装を強制する
  • フックメソッド(デフォルト実装あり)でオプションのカスタマイズも可能
  • Strategyは実行時の切り替えに向き、Template Methodはコンパイル時(クラス設計時)の統一に向く

次回はシリーズ最終回、まとめ——パターンを活かした設計の考え方を解説します。