例外(Exception)って上手に使えたら処理がキレイに書けるのだろうと思いつつ、使いこなせていない感が漂う今日この頃。
と言うことで、例外に関して少し整理したいと思います。
なお、表題を「Laravel:例外(Exception)」としましたが、あくまで「Laravel環境を前提に例外処理の書き方を整理してみた」と言うことであって、「Laravelでは例外はこのように扱うべき」と言うおこがましい内容ではありません。
例外を使わない場合
まずは例外を使わないで正常時/異常時の制御を行うケースを考えます。
コントローラの内容は以下。
public function __invoke(Request $request, \App\Services\Util\DummyService $service)
{
self::logTrace(__METHOD__, '前処理');
if ($service($request->arg)) {
// 正常
self::logTrace(__METHOD__, '後処理');
} else {
// 異常
self::logTrace(__METHOD__, '後処理', 'エラー');
}
}
呼び出し元から指定されたデータ「arg」を引数としてサービス「\App\Services\Util\DummyService」を呼び出し、その結果が正常か異常かで処理を分岐しています。正常/異常は関数値の真偽で判断します。
「self::logTrace」は独自に用意したログ機能です。
サービスの内容は以下。
class DummyService
{
public function __invoke($arg)
{
self::logTrace(__METHOD__, '前処理');
if (!$this->sub1($arg)) {
return false;
}
self::logTrace(__METHOD__, '後処理');
return true;
}
private function sub1($arg)
{
self::logTrace(__METHOD__, '前処理');
if (!$this->sub2($arg)) {
return false;
}
self::logTrace(__METHOD__, '後処理');
return true;
}
private function sub2($arg)
{
self::logTrace(__METHOD__, '前処理');
$DummyRepository = app()->make(\App\Repositories\DummyRepository::class);
if (!$DummyRepository($arg)) {
return false;
}
self::logTrace(__METHOD__, '後処理');
return true;
}
}
サービス内では引数を引き継ぎつつ「sub1」「sub2」と呼び出して行き、「sub2」ではリポジトリ「\App\Repositories\DummyRepository」を呼び出します。
各メソッド内では下位のメソッドを呼び出す前後で処理を行いますが、下位のメソッドでエラーが発生した場合は以降の処理を実行しないで処理を中断する(エラーを返す)ようにしています。
最後にリポジトリの内容は以下。
class DummyRepository
{
public function __invoke($arg)
{
if (($arg % 2) === 0) {
// 正常
self::logTrace(__METHOD__, '正常処理');
return true;
} else {
// 異常
self::logTrace(__METHOD__, 'エラー処理');
return false;
}
}
}
ここでは単純に引数が偶数ならば正常、奇数ならば異常として処理をするようにしています。
上記一連の処理を実施した結果として残されるログは以下のようになります。
まずは正常時(引数が偶数)。
2022/02/12 10:36:06.957941 [前処理] App\Http\Controllers\Util\DummyController::__invoke
2022/02/12 10:36:06.958078 [前処理] App\Services\Util\DummyService::__invoke
2022/02/12 10:36:06.958237 [前処理] App\Services\Util\DummyService::sub1
2022/02/12 10:36:06.958354 [前処理] App\Services\Util\DummyService::sub2
2022/02/12 10:36:06.958748 [正常処理] App\Repositories\DummyRepository::__invoke
2022/02/12 10:36:06.958884 [後処理] App\Services\Util\DummyService::sub2
2022/02/12 10:36:06.958996 [後処理] App\Services\Util\DummyService::sub1
2022/02/12 10:36:06.959105 [後処理] App\Services\Util\DummyService::__invoke
2022/02/12 10:36:06.959213 [後処理] App\Http\Controllers\Util\DummyController::__invoke
最下層であるリポジトリでの正常処理を挟んで各層の前処理、後処理が対称的に実行されています。
次は異常時(引数が奇数)。
2022/02/12 10:39:23.632836 [前処理] App\Http\Controllers\Util\DummyController::__invoke
2022/02/12 10:39:23.632945 [前処理] App\Services\Util\DummyService::__invoke
2022/02/12 10:39:23.633047 [前処理] App\Services\Util\DummyService::sub1
2022/02/12 10:39:23.633146 [前処理] App\Services\Util\DummyService::sub2
2022/02/12 10:39:23.633547 [エラー処理] App\Repositories\DummyRepository::__invoke
2022/02/12 10:39:23.633699 [後処理] App\Http\Controllers\Util\DummyController::__invoke エラー
リポジトリでのエラー後は各層の後処理は行われず、最終的にコントローラでのエラー処理が行われます。
例外を使う場合
上記例では最下層でのエラーを最上位のコントローラまで伝えるために関数値を逐一返していく必要がありますし、各層でエラー判定や処理の分岐が必要になるため処理が煩雑です。
例外を使うとこれらの問題が解決します。
まずはコントローラを以下のように変更します。
public function __invoke(Request $request, \App\Services\Util\DummyService $service)
{
self::logTrace(__METHOD__, '前処理');
try {
$service($request->arg);
// 正常
self::logTrace(__METHOD__, '後処理');
} catch (\Exception $e) {
// 異常
self::logTrace(__METHOD__, '後処理', $e->getMessage().'('.$e->getCode().')');
}
}
関数値での判断から例外に対するtry&catchに処理を変更しています。
例外にはメッセージとコードを付加できるので、それをログに残すようにもしています。
サービスは以下のように変更します。
class DummyService
{
public function __invoke($arg)
{
self::logTrace(__METHOD__, '前処理');
$this->sub1($arg);
self::logTrace(__METHOD__, '後処理');
}
private function sub1($arg)
{
self::logTrace(__METHOD__, '前処理');
$this->sub2($arg);
self::logTrace(__METHOD__, '後処理');
}
private function sub2($arg)
{
self::logTrace(__METHOD__, '前処理');
$DummyRepository = app()->make(\App\Repositories\DummyRepository::class);
$DummyRepository($arg);
self::logTrace(__METHOD__, '後処理');
}
}
ずいぶんスッキリしてしまいました。
正常時の動作が書かれているのみなので、各メソッドでの処理内容が把握しやすくなっています。
最後にリポジトリは以下のように変更します。
class DummyRepository
{
public function __invoke($arg)
{
if (($arg % 2) === 0) {
// 正常
self::logTrace(__METHOD__, '正常処理');
} else {
// 異常
self::logTrace(__METHOD__, 'エラー処理');
throw new \Exception('エラーメッセージ', 123);
}
}
}
関数値として偽を返す代わりに例外を投げるようにしています。
上記実行結果は以下の通り。
まずは正常時。
2022/02/12 10:49:28.501007 [前処理] App\Http\Controllers\Util\DummyController::__invoke
2022/02/12 10:49:28.501116 [前処理] App\Services\Util\DummyService::__invoke
2022/02/12 10:49:28.501216 [前処理] App\Services\Util\DummyService::sub1
2022/02/12 10:49:28.501315 [前処理] App\Services\Util\DummyService::sub2
2022/02/12 10:49:28.501690 [正常処理] App\Repositories\DummyRepository::__invoke
2022/02/12 10:49:28.501794 [後処理] App\Services\Util\DummyService::sub2
2022/02/12 10:49:28.501893 [後処理] App\Services\Util\DummyService::sub1
2022/02/12 10:49:28.501991 [後処理] App\Services\Util\DummyService::__invoke
2022/02/12 10:49:28.502087 [後処理] App\Http\Controllers\Util\DummyController::__invoke
実行結果は例外採用前と変わりません。
次に異常時。
2022/02/12 10:49:51.483302 [前処理] App\Http\Controllers\Util\DummyController::__invoke
2022/02/12 10:49:51.483417 [前処理] App\Services\Util\DummyService::__invoke
2022/02/12 10:49:51.483930 [前処理] App\Services\Util\DummyService::sub1
2022/02/12 10:49:51.484042 [前処理] App\Services\Util\DummyService::sub2
2022/02/12 10:49:51.484405 [エラー処理] App\Repositories\DummyRepository::__invoke
2022/02/12 10:49:51.484535 [後処理] App\Http\Controllers\Util\DummyController::__invoke エラーメッセージ(123)
こちらも基本的には例外採用前と変わりませんが、例外に付加したメッセージとコードがコントローラで適正に取得できていることが分かります。
以上のように、同じ処理内容でも例外を使用することで異常時の処理をごちゃごちゃと書く必要がなくなり、ロジックの流れがかなり見やすくなります。
例外の拡張
上記では基本的な例外であるクラス「Exception」をそのまま使用しましたが、もう少し処理が複雑になって行き、異常の発生パターンが増えていくと、異常の内容に準じて後処理を分けたい場合も出てきます。
加えて、例外は独自に(意図的に)発生させるケース以外に、本当の異常(想定外の原因)によって発生するケースもあります。
これらを区別するために、独自の例外には独自のクラスを適用しておくと便利です。
独自例外クラスの生成(例外の拡張)は以下のように行います。
<?php
namespace App\Exceptions;
use Exception;
class OddException extends Exception
{
}
単に基本クラスを継承しただけで内容的には一切の変更がありませんが、これで問題ありません。
例外の拡張の主目的は、そのクラス自体が例外の内容(意味)を表現することにあります。
上記に対して、リポジトリを以下のように書き換えます。
class DummyRepository
{
public function __invoke($arg)
{
if (($arg % 2) === 0) {
// 正常
$a = $b;
self::logTrace(__METHOD__, '正常処理');
} else {
// 異常
self::logTrace(__METHOD__, 'エラー処理');
throw new \App\Exceptions\OddException('エラーメッセージ', 123);
}
}
}
異常時に返す例外を独自クラス「\App\Exceptions\OddException」に変更しています。
また、さりげなく正常時の処理に想定外のエラー(未設定の変数の参照)を加えています。
上記に対して、コントローラは以下のように変更します。
public function __invoke(Request $request, \App\Services\Util\DummyService $service)
{
self::logTrace(__METHOD__, '前処理');
try {
$service($request->arg);
// 正常
self::logTrace(__METHOD__, '後処理');
} catch (\App\Exceptions\OddException $e) {
// 異常(想定内)
self::logTrace(__METHOD__, '後処理(想定内)', $e->getMessage().'('.$e->getCode().')');
} catch (\Throwable $e) {
// 異常(想定外)
self::logTrace(__METHOD__, '後処理(想定外)', '<'.get_class($e).'>'.$e->getMessage().'('.$e->getCode().')');
}
}
想定内の例外として前述の「\App\Exceptions\OddException」の発生に対する処理を定義しておくと同様に、想定外のエラーが発生した場合はまとめて別の形で処理をするようにしています。
上記のように「\Throwable」を指定することで、サービス配下で発生した全ての例外を対象とすることができるようになります。
なお、「\Throwable」は全ての例外(厳密にはthrow可能なオブジェクト)の基底クラスなので当然ながら「\App\Exceptions\OddException」も含まれることになりますが、複数のcatchに関しては上に書かれたものから型の判定を行っていくので、上記のように「\App\Exceptions\OddException」に関する処理を先に書いておくことでその他の例外とは区別して処理ができるようになります。
上記実行結果ですが、以下のようになります。
まずは想定内の異常(引数が奇数)の場合。
2022/02/12 11:34:13.466806 [前処理] App\Http\Controllers\Util\DummyController::__invoke
2022/02/12 11:34:13.466919 [前処理] App\Services\Util\DummyService::__invoke
2022/02/12 11:34:13.467023 [前処理] App\Services\Util\DummyService::sub1
2022/02/12 11:34:13.467126 [前処理] App\Services\Util\DummyService::sub2
2022/02/12 11:34:13.467518 [エラー処理] App\Repositories\DummyRepository::__invoke
2022/02/12 11:34:13.467900 [後処理(想定内)] App\Http\Controllers\Util\DummyController::__invoke エラーメッセージ(123)
想定内の異常処理が行われています。
次に想定外の異常(引数が偶数で正常処理をしようとしたが想定外のエラーが発生)の場合。
2022/02/12 11:34:37.444270 [前処理] App\Http\Controllers\Util\DummyController::__invoke
2022/02/12 11:34:37.444402 [前処理] App\Services\Util\DummyService::__invoke
2022/02/12 11:34:37.444504 [前処理] App\Services\Util\DummyService::sub1
2022/02/12 11:34:37.444614 [前処理] App\Services\Util\DummyService::sub2
2022/02/12 11:34:37.445001 [後処理(想定外)] App\Http\Controllers\Util\DummyController::__invoke <ErrorException>Undefined variable: b(0)
想定外の異常に関する処理が想定通り(^_^)に行われています。
蛇足ながら、未設定の変数の参照に関する例外はクラスが「ErrorException」、メッセージは「Undefined variable: b」で、エラーコードは0(特にコードが設定されていないと言うことか?)となっているようです。
mockによる例外発生
上記のように例外処理を書いたとして、テストで例外発生時の処理を検証するにはどうすれば良いでしょう?
前述の例では想定外の例外を発生させるためにロジック内に意図的に間違った処理(未設定の変数の参照)を埋め込んでいましたが、いちいちこのようなことをするのは面倒ですし、その間違った処理を消し忘れた結果不具合を発生させてしまったりしたら本末転倒です。
ということで、ここは例によってmockに頑張ってもらいます。
mockでは戻り値の設定に「andReturn」と言うメソッドを使用しますが、例外を発生させる場合はその代わりに「andThrow」を使用します。
以下が具体的な例になります。
$this->mock(\App\Repositories\DummyRepository::class, function($mock) {
$mock->shouldReceive('__invoke')
->andThrow(\App\Exceptions\OddException::class, 'モックによる例外発生', 999);
});
「andThrow」の引数は、第一引数が例外のクラス、第二引数が例外に設定するメッセージ、第三引数が例外に設定するコードになります。
上記設定を行なった上で当該テストケースを実行させた結果は以下のようになります。
2022/02/12 12:09:24.099786 [前処理] App\Http\Controllers\Util\DummyController::__invoke
2022/02/12 12:09:24.099918 [前処理] App\Services\Util\DummyService::__invoke
2022/02/12 12:09:24.100042 [前処理] App\Services\Util\DummyService::sub1
2022/02/12 12:09:24.100164 [前処理] App\Services\Util\DummyService::sub2
2022/02/12 12:09:24.101812 [後処理(想定内)] App\Http\Controllers\Util\DummyController::__invoke モックによる例外発生(999)
mockで設定した例外が発生できていることが確認できます。
総括
私はプログラマーを始めて最初の10年程度はC言語を使用していたので、関数の結果は関数値で返すと言う習慣が身に染みついています。
蛇足ながら、ネット情報を見ていると、未だにC言語では「例外」と言う概念は存在しないようですが(専用のライブラリを適用する必要がある等の記事を見掛けますので)。
本記事で整理したように、改めて異常系を考慮した処理を例外の使用の有無で比較してみると例外の利便性が分かります。そろそろ古い考えから脱却して例外を上手に使いこなせるようになりたいものです。
例外に関して調べていくと「例外的状態にだけ例外を使う」と言う表現が出てきます。
当たり前のことのように思いますが、例えば例外処理をGOTO文的に使用することもできるので、分かりやすくはそのようなことしないようにと言っているとも解釈できます。
ただ、深読みしていくと良く分からなくなってきます。
本記事でも「想定外」「想定内」と言う表現を用いましたが、そもそも「想定内の例外」って言葉自体が矛盾を含んでいるようにも思えますし。
本記事の例に準じて言えば、引数として偶数しか入ってこない仕様になっているにも関わらず奇数が入ってきた場合の対処を考慮したものであれば適切、奇数と偶数のいずれも入ってくる可能性がありつつ処理の分岐的な意味で使用しているのであれば不適切、と言ったところでしょうか。
この辺の用途の適切さの判断が今後の課題かと思います。
加えて、今回の例では下層の例外をまとめて最上位のコントローラでcatchするようにしていますが、ネストのどのレベルで例外をcatchするかは状況にもよります。
あまり細々と拾っていたのではif文を羅列しているのとあまり変わらなくなってしまうようにも思いますので、その辺の設計も重要かと思います。
良く使う例えとして「良い金槌を持つと何でも釘に見えてくる」と言う表現があります(どこで見たか忘れてしまいましたが、うまい表現をするものと感心しました)。
金槌を持っていても使わなくては意味がありませんが、釘以外のものを叩いてしまうのは避けたいところです。
例外もいい感じで使えるように精進して行きたいと思います。