IK.AM

@making's tech note


Java用Validatorライブラリ"YAVI"(ヤヴァイ)の紹介

🗃 {Programming/Java/am/ik/yavi}
🏷 Java 🏷 YAVI 
🗓 Updated at 2021-05-22T10:46:22Z  🗓 Created at 2018-08-29T12:52:54Z   🌎 English Page

YAVIというJava用ValidatorライブラリのYAVIを作っています。 Yet Another Validation for Javaの略で"ヤヴァイ"と呼びます。

この記事では何でYAVIを作っているのか、何が面白いのかを紹介します。

目次

動機

Javaには一応、標準のBean Validationがあり、 実装としてはHibernate Validatorが一般的に使われています。 Spring Bootでもデフォルトで含まれるので、多くの人はこれを使っていますし、これで事足りることが多いです。

YAVIを作ったのはBean Validationが嫌いだからではありません。なので、Bean Validationを批判する訳ではないですし、 Bean Validationで問題を感じていなければそのまま使えば良いと思います。

作ったきっかけは、"Spring 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 Javajavalinなど、 Micro Frameworkと謳っている(けどバリデーションはサポートしていない)ものとも併せて使えます。

これらのコンセプトを基に、

  • 使いやすいプログラミングモデルの提供
  • バリデータとしてエンタープライズレディな機能の提供

を目指しています。Coolさと泥臭さを両立させたいと思っています。 泥臭さの提供にはSIer時代のバックグラウンドが活きると思います。

基本的な使い方

Mavenの場合はpom.xml

<dependency>
  <groupId>am.ik.yavi</groupId>
  <artifactId>yavi</artifactId>
  <version>x.y.z</version>
</dependency>

Gradleの場合はbuild.gradle

compile('am.ik.yavi:yavi:x.y.z')

を追加してください。バージョン(x.y.z)はGitHubから確認してください。

次のUserクラスを例にあげます。

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でバリデーションを定義します。定義場所はどこでも良いです。

import am.ik.yavi.core.Validator;

static final Validator<User> validator = ValidatorBuilder.<User> 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をサポートしました。

バリデーションの実施方法は

User user = new User("making", "making@example.com", 20);
ConstraintViolations violations = validator.validate(user);

です。ConstraintViolationsは制約違反項目を表現するConstraintViolationListです。

violations.isValid(); // true or false 

で検証結果がわかります。 違反項目の詳細はConstraintViolationsオブジェクトをイテレートすれば良いです。 エラーメッセージは次のように出力できます。

violations.forEach(x -> System.out.println(x.message()));

JSONでシリアライズされるときに必要であろう項目だけをまとめた便利メソッドとして、details()メソッドがあります。

List<ViolationDetail> details = violations.details();

REST API実装時は、これをそのまま返すか、これをフィールドにもつエラーオブジェクトを作れば良いでしょう。

Bean Validationと比較して、

  • 各種制約を実現する方法はこちら
  • ネストしたBeanやコレクションに対する制約の設定方法はこちら
  • 独自制約の実装方法はこちら

を参照してください。Bean Validationからのfrom-to形式でサンプルを記載しています。

Either APIの導入

ここまで実装して0.0.1をリリースし、実際に当初の目的であったSpring WebFlux.fnで使用してみました。

static RouterFunction<ServerResponse> 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<String, Object> res = new LinkedHashMap<>();
          res.put("message", "Invalid request body");
          res.put("details", violations.details());
          return badRequest().syncBody(res);
        }
      }));
}

うーん、悪くないんだけど、少し残念感があります。 具体的にいうとviolationsのif文で処理の流れがせき止められている部分が残念です。 せっかくフレームワークが関数型スタイルで書けるので、もう少し関数型にフィットした書き方がしたいです。

これの課題に対して、"Either"という考え方があることをわかりました。 ヒントとなったのは@gakuzzzzさんが書かれたこの記事で、ぱっと見"うむ、わからん"という感じでしたが、 VavrというJavaに関数型プログラミングの要素を導入するライブラリを参考にし、Eitherを使えばこの課題を解決できることがわかりました。

Optionalに少し近いですが、Eitherは二つ(left, right)のオブジェクトのどちらかを表現するクラスです。

Either<String, Integer> either = Either.left("Hello");

あるいは

Either<String, Integer> either = Either.right(100);

という表現ができます。 Eitherに対してfoldメソッドで同じ型のオブジェクトに射影することができます。

String ret = either.fold(s -> s.toUpperCase(), i -> i.toString());

Eitherクラスの実装は非常にシンプルなので、他のAPIはソースを確認してください。

これを使ってValidationの結果を表現すれば、関数型のプログラミングにフィットします。Validationの文脈では右と正解を掛けて、rightに検証結果がOKだった場合のオブジェクトをleftを違反内容を含めるのが慣例のようです。

先の例をEitehrを使って書き換えると、次のようになります。

static RouterFunction<ServerResponse> 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<String, Object> 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オブジェクトに変換するという処理は次のように簡潔に表現できます。

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で定義してあるので、

ここからプロパティファイルを生成して、そのプロパティファイル(messages.properties)でメッセージを定義したい場合は、次のような実装をすれば良いです。

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を差し替えたい場合は次のように設定できます。

static final Validator<User> validator = ValidatorBuilder
            .of(User.class)
            .messageFormatter(ResourceBundleMessageFormatter.SINGLETON)
            // ...
            .build()

プロジェクト共通で使いたい場合は、次のようなユーティリティクラスを作れば良いでしょう。

public static ValidatorBuilder<T> validator(Class<T> clazz) {
  return ValidatorBuilder.of(clazz).messageFormatter(ResourceBundleMessageFormatter.SINGLETON);
}

エラーメッセージのこだわりとして、Bean Validationと比較して、

  • エラーメッセージにデフォルトで項目名を含める
  • エラーメッセージに違反した内容を含められるようにする

が対応されています。 メッセージの第一プレースホルダ{0}には項目名、最後の項目には違反値が入ります。

特に2つめに関しては、一般的なValidationライブラリでは対応されていないため、例えば、"xxxxxは100文字以下にしてください"とエラーメッセージが返って来ても、 実際に今入力した内容が何文字としてカウントされているのかがわからないため、少しずつ文字を削ってトライするということがたまにあります。 ユーザーがこういう無駄なことをしなくても良いようにデフォルトで次のようなメッセージが表示されます。

image

文字長チェック

YAVIの特徴とて文字長チェックに対するサポートが厚いです。

文字数の制約として、入力された文字をできるだけ見た目通りの文字数としてカウントします。 昨今、Unicode上の文字の定義の乱立(?)で、見た目のサイズとString#lengthメソッドの返り値が大きく乖離しています。

  • サロゲートペア
  • 結合文字
  • SVS (Standardized Variation Sequence)
  • IVS (Ideographic Variation Sequence)
  • FVS (Mongolian Free Variation Selector)
  • Emoji

それぞれの説明は割愛しますが、YAVIはこれらの文字種を全て見た目通りの文字数で制約チェックを行います。 (Emojiに関してはデフォルトでは見た目通りサイズのチェックにはなりません。)

代表的な例を見ましょう。

サロゲートペア

序の口です。

image

見た目は3文字ですが、length()の結果は4("\uD842\uDFB7野屋")です。

YAVIでは文字長はコードポイント長として扱うため、この文字列を3文字とみなします。

image

結合文字

image

見た目上は2文字ですが、"シ"と濁点が結合しており、length()の結果は3("モシ\u3099")です。

YAVIではこの文字列をデフォルトで2文字とみなします。

image

java.text.Normalizerを使っており、デフォルトでjava.text.Normalizer.Form#NFCで正規化します。 この挙動は次のように変更できます。(nullを設定すると正規化しない)

static final Validator<Message> validator = ValidatorBuilder
            .of(Message.class)
            .constraint(Message::text, "text", c -> c.normalizer(normalizerForm)
                                                     .lessThanOrEqual(2))
            .build();

IVS

異字体セレクタはNormalizerでは正規化できません。

image

見た目上は1文字ですが、UTF-16で表現すると"D842 DF9F DB40 DD00"なのでlength()の結果は4です。

YAVIはデフォルトでこの文字を1文字とみなします。 YAVIは対象の文字列からIVSの範囲である0xE0100-0xE01EFのコードポイントを無視(削除)します。こうすればただのサロゲートペアです。

image

同様にSVSやFVSも対応します。 これらの処理は正規表現を使って行なっているため、パフォーマンスの影響があります。 この処理を無効(無視しない)にしたい場合は次のように設定できます。

static final Validator<Message> 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

各種Emojiのコードポイントがどう構成されているかはこちらを参照してください。 YAVIはこららのEmojiを頑張って1文字とみなします。確実に保証はできませんが、Emoji 11.0で定義されているものは全部チェックしました。

この処理はコストが大きいため、デフォルトでは有効になっていません。emoji()メソッドを指定します。

image

ここまで見た目の文字長と実際のコードポイント長に乖離があると、 見た目の制約はOKだけれども、データベースに保存するサイズをオーバーする可能性があります。 YAVIでは見た目のサイズに加え、バイト長のチェックもできます。

image

コードポイント集合

文字長とは異なり、エンタープライズではシステム許可文字を定めているシステムが多いです。 例えば、名前には"ひらがな、カタカナ、JIS第1-2水準漢字"のみ許可する。など。 この制約が良いかどうかは別にして、YAVIではシステムで許可するコードポイント集合をホワイトリストあるいはブラックリスト形式で指定できます。

コードポイントの集合はam.ik.yavi.constraint.charsequence.CodePointsインタフェースで表現され、 java.util.Setを使って集合を表現するCodePointsSetとコードポイント範囲のリストで表現するCodePointsRangesインタフェースが用意されています。

例えば"A,B,C,D,a,b,c,d"から成るコードポイント集合は

CodePointsSet<String> codePoints = () -> new HashSet<>(
        Arrays.asList(0x0041 /* A */, 0x0042 /* B */, 0x0043 /* C */, 0x0044 /* D */,
                      0x0061 /* a */, 0x0062 /* b */, 0x0063 /* c */, 0x0064 /* d */));

または

CodePointsRanges<String> codePoints = () -> Arrays.asList(
        Range.of(0x0041/* A */, 0x0044 /* D */),
        Range.of(0x0061/* a */, 0x0064 /* d */));

で表現できます。連続しているコードポイントの場合は後者の方がメモリ効率が圧倒的に良いです。

ここで定義したCodePointsインスタンスをValidatorに指定できます。

ホワイトリスト(許可文字)として扱いたいときは次のように指定します。

Validator<Message> validator = ValidatorBuilder.<Message> 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

ブラックリスト(禁止文字)として扱いたいときは次のように指定します。

Validator<Message> validator = ValidatorBuilder.<Message> 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 (JIS X 0208の定義とは異なります)
  • UnicodeCodePoints#KATAKANA ... Unicodeで定義されているKatakanaおよびKatakana Phonetic Extensions (JIS X 0208の定義とは異なります)

が用意されています。

CompositeCodePointsクラスで複数のコードポイント集合の和集合を表現することもできます。

3rd Partyライブラリとの連携

最後の説明になります。 YAVIとしては依存ライブラリを持たないというコンセプトがありますが、 実際に使う際にはやはり3rd Partyとの連携が出てきます。

例えば、Spring MVCとYAVIを組み合わせて使う場合は、制約違反結果をBindingResultに詰める必要があります。 次のような処理です。

@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:/";
        });
}

これに対して単純に次のようなユーティリティを提供することもできます。

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への依存を持つことなく、次のように記述することができます。

@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のコードポイント集合クラスを公開しています。

第1,2,3,4水準漢字のコードポイント集合が定義されています。 このライブラリも依存なしで使えるのですが、YAVIからは依存したくありません。

ここでもTERASOLUNAのコードポイント集合をYAVIのコードポイント集合に変換するユーティリティを作るのではなく、 メソッド参照を使用します。

例えば、カタカナ、ひらがな、第1,2,3,4水準漢字を含むコードポイント集合を作りたい場合は、次のように記述できます。

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<String> codePoints = new CompositeCodePoints<>(KATAKANA, 
                                                                      HIRAGANA, 
                                                                      new JIS_X_0213_Kanji()::allExcludedCodePoints);

static final Validator<Message> validator = ValidatorBuilder.<Message> of() //
            .constraint(Message::getText, "text", c -> c.codePoints(codePoints).asWhiteList()) //
            .build(); //

YAVIのコンセプトと面白さを紹介しました。 この記事を見て"ヤヴァイ"という感想を持っていただると幸いです。

YAVIはまだまだ開発途上で、フィードバックをお待ちしております。 興味を持っていただいた方は是非使って見て、問題や改善点があればGitHubで報告してください。 Starもください。


✒️️ Edit  ⏰ History  🗑 Delete