Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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

そこで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つに分割されているので、フレームワークの機能が使えない可能性が出てきます。

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした