例外処理は知ってるけどうまく書けない人のためのテンプレ
みなさん、例外処理ってきちんと書いていますか?
例外処理が大事なことはわかっていても、良い書き方がわからないって方も結構居るんじゃないでしょうか。
try〜catch
の構文はわかってるし、いい見本があれば書けるんだけど・・・ってパターンはプログラミングをする中でもよく当たる壁ですよね。(自動テストなんかも・・・)
さて今回は、そんなイメージの湧きにくい例外処理を、 テンプレ化できるくらい具体的かつシンプル にまとめてみましたので、それをご紹介いたします!
# APIで学ぶ 例外処理の例
宗教戦争の回避
REST APIと言うと戦争が起きるらしいんですが、 厳密な意味で捉えず、Web APIの一つの形くらいに思っといて下さい。
# GET
REST APIにおけるGETといえば、商品一覧を取得するための items/
と特定商品の詳細を取得するための items/{id}
があり、
Laravelではそれぞれ index()
と show()
のメソッドが対応します。
まずは index()
から実際のコードを見てみましょう。
class ItemController extends Controller
{
public function index(Request $request)
{
try {
return Item::where(/* 検索条件 */)->get();
} catch (\Throwable $e) {
// 全てのエラー・例外をキャッチしてログに残す
\Log::error($e);
// フロントに異常を通知するため例外はそのまま投げる
throw $e;
}
}
}
index()
に関しては、結果が空になることはあってもエラーが起きるはずはないメソッドです。
ここで起きるエラーとして考えられるのは、SQL構文エラーか、データベースそのもののトラブルなどになります。 ちなみにサーバがダウンした時など、キャッチする間もなく落ちる場合もあるようですがそれはさすがに追えませんw
ともかく、想定外のエラーが起きた場合にきちんとログが追えるようにしておくため、
PHP7系を使っているなら \Exception
ではなく \Throwable
でエラーを含めた全ての異常値をロギングするようにしましょう。
また、catch内で例外を再度スローしていますが、フロントに異常を知らせたくない場合は空配列([]
)を返すことも考えられます。
- 全異常を捕捉する時は
\Exception
ではなく\Throwable
を使おう - catch内ではログを残そう
続いて show()
の実装を見てみます。
class ItemController extends Controller
{
public function show(int $id)
{
try {
return Item::findOrFail($id);
} catch (ModelNotFoundException $e) {
// データが見つからなかっただけならロギング不要
throw $e;
} catch (\Throwable $e) {
// それ以外のエラーは想定外なのでログに残す
\Log::error($e);
throw $e;
}
}
}
show()
メソッドの場合は $id
が定まっているので正常系の実装は単純になりましたが、
代わりに例外処理が2段構えになっています。
POINT
catchを複数連結させる時は、スコープの狭い順に並べよう。
つまり、 特定の例外
→ その他全ての例外
の順ってこと。
Laravelの findOrFail($id)
は、 $id
と一致するデータが見つかった場合はそのインスタンスを、
見つからなかった場合には \Illuminate\Database\Eloquent\ModelNotFoundException
をスローします。
ここで発生する例外は、 起きた原因が自明であり、かつこのメソッドに責任がない問題 のためロギングは不要です。
その他のエラーは商品一覧と同じくロギングを行います。
- catchは複数連結させることができる
- エラー/例外の原因が自明で、かつこのメソッドに責任がない問題はログに残さなくて良い
# POST
POSTは新規作成で、Laravelでは create()
が対応します。
「商品の追加」にあたるPOSTは商品データや画像などを保存する処理が必要になり、 データを返すだけのGETよりも実装は複雑になります。
今回は割愛しますが、 create()
メソッドの前段にユーザー認証や管理者権限のチェックなども必要になります。
これらは通常、ミドルウェアで実装します。
class ItemController extends Controller
{
public function create(Request $request)
{
// バリデーションエラーはキャッチ不要
$this->validate($request, $this->validationRuleForCreate);
try {
// トランザクションの開始
\DB::beginTransaction();
if ($request->hasFile('image')) {
// 画像の保存処理 成功したらファイル名 失敗したら例外を返す
$image_path = Item::IMAGE_DIR . Item::saveImage($request->file('image'));
}
// データの作成(この時点ではDBには保存されない)
$item = Item::make($request->all());
$item->image_path = $image_path ?? '';
// 作成したデータをDBに保存 失敗したら例外を返す
$item->saveOrFail();
// 全ての保存処理が成功したので処理を確定する
\DB::commit();
return ['message' => '保存に成功しました。'];
} catch (\Throwable $e) {
// 例外が起きたらロールバックを行う
\DB::rollback();
// 失敗した原因をログに残す
\Log::error($e);
// フロントにエラーを通知
throw $e;
}
}
}
コード量が増えましたねw
実際には様々な要件があると思いますので、もっと長く複雑なコードになってしまうこともあるかと思います。
そうしてメソッドが肥大化してしまった場合でも、「条件に当てはまらなかった場合は例外をスロー」を徹底するだけで、 一定のコードの読みやすさが保たれます。 早期リターン と同じ感覚で 早期スロー を心がけるのをおすすめします。
また、今回 try
の手前でバリデーションを行っていますが、これは 起きた原因が自明であり、かつこのメソッドに責任がない問題 に該当します。
バリデーションエラーが起きるのはユーザーの入力が原因であって、フロント側で責任をもって処理すべき内容です。
また、LaravelやVueのライブラリの vForm では、 Laravelで発生したバリデーションエラーをフロントでハンドリングする機能を備えていますので、 レスポンスを加工したり、例外を握りつぶすようなことはしないようにしましょう。
POINT
- トランザクション処理中は、早期リターンの代わりに早期スローを使う
- Laravelのバリデーションエラーはフロントにとって有益な情報源。捕まえたりせずにそっと逃してあげよう。
# PUT/PATCH
PUT/PATCHはLaravelのAPIではどちらも update()
が対応します。
厳密には PUTは置換・PATCHは更新
の意味合いらしいので、気にする人はそこらへんもきっちりしたら良いと思います。
また、このメソッドは 冪等性(べきとうせい)
が求められるとも言われていますので、覚えておくと得するかも知れません。
今回は特に掘り下げませんので、気になる人はggってみてください。
class ItemController extends Controller
{
public function update(Request $request, $id)
{
// バリデーションエラーはキャッチ不要
$this->validate($request, $this->validationRuleForUpdate);
try {
// トランザクションの開始
\DB::beginTransaction();
// 更新対象の商品を検索
$item = Item::findOrFail($id);
if ($request->hasFile('image')) {
// 画像の更新処理 成功したらファイル名 失敗したら例外を返す
$image_path = Item::IMAGE_DIR . Item::updateImage($request->file('image'), $item->image_path);
}
// データの更新(この時点ではDBのデータは更新されない)
$item->fill($request->all());
$item->image_path = $image_path ?? '';
// 更新したデータをDBに保存 失敗したら例外を返す
$item->saveOrFail();
// 全ての更新処理が成功したので処理を確定する
\DB::commit();
return ['message' => '更新に成功しました。'];
} catch (ModelNotFoundException $e) {
// データが見つからなかっただけならロギング不要
throw $e;
} catch (\Throwable $e) {
// 例外が起きたらロールバックを行う
\DB::rollback();
// 失敗した原因をログに残す
\Log::error($e);
// フロントにエラーを通知
throw $e;
}
}
}
コードそのものは create()
とほぼ同じ形になりました。
例外処理に関して言えば show()
と同じように NotFound
の場合だけロギングを回避してますが、
更新に関わる処理の失敗はわざわざ回避しなくてもいいかもしれません。
ロギングの回避はいわば 「例外処理の例外」 みたいなものなので、そこまで考え出すと逆に複雑になってしまうかも。
例外をスローする嬉しさは、 とりあえず投げておけば残りの処理はスキップしてくれる というブロック内の複雑度を下げる働きと、 キャッチした後の 一貫した処理内容 という点にありますので、 その恩恵さえしっかりと感じていただければいいと思います。
POINT
- ロギングの回避は 例外処理の例外
- 例外処理の嬉しさは 「処理をスキップする動き」 と 「一貫した後処理」
# DELETE
そしてラストはDELETE。これは destroy()
が対応します。
データの削除といえばなかなか繊細な処理に思えますが、実際のコードはとても単純です。単純すぎてコメントすら要らないレベルです。
class ItemController extends Controller
{
public function destroy($id)
{
try {
Item::destroy($id);
return ['message' => '削除しました。'];
} catch (\Throwable $e) {
\Log::error($e);
throw $e;
}
}
}
はいこんだけ。コメント無くても読めるでしょ?
なぜこんなにシンプルなのか?
それは、「本当に削除しますか?」などの確認はフロントで済ませている前提だから。(というかサーバサイドじゃユーザーには確認できないしね)
実際には他のテーブルのデータとの連携などが発生していたりして削除できないなんてこともありますので、 その辺りの検証処理が入ったりします。
それ以外にも例えば 「男性/女性」
の2レコードだけ入った gender
テーブル、みたいな そもそも削除してはいけないデータ なんてのもあったりしますが、
そういう場合はまず destroy()
を実装すべきではないので、メソッドの宣言だけ残して中身は空っぽで放置しておきましょう。
POINT
- RESTfulには拘らず、使わないメソッドは実装しない勇気を持とう
index
show
create
update
destroy
は使わない場合でもあえて消さず、宣言だけ残しておくのが個人的にはおすすめ。- 後から必要になる場合がある
- 「今のところ必要がないから中身は実装していませんよ」という意思表示にもなる
# Vueでfinallyを使う例
APIを叩く側(フロント)はもちろんVue.jsで書くんですが、 そこでよく使うテクニックがtry〜finallyを利用したローディング操作です。
最後にこのtry〜finallyを使う、ちょっとしたテクニックをご紹介します。
export default {
data () {
return {
items: [],
loading: false,
}
},
methods: {
async fetchItems () {
try {
// ここでローディング開始
this.loading = true
const { data } = await axios.get({ /*...*/ })
this.items = data
} finally {
// 正常終了でもエラーでもローディング終了
this.loading = false
}
},
},
}
# あとがき
慣れていないうちは、技術の雰囲気はわかっているはずなのにどうにも手が動かなかったり、 どんな形にしたらいいか試行錯誤しているうちに手のつけようがないくらいに汚くなってしまったりといった経験を誰しもが通ると思います。
特に同じ社内や環境で開発を続けていると、いい見本を見る機会もなくてなかなか身につかない技術っていうのがあるんですよね。
今回はそんな悩めるプログラマーのみなさんに、テンプレとして使っていただけるような実コードをご紹介できればと思って書いてみました。
この記事が誰かの役に立ってくれたら嬉しいです!
# おまけ
書いてる最中に、Laravelには例外処理を一括管理してくれる機能があることを知ってしまいました...。
この記事をシェア