Java 21でのレイヤードアーキテクチャにおけるエラーハンドリングのすゝめ
概要
Java21によるSealed ClassとPattern Matchingを扱い、エラーハンドリングの可読性を向上させる
課題
現在BFFレイヤーの開発をJavaで行っています。
詳細はこちら
Backends For Frontends(BFF)はじめました
本開発を行う際に、エラーハンドリングの可読性に対しての課題感がありました
BFF層は各マイクロサービスへのアクセスを行います
その際に各サービス毎のエラーハンドリングが必要です
例えばあるエンドポイントがマイクロサービスA, B, Cにアクセスする場合、以下のようなエラーハンドリングが必要です
以下に記載する4xx, 5xxなどはHttpStatusを示します
マイクロサービス | マイクロサービス レスポンス | BFF レスポンス |
---|---|---|
A | 4xx | 500 Internal Server Error |
5xx | 502 Bad Gateway | |
B | 4xx | 500 Internal Server Error |
5xx | 502 Bad Gateway | |
C | 4xx | 500 Internal Server Error |
5xx | 502 Bad Gateway |
内訳の理由としては以下の理由です
- マイクロサービス側が4xx (Client error)を返す
リクエストを投げているBFF側の不具合の場合が多い
BFFの内部エラーと判定し、500 Internal Server Error
をFrontendにレスポンス - マイクロサービス側が5xx (Server error)を返す
リクエストを受け取っているマイクロサービス側の不具合の場合が多い
BFFの外部エラーと判定し、502 Bad Gateway
をFrontendにレスポンス
一番単純なケースでこのような形になりますが、実際にはこの図に当てはまらないケースが多数ありハンドリングは多岐にわたります
- Frontendから渡されるデータが、マイクロサービス側で見つからない
URL Resourceに含まれている、ユーザーが自由に設定できるリソースが見つからない
この場合はユーザー側のクライアントエラーとなる
BFFが400 Bad Request
,404 Not Found
などをFrontend返す必要がある - エラーが返ってきた場合でも、後続処理が継続可能
処理にクリティカルではないエラーが返ってくるケース
別のリソースから値を取れるケース
キャッシュにアクセス後、存在しなければマイクロサービスにアクセス
このようなことを考えた場合、先のエラーハンドリングは以下のように複雑化します
マイクロサービス | マイクロサービスレスポンス | BFFレスポンス | 理由 |
---|---|---|---|
A | 4xx | 500 Internal Server Error | BFFのリクエスト構築に問題がある可能性が高いため、BFF内部エラーとして扱う |
5xx | 502 Bad Gateway | マイクロサービス側の問題であるため、マイクロサービスのエラーとして扱う | |
404 Not Found | 404 Not Found | リソースが見つからない場合、ユーザーリクエストのクライアントエラーとして処理 | |
B | 4xx | 500 Internal Server Error | BFFのリクエスト構築に問題がある可能性が高いため、BFF内部エラーとして扱う |
5xx | 502 Bad Gateway | マイクロサービス側の問題であるため、マイクロサービスのエラーとして扱う | |
400 Bad Request | 400 Bad Request | ユーザーリクエストに問題があるため、クライアントエラーとして処理 | |
C | 4xx (軽微なエラー) | 200 OK (後続処理継続) | 処理にクリティカルではないため、後続処理を続行 |
5xx | 502 Bad Gateway | マイクロサービス側の問題であるため、マイクロサービスのエラーとして扱う | |
5xx (キャッシュ利用可能) | 200 OK (キャッシュ利用) | キャッシュから代替データを取得できる場合は正常処理として返す |
Javaによるエラーハンドリングとその課題
本サービスはレイヤードアーキテクチャを用いています
Infrastructre層にて、マイクロサービスから値を取得
各マイクロサービスへの通信は、SpringFrameworkのRestTemplateを使って行っています
近日上位互換のRestClientに移管予定です
public GetADataResponse getAData()
throws AServiceApiClientErrorException, AServiceServerErrorException {
try {
return restTemplate
.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(),
ADataResponseBody.class)
.getBody();
} catch (HttpClientErrorException e) {
throw new AServiceApiClientErrorException(e);
} catch (RestClientException e) {
throw new AServiceApiServerErrorException(e);
}
}
RestTemplateにて発生する例外はこちらです
ref. https://qiita.com/shohe05/items/88b120432e694c9b63f6
以下2つにより、エラーケースを網羅できます
-
HttpClientErrorException
マイクロサービスから4xxのレスポンスが返ってきた場合 -
RestClientException
マイクロサービスから5xxのレスポンスが返って来た場合
その他の通信エラー
各レスポンスデータを変換
RepositoryパターンをDIPで使用しているので、RepositoryImplにてDomainへ変換を行います
public class ADataRepositoryImpl
...
public AData find()
throws ADataNotFoundException, ADataErrorException,
ADataConnectionErrorException {
try {
GetADataResponse response =
AServiceApiAdapter.getAData();
return toDomainModel(response);
} catch (AServiceApiClientErrorException e) {
if (e.getHttpStatus() == HttpStatus.NOT_FOUND) {
throw new ADataNotFoundException(e.getMessage(), e);
}
throw new ADataErrorException(e.getMessage(), e);
} catch (AServiceApiServerErrorException e) {
throw new ADataConnectionErrorException(e.getMessage(), e);
}
}
UseCaseにて各マイクロサービスから取得したデータを集約してResponse用に加工
上記のRepositoryの実装をA, B, Cに行い、UseCaseとして実装した例が以下です
import org.springframework.stereotype.Service;
@Service
@RequireArgsConstructor
public class AggregateDataUseCase {
private final ADataRepository aDataRepository;
private final BDataRepository bDataRepository;
private final CDataRepository cDataRepository;
public AggregateResponse execute()
throws ADataNotFoundException,
ADataConnectionErrorException,
BDataBadRequestException,
BDataConnectionErrorException,
CDataErrorException,
CDataConnectionErrorException {
AData aData = aDataRepository.find();
BData bData = bDataRepository.find();
CData cData = null;
try {
cData = cDataRepository.find();
} catch (CDataCacheException e) {
// 処理を継続
}
// データを集約してレスポンスを作成
return toAggregateResponse(aData, bData, cData);
}
このコードは以下のような課題が存在します
- throwsのエラーの追跡がしにくい
各Domain毎にエラーを再定義しているため、その付近から出力している例外なのは暗黙的にわかります
しかし、例外発生箇所を見つけるには、各処理を追跡していかなければなりません
実務において今回のような単純な処理は稀で、実際には他にもいくつかの分岐や処理が発生します
すべての検査例外を出力するメソッドにtry-catchを記載し、throw e
を毎回出力することは可能です
これにより、例外を出力するメソッドと例外を近づけることはできます
しかし以下のようなデメリットが存在します
- throwsの記述は残る
- try-catchをどの粒度で記載するかの判断が難しい
毎回try-catchを記載するサンプル
try {
// ADataを取得
aData = aDataRepository.find();
} catch (ADataNotFoundException | ADataConnectionErrorException e) {
throw e;
}
try {
// BDataを取得
bData = bDataRepository.find();
} catch (BDataBadRequestException | BDataConnectionErrorException e) {
throw e;
}
try {
// CDataを取得
cData = cDataRepository.find();
} catch (CDataErrorException | CDataConnectionErrorException e) {
throw e;
}
Sealed classesとPattern Matching for switchでthrowsをなくす
Sealed classesとは
参考にさせていただいた記事
AorBというinterfaceの値を返す場合、以下が成立します
- その値はclass Aである
- そうでなければ、必ずBである
// sealed interface の定義
public sealed interface AorB permits A, B {}
// class A の定義
public final class A implements AorB {}
// class B の定義
public final class B implements AorB {}
Pattern Matching for switchとは
Switch文でclassやrecordが使えるようになる、Java 21からの新機能です
public void process(AorB response) {
switch (response) {
case A a -> a.performAction();
case B b -> b.performAction();
}
}
レイヤードアーキテクチャの例外の受け渡しに利用
これらを先程の例外処理の伝搬に適用します
前提として、Spring FrameWorkの例外は、引き続きtry-catchを使用した従来の方式を使用します
そのためマイクロサービスとの通信の処理は、以前と同様のものとします
あくまでも内部で処理する独自例外のみで利用します
Adapterにて、以下のSpring FrameWorkの例外を独自例外に変換している部分にSealed classesを付与します
HttpClientErrorException → AServiceApiClientErrorException
RestClientException → AServiceApiServerErrorException
明示的にResponseとErrorが変えることを示します
public sealed interface GetADataResponseOrError
permits GetADataSuccessResponse,
AServiceApiClientErrorException,
AServiceApiServerErrorException {}
// 成功時のレスポンスデータ
public record GetADataSuccessResponse
implements GetADataResponseOrError {
String aData;
}
public final class AServiceApiClientErrorException extends Exception
implements GetADataResponseOrError {}
public final class AServiceApiServerErrorException extends Exception
implements GetADataResponseOrError {}
これらをマイクロサービス通信メソッドに適用します
public GetADataResponseOrError getAData() {
try {
return restTemplate
.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(),
ADataResponseBody.class)
.getBody();
} catch (HttpClientErrorException e) {
return new AServiceApiClientErrorException(e);
} catch (RestClientException e) {
return new AServiceApiServerErrorException(e);
}
}
ドメインに変換するメソッドにて、Pattern Matchingを行います
public class ADataRepositoryImpl
...
public ADataOrError find() {
GetADataResponseOrError response = aServiceApiAdapter.getAData();
return switch (response) {
case GetADataSuccessResponse success -> toDomainModel(success);
case AServiceApiClientErrorException clientError -> {
if (clientError.getHttpStatus() == HttpStatus.NOT_FOUND) {
return new ADataNotFoundException(clientError.getMessage(),
clientError);
}
return new ADataErrorException(clientError.getMessage(), clientError);
}
case AServiceApiServerErrorException serverError -> {
return new ADataConnectionErrorException(serverError.getMessage(), serverError);
}
};
}
このPattern Matchingを各サービスのドメイン変換クラスで行うことにより、throwsに記載する例外がなくなります
UseCaseにおけるエラーハンドリング
@Service
@RequiredArgsConstructor
public class AggregateDataUseCase {
private final ADataRepository aDataRepository;
private final BDataRepository bDataRepository;
private final CDataRepository cDataRepository;
public AggregateResponse execute() {
// AData の処理
ADataOrError aDataOrError = aDataRepository.find();
AData aData = switch (aDataOrError) {
case AData success -> success;
case ADataNotFound e -> return e;
case ADataConnectionError e -> return e;
case ADataError e -> return e;
};
BDataOrError bDataOrError = bDataRepository.find();
BData aData = switch (bDataOrError) {
case BData success -> success;
case BDataNotFound e -> return e;
case BDataConnectionError e -> return e;
case BDataError e -> return e;
};
CDataOrError cDataOrError = cDataRepository.find();
CData cData = switch (cDataResult) {
case CData success -> success;
case CDataNotFound e -> return e;
case CDataConnectionError e -> return e;
case CDataError e -> return e;
};
return toAggregateResponse(aData, bData, cData);
};
}
このようにすることで、例外をthrowsに記載することなく、処理が追いやすい形でエラーハンドリングすることができました!
Discussion