YAVIでValue ObjectのValidation (Records対応)

YAVI 0.6.0で Applicative Functor を使用したValidationに対応しました。 特にValue ObjectのValidationを組み合わせる場合に有用です。(もちろんValue Object以外でも有用です。)

YAVIとは何?という場合は"Java用Validatorライブラリ"YAVI"(ヤヴァイ)の紹介"を参照してください。

導入の背景を含めて何が嬉しいかは後述し、まずは使い方を紹介します。

使い方

EmailPhoneNumberというValue Objectクラスがあり、これらをフィールドに持つContactInfoクラスがあるとします。 折角なのでJava 16でサポートされた Records を使用して定義します。

public record Email(String value) {
} 

public record PhoneNumber(String value) {
}

public record ContactInfo(Email email, PhoneNumber phoneNumber) {
}

本題とは関係ないですが、Recordsを使うとvalue Objectの定義はとても簡単です。

Bean ValidationはまだRecordsに対応していませんが、YAVIでは最初のリリースから既に次のようにRecordsに対するValidationを定義できます。

import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validator;

public record Email(String value) {
	private static final Validator<Email> validator = ValidatorBuilder.<Email>of()
			.constraint(Email::value, "email",
					c -> c.notBlank().lessThanOrEqual(128).email())
			.build();
}
import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validator;

public record PhoneNumber(String value) {
	private static final Validator<PhoneNumber> validator = ValidatorBuilder.<PhoneNumber>of()
			.constraint(PhoneNumber::value, "phoneNumber",
					c -> c.notBlank().lessThanOrEqual(16).pattern("[0-9\\-]+"))
			.build();
}

YAVI 0.6.0からはValidationの結果としてValidated型のオブジェクトを返すことができるようになります。これを使って、EmailPhoneNumberのfactory methodを作成します。

import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validated;
import am.ik.yavi.core.Validator;

public record Email(String value) {
	private static final Validator<Email> validator = ValidatorBuilder.<Email>of()
			.constraint(Email::value, "email",
					c -> c.notBlank().lessThanOrEqual(128).email())
			.build();

	public static Validated<Email> of(String value) {
		return validator.applicative().validate(new Email(value));
	}
}
import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validated;
import am.ik.yavi.core.Validator;

public record PhoneNumber(String value) {
	private static final Validator<PhoneNumber> validator = ValidatorBuilder.<PhoneNumber>of()
			.constraint(PhoneNumber::value, "phoneNumber",
					c -> c.notBlank().lessThanOrEqual(16).pattern("[0-9\\-]+"))
			.build();

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

Value Objectは常に不変条件を満たすべきであり、インスタンス作成時に入力チェック済み状態で返すというのはとても理にかなっています。

Validated型は次のように使えます。

Validated<Email> emailValidated = Email.of("maki@example.com");
if (mailValidated.isValid()) {
	Email email = emailValidated.value();
	// ...
} else {
	ConstraintViolations violations = emailValidated.errors();
	// ...
}

if文を書かずに、入力チェックにエラーがある場合に例外をスローさせたい場合は、次のように書くことができます。

Email email = emailValidated
	.orElseThrow(violations -> new ConstraintViolationsException(violations));

あるいはfoldメソッドで入力チェックが成功の場合も失敗の場合も同じ型に変換することができます。

HttpStatus status = emailValidated.fold(violations -> HttpStatus.BAD_REQUEST, email -> HttpStatus.OK)

Validated<T>型は後述のValidation<E, T>型の一部特化型であるValidation<ConstraintViolation, T>型です。

ここまでは0.6.0より前からサポートしていたEitherでも同じように書けます。 Validated型、あるいはその親クラスであるValidation型の場合、次のように入力チェック結果の合成及びエラー情報の集約ができます。

Validated<Email> emailValidated = Email.of("maki@example.com");
Validated<PhoneNumber> phoneNumberValidated = PhoneNumber.of("0120-3456-7890");
Validated<ContactInfo> contactInfoValidated = Validations.apply((email, phoneNumber) -> new ContactInfo(email, phoneNumber), emailValidated, phoneNumberValidated)

説明のため、メソッド参照を使わず明示的にラムダ式を書いています。

Validations.applyは次のコードと同じです。

Validated<ContactInfo> contactInfoValidated = Validations.combine(emailValidated, phoneNumberValidated)
	.apply((email, phoneNumber) -> new ContactInfo(email, phoneNumber));

または

Validated<ContactInfo> contactInfoValidated = emailValidated.combine(phoneNumberValidated)
	.apply((email, phoneNumber) -> new ContactInfo(email, phoneNumber));

applyメソッドの引数は0.6.0時点では10個までサポートされています。

例えばEmail (空白スペース)、PhoneNumberaを入力して、ContactInfoを作成しようとすると、 入力チェックは失敗し、次の3つのContraintViolationが返ります。

* "email" must not be blank
* "email" must be a valid email address
* "phoneNumber" must match [0-9\-]+

EmailPhoneNumberのエラーメッセージが集約されていることがわかります。

今回はfactory methodでValidated型を使用する例を紹介しましたが、通常のフローにおける入力チェックに使ってももちろん良いです。 これまで提供してきたEitherを使用するよりもValidated型を使用する方がオススメです。 Validated型は他のValidated型オブジェクトと組み合わせ可能なため、Validationロジックの再利用性も高いです。

導入の背景

では、なぜこの機能を追加したかというと、契機はこの issue です。 コード例にKotlinが使われていますが、要約すると

  • Value Objectのfactory methodでYAVIによるValidationをかけた状態の値を返したい
  • YAVIで提供されているEitherを返した場合、Value Objectの合成に使用できず、再利用性が低い。

というものでした。このissueでは konad とのIntegrationを提案されましたが、Kotlin特化なものを使うわけにはいかなかったため、Javaで実装しました。 やはりfactory methodでの入力チェック済みのオブジェクトを返す用途が増えているようですね。

YAVIは元々ネストしたオブジェクトのValidationはサポートしています。したがって次のような書き方はできます。

import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validator;

public record Email(String value) {
	public static final Validator<Email> validator = ValidatorBuilder.<Email>of()
			.constraint(Email::value, "value",
					c -> c.notBlank().lessThanOrEqual(128).email())
			.build();
}
import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validator;

public record PhoneNumber(String value) {
	public static final Validator<PhoneNumber> validator = ValidatorBuilder.<PhoneNumber>of()
			.constraint(PhoneNumber::value, "value",
					c -> c.notBlank().lessThanOrEqual(16).pattern("[0-9\\-]+"))
			.build();
}
import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validator;

public record ContactInfo(Email email, PhoneNumber phoneNumber) {
	public static final Validator<ContactInfo> validator = ValidatorBuilder.<ContactInfo>of()
			.nest(ContactInfo::email, "email", Email.validator)
			.nest(ContactInfo::phoneNumber, "phoneNumber", PhoneNumber.validator)
			.build();
}

このようにContactInfo側にValidatorを定義し、NestしたEmailPhoneNumberのValidatorに委譲するという使い方を想定してました。

Validationの使い方は次のようになります。

Either<ConstraintViolations, ContactInfo> either = ContactInfo.validator.either()
		.validate(new ContactInfo(new Email(" "), new PhoneNumber("a")));
ContactInfo contactInfo = either.rightOrElseThrow(violations -> new ConstraintViolationsException(violations));
System.out.println(contactInfo);

Validated型の場合と同じく、入力チェックは失敗し、次の3つのContraintViolationが返ります。

* "email.value" must not be blank
* "email.value" must be a valid email address
* "phoneNumber.value" must match [0-9\-]+

得られる結果は同じなのですが、"全て組み立ててからValidationする"か、"Validatedなものを使って組み立てるか"の違いがあります。 設計方針によりますが、どちらもユースケースはあると思います。 前者の場合、例えばWebフレームワークがリクエストパラメータから組み立てたオブジェクトをWeb層でValidationしたい場合に有用です。 後者の場合、不変状態を満たしたドメインオブジェクトを使ってドメイン層の処理を行いたい場合に有用です。

0.6.0より前のバージョンは主に前者の用途を考慮していました。後者の用途としては Arguments Validator という機能があるのですが、ここでは説明を割愛します。

ではfactory methodでEitherを使って後者のユースケースを実装できないのでしょうか?

次のようにfactory methodを書き直してみます。

import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.ConstraintViolations;
import am.ik.yavi.core.Validator;
import am.ik.yavi.fn.Either;

public record Email(String value) {
	private static final Validator<Email> validator = ValidatorBuilder.<Email>of()
			.constraint(Email::value, "email",
					c -> c.notBlank().lessThanOrEqual(128).email())
			.build();

	public static Either<ConstraintViolations, Email> of(String value) {
		return validator.either().validate(new Email(value));
	}
}
import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.ConstraintViolations;
import am.ik.yavi.core.Validator;
import am.ik.yavi.fn.Either;

public record PhoneNumber(String value) {
	private static final Validator<PhoneNumber> validator = ValidatorBuilder.<PhoneNumber>of()
			.constraint(PhoneNumber::value, "phoneNumber",
					c -> c.notBlank().lessThanOrEqual(16).pattern("[0-9\\-]+"))
			.build();

	public static Either<ConstraintViolations, PhoneNumber> of(String value) {
		return validator.either().validate(new PhoneNumber(value));
	}
}

このfactory methodを使ってContactInfoを作成すると次のようなコードになります。

Either<ConstraintViolations, Email> emailEither = Email.of("   ");
Either<ConstraintViolations, PhoneNumber> phoneNumberEither = PhoneNumber.of("aaa");

Email email = emailEither.rightOrElseThrow(ConstraintViolationsException::new);
PhoneNumber phoneNumber = phoneNumberEither.rightOrElseThrow(ConstraintViolationsException::new);
ContactInfo contactInfo = new ContactInfo(email, phoneNumber);

あるいは0.6.0から追加されたEither.flatMapメソッドを使って次のようにEitherに合成できます。

Either<ConstraintViolations, ContactInfo> contactInfoEither = emailEither
		.flatMap(email -> phoneNumberEither
				.rightMap(phoneNumber ->
						new ContactInfo(email, phoneNumber)));
ContactInfo contactInfo = contactInfoEither.rightOrElseThrow(ConstraintViolationsException::new);

どちらの場合にも、入力チェックは失敗し、次のContraintViolationが返ります。

* "email" must not be blank
* "email" must be a valid email address

見てわかるように、入力チェックが途中でショートカットされて、Emailに関するContraintViolationしか取得できません。 エラーを集約するには次のようなコードを書く必要があります。

ConstraintViolations violations = new ConstraintViolations();
Either<ConstraintViolations, ContactInfo> contactInfoEither;
emailEither.peekLeft(violations::addAll);
phoneNumberEither.peekLeft(violations::addAll);
if (violations.isEmpty()) {
	contactInfoEither = Either.right(new ContactInfo(emailEither.right().get(), phoneNumberEither.right().get()));
}
else {
	contactInfoEither = Either.left(violations);
}
ContactInfo contactInfo = contactInfoEither.rightOrElseThrow(ConstraintViolationsException::new);

すっきりしないですが、これでようやく次の3つのContraintViolationが返ります。

* "email" must not be blank
* "email" must be a valid email address
* "phoneNumber" must match [0-9\-]+

既にValidatedの例を見たので、このコードがよくないことがわかります。

Either<E, T>型に対して、エラーをショートカットせず集約するデータ構造がValidation<E, T>型です。 Validated<T>は汎用型であるValidation<E, T>型のE = ConstraintViolationである特化型です。

YAVIではSemigroupの概念は導入せず、シンプルにList<E>でエラーを集約しました。

HaskellやScalazなどの関数型プログラミングの Applicative Functor の考え方を使っています。 このようなValidationはデザインパターンとして色々な箇所で使われているので、知っておくと便利かもしれません。

例えば Micrometer で使用されています。

YAVIは実装としてvavrを参考にしました。また、gakuzzzzにたくさんアドバイスをいただきました。ありがとうございます。


YAVIはValidationライブラリとして、色々なプログラミングパターンを採用しつつも、業務で使える実用的なバリデーションルールを備えています。 Starも430以上付いて、利用が増えてきています。まだエッジケースでバグがありますが、ぜひ使ってみてフィードバックをください。