【CakePHP】バージョンアップ時に気をつけたい、メジャーバージョン別コンポーネント初期化処理の違い

【CakePHP】バージョンアップ時に気をつけたい、メジャーバージョン別コンポーネント初期化処理の違い

『マイロ・マーフィーの法則』をみて自分を励ましている kagata です。

さて、今回は CakePHP のメジャーバージョンごとに挙動が変わってしまう機能を見つけたので、ここにご報告します。バージョンアップの際の参考になればと思います。

何が起こったか

しばらく前に、CakePHP1系で構築したアプリケーションを CakePHP2系に対応させる形でバージョンアップしたことがありました。しばらくは問題なく動作していたのですが、あるコンポーネントの動作に不具合が出ていることがあとになって発覚したのでした。

不具合の状況は次のとおりです:

  • あるコントローラ(仮に ExampleController としましょう)があるコンポーネント(MainComponent)を読み込んでいる
  • そのコンポーネントは別のコンポーネント(SubComponent)を読み込んでいる
  • コンポーネントから読み込まれたコンポーネントの挙動が CakePHP1系当時と CakePHP2へのバージョンアップ後で異なっている

検証のためのサンプルコード

ということで、上述した3つのオブジェクトの動きを CakePHP2系と1系で比較するためのサンプルコードを用意しました。また、Cake3系のコードもついでに作ってみました。やりたいことはどれも同じで CakePHP のバージョンが違うだけなのですが、得られる結果が異なるのが今回の肝です。

なお、以下のサンプルコードを動かすには Cakebox が便利です。Cakebox の詳しい使い方については以下の過去記事をご参照ください。

CakePHP1

<?php
// app/controllers/example_controller.php
class ExampleController extends Controller
{
    public $uses = null;
    public $autoRender = false;
    public $autoLayout = false;
    public $components = ['Main'];

    public function index()
    {
        $this->Main->dump();
    }
}
<?php
// app/controllers/components/main.php
class MainComponent extends Object
{
    public $components = ['Sub'];
    private $checks = [];

    public function initialize(&$controller)
    {
        $this->checks[] = __CLASS__ . ' is initialized';
    }

    public function startup(&$controller)
    {
        $this->checks[] = __CLASS__ . ' is started up';
    }

    public function dump()
    {
        debug($this->checks);
        $this->Sub->dump();
    }
}
<?php
// app/controllers/components/sub.php
class SubComponent extends Object
{
    private $checks = [];

    public function initialize(&$controller)
    {
        $this->checks[] = __CLASS__ . ' is initialized';
    }

    public function startup(&$controller)
    {
        $this->checks[] = __CLASS__ . ' is started up';
    }

    public function dump()
    {
        debug($this->checks);
    }
}

CakePHP2

<?php
// app/Controller/ExampleController.php
class ExampleController extends AppController
{
    public $uses = null;
    public $autoRender = false;
    public $autoLayout = false;
    public $components = ['Main'];

    public function index()
    {
        $this->Main->dump();
    }
}
<?php
// app/Controller/Component/MainComponent.php
class MainComponent extends Component
{
    public $components = ['Sub'];
    private $checks = [];

    public function initialize(Controller $controller)
    {
        $this->checks[] = __CLASS__ . ' is initialized';
    }

    public function startup(Controller $controller)
    {
        $this->checks[] = __CLASS__ . ' is started up';
    }

    public function dump()
    {
        debug($this->checks);
        $this->Sub->dump();
    }
}
<?php
// app/Controller/Component/SubComponent.php
class SubComponent extends Component
{
    private $checks = [];

    public function initialize(Controller $controller)
    {
        $this->checks[] = __CLASS__ . ' is initialized';
    }

    public function startup(Controller $controller)
    {
        $this->checks[] = __CLASS__ . ' is started up';
    }

    public function dump()
    {
        debug($this->checks);
    }
}

CakePHP3

<?php
// src/Controller/ExampleController.php
namespace App\Controller;

class ExampleController extends AppController
{
    public $uses = null;
    public $autoRender = false;
    public $autoLayout = false;
    public $components = ['Main'];

    public function index()
    {
        $this->Main->dump();
    }
}
<?php
// src/Controller/Component/MainComponent.php
namespace App\Controller\Component;

use Cake\Controller\Component;
use Cake\Event\Event;

class MainComponent extends Component
{
    public $components = ['Sub'];
    private $checks = [];

    public function initialize(array $config)
    {
        $this->checks[] = __CLASS__ . ' is initialized';
    }

    public function startup(Event $event)
    {
        $this->checks[] = __CLASS__ . ' is started up';
    }

    public function dump()
    {
        debug($this->checks);
        $this->Sub->dump();
    }
}
<?php
// src/Controller/Component/SubComponent.php
namespace App\Controller\Component;

use Cake\Controller\Component;
use Cake\Event\Event;

class SubComponent extends Component
{
    private $checks = [];

    public function initialize(array $config)
    {
        $this->checks[] = __CLASS__ . ' is initialized';
    }

    public function startup(Event $event)
    {
        $this->checks[] = __CLASS__ . ' is started up';
    }

    public function dump()
    {
        debug($this->checks);
    }
}

実験結果

上のサンプルコードを動かすと、それぞれ以下のスクリーンショットにあるとおりの結果が得られます。

CakePHP1

20170601-1.png

CakePHP2

20170601-2.png

CakePHP3

20170601-3.png

比較してわかること

  • コントローラのアクションメソッドに入った時点で、MainComponent::initialize()MainComponent::startup() はいずれのバージョンでも呼び出されている
  • 一方 SubComponent::startup() はいずれのバージョンでも呼び出されていない
  • そして SubComponent::initialize() は Cake1系と3系では呼び出されているのに2系では呼び出されていない

この SubComponent::initialize() の挙動の差異が、冒頭に書いた不具合の原因だったのです。

今回のことについて公式ドキュメントに書いてあること

今回の実験にかかわる CakePHP の仕様について、CakePHP の公式ドキュメントから関連する記述をまとめます。

1. コンポーネントには「コールバック」メソッドが用意されている

コンポーネントにはコントローラから明示的に呼び出す通常のメソッドとは別に、「コールバック」と呼ばれるメソッドが用意されています。コールバックは、コンポーネントを読み込むだけで特定のタイミングに自動で呼び出されます。 Controller::beforeFilter() に続いて呼び出される Component::startup() や、出力がブラウザに表示される前に呼び出される Component::shutdown() など、それぞれ呼び出されるタイミングが決まっています。

CakePHP1系のドキュメントにはコールバックについて独立した記載がありませんが、同じくコールバックメソッドが用意されています。

2. コンポーネントは他のコンポーネントを読み込める

コンポーネントはコントローラから読み込むのがもっともオーソドックスな使い方ですが、それだけでなくコンポーネントからさらに別のコンポーネントを呼び出すこともできます。

例えば、上の実験では MainComponent から SubComponent を呼び出しています。

3. コンポーネントが読み込んだコンポーネントのコールバックメソッドは自動で呼び出されず、明示的に呼び出さないといけない

上のリンク先にも注意書きがあるとおり、コンポーネントから読み込んだコンポーネントのコールバックは自動では呼び出されません。必要なタイミングで明示的に呼び出してやる必要があります。

コントローラから読み込んだコンポーネントと違い、コンポーネントから コンポーネントを読み込んだ場合は、コールバックが呼ばれないことに注意して下さい。

上の実験で SubComponent::startup() が一度も呼び出されなかったのが、これにあたります。

ドキュメントを読んでわかること

上でリンクした CakePHP3 と CakePHP2 のコールバック一覧を見比べると、2の一覧にある initialize メソッドが3のほうにないことに気づきます。しかし Cake2系に Component::initialize() がないわけではありません。 Component::initialize() はコンポーネントオブジェクトの初期化処理をになうメソッドとして、Cake1系から一貫して用意されています。

つまり、Component::initialize() は Cake2系では(ドキュメントがいうところの)「コールバック」だが Cake3系では「コールバック」でないということです。また、実は Cake1系の Component::initialize() は3系と同様「コールバック」です。そのため、コンポーネントから読み込んだコンポーネントの初期化処理は Cake2系では自動で呼び出されず、1系や3系では自動で呼び出されるというわけです。

実際には、コンポーネントのコールバックメソッドが呼び出される仕組みはメジャーバージョンごとに実装が異なっており、何をもって「コールバック」と呼ぶかはまちまちです。いずれにせよ、Component::initialize() の呼び出しと Component::startup() の呼び出しのロジックを見比べると、Cake2系でのみ同じ仕組みになっていることがわかります。

結局のところ、CakePHP1系で動かしたサンプルコードと同じ挙動を Cake2系で実現するには、以下のようにMainComponent::initialize() から SubComponent::initialize() を明示的に呼び出してやらないといけなかったわけです。


    // MainComponent
    public function initialize(Controller $controller)
    {
        $this->checks[] = CLASS . ' is initialized';
        $this->Sub->initialize($controller); // SubController::initialize() の明示的な呼び出しを追加!
    }

そしてこれを Cake3系にバージョンアップするときは、上で追加した SubController::initialize() の呼び出しを削除しないといけないのです。

移行ガイドも読む

実は、CakePHP1系から2系にバージョンアップしようとする人が必ず読むべき『2.0 移行ガイド』には、CakePHP1系と2系の違いについてこのような記述があります。

加えて、initialize()メソッドはコンポーネントが有効な時のみ呼び出されます。 これは通常、コントローラに直接付随したコンポーネントを意味します。

ここまで長々と書いてきたことは、この1文に集約されていたんですね。これを読んだだけで何をすべきか勘づいた人はいたのでしょうか。

また、『3.0 移行ガイド』には以下のような記述があります。

 Component::initialize() メソッドは、もはやイベントリスナーではありません。

「イベントリスナー」≒「コールバック」と思えれば、あるいは気づく人もいるのかもしれませんが…『2.0 移行ガイド』より難易度が上がっているような気がします。

まとめ

CakePHP において、コンポーネントから読み込んだコンポーネントの initialize メソッドの挙動はメジャーバージョンごとに異なります。バージョンアップの際には見落とさないようにしましょう。

  • このエントリーをはてなブックマークに追加

この記事を読んだ人にオススメ