CakePHPにDIコンテナが入った(る)と聞いて見学に行ってきました

ということがありまして、20201005現在で「4.next」に取り込まれているスティタスです!
※ 現行の4.1のパッチバージョンについてはmasterに向けられるので、 4.nextは「次のマイナーバージョン」である4.2を指します

CakePHPにDIコンテナが入ったらどんな感じに使われるんだろう?」というのは個人的にかねてより興味範囲でした。
そこで、このタイミングで「どうやって取り込まれたのかな?」を見てみようという試みです。

なお、本文中の英語で表記している単語(※略称を除く)はCakePHP中のクラスや名称を、カタカナで表記している単語は一般名称を指しているつもりです。

ちなみに!!!
今日はCakePHPのカンファレンスCakeFestのConference Dayでして、リードデベロッパーのMark Story氏の発表もありますからね!
本記事は、「ご本人の発表で答え合わせしたいな〜」という気持ちで、先に自分なりの文章を晒していくスタイルです。

cakefest.org

15:30 UTC

Dependency Injection in CakePHP
Mark Story
DESCRIPTION
Dependency Injection, and Dependency Injection Containers are some of the bigger buzzwords in PHP frameworks in the past few years. I'll be reviewing what dependency injection is at a high-level. Then we'll look at why CakePHP how dependency injection works in CakePHP without a container. In 4.2 we'll be adding an injection container for use in applications. I'd like to cover how that integration will work and give some guidelines/recommendations for how to use a Dependency Injection container in your application.

そもそもDI/DIコンテナ?

CakePHPと依存管理

CakePHPの中では、Table・Behavior・Component・View・Task・・といった色々なオブジェクトが、例えば「とある(コントローラーの)アクション実行するのに必要なんだよね!」といったように、「依存」されています。

これらのオブジェクト(インスタンス)の管理や、生成方法(必要なパラメータとか、処理すべき手続きとか)についてフレームワーク側が色々とお世話をしてくれることで、開発者 = フレームワークに対するユーザーである我々は簡潔で強力なアプリケーションコードを読み書きすることが出来るわけです。

例えば、ControllerとComponentの関係を見てみましょう。
Componentは、次のようなコードでControllerの中で生成・セットされています。

<?php
// https://book.cakephp.org/3/en/controllers/components.html

class PostsController extends AppController
{
    public function initialize()
    {
        parent::initialize();
        $this->loadComponent('Auth', [
            'authorize' => 'Controller',
            'loginAction' => ['controller' => 'Users', 'action' => 'login']
        ]);
        $this->loadComponent('Cookie', ['expires' => '1 day']);
    }

}

この$this->loadComopnent()が「Controller(=自分自身)にComponentをセットする」というメソッドです。
更にもう1歩踏み込んでみると、このloadComponent()ComponentRegistry::load()を呼んでいることが分かります。*1

Tableに対してはTableRegistry、Behaviorに対してはBehaviorRegistry、Helperに対してはHelperRegistry・・・といったように、それぞれのコンポーネントごとに対応したレジストリを用意し、レジストリパターンを用いた生成・管理戦術をとっています。

CakePHPでDI実践

このように、CakePHPで「何かを使う」時には「処理の内部から直接的にレジストリに働きかけている」という方法が多かったわけです。
それが、今回の改修で「外から入れる」ことも(部分的に)可能となっています。

Issueを見てみると、以下のように示されています。

<!-- ココから引用 -->

An example controller action using injection would be:

function index(RepositoryService $repos)
{
  // RepositoryService would be injected from the container.
  $records = $repos->findAll($user);
}

If a controller action accepts passed parameters, services would be appended to the end of the argument list:

function edit(int $pullRequestId, int $commentId, RepositoryService $repos)
{
  // method body.
}

<!-- /ココまで引用 (https://github.com/cakephp/cakephp/issues/14865) -->

なんと!
「引数の型にインターフェイスをしておけば、実行する側が依存の解決・注入をしてくれる」という風になりました。

例えば「特定の外部APIを叩く」ようなサービスなどは、この形で外から渡してしまえれば、アクションの責務から「サービス生成の知識」を追い出す事が出来ます。

詳しい利用方法については、ドキュメントも用意されつつありますので、そちらを。

book.cakephp.org

依存と利用

以上の2つは異なるアプローチをとってはいますが、いずれも「サービス(オブジェクト)を利用したい側」に対して「サービスを利用できるか・ちゃんと作れるか」の責任を負わせないようにする〜という実践方法の1つとして、制御の反転と呼ばれるものです。
それに際して、「利用したい対象を管理機構から自分で取得する」か「取得すら放棄して、外から渡してもらう」かという違いで、Dependency Injectionと呼ばれたりService Locatorと呼ばれたりするデザインパターンになっていきます。
詳しい話は↓の記事にて。
martinfowler.com

それにしても、「Interfaceを指定してあげただけで、メソッドが呼び出される頃には本当にオブジェクトが渡ってきている!」というのは不思議なものですね。
これまでのCakePHPでは、アクションメソッドの引数として受け取れるのは「Routerからpathとしてpassされた値(文字列)」のみでした。*2

この「不思議」の招待を暴くには、実際に「Controllerの生成」「アクションメソッドの呼び出し」を行っている箇所のdiffに注目してみる!!と良い訳です。

具体的には、ControllerFactory::invoke() の内容になります。
この辺りを、要点を掻い摘みながら眺めてみましょう
※ 該当箇所のフル差分は以下のPRを参照してください。
Add a dependency injection container by markstory · Pull Request #14945 · cakephp/cakephp · GitHub

まず、以前の実装であれば、アクションメソッドの呼び出しは次のような処理で実現されています。

<?php
$args = array_values($controller->getRequest()->getParam('pass'));
$controller->invokeAction($action, $args);

これが、「Diコンテナ対応」のCakePHPでは改修されている・・・つまり「依存性の注入を行って、処理を呼び出す」ようになります。

<?php
$args = [];
// ① 対象メソッドのReflectionを取得して
$reflection = new ReflectionFunction($action);
foreach ($reflection->getParameters() as $i => $parameter) {
    // ② 引数毎に型の指定を取得して
    $type = $parameter->getType();
    $typeName = $type instanceof ReflectionNamedType ? ltrim($type->getName(), '?') : null;
    // ③ コンテナから対象オブジェクトを取得し、引数リストに追加
    if ($typeName && $this->container->has($typeName)) {
        $args[$position] = $this->container->get($typeName);
    } elseif ($parameter->isDefaultValueAvailable()) {
      $args[$position] = $parameter->getDefaultValue();
  }
 $controller->invokeAction($action, $args);

すなわち、「メソッドの引数として指定されている型を、メソッド本体を実際に呼び出す前に解釈して、DIコンテナ(≒サービスローケータとしての)から依存性を用意する」ようになっている訳です。
この「依存性の解決」において、DIコンテナが活躍しているわけですね✨

CakePHPとDIコンテナの距離感

DIコンテナの導入は、元々4.1での実施が予定されていたものが延期されました。 Roadmapを見ると、 Experimental support for PSR11 compliant dependency injection container. と表現されています。(4.2でも表現は同じ)

Semantic Versioningでは「マイナーチェンジで互換性を破壊しない」ことが重要です。そして、この「実験的な提供」は4.1 -> 4.2という小さな変更の中で扱われました。
実際にDIコンテナの導入に伴う変更を見ても、4.1以前のアプリケーションとの互換性を破壊されていなさそうに思います。また、「ガッツリとフレームワークのコア部分」については踏み込んでいないような印象も受けました。

とはいえ!!
長らく予定されていた変更であることは事実です。
CakePHPにとってのDI(コンテナ)とは、どんな存在なんだろう?」を主観120%で考えてみたいと思います。

CakePHPとService Locator パターン

先にも少し触れたとおり、CakePHPにおいては依存性の解決に「依存する側が直接レジストリに呼びかける」というService Locatorっぽい*3方式を積極的に採用していました。

個人的には、これはCakePHPの性格や思想をよく表しているのかな?という気もしています。
「Model」「View\Helper」「Controller\Component」といったものは、フレームワークに規定された通りに用意されるであろう(べき)、そしてユーザーはフレームワークのデザインに従って必要なもの利用する・・・というのが、CakePHPの性格*4だと思っています。
そこから「使われ方」「作り方」「使う場面」についても均一化が狙えるので、割と”自由度の低い”レジストリに寄る一元管理を行うのは合理的ともいえるのではないでしょうか。

・・・と勝手に邪推しているのですが、今になって「DIコンテナを入れる」という判断をしている訳です。

CakePHPとDI、或いはインクリメンタルな導入

じゃあ何でDIコンテナを・・?というと、まず「DIをサポートしたいから(ユーザーに開放したいから)」「そのための手法としてDIコンテナを」という論法になっていると思います。

「CakePHP4でDIコンテナを入れる」という話を聞いた時に、まず「お、今のRegistryを置き換えていくのかな?」と個人的にはイメージしました。
が!!(将来はともかくとして)今の時点では、そのような選択はしなかった*5訳です。それどころか、既存のコアな部分をいじるような変更については避けているようにすら見えます。

ん〜〜、もしかして「DIコンテナ欲しい気がするけど今までの流れ変えたくないし。。」というビビりなのでしょうか・・・
「どのような目的でDI(コンテナ)をサポートしたのか?」というのが気になってくるところです。

そこでIssueを見てみます。

github.com

まず、「これまではDIコンテナをコアに必要としていなかったし、その辺りはService Locatorを活用していた」「CakePHP自体は直接的にDIコンテナを必要とはしないものの、アプリケーション開発者にとって有益な機能を提供し得る」と言及しています。

CakePHP has long been without a dependency injection container, as we haven't needed one in CakePHP core. Instead of using a DI container we rely on service locators for the ORM, global configuration and constructor/method parameters instead. While CakePHP hasn't needed a dependency injection container directly it would be useful for application developers.

では「本体は必要とする訳ではないけど開発者の利便性のために」というと、一体どんな風にどう折り合いをつけていくのか?
その辺りについては、"High Level Goals"で説明されています。

  • アプリケーションやプラグインが「サービス」を定義し、コントローラーやコマンドが要求したものを注入できるようにする
  • View, Helper, Table, Mailer, Behaviorについてはコンテナで管理しない
    • これらは(DIコンテナの利用者である)アプリケーション(層)から遠い場所で生成されるものであり、(依存)オブジェクトを持っていって注入するのが困難であるため

A dependency injection container in CakePHP should enable applications and plugins to define services and then have those services injected into controllers and commands as requested. Views, Helpers, Tables, Mailers and Behaviors will not have container services injected into them. These objects are generally created far away from the Application and thus hard to inject into. Furthermore, none of these class types are 'entrypoints' into an application and could have services passed into their methods as parameters by the top level 'entrypoint' classes (controllers and commands).

ということで、「開発者の領域の起点となる場所においてDIできる」ようなイメージなのかな、という気がしました。

  1. 雑にまとめると、 一般的に(webであれば) webroot/index.php -> Application::run() -> Controller::action() と処理が進みます。
  2. このうち、「Application::run()が実行される」ところまでがフレームワークのもたらす流れ(制御)と捉えると、開発者が初めて介入できる処理はinvokeAction()の対象となる(制御の逆転が発生してるポイント)という事になります。ここが「エントリーポイント」という見方ができそうです。
  3. そこを境界に、下流で使われる依存の注入は(入り口において)開発者に支配を渡す

・・・・みたいな説明だと苦しいですかね、どうでしょう。

とりあえず個人的には「今の時点でやりたかったこと」というのは、フレームワークの指定する「制約」は崩さずに、開発者に開放する「介入箇所」を拡大する!!というバランス感なのだ、と思っています。

CakePHPとDIコンテナ

CakePHPとしては、「フレームワーク側の規定」と「ユーザーの裁量」を同時に与えたいんだろうな、と思っています。
「んじゃ実際のDIコンテナ具体的にどれにすんのよ」という議論も当然ながら行われていますが、その点を踏まえて見てみると興味深いのです。

まず、「DIコンテナ自体は作成せずに、すでに世の中にあるものを再利用する」「候補はPSR-11に準拠しているもの」としています。

This proposal does not include the creation of a container implementation. Instead, we should re-use an existing container based on PSR-11. Using a PSR-11 container and coupling to those interfaces allow us to change container libraries more easily in the future if necessary.

また、PR中でも「league/containerにロックインされないで、任意のDIコンテナへの入れ替えが可能である」という状態を保とうという姿勢が見て取れます。

I'm not sure I like sticking to the League implementation of DIC, but this is fine by me as long as the interface is there just for the out-of-the-box plugin compatibility and that I can swap the container in my applications. (https://github.com/cakephp/cakephp/pull/14945#pullrequestreview-478336883)

であったり

We should probably make our own interface which extends league/container's interface and use that for typehints to be safe. (https://github.com/cakephp/cakephp/pull/14945/files#r479929630)
I didn't add a typehint as I was trying to keep the interface aligned with what is in league/container. (https://github.com/cakephp/cakephp/pull/14945/files#r480209332)

というのが、その例です。
「PSR-11になくてフレームワークが依存している機能」については、CakePHP内部にInterfaceを用意することで吸収しています。

github.com

これを利用すれば、デフォルトとなるleague/container以外のPSR-11対応DIコンテナ・・例えばPHP-DIやAura.Di、symfony/dependency-injectionといったフレームワークを採用できるはずです*6*7
(「実際にDIコンテナを入れ替えて動かしてみる」というのは本記事では扱いませんが、別の機会にやってみようと思っています*8 )

以前PHPerKaigiでの発表でも言及したように、CakePHPは現役のPHP-FIGのメンバーでもあります。
その事自体は、「CakePHPがあらゆるPSRを採用すること」を必ずしも強制しませんが*9、姿勢として、「CakePHPとしてのユニークな利便性・開発体験を追求する一方で、ユーザー(開発者)がPHPコミュニティの資産を活用していく事を歓迎している」という表れだと思います。
まさに「フレームワーク間の相互運用」と呼べる取り組みですね!

まとめ

書いてたら想像以上の長文になって疲れているのですが・・・ 😇
(ここまでお読みいただき、本当にありがとうございます!!)

長らくCakePHPを仕事・趣味で使ってきた身として、今回の変更はマイナーアップデートではあるものの「非常に印象的な変更だな」と思っています!

PHPに限らず、「Webアプリケーションに何が求められているか?」については、刻々と変化していっているように感じます。
その中で、「コンポーネントやレイヤー間の結合を疎にしたい」というのは、特に需要を増しているトピックの1つではないでしょうか。

過去に書いたブログ記事でも触れましたが、単純に見ると、やはりCakePHP3+以降は「少し難しいフレームワークになった」ようには感じます。それもまた、「時代に即したより良い姿」を求めた結果なのでしょう。
また、Railsとそのフォロワーに見られる「リレーショナルモデルを主役とした、データベース(テーブル)中心のアプリケーションを前提としてパワフルなDXを提供する1枚岩なMVC」だけではなく、アプリケーション側の「自治領域」の拡大を支援するようなツールも存在感を増しているように見えます。
その流れの中で、「クリーンアーキテクチャ」や「DI」といったコンセプトは、サーバーサイドアプリケーションを作る立場でも、より日常的に耳にするようになりました。

だからといって、決して「ユーザーにも一枚岩に作ることを要求するフレームワーク」の価値が汚される訳ではなく、まして「時代遅れ」な産物になるという話でもなく(というか何だソレ*10 )、チームやプロダクトに応じた「何をどうしたいか」を踏まえた選択が引き続き重要ではないでしょうか。

今回の変更は、色々な意味で興味深く楽しいものだなぁと感じました。
コンセプトレベルで考えると非常に大きな変化の導入だと思うので、恐らく4.2.x台やその後にもDIコンテナ周りはどんどんブラッシュアップされていくような気がします。
引き続き、進化するCakePHPをウォッチしていきたいですね!!

まだ追いきれていない部分があるのと、コードだったりIssueコメントを見ていてちょっと自信がねぇや・・って部分もあったので、もし良かったら是非是非ご感想・ご指摘くださいませ。お待ちしています!!

あとは元同僚かつ元上司の @itosho さんにも詳しい話・昂ぶる話をお任せしたいな〜、という気持ちを表明して本記事を締めます。

fortee.jp

*1:具体的な読み込み処理は https://github.com/cakephp/cakephp/blob/3.9.0/src/Core/ObjectRegistry.php#L74-L100

*2:ルーティング#アクションへのパラメーター渡し- 3.9 https://book.cakephp.org/3/ja/development/routing.html#id7

*3:これを「サービスロケータだ」と断言して良いのか?がいまいち確証を持てていません・・・やりたかったこととして、「任意のキー名をつけて保存して生成手続きを隠蔽できるようにレジストリを置きたい」っていうのが動機だったとすると、Service Locatorパターンとは(実装方法は同じでも)目的が少し違う・・・・?とかモヤモヤしています。引き続き勉強したい部分!

*4:この辺りのメリットは、以前に一緒に働いていたCTOの人がインタビュー記事で語ったりしているのでリンク貼ってみる。概ね同じ意見です https://www.wantedly.com/companies/connehito/post_articles/193980

*5:最終的にはどうなるんですかね〜!

*6:この辺り https://packagist.org/providers/psr/container-implementation

*7:余談ですけれど、PSR対応パッケージを探す際には https://zenn.dev/shin1x1/articles/26febea4736aaa6fd24f

*8:\Cake\Http/BaseApplication::getContainer(), \Cake\Http/BaseApplication::handle(), \Cake\Http/BaseApplication::register() / Cake\Console\CommandRunner:: __construct(), Cake\Console\CommandRunner::createCommand 辺りを見ると良いはず

*9: Member Projects are not required to implement any particular PSR, although are expected to give relevant PSRs due consideration. https://www.php-fig.org/bylaws/mission-and-structure/#member-projects

*10:流行り廃りとの付き合い方は決めておく必要があると思います