JSON API, Failure統一ハイブリッドメッセージ
jfluteもまだまだ研究中のテーマなので、何か要因が見つかれば随時更新していきます。
- 概要
- 戻す JSON のかたち
- バリデーションエラーのJSON
- ビジネスエラーのJSON (業務例外)
- クライアントエラーのJSON (クライアント例外)
- サーバーエラーのJSON (システム例外)
- LastaFluteでの実装
概要
JSONデザインとして、以下を採用したときのパターンを ふぇいはいパターン と呼んでいます。
基本的には、Failure統一クライアントメッセージ (ふぁいくらパターン) のメッセージ部分をハイブリッドに変更しただけ です。 ふぁいくらパターンについて理解してからのほうがわかりやすいので、そちらをぜひ読んでからこのページを読みましょう。
もし、このパターンを採用しているのであれば、具体的な "JSONや形" や "LastaFluteでの実装" の参考にと。 もちろん、必ずしもこのページの通りではなくても良いです。思想の共有になればと。
クライアントが特定できないような場合に、利用できるパターンと言えます。
戻す JSON のかたち
以下のような JSON を戻します。(名前や構造などは、必ずしも一致しなくてもOKです)
e.g. "ふぇいはいパターン" で、バリデーションエラーのとき @Json
{
// Failureの原因
"cause" : (VALIDATION_ERROR or BUSINESS_ERROR
or CLIENT_ERROR or SERVER_ERROR)
// エラーの詳細
, "errors" : [{
, "field" : (受け取ったJSON項目名、階層表示あり e.g. product_name)
, "code" : (エラーチェックの種別を示すコード e.g. REQUIRED, MAX など)
, "data" : (チェックに関連するデータ、長さチェックの最大長など
e.g. min : "20", max : "50")
, "message" : (自然言語のユーザーメッセージ、利用したければどうぞ)
}, {
...
}]
}
そして、それを受け取るクライアントサイドでの実装イメージです。
e.g. Failure統一パターンでのクライアントサイドのJSONパースの共通部品での実装イメージ @Java?
if (HTTP Status: 200) { // success
XxxJsonResult result = parseJsonAsSuccess(response);
// do process per action
...
} else if (HTTP Status: 400) { // e.g. validation error, application exception, client exception
FailureResult result = parseJsonAsFailure(response);
// show result.errors or do process per result.cause
...
} else if (HTTP Status: 404) { // e.g. real not found, invalid parameter
showNotFoundError();
} else { // basically 500 or other client errors
showSystemError();
}
バリデーションエラーのJSON
- field は、ユニークとは限らない (一つの項目で二つエラーになり得るため)
- field は、階層構造を表現 (受け取ったJSONの階層構造をドット区切りで表現)
- code は、チェック処理の種別ごとで、基本的にValidatorアノテーションごと
- data は、チェック処理で使った定義値など (メッセージに埋め込まれることを想定)
- message は、自然言語のユーザーメッセージ ("主語あり" か "主語なし" かはまた要検討)
e.g. "ふぇいはいパターン" で、バリデーションエラーのとき @Json
{
"cause" : "VALIDATION_ERROR"
, "errors" : [{
, "field" : "product_name"
, "code" : "REQUIRED"
, "data" : {}
, "message" : "is required"
}, {
, "field" : "product_count"
, "code" : "MAX"
, "data" : { max : "100" } // 数値の最大値
, "message" : "should be less than or equal 100"
}, {
, "field" : "member.email"
, "code" : "LENGTH"
, "data" : { min : "20", max : "50" } // 文字列の最大長
, "message" : "should be between 20 and 50"
}, {
, "field" : "member.email"
, "code" : "EMAIL"
, "data" : {}
, "message" : "wrong format as email"
}]
}
ビジネスエラーのJSON (業務例外)
ここで言うビジネスエラーとは、業務例外によって処理が中断された状態を表します。
- field は、_global 固定 (特定の項目がないため) (LastaFluteのデフォルト)
- code は、業務例外の種別を特定
- data は、基本的に使うことはないが、必要なこともあるかもしれないので
- message は、自然言語のユーザーメッセージ
e.g. "ふぁいはいパターン" で、業務例外のとき @Json
{
"cause" : "BUSINESS_ERROR"
, "errors" : [{
, "field" : "_global"
, "code" : "ALREADY_DELETED"
, "data" : {}
, "message" : "others might be deleted, so retry"
}]
}
cause が直接 ALREADY_DELETED になるやり方もあり得ますが、フラット構造になってしまってクライアントサイドが分岐をしづらいかもしれませんし、data をどうやって渡すかを工夫しないといけないでしょう。
field の _global は、実質クライアントは見ないかもしれません。BUSINESS_ERROR だったら、特定の項目に対するメッセージじゃないって割り切った判断をするかもしれませんので。 万が一、項目特化の業務例外があったときのためのものです。
ちなみに業務例外の粒度ですが、これは "ふぇいはいパターン" に限らず、例外ハンドリングの "一件検索でデータ無かったとき" のページを参考に。
クライアントエラーのJSON (クライアント例外)
ここで言うクライアントエラーとは、クライアント例外によって処理が中断された状態を表します。
基本的には、クライアントエラーはクライアントにとっての不具合なので、発生したらサーバーサイドのログを見て状況を把握してクライアントプログラムを修正します。 (HTTPステータスが 400, 403, 404 の違いで若干状況は把握できますが、あまりデバッグとしてはそこまで重要ではないでしょう)
ゆえに、クライアント例外は、多くのケースでメッセージを必要としないので errors は空っぽです。 (ただ、必要であればビジネスエラーのときと同じようにerrorsに何か入れても良いでしょう)
e.g. クライアント例外のとき @Json
{
"cause" : "CLIENT_ERROR"
, "errors" : []
}
サーバーエラーのJSON (システム例外)
ここで言うサーバーエラーとは、システム例外によって処理が中断された状態を表します。
基本的に、サーバーエラーはクライアントとしてはどうにもならないので、単に判断ができるだけで問題ないでしょう。 HTTPステータスが500になるので、なので実質的にJSON自体は不要であると考えられますが、念のため同じ形で戻せるようにしても良いでしょう。
e.g. サーバーエラーのとき @Json
{
"cause" : "SERVER_ERROR"
, "errors" : []
}
LastaFluteでの実装
Failure統一パターンは、Exampleと同じ
このページで紹介したパターンを、そのまま Example で実装しています。Maihamaプロジェクトの Showbase アプリがそれになります。
全く同じ構造なのであれば、コピーして名前の微調整だけで動く可能性もあります。"Showbase" の部分はアプリの名前に適切に変えていきましょう。 ただ、(念のため、当然のことですが)しっかり理解をしてテストもしましょう。
errorsの実装をどうしよう?
errors.code や errors.data をどうやって定義して取得するか?ここが実装のポイントとなります。
というのは、Hibernate Validator からは、REQUIRED や MAX などの種別情報は取得できず、[app]_message.properties のメッセージしか取得できないからです。(constraints.Required.message ではなく、"is required" しか取得できない)
なので、[app]_message.properties にて、自然言語のメッセージをやめて、種別情報とチェック関連の値を定義し、 ApiFailureHookにて、この "メッセージ" (errors.code, errors.data) をパースして、JSON Result に設定するロジックを実装しましょう。
[app]_message.propertiesの整備
Exampleはこちら:
バリデーションエラーのメッセージ
バリデーションメッセージを、このように修正します。(ここでは "主語なし" を想定しています)
e.g. "ふぁいはいパターン" のために [app]_message.properties のバリデーションメッセージを修正 @Properties
# (key of message) = (errors.code, errors.data)
...
constraints.Max.message = MAX | max:{value} :: should be less than or equal {value}
constraints.Min.message = MIN | min:{value} :: should be greater than or equal {value}
...
constraints.Length.message = LENGTH | min:{min}, max:{max} :: should be between {min} and {max}
...
constraints.Required.message = REQUIRED :: is required
constraints.TypeAny.message = TYPE_ANY | type:"{propertyType}" :: should be {propertyType}
constraints.TypeInteger.message = TYPE_NUMBER :: should be number // これ新しく追加
constraints.TypeLong.message = TYPE_NUMBER :: should be number // これ新しく追加
constraints.TypeLocalDate.message = TYPE_DATE :: should be date // これ新しく追加
...
データ型変換エラーで、Integer や LocalDate など Java に依存した値を戻しても良くないので、 必要に応じて、TypeInteger, TypeLocalDate などのメッセージを追加すると良いでしょう。 (TypeAnyは、データ型変換エラーのデフォルトのメッセージという扱い)
バリデーションエラーの種別 (Required, Max など) と、errros.code は必ずしも同じ値でしなくても良いですが、基本的には合わせておいたほうが間違いは少ないでしょう。 ただ、Integer か Long や LocalDate をクライアントに意識させる必要もないので、TYPE_NUMBER や TYPE_DATE という風に少し翻訳しています。
業務例外のメッセージ
そして、業務例外 (BUSINESS_ERROR) の方です。プロパティの追加もあります。
e.g. "ふぁいはいパターン" のために [app]_message.properties の業務例外メッセージを修正 @Properties
...
# ----------------------------------------------------------
# Application Exception
# ---------------------
# /- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# six framework-embedded messages (don't change key names)
# - - - - - - - - - -/
errors.login.failure=LOGIN_FAILURE :: could not login
errors.app.illegal.transition=ILLEGAL_TRANSITION :: retry because of illegal transition
errors.app.db.already.deleted=ALREADY_DELETED :: others might be deleted, so retry
errors.app.db.already.updated=ALREADY_UPDATED :: others might be updated, so retry
errors.app.db.already.exists=ALREADY_EXISTS :: already existing data, so retry
errors.app.double.submit.request=DOUBLE_SUBMIT :: double submit might be requested
# _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
# you can define your messages here:
# e.g.
# errors.xxx = ...
# info.xxx = ...
# _/_/_/_/_/_/_/_/_/_/
# ========================================================================================
# General
# =======
# ----------------------------------------------------------
# Application Validator
# ---------------------
# e.g.
# org.docksidestage.validator.constraints.SeaLand.message = SEA_LAND
# ----------------------------------------------------------
# Application Exceptionv
# ---------------------
# framework does not have own message so define here, used in your ApiFailureHook
errors.login.required=LOGIN_REQUIRED :: you should login
# for no-message application exception, basically should not be used
errors.unknown.business.error=UNKNOWN_BUSINESS_ERROR :: (no message error)
...
FreeGenを叩きましょう
この後、ApiFailureHookで追加されたプロパティなどを利用するので、FreeGenを叩きましょう。
ApiFailureHookの実装
もう、Exampleをがっつり読んでください
Exampleはこちら:
commonか?appか?
Exampleは、マルチプロジェクトの中の一つのアプリプロジェクトだけが "ふぁいくらパターン" なので、すべてのプロパティを怒涛の @Override でオーバーライドしています。もし、すべてのアプリプロジェクトで "ふぁいくらパターン" なのであれば、これら修正は common でも良いでしょう。
[App]BaseActionでfilterも実装
細かいですが、[App]BaseAction にて filterApplicationExceptionMessageValues() をオーバーライドし、業務例外のメッセージの values を JSON として展開する必要があります。 (業務例外で values を使うときだけの処理ではありますが)
クライアントエラーの実装は? (クライアント例外)
ApiFailureHookにて
causeだけを CLIENT_ERROR にして、errorsの実装は実質的にビジネスエラー(業務例外)と同じで良いでしょう。メッセージがあればが設定されるし、なければ空っぽになるだけなので。
web.xmlにて最後の砦JSON
もし、アプリケーションサーバー (e.g Tomcat) レベルの 404 なども JSON でしっかり戻すのであれば、web.xml にて定義されている 400.html, 404.html などを 400.json, 404.json に変更して、アプリが戻すクライアント例外のJSONと一致する固定のJSONを定義しておくと良いでしょう。
ただし、そのアプリが純粋な JSON API ではなく、サーバーサイドHTMLも混じっているのであれば、この技は使えないです。 とはいえ、その場合だとなおさら、あまりクライアントがクライアントエラーを詳細にハンドリングする必要もないので、特に問題はないと想定されます。
サーバーエラーの実装は? (システム例外)
ApiFailureHookにて
errors に何か設定することはないので、ApiFailureHook では何もせず(OptionalThing.empty()を戻す)、web.xml の設定に任せるで良いでしょう。
web.xmlにて最後の砦JSON
web.xmlにて定義されている 500.html を 500.json に変更して、アプリが戻すJSONと一致する固定のJSONを定義しておくと良いでしょう。
こちらも同様に、そのアプリが純粋な JSON API ではなく、サーバーサイドHTMLも混じっているのであれば、この技は使えないですが、そもそもクライアントは、HTTPステータスが 500 だったら中身も見ずにシステムエラーの処理をするだけ という振舞いが想定されるので、特に問題はないと想定されます。