2015.03.27

FuelPHPのコントローラとモデルの拡張



はじめに

こんにちは。次世代システム研究室のT.Tです。
今回は、FuelPHPの単体テスト戦略PHP標準フレームワークのガイドラインの効果で触れてきたPHP標準フレームワークのコントローラとモデルについてご紹介したいと思います。

標準フレームワークの概要

システム要件

再掲になりますが、構築したフレームワークを導入するシステムの要件は概ね以下のようなものです。
  • クライアントからHTTPでJSONをPOSTして、サーバーからJSONをレスポンスする
  • 各クライアントからのリクエスト数は多くても毎分数回程度
  • マスタデータとユーザーデータを扱う
  • DBはマスタとスレイブの二台構成で、更新時だけマスタに接続する
  • あまり厳密な処理はしない(語弊はありますが、リクエストやレスポンスの損失は若干許されて、無論ですがDB上の整合性は必要という感じ)

標準化の対象

標準フレームワークの適用対象となる最初のプロジェクトは、機能仕様は既に決まっていて上記の要件よりも詳細がわかっているものでしたが、その後に適用される予定のシステムは上記の要件程度しか見えていませんでした。
そのため、以下のものを標準化の対象としました。
  • JSONをPOSTメソッドで受け取り、JSONをレスポンスするRESTコントローラ
  • 共通で使えそうなメソッドを持ったモデル
  • 可読性と保守性の高い単体テスト
  • フレームワークのガイドライン
  • 環境構築用のAnsibleのPlaybook
見えている要件が少なく、この要件であればFuelPHP自体が十分な機能を持っているので、コントローラとモデルに標準を追加する余地はあまりなかったのですが、形になったところをいくつか見ていきたいと思います。

コントローラ

コントローラはController_Restを継承した上で、以下の機能を標準化し、各プロジェクトではこのコントローラを継承して使うようにしました。
  • セッションチェック等のサービス利用可能かどうかを判定する各種チェック処理
  • DBのトランザクション管理
  • HMVCの呼び出し
  • JSONをPOSTメソッドで受け取り、JSONをレスポンスする処理
リソースはシステム内の各ユーザーのアカウントに紐づく個々のデータで、コントローラに対してそのリソースを操作するためのリクエストのJSONとURIを定義することができるようになっています。
リクエストの結果、成功であればリソースの操作結果、失敗であれば失敗した内容を示すステータスコードをJSONで返します。

セッションチェック等のサービス利用可能かどうかを判定する各種チェック処理

セッションの内容の正当性を確認して、不正な場合はリクエストを受け付けないようにしています(実際にはそれ以外にもいくつかのチェック処理をした上でリクエストを受け付けています)。

セッション管理の仕組みにはFuelPHP標準のものを利用していて、fuel/core/config/session.phpをfuel/php/configにコピーして、各プロジェクト用の設定に書き換え、oilで設定した内容でセットアップした上で利用しています。


セッションのセットアップ
$ FUEL_ENV=development php oil refine session:create
コントローラのセッションチェックの処理
<?php
abstract class Controller_Base extends Controller_Rest
{
    public function before()
    {
        try {
            // セッション情報の確認
            $this->checkSession();
        } catch (\Exception $e) {
            \Log::info($e);
            throw $e;
        }
    }

    private function checkSession()
    {
        $this->user_id = Session::get('user_id');
        // この後セッション情報をチェックして不正なら例外を投げる
    }
}

DBのトランザクション管理

トランザクションを一元的に管理するための仕組みを提供して、各コントローラではトランザクションを意識せずにロジックを実装できるようにしています。
ユーザーがリソースに対して利用できるサービスごとにコントローラを作り、各サービスごとにリソースの閲覧と作成、更新、削除のためのメソッドを提供しています。
トランザクションの管理
<?php
abstract class Controller_Base extends Controller_Rest
{
    // リソース閲覧用の処理を書くためのメソッド
    protected function index()
    {
        throw new \HttpNotFoundException('Invalid method');
    }

    // リソース作成用の処理を書くためのメソッド
    protected function create()
    {
        throw new \HttpNotFoundException('Invalid method');
    }

    // リソース更新用の処理を書くためのメソッド
    protected function update()
    {
        throw new \HttpNotFoundException('Invalid method');
    }

    // リソース削除用の処理を書くためのメソッド
    protected function delete()
    {
        throw new \HttpNotFoundException('Invalid method');
    }

    public function post_index()
    {
        $this->use_transaction = false;
        $this->execute(array($this, 'index'));
    }

    public function post_create()
    {
        $this->execute(array($this, 'create'));
    }

    public function post_update()
    {
        $this->execute(array($this, 'update'));
    }

    public function post_delete()
    {
        $this->execute(array($this, 'delete'));
    }

    private function execute($callback)
    {
        try {
            if ($this->use_transaction) {
                \DB::start_transaction(MASTER);
            }

            $callback();

            if ($this->use_transaction) {
                \DB::commit_transaction(MASTER);
            }
        } catch (\ProtocolException $e) {
            $this->rollbackTransaction($e);
            $this->data['result_code'] = $e->getCode();
            $this->data['error_message'] = $e->getMessage();
            \Log::info("[{$this->data['result_code']}] {$e->getMessage()} ".print_r($this->params, true));
        } catch (\HttpException $e) {
            $this->rollbackTransaction($e);
            $this->response_status = $e->response()->status;
            $this->data['result_code'] = \ProtocolException::RESULT_CODE_ERROR;
            $this->data['error_message'] = $e->getMessage();
            \Log::info($e);
        } catch (\Exception $e) {
            $this->rollbackTransaction($e);
            $this->response_status = 500;
            $this->data['result_code'] = \ProtocolException::RESULT_CODE_ERROR;
            $this->data['error_message'] = $e->getMessage();
            \Log::error($e);
        }

        $this->response($this->data, $this->response_status);
    }

    private function rollbackTransaction($e)
    {
        if ($this->use_transaction) {
            \DB::rollback_transaction(MASTER);
        }
    }
}
routes.php
<?php
return array(
    // サービスA
    'servicea/get' => 'servicea/index',

    // サービスB
    'serviceb/update' => 'servicea/update',
);

サービス用のコントローラの定義
<?php
class Controller_ServiceA extends \Controller_Base
{
    protected function index()
    {
        // サービスAのリソース閲覧ロジック
    }
}

<?php
class Controller_ServiceB extends \Controller_Base
{
    protected function update()
    {
        // サービスBのリソース更新ロジック
    }
}

HMVCの呼び出し

先述のController_Baseを継承したコントローラを、別のコントローラからHMVCでそのまま呼び出すと、コントローラ単位でトランザクションが実行されて全体として整合性が取れなくなったり、異なるユーザーインスタンスに対して処理を実行してしまう問題がありました。

その問題を解消してHMVCを利用できるようにしました。

サービスへのリクエストは全てPOSTで受け取り、post系のメソッドの引数でリクエストパラメータを受け取る必要がないので、その引数は標準フレームワーク内で自由に定義することができます。

そこで、第一引数をコントローラ共有で使えるユーザーインスタンスを渡すために使うようにしています。
HMVC用に拡張したコントローラ
<?php
abstract class Controller_Base extends Controller_Rest
{
    // 呼び出すコントローラのURL等を定義
    protected $hmvc_settings = array(
        // array('url' => 'servicea/index', ...),
    );

    public function before()
    {
        // HMVCのときはトランザクション管理を呼び出し元に任せる
        $this->use_transaction = !Request::is_hmvc();
    }

    // HMVCを呼ぶ前に必要な前処理をするためのメソッド
    protected function beforeHMVC()
    {
    }

    // HMVCを使う場合はユーザーインスタンスを渡す
    public function post_index($user = null)
    {
        $this->use_transaction = false;
        $this->execute(array($this, 'index'), $user);
    }

    public function post_create($user = null)
    {
        $this->execute(array($this, 'create'), $user);
    }

    public function post_update($user = null)
    {
        $this->execute(array($this, 'update'), $user);
    }

    public function post_delete($user = null)
    {
        $this->execute(array($this, 'delete'), $user);
    }

    private function execute($callback, $user)
    {
        try {
            if ($this->use_transaction) {
                \DB::start_transaction(MASTER);
            }

            // HMVCのときは呼び出し元のコントローラから渡されたユーザーインスタンスを利用する
            $this->user = !Request::is_hmvc() ? \Model_User::find($this->user_id) : $user;

            $this->beforeHMVC();
            $this->callHMVC();
            $callback();

            if ($this->use_transaction) {
                \DB::commit_transaction(MASTER);
            }
        } catch (\ProtocolException $e) {
            $this->rollbackTransaction($e);
        ...
    }

    private function callHMVC()
    {
        foreach ($this->hmvc_settings as $setting) {
            $result = \Request::forge($setting['url'])->execute(array('user' => $this->user));
            // この後、セッティング内容に応じた処理を実行
        }
    }

    private function rollbackTransaction($e)
    {
        if ($this->use_transaction) {
            \DB::rollback_transaction(MASTER);
        } else if (Request::is_hmvc()) {
            // HMVCのときは呼び出し元で例外処理する
            throw $e;
        }
    }
}

HMVCの呼び出し
<?php
class Controller_ServiceB extends \Controller_Base
{
    // この変数にURLを持った配列を追加すればHMVCで対応するコントローラを実行する
    protected $hmvc_settings = array(
        array('url' => 'servicea/index', ...),
    );

    protected function update()
    {
    }
}

JSONをPOSTメソッドで受け取り、JSONをレスポンスする処理

postプリフィックスを付けたメソッドを定義すればPOSTメソッドのリクエストだけを受けることができるので、コントローラではそのようにメソッド名を付けています。

さらにJSONをパースしてJSONを返す処理を以下のように付け加えています。
<?php
abstract class Controller_Base extends Controller_Rest
{
    // コントローラ内のresponseメソッドに渡した配列をJSONで返す
    protected $format = 'json';

    protected $data = array(
        'result_code' => \ProtocolException::RESULT_CODE_OK,
        'error_message' => ''
    );

    public function before()
    {
        try {
            // リクエストのJSONをパースして書式が間違っていれば例外を投げる
            $this->parseJson();
        } catch (\Exception $e) {
            \Log::info($e);
            throw $e;
        }
    }

    private function parseJson()
    {
        $this->params = \Input::json();
        if (json_last_error() != JSON_ERROR_NONE) {
            throw new \HttpBadRequestException('Invalid json');
        }
    }

    public function post_index($user = null)
    {
        $this->execute(array($this, 'index'), $user);
    }

    public function post_create($user = null)
    {
        $this->execute(array($this, 'create'), $user);
    }

    public function post_update($user = null)
    {
        $this->execute(array($this, 'update'), $user);
    }

    public function post_delete($user = null)
    {
        $this->execute(array($this, 'delete'), $user);
    }

    private function execute($callback, $user)
    {
        ...

        $this->response($this->data, $this->response_status);
    }
}

<?php
class Controller_ServiceB extends \Controller_Base
{
    protected function update()
    {
        $this->data['result'] = 'success';
    }
}

モデル

モデルではOrm\Modelを継承した上で、以下の機能を標準化し、各プロジェクトではこのモデルを継承して使うようにしました。
  • DBの接続先の自動選択処理
  • シーケンスIDを付与する処理

DBの接続先の自動選択処理

DBはマスタとスレイブの二台構成で、UPDATE系のクエリはマスタに、SELECT系のクエリはスレイブに投げるようにしています。

モデルでは書き込み用のコネクション変数と読み込み用のコネクション変数に、それぞれDBのconfigのマスタとスレイブの接続先のIDを指定します。
標準モデルのDB接続先の定義
<?php
abstract class Model_Standard_Model extends Orm\Model
{
    // マスタ用の接続先IDを指定
    protected static $_write_connection = 'master';
    // スレイブ用の接続先IDを指定
    protected static $_connection = 'slave';
}

DB接続先のdb.php
<?php
return array(
    'master' => array(
        'type' => 'mysqli',
        'table_prefix'=>'',
        'connection'  => array(
            'hostname' => '127.0.0.1',
            'port' => '3306',
            'database' => 'database',
            'username'   => 'username',
            'password'   => 'password',
        ),
    ),
    'slave' => array(
        'type' => 'mysqli',
        'table_prefix'=>'',
        'connection'  => array(
            'hostname' => '127.0.0.1',
            'port' => '13306',
            'database' => 'database',
            'username'   => 'username',
            'password'   => 'password',
        ),
    ),
);

シーケンスIDを付与する処理


ユーザーデータモデルでユーザーごとにシーケンスIDを割り当てることがあるのでそのためのメソッドを提供しています。

各ユーザーデータモデルでは、_event_before_insertメソッドでsave前に保存するようにします。
ユーザーごとにシーケンスIDを割り当てるメソッド
<?php
abstract class Model_Standard_Model extends Orm\Model
{
    protected static $_observers = array(
        'Orm\\Observer_Self' => array(
            'events' => array('before_insert'),
        ),
    );

    protected function getNewSequenceId()
    {
        $result = DB::select(
            DB::expr('max(sequence_id) + 1 as sequence_id')
        )->
        from(static::table())->
        where('user_id', '=', $this->user_id)->
        execute()->
        current()['sequence_id'];

        return isset($result) ? (int)$result : 1;
    }
}

新しいシーケンスIDをsave前に割り当てる
<?php
class Controller_ServiceB extends \Controller_Base
{
    protected static $_properties = array(
        'sequence_id' => array(
            'data_type' => 'int',
        ),
    );

    public function _event_before_insert()
    {
        $this->sequence_id = static::getNewSequenceId();
    }
}

開発メンバーの感想

最初に標準フレームワークを導入したプロジェクトでの利用も進んできたので、利用している三名の開発メンバーに使用した感想を聞いてみました。
良い点と悪い点に分けていくつか紹介したいと思います。
  • 良い点
    • サービスのロジックとvalidationが分離されていてわかりやすい
    • コントローラでの入力と処理、出力が分離されていてわかりやすい
    • 単体テストに新しいメソッドのテストを追加しやすい
    • 標準に沿って実装されたソースコードが勉強になった
  • 悪い点
    • 特定のモデルのメソッド数が多過ぎる
今回のプロジェクトメンバーはフレームワークを使った開発が初めてだったり、FuelPHPを使うのが初めてだったりで、FuelPHPに対して良い使用感を持った感じだと思いますが、その持ち味を損ねずに拡張できているということだと思うので良かったです。
勉強になったと思ってもらえたのも良かったです。

モデルの数は71個あり、そのうち最もメソッド数が多いモデルの全体に占める行数の割合は18%程度で確かに大分偏っています。今回のプロジェクトの設計の問題なので別途改善したいと思います。

反省と感想


この標準フレームワークを開発する以前にもFuelPHPで開発していたプロジェクトで設計や実装をしたことはありましたが、正直そのときはFuelPHPをちゃんと使えていませんでした。
今回標準フレームワークを開発するにあたって、当時の開発メンバー(今回のプロジェクトメンバーとは全員別のメンバー)にも改善したい点をヒアリングして、より洗練された形で設計、実装しようというところから開始しました。
そのときの改善要望はいくつか上がりましたが、以下の二点の改善要望が強かったです。
  • コントローラの処理が複雑なので簡易化したい
  • 単体テストの追加と保守が大変なので簡易化したい

これを受けて、コントローラと単体テストを簡易的に書ける仕組みを標準化の最初のターゲットとして進めてきました。
そうして始めたこのプロジェクトの反省点や感想をいくつか書いておきたいと思います。

コントローラは今回標準化したものを利用して、コントローラにはサービス固有のロジックを組み込むだけで済み、見通し良くコントローラを実装できるようになっていて、今後のプロジェクトでも概ねそのまま利用できると思います。
モデルは要件がはっきりしない段階で標準化するのは難しく、今後要件が明らかになるにつれ発展させていく必要があると思います。

単体テストもDBにデータを用意せずに済むように改良したことで、テストごとの独立性が高くなり、追加しやすく、保守性も向上して単体テストに費やすコストを下げられたと思います。

それと、効果の程はわからないながらも標準フレームワークに一般的なMVCフレームワークのものとあまり変わらないガイドラインも導入してみました。
開発経験が浅いメンバーでの開発ということもあり、限られている用途に合わせて焦点を絞ってコンパクトにまとめ直した効果は予想以上にあったと思います。

これまで、標準フレームワークの実装中に生じた問題をいくつか開発メンバーと共有しながら進めてきましたが、そういった問題も自発的に一緒に解決していこうという姿勢も見られ始めてきて、そういったところも嬉しく感じました。

最初のプロジェクトはこの標準フレームワークを使って開発を進めることで、FuelPHPの扱い方や開発メンバーの意識向上も含め、色々な点で大きく改善されたと実感しています。

最後に

最近になり標準フレームワークを二つ目のプロジェクトにも適用することができました。
ブログでは詳細に触れていませんが、標準化したAnsibleのPlaybookを利用して開発環境を構築するところから始まり、本ブログで紹介したコントローラとモデルをベースに利用することで、プロジェクト開始直後にアプリケーションロジックの開発から取り掛かれるようになり、その点でも大きな改善が見られたと思います。

今後導入対象のシステム要件が明らかになることで、ロジックの共通化による開発効率の向上も期待でき、そうなるとサービス提供のリードタイムも大きく改善できると思います。

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。