JSON API, Failure統一クライアントメッセージ

jfluteもまだまだ研究中のテーマなので、何か要因が見つかれば随時更新していきます。

概要

JSONデザインとして、以下を採用したときのパターンを ふぇいくらパターン と呼んでいます。

よく見かけるやり方でありながら、よく考えてデザインしてクライアントと共有しないと、"へんてこりん" になりがちで、LastaFluteでも少し実装に工夫が必要なので、ここで特集して紹介します。

もし、このパターンを採用しているのであれば、具体的な "JSONや形" や "LastaFluteでの実装" の参考にと。 もちろん、必ずしもこのページの通りではなくても良いです。思想の共有になればと。

基本的には、"特定クライアント向けAPI" を想定しています。

戻す 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")
    }, {
        ...
    }]
}

そして、それを受け取るクライアントサイドでの実装イメージです。

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 は、チェック処理で使った定義値など (メッセージに埋め込まれることを想定)
e.g. "ふぇいくらパターン" で、バリデーションエラーのとき @Json
{
    "cause" : "VALIDATION_ERROR"
    , "errors" : [{
         , "field" : "product_name"
         , "code" : "REQUIRED"
         , "data" : {}
    }, {
         , "field" : "product_count"
         , "code" : "MAX"
         , "data" : { max : "100" } // 数値の最大値
    }, {
         , "field" : "member.email"
         , "code" : "LENGTH"
         , "data" : { min : "20", max : "50" } // 文字列の最大長
    }, {
         , "field" : "member.email"
         , "code" : "EMAIL"
         , "data" : {}
    }]
}

ビジネスエラーのJSON (業務例外)

ここで言うビジネスエラーとは、業務例外によって処理が中断された状態を表します。

  • field は、_global 固定 (特定の項目がないため) (LastaFluteのデフォルト)
  • code は、業務例外の種別を特定
  • data は、基本的に使うことはないが、必要なこともあるかもしれないので
e.g. "ふぁいくらパターン" で、業務例外のとき @Json
{
    "cause" : "BUSINESS_ERROR"
    , "errors" : [{
         , "field" : "_global"
         , "code" : "ALREADY_DELETED"
         , "data" : {}
    }]
}

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プロジェクトの Hangar アプリがそれになります。

全く同じ構造なのであれば、コピーして名前の微調整だけで動く可能性もあります。"Hangar" の部分はアプリの名前に適切に変えていきましょう。 ただ、(念のため、当然のことですが)しっかり理解をしてテストもしましょう。

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}
constraints.Min.message = MIN | min:{value}
...
constraints.Length.message = LENGTH | min:{min}, max:{max}
...
constraints.Required.message = REQUIRED
constraints.TypeAny.message = TYPE_ANY | type:"{propertyType}"
constraints.TypeInteger.message = TYPE_NUMBER // これ新しく追加
constraints.TypeLong.message = TYPE_NUMBER // これ新しく追加
constraints.TypeLocalDate.message = TYPE_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
errors.app.illegal.transition=ILLEGAL_TRANSITION
errors.app.db.already.deleted=ALREADY_DELETED
errors.app.db.already.updated=ALREADY_UPDATED
errors.app.db.already.exists=ALREADY_EXISTS
errors.app.double.submit.request=DOUBLE_SUBMIT


# _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
# 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

# for no-message application exception, basically should not be used
errors.unknown.business.error=UNKNOWN_BUSINESS_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 だったら中身も見ずにシステムエラーの処理をするだけ という振舞いが想定されるので、特に問題はないと想定されます。

ふぁいはいパターンだと? (ハイブリッド)

メッセージをハイブリッド方式にした場合は、こんな感じでしょうか。クライアントメッセージ方式のやり方に、単に自然言語のユーザーメッセージを載せるための message を追加しています。

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" : (自然言語のユーザーメッセージ、利用したければどうぞ)
    }, {
        ...
    }]
}

Maihama プロジェクトの Showbase アプリにて、Example があります。