PHP
DependencyInjection
container
PSR-11
BuilderPattern

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

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

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

1.問題

どんなコンストラクター?

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

$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,
);

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

ビルダークラス作って

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

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(),
            ...
        );
    }
    // ...ずっと続く...
}

これが本当にビルダーパターンかどうか、自信がないですが…動きます。使い方は、

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

問題点

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

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

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

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

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

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

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

サービスプロバイダー

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

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

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

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でさえ複雑化しているので、自作。

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コンテナーには実際の構築ロジックが、それぞれ定義されたことで、コードとして見通しがよくなったと思う。

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

$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文で外から持ってきてたりします。

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

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

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

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

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

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

移譲(Delegation)

最初にコンテナーの移譲(
Delegation)を知ったのは、やはりContainer-interopのcontainer-interopレポジトリでした。

未だにちゃんと理解したか怪しいのですが、別コンテナをサービスプロバイダーとして利用する方法だと思います。移譲を実装しているコンテナーとしてはLeagueのContainerがありますが、そのコードを見て初めて理解できたのでした。

で実装したら、

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);
    }
}

…動きました。

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

// 適当にサービスを定義しておく
$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)があります。サービスの定義しなくても、クラスのソースコードを解析して必要そうな依存を注入してくれる便利な機能です。

移譲されたコンテナがオートワイヤリングが可能な場合。おそらくすべてのクラスについて構築可能と返すと考えらます。つまり、$container->get($id)で、$idがクラス名なら、常にtrueを返します。

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

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

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

問題点:コンテナが2つ

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

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

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

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