IK.AM

@making's tech note


YAVIによるValue ObjectのValidation

🗃 {Programming/Java/am/ik/yavi}
🏷 Java 🏷 YAVI 
🗓 Updated at 2022-06-10T21:50:56+09:00  🗓 Created at 2022-06-10T21:45:10+09:00 {✒️️ Edit  ⏰ History  🗑 Delete}

YAVIにはValueValidator<X, Y>という、あるXクラスの値を検証した後にYクラスの値に変換して返す便利な機能があります。 この機能を使うとValue ObjectのValidationをエレガントに定義できます。

次のRecordを題材にします。

record Name(String value) { }
record Age(Integer value) { }
record Person(Name name, Age age) { }

Stringを検証し、Nameに変換して返すValueValidator<String, Name>Integerを検証してAgeを返すValueValidator<Integer, Age>は次のように定義できます。

final StringValidator<Name> nameValidator = StringValidatorBuilder
        .of("name", c -> c.notBlank().lessThanOrEqual(255))
        .build(Name::new);

final IntegerValidator<Age> ageValidator = IntegerValidatorBuilder
        .of("age", c -> c.notNull().greaterThanOrEqual(0))
        .build(Age::new);

検証結果は次のようにValidated型として返ります。

final Validated<Name> nameValidated = nameValidator.validate("John Doe");
final Validated<Age> ageValidated = ageValidator.validate(30);

検証が成功する場合は変換されたインスタンスを取得し、検証が失敗する場合は例外をスローしたい場合は次のように取り出せます。

final Name name = nameValidated.orElseThrow(ConstraintViolationsException::new);
final Age age = ageValidated.orElseThrow(ConstraintViolationsException::new);

ショートカットしたい場合は、次のように書けます。

final Name name = nameValidator.validated("John Doe");
final Age age = ageValidator.validated(30);

Validated<Name>Validated<Age>からValidated<Person>を作ることができます。

final Validated<Person> personValidated = nameValidated.combine(ageValidated).apply(Person::new);

この場合、NameAgeのエラーメッセージは集約されます。

次のようなファクトリメソッドを作ると便利でしょう。

record Name(String value) {
    static final StringValidator<Name> validator = StringValidatorBuilder
            .of("name", c -> c.notBlank().lessThanOrEqual(255))
            .build(Name::new);

    public static Validated<Name> of(String value) {
        return validator.validate(value);
    }
}

record Age(Integer value) {
    static final IntegerValidator<Age> validator = IntegerValidatorBuilder
            .of("age", c -> c.notNull().greaterThanOrEqual(0))
            .build(Age::new);

    public static Validated<Age> of(Integer value) {
        return validator.validate(value);
    }
}

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

final Validated<Person> personValidated = Name.of("John Doe")
        .combine(Age.of(30))
        .apply(Person::new);

recordの場合はパブリックコンストラクタができるため、ofファクトリを用意しても、入力チェックなしでコンストラクタからインスタンスを生成することができてしまいます。 コンストラクタ内で入力チェックし、不変条件を満たさないインスタンスを作れないようにしたい場合は、lazy()メソッドをつけることでコンストラクタ内でvalidateを実行可能です。

record Name(String value) {
    static final StringValidator<Name> validator = StringValidatorBuilder
            .of("name", c -> c.notBlank().lessThanOrEqual(255))
            .build(Name::new);
    
    // compact constructor
    Name {
        Name.validator.lazy().validated(value);
    }
}

record Age(Integer value) {
    static final IntegerValidator<Age> validator = IntegerValidatorBuilder
            .of("age", c -> c.notNull().greaterThanOrEqual(0))
            .build(Age::new);

    // compact constructor
    Age {
        Age.validator.lazy().validated(value);
    }
}

lazy()がないと、例えばNameコンストラクタ内でNameインスタンスを作ろうとしてまたNameコンストラクタが呼ばれ、StackOverflowErrorが発生します。 lazy()をつけるとNameインスタンスを返す代わりにSupplier<Name>が返り、インスタンスを生成するタイミングを遅延できます。これによりStackOverflowErrorの発生が防がれます。

Name name = new Name("  "); // => "name" must not be blank
Age age = new Age(-1); // => "age" must be greater than or equal to 0

Validated<Name>Validated<Age>からValidated<Person>を作る代わりに、 StringValidator<Name>IntegerValidator<Age>からArguments2Validator<String, Integer, Person>を作り、 そこから直接Validated<Person>を作ることもできます。

final Arguments2Validator<String, Integer, Person> personValidator = Name.validator
        .split(Age.validator)
        .apply(Person::new);

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

final Validated<Person> personValidated = Person.validator.validate("John Doe", 30);

または

final Person person = Person.validator.validated("John Doe", 30);

エラーは集約されるので、次のような検証を行うと、

final Person person = Person.validator.validated("  ", -1);

次のようなメッセージを持つ例外がスローされます。

am.ik.yavi.core.ConstraintViolationsException: Constraint violations found!
* "name" must not be blank
* "age" must be greater than or equal to 0

なお、制約違反情報はConstraintViolationsException#violationsで取得できます。


YAVIを使ったValue ObjectのValidationについて紹介しました。 とても便利なのでYAVIを使いましょう。