ビルダーパターンをDIコンテナの移譲で書き直してみた


ビルダーパターンを使って、[自作モジュール](https://github.com/TuumPHP/Respond)のオブジェクト構築してたのですが、ユーザー定義設定やモジュール外のサービスを利用する場合に不満が出てきました。

そこでDIコンテナーと移譲（Delegation）を使って書き直してみたところ、コードがスッキリしつつ使い勝手も良くなった気がしたので、アウトプットしてみます。



１．問題
----

### どんなコンストラクター？

素のコードで書くと、こんな感じになる。

```php
$renderer = new TwigView(   // 1. 別オブジェクトを利用したい場合は？
    new Twig(
        'dir/template', [   // 2. ユーザーが設定をするには？
            'cache' => '/path/to/cache',
        ]))
);
$session = new SessionStorage();  // 3. 共通して利用されるオブジェクト
new Responder(
   　new View($renderer, $session),
   　new Redirect($session, isset($router) // 4. $routerは外部サービス…
        ? new NamedRoute($router)          // 4. NamedRouterも実は外部サービス…
        : null),
   　new Error($renderer, new ErrorView([  // 5. 設定を上書きしたい場合は？
        'error_dir' => 'errors',
        'status' => [404 => 'notFound', ],
    ])),
    $session,
);
```

作った本人すら使い切れない複雑なコンストラクター。


### ビルダークラス作って

少しでも使い勝手を良くするため、次のようなビルダークラスを作ってみました。

```php
class Builder {
	private $renderer;
	private $namedRoute;
	public function setRenderer(RendererInterface $renderer): self {
	   $this->renderer = $renderer;
	   return $this;
	}
	public function getView(): View {
		return $this->view ?: new view($this->renderer, $this->getSessionStorage());
	}
	public function setNamedRoute(NamedRouteInterface $namedRoute): self {
	    $this->namedRoute = $namedRoute;
	    return $this;
	}
	public function build(): Responder {
		return new Resopnder(
			$this->getView(),
			...
		);
	}
	// ...ずっと続く...
}
```

これが本当に[ビルダーパターン](https://ja.wikipedia.org/wiki/Builder_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3)かどうか、自信がないですが…動きます。使い方は、

```php
$responder = (new Builder())
    ->setRenderer('twig', [ // Twig使うことをしていしている…
        'dir/template', [   // Twig用の設定
            'cache' => '/path/to/cache',
    ]])->setErrorOptions([  // 設定を上書きしたい場合は？
        'error_dir' => 'errors',
        'status' => [404 => 'notFound', ],
    // ...ずっと続く...
    ])->build();
```


### 問題点

まず、コードが気に入らないです。

- やはり長い。
- `getter`や`setter`多すぎて使いにくい。
- どれが必要なオプションなのかわかりずらい。
- コードの見通しが悪いので、久々に修正するとき戸惑う。

その他、もう少し気になる点としては。

例えばテンプレートディレクトリはフレームワーク側のコンテナで設定すべき項目。今は、設定をビルダーに再設定する必要がある。できればコンテナーとビルダーで設定を共有したい。

また、TwigViewの代わりに自作クラスを登録する場合、オブジェクトにする必要がある。できればファクトリを登録して、必要なときに構築するのほうが望ましいかなと。



２．DIコンテナーとサービスプロバイダーで書き直す
------

先のビルダーを、DIコンテナとサービスプロバイダーに分解して書き直してみます。

### サービスプロバイダー

ここで言うサービスプロバイダーとは、コンテナに対してサービス（要はオブジェクトのこと）の構築方法を教えるクラスのこと。と理解すればいいようです。

例えば、[container-interop/service-provider](https://github.com/container-interop/service-provider)ではサービスプロバイダーの標準化の実験が行われたりします。

ここのコードを参考に、簡単に作成してみました。プロバイダーは、モジュール側で設定済みなので、利用する側が意識することはありません。

```php
class MyServices implements MyServiceInterface {
    /**
     * @return callable[]|mixed[]
     */
    public function getFactories() {
        return [
            Responder::class => [$this, 'getResponder'],
            View::class => [$this, 'getView'],
            // ...
        ];
    }
    public function getResopnder(ContainerInterface $c) {
        return new Responder(
            $c->get(View::class),
            $c->get(Redirect::class),
            $c->get(Error::class),
            $c->get(SessionStorageInterface::class)
        );
    }
    public function getView(ContainerInterface $c) {
        return new View(
            $c->get(RendererInterface::class), 
            $c->get(SessionStorageInterface::class)
        );
    }
    public function getTwig(ContainerInterface $c) {
        return new Twig($c->get('template_dir'), $c->get('twig_options'))
    }
    public function getRedirect(ContainerInterface $c) {
        return new Redirect(
            $c->get(SessionStorageInterface::class), 
            $c->get(NamedRouteInterface::class)
        );
    }    
    // ...    
}
```

### DIコンテナ

次はビルダーの代わりになるDIコンテナー。Pimpleでさえ複雑化しているので、自作。

```php
class MyContainer implements ContainerInterface {
    private $factories = [];
    public function __construct(MyServiceInterface $service) {
        foreach($service->getFactories() as $key => $factory) {
            $this->factories[$key] = $factory;
        }
    }
    public function get($key): bool {
        return ($this->factories[$key])($this);
    }
    public function has($key) {
        return array_key_exists($key, $this->factories);
    }
}
```

> エラー処理やファクトリと実態を分けて管理するコードなどは省略してます…

### 問題点

個人的には、サービスプロバイダーにはクラスの依存性が、DIコンテナーには実際の構築ロジックが、それぞれ定義されたことで、コードとして見通しがよくなったと思う。

が、しかし、もとの問題のフレームワーク側のコンテナで管理すべき設定や独自オブジェクトのサービスプロバイダーをどうするかという問題が残っています。

```php
$myContainer = new MyContainer();
$myContainer->set('template_dir', 'path/to/view'); // ユーザー設定
$myContainer->set('twig_options', [...]]);
$myContainer->set(
    NamedRoutesInterface::class, 
    function() use($router) { // 面倒なことに
        return new YourNamedRoutesResolver($router);
    });
```

さらに、例えば`YourNamedRoutesResolver`はユーザー定義オブジェクトなのですが、ファクトリを登録する際に面倒な事になってます。`$router`が必要なのですが、`$myContainer`は本モジュール以外は登録されていません。なので、この場合はuse文で外から持ってきてたりします。

結論としては、利用者にはモジュール内のコンテナーを意識しないで使える設計にするのが一番良さそう、ということになります。

### サービスプロバイダー標準化、まだ？

最初に考えたのは、サービスプロバイダーが標準化されるのを待つこと。

残念ながら、コンテナー用サービスプロバイダーの標準化は進んでいる気配がない。`Pimple`も`League/Container`などサービスプロバイダーはそれぞれ定義されているるが、同じコードを使い回すことができない。

まさか、フレームワークごとコンテナーごとに、モジュールの利用者が定義するのは不可能（開発者本人ですら忘れてるし）なので困っていた。

そこで移譲（Delegation）の出番となりました。

移譲（Delegation）
-------

最初にコンテナーの移譲（
Delegation）を知ったのは、やはり[Container-interopのcontainer-interop](https://github.com/container-interop/container-interop/blob/master/docs/Delegate-lookup-meta.md)レポジトリでした。

> 未だにちゃんと理解したか怪しいのですが、別コンテナをサービスプロバイダーとして利用する方法だと思います。移譲を実装しているコンテナーとしては[LeagueのContainer](https://container.thephpleague.com/3.x/delegate-containers/)がありますが、そのコードを見て初めて理解できたのでした。

で実装したら、

```php
class MyContainer implements ContainerInterface {
    private $factories = [];
    private $delegatedContainer;
    public function setContainer(ContainerInterface $container) {
        $this->delegatedContainer = $container;
    }
    public function get($key) {
        if ($this->delegatedContainer->has($key)) {
            return $this->delegatedContainer->get($key);
        }
        return ($this->factories[$key])($this);
    }
    public function has($key) {
        return $this->delegatedContainer->has($key) || array_key_exists($key, $this->factories);
    }
}
```

…動きました。

これで、想像よりはるかに簡単にフレームワーク側のコンテナー定義を利用することが可能になりました。

```php
// 適当にサービスを定義しておく
$fwContainer->add(Twig::class)
	->addArgument('template_dir')
	->addArgument(...); // 適当にサービスを定義しておく。
$fwContainer->add('template_dir', 'path/to/view');

// 移譲するためのコンテナーを設定
$myContainer = new MyContainer(new MyProvider);
$myContainer->setContainer($fwContainer);

// サービスを別コンテナーで利用する。
$responder = $myContainer->get(Responder::class); // Responderオブジェクトが返ってくる
```

最後に
-----

素晴らしい解決法に見えるコンテナーの移譲ですが、まだ問題が残ってます。このエントリーは試行錯誤している段階で書いています。

### 問題点：オートワイヤリング

コンテナの機能の一つにオートワイヤリング（Auto-wiring）があります。サービスの定義しなくても、クラスのソースコードを解析して必要そうな依存を注入してくれる便利な機能です。

移譲されたコンテナがオートワイヤリングが可能な場合。おそらく[すべてのクラスについて構築可能と返す](https://github.com/php-fig/container/blob/1.0.0/src/ContainerInterface.php#L26)と考えらます。つまり、`$container->get($id)`で、`$id`がクラス名なら、常に`true`を返します。

モジュール側で定義したクラスも、移譲したコンテナで構築することになります。ちょっと考えた限りでは、

- IDにインターフェースあるいは単なる文字列を利用する

のが解決方法としては簡単かなと思ってます。



### 問題点：コンテナが２つ

何を言っているのか分かりにくいですが…

フレームワークのコンテナには、様々な機能が存在する「可能性」があります。例えば、タグ機能やオブジェクトキャッシュなど。

コンテナが２つに分割されているので、フレームワークの機能が使えない可能性が出てきます。

仕方ないと言えばそうなのですが、現状で解決方法はない気がするなぁ。