LoginSignup
9
4

More than 5 years have passed since last update.

PHP: Builderパターンの実装手順 #4【Builder経由での生成を強制する】

Last updated at Posted at 2019-02-25

このBuilderパターン実装シリーズではPHPでどのようにBuilderパターンを実装するのかを解説する。前回は、ImmutableなBuilderを実装する方法を解説した。

前回: PHP: Builderパターンの実装手順 #3【Immutable Builder実装】 - Qiita

今回は、生成対象のオブジェクトをBuilder経由でのみ生成できるようにする方法を紹介する。

本稿で紹介する方法は、PHPの言語仕様の限界にチャレンジする側面があるので、実装としてはトリッキーで必ずしも美しくないかもしれないが、オブジェクトをBuilder経由でのみ生成させたいときには「こういった方法もあるよ」ということを示したいと思う。

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

なぜBuilder経由でのみ生成できるようにしたいか?

前回までに作ってきたEmailクラスはnew EmailすることでEmailBuilderを使わなくても生成することが可能だった:

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

このコードは動作するし、問題なさそうだ。では、なぜnew Emailをさせたくないのか? そのモチベーションはいくつか考えられる。

  • コードの一貫性: すべての生成をEmailBuilder経由にすることで、実装のゆれをなくしたい。ここはnewで、ここはビルダーで、となっていると読み手は混乱するし、ひとつのことを複数の方法でできるようになっているのは美しくない。
  • バグりにくさ: new Emailは引数が多く、注意して書かないとバグを生みやすい。そうならないよういするためにビルダーを作っている。
  • ビルダーへの誘導: EmailBuilderという便利なクラスがあることに気づかなければ、new Emailを頑張って書いてしまうだろう。コード上でnew Emailできないようになっていれば、EmailBuilderを自然と使ってくれるだろう。
  • 保守性の観点: EmailBuilderを通じて生成されるよう一貫したコードになっていれば、EmailBuilderをメンテするだけで生成の処理をアップデートしていける。もし、newが併用されていたら、どちらもメンテしていかなければならない。

new Emailを封じる

ビルダー経由でのみEmailオブジェクトを作れるようにするための第一歩は、new Emailを実行できないようにすることだ。そのためには、Emailクラスのコンストラクタをプライベートメソッドにする。public function __construct()となっていた部分をprivate function __construct()に変更しよう。そうすれば、Emailクラス以外からnew Emailは呼び出せなくなる:

final class Email
{
    // ...略...
    private 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だけはnew Emailできるようにする

このままだと、EmailBuilderまでもnew Emailできない。それだと、意味のないコードになってしまうので、EmailBuilderだけは、new Emailできるようにする。

PHPの言語仕様上、そういったことをできるようにする都合のいい構文はない1ので、トリッキーな方法だがプライベートコンストラクタをClosureを使って呼び出す小技を使うことにする。

まずは、EmailクラスにEmailBuilderを返す静的メソッドを生やす:

final class Email
{
    // ...略...
    public static function builder(): EmailBuilder
    {
        return new EmailBuilder();
    }

    // ...略...
}

EmailBuilderが間接的にnew Emailできるように、EmailクラスのコンストラクタをEmailBuilderに渡すようにする:

    public static function builder(): EmailBuilder
    {
        $constructor = function (
            array $from,
            array $to,
            array $cc,
            string $subject,
            string $body
        ) {
            return new Email($from, $to, $cc, $subject, $body);
        };
        return new EmailBuilder($constructor);
    }

この変更はクライアントコードにも影響がある。これまでは、new EmailBuilder()でビルダーを作っていたが、これからはEmail::builder()でビルダーを作る必要がある。

// 旧コード
$email = (new EmailBuilder())
    ->setFrom('alice@example.com')
    ->setTo('bob@example.com')
    ->setCc('carol@example.com')
    ->setSubject('Hello')
    ->setBody('Hello, Bob.')
    ->build();

// 新コード
$email = Email::builder()
    ->setFrom('alice@example.com')
    ->setTo('bob@example.com')
    ->setCc('carol@example.com')
    ->setSubject('Hello')
    ->setBody('Hello, Bob.')
    ->build();

EmailBuilderEmailのコンストラクタを受け取れるようにする

次に、EmailBuilderを変更して、Emailのコンストラクタを受け取れるようにする。EmailBuilderクラスにcallable型の引数を受け取るコンストラクタ追加し、それを$emailConstructorに代入するようにしよう:

final class EmailBuilder
{
    /**
     * @var callable
     */
    private $emailConstructor;

    // ...略...

    public function __construct(callable $emailConstructor)
    {
        $this->emailConstructor = $emailConstructor;
    }
    // ...略...
}

new Emailは直接呼び出せなくなったので、EmailBuilder::buildは受け取ったコンストラクタを使うように変更する:

    public function build(): Email
    {
        $this->assertAtLeastOneFromAddress();
        $this->assertAtLeastOneRecipientAddress();
        $this->assertSubjectIsProvided();
        $this->assertThatBodyIsSpecified();
        return ($this->emailConstructor)( // new Emailしていた箇所
            $this->from,
            $this->to,
            $this->cc,
            $this->subject,
            $this->body
        );
    }

以上で、EmailBuilderの対応は完了だ。

テストコードを変更する

前回までに作ったテストコードではnew EmailBuilder()と書いていたが、今回の変更でEmail::builder()に書き換える必要が出てきたのでその対応をする。対応箇所は数が少なくないのが、エディタの一括置換で直せば良さそうだ。

use PHPUnit\Framework\TestCase;

final class EmailTest extends TestCase
{
    /**
     * @test
     */
    public function email_builder_usage(): void
    {
        $email = Email::builder() // これまでは (new EmailBuilder()) となっていた
            ->setFrom('alice@example.com')
            ->setTo('bob@example.com')
            ->setCc('carol@example.com')
            ->setSubject('Hello')
            ->setBody('Hello, Bob.')
            ->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());
    }
    // ...略...
}

すべて変更したら、テストが通るかチェックしよう。

リファクタリングする

テストがすべて通ることを確認したら、テストを回しながらEmail::builder()をリファクタリングしていこう。下記のコードはもっとシンプルにすることができるからだ:

    public static function builder(): EmailBuilder
    {
        $constructor = function (
            array $from,
            array $to,
            array $cc,
            string $subject,
            string $body
        ) {
            return new Email($from, $to, $cc, $subject, $body);
        };
        return new EmailBuilder($constructor);
    }

まず、一時変数を無くす。

    public static function builder(): EmailBuilder
    {
        return new EmailBuilder(
            function (
                array $from,
                array $to,
                array $cc,
                string $subject,
                string $body
            ) {
                return new Email($from, $to, $cc, $subject, $body);
            }
        );
    }

次に、Emailselfに置き換えられるのでそれも直す:

    public static function builder(): EmailBuilder
    {
        return new EmailBuilder(
            function (
                array $from,
                array $to,
                array $cc,
                string $subject,
                string $body
            ) {
                return new self($from, $to, $cc, $subject, $body);
            }
        );
    }

new selfへの引数はクロージャの引数をそのまま渡せばいいので、可変長引数を使うように変える:

    public static function builder(): EmailBuilder
    {
        return new EmailBuilder(
            function () {
                return new self(...func_get_args());
            }
        );
    }

これで最もシンプルな記述になった。

最後に、new EmailBuilder()に渡す変数が何なのか読み手に伝わりやすくするために、クロージャーを作る処理を適切な名前をつけたプライベートメソッドに切り出す。ここでは切り出したメソッドはgetEmailConstructorと名付ける。

final class Email
{
    // ...略...

    public static function builder(): EmailBuilder
    {
        return new EmailBuilder(self::getEmailConstructor());
    }

    // ...略...

    private static function getEmailConstructor(): callable
    {
        return function () {
            return new self(...func_get_args());
        };
    }
}

クロージャー構文を直接EmailBuilderのコンストラクタに書くよりも説明的になったのではないだろうか。

これで、リファクタリングは完了となる。

今回のパターンの問題点

今回紹介したパターンは、確かにビルダー経由での生成を強制することに成功したが、問題点がまったくないわけではない。

  1. まず、トリッキーである。クロージャを使ってprivateコンストラクタにアクセスしているので初見殺し感はいなめない。
  2. 次に、EmailとEmailBuilderが密結合になっている。そして循環依存になっている。本当ならEmailがEmailBuilderを知らないほうが設計としては優れている。

これらの問題があるが、PHPにおいてはこれはトレードオフだと思うので、問題点を理解した上で使う分には有りだと思う。

まとめ

  • Builderパターンの実装において、生成対象のオブジェクトをBuilder経由でのみ生成できるようにする方法を紹介した。
  • それに当たってのテストコードの変更方法を説明した。
  • 紹介した方法にある問題点と、それにはトレードオフがあることを説明した。

関連


  1. 他の言語だと、例えばJavaのinner classやScalaのスコープを指定したprivate修飾子があったりするが、PHPにはそれに該当する機能がない。 

9
4
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
9
4