🕶️

Java 21でのレイヤードアーキテクチャにおけるエラーハンドリングのすゝめ

2024/12/04に公開

概要

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を毎回出力することは可能です
これにより、例外を出力するメソッドと例外を近づけることはできます
しかし以下のようなデメリットが存在します

  1. throwsの記述は残る
  2. 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とは

参考にさせていただいた記事
https://qiita.com/hanohrs/items/964e9cbf41961e701484

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