見出し画像

入門laravelチュートリアルを構造的に抽象的に理解する(7) エラーハンドリング


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

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

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

1 存在しないコンテンツ


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

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


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


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

たくさんのステータスコードが定義されている。ただ実際アプリケーション開発でよく使うのは 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 関数が呼び出されると引数のレスポンスコードで、コードに対応するエラーページが返却される。言語的には例外が投げられるので、以降の処理は実行されない。

ここでは上記のようにコントローラーメソッドで abort 関数を呼び出す方法の問題点を考えます。

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

index、showCreateForm、create、showEditForm、edit、、、すべてですね!

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

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


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


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

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

/something/何かのID

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

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


ルーティング

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

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

URL の {id} を {folder} に書き換える。ここに、フォルダのIDが入る。名前が変わっただけで入るものは変わらない。


コントローラ

TaskController.php

ublic 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 とみなし、自動的に引数の型のモデルクラスインスタンスを作成する。

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


タスク作成機能と編集

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

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 メソッドと同様に編集します。一つずつ説明するのは冗長かと思いますので、クラスの全文を載せます。

<?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,
       ]);
   }
}


2 権限がないコンテンツ


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


abort関数

まずは abort 関数による実現方法を学ぶ。

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

   // 以下略
}

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

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


ポリシークラス


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

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

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


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


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

$ php artisan make:policy FolderPolicy

雛形 app/Policies/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 の変数部分)を示す

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

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

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

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


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


では、タスク一覧以外のルートにもポリシーを適用する。

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

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();

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


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


次にリレーションが存在しないパターンを考える。タスク編集ルートの 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 でのアクセスにも対策が取れた。


4 エラー画面を作る


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

エラー画面を作るのは非常に簡単で、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

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​


この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!
感謝しまスロベキア
2
ただひたすら最善手。塞翁が馬。

こちらでもピックアップされています

チュートリアル
チュートリアル
  • 7本
コメントを投稿するには、 ログイン または 会員登録 をする必要があります。