Posted at

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

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