#はじめに
みなさんこんにちは、今日も元気にMVCで開発してますか?
ところで、あなたのcontroller
の様子はどうでしょうか?
MVCのCであるところのcontroller
ですが、その責務はview
とmodel
の受け渡しをするに過ぎません。
つまり、直接に描画を行ったり、'model'の内部データを直接したりしてはいけないのです。
しかし、実態としては、controller
が一番処理を書きやすい構造になっているため、すべての責務をcontroller
に追わせた結果、**肥大化したcontroller
(fat controllr)**が誕生します。
(私は一つのアクションで4,000行を超えるcontroller
を観測しました。。。)
Cake PHPはRailsから影響を受けているため、目指すところはfat model skinny controller
となります。
#対象読者
じゃあ実際controller
から処理を切り離すにはどうしたらいいの!?って悩んでる人向けの記事です。
Cake PHPもmodel
層は、table
とentity
の2つのクラスに別れていますが、これらは効率的にfat model
を実現してくれるためのメソッドを提供してくれます。
それでは、一般的なWebページのライフサイクルに沿って見ていきましょう!
#Cake PHPもModelについて
まずは、model
に関する外観を見ていきましょう。
公式のドキュメントでは、次のように記されています。
モデル層はビジネスロジックを実装するアプリケーションの部品を表します。アプリケーションにおいて データを取得し、それを最初の意味ある形に変換する役割を担います。これには、加工、検証 (validating) 、 関連付け (associating) 、あるいはデータの処理に関係のあるその他のタスクが含まれます。
よく、model
はデータベースと接続するところと勘違いされることがあります。
model
は、ビジネスロジックを扱うところであり、データベースそのものではないのです。
理想的な設計は、互いに詳細を知らないことです。
例えば、model
のデータベースやview
のhtmlなどは詳細です。
model
はデータをどのように表示するかは知るべきでないですし、具体的な実装がなんであれ構わないデータを提供します。
view
もまた同じです。model
がファイルシステムでデータを管理しているか、はたまたデータベースで管理しているかは関係ありません。ただ抽象化されたデータを扱えればよいのです。
話が少々脇にそれましたが、アプリケーションに応じてデータを加工するのは、モデルの責務だということが肝要です。
##ビジネスロジックとは
ビジネスロジックという言葉が出てきました。
ビジネスロジックについて、明確に定義はありません。
ですが、アプリケーションにおける実際の業務のルールを指すことが多いです。
ビジネスロジックは時に複雑で、また技術的なレベルと関係なく変更されることの多い部分です。
そのために、このようなロジックはmodelに閉じ込めることが肝要なのです。
#モデルからデータを取り出す
まずはindexなどの一覧を表示する一般的なアクション向けです。
controller
はあくまでmodel
を呼び出してview
に橋渡しをするだけに責務を負います。
##カスタムファインダー
不動産情報を掲載しているサイトを前提とします。
不動産の広告掲載には複雑なルールがあり、例えば、
- NGワードが記載されていない
- 広告掲載が可である
- 更新日から2週間以内である
というルールを守った物件を取得するために、controller
にこんな記述をしたいかもしれません。
$date = date('Y-m-d H:i:s', strtotime('-2 week', time()));
$this->Estates->find()
// 一覧上で常に必要となるカラムを取得
->select(['price', 'Stations.name', 'buiding_age' etc...])
->contain(['Stations'])
->where([
'NGCheck' => 1,
'Advertising' => 1,
'update_at >=' => $date
]);
controller
にビジネスロジックが入り込む隙を与えてしまいました。
おそらくこの条件は、ユーザーに見せる画面全てのアクションで記述されていることでしょう。
また、このようなルールは変更されることが容易に想像され、変更作業は困難を極めることでしょう。
このような条件は、table
のカスタムファインダーに閉じ込めます。
class EstatesTable extends Table
{
public function findFrontRule(Query $query, array $options)
{
$date = date('Y-m-d H:i:s', strtotime('-2 week', time()));
return $query
// 一覧上で常に必要となるカラムを取得
->select(['price', 'Stations.name', 'buiding_age' etc...])
->contain(['Stations'])
->where([
'NGCheck' => 1,
'Advertising' => 1,
'update_at >=' => $date
]);
}
}
カスタムファインダー内では、$query
に対してクエリービルダーのメソッドを使用することができます。
controller
ではこのように使います。だいぶスッキリした感じになりました。
その上は、複雑なwhere文
を見るよりも、意図がわかりやすくなったと思います。
$this->Estates->find('FrontRule');
さらに、カスタムファインダーはチェーンすることも可能です。
// 基本的な条件に加えて、詳細画面用のカラムを更に追加する。
$this->Estates->find('FrontRule')->find('show');
さらに$optionsが気になりますか?
$options`を使用すると関連するアプリケーションのロジックに合わせてファインダーの操作をカスタマイズできます。
例えば、これはユーザーがお気に入りに登録した物件を検索する例です。
public function findFavorites(Query $query, array $options)
{
$optionsの配列からユーザー情報を取得
$user = $options['user'];
$favorites_table = TableRegistry::get('Favorites');
// サブクエリーを構築
$subquery = $favorites_table->find()
->select(['id'])
->where(['users_id' => $user['id'])
->where(function ($exp, $q) {
return $exp->equalFields('Estates.id', 'estate_id');
});
return $query->where(function ($exp, $q) use ($subquery) {
return $exp->exists($subquery);
});
}
controller
内ではこのように使用します。
// 今ログインしているユーザーを取得
$user = $this->Auth->user();
$this->Estates->find('Favorites', ['user' => $user]);
##仮想プロパティ
大抵の場合には、データベースから取り出したデータはそのまま使用せずに、加工してからview
で表示したいはずです。
$estates = $this->Estates->find('FrontRule');
foreach ($estates as $estate) {
// 利回りを計算 家賃収入 ÷ 購入金額
$estate->yield = ($estate->income / $estate->price) * 100;
// 購入金額を万円単位に変換
$estate->ten_thousand_price = $estate->price / 10000;
// 新築物件かどうか
// 完成後1年以内かつ誰も入居していない
$one_year_ago = date('Y-m-d H:i:s', strtotime('-1 year'));
if ($estate->buiding_year => $one_year_ago && !$estate->occupancy) {
$estate->construction = '新築'
} else {
$estate->construction = '中古'
}
}
なんだかとても嫌な匂いが漂っていますね。。。
これはmodel
がデータベースと接続するところだと勘違いしているところから生じているのだと推測されます。
これは仮想プロパティを用いると簡単に解決することができます。
仮想プロパティはEntity
クラスのメソッドです。
仮想プロパティの定義は簡単です。protected
で宣言し_get
の後にキャメル記法でプロパティ名を記述します。
protected function _getYield()
{
return ($this->income / $this->price) * 100;
}
protected function _getTenThousandPrice()
{
return $this->price / 10000;
}
protected function _getConstruction()
{
$one_year_ago = date('Y-m-d H:i:s', strtotime('-1 year'));
if ($this->buiding_year => $one_year_ago && !$this->occupancy) {
$this->construction = '新築'
} else {
$this->construction = '中古'
}
}
これで存在しないプロパティにあたかも存在しているかのようにアクセスできます。
// view 画面などで
<?= $estate->yield ?>
<?= $estate->ten_thosand_price ?>
<?= $estate->construction ?>
これでcontroller
の悪夢のような記述は綺麗サッパリなくなりました!
#エンティティを永続化する流れ
つぎは、editアクションに代表するエンティティを永続化する流れを追っていきます。
実例として、一般的な会員情報を編集する流れを見てみましょう。
controller
の基本の流れは、view
から受け取ったデータをmodel
に一括代入することです。
検証やデータの処理は、モデルの責務だということを忘れないでください。
// コントローラーの美しい処理の流れ
public function edit($id = null)
{
$user = $this->Users->get($id);
if ($this->request->is('post')) {
$user = $this->Users->patchEntity($user, $this->request->getData());
if ($this->Users->save($user)) {
$this->Flash->success(__('成功しました。'));
} else {
$this->Flash->error(__('失敗しました。'));
}
}
$this->set('user', $user);
}
##バリデーション
データの検証はmodel
の責務です。
次のように、controller
で受け取ったデータを検証するのはアンチパターンです。
// コントローラーでデータを検証
public function edit($id = null)
{
if ($this->request->is('post')) {
$data = $this->request->getData();
if ($data['name'] == '') {
$this->redirect($this->request->referer());
}
if ($data['email'] == '' ) {
$this->redirect($this->request->referer());
}
// 以下、永遠と検証が続く
データの検証はすべて'table'クラスに定義します。
実は、CakePHPの検証メソッドは2つ存在します。まずは、バリデーションルールから見ていきましょう。
###バリデーションルール
これは、データからエンティティを構築する時に発動します。
具体的には、newEntity()
,patchEntity()
, newEntities()
, patchEntities()
が呼ばれたときです。
ここでは、データの型(文字列か数字),形状(正規表現で正しいメールアドレスかチェックするなど),サイズを検証します。
次のようにValidationDefault
メソッドで定義されたバリデーションは自動で呼ばれます。
public function validationDefault(Validator $validator)
{
$validator
->requirePresence('name', 'create')
->notEmpty('name', '名前は必須項目です');
$validator
->requirePresence('email', 'create')
->notEmpty('email', '名前は必須項目です')
->email('email', 'メールアドレスの形式が間違っています')
// いろいろな検証
return $validator;
}
'Validator'クラスは、様々な既存のバリデーションルールをチェーンすることができます。
具体的なバリデーションルールの存在については、公式のAPIか私の記事を参考にしてください。
バリデーションエラーは次のように取り出すことができます。
$user = $this->Users->patchEntity($user, $this->request->getData());
// バリデーションエラーが存在していたらtrueを返す
if ($this->errors()) {
// エラー時の処理
}
erros()
はつぎのような配列になっています。
エラーメッセージを取り出した、表示してあげるのが良いでしょう。
$errors = [
'email' => ['メールアドレスの形式が間違っています']
];
また、一つのmodel
に対して複数のバリデーションルールを適応させたい場面があるかもしれません。
例えば、有料会員として登録するときだけ、クレジットカードの登録を必須としたいでしょう。
// バリデーションルールごとに名前をつける
public function validationPremium(Validator $validator)
{
// 基本のバリデーションルールも使用する
$validator = $this->validationDefault($validator);
$validator
->notEmpty('credit', 'クレジットカード番号が入力されていません。'
->creditCard('credit', 'クレジットカード番号の形式が間違っています');
return $validator;
}
validationDefault()
以外のバリデーションルールを使用したい場合、次のようにしていします。
$user = $this->Users->patchEntity($user, $this->request->getData(), ['validate' => 'prenium']);
###アプリケーションルール
これは、バリデーションルールによって基本的な検証を終えた後、ドメインルールに基づく複雑な検証を行います。
ドメインルールの例として、公式ではこれらが挙げられています。
メールアドレスの一意性の保証。
ステータス遷移や業務フローの手順 (たとえば、請求書のステータス更新)。
論理削除されたアイテムの更新の抑制。
使用量/料金の上限の強制。
アプリケーションルールは、save
またはdelete
メソッドが呼ばれた時に発動します。
実際にメールアドレスの一意性を確保するルールを設定してみましょう。
一意ルールは一般的なので予め用意されています。
use Cake\ORM\RulesChecker;
use Cake\ORM\Rule\IsUnique;
public function buildRules(RulesChecker $rules)
{
$rules->add($rules->isUnique(['email']));
return $rules;
}
他にも、外部キールールやアソシエーションカウントルールもあり、もちろん自ら定義したルールを適応することも可能です。
##テーブルコールバック
table
クラスは、そのライフサイクルの中でイベントを監視します。
すなわち、Observer
パターンにおけるリスナーのような立ちふるまいをします。
ちなみに、イベントシステムはtable
クラスに限った話でなく、'controller'や'view'イベントも存在します。
###beforeMarshal
数あるイベントのなかでもこのイベントは有益です。
このイベントは、データからエンティティを構築する際に、バリデーションルールが発動する前に発生します。
すなわち、入力フォームから送られた値をバリデーションが適応される形に修正することができるのです。
よくある欲求として、入力フォームに全角数字で入力された項目を半角数字に戻してあげてからバリデーションルールを適応させてあげたいでしょう。
// これらをuseすることが必要
use Cake\Event\Event;
use ArrayObject;
public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options)
{
if (isset($data['age'])) {
// 全角を半角に変換
$data['age'] = mb_convert_kana($data['age'], "KVa");
}
// returnする必要はない
}
###beforeSave / AfterSave
名前の通り、これらのイベントはデータベースにエンティティが保存される前後に発動します。
また、beforeSave
~ afterSave
は一つのトランザクションの中で行われます。
実際の使い方としては、価格が値下げして更新された場合にデータの保存前に値下げフラグをonにすることができます。
// これらをuseすることが必要
use Cake\Event\Event;
use ArrayObject;
public function beforeSave(Event $event, ArrayObject $data, ArrayObject $options)
{
// 特定のプロパティが変更されたか
if ($data->isDirty('price')) {
// getOriginal()は変更前の値を取得
if ($data->getOriginal('price') > $data->price) {
// 価格値下げフラグ
$data->price_down = 1;
}
}
#参考文献
CakePHPについて
公式CookBook
【CakePHP3】バリデーション・アプリケーションルールについて
MVCについていろいろ
Model View Controller
Clean Architecture 達人に学ぶソフトウェアの構造と設計
エリック・エヴァンスのドメイン駆動設計