Builderパターンとは、生成過程を抽象化したり、コンストラクタの引数が多かったり、コンストラクタの引数を作るのが複雑なときに使うデザインパターンのひとつだ。本稿では、引数が多すぎるコンストラクタの解消法のひとつとしてPHPでのBuilderパターンの実装方法を紹介する。
なお、本稿で実装したコードの完全版はGitHubで公開している。
お題のモデル
Builderパターンを実装するに当たってのお題として、Eメールを扱う。Eメールは、To, CC, From, Subject, Bodyの5つの情報を持っていることにする。これを素直にモデルにするとこうなる:
コンストラクタには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
を用意しよう。EmailBuilder
はEmail
のコンストラクタよりも明示的な表現ができるAPIを備える。
この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生成方法の抽象化
関連
-
PHP: Builderパターンの実装手順 #1【基礎実装】
この投稿
- PHP: Builderパターンの実装手順 #2【Fluent Interface実装】
- PHP: Builderパターンの実装手順 #3【Immutable Builder実装】
- PHP: Builderパターンの実装手順 #4【Builder経由での生成を強制する】
-
他の言語のように名前付きパラメータが使えればいいのですが……。 ↩