Displaying posts filed under

CakePHP

グローバル関数のMockを作る

CakePHP 1.3 + SimpleTestでグローバル関数をMock化するためのラッパークラスを作ってみました。libs/ に置いて使用します。

global_function.php – gist

次のように任意の関数を呼び出せます。

//bootstrap.php 等で読み込んでおく
App::import('Lib', 'GlobalFunction');
 
GlobalFunction()->env('REMOTE_ADDR');
GlobalFunction()->gethostbyname('example.com');

テストケースでMockを使用したいとき、GlobalFunction::registerMock を呼び出します。戻り値はMockオブジェクトです。

function testRegisterMock() {
    //使用する関数名を引数に渡す
    $mock = GlobalFunction::registerMock('env', 'gethostbyname');
 
    //通常のmockと同様に使用できる
    $returnValue = '192.168.0.1';
    $mock->expectOnce('env', array('REMOTE_ADDR'));
    $mock->setReturnValue('env', $returnValue, array('REMOTE_ADDR'));
    $mock->expectNever('gethostbyname');
 
    $this->assertEqual($returnValue, GlobalFunction()->env('REMOTE_ADDR'));
 
    //ClassRegistry::flush を呼び出せば元に戻る
    ClassRegistry::flush();
 
    //本物の関数envが呼ばれる
    $addr = GlobalFunction()->env('REMOTE_ADDR');
}

注意:参照を引数に取る関数(array_push など)は使用できません。

第5回CakePHP勉強会に参加してきました

「CakePHP TOKYO 2010.5.29」ロゴ入りのマシュマロ

会場で配られたマシュマロ

5月29日の『第5回CakePHP勉強会』に参加してきました。

京都から東京まで移動するため、今回初めて夜行バスを使ったのですが……失敗でした。想像以上に揺れがひどくて椅子が堅くて、ほとんど眠れなかったのです。おまけに東京はあまり天気がよくなくて、汗だくになったかと思うと寒くて震えるというような気温の変化が連続して、勉強会会場にたどり着いたときには既に満身創痍という有様でした。

そんなこんなで、プレゼンのたび電気が消されるたびに意識が飛びそうになるのを懸命に堪えつつ、きっちり懇親会まで参加してきました。その感想です。

技術ネタ

全部は書ききれないので、私がコメントできそうなものを選んで感想を書いています。プログラムの全体はEvent Entry::第5回CakePHP勉強会@TokyoおよびMASA-Pさんのブログ記事をご覧ください。

CakePHP1.3の概要 市川さん(@cakephper)

betaのころから1.3を使っていたので、発表中いくつかの機能については「そういえばこれは1.3の新機能だったな」と妙な感慨を持って聞いていました。それだけ1.3の新機能は利便性が高く、一度使えば当たり前になるということです。

一方で、それら新機能の実践的なノウハウについてはまだまだこれから発見・共有されていく段階だな、とも感じました。例えば当日 #cakephpstudy のTLでは「libsとverndorsの使い分けはどうするの?」という疑問が流れていましたし、スライド中routeClassの例として『Using custom Route classes in CakePHP』から転載されたコードは、直接Modelをnewしているためにテスタビリティに難があります。そうしたノウハウはいずれこのブログなどでもまとめていければと思います。

Ktai Library on CakePHP1.3 滝下さん(@ecworks_masap)

幸か不幸かまだ本格的な携帯サイトを作った事はないのですが、将来作るとなったらほぼ間違いなくお世話になるであろうライブラリなので、興味深く聞くことができました。最新版ではktai-devという形でライブラリ統合済のCakeコアも配布しているとのことで、もはや一ライブラリというよりも「CakePHPをベースとした用途特化型フレームワーク」に近づきつつあると言えそうです。それすなわち、CakePHP 1.xの成熟度が一定のレベルに達した証左だと言えるかもしれません。

ただそうして「ライブラリ」がカバーする範囲が大きくなればなるほど、ソフトウェアとしての Responsibility も大きくなります。その点については懇親会で直接お話しさせていただく機会があったのですが、やはり既存のライブラリ・サービスとの兼ね合いや、本来キャリアが提供すべき情報をどこまでサポートすべきかといった点について苦労されているようでした。

コアライブラリのエレガントなハック 清水紘己さん(@hiromi2424)

「Cakeコアの構成要素をいかにエレガントに置き換えるか」という、なかなか聞く人を選ぶ話題でした。個人的には大好物です。特にコンポーネント初期化処理をハックする苦労を味わった同士として、懇親会では固い握手を交わさせていただきました。

CakePHP2.0の概要を見たときに「エイリアス」というのがイマイチ何なのかピンと来なかったのですが、この発表を聞いていてよく理解できました。静的型付言語でいうと置き換えられる「名前」がインターフェイスを規定し、置き換えるクラスが実装クラスという事になります。依存関係が生ずる対象がクラスではなく「名前」なので、コア内部の依存関係であっても後から置き換える事が可能になります。

「CakePHPでjQueryを使ってみた(仮)」 – @nano_eightさん

CakePHPでjQueryを使うなら下手にHelperなど使うよりも直接JavaScriptを書いた方が早い……と考えていたので、flashとelementの組み合わせ+SCRIPTタグべた書きという手法は目から鱗でした。Cake/PHP, JavaScriptそれぞれを分けて考えるのではなく、トータルで最も効率の良い方法をこそ考えていくべきなんだな、と認識を改めました。

「WordPressの管理画面のプラグインにCakePHPを使う」- 原さん(@kara_d)

「あのCMS eZ publishをCakePHPのModelにしちゃう」- @leebennyさん

他のPHP製ソフトウェアとCakePHPを組み合わせて使用する、というアイデアは多くの人が検討したことがあるだろうと思います(私もあります)。@kara_dさんのWordPressプラグインではDispatcherを無効にしたindex.phpを読み込むという大胆な手法で、@leebennyさんのeZ publishではDataSourceを実装するという正攻法で、これを実現されていました。いずれも他のソフトウェアに応用可能な方法だろうと思います。またCakeとは直接関係ありませんが、eZ publishの概略について学べたのも良かったです。

会場・コミュニティの雰囲気など

発表の様子は常時Ustreamで配信され、全国各地のサテライト会場に中継がつながっており、海外からの発表がSkype経由で行われ、会場では記念ロゴ入りのマシュマロが配られ……「コミュニティーベースの勉強会でここまでのことができるようになったのか!」と感嘆せずにはいられませんでした。

この種の勉強会に参加するのが初めてということもあり、正直なところ、ちょっと距離感を掴むのに戸惑ったというのも事実です。ただ懇親会で近くで飲んでいた@nano_eightさんのところに「プレゼン良かったですよ!」と多くの人が集まるのを見て「うらやましいなー」と思ったのもまた事実で、次の機会にはぜひ発表者として参加しよう!と思いました(実は今回もLTのお誘いは受けていたのですが、忙しさと不慣れを理由に断っていたのです)。


……

東京散策ではiPhoneが超役に立ちました。慣れない都市で迷わずに動き回るコツは「自分の勘を一切信用しないこと」なのです。午前中は国立新美術館で開催中の「オルセー美術館展」を堪能しました。モネの『ロンドン国会議事堂、霧の中に差す陽光』とゴッホの『星降る夜』には魂が震えました。もう一回見に行きたいくらいです。久しぶりに歩き回った東京は広々としていました。京都と比べればほとんどどんな土地も広々と感じるのですが、東京も例に漏れず、ずいぶんと空間に余裕があるなあ、と思いました。

find(‘list’) で集約関数を使用する方法

単に field パラメータに指定するだけではうまく動作しないので、一旦 find(‘all’) で結果を取得してから Set::combine を使って同等の構造を得る。

// user_id => 件数
$tmp = $Post->find('all',
                   array('fields' => array('user_id', 'COUNT(*) AS posts_count'),
                         'group'  => 'user_id'));
return Set::combine($tmp, '{n}.Post.user_id', '{n}.0.posts_count');

Set::combine は恐ろしく多機能なので、find(‘list’) をそのまま使うよりずっと多くの事ができる。例えば次のように、複数カラムで GROUP BY した結果をフラットな配列にまとめることができる。

// "user_id:category_id" => 件数
$tmp = $Post->find('all',
                   array('fields' => array('user_id', 'category_id', 'COUNT(*) AS posts_count'),
                         'group'  => array('user_id', 'category_id')));
return Set::combine($tmp,
                    array('{0}:{1}', '{n}.Post.user_id', '{n}.Post.category_id'),
                    '{n}.0.posts_count');

値を連想配列にする事もできる。

// user_id => array("created_max" => 最大値, "created_min" => 最小値)
$tmp = $Post->find('all',
                   array('fields' => array('user_id',
                                           'MAX(created) AS created_max',
                                           'MIN(created) AS created_min'),
                         'group'  => 'user_id'));
return Set::combine($tmp, '{n}.Post.user_id', '{n}.0');

メールの送信をテストする

環境: CakePHP1.3

ComponentTestCaseを使ってメールの送信をテストする方法をまとめておきます。Qdmailerを使うことを前提としていますが、標準のEmailComponentなど他のコンポーネントでも同じようにテストできると思います。

単純なケース

メール送信をテストする上で最も重要なことは、メールの種類ごとにコンポーネントを作るということです。

まずメール送信コンポーネントに共通の振る舞いを定義するため、以下のような Mailer クラスを app/libs に作ります。 実際のインターフェイスや初期化処理などは自分のアプリケーションに合わせて調整してください。

abstract class Mailer extends Object
{   
  var $components = array('Qdmailer.Qdmailer');
  var $_controller;
  var $_settings;
 
  function initialize($controller, $settings=array()) {
    $this->_controller = $controller;
    $this->_settings = $settings;
  }
 
  function create() {
  }
 
  protected function _rawSend() {
    $this->Qdmailer->send();
  }
 
  function send() {
    $this->Qdmailer->resetHeaderBody();
    $args = func_get_args();
    $this->dispatchMethod('create', $args);
    $this->_rawSend();
  }
}

次にメールの種類に合わせたメール送信用コンポーネントを作ります (他のコンポーネントとの混在を防ぐために components/mailers などサブディレクトリに作成することをおすすめします)。

先ほど作った Mailer クラスを継承して create メソッドを実装します。 メールの送信に使用する値は、create の引数で受け渡すよりは、 コントローラの viewVars 経由で受け取る方が良いと思います(メールテンプレートのレンダリングでも必要になるので)。

App::import('Lib', 'Mailer');
 
class RegisterRequestMailerComponent extends Mailer
{
  function create() {
    $email = $this->_controller->viewVars['User']['email'];
 
    $q = $this->Qdmailer;
    $q->resetHeaderBody();
    $q->to($email);
    $q->subject('ユーザー登録が完了しました');
    $q->cakeText('', 'register');
  }
}

単体テストではこの create メソッドをテストします(Mailerクラスに対するテストも書く必要がありますが省略)。 特定の入力(引数、viewVars)に対して宛先、送信元、そして本文などが期待通りに作成できるかどうかをテストします。

class RegisterMailerComponentTest extends ComponentTestCase
{
  var $components = array('RegisterMailer');
 
  function testCreate() {
    //viewVarsの登録
    $data = array('User' =>
                  array('id' => 123,
                        'username' => 'xxxxxx',
                        'email' => 'abc@example.com'),
                  'login_url' => 'http://example.com/register');
    $this->Controller->set($data);
 
    $this->RegisterMailer->create();
    $q = $this->RegisterMailer->Qdmailer;
 
    //本文
    $body = mb_convert_encoding($q->content['TEXT']['CONTENT'],
                                'utf-8', $q->charset_content);
    $this->assertTrue(strpos($body, $data['login_url']) !== false);
 
    //宛先など
    $this->assertEqual(1, count($q->to));
    $this->assertEqual($data['User']['email'], $q->to[0]['mail']);
  }
}

このコンポーネントを使用するコントローラからは、send メソッドを呼ぶだけです。

class UsersController extends AppController
{
  var $components = array('RegisterMailer');
 
  function finish() {
    //...
    if($this->User->save()) {
      $this->RegisterMailer->send();
    }
  }
}

より複雑なケース

次に一度に複数のメールを送る場合について考えてみます。 例えば登録完了メールをユーザと管理者に送るとしましょう。 この場合は「ユーザ宛メール(UserRegisterMailer)」と「管理者宛メール(AdminRegisterMailer)」の他に、 それらをまとめる「RegisterMailer」という3つの Mailer クラスを作成します。

UserRegisterMailer と AdminRegisterMailer は単純な例と同じように作ってテストしてください。 RegisterMailer では $components 配列を使ってそれら2つを読み込み、送信処理を委譲します。

App::import('Lib', 'Mailer');
 
class RegisterMailerComponent extends Mailer
{
  var $components = array('UserRegisterMailer',
                          'AdminRegisterMailer');
 
  function sendUserMail() {
    //viewVarsの加工処理など...
    $this->UserRegisterMailer->send();
  }
 
  function sendUserMail() {
    //viewVarsの加工処理など...
    $this->AdminRegisterMailer->send();
  }
 
  function send() {
    $this->sendUserMail();
    $this->sendAdminMail();
  }
}

このクラスに対するテストはモックを使って行うことができます。 本文や宛先の生成処理は移譲先でテストされているので、 移譲先に対する入力の形式と、適切なメソッドの呼び出しが行われていることをテストすれば十分です。

App::import('Component', 'UserRegisterMailer');
App::import('Component', 'AdminRegisterMailer');
Mock::generate('UserRegisterMailerComponent');
Mock::generate('AdminRegisterMailerComponent');
 
class RegisterMailerComponentTest extends ComponentTestCase
{   
    var $components = array('RegisterMailer');
 
  function startTest($m) {
    parent::startTest($m);
 
    $this->RegisterMailer->UserRegisterMailer
	  = new MockUserRegisterMailerComponent();
 
    $this->RegisterMailer->AdminRegisterMailer
	  = new MockAdminRegisterMailerComponent();
  }
 
  function testSendUserMail() {
    $this->Controller->set(array(/* ... */));
 
    $this->RegisterMailer->UserRegisterMailer->expectOnce('send');
    $this->RegisterMailer->sendUserMail();
    //...$this->Controller->viewVars が適切に加工されていることなどを確認...
  }
 
  function testSend() {
    $this->RegisterMailer->UserRegisterMailer->expectOnce('send');
    $this->RegisterMailer->AdminRegisterMailer->expectOnce('send');
    $this->RegisterMailer->send();
  }
}

このコンポーネントの使い方は「単純な場合」と全く同じで、コントローラは一切変更する必要がありません。

複雑なコンポーネントの単体テスト

環境: CakePHP1.3

単純な場合のテスト方法はクックブックに載っていますが、 より複雑な場合(コントローラや他のコンポーネントと連携する場合)は多くの準備作業が必要です。

その作業を省略するための ComponentTestCase クラスを作りました。ソースコードはとりあえずgistに置いておきます。

component_test_case.php

適当な場所 app/tests/libs などに置いてテストファイルの中で読み込んでください。

Fooコンポーネントをテストする場合の例は次のようになります。

require_once (TESTS .'libs/component_test_case.php');
 
class FooTestCase extends ComponentTestCase
{
  //コントローラの $components と全く同じ書式
  var $components = array('Foo' => array('a' => 'b'));
 
  function testAdd() {
    //$this->Controller でコントローラにアクセスできる
    $this->Controller->set(array('var1' => 1, 'var2' => 2));
 
    //$this->{コンポーネント名} でコンポーネントのインスタンスにアクセスできる
    $this->assertEqual(3, $this->Foo->add());
 
    //Fooの中で別のコンポーネントBarを読み込むことも可能
    //$components = array('Bar');
    $this->Foo->Bar->barmethod();
  }
}

モデルを使用することもできます。

class BarTestCase extends ComponentTestCase
{
  var $components = array('Bar');
  var $uses = array('User');
  var $fixtures = array('app.user');
 
  function testSomething() {
    //Barの中で、Controller経由でモデルにアクセスできる
    //$this->Controller->User
    $result = $this->Bar->doSomething();
  }
}

初期化パラメータを変えてテストしたい場合は _createController メソッドを使って直接 Controller を生成します。

class FooTestCase extends ComponentTestCase
{
  function testAdd() {
    $controller = $this->_createController(array('Foo' => array('c' => 'd')));
    $controller->set(array('var1' => 1, 'var2' => 2));
 
    //$this->{コンポーネント名} でアクセスすることはできない
    $this->assertEqual(3, $controller->Foo->add());
  }
}

デフォルトの初期化は startTest メソッドの中で行われるため、オーバーライドすることで柔軟な設定が可能です。 例えばメソッド名に _disableAutoLoad が入っているときだけ自動処理を停止する、といったことができます。

class FooTestCase extends ComponentTestCase
{
  var $defaultConfig = array('Foo' => array('a' => 'b'));
 
  function startTest($method) {
    if(preg_match('/_disableAutoLoad/', $method)) {
      $this->components = array();
    } else {
      $this->components = $this->defaultConfig;
    }
    parent::startTest($method);
  }
 
  function testAdd() {
    $this->Foo;
  }
 
  function testAdd2_disableAutoLoad() {
    $c = $this->_createController(array('Foo' => array('c' => 'd')));
  }
}

テストに使用するコントローラのクラスを切り替えることもできます。

class BarTestCase_TestController extends Controller
{
   //独自のテスト用ロジックを実装する...
}
 
class BarTestCase extends ComponentTestCase
{
  var $controllerClassName = 'BarTestCase_TestController';
}

TestCase内でのコントローラの初期化については Testing CakePHP Controllers the hard way | Mark Story を参考にしました。

動的にコンポーネントを読み込むコンポーネント

CakePHP1.3で動的にコンポーネントを読み込むためのコンポーネント DynamicComponent を作成しました。

cakephp-dynamic-component

注意: Cake本来の機能を超える機能を提供するため、Cakeコアのprivateなメソッドやプロパティにアクセスしています。Cakeのバージョンが上がると使用できなくなる可能性があります。十分ご注意ください。

このコンポーネントを使うと次のようなことが可能になります:

  • admin プレフィクスが付いているときだけ Auth コンポーネントを読み込む
  • debug > 0 のときだけ DebugKit を読み込む

読み込みはコントローラの _initialize メソッドで loadComponents メソッドを使って行います。

class AppController extends Controller
{
  var $components = array('DynamicComponent.Dynamic');
 
  function _initialize() {
    if(!empty($this->params["prefix"]) && $this->params["prefix"] == 'admin') {
      $this->Dynamic->loadComponents('Auth');
    }
    if(Configure::read('debug') > 0) {
      $this->Dynamic->loadComponents('DebugKit.Toolbar');
    }
  }
}

この方法で読み込んだコンポーネントの initialize メソッドからは、さらに別のコンポーネントを読み込むことができます。そのためコントローラから直接 Auth や DebugKit を読み込むよりは、状況に合わせた専用のコンポーネントを用意して、その中で Auth や DebugKit の読み込み・初期化を行うことをお勧めします。以下、Admin と Debug という2つのコンポーネントを作っています。

// app/controllers/components/admin.php
class AdminComponent extends Object
{
  function initialize($controller) {
    $controller->Dynamic->loadComponents('Auth');
 
    //Authコンポーネントのセットアップ
    $controller->Auth->loginAction = array(/*...*/);
  }
}
// app/controllers/components/debug.php
class DebugComponent extends Object
{
  function initialize($controller) {
    $controller->Dynamic->loadComponents('DebugKit.Toolbar');
 
    //その他debugに有用な設定など
    $controller->set(...);
  }
}

また prefix 名に応じた読み込みを行うオプションがあらかじめ用意されているので、コントローラの prefix 判定コードは省略できます。最終的に _initialize メソッドは次のようになります。

class AppController extends Controller
{
  var $components = array('DynamicComponent.Dynamic'
                          => array('prefix' => true));
 
  function _initialize() {
    if(Configure::read('debug') > 0) {
      $this->Dynamic->loadComponents('Debug');
    }
  }
}

CakePHP 1.3RC1の新機能:プラグインdriver

せっかく私のpatchが採用されたので、ブログにも書いておきます。

CakePHP 1.3からはプラグイン内の DataSource が使えるようになりますが、1.3RC1からは DataSource の driver もプラグインから読み込めるようになりました。

たとえばGithubのCakePHP datasources pluginでは、Cakeコアに含まれない様々なデータベースに対するDboドライバが提供されていますが、RC1ではこれを次のようにして読み込むことができます。

class DATABASE_CONFIG {
  var $sqlite3 = array('driver' => 'Datasources.DboSqlite3',
                       ...);
 
}

また driver を使用する DataSource をプラグインとして提供することも可能です。拙作のKeyValueSourceは次のように使用できます。

class DATABASE_CONFIG {
  var $memcache = array('datasource' => 'KeyValueStore.KeyValueSource',
                        'driver' => 'KeyValueStore.KeyValueMemcache',
                        ...);
}

プラグインが読み込む driver を app/models/datasources 内に作ることもできます。たとえば memcache ドライバを拡張した my_memcache ドライバを作った場合、次のように読み込みます。

// app/models/datasources/key_value/key_value_my_memcache.php
 
class DATABASE_CONFIG {
  var $memcache = array('datasource' => 'KeyValueStore.KeyValueSource',
                        'driver' => 'my_memcache',
                        ...);
}

CakePHPでSchemalessなデータを扱う

拙作のKeyValueSource(key-valueストアのためのDataSource)で、スキーマ・レスなデータを保存できるようにしてみました。

cakephp-key-value-source – GitHub
(このプロジェクトは実用より実験を重視しているので、頻繁に仕様が変わります)

モデルクラスで $looseSchema というプロパティを設定すると、プライマリキーidを除いて、どんな構造のデータでも保存できるようになります。

class SchemalessUser extends AppModel {
  var $useDbConfig = 'memcache';
  var $looseSchema = true;
}
 
// in Controllers 
$SchemalessUser->save(array('id' => 1234,
                            'name' => 'John Smith',
                            'hobby' => array('baseball', 'soccer')));
$SchemalessUser->save(array('id' => 1235,
                            'firstname' => 'John',
                            'lasttname' => 'Smith',
                            'age' => 20));

saveメソッドの fieldList オプションでキーを制限することができます。

$SchemalessUser->save(array('id' => 1234,
                            'name' => 'John Smith',
                            'hobby' => array('baseball', 'soccer')),
                      array('fieldList' => array('id', 'name')));
 
// hobby は保存されない
// array('id' => 1234, 'name' => 'John Smith')
$ret = $SchemalessUser->read(null, 1234);

$looseSchema プロパティで一部のスキーマを明示的に設定しておくこともできます。下の例ではプライマリキーを key に変更し、更新日時を入力するために updated フィールドを定義しています。

class LooseSchemaUser extends AppModel {
  var $useDbConfig = 'memcache';
  var $primaryKey = 'key';
  var $looseSchema = array('key' => array('type' => 'string', 'length' => 255),
                           'updated' => array('type' => 'datetime'));
}
 
// in Controllers 
$LooseSchemaUser->save(array('key' => 1234,
                             'name' => 'John Smith'));
 
// array('key' => 1234, 'name' => 'John Smith', 'updated' => '2010-02-07 22:23:13')
$ret = $LooseSchemaUser->read(null, 1234);

$looseSchemaを設定せず、$_schema を設定すれば通常の動作になります。

class StrictUser extends AppModel {
  var $useDbConfig = 'memcache';
  var $_schema = array('id' => array('type' => 'string', 'length' => 255),
                       'name' => array('type' => 'string'));
}
 
// in Controllers 
$StrictUser->save(array('id' => 1234,
                        'name' => 'John Smith',
                        'hobby' => array('baseball', 'soccer')));
 
// hobby は保存されない
// array('id' => 1234, 'name' => 'John Smith')
$ret = $StrictUser->read(null, 1234);

裏側では KeyValueLooseSchemaBehavior というBehaviorを使って、$_schema に存在しないデータを一旦別のフィールドに退避しておき、DataSource の中で取り出しています。退避用のフィールドはデフォルトでは _schemaless_data という名前で、DataSource の describe メソッドの中で組み込まれます。さらに(ちょっと反則気味ですが) Behavior 自体も describe メソッドの中で $actsAs に設定することで、特別な設定なしで動くようになっています。

自動設定される部分を書き下すと、おおむね次のようになります。

class User extends AppModel {
  var $actsAs = array('KeyValueLooseSchema' => array('schemalessField' => '_schemaless_data'));
  var $_schema = array('id' => array('type' => 'string', 'length' => 255),
                        '_schemaless_data' => array('type' => 'schemaless'));
}

KeyValueLooseSchemaBehavior の実装は汎用的になっているので、他の DataSource にほとんどそのまま持っていけるはずです。

CakePHP用Cicindelaライブラリ

CakePHP から レコメンデーションエンジン Cicindela にアクセスするためのライブラリを公開しました。

http://github.com/tkyk/cakephp-cicindela

使い方はREADMEを読んでください。Cicindela本体のWeb APIとほとんど同じ構造なので、特に迷うことはないだろうと思います。

ちなみに Configure::read(‘debug’) > 1 で実行すると、DboSourceと同じようにリクエスト情報が一覧表示されます。デバッグに便利です。こんな感じ:

(cicindela) 2 request(s)
NrURLStatusTook (ms)
1http://host/cicindela/recommend?set=pick&op=for_item&item_id=RubyHTTP/1.1 200 OK2887
2http://host/cicindela/recommend?set=pick&op=for_item&item_id=PHPHTTP/1.1 200 OK1346

….
以下、このライブラリを作りながら考えたこと。

このライブラリはDataSource + Behaviorという2クラス構成になっている。最初はDataSourceだけでできるかと思っていたのだが、無理だった。現在のCakeのModelクラスは、DataSourceとDboSourceの関係で言うなら”DboModel”であって、非DboのDataSourceを扱うには適していない。

現在のModelが”DboModel”であるように、XXXSourceに対してはXXXModelが存在することが望ましい。CakePHP 1.3からはDataSourceがプラグイン化できるようなので、同時にModelのベースクラスを提供するようにすれば、ある程度は改善されると思う。ただそれでも結局(プラグインの)ユーザがいちいちクラスの継承関係に気を使わなければいけない点が煩わしい。理想を言うなら、1.非常に簡素なBaseModelクラスを用意する、2.DataSourceにモデル初期化用コールバックを設ける、3.初期化用コールバックにおいてBehaviorをattachする、DataSource固有の機能はBehaviorに実装する……という構成にできれば良いのではないか。

Behavior の適用範囲を広げる $mapMethods

(CakePHP 1.2.5)
ビヘイビアに定義されたメソッドは、モデル自身のメソッドであるかのように呼び出すことができます。

class FooBehavior extends ModelBehavior {
  function foo($model){ /* ... */ }
}
 
class Bar extends AppModel {
  var $actsAs = array('Foo');
}
 
// in Controllers
$this->Bar->foo();

しかしこの方法では Dynamic Finder のように動的に名前が変化するメソッドは実装できません。

ModelBehavior クラスの $mapMethods プロパティを使用すると、この制約を乗り越えることができます。$mapMethods は配列で、次のような構造をしています:

array( '/メソッド名に対する正規表現/' => '実際に呼び出されるメソッド'  )

例えば '/^list/' => '_genericList' という設定を行った場合、listから始まる全てのメソッド呼び出しが、ビヘイビアの _genericList メソッドの呼び出しに変換されます。このとき _genericList メソッドの引数には元のメソッド名と引数が渡されます。ちょうど __call によるメソッドのオーバーロードと同じです。

この機能を活用して、あるカラムの値を配列として取得するための listColumn メソッドを作ってみます(Column はカラム名)。このメソッドは ListingBehavior に実装することにしましょう。

class ListingBehavior extends ModelBehavior {
  var $mapMethods = array('/^list[a-zA-z0-9]+$/' => '_listColumn');
 
  function _listColumn($model, $methodName, $query=array()) {
    $column = substr($methodName, 4);
    $query = am($query, array('fields' => array($model->alias.".".$column)));
    $arr = $model->find('all', $query);
    if(is_array($arr)) {
      return Set::extract("/{$model->alias}/{$column}", $arr);
    }
    return $arr;
  }
}

モデルにこのビヘイビアを組み込むと、任意のカラムに対して listXXX メソッドを呼び出すことができるようになります。

class User extends AppModel {
  var $actsAs = array('Listing');
}
 
// in Controllers
$this->User->listId();
$this->User->listUsername(array('conditions' => 'active = 1'));
$this->User->listGroup_id();

メソッド名は小文字に変換されて渡されるので(これが仕様なのかバグなのか微妙なところです)、残念ながら Inflector::underscore を使うことはできません。