102
87

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PHP: Builderパターンの実装手順 #1【基礎実装】

Last updated at Posted at 2019-02-13

Builderパターンとは、生成過程を抽象化したり、コンストラクタの引数が多かったり、コンストラクタの引数を作るのが複雑なときに使うデザインパターンのひとつだ。本稿では、引数が多すぎるコンストラクタの解消法のひとつとしてPHPでのBuilderパターンの実装方法を紹介する。

なお、本稿で実装したコードの完全版はGitHubで公開している。

お題のモデル

Builderパターンを実装するに当たってのお題として、Eメールを扱う。Eメールは、To, CC, From, Subject, Bodyの5つの情報を持っていることにする。これを素直にモデルにするとこうなる:

image.png

コンストラクタには5つ引数がある。この実装では、Emailクラスを利用したいクライアントコードは次のようになる:

new Email(
    ['alice@example.com'],
    ['bob@example.com'],
    [],
    'Hello',
    'Hello, there.'
);

これは可読性が高いコードとは言えないだろう。一見するとどれが送信者のアドレスでどれが受信者のものか見分けがつきにくい。引数の順番を間違えれば、送信者と受信者が真逆になるかもしれない。また、CCはオプショナルなのだが、いちいち空の配列を渡さないとならない。しかしながら、PHPのコンストラクタではこれ以上の表現は難しいだろう。1

new Email(
    to = ['alice@example.com'],
    from = ['bob@example.com'],
    subject = 'Hello',
    body = 'Hello, there.'
);

PHPでこれと似たようなことをするには、連想配列を渡すことになりますが、IDEのリファクタリング支援が受けにくくなったり、型の安全性を損なうのでおすすめしません。

$config = ['to' => 'alice@example.com', 'from' => ...];
new Email($config);

Builderパターンはこうした問題を解決する。Emailを作るクラスとしてEmailBuilderを用意しよう。EmailBuilderEmailのコンストラクタよりも明示的な表現ができるAPIを備える。

image.png

このEmailBuilderを使うと、クライアントコードの可読性が高まる。

$emailBuilder = new EmailBuilder();
$emailBuilder->setFrom('alice@example.com');
$emailBuilder->setTo('bob@example.com');
$emailBuilder->setSubject('Hello');
$emailBuilder->setBody('Hello, there.');
$email = $emailBuilder->build();

Emailクラスの実装

以上は利用者視点で見たBuilderパターンだったが、ここからはBuilderパターンの実装者として各クラスを見ていこう。

EmailクラスはシンプルにEmailの属性とコンストラクタを実装する:

final class Email
{
    private $from;
    private $to;
    private $cc;
    private $subject;
    private $body;

    /**
     * @param string[] $from
     * @param string[] $to
     * @param string[] $cc
     */
    public function __construct(
        array $from,
        array $to,
        array $cc,
        string $subject,
        string $body
    ) {
        $this->from = $from;
        $this->to = $to;
        $this->cc = $cc;
        $this->subject = $subject;
        $this->body = $body;
    }
}

あとは振る舞いを持たせるなり、ゲッターを生やすなりする。

EmailBuilderクラスの実装

次にEmailBuilderクラスを実装していこう。

まずはEmailクラスのコンストラクタ引数と同じ数だけのプロパティをEmailBuilderに持たせる。デフォルト値がセットできるものはせっとしておこう。ここではarray型のプロパティである$from, $to, $ccは空の配列で初期化しておいたほうがいい。$bodyなどは0バイトの文字列もOKという仕様にしたいので未設定は""ではなくnullで表現する。

final class EmailBuilder
{
    private $from = [];
    private $to = [];
    private $cc = [];

    /**
     * @var string|null
     */
    private $subject;

    /**
     * @var string|null
     */
    private $body;
}

次にセッターを用意する。このへんはPhpStormのコード生成(Generate Code)でサクッと作ってしまおう。ちなみに、引数に使っている...についてはジェネリクスがないPHPでも配列中身のタイプヒントを可能にする「Splat Operator」を参照。

final class EmailBuilder
{
    // ...プロパティ略...

    public function setFrom(string ...$from): void
    {
        $this->from = $from;
    }

    public function setTo(string ...$to): void
    {
        $this->to = $to;
    }

    public function setCc(string ...$cc): void
    {
        $this->cc = $cc;
    }

    public function setSubject(string $subject): void
    {
        $this->subject = $subject;
    }

    public function setBody(string $body): void
    {
        $this->body = $body;
    }
}

最後にEmailオブジェクトを生成するbuildメソッドを生やす。

final class EmailBuilder
{
    // ...プロパティ略...

    // ...セッター略...

    public function build(): Email
    {
        return new Email(
            $this->from,
            $this->to,
            $this->cc,
            $this->subject,
            $this->body
        );
    }
}

ここまでやればEmailBuilderクラスはビルダーとして機能するようになる。

EmailBuilderクラスにバリデーションをしかける

このままだと、EmailBuilderは受信者や件名、本文が指定されてないメールが作れてしまうバグがある。なので、build()メソッドを直しておかしなEmailオブジェクトが作られないようにしよう。

buildメソッドでnew Emailする前に、Emailオブジェクトが満たすべき不変条件を定義する。難しく言ったが平たく言えばバリデーションするということだ。

    public function build(): Email
    {
        $this->assertAtLeastOneFromAddress();
        $this->assertAtLeastOneRecipientAddress();
        $this->assertSubjectIsProvided();
        $this->assertThatBodyIsSpecified();
        return new Email(
            $this->from,
            $this->to,
            $this->cc,
            $this->subject,
            $this->body
        );
    }

buildメソッドにおいては、どんな不変条件があるかは抽象的に書いたほうが良い。なぜなら、「何がチェックされるか」と「どうチェックされるか」を分けたほうが可読性のみならず保守性も向上するからだ。assertのメソッド名が「何が」を表現し、各assertメソッドの実装が「どう」を表現する。「何がチェックされるか」は変更されにくいが、「どうチェックするか」はリファクタリングやチューニングのために変更されやすい。

チェック方法がアップデートされるたびにbuildメソッドに手が入るのは好ましくない。buildメソッドを書き換えるのは、Emailオブジェクトの作成方法を変えるときだ。できるだけそうなるように設計する。

assertメソッドはEmailBuilderでしか使わないのでprivateメソッドとして実装する。

final class EmailBuilder
{
    // ...プロパティ略...
    // ...セッター略...
    // ...buildメソッド略...

   private function assertAtLeastOneFromAddress(): void
    {
        if (empty($this->from)) {
            throw new \LogicException(
                'At least one from-address must be provided'
            );
        }
    }

    private function assertAtLeastOneRecipientAddress(): void
    {
        if (empty($this->to) && empty($this->cc)) {
            throw new \LogicException(
                'At least one recipient address (To or CC) must be provided'
            );
        }
    }

    private function assertSubjectIsProvided(): void
    {
        if ($this->subject === null) {
            throw new \LogicException('Email subject must be provided');
        }
    }

    private function assertThatBodyIsSpecified(): void
    {
        if ($this->body === null) {
            throw new \LogicException('Email body must be provided');
        }
    }
}

テストを書く

最後にPHPUnitでテストを書く。

Builderパターンのテストを書くコツは、ひとつひとつのメソッドに対してテスト(setTo()のテストなど)を書くより、build()までやって期待したオブジェクトが生成できるかどうかまで通貫したテストを書くことだ。そうしたほうが、Builderの利用者にとってサンプルコードになるし、パラメータの数だけテストケースを用意しないといけない地獄を味わわなくて済む。

use PHPUnit\Framework\TestCase;

final class EmailTest extends TestCase
{
    /**
     * @test
     */
    public function email_builder_usage(): void
    {
        // 1. create EmailBuilder object.
        $emailBuilder = new EmailBuilder();

        // 2. provide email properties.
        $emailBuilder->setFrom('alice@example.com');
        $emailBuilder->setTo('bob@example.com');
        $emailBuilder->setCc('carol@example.com');
        $emailBuilder->setSubject('Hello');
        $emailBuilder->setBody('Hello, Bob.');

        // 3. get Email object with calling build() method.
        $email = $emailBuilder->build();

        // The Email object will be like the following:
        self::assertSame(['alice@example.com'], $email->getFrom());
        self::assertSame(['bob@example.com'], $email->getTo());
        self::assertSame(['carol@example.com'], $email->getCc());
        self::assertSame('Hello', $email->getSubject());
        self::assertSame('Hello, Bob.', $email->getBody());
    }

    /**
     * @test
     * @testdox buildするには少なくともToかCCどちらかにメールアドレスがあれば良い
     */
    public function build_email_without_to(): void
    {
        $emailBuilder = new EmailBuilder();
        $emailBuilder->setFrom('dummy@sender');
        $emailBuilder->setCc('bob@example.com');
        $emailBuilder->setSubject('dummy_subject');
        $emailBuilder->setBody('dummy_body');
        $email = $emailBuilder->build();
        self::assertSame([], $email->getTo());
        self::assertSame(['bob@example.com'], $email->getCc());
    }
}

あとは、不変条件違反のテストケースを用意する。これで単体テストを書く作業が完了となる。

final class EmailTest extends TestCase
{
    // ...

    public function test_missing_sender_email_address(): void
    {
        $this->expectException(\LogicException::class);
        $this->expectExceptionMessage(
            'At least one from-address must be provided'
        );
        $emailBuilder = new EmailBuilder();
        $emailBuilder->setCc('dummy@address');
        $emailBuilder->setSubject('dummy_subject');
        $emailBuilder->setBody('dummy_body');
        $emailBuilder->build();
    }
}

今後のお題

今回は基本的なBuilderパターンの実装手順を紹介したかったので、紹介できなかったテーマがいくつかある。

  • EmailをEmailBuilder経由で作ることを強制する方法
  • Fluent Builder
  • イミュータブルなBuilder
  • Builderを使ったEmail生成方法の抽象化

関連

  1. 他の言語のように名前付きパラメータが使えればいいのですが……。

102
87
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
102
87

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?