CakePHPで動的にBehaviorを切り替えてModelの振舞いを変えるときはunload、loadを使う

CakePHP DocumentのBehaviorsの項にも書いてありますが、「ある時はこういう処理をして欲しいけど、違う時は別の処理をして欲しい」みたいな話。

やりたいこと

  • あるModelがあって、今はAという処理だけあれば良いのだけど、今後BやCという処理も増やしたい
    • 例えば「自動販売機」というModelがあって、今は「缶を売る」という処理だけだけど、今後「ビンを売る」「ペットボトルを売る」という処理も増やしたい

こんな時はデザインパターンStrategy パターンが使えそうなのだけど、CakePHPでやる時にはBehaviorを使えば似たようなことできるのかな?と思って調べて試してみた。

CakePHPのドキュメントを確認

Behaviorsのドキュメントを読むと、

However, we may need to “detach” behaviors from our models at runtime. Let’s say that on our previous Category model, which is acting as a Tree and a Translate model, we need for some reason to force it to stop acting as a Translate model:

<?php
// Detach a behavior from our model:
$this->Category->Behaviors->unload('Translate');

That will make our Category model stop behaving as a Translate model from thereon.

「実行時にmodelからbehaviorをdetachする必要があったらunloadすればその振舞いは止まるよ」と書いてある。
そして、

Just as we could completely detach a behavior from a model at runtime, we can also attach new behaviors. Say that our familiar Category model needs to start behaving as a Christmas model, but only on Christmas day:

<?php
// If today is Dec 25
if (date('m/d') == '12/25') {
    // Our model needs to behave as a Christmas model
    $this->Category->Behaviors->load('Christmas');
}

We can also use the load method to override behavior settings:

<?php
// We will change one setting from our already attached behavior
$this->Category->Behaviors->load('Tree', array('left' => 'new_left_node'));

「loadを使えば新しいbehaviorをattachすることもできて、設定も渡せるよ。」と書いてある。


なるほどなるほど。

ということで、具体的に試してみる

単純な例で試したかったので、Head Firstデザインパターンの1章にあるDuckの例(ModelDuck)を参考にした。

登場人物
  • Duckクラス
    • 色んな鴨の親クラスになる抽象クラス。
  • ModelDuckクラス
    • 鴨の実装。模型の鴨。飛ぶ手段がないためdefaultでは飛べず、performFly()すると「飛べません」と返す。
  • FlyNoWayBehaviorクラス
    • performFlyの振舞いの実装。fly()すると「飛べません」と返す。
  • FlyRocketPoweredBehaviorクラス
    • performFlyの振舞いの実装。fly()すると「ロケットで飛んでいます!」と返す。
  • ModelDuckTestクラス
    • 確認するためのテストクラス。
クラス図

ざっくりこんな感じ。ModelBehaviorは出て来ないけど、actsAsプロパティの配列の中身はModelBehaviorの実装なのでクラス図には入れた。

ソースコード

まずはテストコード。
基本はbakeで作られたテンプレートなので、「testModelDuckは飛ぶ振舞いが変わる」というmethodの所だけ実装。
やってることは、

  1. 初期は「飛べない」
  2. 「飛べない」振舞いをunloadし、「ロケットで飛べる」振舞いをloadし、振舞いを変える
  3. そうすると「ロケットで飛べる」

という確認だけ。このテストが通れば今回やりたいことはOK。

<?php
// app/Test/Case/Model/ModelDuckTest.php
App::uses('ModelDuck', 'Model');

class ModelDuckTestCase extends CakeTestCase {
	public $fixtures = array(
    );

	public function setUp() {
		parent::setUp();

		$this->ModelDuck = ClassRegistry::init('ModelDuck');
	}

	public function tearDown() {
		unset($this->ModelDuck);

		parent::tearDown();
	}

    public function testModelDuckは飛ぶ振舞いが変わる()
    {
        debug(__FUNCTION__);

        $this->_modelShouldNotFly();
        $this->_changeBehavior();
        $this->_modelShouldFly();
    }

    private function _changeBehavior()
    {
        $this->ModelDuck->Behaviors->unload('FlyNoWay');
        $this->ModelDuck->Behaviors->load('FlyRocketPowered');
    }

    private function _modelShouldNotFly()
    {
        $this->assertEquals('飛べません', $this->ModelDuck->performFly(), '飛べない状態になっているか');
    }

    private function _modelShouldFly()
    {
        $this->assertEquals('ロケットで飛んでいます!', $this->ModelDuck->performFly(), 'ロケットで飛べているか');
    }
}

次に具体的な鴨の親にあたるDuckクラス。abstrat。
fly()を呼ぶためのperformFly()が定義されている。具象の鴨を実装する時にactsAsまたはloadでBehaviorを読み込み、fly()の振舞いが決まる。

<?php
// app/Model/Duck.php
abstract class Duck extends AppModel {
    public function performFly()
    {
        return $this->fly();
    }
}

今回の具象クラスであるModelDuckクラス。
CakePHPの都合だけども、今回はDBを使わないのでuseTableはfalse。
あとは自分のfly()のdefault振舞いをFlyNoWay(飛べません)にする。

<?php
// app/Model/ModelDuck.php
App::uses('Duck', 'Model');

class ModelDuck extends Duck {
    public $useTable = false;
    public $actsAs = array('FlyNoWay');

    public function display()
    {
        return '模型の鴨です';
    }
}

「飛べない」振舞いを表すクラス。
setupはactsAsに指定した時やloadを実行した時に呼ばれる初期値。CakePHPのdefaultのままなので今回は特に何もしてない。

<?php
// app/Model/Behavior/FlyNoWayBehavior.php
class FlyNoWayBehavior extends ModelBehavior {
    public function setup(Model $Model, $settings) {
        if (!isset($this->settings[$Model->alias])) {
            $this->settings[$Model->alias] = array(
                'option1_key' => 'option1_default_value',
            );
        }
        $this->settings[$Model->alias] = array_merge(
            $this->settings[$Model->alias], 
            (array)$settings
        );
    }

    public function fly(Model $model)
    {
        return '飛べません';
    }
}

「飛べない」振舞いを表すクラス。
setUpはFlyNoWayBehavior同様。

<?php
// app/Model/Behavior/FlyRocketPoweredBehavior.php
class FlyRocketPoweredBehavior extends ModelBehavior {
    public function setup(Model $Model, $settings) {
        if (!isset($this->settings[$Model->alias])) {
            $this->settings[$Model->alias] = array(
                'option1_key' => 'option1_default_value',
            );
        }
        $this->settings[$Model->alias] = array_merge(
            $this->settings[$Model->alias], 
            (array)$settings
        );
    }

    public function fly(Model $model)
    {
        return 'ロケットで飛んでいます!';
    }
}

テストの実行

実行すると下のように上手くいきました。

% ./cakephp/lib/Cake/Console/cake -app app testsuite --tap app Model/ModelDuck

Welcome to CakePHP v2.0.4 Console
---------------------------------------------------------------
App : app
Path: /Applications/MAMP/htdocs/lib/app/
---------------------------------------------------------------
CakePHP Test Shell
---------------------------------------------------------------
TAP version 13
ok 1 - ModelDuckTestCase::testModelDuckは飛ぶ振舞いが変わる
1..1

まとめ

CakePHPでStrategyパターンみたいなことがやりたいときにはBehaviorのunloadとloadを使えば良さそう。
というか、それがBehaviorの元々の意図なんだろうけども。僕は今までそういう使い方してなかったので。