Laravel3で活かす例外

タグ: Laravel3  

Laravelは使いやすいフレームワークです。とはいえ、コードも書き慣れてくるといささか気になってくることもあります。

私の場合、特にif文の入れ子です。Laravelの場合、推奨されている書き方はビューの表示にGETメソッド、フォームの処理にはPOSTメソッドとHTTPメソッドで処理を分けることです。これは他のフレームワークでも同様にすることで、if分を一つ減らせます。(フレームワークを使用しない場合でも、コードはすっきりします。)

さらに、認証のチェックはルートのフィルターを使用することで、if文を更に減らせます。CSRF対策もフィルターですから、もうひとつ減らせます。この2つは入れ子にする必要は無いにせよ、必要に応じ必ず書かれますね。

フォームの処理では、たいていバリデーションを行います。ほとんどのフレームワークでバリデーションのクラスが用意され、いつも同じようなコードを書くことになります。入力が有効かどうかをチェックするif文が一つ入ります。

こうして、同じようなコードになり、それでもちょっと変わった更新処理をしようとだらだらコーディングするとコントローラーが重くなり、テストがしづらくなります。これをどうにかしたいと思っていました。

2ヶ月前、Laravelフォーラムを見ていた時に、ふとバリデーションを含め定形的なチェックは全部例外として処理するアイデアが浮かびました。それなら、コントローラーは例外を受け、処理するだけなのでシンプルになります。

そこで、ちょっとした実験コードを書き、このアイデアをこうしてブログに書こうと思いましたが、どうしても内容が長くなりました。長すぎました。そうして一度は御蔵入りにしたものの、再度うまく伝える方法はないかと模索していました。そして昨日、前提知識として例外をLaravelに活用するアイデアとしてまず書いたら良いことに気が付きました。

PHPでは、例外を上手く使用したコードが少ないですね。一応ベストプラクティス的なものを探したのですが、良い記事がありませんでした。先日"PHP the right way"を読んでいたら、やはり例外の使用が少ないことが書かれていました。ですから、PHPで例外を多用しても間違いではないようです。開発者がPHPで例外を活用するアイデアを持っていないか、もしくは面倒なので行わないだけです。

例外を使用すると、コントローラーもすっきりと仕上げられます。(実は、Laravel以外でも活用できます。ですからこの記事を書いた後、あのフレームワークや、このフレームワークで似たような記事を書く人が出てくるでしょう。 :D 実際、よくあるんですよ。このサイトで記事を書いた後に、別のフレームワークで似た内容が書かれる事が。:P :D まあ、参照先としてリンクでも付けておいてくれたら文句はないんですけどね。一部借用でも、要約でも、アイデアの置き換えでもリンク張っておくのは著作権云々の前にマナーだと私は思っているんです。)

例外の基本

例外は「例外」です。何に対して例外なのかと言えば、プログラムの通常の流れに対して、例外処理を行う場合に使用します。一般的には、PHPの一部関数やライブラリー、フレームワーク、CMSの中で、「異常事態発生」の合図として使用されます。

例外は開発者が自分でも発生させることができます。例外を発生させるのは"throw"文です。「投げる」です。ですから例外を発生させることを「例外を投げる」とも言います。

発生した例外は何もしなければ、PHPにより、「例外発生」として通常はエラー表示されます。(今回は細かいエラー表示周りの設定には触れません。)発生したエラーを自分で捉えたい場合、その範囲をtry{}で囲み、捕まえる例外を"catch"で指定します。

try {
    // 例外を捕まえたいコードをずらずら書く
}
catch ( Exception $e ) // 捕まえたい例外のクラス
{
    // 捕まえた時の処理
}

例外も普通のクラスです。一番の親玉はExceptionクラスです。ですから、処理したい「例外」ごとにクラスを拡張することができます。もちろん、拡張したクラスを発生させることも、それを捕まえることもできます。

// 自分の独自例外を作成する
class myException extends Exception {}

class myCode {
    try {
        $this->myFunction();
    }
    catch ( myException $e) // 前記の独自クラスを捕まえる
        // 捕まえた時の処理
    }

    function myFunction() {
        if ($doushiyoumonai) {  // どうしようない事態発生
            throw new myException; // 独自例外を投げる
        }
    }
}

return文と同様に、throw文の後のコードは実行されません。また、今回の例ではmyExceptionクラスのみを捕まえていますので、他の例外が発生することがあっても捕まえられません。

私は自分の作成する例外クラスは2種類に分けています。

// プログラムエラー(発生したら、原則アプリを中止する)
class MyProgramErrorException extends Exception {}

// 実行時エラー
class MyExecutionErrorException extends Exception {}

これ以降も"My"をクラスのプレフィックスで使用しますが、プロジェクトやパッケージ名をプレフィックスとして付けるか、名前空間を利用することをおすすめします。例外クラス名は似たようなものになりがちですので、PHPや他のライブラリーの名前と干渉しないようにしましょう。

プログラムエラーはその名の通り、プログラム時のミスが起きた場合に使用します。この例外は原則catchしません。エラーとして表示し、プログラミング時に完全に対処することを目指します。

実行時エラーはその名の通り、実行時に起き得る「普通」の流れ以外で発生させる例外です。その状況により、アプリを中止させないようにする場合と、中止させる場合があります。Webアプリの場合は、原則中止させないように処理するため、ほとんどの状況では発生する可能性がある例外全部をcatchします。

全部?実行時エラーは一つしかない?そうですね。ですからコードを組んでいくと発生する状況に対応するための例外を増やす事になります。(あなたが優秀なら、最初から例外もうまく設計していることでしょう。)

// IDがテーブルに存在しない
class MyIdNotFoundException extends MyExecutionErrorException {}

// テーブルにレコードが存在しない
class MyNoRecodeExistException extends MyExecutionErrorException {}

// 通常はまだまだあります…

Exceptionクラスではなく、MyExecutionErrorExceptionクラスを拡張していることに注目です。

もちろん通常は、その状況で起こりえる例外を捉えて、個別に対処コードを記述します。

try {
    $apple = AppleRepository::get($id);
}
catch( MyIdNotFoundException $e )
{
    // IDが見つからない場合の処理
    return Redirect::error('404');
}
catch ( MyNoRecodeExistException $e )
{
    // レコードが無い場合の処理
    return Redirect::error('500');
}

場合により、まとめて処理したいこともありますよね。

try {
    $apple = AppleRepository::get($id);
}
catch( MyIdNotFoundException $e )
{
    // IDが見つからない場合の処理
    return Redirect::error('404');
}
catch ( MyExecutionErrorException $e )
{
    // IDが見つからない場合は前で処理されるので、
    // それ以外のMyExecutionErrorExceptionを
    // 拡張した残りのクラスは全部これで補足できる
    return Redirect::error('500');
}

例外クラスを継承で作成すれば、親のクラスをcatchで指定することでまとめて補足できます。一番大本のExceptionクラスをcatchすれば、あらゆる例外を補足できます。でも、そうした大きな処理はLaravelに任せ、私達は自分が作成した例外や、使用するライブラリー、バンドル、コンポーネントが発生させる例外に集中しましょう。

さて、catchで補足する実行時エラー系の例外を個別に定義するのは理解できました。それでは、原則補足しないで、「例外が発生しましたーーー。」と表示させるプログラムエラー系の処理はどうしましょう。

// 必要なクラスがない
class MyClassNotFound extends MyProgramErrorException {}

// 必要なファイルがない
class MyFileNotFoundException extends MyProgramErrorException {}

// こちらも、通常はまだまだあります…

例外のクラスをthrowし、それが補足されなければ、前述の通り「例外が発生しました」と英語で表示されます。そして、その例外が表示されます。

自分で作った例外であれば、その名前から何が起きているか種類は分かります。ですから、自作例外の場合は、これでも最低の情報はつかめます。ところが、時間が経てば私達は自分の決めたことも忘れますし、数が多くなれば名前だけで見分けるのは段々困難になってきます。

そこで、エラーにはそれぞれメッセージを出してあげるようにしましょう。このメッセージは例外クラスごとに設定するのではなく、throwする時のインスタンスの生成時に指定します。

throw new MyClassNotFound('指定されたクラスが見つかりません。クラス名:'.$className);

これで「例外が発生しました。」だけの寂しい英文だけでなく、日本語で自分に分かりやすいメッセージが表示できます。

まてよ。それなら、わざわざ個別にクラスを作成しMyProgramErrorExceptionを拡張しなくても、直接MyProgramErrorExceptionだけを利用し、メッセージを適切に切り替えて使用すれば良いのでは?

答えは、そうしたければそれでも良しです。通常のクラスの設計と同様に、開発者により考えが異なるでしょう。

今回わざわざ個別に設定するようにしました。なぜなら「原則catch」しないというのは、あくまで原則であり、場合によってはcatchしたいことも起き得ます。エラー処理の場合は、頻繁に発生するでしょう。ですから、個別に捉えられるよう、実行時エラー系と同様にプログラムエラー系の例外も個別に分けて定義する例を出しました。

しかし、ちょっとした小さいシステムではこの方法は手間が掛かり過ぎると負担に思えるでしょう。その場合は一つの例外を使いまわし、メッセージを切り換えるというのも正解でしょう。

さて、例外の使用方法を紹介しつつ、Laravelで使用するヒントも散りばめながら基本的な紹介をさせてもらいました。なんとなく、使えそうだと思えてきたでしょうか。既にピンと来ている人もいらっしゃることでしょう。

Laravelで応用する

今回はLaravelを使用し、例を上げて説明しますが、同じ考えはフレームワークが違えど適用可能です。場合によってはフレームワークに取り込まれているものもあるかも知れません。

さて、基本的な説明の中にIDが見つからない例外を入れておきました。気が付かれましたか。

例えばURIで/list/8とか、post/3/addとか、URIで指定され、コントローラーに渡される事が多いわけです。Laravelですとフィルターで処理できないこともありませんが、通常IDチェックだけにDBアクセスを発生させたくありませんから、フィルターは使用せずにコントローラーでこんなふうに書くことになります。

function get_show($id) {
    $post = Post::find($id);
    if ($post === null){
        return Redirect::error('404');
    }
    // これで$idはテーブルに存在する有効な
    // IDであったと考えられる。
    // $postの内容を使用できる。
    return View::make('post.add')
        ->with('post', $post);
}

皆さんのコントローラでもよく見かけるコードでしょう。フィルターでチェックできるのですが、フィルターでチェックした内容をコントローラーに渡せないため、改めて$idでpostsテーブルを引き直すことになり、無駄ですからどうしてもコントローラーで行うことになります。

また、入力フォームを処理する場合ならば、典型的なコーディングは以下の形式です。

function post_update($id) {
    // バリデーションの内容
    $rules = array(
        'title' => 'required|max:40',
        'body' => 'required|max:100',
    );
    // IDの存在チェック
    $post = Post::find($id);
    if ($post === null){
        return Redirect::error('404');
    }
    // バリデーションの内容
    $inputs = Input::only(array('title', 'body'));
    $val = Validator::make($inputs, $rules);
    if ($val->passed()) {
        // バリデーション成功
        // 更新
        $post->fill($inputs);
        $post->save(); // 本来、保存成功かチェックが必要
        return View::make('post.update')
            ->with('message', '更新しました');
    }
    // バリデーション失敗
    return View::make('post.update')
        ->with_errors($val)
        ->with_input();
}

よく、チュートリアルではIDの存在チェックは抜かされますが、現実的なプログラミングでは必要です。IDはURIで渡される内容です。URIは改変可能です。ですから、IDの値を持つレコードがテーブルに存在しない可能性があります。

LaravelのORMであるEloquentはPDOを使用し、渡された内容を必ずエスケープしてくれますので、直接EloquentモデルであるPostに渡すことが可能です。セキュリティ的に直接IDを渡しても問題ありません。

そしてバリデーションのコードです。これがコントローラーに存在することに慣れてしまい、疑うことがありませんが、よくよく考えれば、バリデーションはビジネスロジックです。モデルとビューの「コントロール」には直接関係がありません。ですから、本来はモデル(MVCパターンではビジネスロジックはモデルに置くべきだと偉い人が言っているらしいです。)で行うことです。ただし、モデルと言ってもEloquentだけに限りませんよ。

今回、私が提案するのは「正しいIDで正しいレコードを入手し、表示する」、「正しいID、正しいフォーム入力で、レコードを更新する」以外のルートは全部「例外」であると考え、例外を通して処理することです。

この考えで、最初の1レコード表示コントローラーを記述するとこうなります。

function get_show($id) {
    try {
        $id = PostRepository::find($id);
    }
    catch ( MyIdNotFoundException $e )
    {
        return Redirect::error('404');
    }
    return View::make('post.add')
        ->with('post', $post);
}

具体的なPostRepositoryの実装方法については、今回は紹介しません。$idをチェックしnullであれば、MyIdNotFoundException例外を投げます。find()メソッドが返すのはEloquentのPostクラスです。

同様に更新処理も考えましょう。

function post_update($id) {
    $inputs = Input::only(array('title','body'));
    try {
        $id = PostRepository::update($id, $inputs);
    }
    catch ( MyIdNotFoundException $e )
    {
        return Redirect::error('404');
    }
    catch ( MyValidationFaildException $e )
    {
        return View::make('post.update')
            ->with_errors($e->val)
            ->with_input();
    }
    catch ( MyUpdateFaildException $e )
    {
        return Redirect::error('500');
    }
    return View::make('post.update')
            ->with('message', '更新しました');
}

PostRepositoryのupdate()メソッドの実装は考えません。しかし、このコードが全部物語っています。

  • IDをチェックし、存在しなければMyIdNotFoundException例外が投げられるでしょう
  • バリデーションに失敗したら、MyValidationFaildException例外が投げられるでしょう。そのクラスにはvalというプロパティがあり、バリデーションクラスが渡されます。
  • 更新処理に失敗したら、MyUpdateFaildException例外が投げられます。

とても単純です。ロジックを追っかける必要がありません。コントローラーはモデルの実行結果に従い、ビュー(もしくはビューを表示するリダイレクト)を指定しているだけです。

PostRepositoryをどう実装するかとか、どの様なクラス構成にするかとかは今回触れません。別の記事にします。あくまでも例外を使用するアイデアについて理解してください。

まだ、学んでいない点はMyValidationFaildExceptionでValidtorクラスの実体を渡す方法です。これを最後に紹介しましょう。

class MyValidationFaildException extends MyExecutionErrorException
{
    public $validator;

    public function __construct( $validator, $message = null, $code = 0, Exception $previous = null )
    {
        parent::__construct( $message, $code, $previous );

        $this->validator = $validator;
    }
}

構成は通常のクラスと一緒です。大本のExceptionクラスの引数の並びに加え、最初の引数としてValidatorクラスのインスタンスを渡せるようにします。それをコンストラクターでセットするだけです。他の例外でも同様に、例外の処理で必要な情報を渡すことができます。

例外を投げる方のコードはこのようになるでしょう。

$val = Validator::make($inputs, $rules);
if ($val->failed())
{
    throw MyValidationFaildException($val, 'バリデーションに失敗しました。');
}

例外の処理コード中で意図的にメッセージを表示しなければ、補足した例外のメッセージが自動的に表示されることはありません。ですから、この場合メッセージを無理に指定する必要はありません。これが役に立つとしたら、catchし忘れた場合に、メッセージが表示されるので、ミスに気づきやすくなることでしょう。

尚、最後になりますが、この記事のコードは実行を確認したものでなく、この記事のために直接打ち込んだものです。そのため、間違いを含んでいる可能性があります。ご了承ください。

続きの記事