Clean Code アジャイルソフトウェア達人の技の本を整理した内容です
7 章 エラーハンドリング
実務でサービスを開発していると、様々な状況に対してエラーを予想し例外処理を行いますが、実際にユーザーがサービスを使用する際には予期しないエラーが発生することもあります。
したがって、開発者はさまざまな場合を考慮してエラーを防止する必要があります。
しかし、だからといって無闇に例外処理を行うべきではありません。この章では、よりクリーンで堅牢なコードに適用できるように、エレガントで上品なエラーハンドリングの技法と考慮事項をいくつか紹介します。
1. エラーコードより例外を使用せよ
少し前までは、例外をサポートしていないプログラミング言語が多くありました。これらの言語では、エラーを処理し報告する方法が限られていました。
以下は、エラーをコードで処理する悪い例です。
HashMap isValidIdAndPwFromUser = outApiService.checkIdAndPwFromUser(totalLoginDTO);
if (isValidIdAndPwFromUser.get("results").equals(false)) {
return ErrObj.error(UserError.NONE_SELECTOR_USER);
}
boolean hasRedisKey = redisService.hasKey(totalLoginDTO.getUserId());
if (!hasRedisKey) {
return ErrObj.error(UserError.NONE_SELECTOR_USER);
}
...
このコードでは、エラーを処理するために毎回戻り値を確認しなければならず、コードが複雑になり、確認するたびに if 文の分岐が発生してコードの階層が深くなります。
そして、このような処理は開発者が直接コードを書いて行うため、ミスをしたり見逃したりすると、すぐにプログラムが終了してしまう可能性があります。
したがって、次のようにエラーをコードで処理するのではなく、例外で処理することをお勧めします。
try {
validateUserLogin(totalLoginDTO);
...
} catch (InvalidUserException e) {
return ErrObj.error(UserError.NONE_SELECTOR_USER);
} catch (RedisKeyNotFoundException e) {
return ErrObj.error(UserError.NONE_SELECTOR_USER);
} catch (Exception e) {
return ErrObj.error(UserError.GENERIC_ERROR);
}
変換されたコードでは、ビジネスロジックとエラーハンドリングコードを分離し、それぞれの概念を独立して理解しやすくなります。
2. Try-Catch 文から書き始めよ
ある意味、try ブロックは Transaction に似ているようです。
try ブロックで例外が発生すると、実行が中断され、catch ブロックに移行するためです。
したがって、例外が発生する可能性があるコードを書くときには、try-catch 文から始めるのが良いでしょう。
以下は、本に書かれている例で、TDD を適用してファイルが存在しない場合に例外をスローするかどうかを確認する単体テストです。
@Test(expected = StorageException.class)
public void testRetrieveSection() {
sectionStore.retrieveSection("invalid - file");
}
sectionStore.retrieveSection(”invalid-file”)
メソッドを実行すると StorageException
が発生することを予想したテストコードです。
単体テストに合わせて、次のコードを実装します。
public List<RecordedGrip> retrieveSection(String sectionName) {
// 実際に具現化するまでは空のダミーを返します。
return new ArrayList<RecordedGrip>();
}
しかし、このコードでは StorageException 例外が発生しないため、単体テストは失敗します。以下のように例外をスローするようにコードを変更します。
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionname);
} catch (Exception e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
このコードの catch ブロックで StorageException を発生させるので、これでテストが成功します。
ここで、より正確に例外の種類を捉えるために、catch 部分で Exception ではなく FileNotFoundException をキャッチするように修正します。
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (FileNotFoundException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
TDD と try-catch を適用して、このメソッドで発生する可能性のある例外に事前に備えました。
その後は、try ブロック内に必要なロジックを記述すればよいです。
まとめ: TDD と try-catch をなぜ使うのか?
-> TDD を通じて例外が発生する状況をまずテストで作成し、それに対応するコードを作成すれば、データベースのトランザクション概念のように安定した処理が可能だと言われています。
3. unchecked 例外を使用せよ
Java の例外には、checked と unchecked の 2 種類の例外が存在しますが、checked 例外は特別な場合を除いて使用しません。
以下の図は、Java で発生する可能性のあるエラーを Exception と Error に分けた図です。
Java では、Exception クラスは 2 つのグループに分けられます。
- Exception クラスとそのサブクラス (RuntimeException とそのサブクラスを除く) ⇒ チェックされる例外(Checked Exception)
- RuntimeException とそのサブクラス ⇒ チェックされない例外(Unchecked Exception)
チェックされる例外(Checked Exception)とは?
- コンパイラが例外処理を強制する例外
- ユーザー(外部)の動作によって発生する可能性のある例外
- 例) IOException, SQLException など
チェックされない例外(Unchecked Exception)とは?
- コンパイラが例外処理を強制しない例外
- 開発者のミスによって発生する可能性のある例外
- 例) NullPointerException, ArrayIndexOutOfBoundException など
3-1. なぜ Unchecked Exception は例外処理を強制されないのか?
Checked Exception と Unchecked Exception の最大の違いは、例外処理をコンパイル時に強制するかどうかです。
RuntimeException クラスとそのサブクラスは、開発者によるミスで発生する可能性があるため、強制されません。
もし強制される場合、以下のように配列を使用する際に ArrayIndexOutOfBoundException や NullPointerException の処理を毎回行わなければならない状況が発生します。
int[] numbers = new int[10];
try {
numbers[0]=1;
System.out.println(numbers[0]);
} catch (NullPointerException e) {
System.out.println("NullPointerException: " + e.getMessage());
} catch(ArrayIndexOutOfBoundException e) {
System.out.println("ArrayIndexOutOfBoundException: " + e.getMessage());
}
上記のようなコードでは、配列を使用するたびに例外処理を繰り返す必要があります。
これにより、コードの可読性とメンテナンス性が低下します。
したがって、RuntimeException
は開発者のミスによって発生する可能性のある例外と見なし、明示的な処理を強制しないことが一般的です。
3-2. なぜ Checked Exception は使用すべきではないのか?
一般的に、Checked Exception はメソッドシグニチャに宣言され、呼び出し元に例外が発生する可能性を知らせます。 これは、呼び出し元が該当の例外を処理するか、上位のメソッドに再度例外を渡す必要があることを意味します。
public void marigoldMethod() throws IOException {
...
}
// CASE 1
public void aimyonMethod(){
try {
marigoldMethod();
} catch (IOException e) {
// Exception 処理
}
}
// CASE 2
public void aimyonMethod() throws IOException {
marigoldMethod();
}
marigoldMethod()
で IOException が発生する可能性を宣言しているため、marigoldMethod()
を呼び出すすべてのメソッドでは、IOException に対して適切な処理を行うか、再度上位のオブジェクトに例外をスローします。
このような方式は、下位メソッドである marigoldMethod()
の変更が上位メソッド aimyonMethod()
に影響を与えるため、OCP を違反します。
一方で、Unchecked Exception は RuntimeException を継承しており、シグニチャメソッドに宣言する必要がありません。
呼び出し元が明示的に例外処理を行わなくてもよい
public void marigoldMethod(){
...
}
これで、marigoldMethod()
を呼び出すメソッドで例外処理を行う必要がありません。
public void aimyonMethod(){
marigoldMethod();
}
このように、安定したサービスを作成するために必ずしもチェックされた例外が必要なわけではありません。 たとえば、C#、C++、Python、Ruby などの言語はチェックされた例外をサポートしていませんが、それでも安定したサービスを実装するのに問題はありません。
内容が長くなったので、後ほど続けて書きます。
読んでいただき、ありがとうございました。