はじめに
良い例外処理はソフトウェアの安定性を高め、ユーザーエクスペリエンス(UX)を向上させるために重要です。
例外で、アプリがクラッシュするのを防止し、ユーザーは発生した問題に対して、その問題の原因を把握できます。
また、その問題が解決できる意味のあるエラーメッセージを受け取ります。
それだけではなく、明確な例外処理を作成しておくことで、エンジニアは例外を把握しやすくなり、迅速な対処が可能となります。
特に、複雑なシステムで色んな層のプロセスがある場合、各層にふさわしい例外処理をすることで、プロジェクトを管理の利便性が向上させるはずです。
一方、プロジェクトの色んな所で例外処理を過度に使うと、ビジネスロジックを把握しずらいほど、例外が増えることもあるでしょう。
この記事では、良いコードの書き方をするために、必要な例外処理について説明します。
例のコードはJavaとSpringで実装されています。
回復可能なエラーと回復不可能なエラーを区別する
エラーが発生した際、一番最初にすべきことは回復可能なエラーと回復不可能なエラーを区別することです。
すべてのケースに当てはまるわけではありません。
あるエラーは常に生じることとで無視してもよいが、必ず対応しないといけないエラーもあります。
これらを区別なく同じエラーとして扱うと、ユーザーはサービスの利用に不便を感じ、エンジニアは毎度発生するエラーアラームでますますシステムのエラーをなおざりにすることもあると思います。
だから、これらを区別し、エラーが発生した際の対策を考えないといけません。
回復可能なエラー
回復可能なエラーはシステム外部側から発生する致命的ではないエラーです。
会員登録の際、ユーザーが入力した住所や電話番号に不備があったとしても、これはシステムを停止するほどの問題ではありません。
ただ、ユーザーに入力データに不備があることをエラーメッセージで伝えれば済むことです。
同様に、ネットワーク通信エラーの場合も、時間をおいて再度確認すればいいです。
このようなエラーはシステム側で検知し、適切に対処すれば、問題ないはずです。
- ユーザーの入力データ不備
- ネットワーク通信エラー
- その他、深刻ではないタスクエラー
回復可能なエラーは常に発生しうると想定し、ユーザーにエラーの原因を認知させるのが大事です。
しかし、頻繁に発生する回復可能なエラーは回復不可能なエラー(プログラミングエラー)の可能性があるため、ログのレベルをwarn
で設定し、エラーしきい値を超えるとエンジニアにアラームを送るようにします。
回復不可能なエラー
回復不可能なエラーはシステム外部側からの措置がないと、システムが自動的に回復する手段がない場合です。
この場合、エラーの原因を解消するまでにはシステムを稼働させることはできません。
- メモリ不足(Out of Memory)
- システムの空きメモリが足りないために起きる。
- スタックオーバーフロー(StackOverflow)
- スタック領域でメモリがなくなることで起きる。
- システムレベルのエラー
- ハードウェアやOSの重大なバグで起きる。
- プログラミングエラー(エンジニアのコード実装ミス)
回復不可能なエラーは発生したら、迅速にエンジニアにエラーの原因を知らせるべきです。
このために、ログのレべえるはerror
で設定し、エラートレースをログで取得するようにします。
また、エラーしきい値を超えるとエンジニアにアラームを送るようにします。
null、-1、 空文字等を例外として使わない
例外はエラー(Exception)で処理しないといけません。
一部のプロジェクトでは想定外の状況でエラーではなく、特定の値を使う場合があります。
例えば、下記のようにユーザーの誤入力の場合、-1を返すコードがあるとしましょう。
// bad
int divideWrong(int a, int b) {
if (b == 0) {
return -1; // Exceptionの代わりに-1を返す
}
return a / b;
}
この場合、呼び出すたびに、返却値を確認しなければいけません。
そして、-1
の意味も把握したいといけません。
一方、次のようにエラーを返すと、呼び出す側からエラーを処理します。
そして、エラーの意味を把握するにはエラーのタイプさえ確認すればいいです。
// good
int divideRight(int a, int b) {
if (b == 0) {
throw new Error("Division by zero is not allowed."); // エラーを throw
}
return a / b;
}
エラーを使うのは、上記のケース以外にもメリットがいくつかあります。
- どんなエラーかを明確に表現できる
- エラーの詳細をメッセージで伝えられる
- どこでエラーが発生したのかをStack Traceで分かる
- もっと綺麗にコードが書ける
文字列でthrowしない
JS/TSで多いケースですが、下記のように文字列をそのままthrow
する場合があります。
// bad
throw 'ユーザーデータ取得に失敗しました。'
文字列でエラーを発生させると、色んなCustomExceptionをエラーメッセージで判断しなければなりません。
これだけでなく、スタックとレースなどの、エラーに関する情報がないため、エラーを解消するに困難が生じることになります。
そのため、次のようにエラーはExceptionオブジェクトを使うべきです。
// good
throw new Error ('ユーザーデータ取得に失敗しました。');
throw new NotFoundResourceException ('ユーザーデータ取得に失敗しました。');
このように、Exceptionオブジェクトを投げると、エラーの詳細まで伝えることができます。
Exceptionオブジェクトはエラーが発生したコンテクストまたは、デバッグのためのスタックトレースなど、エラーの詳細を持っているため、エラーの原因把握と解決に役に立てると思います。
追跡可能な例外スタック
エラーが発生した処理の原因を把握するには例外スタックだけでは足りません。
なので、次の情報が例外に含まれないといけません。
- エラーメッセージにある処理で使ったデータ
- エラーが発生した処理名とエラーのタイプ
上記の内容が含まれていれば、現場でエラーが発生した際、少しでも早く対応が可能となるはずです。
発生したエラーに該当する根本的な原因を把握できるように、正確なデータを残すのが大事です。
例としてユーザが会員登録をする際、データ形式を間違って入力をしたと想定してみます。
次のようにエラーを発生させると、入力データに関するエラーということは把握できますが、どんなデータが検証処理でエラーになったのかを把握できません。
// bad
throw new IllegalArgumentException("データに不正な値があります。");
一方、次のようにエラーを発生させると、
// good
throw new IllegalArgumentException(
String.format("メールアドレス(%s)が不正な値です。", mail)
);
エラーの原因を早く把握できます。
エラーについてExceptionの内容をそのままユーザに表示させるのは、別の話です。
Loggerに残すメッセージとユーザに表示させるメッセージは分ける必要があります。
意味が含まれている例外
例外名はその原因と、内容をすべて反映させるべきです。
コードを読む人が例外名だけで、その内容が推測できなければなりません。
これは、大きく2つの理由があります。
- コード可読性の向上
- 便利なデバッグ
// bad
class CustomException extends RuntimeException {}
void connectToDatabase() {
throw new CustomException("Connection failed because of invalid credentials.");
}
上記の例外はあまりにも包括的な意味を持っています。(CustomException)
この例外をもっと有意味な例外にして改善できます。
// good
class InvalidCredentialsException extends RuntimeException {}
void connectToDatabase() {
throw new InvalidCredentialsException ("Failed to connect due to invalid credentials.");
}
改善したコードでは「InvalidCredentialsException」という例外名を使用して、データベース接続時に発生する例外ということを明確にしています。
そして、例外名だけで、そのエラーの原因を把握することができます。
Layerにふさわしいエラーを投げる
RepositoryでHttpExceptionを投げるまたはPresentation(Controller)でSQLExceptionを処理するのはLayerごとの役割にふさわしくありません。
複数の階層(Layer)で構成されているソフトウェアアーキテクチャーでは、各層にふさわしい例外を定義し、エラーを発生させることを意味します。
このようなアーキテクチャーはエラーの追跡をもっと簡単にさせ、各層で発生するエラーを適切に対応することができます。
3層アーキテクチャーアプリケーションは次のように層があります。
- データアクセス層(Data Access Layer)
- ビジネスロジック層(Business Logic Layer)
- プレゼンテーション層(Presentation Layer)
各層で発生しうる例外のタイプが異なるため、各層にふさわしい例外を投げるのがいいです。
// Data Access Layer
String fetchUserData(String userId) {
// ...
throw new DataAccessException("Failed to fetch user data from database.");
}
// Business Logic Layer
String getUserProfile(String userId) {
try {
String userData = fetchUserData(userId);
// ... some business logic
} catch (DataAccessException e) {
throw new BusinessLogicException("Error processing user profile.");
}
}
// Presentation Layer
void displayUserProfile(String userId) {
try {
String profile = getUserProfile(userId);
// ... display logic
} catch (BusinessLogicException e) {
throw new PresentationException("Error displaying user profile.");
}
}
そして、各層で発生した例外は適切なところで処理します。
後で、記述しますが例外は最上位の層で処理します。
// global Error Handler
try {
displayUserProfile("someUserId");
} catch (PresentationException e) {
log.error("UI Error:" + e.getMessage());
} catch (Exception e) {
log.error("Unknown error:" + e.getMessage());
}
上記の例では、各層でエラーが発生すると、該当の層にふさわしい例外クラスを通じて例外を投げます。
このようにすると、どの層で例外が発生したのかを把握しやすくなります。
これらの理由で、抽象化されたExceptionを定義したり、IllegalArgumentException みたいなJavaのExceptionを活用するのがいいと思います。
もちろん、この際に原因となるExceptionを上位のExceptionのコンストラクタに渡すexception chaningも使えます。
例外の階層構造を作る
例外をできる限り階層構造を作って使います。
"意図を含む例外", "Layerにふさわしい例外"等を考えながら実装すると、数多くのCustom Exceptionが作られます。
これらを意図に合わせて分類する必要があり、このようにある意図に合わせて分類したExceptionは同じ処理方法を適用できます。
// bad
class DuplicatedException extends RuntimeException {}
class UserAlreadyRegisteredException extends RuntimeException {}
下記のように意図に合わせたCustomExceptionを分類する上位のExceptionを継承するようにします。
// good
class ValidationException extends RuntimeException {}
class DuplicatedException extends ValidationException {}
class UserAlreadyRegisteredException extends ValidationException {}
外部例外をラッピングする
外部のSDKやAPIを利用した際に、発生した例外は合わせて処理します。
これは例外の階層構造を作ると関係があります。
例えば、次のように外部の決済サービスのSDKを使う場合、これらを合わせて処理することができます。
// bad
void order() {
Pay pay = new Pay();
try {
pay.billing();
database.save(pay);
} catch (Exception e) {
log.error("pay fail" + e);
}
}
上記のようにExceptionで合わせてcatchして処理すると、具体的にどこでエラーが発生したのか?、各エラーによって対応するのが難しくなります。
外部ライブラリ(pay.billing)で発生するエラーと内部で管理する(database.save)が同じ方法でエラーを解決しようとすればいけません。
特に、決済サービスは、ユーザのミスでエラーが発生する場合もあります。
そのため、次のように外部ライブラリのすべてのエラーをcatchするという方法もありますが、この方法もデメリットはあります。
// bad
void order() {
Pay pay = new Pay();
try {
pay.billing();
database.save(pay);
} catch (PayNetworkException e) {
// ...
} catch (EmptyMoneyException e) {
// ...
} catch (PayServerException e) {
// ...
}
// ...
}
上記のような対応は、決済サービスが変わると、エラーの種類も変わってしまうので、サービスコード全体に影響を与えることになります。
結局、次のように2つの機能間の依存性を分ける必要があります。
// good
void billing() {
try {
pay.billing();
} catch (Exception e) {
// ...
throw new BillingException(e);
}
}
void order() {
Pay pay = new Pay();
pay.billing();
database.save(pay)
try {
} catch (BillingException e) {
pay.cancel();
}
}
- BillingExceptionは内部で管理している例外のため、できる限り上位の層で処理を行います。(ミドルウェア、グローバルエラーハンドラー等)
- 決済情報をDB保存に失敗する場合、決済も取り消しを行います。
こうすることで、外部ライブラリ(決済サービス)と内部サービス(order)の依存性を分離できます。
そのため、サービスコードを変更せずに、外部ライブラリの変更が可能となります。
!!外部例外をラッピングする際の注意点!!
例外をラッピングする際には、必ず既存の例外をラッピングする例外に渡す。
try {
billing();
} catch (Exception e) {
throw new BillingException(e);
}
既存の例外を渡すことで、下記のようにCaused byで既存の例外を確認し、迅速に対応することができます。
sample.cafekiosk.unit.CafeKioskTest$BillingException: sample.cafekiosk.unit.CafeKioskTest$PayNetworkException: ex
at sample.cafekiosk.unit.CafeKioskTest.exceptionTest(CafeKioskTest.java:26)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Caused by: sample.cafekiosk.unit.CafeKioskTest$PayNetworkException: ex
at sample.cafekiosk.unit.CafeKioskTest.billing(CafeKioskTest.java:31)
at sample.cafekiosk.unit.CafeKioskTest.exceptionTest(CafeKioskTest.java:24)
... 3 more
上記のログで分かる通り、Caused byは実際に発生した例外(PayNetworkException)の情報を持っていることが分かります。このログを見てエンジニアはエラーを把握し、対応するはずです。
しかし、エンジニアのミスで既存例外が渡されなかったらどうなるのでしょう。
try {
billing();
} catch (Exception e) {
throw new BillingException();
}
次のように、BillingExceptionの情報のみ、ログに表示されることになります。
sample.cafekiosk.unit.CafeKioskTest$BillingException
at sample.cafekiosk.unit.CafeKioskTest.exceptionTest(CafeKioskTest.java:26)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
こうなると、外部の決済サービスからどんなエラーが起きたのか把握することができなくなります。
Catchの使い過ぎ禁止(正常な流れでCatchは禁止)
ロジックの正常な流れを制御するために例外を使用しないようにします。
エラーは例外的な場合のみ使えるようにしましょう。
// bad
String fetchDataFromAPI() {
// apiを呼び出す
// ...
if (/** データがない場合 */) {
throw new NoDataFoundError();
}
// データ返却
return data;
}
void display() {
try {
String data = fetchDataFromAPI();
process(data);
} catch (NoDataFoundError e) {
displayEmptyState();
} catch (Exception e) {
displayGenericError();
}
}
上記の例でNoDataFoundError例外はデータがない状況を伝えるために使用されます。
これは、例外をロジックの正常な流れを制御する手段として使う間違っている使い方です。
逆に、次のようにコードを書くと、
// good
String fetchDataFromAPI() {
// apiを呼び出す
// ...
if (/** データがない場合 */) {
return null;
}
// データ返却
return data;
}
void display() {
String data = fetchDataFromAPI();
if(StringUtils.isEmpty(data)) {
displayEmptyState();
return;
}
process(data);
}
メソッドはnullを返却し、呼び出しもとはその結果を確認し、次の処理を行います。
OptionalやNull Object Patternなどを活用し、Nullを直接返却しない方がいい
例外は実際のサービスの仕様以外の状況に対応するために、使えるべきだと思います。
できる限り、上位の層でエラーをハンドリングする。
例外はできる限り、上位の層でハンドリングした方がいいです。
もちろん、これがいつもグローバルハンドラーで処理するというわけではありません。
例外をハンドリングできる色んな階層の中、最上位(もっとも離れている)階層でハンドリングを行います。
int divide(int a, int b) {
if (b == 0) {
throw new DivideZeroError("Cannot divide by zero!");
}
return a / b;
}
void calculate() {
try {
divide(1, 2);
} catch (DivideZeroError e) {
// ...
}
}
上記のようにすると、divide()を呼び出すところはいつも例外をハンドリングしないといけません。
これだけではなく、エラーハンドリングが必要なメソッドが複数ある場合、それらを呼び出すところには多くのはエラーハンドリングコードが含まれ、実際のビジネスロジックよりエラーハンドリングコードの方が多くなります。
一方、グローバルハンドラーやミドルウェアなど、最上位の層でエラーをハンドリングすると、コードの可読性と再利用性が向上される。
springではControllerAdviceでエラーを処理することができます。
// good
class Service {
int divide(int a, int b) {
if (b == 0) {
throw new DivideZeroError("Cannot divide by zero!");
}
return a / b;
}
void calculate() {
divide(1, 2);
// ...
}
}
// グローバルハンドラー
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DivideZeroError.class)
public ResponseEntity<String> handle(DivideZeroError ex) {
// エラーをハンドリングする。
// ...
}
}
Exceptionをできる限り、上位の層でハンドリングをすると、
次のようなメリットがあります。
- 可読性
- メソッドがエラーをハンドリングしないため、コードがきれいになり、可読性が向上される
- 再利用性
- ロジックとエラーハンドリングコードが分離されていると、メソッドやモジュールをほかのおころで再利用しやすくなります
- 統一性
- 例外をグローバルハンドラーで処理すると、ハンドリング方法を統一させることができます
最後に
開発をする上で、例外とは必ず遭遇すると思います。
だから、いいコードは例外をどうハンドリングする方法が大事です。
この記事で記述した内容以外にも、
エラーハンドリングに関しては色んな方法があると思います。
本やブログの記事、プレームワークのコードなどでエラーハンドリングのいい例を確認することもできるはずです。
私も現場でプロジェクトを経験しながら、エラーのハンドリングに興味を持ち、先輩社員のソースを見たり、いろいろ検索したりしています。
これからも、たくさんのプロジェクトを経験しながら、エラーハンドリングついて自分だけの軸を立てて、開発に挑もうと思います。
最後までご覧くださりありがとうございました。
参考サイト
・https://jojoldu.tistory.com/734
・https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard
・https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard