2018.12.10

入門Laravelチュートリアル (10) エラーハンドリング


この連載記事では、Laravel を使用した Web アプリケーションの開発方法を紹介します。実際に(お決まりの?)ToDo アプリを開発する手順を通して Web 開発のエッセンスを学んでいただけるように書いていきます。取り扱う Laravel のバージョンは現時点で最新の 5.7 です。

👇 電子書籍版も公開しています。

第10章ではエラーハンドリングと題して、アプリが想定する通常の使用とは異なる URL をリクエストされてもクラッシュせずにユーザーに適切なフィードバックを返却する方法を説明します。

Web アプリケーションでは、ユーザーがリクエストする URL は決して制限できません。ページ上にリンクがなかったとしても、ユーザーはブラウザのアドレスバーで自由な URL を作り出してアプリケーションにリクエストを送信できるのです。

しかし、開発者としては想定したリクエストと違うからといってアプリケーションがクラッシュするに任せるわけにはいきません。例えば定義されていない URL にアクセスがあったとき、システムが例外を吐いて真っ白な画面になってしまってはユーザーは訳が分かりません。単に URL を間違えただけかもしれないのに「このアプリは壊れてる」と思われたら機会損失ですよね。代わりに「そのページは見つかりません」といった適切なフィードバックを返してあげるべきです。

アプリケーション開発は「普通に」使える実装でおしまいではありません。URL の構造やロジックから起こりうるエラーパターンまで想定して対処する必要があります。

存在しないコンテンツ

まずは存在しないコンテンツにアクセスされた場合を考えます。つまり例えば存在しないフォルダの ID を含むタスク一覧の URL にアクセスした場合などです。試しに /folders/999 などの URL でアクセスしてみてください。task メソッドを呼び出せないエラー画面が表示するはずです。

これはアプリケーションがクラッシュしている状態です。しかしこの場合のユーザーへの適切なフィードバックは「アプリケーションが壊れました」ではなく「お探しのページは見つかりません」であるべきです。この所謂 404 ページを皆さんも見たことがあるのではないでしょうか。

レスポンスステータスコード

HTTP の世界では、リクエストに対するレスポンスにはステータス(状態)を表すコード番号を添えるという決まりがあります。リクエストを受けての処理が成功したのか、失敗したのか、失敗したのなら原因はクライアント側かサーバー側か、という情報を決められたステータスコードで表現します。どのようなステータスコードがあるのか、下のページを読んでみてください。

HTTP レスポンスステータスコード - HTTP | MDN

たくさんのステータスコードが定義されていますね。ただ実際アプリケーション開発でよく使うのは 200, 201, 302, 303, 401, 404, 403, 500 あたりでしょう。他に Laravel ではバリデーションエラーの場合のレスポンスコードとして 422 が使用されています。

レスポンスコードにはそれぞれ意味があるので、状況によって適切なコードを選択しましょう。

ここでは存在しないコンテンツへのアクセスということで 404 を返却します。

abort 関数

Laravel ではエラー系(400番台 / 500番台)のレスポンスを返却する一番手軽な方法は abort 関数を使うことです。タスクコントローラーの index メソッドでフォルダデータを取得処理の下に以下のようにコードを追加してください。

TaskController.php
public function index(Folder $folder)
{
    // 略

    // 選ばれたフォルダを取得する
    $current_folder = Folder::find($id);

    if (is_null($current_folder)) {
        abort(404);
    }

    // 略
}

abort 関数が呼び出されると引数のレスポンスコードで、コードに対応するエラーページが返却されます。言語的には例外が投げられるので、以降の処理は実行されません。

ブラウザからもう一度存在しないフォルダ ID のタスク一覧画面にアクセスしましょう。
今度は Laravel デフォルトの 404 ページが表示されたはずです。

このページをオリジナルのページに変更する方法はこの章の最後で紹介します。ここでは上記のようにコントローラーメソッドで abort 関数を呼び出す方法の問題点を考えます。

タスクコントローラー全体をざっと見てみましょう。先ほどと同じくフォルダデータが取得できなかったら abort(404) を呼ぶ、という処理が必要なメソッドはどれでしょうか。

indexshowCreateFormcreateshowEditFormedit、、、すべてですね!

もっと言えば、すべてのコントローラーメソッドで ①フォルダデータを取得する、②取得できなかったら abort(404) を呼ぶ、という一連の処理を記述する必要がありそうです。要するにこのまま abort での実装方法を進めるとコードの重複がたくさん発生するでしょう。

この重複を防いでコードを美しく保つ機能が Laravel には用意されています。
「ルートモデルバインディング」です。

ルートモデルバインディング

ルートモデルバインディングは、Web アプリケーションでありがちな処理をまとめてフレームワーク側で面倒を見てくれる機能です。一言で言うと、ルーティングで定義された URL から自動的にデータを取得し、モデルクラスインスタンスをコントローラーメソッドに渡してくれます。

Web アプリケーションではコンテンツを特定する ID を URL に含めて、コントローラー側でその ID に対応するデータを取得し、取得できなかったら 404 を返却する処理は、言語やフレームワークを問わず頻出パターンです。

// URL
/something/何かのID

// コントローラーメソッド
$something = Something::where('id', 何かのID)->first();
if (is_null($something)) {
    abort(404);
}

ルートモデルバインディングを使えばこの一連のパターンをいちいち自分で書かずともフレームワークに任せることができます。

ルーティング

まずはタスク一覧のルート定義を編集します。

web.php
Route::get('/folders/{folder}/tasks', 'TaskController@index')->name('tasks.index');

URL の {id}{folder} に書き換えてください。

コントローラー

次にコントローラーメソッドを編集します。

TaskController.php
public function index(Folder $folder)
{
    // ユーザーのフォルダを取得する
    $folders = Auth::user()->folders()->get();

    // 選ばれたフォルダに紐づくタスクを取得する
    $tasks = $folder->tasks()->get();

    return view('tasks/index', [
        'folders' => $folders,
        'current_folder_id' => $folder->id,
        'tasks' => $tasks,
    ]);
}

int 型の $id を受け取るのではなく、Folder クラスの $folder を受け取るよう記述します。これだけで URL 中の ID に該当するフォルダデータがコントローラーメソッドに渡されます。そのためフォルダデータを取得していた記述と abort していた記述は不要になります。あとは $current_folder$folder という変数名に合わせて書き換えます。

Route::get('/folders/{folder}/tasks', ... );

public function index(Folder $folder)

Laravel は、ルーティング定義の URL の中括弧で囲まれたキーワード({folder})とコントローラーメソッドの仮引数名($folder)が一致していて、かつ引数が型指定(Folder)されていれば、URL の中括弧で囲まれた部分の値を ID とみなし、自動的に引数の型のモデルクラスインスタンスを作成します。

ルートとモデルを結びつける(バインディング)機能というわけです。

タスクの作成と編集

タスク作成と編集のルートにもモデルとのバインディング機能を適用しましょう。

web.php
Route::get('/folders/{folder}/tasks/create', 'TaskController@showCreateForm')->name('tasks.create');
Route::post('/folders/{folder}/tasks/create', 'TaskController@create');

Route::get('/folders/{folder}/tasks/{task}/edit', 'TaskController@showEditForm')->name('tasks.edit');
Route::post('/folders/{folder}/tasks/{task}/edit', 'TaskController@edit');

タスク編集のルートにはタスクモデルもバインディングしています。

コントローラーメソッドもそれぞれ index メソッドと同様に編集します。一つずつ説明するのは冗長かと思いますので、クラスの全文を載せます。

TaskController.php
<?php

namespace App\Http\Controllers;

use App\Folder;
use App\Http\Requests\CreateTask;
use App\Http\Requests\EditTask;
use App\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class TaskController extends Controller
{
    /**
     * タスク一覧
     * @param Folder $folder
     * @return \Illuminate\View\View
     */
    public function index(Folder $folder)
    {
        // ユーザーのフォルダを取得する
        $folders = Auth::user()->folders()->get();

        // 選ばれたフォルダに紐づくタスクを取得する
        $tasks = $folder->tasks()->get();

        return view('tasks/index', [
            'folders' => $folders,
            'current_folder_id' => $folder->id,
            'tasks' => $tasks,
        ]);
    }

    /**
     * タスク作成フォーム
     * @param Folder $folder
     * @return \Illuminate\View\View
     */
    public function showCreateForm(Folder $folder)
    {
        return view('tasks/create', [
            'folder_id' => $folder->id,
        ]);
    }

    /**
     * タスク作成
     * @param Folder $folder
     * @param CreateTask $request
     * @return \Illuminate\Http\RedirectResponse
     */
    public function create(Folder $folder, CreateTask $request)
    {
        $task = new Task();
        $task->title = $request->title;
        $task->due_date = $request->due_date;

        $folder->tasks()->save($task);

        return redirect()->route('tasks.index', [
            'id' => $folder->id,
        ]);
    }

    /**
     * タスク編集フォーム
     * @param Folder $folder
     * @param Task $task
     * @return \Illuminate\View\View
     */
    public function showEditForm(Folder $folder, Task $task)
    {
        return view('tasks/edit', [
            'task' => $task,
        ]);
    }

    /**
     * タスク編集
     * @param Folder $folder
     * @param Task $task
     * @param EditTask $request
     * @return \Illuminate\Http\RedirectResponse
     */
    public function edit(Folder $folder, Task $task, EditTask $request)
    {
        $task->title = $request->title;
        $task->status = $request->status;
        $task->due_date = $request->due_date;
        $task->save();

        return redirect()->route('tasks.index', [
            'id' => $task->folder_id,
        ]);
    }
}

権限がないコンテンツ

続いて権限がないコンテンツへのアクセスへの対策を考えます。存在はするが自分のものではないフォルダの ID を含む URL にアクセスされた場合です。この場合は権限がないことを意味する 403 コードをレスポンスするのが適切でしょう。

abort 関数

まずは abort 関数による実現方法を紹介します。

TaskController.php
public function index(Folder $folder)
{
    if (Auth::user()->id !== $folder->user_id) {
        abort(403);
    }

    // 以下略
}

例によってタスク一覧表示の処理で考えます。index メソッドの冒頭に上記のコードを追加してください。ログインユーザーの ID とフォルダの user_id カラムの値を比較しています。一致しなければログインユーザーはそのフォルダとは紐づいていない、つまり閲覧する権限がないので abort(403) を実行します。

ブラウザで確認してみましょう。存在はするが現在のログインユーザーとは紐づいていないフォルダの ID でタスク一覧にアクセスします。

先ほどの 404 とは違う 403 のエラーページが表示されましたね?

しかし、この abort(403) を呼び出す処理もすべてのコントローラーメソッドに繰り返し同じく記述しなければいけません。その重複を排除するために、ポリシークラスを紹介します。

ポリシークラス

ポリシークラスは Laravel での認可(Authorization)処理を司ります。

認可というのは、ユーザーの持つ権限にしたがって特定の処理を許可するか判断することです。認証(Authentication)とは似て非なる概念ですね。

ポリシークラスはモデルクラスを元に認可処理を行います。

ポリシークラスを作成する

コマンドラインからポリシークラスを作成します。

$ php artisan make:policy FolderPolicy

雛形 app/Policies/FolderPolicy.php を以下の内容で編集してください。

FolderPolicy.php
<?php

namespace App\Policies;

use App\Folder;
use App\User;

class FolderPolicy
{
    /**
     * フォルダの閲覧権限
     * @param User $user
     * @param Folder $folder
     * @return bool
     */
    public function view(User $user, Folder $folder)
    {
        return $user->id === $folder->user_id;
    }
}

ポリシークラスでは認可処理を、真偽値を返すメソッドで表現します。FolderPolicy クラスでは view メソッドによって「ユーザーとフォルダが紐づいているときのみ許可する」という意味の認可処理が定義されています。何を許可するのかはここでは定義しません。

ポリシーとモデルを紐づける

作成したポリシーは AuthServiceProvider に登録します。

まずは Folder クラスと FolderPolicy クラスをインポートします。

AuthServiceProvider.php
<?php
namespace App\Providers;

use App\Folder; // ★ 追加
use App\Policies\FolderPolicy; // ★ 追加

$policies プロパティでモデルクラスとポリシークラスを紐づけます。

AuthServiceProvider.php
protected $policies = [
    Folder::class => FolderPolicy::class,
];

Folder モデルに対する処理への認可には FolderPolicy ポリシーを使用する、という意味です。

ポリシーをミドルウェアを介して使用する

では作成したポリシーを使用しましょう。いくつかの方法がありますが、今回はミドルウェアから呼び出して使用する方法を紹介します。

まずはタスク一覧のルートにのみミドルウェアを適用します。

web.php
Route::group(['middleware' => 'can:view,folder'], function() {
    Route::get('/folders/{folder}/tasks', 'TaskController@index')->name('tasks.index');
});

can という名前のミドルウェアは、引数(コロン以降の部分)から適切な認可処理を判定してコントローラーメソッド実行前に適用します。認可処理が true を返せばそのまま後続処理に移り、false を返せば処理を中断してコード 403 でレスポンスします。can ミドルウェアの引数(view,folder)はカンマ区切りになっていて、カンマの左側が認可処理の種類、右側がポリシーに渡すルートパラメーター(URL の変数部分)を示します。

ルートモデルバインディングによってルートパラメーターから対応するモデルクラスが割り出されます。モデルクラスが分かると AuthServiceProvider に登録した内容から適用すべきポリシークラスを特定できます。さらに認可処理の種類はポリシークラスのメソッド名とみなされます。

つまり今回は view,folder という引数から、Folder モデル → FolderPolicy ポリシーの view メソッドが認可に使用されることになります。view メソッドで定義された認可処理は「ユーザーとフォルダが紐づいているときのみ許可する」という内容でした。

結果としてタスク一覧にアクセスしたとき、ユーザーに対して、ルートモデルバインディングで取得できたモデルインスタンスへの上記の認可処理を実行します。

コード間の関連が複雑ですが、少ないコードの記述でコントローラーメソッド内で同じ処理を繰り返し記述せずに済む、便利な機能です。

では abort(403) のコードは削除してもう一度ブラウザから動作を確かめてください。

ログインユーザーに紐づかないフォルダのタスク一覧ページへのアクセスに対して 403 ページがレスポンスされたでしょうか?

すべてのルートにポリシーを適用する

では、タスク一覧以外のルートにもポリシーを適用しましょう。

ルーティングの定義は以下の通りです。

web.php
<?php

Route::group(['middleware' => 'auth'], function() {
    Route::get('/', 'HomeController@index')->name('home');

    Route::get('/folders/create', 'FolderController@showCreateForm')->name('folders.create');
    Route::post('/folders/create', 'FolderController@create');
    
    Route::group(['middleware' => 'can:view,folder'], function() {
        Route::get('/folders/{folder}/tasks', 'TaskController@index')->name('tasks.index');

        Route::get('/folders/{folder}/tasks/create', 'TaskController@showCreateForm')->name('tasks.create');
        Route::post('/folders/{folder}/tasks/create', 'TaskController@create');

        Route::get('/folders/{folder}/tasks/{task}/edit', 'TaskController@showEditForm')->name('tasks.edit');
        Route::post('/folders/{folder}/tasks/{task}/edit', 'TaskController@edit');
    });
});

Auth::routes();

ルートグループはネストすることができるので、まず認証ミドルウェアを適用してから、必要なルートに対しては認可ミドルウェアを適用しています。

リレーションが存在しない

次にリレーションが存在しないパターンを考えましょう。タスク編集ルートの URL にはフォルダ ID および タスク ID が含まれていますが、このフォルダ ID とタスク ID がちぐはぐで紐づいていなかったらどうなるかということです。

いまのところ、フォルダが存在してそのフォルダとログインユーザーが紐づいてさえいれば処理を実行できます。つまりタスク ID が他者のものでも編集できてしまうということです。

これはかなり脆弱ですね!そこで処理を実行する前にフォルダとタスクの紐づきを確認して、紐づいていなければ 404 を返すことにします。

TaskController.php
public function showEditForm(Folder $folder, Task $task)
{
    if ($folder->id !== $task->folder_id) {
        abort(404);
    }

    // 以下略
}

public function edit(Folder $folder, Task $task, EditTask $request)
{
    if ($folder->id !== $task->folder_id) {
        abort(404);
    }

    // 以下略
}

ここでは abort 関数を使用して実装します。ただしやはり重複はなんとかしたいですね。

以下のように、チェックの処理をメソッドに切り出しましょう。

TaskController.php
public function showEditForm(Folder $folder, Task $task)
{
    $this->checkRelation($folder, $task);

    // 以下略
}

public function edit(Folder $folder, Task $task, EditTask $request)
{
    $this->checkRelation($folder, $task);

    // 以下略
}

private function checkRelation(Folder $folder, Task $task)
{
    if ($folder->id !== $task->folder_id) {
        abort(404);
    }
}

これで意図しない URL でのアクセスにも対策が取れました。

エラー画面を作ろう

この章の最後に、オリジナルのエラー画面を作成する手順を紹介します。

エラー画面を作るのは非常に簡単で、resources/views ディレクトリにさらに errors ディレクトリを作成します。この errors ディレクトリに レスポンスコード.blade.php という名前でテンプレートを作成すれば、abort 関数などでエラー系のレスポンスが作成されるときに対応するファイル名のテンプレートが画面として使われます。

以下が作成例です。

404

resources/views/errors/404.blade.php

404.blade.php
@extends('layout')

@section('content')
  <div class="container">
    <div class="row">
      <div class="col col-md-offset-3 col-md-6">
        <div class="text-center">
          <p>お探しのページは見つかりませんでした。</p>
          <a href="{{ route('home') }}" class="btn">
            ホームへ戻る
          </a>
        </div>
      </div>
    </div>
  </div>
@endsection

403

resources/views/errors/403.blade.php

403.blade.php
@extends('layout')

@section('content')
  <div class="container">
    <div class="row">
      <div class="col col-md-offset-3 col-md-6">
        <div class="text-center">
          <p>お探しのページにアクセスする権限がありません。</p>
          <a href="{{ route('home') }}" class="btn">
            ホームへ戻る
          </a>
        </div>
      </div>
    </div>
  </div>
@endsection

500

サーバー側のエラーで正常なレスポンスを返せないことを表すのが 500 番です。システムエラー画面とも呼ばれます。400 や 403 と同じ要領で作成してみましょう。

🎲 🎲 🎲

第10章はこれでおしまいです。
ここまでのソースコードはリポジトリ(chapter10 ブランチ)を参照してください。

そしてここまでで ToDo アプリケーションの実装は完了しました 😆 🎉
機能は多くないですが、しっかり動作する一人前のアプリではないでしょうか。

チュートリアルのラストを飾る11章では、作成したアプリケーションを Heroku というクラウドサービスを利用してインターネットに公開する方法を紹介します。

連載記事