見通しのValidation

Validationの実装方法

アノテーションを付けて、validate() を呼びます。

1. Form,Bodyにアノテーション

Hibernate Validator

Form, Body クラスに、Hibernate Validator のアノテーションを付けます。

e.g. Hibernate validator annotation in Form class @Java
public class SeaLandForm {

    @Length(max = 10)
    public String memberName;
}

必須チェックは @Required

必須チェックに関しては、LastaFlute で Required アノテーションを用意しています。 String や Integer など型を意識せずに利用できるアノテーションです。 (StringだからNotEmpty, IntegerだからNotNullなどと型ごとに使い分けなくてもいいように)

e.g. Required annotation in Form class @Java
public class SeaLandForm {

    @Required
    @Length(max = 10)
    public String memberName;
}

ルールは以下の通り。

String
NotBlank と同じ、nullと空文字と空白はダメ
Integer など
NotNull と同じ (必ず Wrapper 型 である Integer や Long を使いましょう)
LocalDate など
NotNull と同じ (必ず Java8 から導入された LocalDate などを使いましょう)
Boolean
NotNull と同じ (必ず Wrapper 型である Boolean を使いましょう)
List や Map など
NotEmpty と同じ、null や空リストはダメ、一件以上の要素が必要
Primitive型
効かない (0がsetされたのか、setされなくて0なのか区別が付かない)

全体的に プロパティの型にはWrapper型を使う というポリシーでよいでしょう。

システム項目は groups=ClientError.class

例えば、hiddenフィールドに保持しておいたIDやバージョン番号など、ユーザー入力ではないシステム項目は、 アノテーションの属性として groups=ClientError.class を指定するとよいでしょう。

e.g. Required annotation in Form class @Java
public class SeaLandForm {

    @Required(groups=ClientError.class)
    public Integer memberId;

    @Required
    @Length(max = 10)
    public String memberName;

    ...

    @Required(groups=ClientError.class)
    public Long versionNo;
}

これらの値が不正であれば、ユーザーの操作によるものではなくクライアントアプリのバグ (もしくは、ユーザーのいたずら) と考えられるので、 ユーザー向けメッセージではなく、400 Bad Request が戻ります。サーバー側ではINFOでログに残ります。 Assertの代わりのようなものと言えるでしょう

Eclipse でDBFlute補完テンプレートを入れていれば、_lavcli で補完できます。

e.g. completion of ClientError.class annotation @Java
@Required(_lavc...) // 丸括弧の中にカーソルを合わせて _lavcli で補完すると...
public Integer memberId;
--
@Required(groups=ClientError.class) // どん!
public Integer memberId;

ネストのプロパティは @Valid

ネストしているクラスの中のアノテーションも有効にしたいときは、@Valid を付けます

e.g. how to validate nested class @Java
public class SeaLandForm {

    @Valid
    public PiariElement piari;

    @Valid
    public List<BonvoElement> bonvos;

    public static class PiariElement {

        @Required
        public String dstore;
    }

    public static class BonvoElement {
        ...
    }
}

ちなみに、再利用しないネストクラスは、Form や Body のインナークラスでの定義をオススメしています。 独立している必要もなく、見通しをよくするためにと。戻りの JsonResult も同じです。

2. Action で validate() を呼ぶ

メソッド呼び出し方式です!

Actionクラスで validate() メソッドを呼ぶと、アノテーションの通りにバリデーションされます。

e.g. validate() in Action class @Java
@Execute
public HtmlResponse index(int productId, SeaLandForm form) {
    validate(form, messages -> {}, () -> {
        return asHtml(path_Sea_SeaLandJsp);
    });
    ...
    return asHtml(path_Sea_SeaLandJsp); 
}
第一引数
アノテーションが付与されている Form もしくは Body
第二引数
アノテーションではできないバリデーションを実装 (後述)
第三引数
バリデーションエラーになったときのResponse処理 (いまの画面に戻すことがほとんど)

JsonResponse なら validateApi()

JsonResponseの場合は、validateApi() の方を使います。 こちらは第三引数がなく、バリデーションエラーは ApiFailureHook#handleValidationError() で、統一的なResponse処理がされます。

そもそも全部 JsonResponse なら

もし、そのアプリがJSON APIサーバーで全部 JsonResponse というときは、BaseAction が implements しているインターフェースを LaValidatableApi に変更すると、validate() 自体が JsonResponse 用のメソッドになります。(これは一番最初に決めましょう)

3. Lambdaで、もっとValidation

相関チェック、DB検索が必要なチェック

アノテーションではできないようなバリデーション (相関チェック、DB検索が必要なチェック) をする場合は、第二引数の Lambda で実装します。 そのとき、メッセージは messages のタイプセーフな add メソッドを使って指定します。

e.g. more validation in Lambda @Java
@Execute
public HtmlResponse index(int productId, SeaLandForm form) {
    validate(form, messages -> {
        if (form.sea != null && form.land != null) {
            if (form.sea.length() > form.land.length()) {
                messages.addErrorsSeaLand("sea");
            }
        }
    }, () -> {
        return asHtml(path_Sea_SeaLandJsp);
    });
    ...
}

エラーメッセージを追加する場合は、[App]_message.properties に追加して FreeGen を叩きます。

moreValidate() のススメ

ちょっとせまっくるしいので、moreValidate() メソッドに出すとよいでしょう。

e.g. moreValidate() @Java
@Execute
public HtmlResponse index(int productId, SeaLandForm form) {
    validate(form, messages -> moreValidate(form, messages), () -> {
        return asHtml(path_Sea_SeaLandHtml);
    });
    ...
}

private void moreValidate(SeaLandForm form, HarborMessages messages) {
    if (form.sea != null && form.land != null) {
        if (form.sea.length() > form.land.length()) {
            messages.addErrorsSeaLand("sea");
        }
    }
}

アノテーションでエラーがあっても呼ばれる

アノテーションの方でバリデーションエラーが発生しても、第二引数の moreValidation の Lambda は必ず呼び出されます。 なので、必須項目でも必須チェックに引っかかって null になっているかもしれないので、実際に値が入っていたら、というチェックロジックの前提条件のための分岐を入れましょう。

e.g. null check for more validation @Java
private void moreValidate(SeaLandForm form, HarborMessages messages) {
    if (form.sea != null && form.land != null) { // チェックロジックの前提条件
        if (form.sea.length() > form.land.length()) { // チェックロジック本体
            messages.addErrorsSeaLand("sea");
        }
    }
}

なぜ、アノテーションの方でバリデーションエラーがあったときに処理を止めないのか?

これにはれっきとして理由があります。

ユーザーから見た時に... 一回ですべてのバリデーションエラーが表示されず、修正したらまた別のエラーが表示される というのを避けたいからです。二段階バリデーション と呼んでいます。

また違うエラーが出てくるのかなぁ? (いつになったら終わるんだろう...) という気持ちにさせるため、あまり良いUIとは言えないと考えています。 パスワードなど再表示で消える項目があるとなおさらストレス大です。 ボタンを押してチェックを掛けたからには、できるだけすべての項目に対するチェックが掛かった方が良いと考え、実装上は少し面倒かもですが、それを踏まえてこのようにしています。

また、アノテーションの定義とプログラムによるバリデーションの実装では若干距離があるので(チェックロジックを別のクラスに切り出すことも十分ありえますし...)アノテーションの有無を意識せず、プログラムによるバリデーションが成り立つように独立性を保った実装 をしておいたほうがミスは少ないだろうという考えます。"後から必須項目じゃなくなって @Required 外したら落ちる" というデグレがないように(開発時のミスよりも変更時のミスのほうが業務インパクトは大きいもの)

そしてまた、他のフレームワークのやり方を見ても、相関チェックをする前に null チェックを入れるのは、わりと当たり前のようにされているので、そこまで抵抗がないだろうという判断もあります。 例えば、SpringMVC では、アノテーションのエラーは BindingResult に蓄積されるだけで処理は続行され、プログラムによるバリデーションをするときには同じように存在チェックが必要となります。

そもそも、Hibernate Validator がそのような思想で作られているようにも感じます。(後述する AssertTrue のところでも、この話に関する話題がありますので参考に)

※本来、アノテーションによるバリデーションと、プログラムによるバリデーションで分けて実装するのではなく、 項目ごとに両方を一気に実装できる仕組みがある方が良いと思いますが、Java文法上それはできないのが残念です。

ちなみに、アノテーションで相関チェックもできる!?

Hibernate Validator を使っているので、Hibernate Validator の機能がそのまま使えます。 LastaFluteで特に制限もかけていません。

DB検索を必要としない相関チェックであれば、Form や Body に AssertTrue アノテーションを付与したチェックするメソッドを定義すれば、Actionの中でやらなくても同じことが実現できます。

e.g. correlative validator by annotation @Java
public class SeaForm {

    // ===================================================================================
    //                                                                           Attribute
    //                                                                           =========
    @Length(max = 10)
    public String productName;

    public CDef.ProductStatus productStatus;

    ...

    // ===================================================================================
    //                                                                          Validation
    //                                                                          ==========
    // AssertTrueで、独自のエラーメッセージを設定する。
    // must be true と表示されても嬉しくないので、メッセージの指定は実質的に必要。
    // (ただ、クライアントエラーにするときは、メッセージはあまり気にしなくてもいい)
    // この場合、ユーザーメッセージは、productNameToStatus という名前のプロパティに紐付けられる。
    @AssertTrue(message = FortressMessages.ERRORS_PRODUCT_NAME_THEN_ONSALE)
    public boolean isProductNameToStatus() { // is 始まりじゃないと呼ばれないよ
        if (productName == null) {
            return true;
        }
        if (productName.equals("sea")) {
            // sea と入力されてたら、販売中ステータスじゃないとダメ
            // (通常ありえないけど、Exampleなのででたらめ)
            return CDef.ProductStatus.OnSaleProduction.equals(productStatus);
        } else {
            return true; // 他の入力値であれば特に相関関係は無し (という仕様)
        }
    }

    // クライアントエラーのときは、メッセージは指定しなくてもOKなのでこんな感じかな
    //@AssertTrue(groups = ClientError.class)
    //public boolean ...
}

とはいえ、これを推奨しているかというと、別にそうではありません。それはズバリ...

覚えるコストとバラバラを避けたい

何種類も手段を覚えないといけないのを避けたい

というところです。DB検索が必要なバリデーションには使えないですし、DIもできず、Assist や Logic など呼べないので、moreValidate() がなくなることはありません。 (厳密にはDIできないわけじゃないので、FormやBodyではDIをしないポリシーを前提として)

AssertTrue方式でも再利用ができるわけではないので(*A)、であれば、相関チェックなどロジカルなバリデーションは moreValidate() に寄せた方が良いだろう、と考えられるのです。 (*A: 厳密には、Form や Body 自体を再利用すれば再利用になりますが、それもレアケースであろうと)

あと、AssertTrue方式だと、メッセージの中の動的変数を活用できません。 また、とあるプロパティ項目へのメッセージの紐付けもできません(isメソッドのプロパティ名に関連付けられる)。あまり致命的ではないかもしれませんが、moreValidate() の方がメッセージの自由度が高いのは確かです。

とは言え、AssertTrue方式もメリットがある

一方で、後述する "レスポンスデータのバリデーション" の相関バリデーションの実装方法に、AssertTrue方式が活用できます。 レスポンスのときはロジカルなバリデーションをする領域を定めていませんので、統一的な実装のためのわかりやすい一つの手段になります。 そちらで使うのであれば、リクエストのバリデーションでも同じように使っていく方がわかりやすいかもしれません。

また、やはり単項目バリデーションのアノテーションと相関バリデーションが同じクラスの中に一緒に入っているというのが可読性をもたらすのは確かです。 (ただし、すべて一緒になるわけではないですが)

それらメリットを重視するのであれば、"リクエストもレスポンスもDB検索やDIを必要としない相関バリデーションならAssertTrue方式" というようなポリシーでも良いでしょう。 そのときは、moreValidate() は、基本的に Behavior や Assist などのDIコンポーネントを必要とするバリデーションだけに使うというニュアンスになります。

先ほどの "覚えるコストとバラバラを避けたい" の心配は、ディベロッパーのフレームワークに対するリテラシーと、開発チームの統一性に対する姿勢に対するものなので、そこに不安がないのであれば積極的に使っていっても良いでしょう。

やはり、nullチェックはどのみち必要

ちなみに、先ほどの Example にて、productName に @Required や @NotNull が付いていてエラーになったとしても、isProductNameToStatus() メソッドは呼び出されます。

なので、どのみち productName の null チェックは必要です。 全体的な思想として他のバリデーションの結果に依存しないようにロジックを積み重ねていく思想があるように感じていますので、 先ほど話題にした、moreValidate() が呼ばれるようにした理由につながります。

エラーメッセージを調整

LastaFluteでは、src/main/resources の [app]_message.properties にて管理しています。 UTF-8で読み込んでいるので、日本語をユニコードエスケープする必要はありません。

e.g. validation error and application exception message @[app]_message.properties
constraints.Required.message                = is required
constraints.TypeAny.message                 = should be {propertyType}
constraints.TypeInteger.message             = should be number

...

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

継承関係を持っているので、マルチプロジェクトでもプロジェクト間で再利用もできますし、オーバーライドを活用してプロジェクトごとに微調整することもできます。

e.g. リファレンス実装のMaihamaプロジェクトでのProperties継承構造 @Directory
maihama-common
 |-src/main/resources
    |-maihama_message.properties // 共通のメッセージリソース

maihama-dockside
 |-src/main/resources
 |  |-dockside_message.properties // アプリ固有のメッセージリソース

番外. レスポンスデータのバリデーション

レスポンスにバリデーション?と思われるかもしれませんが、これは外部に出力するデータの整合性を自分でチェックするためのバリデーションです。 なので、自分たちのロジックを確実なものにするためのチェックとなりますので、少しニュアンスが違いますし、今まであまりない概念の実装ですが、実際にやってみると非常に安心感があって便利です。

レスポンスのバリデーション方法

asHtml()で登録する HtmlBean や、asJson() の引数に指定する JsonResult にも、@Required などの Validatorアノテーションを付けます。

付けておくと Action の処理が終わった直後にアノテーションが評価されて、バリデーションエラーになった場合は、システム例外(サーバーの不具合)として処理が中断します。 本当は不整合なデータを外部に戻してしまっているのに、なんとなく動いてしまっていた、なんことを防ぐことができます。

(この後、JsonResultを中心に説明しますが、HtmlBeanでも同様です)

LastaDocに載るのでフロント側に伝えやすい

付けておくと、単なるサーバー側のバグチェックだけでなく、LastaDoc にアノテーションが表示されるので、 フロント側のディベロッパーに項目の特性を自然と伝えることができます。

e.g. Required annotation in JSON result class @Java
public class SeaLandResult {

    @Required
    public Integer memberId;

    @Required
    public String memberName;

    ...
}
e.g. asJson() in Action class @Java
@Execute
public JsonResponse<SeaLandResult> index() {
    ...
    SeaLandResult result = ...
    return asJson(result); // validated in this method
}

これはユーザー入力でもなければクライアントエラーでもなく、サーバーのバグやデータ不備なので、エラーが発生したらシステムエラー (500 Server Error) です。Form や Body の ClientError と同様、Assertの代わりのようなものです。 なので、こちらではメッセージ調整は発生しません。

凝り過ぎてもつらいので、例えば Required だけチェックする というような割り切りがオススメです。フロントサイドのディベロッパーと相談してみましょう。

同じ JsonResult でも groups でバリデーションを分岐

同じ JSON Result でも、状況によってバリデーションの内容が異なる場合は、Hibernate Validator の groups の機能を使うとよいでしょう。

例えば、mysticId はデフォルトのときに必須、Landが指定されたら onemanDate の方が必須(そのときは、mysticIdは必須ではない)、というようなときは以下のようになります。

e.g. groups in JSON Result @Java
@Required
public Integer mysticId;

@Required(groups=Land.class)
public LocalDate onemanDate;
e.g. asJson() with groups in action @Java
@Execute
public JsonResponse<SeaLandResult> index() {
    ...
    SeaLandResult result = ...
    boolean land = ...
    return asJson(result).groupValidator(land ? Land.class : null);
}

JsonResult の相関バリデーション

レスポンスのバリデーションは、必ずすべての整合性をチェックする義務はなく、凝りすぎるとキリがないので相関バリデーションまでやるかはプロジェクトポリシーで要検討です。

ただ、もし相関バリデーションも入れておきたいというのであれば、AssertTrue方式を利用することができます。 これは、すでに通常の Form や Body のバリデーションのところで話題になった方式です。

このように実装します。

e.g. correlative validator by annotation for response @Java
public class SeaResult {

    // ===================================================================================
    //                                                                           Attribute
    //                                                                           =========
    @Required
    public String productName;

    public CDef.ProductStatus productStatus;

    ...

    // ===================================================================================
    //                                                                          Validation
    //                                                                          ==========
    // レスポンスのバリデーションなので、ユーザーメッセージは気にしなくてもいい。
    @AssertTrue
    public boolean isProductNameToStatus() { // is 始まりじゃないと呼ばれないよ
        if (productName == null) {
            return true;
        }
        if (productName.equals("sea")) {
            // sea と入力されてたら、販売中ステータスじゃないとダメ
            // (通常ありえないけど、Exampleなのででたらめ)
            return CDef.ProductStatus.OnSaleProduction.equals(productStatus);
        } else {
            return true; // 他の入力値であれば特に相関関係は無し (という仕様)
        }
    }
}

本番ではチェックしない、とか、警告だけとか

TODO jflute

三大チェックポイント

気をつけて!int や boolean の @Required

Primitive型を使うと、setされたのかされてないのかが区別付かないため、@Required が効きません。

e.g. don't use primitive, use wrapper type @Java
public class SeaLandBean {

    @Required
    //public int piari; *Bad
    public Integer piari; // Good

    @Required
    //public boolean bonvo; *Bad
    public Boolean bonvo; // Good
}

特に、Boolean などはついつい boolean を使いたくなるので注意です。

全体的に プロパティの型にはWrapper型を使う というポリシーでよいでしょう。

忘れないで!ネストBean には @Valid

ネストした Bean には @Valid を定義し忘れないようにしましょう。

e.g. don't forget @Valid annotation for nested bean @Java
public class SeaLandBean {

    @Required
    @Valid
    public PiariElement piari;

    @Valid
    public List<BonvoElement> bonvos;

    public static class PiariElement {

        @Required
        public String dstore;
    }

    public static class BonvoElement {
        ...
    }
}

思い出して!List の @Required は一件以上

List に @Required を付けると、空リストでバリデーションエラーになります。 もし、"空リストでもOK だが null はあり得ない" とかであれば、@NotNull を付けましょう。

e.g. List @Valid annotation for nested bean @Java
public class SeaLandBean {

    @NotNull
    @Valid
    public List<PiariElement> piaris; // (NotNull, EmptyAllowed)

    @Required
    @Valid
    public List<BonvoElement> bonvos; // (NotNull, NotEmpty)

    public static class PiariElement {

        @Required
        public String dstore;
    }

    public static class BonvoElement {
        ...
    }
}

プロフェッショナルバリデーション

TODO jflute バリデーション内で検索したデータを使い回し、バリデーションエラー時にリダイレクト、独自のアノテーションとか

バリデーション内で検索したデータを使い回し
messages.saveSuccessAttribute() で、ValidationSuccess@getAttribute()
バリデーションエラー時にリダイレクト
普通に、redirect()
errors 情報をセッションに入れるときは、redirect(...).saveErrorsToSession();
独自のアノテーション
Hibernate Validator のやり方そのまま