ビルダーパターンを使って、自作モジュールのオブジェクト構築してたのですが、ユーザー定義設定やモジュール外のサービスを利用する場合に不満が出てきました。
そこで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();
問題点
まず、コードが気に入らないです。
- やはり長い。
-
getter
やsetter
多すぎて使いにくい。 - どれが必要なオプションなのかわかりずらい。
- コードの見通しが悪いので、久々に修正するとき戸惑う。
その他、もう少し気になる点としては。
例えばテンプレートディレクトリはフレームワーク側のコンテナで設定すべき項目。今は、設定をビルダーに再設定する必要がある。できればコンテナーとビルダーで設定を共有したい。
また、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文で外から持ってきてたりします。
結論としては、利用者にはモジュール内のコンテナーを意識しないで使える設計にするのが一番良さそう、ということになります。
サービスプロバイダー標準化、まだ?
最初に考えたのは、サービスプロバイダーが標準化されるのを待つこと。
残念ながら、コンテナー用サービスプロバイダーの標準化は進んでいる気配がない。Pimple
もLeague/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つに分割されているので、フレームワークの機能が使えない可能性が出てきます。
仕方ないと言えばそうなのですが、現状で解決方法はない気がするなぁ。