忘れられないApiFailureHook
JSON時のエラーハンドリング
統一的なJSONを戻す
JsonResponse の Action にて バリデーションエラー、業務例外、システム例外が発生したときは、 Actionごとつどつど制御するのではなくアプリ全体で共通的な処理をすることが想定される ため、共通部分で統一的なJSONを戻します。
それを司るのが、ApiFailureHook です。これ自体はインターフェースなので、実装クラスは Maihama プロジェクトであれば、MaihamaApiFailureHook というクラスとなり、AssistantDirectorで登録されます。
- handleValidationError()
- バリデーションエラーのHook
- handleApplicationException()
- 業務例外のHook
- handleClientException()
- クライアント例外のHook
- handleServerException()
- システム例外のHook
最初にHookポリシーをデザイン
これは、フロントサイドと相談して決める必要があります。
- バリデーションエラーのとき、どういう形式でメッセージを戻すのか?
- そもそもバリデーションエラーをどう判定してもらうのか?
- 業務例外のユーザー用メッセージはどうやって戻すのか?
- そもそも業務例外のユーザー用メッセージはサーバー管理?クライアント管理?
- それぞれの業務例外をどう判定してもらうのか?
- JSONパースエラーなどフロントサイドの不具合のときどうするのか?
- nullPointerなどサーバーサイドの不具合のときどうするのか?
これはプロジェクトによってガラリを変わるものなので、いい感じのデザインをしましょう。
LastaFlute の Example プロジェクト (harbor や maihama) では、シンプルな実装がされていますので、それを参考にしてもよいでしょう。
そもそもシステム例外や業務例外とは?
そもそも、システム例外やクライアント例外や業務例外の区別がよくわかってない場合や、LastaFluteの例外ハンドリングを把握していない場合は、こちらのページを読んでから考えましょう。
バリデーションエラーをHook
handleValidationError(resource)
バリデーションエラーならここ!
- 考えるべきこと
- どういう形式でバリデーションエラーのユーザー用メッセージを戻すのか?
- バリデーションエラーであることをどうやって判定してもらうか? (業務例外との区別など)
- 実装上のヒント
- バリデーションエラーのメッセージは、ApiFailureResource から取得できる
- resource.getPropertyMessageMap() で、プロパティごとの変数解決済みメッセージ
- この時点ではメッセージは必ず存在する (バリデーションエラーがあるのにメッセージがないってのはない)
- LastaFluteの中でDEBUGレベルで詳細がログ出力される (本番で出すと大変なことになるため)
- HTTPステータスは何も指定しなければ 200 になる
MaihamaのValidationError
シンプル過ぎるBeanに FailureType "VALIDATION_ERROR" でメッセージを載せて、HTTPステータスは 400 (Bad Request) にしています。(BUSINESS_FAILURE_STATUS は業務例外の方でも利用する)
e.g. handleValidationError() of MaihamaApiFailureHook @Java
protected static final int BUSINESS_FAILURE_STATUS = HttpServletResponse.SC_BAD_REQUEST;
...
@Override
public ApiResponse handleValidationError(ApiFailureResource resource) {
final TooSimpleFailureBean bean = createFailureBean(TooSimpleBizStatus.VALIDATION_ERROR, resource);
return asJson(bean).httpStatus(BUSINESS_FAILURE_STATUS);
}
業務例外も同じく 400 Bad Request で統一し、フロントサイドでは 400 なら Failure 用の統一的なJSONをパースし、failureType で判断してもらうという想定です。
業務例外をHook
handleApplicationException(resource, cause)
LaApplicationException or 組み込み業務例外ならここ!
LaApplicationException を継承した例外、もしくは、フレームワーク組み込み業務例外が throw されたときに呼び出されます。 (アプリで作った例外でも、LaApplicationExceptionを継承していれば業務例外です)
- 考えるべきこと
- 業務例外のユーザー用メッセージはどうやって戻すのか?
- そもそも業務例外のユーザー用メッセージはサーバー管理?クライアント管理?
- それぞれの業務例外をどう判定してもらうのか? (判定する必要があるケースとないケースは?)
- 実装上のヒント
- メッセージが紐づいている業務例外であれば、そのメッセージを ApiFailureResource から取得できる。 例えば、組み込みメッセージに紐づいているフレームワーク組み込み業務例外だったり、MessageKeyApplicationException で明示的にメッセージを紐づけられた場合である。
- LastaFluteの中でINFOレベルで詳細がログ出力される (業務例外なのかエラーなのか判断が難しい)
- HTTPステータスは何も指定しなければ 200 になる
フレームワーク組み込み業務例外
LastaFluteですでに用意している業務例外があります。 LastaFlute自体が自動でthrowするものもあれば、アプリがthrowするメソッドを明示的に呼ぶこともあります。
- EntityAlreadyDeletedException (DBFluteの例外クラス)
- DBFluteの一件検索や更新でデータがなかったとき (selectEntity() などが throw)
- 組み込みメッセージの errors.app.db.already.deleted に紐づいている
- すれ違い更新、削除やアドレスバーいたずらが考えられるが、業務例外 or エラーの判断が難しい
- EntityAlreadyUpdatedException (DBFluteの例外クラス)
- DBFluteの一件更新ですれ違いが発生したとき (update() などが throw)
- 組み込みメッセージの errors.app.db.already.updated に紐づいている
- 専用の排他制御例外だが、存在しないPK指定でも発生するので、業務例外 or エラーの判断が難しい
- EntityAlreadyExistsException (DBFluteの例外クラス)
- DBFluteの登録や更新でユニーク制約違反が発生したとき (insert(), update() などが throw)
- 組み込みメッセージの errors.app.db.already.exists に紐づいている
- すれ違い登録や重複リクエストなどが考えられるが、こちらも業務例外 or エラーの判断が難しい
- LoginFailureException (extends LoginUnauthorizedException, LaApplicationException)
- ログイン処理に失敗したとき (LoginAssist の login() などが throw)
- 組み込みメッセージの errors.login.failure に紐づいている
- LoginRequiredException (extends LoginUnauthorizedException, LaApplicationException)
- ログイン必須画面にログインなしでリクエストしたとき (LastaFlute内で検知して throw)
- 特定の組み込みメッセージには紐づいていない (フロントサイドではログイン画面に飛ばすだけを想定)
- ForcedIllegalTransitionException (extends MessageKeyApplicationException)
- 不正な画面遷移を検知したとき (Action で明示的に throwIllegalTransition(), JSON APIだと使わないかも)
- 組み込みメッセージの errors.app.illegal.transition に紐づいている
- DoubleSubmitRequestException (extends MessageKeyApplicationException)
- ダブルサブミットを検知したとき (Action で明示的に verifyToken(), JSON APIだと使わないかも)
- 組み込みメッセージの errors.app.double.submit.request に紐づいている
- MessageKeyApplicationException (extends LaApplicationException)
- 任意のメッセージを載せてthrow (throwする人がメッセージを決めることができる)
- これを継承して、メッセージ固定の専用の例外を作ることもある
フレームワーク組み込みメッセージ
フレームワーク組み込み業務例外に対応する組み込みメッセージを、アプリに適したものに調整しましょう。 [App]_message.properties にて定義されています。
e.g. embedded messages in maihama_message.properties @Properties
...
constraints.Required.message = is required
constraints.TypeAny.message = should be {propertyType}
# ----------------------------------------------------------
# Application Exception
# ---------------------
# /- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# five framework-embedded messages (don't change key names)
# - - - - - - - - - -/
errors.login.failure=could not login
errors.app.illegal.transition=retry because of illegal transition
errors.app.db.already.deleted=others might be deleted, so retry
errors.app.db.already.updated=others might be updated, so retry
errors.app.db.already.exists=already existing data, so retry
errors.app.double.submit.request=double submit might be requested
# _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
# you can define your messages here:
# e.g.
# errors.xxx = ...
# info.xxx = ...
# _/_/_/_/_/_/_/_/_/_/
# ========================================================================================
# Sign up
# =======
...
MaihamaのApplicationException
例外に対応する FailureType を探して、シンプル過ぎるBeanに例外に紐づいているものメッセージを載せて、 HTTPステータスは 400 (Bad Request) で戻しています。
e.g. handleApplicationException() of MaihamaApiFailureHook @Java
protected static final int BUSINESS_FAILURE_STATUS = HttpServletResponse.SC_BAD_REQUEST;
protected static final Map<Class<?>, TooSimpleFailureType> failureTypeMap; // for application exception
static { // you can add mapping of failure type with exception
final Map<Class<?>, TooSimpleFailureType> makingMap = new HashMap<Class<?>, TooSimpleFailureType>();
makingMap.put(LoginFailureException.class, TooSimpleFailureType.LOGIN_FAILURE);
makingMap.put(LoginRequiredException.class, TooSimpleFailureType.LOGIN_REQUIRED);
failureTypeMap = Collections.unmodifiableMap(makingMap);
}
...
@Override
public ApiResponse handleApplicationException(ApiFailureResource resource, RuntimeException cause) {
final TooSimpleFailureType failureType = findAssignableValue(failureTypeMap, cause.getClass()).orElseGet(() -> {
return TooSimpleFailureType.APPLICATION_EXCEPTION;
});
final TooSimpleFailureBean bean = createFailureBean(failureType, resource);
return asJson(bean).httpStatus(BUSINESS_FAILURE_STATUS);
}
protected <VALUE> OptionalThing<VALUE> findAssignableValue(Map<Class<?>, VALUE> map, Class<?> key) {
final VALUE found = map.get(key);
if (found != null) {
return OptionalThing.of(found);
} else {
return OptionalThing.migratedFrom(map.entrySet().stream().filter(entry -> {
return entry.getKey().isAssignableFrom(key);
}).map(entry -> entry.getValue()).findFirst(), () -> {
throw new IllegalStateException("Not found the exception type in the map: " + map.keySet());
});
}
}
ログイン以外の業務例外は、フロントサイドではメッセージを表示するだけで特に区別はしないことを想定しています。 特にフロントサイドで制御する必要のない例外は、特にJSONにその情報を載せる必要はないという割り切りです。
もし、フロントサイドで個々の例外で特別な制御をしたくなるようであれば、failureTypeMap に例外とfailureTypeのマッピングを追加します。あまりに細かく多くなるようであれば、throwする側で FailureType を指定する例外クラスを作ってもよいでしょう。
クライアント例外をHook
handleClientException(resource, cause)
JSONパースエラー、ClientError.classならここ!
フロントからリクエストされたJSONのパースエラーや、ClientError.classを指定したバリデーションエラーなどのときに呼び出されます。 基本的に、LastaFlute内部で throw される例外です。
- 考えるべきこと
- JSONパースエラーなどフロントサイドの不具合のときどうするのか?
- 実装上のヒント
- ApiFailureResourceのユーザー用メッセージは存在しない
- HTTPステータスは何も指定しなくても自動で400系 (e.g. 400 BadRequest) になる
- OptionalThing.empty()を戻せば、400系 の空っぽResponseになる
- Webコンテナレベル (e.g. Tomcat) での 404 Not Found はここには来ない
- いずれにせよ、詳細なログはLastaFluteの中でINFOレベルで出力される
MaihamaのClientException
シンプル過ぎるBeanに failureType "CLIENT_EXCEPTION" を設定して、HTTPステータスはLastaFlute内部の判断 (e.g. 400, 404) で戻します。
e.g. handleClientException() of MaihamaApiFailureHook @Java
@Override
public OptionalThing<ApiResponse> handleClientException(ApiFailureResource resource, RuntimeException cause) {
final TooSimpleFailureBean bean = createFailureBean(TooSimpleBizStatus.CLIENT_EXCEPTION, resource);
return OptionalThing.of(asJson(bean)); // HTTP status will be automatically sent as client error for the cause
}
バリデーションエラーや業務例外を 400 Bad Request にしているので、フロントサイドで区別できるようにJSONで戻しています。 (区別してもあまり業務的な意義がないかもしれませんが、念のため)
Webコンテナレベル (e.g. Tomcat) での 404 Not Found などはこちらには来ませんので、フロントサイドで 400 系の JSON のパースエラーを考慮することが前提です。(その場合、クライアントだろうがサーバーだろうが、システムの不具合であることには変わりないので、実質的に500エラーと同等と考えてもよいでしょう)
サーバー例外をHook
handleServerException(resource, cause)
nullPointer など、論外なサーバーサイドならここ!
- 考えるべきこと
- nullPointerなどサーバーサイドの不具合のときどうするのか?
- 実装上のヒント
- ApiFailureResourceのユーザー用メッセージは存在しない
- HTTPステータスは何も指定しなくても自動で500 (ServerError) になる
- OptionalThing.empty()を戻せば、500 の空っぽResponseになる
- いずれにせよ、詳細なログはLastaFluteの中でERRORレベルで出力される
MaihamaのServerException
何もせず OptionalThing.empty() を戻して、500 の Response にする。フロントサイドは、HTTPステータスが 500 であればシステムエラー画面を表示しておしまい。
e.g. handleServerException() of MaihamaApiFailureHook @Java
@Override
public OptionalThing<ApiResponse> handleServerException(ApiFailureResource resource, RuntimeException cause) {
return OptionalThing.empty(); // means empty body, HTTP status will be automatically sent as server error
}
Maihamaのフロント実装イメージ
Javaっぽい書き方ですが...
こんな感じです。
e.g. front-side implementation image of Maihama @Java
if (HTTP Status: 200) { // success
XxxJsonResult bean = parseJsonAsSuccess(response);
...(do process per action)
} else if (HTTP Status: 400) { // e.g. validation error, application exception, client exception
FailureBean bean = parseJsonAsFailure(response);
...(show bean.messageList or do process per bean.failureType)
} else if (HTTP Status: 404) { // e.g. real not found, invalid parameter
showNotFoundError();
} else { // basically 500, server exception
showSystemError();
}
Failure統一パターン
正常系は Action ごと固有の JSON を戻して(特に共通項目は無し)、Failure のケースのみ統一した JSON を戻すやり方を、 Failure統一パターン と呼んでいます。
もちろん、この通りでなくてもOKですが、一つの参考として。