CakePHPアプリケーションの基本的な設計指針 (3) - カスタムfindタイプ -
イントロダクション
標準のfindの種類(first, all, count, threaded, neighbor, list)だけでは、ビジネスロジックに対応できないことがあります。
これに対するひとつのプラクティスとしては、カスタムfindタイプを定義することです。
この記事では、実際の開発を想定したリファクタリングの過程を通してカスタムfindタイプの定義の仕方と活用方法、その意義をご紹介します。
「次」の記事
例えば、カレントのレコードの「次*1」のレコードを取得したい場合、それに纏わる複雑な処理は、単純なqueryの発行だけでは済まないことがあります。
この「次」のレコードを探索するロジックを例に、ボブ*2がこれを実装していくお話をしましょう。
要件の定義
ボブの上司のサム*3は、クライアントの会社のサイトにブログモジュールを追加するプロジェクトの打ち合わせで、次の要件を定義しました。
- 記事のモデルとして「Post」を使う。
- IDを比較することによって「次」の記事を決める(ID昇順)
- 「自分」がID順で「最後」のものであったら、「最初」の記事を「次」の記事とする(A -> B -> C -> A)
- 「次」に「自分」自身は含めない
- 「自分」以外何もなかったら見つからなかったものとする
クライアントはこれさえあればユーザは次々と記事を見てくれるだろうとご満悦のようです。
この要件を渡されたボブは、こんなの楽勝だなと思い、さっそく実装にとりかかることにしました。
個別メソッド
まずはじめに、ボブはモデルに専用のメソッドを定義し、それをアプリケーション内で利用することにしました。
非常にシンプルに記述でき、ボブはにんまりとしながら泥のようなコーヒーをすすりました。
これで要件を満たすことはできましたが、これでボブの仕事は終わりではありませんでした。
カテゴリーの絞り込み
しばらく時が経ち、ボブが休憩室でギターの練習をしていると、サムが来てこう言いました。
「ブログのカテゴリーの絞り込みからも『次』の記事に飛ばして欲しいという要望があったんだ。やってくれるかい」
ボブはようやく新しい曲が覚えられそうなのに横槍を入れられて不機嫌そうですが、渋々これを承諾し、作業に取り掛かることにしました。
ボブはカテゴリーで絞りこまれたレコードを探索するには、条件(スコープ)をつけることが必要と考えました。
そこで、以下のように書き換えることにしました。
これでカテゴリーの絞り込みができるようになりました。ボブがチケット管理システムで該当チケットに完了のステータスをつけると、5分もしないうちにサムがボブのデスクに来て言いました。
「これではカテゴリーを必ず指定しないといけないから、全体記事で使うことができないじゃないか。*4」
ボブは慌てて、カテゴリーを渡す引数をオプションにして対応することにしました。
これで全体記事、またはカテゴリー絞り込みから「次」の記事を探すことができるようになりました。
タグでの絞り込みの追加
これで柔軟に対応できたな、と思ってボブがまたギターの練習をしている間、タグ機能の追加の要件が出てきました。
タグ絞り込みから「次」の機能を使おうと思うと、やはりこのままではいけないようです。
そこで、試行錯誤した結果、以下のようなコードでなんとか動くことを確認しました。
最初のシンプルなコードから比べると、かなりごちゃごちゃとしてきました。
更に、引数の取り方も強引で、使う時に都度確認しながらでないとよくわからくなってきました。
ボブはすっかり疲れきった顔で、今日は退社することにしました。
翌日
昨日、ようやくタグの絞り込み機能が実装できたと思った束の間、今度はACLによる権限からの絞り込みを追加する必要が出てきました。
また引数を追加するのでしょうか。
ACLを通した絞り込みはどうするのでしょうか。
タグと同じ方法でできればいいですが、そうでなかった場合、いくら時間をかけて調べることになるのでしょうか。
「もうたくさんだ!」ボブは叫びます。
そして、CPUロゴマークがはがれかけてる古いノートPCは窓から投げ捨てられ、プロジェクトは闇へと消えさっていくのでした・・・。
と、ならないように
ボブは新しい仕事をさがすために就職情報サイトを閲覧していると、上司のサムからメールが届きます。
「パン屋のジョナサン*5が言うには、カスタムfindが全てを上手くやってくれる*6というんだ。あのノートPCだが、減価償却として経理に申請が通ったから*7、気にすることはないさ」
ボブはこう返信しました。
「そうか。その助言は調べる価値がありそうだ。しかし、なぜジョナサンにこの仕事を頼まない?」
色々問答があった末、ボブはカスタムfindへのリファクタリングを行った上で、ジョナサンにこの仕事を引き継ぐことになりました。
カスタムfindタイプの定義
ボブが調べた結果、カスタムfindタイプの定義は、特殊な書き方を伴うということがわかりました。
そこで、現状のソースコードを一旦捨て、「次」のレコードを探索する汎用的なカスタムfindタイプを作成することにしました。
まず、カスタムfindタイプを使うには、Model::$_findMethodsプロパティに、使うfindタイプが有効であることを伝えなければいけません。
実は他のfindタイプ(all, first)などが同じようにclass Modelに定義されていますが、モデルのコンストラクタでModel::$_findMethodsとPost::$_findMethodsを自動的にマージしてくれます。賢いですね :D
これで、find()に'next'タイプがあることが伝わりました。
さて、find('next')を呼んだ時に、いったい何が起こるのでしょうか。
ボブは自身の情報網(と、ジョナサン)を駆使した挙句、以下のような実装に成功しました。
呼び出されるモデルのメソッドは、_findNext()です。これは、_find + findタイプの頭文字を大文字にしたもので、_findFirst(), _findAll()などがあります。
第一引数$stateにはbefore、afterしか入らず、これはfind()の低級APIであるデータソースのread()が呼ばれる前(before)か後(after)かが入ります。
beforeの場合、$query(第二引数)を返す必要があります。これは、このメソッドでfindオプション($query)の書き換えができることを意味します。
afterの場合、find()の返り値そのものを返却する必要があります。これは第三引数の$resultsを元に返す必要があります。これは、このメソッドで返り値の整形ができることを意味します。
他の_findMethod()の再利用
さて、ボブがテストしてみたところ、今のところ順調に動いているようです。しかし、調べた過程の中で、一部がModel#_findFirst()と同じことをしていることに気づきました。
そこで、_findFirst()を再利用するようにリファクタリングすることにしました。
上手く動いているようです!ボブはカテゴリとタグの絞り込みをどうするか思案した結果、これらはbeforeFind()で記述することにしました。
これにより、'next'以外でもカテゴリやタグの絞り込みが行えます。
エピローグ
かくてPostモデルは、その複雑なロジックが完全に抽象化され、汎用的なロジックの組み合わせとして記述されるようになりました。
これなら、ACLを用いた探索機能の追加も他のことを忘れて行うことができるでしょう。
その後ボブは新しい仕事として、パン屋ジョナサンの店員として働き、ジョナサンはサムと一緒にベンチャーとして独立し一山当てることになりますがこの設定本当に必要だったんでしょうか。
後書き
Mode#find()の振る舞い・用法は、まずほとんどの開発者が理解すべきもので、これに追従することによって様々な設計を統一することができます。
また、再利用法として場合によってはページネーションにも使えます*9。
更に、CakeDC謹製Searchプラグインを使う場合はfindを経由するので、これに応用することもできます。
一見面倒に見えるカスタムfindタイプですが、慣れてしまえば非常にストレスフリーに、柔軟さを伴った実装ができます。
複雑なロジックからボブのように逃げるのではなく、ジョナサンのようにスマートに実装するようにしましょう ;)
-
-
- -
-
この記事で使ったサンプルコードはこちら:
preferable example for getting data method for model ― Gist
最後にビヘイビア化したコードはこちら:
preferable example for getting data method for model(behaviorized) ― Gist
*1:アプリケーションの要件により「次」が何を示すのかは異なる
*2:新入社員24歳PG。CakePHP歴1年。細かいことを言うサムは苦手
*3:入社4年目32歳GM。自由すぎるボブにいつも振り回されがち。ジョナサンとは家が近く親友。
*4:「すべての記事」というカテゴリーは、HABTMにすれば実現できますが、それは別の話とします
*5:28歳パン屋店主。プログラムは趣味。サムのことはただの知り合いだと思っている
*6:注:誇張
*7:ダメ、絶対
*8:カスタムfindモデルのビヘイビア化には、ModelBehavior#mapMethods()を使ってマッピングする必要があります。詳しくはソースコードとAPIを参照してください
*9:$this->paginate[] = 'next'など