YAVIのArguments Validatorについて

YAVIでValue ObjectのValidation (Records対応) の記事では説明を省いた Arguments ValidatorYAVI 0.7.0 で大きな進化を遂げたので紹介します。

目次

Arguments Validatorとは

Arguments Validatorは名前の通り、引数に対するValidatorです。 Bean Validationを含む、通常のValidatorは値が設定されたObjectに対して、設定された値が適切かどうかを検証しますが、 Arguments ValidatorはObjectへ設定する引数が適切かどうかを検証します。

通常のValidatorでは検証前にObjectが作成されるので、一時的に不完全な状態のObjectを作成してしまう可能性があります。 例えばValidator側でnotNull()の制約を課していても、コンストラクタ内でnullチェックが組み込まれている場合はValidatorによる検証実行前にこのnullチェックが働きます。 Arguments Validatorは検証後にObjectを作成するので、不完全なObjectを作らなくても済みます。

Arguments ValidatorはYAVI 0.3.0でサポートされました。0.7.0以前では次のように定義できます。

public record Person(String name, String email, Integer age) {
    public static Arguments3Validator<String, String, Integer, Person> validator = ArgumentsValidatorBuilder
        .of(Person::new)
        .builder(b -> b
            ._string(Arguments1::arg1, "name", c -> c.notBlank().lessThanOrEqual(100)) // Person::newの第1引数に対する制約
            ._string(Arguments2::arg2, "email", c -> c.notBlank().lessThanOrEqual(100).email()) // Person::newの第2引数に対する制約
            ._integer(Arguments3::arg3, "age", c -> c.greaterThanOrEqual(0).lessThan(200))) // Person::newの第3引数に対する制約
        .build();
}

Arguments Validatorは次のように利用できます。

Validated<Person> personValidated = Person.validator.validate("Jone Doe", "jdoe@example.com", 30);
// 0.6.0以前はEitherを返していたが、0.6.0でValidatedに変更

Validated型に関してはYAVIでValue ObjectのValidation (Records対応) を参照してください。

または

Person person = Person.validator.validated("Jone Doe", "jdoe@example.com", 30);
// 検証失敗時にはConstraintViolationsExceptionがスローされる

Arguments1ValidatorからArguments16Validatorまで用意されています。

Arguments Validatorは用途は有用なのですが、定義が少し面倒に感じるかもしれません。 Type-Safeなので型に合わない定義はできないのですが、型に合うようにパズルのようにはめ込むように感じられます。

小さなValidatorの定義のサポート

YAVI 0.7.0では gakuzzzzさんの提案 により、 小さなArguments Validatorの簡易的な定義とその合成がサポートされました。

例えば単一のStringやIntegerに対するArguments1Validatorを定義するだけであれば次のように記述できます。

StringValidator<String> nameValidator = StringValidatorBuilder
    .of("name", c -> c.notBlank().lessThanOrEqual(100))
    .build();  // -> extends Arguments1Validator<String, String>

StringValidator<String> emailValidator = StringValidatorBuilder
    .of("email", c -> c.notBlank().lessThanOrEqual(100).email())
    .build();  // -> extends Arguments1Validator<String, String>

IntegerValidator<Integer> ageValidator = IntegerValidatorBuilder
    .of("age", c -> c.greaterThanOrEqual(0).lessThan(200))
    .build();  // -> extends Arguments1Validator<Integer, Integer>

Validated<String> nameValidated = nameValidator.validate("Jone Doe");
Validated<String> emailValidated = nameValidator.validate("jdoe@example.com");
Validated<Integer> ageValidated = nameValidator.validate(30);

andThenメソッドを使うことで検証後に任意のオブジェクトに変換できます。

public record Name(String value) { /* ... */ }
public record Email(String value) { /* ... */ }
public record Age(Integer value) { /* ... */ }

StringValidator<Name> nameValidator = StringValidatorBuilder
    .of("name", c -> c.notBlank().lessThanOrEqual(100))
    .build()
    .andThen(Name::new); // -> extends Arguments1Validator<String, Name>

StringValidator<Email> emailValidator = StringValidatorBuilder
    .of("email", c -> c.notBlank().lessThanOrEqual(100).email())
    .build()
    .andThen(Email::new); // -> extends Arguments1Validator<String, Email>

IntegerValidator<Age> ageValidator = IntegerValidatorBuilder
    .of("age", c -> c.greaterThanOrEqual(0).lessThan(200))
    .build()
    .andThen(Age::new); // -> extends Arguments1Validator<Integer, Age>

Validated<Name> nameValidated = nameValidator.validate("Jone Doe");
Validated<Email> emailValidated = nameValidator.validate("jdoe@example.com");
Validated<Age> ageValidated = nameValidator.validate(30);

Listを扱いたい場合は次のように変換できます。

Arguments1Validator<Iterable<String>, List<Email>> emailsValidator = ArgumentsValidators.liftList(emailValidator);
Validated<List<Email>> emailsValidated = emailsValidator.validate(List.of("foo@example.com", "bar@example.com"));

面白いことにcomposeメソッドを使うことで、各値の導出元のオブジェクトからの変換を含むValidatorを作成することもできるようになりました。

例えばServletでnameemailageがHTTPのリクエストパラメータとしてHttpServletRequestオブジェクトから取得するケースでは次のように定義できます。

Argument1Validator<HttpServletRequest, Name> requestNameValidator = nameValidator
    .compose(req -> req.getParameter("name"));
Argument1Validator<HttpServletRequest, Email> requestEmailValidator = emailValidator
    .compose(req -> req.getParameter("email"));
Argument1Validator<HttpServletRequest, Age> requestAgeValidator = ageValidator
    .compose(req -> Integer.valueOf(req.getParameter("age")));

HttpServletRequest request = ...;
Validated<Name> nameValidated = requestNameValidator.validate(request);
Validated<Email> emailValidated = requestEmailValidator.validate(request);
Validated<Age> ageValidated = requestAgeValidator.validate(request);

Arguments Validatorの合成のサポート

小さなValidatorは定義できましたが、ではPersonオブジェクトの引数に対するValidatorはどうすれば良いでしょうか。

0.6.0では検証結果であるValidated(またはValidation)の合成がサポートされましたが、 0.7.0では検証するためのArguments Validatorの合成がサポートされました。

コンストラクタPerson(String, String, Integer)に対するValidatorはArguments3Validator<String, String, Integer, Person>ですが、 これをArguments1Validator<String, String>(StringValidator<String>)、Arguments1Validator<String, String>(StringValidator<String>)、Arguments1Validator<Integer, Integer>(IntegerValidator<Integer>)の 3つのValidatorに分割して合成します。

次のように定義できます。

StringValidator<String> nameValidator = ...;
StringValidator<String> emailValidator = ...;
IntegerValidator<Integer> ageValidator = ...;

Arguments3Validator<String, String, Integer, Person> personValidator = ArgumentsValidators
    .split(nameValidator, emailValidator, ageValidator)
    .apply(Person::new);
// or
Arguments3Validator<String, String, Integer, Person> personValidator = nameValidator
    .split(emailValidator)
    .split(ageValidator)
    .apply(Person::new);

Value Objectに変換する場合も同じです。

public record Person(Name name, Email email, Age age) { /* ... */ }

StringValidator<Name> nameValidator = ...;
StringValidator<Email> emailValidator = ...;
IntegerValidator<Age> ageValidator = ...;

Arguments3Validator<String, String, Integer, Person> personValidator = ArgumentsValidators
    .split(nameValidator, emailValidator, ageValidator)
    .apply(Person::new);
// or
Arguments3Validator<String, String, Integer, Person> personValidator = nameValidator
    .split(emailValidator)
    .split(ageValidator)
    .apply(Person::new);

使い方は最初の例と同じです。

Validated<Person> personValidated = Person.validator.validate("Jone Doe", "jdoe@example.com", 30);
// or
Person person = Person.validator.validated("Jone Doe", "jdoe@example.com", 30);

先の例でHttpServletRequestからvalidateするケースを扱いましたが、この時に作ったValidatorは次のようにcombineメソッドで合成してPersonオブジェクトを作成できます。

Arguments1Validator<HttpServletRequest, Person> requestPersonValidator = ArgumentsValidators
    .combine(requestNameValidator, requestEmailValidator, requestAgeValidator)
    .apply(Person::new);
// or
Arguments1Validator<HttpServletRequest, Person> requestPersonValidator = requestNameValidator
    .combine(requestEmailValidator)
    .combine(requestAgeValidator)
    .apply(Person::new);

HttpServletRequest request = ...;
Validated<Person> personValidated = requestPersonValidator.validate(request);

なお、このValidatorはArguments3Validator<String, String, Integer, Person>からも次のように変換可能です。

Arguments1Validator<HttpServletRequest, Person> requestPersonValidator = personValidator
    .compose(req -> Arguments.of(req.getParameter("name"), req.getParameter("email"), Integer.valueOf(req.getParameter("age"))));

このようにValidatorを合成することでいろいろなパターンのValidatorを作り出すことができます。 小さなArguments Validatorを作っておけば、オブジェクト作成前に値を検証できるだけでなく、再利用性が高まります。


YAVI 0.7.0での改善の嬉しい点はドメイン層で定義したValidator(ここではArguments3Validator<String, String, Integer, Person>)を使って、 Web層のValidator(ここではArguments1Validator<HttpServletRequest, Person>。Formクラスを作成する場合はArguments1Validator<PersonForm, Person>など。)を導出できる点です。

これまではWeb層で用意したValidatorからValidated<PersonForm>を作成し、Personオブジェクトに変換する(パターン1)か、 Web層のPersonFormは検証せず、Personオブジェクトに値を詰め替えてからドメイン層で用意したValidatorでValidated<Person>を作る(パターン2)必要がありました。 Bean Validationを使う場合は実質的にパターン1の場合が多いと思います。

// パターン1
ApplicativeValidator<PersonForm> formValidator = PersonForm.validator; // Web層のValidator
PersonForm form = ...;
Validated<PersonForm> formValidated = formValidator.validate(form);
Validated<Person> personValidated = formValidated.map(form -> new Person(form.getName(), form.getEmail(), form.getAge()));

// パターン2
ApplicativeValidator<Person> personValidator = Person.validator; // ドメイン層のValidator
PersonForm form = ...;
Person person = new Person(form.getName(), form.getEmail(), form.getAge());
Validated<Person> personValidated = personValidator.validate(person);

YAVI 0.7.0からパターン3としてドメイン層のValidatorからWeb層のValidatorを作り、検証後にPersonオブジェクトを返すことができるようになります。 個人的には検証ルールはできるだけドメイン層に持ちたいと思っていたので、このパターンが使えるのようになるのは嬉しいです。

// パターン3
Arguments3Validator<String, String, Integer, Person> personValidator = Person.validator; // ドメイン層のValidator
Arguments1Validator<PersonForm, Person> formValidator = personValidator
    .compose(form -> Arguments.of(form.getName(), form.getEmail(), form.getAge())); // Web層のValidator
PersonForm form = ...;
Validated<Person> personValidated = formValidator.validate(form);

Web層にしか存在しないパラメータのバリデーションが必要な場合も、Arguments1Validatorを合成することで対応できます。

// チェックボックスにチェックが必要な例。ドメイン層には不要なルール(そもそもUIのみで閉じても良い...)
Arguments1Validator<PersonForm, Boolean> acceptedValidator = BooleanValidatorBuilder
    .of("accpected", c -> c.notNull().isTrue())
    .build()
    .compose(PersonForm::isAccepted);

Arguments1Validator<PersonForm, Person> formValidator = Person.validator // ドメイン層のValidator
    .<PersonForm>compose(form -> Arguments.of(form.getName(), form.getEmail(), form.getAge()))
    .combine(acceptedValidator)
    .apply((person, accepted) -> person); // Web層のValidator

パターン3を実現するためのcomposeメソッドは強力で、ApplicativeValidator<T>でも使えるように0.8.0で対応します。
https://github.com/making/yavi/pull/138
YAVI 0.7.0では次のような変換が必要です。

Validator<T> validater = ...;
Arguments1Validator<T, T> arguments1Validator = validator.applicative()::validate;

めちゃくちゃ強力だと思いませんか? ぜひYAVIを使ってみてフィードバックをください。