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

Builder——複雑なオブジェクトを段階的に組み立てる

問題: コンストラクタが複雑になりすぎる

オプションの多いオブジェクトを生成するとき、コンストラクタに引数を詰め込むとすぐに読みにくくなります。

// 問題のあるコード: コンストラクタ引数が多すぎる
$query = new SqlQuery(
    'users',          // テーブル
    ['id', 'name'],   // カラム
    ['age > 18'],     // WHERE条件
    'name',           // ORDER BY
    'ASC',            // 方向
    10,               // LIMIT
    0,                // OFFSET
    ['orders'],       // JOIN
    false             // DISTINCT
);

引数の順序を覚えなければならず、何番目が何を意味するかが一目でわかりません。また、オプション項目を省略したい場合に null を大量に渡す必要があります。


パターン: Builder

Builderパターンは、複雑なオブジェクトの構築プロセスを専用の「ビルダークラス」に分離するパターンです。各オプションをメソッドで順番に設定し、最後に build()get() で完成品を取り出します。

+--------------------------------+
|       QueryBuilder             |
+--------------------------------+
| - table: string                |
| - columns: array               |
| - conditions: array            |
| - orderBy: string|null         |
| - limitValue: int|null         |
+--------------------------------+
| + from(table): static          |
| + select(columns): static      |
| + where(condition): static     |
| + orderBy(column): static      |
| + limit(n): static             |
| + build(): SqlQuery            |
+--------------------------------+

PHP 8.x での実装

<?php

// 完成品クラス
final class SqlQuery
{
    public function __construct(
        public readonly string $table,
        public readonly array $columns,
        public readonly array $conditions,
        public readonly ?string $orderColumn,
        public readonly string $orderDirection,
        public readonly ?int $limitValue,
        public readonly int $offsetValue,
    ) {}

    public function toSql(): string
    {
        $cols   = empty($this->columns) ? '*' : implode(', ', $this->columns);
        $sql    = "SELECT {$cols} FROM {$this->table}";

        if (!empty($this->conditions)) {
            $sql .= ' WHERE ' . implode(' AND ', $this->conditions);
        }

        if ($this->orderColumn !== null) {
            $sql .= " ORDER BY {$this->orderColumn} {$this->orderDirection}";
        }

        if ($this->limitValue !== null) {
            $sql .= " LIMIT {$this->limitValue}";
        }

        if ($this->offsetValue > 0) {
            $sql .= " OFFSET {$this->offsetValue}";
        }

        return $sql;
    }
}

// ビルダークラス
class QueryBuilder
{
    private string $table = '';
    private array $columns = [];
    private array $conditions = [];
    private ?string $orderColumn = null;
    private string $orderDirection = 'ASC';
    private ?int $limitValue = null;
    private int $offsetValue = 0;

    public function from(string $table): static
    {
        $this->table = $table;
        return $this; // メソッドチェーンのためにselfを返す
    }

    public function select(string ...$columns): static
    {
        $this->columns = $columns;
        return $this;
    }

    public function where(string $condition): static
    {
        $this->conditions[] = $condition;
        return $this;
    }

    public function orderBy(string $column, string $direction = 'ASC'): static
    {
        $this->orderColumn    = $column;
        $this->orderDirection = strtoupper($direction);
        return $this;
    }

    public function limit(int $limit): static
    {
        $this->limitValue = $limit;
        return $this;
    }

    public function offset(int $offset): static
    {
        $this->offsetValue = $offset;
        return $this;
    }

    public function build(): SqlQuery
    {
        if (empty($this->table)) {
            throw new \RuntimeException('テーブル名は必須です');
        }

        return new SqlQuery(
            table:          $this->table,
            columns:        $this->columns,
            conditions:     $this->conditions,
            orderColumn:    $this->orderColumn,
            orderDirection: $this->orderDirection,
            limitValue:     $this->limitValue,
            offsetValue:    $this->offsetValue,
        );
    }
}

使う側のコードは格段に読みやすくなります。

$query = (new QueryBuilder())
    ->from('users')
    ->select('id', 'name', 'email')
    ->where('age > 18')
    ->where('active = 1')
    ->orderBy('name')
    ->limit(10)
    ->offset(20)
    ->build();

echo $query->toSql();
// SELECT id, name, email FROM users WHERE age > 18 AND active = 1 ORDER BY name ASC LIMIT 10 OFFSET 20

メソッドチェーン(Fluent Interface)

return $this; を返すことで実現するメソッドチェーンを Fluent Interface(流暢なインターフェース) と呼びます。英語で読むように左から右に流れるコードが書けるため、可読性が大幅に向上します。

// Fluent Interface の例: 条件付きで組み立てられる
function buildUserQuery(bool $onlyActive, ?string $keyword): SqlQuery
{
    $builder = (new QueryBuilder())->from('users')->select('id', 'name');

    if ($onlyActive) {
        $builder->where('active = 1');
    }

    if ($keyword !== null) {
        $builder->where("name LIKE '%{$keyword}%'");
    }

    return $builder->orderBy('created_at', 'DESC')->limit(20)->build();
}

LaravelのEloquentビルダーとの関係

LaravelのEloquentクエリビルダーはBuilderパターンの典型例です。

// Eloquentビルダー: まさにBuilderパターン
$users = User::query()
    ->select('id', 'name', 'email')
    ->where('active', true)
    ->where('age', '>', 18)
    ->orderBy('name')
    ->limit(10)
    ->offset(20)
    ->get();

// 条件付きビルド: when() を使ったFluent Interface
$users = User::query()
    ->when($request->has('keyword'), function ($query) use ($request) {
        $query->where('name', 'like', "%{$request->keyword}%");
    })
    ->when($request->has('role'), function ($query) use ($request) {
        $query->where('role', $request->role);
    })
    ->get();

Eloquentの query() が返す Builder オブジェクトは、最終的に get()first() を呼ぶまでSQLを実行しません。これを遅延実行(Lazy Evaluation)と呼び、Builderパターンの大きなメリットのひとつです。


メールビルダーへの応用

Builderパターンはメール送信など他の場面でも活躍します。

// メール送信用ビルダー
class MailBuilder
{
    private string $to = '';
    private string $subject = '';
    private string $body = '';
    private array $attachments = [];
    private bool $htmlEnabled = false;

    public function to(string $email): static
    {
        $this->to = $email;
        return $this;
    }

    public function subject(string $subject): static
    {
        $this->subject = $subject;
        return $this;
    }

    public function body(string $body, bool $html = false): static
    {
        $this->body        = $body;
        $this->htmlEnabled = $html;
        return $this;
    }

    public function attach(string $filePath): static
    {
        $this->attachments[] = $filePath;
        return $this;
    }

    public function send(): void
    {
        // メール送信の実装
        echo "Sending mail to {$this->to}: {$this->subject}\n";
    }
}

// 使用例
(new MailBuilder())
    ->to('user@example.com')
    ->subject('登録完了のお知らせ')
    ->body('<h1>ようこそ!</h1>', html: true)
    ->attach('/path/to/guide.pdf')
    ->send();

まとめ

  • Builderパターンは複雑なオブジェクトの構築をビルダークラスに分離する
  • return $this; によるメソッドチェーン(Fluent Interface)で読みやすいコードになる
  • 必須パラメータと任意パラメータを明確に分けられる
  • LaravelのEloquentクエリビルダーはBuilderパターンの代表例

次回はStrategyパターンで、アルゴリズムを差し替え可能にする方法を解説します。