24
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【CakePHP】fat Controllerを避ける方法

Posted at

#はじめに
みなさんこんにちは、今日も元気にMVCで開発してますか?
ところで、あなたのcontrollerの様子はどうでしょうか?

MVCのCであるところのcontrollerですが、その責務はviewmodelの受け渡しをするに過ぎません。
つまり、直接に描画を行ったり、'model'の内部データを直接したりしてはいけないのです。

しかし、実態としては、controllerが一番処理を書きやすい構造になっているため、すべての責務をcontrollerに追わせた結果、**肥大化したcontroller(fat controllr)**が誕生します。
(私は一つのアクションで4,000行を超えるcontrollerを観測しました。。。)

Cake PHPはRailsから影響を受けているため、目指すところはfat model skinny controllerとなります。

#対象読者
じゃあ実際controllerから処理を切り離すにはどうしたらいいの!?って悩んでる人向けの記事です。
Cake PHPもmodel層は、tableentityの2つのクラスに別れていますが、これらは効率的にfat modelを実現してくれるためのメソッドを提供してくれます。

それでは、一般的なWebページのライフサイクルに沿って見ていきましょう!

#Cake PHPもModelについて
まずは、modelに関する外観を見ていきましょう。
公式のドキュメントでは、次のように記されています。

モデル層はビジネスロジックを実装するアプリケーションの部品を表します。アプリケーションにおいて データを取得し、それを最初の意味ある形に変換する役割を担います。これには、加工、検証 (validating) 、 関連付け (associating) 、あるいはデータの処理に関係のあるその他のタスクが含まれます。

よく、modelはデータベースと接続するところと勘違いされることがあります。
modelは、ビジネスロジックを扱うところであり、データベースそのものではないのです。

理想的な設計は、互いに詳細を知らないことです。
例えば、modelのデータベースやviewのhtmlなどは詳細です。

modelはデータをどのように表示するかは知るべきでないですし、具体的な実装がなんであれ構わないデータを提供します。
viewもまた同じです。modelがファイルシステムでデータを管理しているか、はたまたデータベースで管理しているかは関係ありません。ただ抽象化されたデータを扱えればよいのです。

話が少々脇にそれましたが、アプリケーションに応じてデータを加工するのは、モデルの責務だということが肝要です。

##ビジネスロジックとは
ビジネスロジックという言葉が出てきました。
ビジネスロジックについて、明確に定義はありません。
ですが、アプリケーションにおける実際の業務のルールを指すことが多いです。

ビジネスロジックは時に複雑で、また技術的なレベルと関係なく変更されることの多い部分です。

そのために、このようなロジックはmodelに閉じ込めることが肝要なのです。

#モデルからデータを取り出す
まずはindexなどの一覧を表示する一般的なアクション向けです。
controllerはあくまでmodelを呼び出してviewに橋渡しをするだけに責務を負います。

##カスタムファインダー
不動産情報を掲載しているサイトを前提とします。
不動産の広告掲載には複雑なルールがあり、例えば、

  1. NGワードが記載されていない
  2. 広告掲載が可である
  3. 更新日から2週間以内である

というルールを守った物件を取得するために、controllerにこんな記述をしたいかもしれません。

EstatesController.php
$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カスタムファインダーに閉じ込めます。

EstatesTable.php
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文を見るよりも、意図がわかりやすくなったと思います。

EstatesController.php
$this->Estates->find('FrontRule');

さらに、カスタムファインダーはチェーンすることも可能です。

EstatesController.php
// 基本的な条件に加えて、詳細画面用のカラムを更に追加する。
$this->Estates->find('FrontRule')->find('show');

さらに$optionsが気になりますか? $options`を使用すると関連するアプリケーションのロジックに合わせてファインダーの操作をカスタマイズできます。

例えば、これはユーザーがお気に入りに登録した物件を検索する例です。

EstatesTable.php
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内ではこのように使用します。

EstatesController.php
// 今ログインしているユーザーを取得
$user = $this->Auth->user();

$this->Estates->find('Favorites', ['user' => $user]);

##仮想プロパティ
大抵の場合には、データベースから取り出したデータはそのまま使用せずに、加工してからviewで表示したいはずです。

EstatesController.php
$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の後にキャメル記法でプロパティ名を記述します。

Estate.php
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 = '中古'
    }
}

これで存在しないプロパティにあたかも存在しているかのようにアクセスできます。

index.ctp
// view 画面などで
<?= $estate->yield ?>
<?= $estate->ten_thosand_price ?>
<?= $estate->construction ?>

これでcontrollerの悪夢のような記述は綺麗サッパリなくなりました!

#エンティティを永続化する流れ
つぎは、editアクションに代表するエンティティを永続化する流れを追っていきます。
実例として、一般的な会員情報を編集する流れを見てみましょう。

controllerの基本の流れは、viewから受け取ったデータをmodelに一括代入することです。
検証やデータの処理は、モデルの責務だということを忘れないでください。

UsersController.php
// コントローラーの美しい処理の流れ
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で受け取ったデータを検証するのはアンチパターンです。

UsersController.php
// コントローラーでデータを検証
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メソッドで定義されたバリデーションは自動で呼ばれます。

UsersTabel.php
public function validationDefault(Validator $validator)
{
    $validator
        ->requirePresence('name', 'create')
        ->notEmpty('name', '名前は必須項目です');

    $validator
        ->requirePresence('email', 'create')
        ->notEmpty('email', '名前は必須項目です')
        ->email('email', 'メールアドレスの形式が間違っています')
        
    // いろいろな検証
    return $validator;
}

'Validator'クラスは、様々な既存のバリデーションルールをチェーンすることができます。
具体的なバリデーションルールの存在については、公式のAPI私の記事を参考にしてください。

バリデーションエラーは次のように取り出すことができます。

UsersController.php
$user = $this->Users->patchEntity($user, $this->request->getData());
// バリデーションエラーが存在していたらtrueを返す
if ($this->errors()) {
    // エラー時の処理
}

erros()はつぎのような配列になっています。
エラーメッセージを取り出した、表示してあげるのが良いでしょう。


$errors = [
    'email' => ['メールアドレスの形式が間違っています']
];

また、一つのmodelに対して複数のバリデーションルールを適応させたい場面があるかもしれません。
例えば、有料会員として登録するときだけ、クレジットカードの登録を必須としたいでしょう。

UsersTable.php
// バリデーションルールごとに名前をつける
public function validationPremium(Validator $validator)
{
    // 基本のバリデーションルールも使用する
    $validator = $this->validationDefault($validator);

    $validator
        ->notEmpty('credit', 'クレジットカード番号が入力されていません。'
        ->creditCard('credit', 'クレジットカード番号の形式が間違っています');
    return $validator;
}

validationDefault()以外のバリデーションルールを使用したい場合、次のようにしていします。

UsersController.php
$user = $this->Users->patchEntity($user, $this->request->getData(), ['validate' => 'prenium']);

###アプリケーションルール
これは、バリデーションルールによって基本的な検証を終えた後、ドメインルールに基づく複雑な検証を行います。

ドメインルールの例として、公式ではこれらが挙げられています。

メールアドレスの一意性の保証。
ステータス遷移や業務フローの手順 (たとえば、請求書のステータス更新)。
論理削除されたアイテムの更新の抑制。
使用量/料金の上限の強制。

アプリケーションルールは、saveまたはdeleteメソッドが呼ばれた時に発動します。

実際にメールアドレスの一意性を確保するルールを設定してみましょう。
一意ルールは一般的なので予め用意されています。

UsersTable.php
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
数あるイベントのなかでもこのイベントは有益です。
このイベントは、データからエンティティを構築する際に、バリデーションルールが発動する前に発生します。

すなわち、入力フォームから送られた値をバリデーションが適応される形に修正することができるのです。

よくある欲求として、入力フォームに全角数字で入力された項目を半角数字に戻してあげてからバリデーションルールを適応させてあげたいでしょう。

UsersTable.php
// これらを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にすることができます。

EstatesTable.php
// これらを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 達人に学ぶソフトウェアの構造と設計
エリック・エヴァンスのドメイン駆動設計

24
28
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?