--- title: Java用Validatorライブラリ"YAVI"(ヤヴァイ)の紹介 tags: ["Java", "YAVI"] categories: ["Programming", "Java", "am", "ik", "yavi"] date: 2018-08-29T12:52:54Z updated: 2021-05-22T10:46:22Z --- [YAVI](https://github.com/making/yavi)というJava用ValidatorライブラリのYAVIを作っています。 *Y*et *A*nother *V*al*i*dation for Javaの略で"ヤヴァイ"と呼びます。 この記事では何でYAVIを作っているのか、何が面白いのかを紹介します。 **目次** ### 動機 Javaには一応、標準の[Bean Validation](https://beanvalidation.org/)があり、 実装としては[Hibernate Validator](https://hibernate.org/validator/)が一般的に使われています。 Spring Bootでもデフォルトで含まれるので、多くの人はこれを使っていますし、これで事足りることが多いです。 YAVIを作ったのはBean Validationが嫌いだからではありません。なので、Bean Validationを批判する訳ではないですし、 Bean Validationで問題を感じていなければそのまま使えば良いと思います。 作ったきっかけは、"[Spring WebFlux.fn](https://docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html#webflux-fn)で使えるValidatorが欲しかった"という点です。 アノテーションベースのSpring MVCやSpring WebFluxとは異なり、FunctionベースのSpring WebFlux.fnにはバリデーションがサポートされていません。 好きなValidationライブラリを持ち込んで任意のタイミングで呼び出せば良いと言う割り切りになっています。 Spring WebFlux.fnはアノテーション(リフレクション)を使わないラムダベースのWebフレームワークであり、 ここでリフレクションバリバリのBean Validationを使うのは合わないかなと感じ、WebFlux.fnでの使用フィットするValidatorが欲しいと言うのがYAVIを作り始めた動機です。 ### コンセプト YAVIを作るに当たってのコンセプトとしては、FunctionベースのWebフレームワークにフィットすることを前提としているため、 * リフレクションは使わない * アノテーションは使わない * Java Beansを前提としない そして、WebFlux.fn以外のフレームワークでも使えるように * 3rd partyライブラリに依存しない を掲げています。 リフレクションの代わりにラムダ式、メソッド参照を多用します。 [Spark Java](http://sparkjava.com/)や[javalin](https://javalin.io/)など、 Micro Frameworkと謳っている(けどバリデーションはサポートしていない)ものとも併せて使えます。 これらのコンセプトを基に、 * 使いやすいプログラミングモデルの提供 * バリデータとしてエンタープライズレディな機能の提供 を目指しています。Coolさと泥臭さを両立させたいと思っています。 泥臭さの提供にはSIer時代のバックグラウンドが活きると思います。 ### 基本的な使い方 Mavenの場合は`pom.xml`に ```xml am.ik.yavi yavi x.y.z ``` Gradleの場合は`build.gradle`に ``` compile('am.ik.yavi:yavi:x.y.z') ``` を追加してください。バージョン(`x.y.z`)は[GitHub](https://github.com/making/yavi/releases)から確認してください。 次の`User`クラスを例にあげます。 ```java public class User { private final String name; private final String email; private final Integer age; public User(String name, String email, Integer age) { this.name = name; this.email = email; this.age = age; } public String name() { return this.name; } public String email() { return this.email; } public Integer age() { return this.age; } } ``` `User`クラスに対して、まずはYAVIでバリデーションを定義します。定義場所はどこでも良いです。 ```java import am.ik.yavi.core.Validator; static final Validator validator = ValidatorBuilder. of() // of(User.class)でも可 .constraint(User::name, "name", c -> c.notNull() // .greaterThanOrEqual(1) // .lessThanOrEqual(20)) // .constraint(User::email, "email", c -> c.notNull() // .greaterThanOrEqual(5) // .lessThanOrEqual(50) // .email()) // .constraint(User::age, "age", c -> c.notNull() // .greaterThanOrEqual(0) // .lessThanOrEqual(200)) .build(); ``` `constraint`メソッドでラムダ式/メソッド参照に対してメソッドチェーンで制約を追加する形式です。 第一引数のラムダ式の型から、設定できる制約が決まります。例えば`email()`は文字列を返すラムダに対してしか設定できません。 Bean Validationでは型の違うプロパティへの制約は実行時エラーになるので、ここはYAVIのメリットと言えます。 > **余談** > > 第二引数は制約対象の名前で、ここだけ文字列で指定する必要があります。リクフレクションを使用しないため、ここは妥協ポイントでした。 > Annotation Processorを使えばコンパイル時にメタ情報を生成してそれを使用することで文字列は指定しなくてもよくなりますが、 > アノテーションを使用しないと言うコンセプトに反します。アイディアとしては文字列以外にも対象名を返すインタフェースとそれと受け取るメソッドだけを作り、 > インタフェースを実装したクラスは別プロジェクトにして、そちらでAnnotation Processorから生成すると言うのも考えています。 > > **YAVI 0.4.0で[Annotation Processor](https://github.com/making/yavi/blob/develop/docs/AnnotationProcessor.md)をサポートしました。** バリデーションの実施方法は ```java User user = new User("making", "making@example.com", 20); ConstraintViolations violations = validator.validate(user); ``` です。`ConstraintViolations`は制約違反項目を表現する`ConstraintViolation`の`List`です。 ```java violations.isValid(); // true or false ``` で検証結果がわかります。 違反項目の詳細は`ConstraintViolations`オブジェクトをイテレートすれば良いです。 エラーメッセージは次のように出力できます。 ```java violations.forEach(x -> System.out.println(x.message())); ``` JSONでシリアライズされるときに必要であろう項目だけをまとめた便利メソッドとして、`details()`メソッドがあります。 ```java List details = violations.details(); ``` REST API実装時は、これをそのまま返すか、これをフィールドにもつエラーオブジェクトを作れば良いでしょう。 Bean Validationと比較して、 * 各種制約を実現する方法は[こちら](https://github.com/making/yavi/blob/develop/docs/FromBeanValidationToYAVI.md#from-bean-validation-to-yavi) * ネストしたBeanやコレクションに対する制約の設定方法は[こちら](https://github.com/making/yavi/blob/develop/docs/FromBeanValidationToYAVI.md#valid) * 独自制約の実装方法は[こちら](https://github.com/making/yavi/blob/develop/docs/FromBeanValidationToYAVI.md#custom-constraint) を参照してください。Bean Validationからのfrom-to形式でサンプルを記載しています。 ### Either APIの導入 ここまで実装して0.0.1をリリースし、実際に当初の目的であったSpring WebFlux.fnで使用してみました。 ```java static RouterFunction routes() { return route(POST("/"), req -> req.bodyToMono(User.class) // .flatMap(body -> { ConstraintViolations violations = validator.validate(body); if (violations.isValid(())) { return ok().syncBody(body); } else { Map res = new LinkedHashMap<>(); res.put("message", "Invalid request body"); res.put("details", violations.details()); return badRequest().syncBody(res); } })); } ``` うーん、悪くないんだけど、少し残念感があります。 具体的にいうと`violations`のif文で処理の流れがせき止められている部分が残念です。 せっかくフレームワークが関数型スタイルで書けるので、もう少し関数型にフィットした書き方がしたいです。 これの課題に対して、"Either"という考え方があることをわかりました。 ヒントとなったのは[@gakuzzzz](https://twitter.com/gakuzzzz)さんが書かれた[この記事](https://gist.github.com/gakuzzzz/0c779d5335f4b2bff596)で、ぱっと見"うむ、わからん"という感じでしたが、 [Vavr](https://www.vavr.io/vavr-docs/#_either)というJavaに関数型プログラミングの要素を導入するライブラリを参考にし、`Either`を使えばこの課題を解決できることがわかりました。 `Optional`に少し近いですが、`Either`は二つ(left, right)のオブジェクトのどちらかを表現するクラスです。 ```java Either either = Either.left("Hello"); ``` あるいは ```java Either either = Either.right(100); ``` という表現ができます。 `Either`に対して`fold`メソッドで同じ型のオブジェクトに射影することができます。 ```java String ret = either.fold(s -> s.toUpperCase(), i -> i.toString()); ``` `Either`クラスの実装は非常にシンプルなので、他のAPIは[ソース](https://github.com/making/yavi/blob/develop/src/main/java/am/ik/yavi/fn/Either.java)を確認してください。 これを使ってValidationの結果を表現すれば、関数型のプログラミングにフィットします。Validationの文脈では右と正解を掛けて、rightに検証結果がOKだった場合のオブジェクトをleftを違反内容を含めるのが慣例のようです。 先の例をEitehrを使って書き換えると、次のようになります。 ```java static RouterFunction routes() { return route(POST("/"), req -> req.bodyToMono(User.class) // .flatMap(body -> validator.either().validate(body) // YAVI 0.6.0以前は validator.validateToEither(body) .fold(violations -> { Map res = new LinkedHashMap<>(); res.put("message", "Invalid request body"); res.put("details", violations.details()); return badRequest().syncBody(res); }, user -> ok().syncBody(user)))); } ``` if文が消えて、ラムダ式だけで表現できるようになりました。これでSpring WebFlux.fnに合ったバリデーションが実現できます。 確かに`Either`を実際に解決したい問題に対して適用すると、とてつもなく強力だと言うことが実感できました。 WebFlux.fnに限らず`Either`を使うと便利です。 例えば、ユーザーからの入力をFormオブジェクトで受け取り、バリデーションが通った後にDomainオブジェクトに変換するという処理は次のように簡潔に表現できます。 ```java validator.either().validate(form) // YAVI 0.6.0以前は validator.validateToEither(form) .rightMap(f -> f.toDomainObject()) .fold(violations -> "NG", obj -> { obj.doSomething(); return "OK"; } ); ``` ### エラーメッセージ補間 エラーメッセージの解決はValidatorライブラリの1つの大きなテーマです。 YAVIではここは本格的には取り組んでおらず、現時点では"Bring Your Own MessageInterpolator"のスタンスです。 `MessageFormatter`インタフェースだけを用意しており、 デフォルトでは`java.text.MessageFormatter`を使った極めてシンプルな実装が使用されます。 メッセージとキーの一覧は[Enum](https://github.com/making/yavi/blob/develop/src/main/java/am/ik/yavi/constraint/ViolationMessage.java)で定義してあるので、 ここからプロパティファイルを生成して、そのプロパティファイル(`messages.properties`)でメッセージを定義したい場合は、次のような実装をすれば良いです。 ```java import java.text.MessageFormat; import java.util.Locale; import java.util.MissingResourceException; import java.util.ResourceBundle; import am.ik.yavi.constraint.ViolationMessage; import am.ik.yavi.message.MessageFormatter; public enum ResourceBundleMessageFormatter implements MessageFormatter { SINGLETON; @Override public String format(String messageKey, String defaultMessageFormat, Object[] args, Locale locale) { ResourceBundle resourceBundle = ResourceBundle.getBundle("messages", locale); String format; try { format = resourceBundle.getString(messageKey); } catch (MissingResourceException e) { format = defaultMessageFormat; } try { String target = resourceBundle.getString((String) args[0]); args[0] = target; } catch (MissingResourceException e) { } return new MessageFormat(format, locale).format(args); } } ``` `MessageFormatter`を差し替えたい場合は次のように設定できます。 ```java static final Validator validator = ValidatorBuilder .of(User.class) .messageFormatter(ResourceBundleMessageFormatter.SINGLETON) // ... .build() ``` プロジェクト共通で使いたい場合は、次のようなユーティリティクラスを作れば良いでしょう。 ```java public static ValidatorBuilder validator(Class clazz) { return ValidatorBuilder.of(clazz).messageFormatter(ResourceBundleMessageFormatter.SINGLETON); } ``` エラーメッセージのこだわりとして、Bean Validationと比較して、 * エラーメッセージにデフォルトで項目名を含める * エラーメッセージに違反した内容を含められるようにする が対応されています。 メッセージの第一プレースホルダ`{0}`には項目名、最後の項目には違反値が入ります。 特に2つめに関しては、一般的なValidationライブラリでは対応されていないため、例えば、"xxxxxは100文字以下にしてください"とエラーメッセージが返って来ても、 実際に今入力した内容が何文字としてカウントされているのかがわからないため、少しずつ文字を削ってトライするということがたまにあります。 ユーザーがこういう無駄なことをしなくても良いようにデフォルトで次のようなメッセージが表示されます。 ![image](https://user-images.githubusercontent.com/106908/44784067-4b010600-abc7-11e8-8878-930d017405bb.png) ### 文字長チェック YAVIの特徴とて文字長チェックに対するサポートが厚いです。 文字数の制約として、入力された文字をできるだけ見た目通りの文字数としてカウントします。 昨今、Unicode上の文字の定義の乱立(?)で、見た目のサイズと`String#length`メソッドの返り値が大きく乖離しています。 * サロゲートペア * 結合文字 * SVS (Standardized Variation Sequence) * IVS (Ideographic Variation Sequence) * FVS (Mongolian Free Variation Selector) * Emoji それぞれの説明は割愛しますが、YAVIはこれらの文字種を全て見た目通りの文字数で制約チェックを行います。 (Emojiに関してはデフォルトでは見た目通りサイズのチェックにはなりません。) 代表的な例を見ましょう。 #### サロゲートペア 序の口です。 ![image](https://user-images.githubusercontent.com/106908/44789113-c918d900-abd6-11e8-8b06-2c65cdd14fda.png) 見た目は3文字ですが、`length()`の結果は4("\uD842\uDFB7野屋")です。 YAVIでは文字長はコードポイント長として扱うため、この文字列を3文字とみなします。 ![image](https://user-images.githubusercontent.com/106908/44789146-d930b880-abd6-11e8-8a42-5826117c1bb6.png) #### 結合文字 ![image](https://user-images.githubusercontent.com/106908/44789180-e8176b00-abd6-11e8-8ff1-1e6ce4f64472.png) 見た目上は2文字ですが、"シ"と濁点が結合しており、`length()`の結果は3("モシ\u3099")です。 YAVIではこの文字列をデフォルトで2文字とみなします。 ![image](https://user-images.githubusercontent.com/106908/44789203-f49bc380-abd6-11e8-92dd-71a392559a55.png) `java.text.Normalizer`を使っており、デフォルトで`java.text.Normalizer.Form#NFC`で正規化します。 この挙動は次のように変更できます。(`null`を設定すると正規化しない) ```java static final Validator validator = ValidatorBuilder .of(Message.class) .constraint(Message::text, "text", c -> c.normalizer(normalizerForm) .lessThanOrEqual(2)) .build(); ``` #### IVS 異字体セレクタは`Normalizer`では正規化できません。 ![image](https://user-images.githubusercontent.com/106908/44789232-0715fd00-abd7-11e8-86f2-f07f422ce0bc.png) 見た目上は1文字ですが、UTF-16で表現すると"D842 DF9F DB40 DD00"なので`length()`の結果は4です。 YAVIはデフォルトでこの文字を1文字とみなします。 YAVIは対象の文字列からIVSの範囲である0xE0100-0xE01EFのコードポイントを無視(削除)します。こうすればただのサロゲートペアです。 ![image](https://user-images.githubusercontent.com/106908/44789264-17c67300-abd7-11e8-9c62-af40680942b9.png) 同様にSVSやFVSも対応します。 これらの処理は正規表現を使って行なっているため、パフォーマンスの影響があります。 この処理を無効(無視しない)にしたい場合は次のように設定できます。 ```java static final Validator validator = ValidatorBuilder .of(Message.class) .constraint(Message::text, "text", c -> c.variant(opts -> opts.ivs(NOT_IGNORE) /* あるいはopts.notIgnoreAll() */) .lessThanOrEqual(2)) .build(); ``` #### Emoji Unicode界最狂にして、前出の文字に比べて利用頻度が極めて高い文字種が"Emoji"です。 もうメチャクチャです。 ![image](https://user-images.githubusercontent.com/106908/44789615-fd40c980-abd7-11e8-85ce-523ad4a59747.png) 各種Emojiのコードポイントがどう構成されているかは[こちら](https://unicode.org/Public/emoji/11.0/emoji-test.txt)を参照してください。 YAVIはこららのEmojiを頑張って1文字とみなします。確実に保証はできませんが、Emoji 11.0で定義されているものは全部チェックしました。 この処理はコストが大きいため、デフォルトでは有効になっていません。`emoji()`メソッドを指定します。 ![image](https://user-images.githubusercontent.com/106908/44789637-0893f500-abd8-11e8-922b-f45daa21a3a5.png) ここまで見た目の文字長と実際のコードポイント長に乖離があると、 見た目の制約はOKだけれども、データベースに保存するサイズをオーバーする可能性があります。 YAVIでは見た目のサイズに加え、バイト長のチェックもできます。 ![image](https://user-images.githubusercontent.com/106908/44789574-e00bfb00-abd7-11e8-97ea-6ce74053173f.png) ### コードポイント集合 文字長とは異なり、エンタープライズではシステム許可文字を定めているシステムが多いです。 例えば、名前には"ひらがな、カタカナ、JIS第1-2水準漢字"のみ許可する。など。 この制約が良いかどうかは別にして、YAVIではシステムで許可するコードポイント集合をホワイトリストあるいはブラックリスト形式で指定できます。 コードポイントの集合は`am.ik.yavi.constraint.charsequence.CodePoints`インタフェースで表現され、 `java.util.Set`を使って集合を表現する`CodePointsSet`とコードポイント範囲のリストで表現する`CodePointsRanges`インタフェースが用意されています。 例えば"A,B,C,D,a,b,c,d"から成るコードポイント集合は ```java CodePointsSet codePoints = () -> new HashSet<>( Arrays.asList(0x0041 /* A */, 0x0042 /* B */, 0x0043 /* C */, 0x0044 /* D */, 0x0061 /* a */, 0x0062 /* b */, 0x0063 /* c */, 0x0064 /* d */)); ``` または ```java CodePointsRanges codePoints = () -> Arrays.asList( Range.of(0x0041/* A */, 0x0044 /* D */), Range.of(0x0061/* a */, 0x0064 /* d */)); ``` で表現できます。連続しているコードポイントの場合は後者の方がメモリ効率が圧倒的に良いです。 ここで定義した`CodePoints`インスタンスを`Validator`に指定できます。 ホワイトリスト(許可文字)として扱いたいときは次のように指定します。 ```java Validator validator = ValidatorBuilder. of() // .constraint(Message::getText, "text", c -> c.codePoints(codePoints).asWhiteList()) // .build(); // validator.validate(new Message("aBCd")).isValid(); // true validator.validate(new Message("aBCe")).isValid(); // false ``` ブラックリスト(禁止文字)として扱いたいときは次のように指定します。 ```java Validator validator = ValidatorBuilder. of() // .constraint(Message::getText, "text", c -> c.codePoints(codePoints).asBlackList()) // .build(); // validator.validate(new Message("hello")).isValid(); // true validator.validate(new Message("hallo")).isValid(); // false ``` プリセットのコードポイント集合としては * `AsciiCodePoints#ASCII_PRINTABLE_CHARS` ... ASCII印字可能文字 * `AsciiCodePoints#ASCII_CONTROL_CHARS` ... ASCII制御文字 * `UnicodeCodePoints#HIRAGANA` ... Unicodeで定義されている[Hiragana](https://www.unicode.org/charts/nameslist/c_3040.html) (JIS X 0208の定義とは異なります) * `UnicodeCodePoints#KATAKANA` ... Unicodeで定義されている[Katakana](https://www.unicode.org/charts/nameslist/c_30A0.html)および[Katakana Phonetic Extensions](https://www.unicode.org/charts/nameslist/c_31F0.html) (JIS X 0208の定義とは異なります) が用意されています。 `CompositeCodePoints`クラスで複数のコードポイント集合の和集合を表現することもできます。 ### 3rd Partyライブラリとの連携 最後の説明になります。 YAVIとしては依存ライブラリを持たないというコンセプトがありますが、 実際に使う際にはやはり3rd Partyとの連携が出てきます。 例えば、Spring MVCとYAVIを組み合わせて使う場合は、制約違反結果を`BindingResult`に詰める必要があります。 次のような処理です。 ```java @PostMapping("users") public String createUser(Model model, UserForm userForm, BindingResult bindingResult) { return validator.either().validate(userForm) // YAVI 0.6.0以前は validator.validateToEither(userForm) .fold(violations -> { violations.forEach(v -> { bindingResult.rejectValue(v.name(), v.messageKey(), v.args(), v.message()); // Here!! }); return "userForm"; }, form -> { // ... return "redirect:/"; }); } ``` これに対して単純に次のようなユーティリティを提供することもできます。 ```java public static void rejectValue(BindingResult bindingResult, ConstraintViolation v) { bindingResult.rejectValue(v.name(), v.messageKey(), v.args(), v.message()); } ``` しかし、このメソッドを提供するということはYAVIが`org.springframework.validation.BindingResult`への依存を持つことになります。 依存を持つことなく、3rd Partyライブラリと連携しやすように、Functional Interfaceとメソッド参照を活用しています。 YAVI側で`BindingResult#rejectValue`と同じ引数をもつFunctional Interfaceをもち、それをコールバック関数として受け取るメソッドを 作成すると、`BindingResult`への依存を持つことなく、次のように記述することができます。 ```java @PostMapping("users") public String createUser(Model model, UserForm userForm, BindingResult bindingResult) { return validator.either().validate(userForm) // YAVI 0.6.0以前は validator.validateToEither(userForm) .fold(violations -> { violations.apply(bindingResult::rejectValue); return "userForm"; }, form -> { // ... return "redirect:/"; }); } ``` ユーティリティを提供するより、Coolではないでしょうか。 もう1例あります、前出の`CodePoints`集合としてNTT DATAのTERASOLUNAがJIS X 201, JIS X 208およびJIS X 0213のコードポイント集合クラスを公開しています。 * [ドキュメント](https://terasolunaorg.github.io/guideline/5.4.1.RELEASE/ja/ArchitectureInDetail/GeneralFuncDetail/StringProcessing.html#stringprocessinghowtousecodepoints) * [ソースコード](https://github.com/terasolunaorg/terasoluna-gfw/tree/master/terasoluna-gfw-common-libraries/terasoluna-gfw-codepoints/catalog) 第1,2,3,4水準漢字のコードポイント集合が定義されています。 このライブラリも依存なしで使えるのですが、YAVIからは依存したくありません。 ここでもTERASOLUNAのコードポイント集合をYAVIのコードポイント集合に変換するユーティリティを作るのではなく、 メソッド参照を使用します。 例えば、カタカナ、ひらがな、第1,2,3,4水準漢字を含むコードポイント集合を作りたい場合は、次のように記述できます。 ```java import org.terasoluna.gfw.common.codepoints.catalog.JIS_X_0213_Kanji; import am.ik.yavi.constraint.charsequence.CodePoints; import static am.ik.yavi.constraint.charsequence.codepoints.UnicodeCodePoints.KATAKANA; import static am.ik.yavi.constraint.charsequence.codepoints.UnicodeCodePoints.HIRAGANA; static final CodePoints codePoints = new CompositeCodePoints<>(KATAKANA, HIRAGANA, new JIS_X_0213_Kanji()::allExcludedCodePoints); static final Validator validator = ValidatorBuilder. of() // .constraint(Message::getText, "text", c -> c.codePoints(codePoints).asWhiteList()) // .build(); // ``` --- YAVIのコンセプトと面白さを紹介しました。 この記事を見て"ヤヴァイ"という感想を持っていただると幸いです。 YAVIはまだまだ開発途上で、フィードバックをお待ちしております。 興味を持っていただいた方は是非使って見て、問題や改善点があれば[GitHub](https://github.com/making/yavi)で報告してください。 Starもください。