この記事は筆者のソロ Advent Calendar 2022 9日目の記事です。
前回Java製のバリデーションライブラリであるYAVI(ヤバイ)の基本的な使い方を紹介しましたが、今回はもう少しYAVIで用意されている機能を紹介していきます。
Java製のバリデーションライブラリYAVI(ヤバイ)を使ってみた
Java製のバリデーションライブラリYAVI(ヤバイ)を使ってみた[応用編] <- 今ここ
Java製のバリデーションライブラリYAVI(ヤバイ)を使ってみた[API導入編]
今回までの成果物はこちらです
バリデーション結果の結合(Combining validation results)
YAVIはApplicative Functorと呼ばれる関数型プログラミングの概念をサポートしています。筆者は関数型のプログラミング言語の経験がないのでここら辺の詳しい概念は今回は説明いたしませんが、YAVIでは一連の検証処理の一部または全部が失敗したとしても全ての検証結果(ConstraintViolation)を蓄積しながら処理が実行されます。
複数の検証結果を組み合わせて新しいオブジェクトを生成したりするのに便利なメソッドが用意されています。例えば、以下のようなデータクラスと簡単なバリデーターを準備します。
data class Email(val value: String)
data class PhoneNumber(val value: String)
data class ContactInfo(val email: Email?, val phoneNumber: PhoneNumber?)
val emailValidator = validator<Email> {
(Email::value)("email") {
notBlank()
email()
}
}
val phoneNumberValidator = validator<PhoneNumber> {
(PhoneNumber::value)("phoneNumber") {
notBlank()
pattern("[0-9\\-]+")
}
}
Validatorからはapplicative()を使用することでApplicativeValidator<T>
を取得することができ、validate()を使用することでValidated<T>
を得ることができます。
Validated<T>
は Validation<ConstraintViolation, T>
のショートカットで、Validator の用途に特化したものです。
前述したEmailクラスに対してのvalidatorからapplicative()でValidatedを取り出すと以下のように書けます。テストは前回と同様Kotestを使用しています。
internal class ContactInfoTest : FunSpec({
test("test email") {
val email = Email("test@test.com") //これは通る
val invalidEmail = Email("test.com") //これは違反
//それぞれからValidatedを取得
val emailValidated = emailValidator.applicative().validate(email)
val emailValidated2 = emailValidator.applicative().validate(invalidEmail)
//value()で値を取り出すことができる
emailValidated.isValid shouldBe true
emailValidated.value() shouldBe email
//orElseThrowでConstraintViolationから例外を作成しスローできる
emailValidated2.isValid shouldBe false
shouldThrow<ConstraintViolationsException> {
emailValidated2.orElseThrow { ConstraintViolationsException(it) }
}
//fold()を使用すると検証結果に関わらず共通の型に変換する場合に便利
emailValidated.fold({ HttpStatus.BAD_REQUEST }) { HttpStatus.OK } shouldBe HttpStatus.OK
emailValidated2.fold({ HttpStatus.BAD_REQUEST }) { HttpStatus.OK } shouldBe HttpStatus.BAD_REQUEST
}
}
また、emailValidatedとphoneNumberValidatedを組み合わせるのにはcombine()を使用し以下のような書き方ができる。
test("test combining") {
// given
//email, phoneNumber共に違反
val email = Email("")
val phoneNumber = PhoneNumber("")
//validatedを取得
val emailValidated = emailValidator.applicative().validate(email)
val phoneNumberValidated = phoneNumberValidator.applicative().validate(phoneNumber)
//2つの検証を組み合わせ2つの値を持つContactInfoクラスのインスタンスを作成する新たなValidatedを作り出す
val contactInfoValidated = emailValidated.combine(phoneNumberValidated).apply { em, ph -> ContactInfo(em, ph) }
// expected 3種類の違反が取り出せる
contactInfoValidated.isValid shouldBe false
var errors = contactInfoValidated.errors()
errors.size shouldBe 3
errors[0].message() shouldBe """
"email" must not be blank
""".trimIndent()
errors[1].message() shouldBe """
"phoneNumber" must not be blank
""".trimIndent()
errors[2].message() shouldBe """
"phoneNumber" must match [0-9\-]+
""".trimIndent()
}
当然、違反がなければ以下のようにvalue()でContactInfoのインスタンスが取得できる。
test("test valid contactInfo") {
// given
val email = Email("test@test.com")
val phoneNumber = PhoneNumber("000-0000-0000")
val emailValidated = emailValidator.applicative().validate(email)
val phoneNumberValidated = phoneNumberValidator.applicative().validate(phoneNumber)
val contactInfoValidated = emailValidated.combine(phoneNumberValidated).apply { em, ph -> ContactInfo(em, ph) }
// expected
contactInfoValidated.isValid shouldBe true
contactInfoValidated.value() shouldBe ContactInfo(email, phoneNumber)
}
また、ArgumentValidatorsを使用することで以下のようにContactInfoのValidatorを作成し、使用することもできる。
val contactInfoValidator = ArgumentsValidators
.split(emailValidator.applicative(), phoneNumberValidator.applicative())
.apply { em, ph -> ContactInfo(em, ph) }
引数のバリデーション(Validating arguments)
YAVIは、Arguments Validatorを使用して、オブジェクトを作成する前にコンストラクタやファクトリメソッドの引数を検証することをサポートしています。これはYAVIのユニークな機能なようです。
BeanValidationのようなバリデーターは検証前にオブジェクトを作成するため不完全な状態のオブジェクトが作成されてしまうのに対して、Arguments Validatorの使用はオブジェクト作成前に検証を完了させるため不完全なオブジェクトの存在を気にする必要はありません。
以下のようなPersonオブジェクトのコンストラクタ引数の検証をするためには以下のようなvalidatorを定義することができます。
data class Person(val name: String?, val email: String?, val age: Int?)
val personArgumentsValidator: Arguments3Validator<String?, String?, Int?, Person> = ArgumentsValidatorBuilder
.of { name: String?, email: String?, age: Int? -> Person(name, email, age) }
.builder {
it._string({ arg -> arg.arg1() }, "name") { c -> c.notBlank().lessThanOrEqual(100) }
._string({ arg -> arg.arg2() }, "email") { c -> c.notBlank().lessThanOrEqual(100).email() }
._integer({ arg -> arg.arg3() }, "age") { c -> c.greaterThanOrEqual(0).lessThan(200) }
}.build()
ドキュメントに書いてもあるのですがこれくらいならまだ書けそうですが、もっと複雑な構造のオブジェクトのvalidatorを作成しようとするといわゆる型パズルを解く感じになってきます。Kotlinで書くとJavaのラムダの書き方などもいい感じに変換する必要があるので複雑になりそうであれば細かく作って組み合わせて使う感じがいいのかなと思いました。
また、以下のようなファクトリメソッドのように対象オブジェクトを作成するメソッドを利用してvalidatorを作成することもできる。
data class User(val email: String?, val name: String?)
class UserService {
fun createUser(email: String?, name: String?) = User(email, name)
}
val userServiceValidator: Arguments3Validator<UserService?, String?, String?, User> = ArgumentsValidatorBuilder
.of { service: UserService?, name: String?, email: String? -> service!!.createUser(name, email) }
.builder {
it._object({ arg -> arg.arg1() }, "userService") { c -> c.notNull() }
._string({ arg -> arg.arg2() }, "email") { c -> c.email() }
._string({ arg -> arg.arg3() }, "name") { c -> c.notNull() }
}.build()
上記の例で使用しているArgumentValidatorは1から16まで用意されておりある程度の引数に対応できますが逆にArguments1ValidatorをStringやIntなどに対して使用したい場合には以下のように書ける。
val nameValidator = StringValidatorBuilder
.of("name") { it.notBlank().lessThanOrEqual(100) }
.build()
andThen()とつなげるとで新しいインスタンスを作成することもできる。
data class Name(val value: String)
data class Email(val value: String)
data class Age(val value: Int)
val nameValidator = StringValidatorBuilder
.of("name") { it.notBlank().lessThanOrEqual(100) }
.build()
.andThen { name -> Name(name) }
val emailValidator = StringValidatorBuilder
.of("email") { it.notBlank().lessThanOrEqual(100).email() }
.build()
.andThen { email -> Email(email) }
val ageValidator = IntegerValidatorBuilder
.of("age") { it.greaterThan(0).lessThan(200) }
.build()
.andThen { age -> Age(age) }
ListやMapみたいなCollectionに対しては以下のように書きます。
test("test liftList") {
ageValidator.liftList().validate(listOf(1, 2, 3, 4, 5)).also {
it.isValid shouldBe true
it.value() shouldBe listOf(Age(1), Age(2), Age(3), Age(4), Age(5))
}
ageValidator.liftList().validate(listOf(-1, 0, 1)).also {
it.isValid shouldBe false
it.errors().size shouldBe 2
}
}
繰り返しになりますがこのような小さく作成したvalidatorを再利用し組み合わせることで大きなオブジェクトや複雑な検証を可能にしていることが他のvalidatorライブラリと比べてヤバイところかなと思います。
バリデーターの組み合わせ(Combining validators)
小さく定義したvalidatorは以下のようにsplit()を使用し組み合わせることができます。
val personNameValidator = StringValidatorBuilder
.of("name") { it.notBlank().lessThanOrEqual(100) }
.build()
val personEmailValidator = StringValidatorBuilder
.of("email") { it.notBlank().lessThanOrEqual(100) }
.build()
val personAgeValidator = IntegerValidatorBuilder
.of("age") { it.greaterThanOrEqual(0).lessThanOrEqual(100) }
.build()
val personValidator = ArgumentsValidators
.split(personNameValidator, personEmailValidator, personAgeValidator)
.apply { name, email, age -> Person(name, email, age) }
そして、これはめちゃくちゃ便利だなと思ったのがフォームクラスなどの検証からドメインクラスへの変換までを処理できるvalidatorを作成できることです。HttpServletRequestからの変換は以下のように書くことができます。
val requestNameValidator = personNameValidator.compose<HttpServletRequest> {
it.getParameter("name")
}
val requestEmailValidator = personEmailValidator.compose<HttpServletRequest> {
it.getParameter("email")
}
val requestAgeValidator = personAgeValidator.compose<HttpServletRequest> {
it.getParameter("age").toInt()
}
val requestPersonValidator = ArgumentsValidators.combine(requestNameValidator, requestEmailValidator, requestAgeValidator)
.apply { name, email, age -> Person(name, email, age) }
test("test HttpServletRequest") {
val request = MockHttpServletRequest().also {
it.setParameter("name", "user")
it.setParameter("email", "test@test.com")
it.setParameter("age", "32")
}
requestPersonValidator.validate(request).also {
it.isValid shouldBe true
it.value() shouldBe Person("user", "test@test.com", 32)
}
}
クリーンアーキテクチャやオニオンアーキテクチャのようなアーキテクチャを採用してるとレイヤー間のオブジェクトのやり取りのためにオブジェクトの変換を頻繁に行うことになると思いますが、上記のように検証とオブジェクト変換がまとめてできるのはかなり書き味がいいなと思いました!
まとめ
今回はバリデーション結果やバリデーターの組み合わせを中心に紹介しました。前述してますが
- バリデーターを小さく作成して再利用性を高められる。
- 検証とあわせてオブジェクト変換までできる。
このような点が他のバリデーターにないYAVIの特徴で書いていてとても楽しかったです!今後業務などでも使える機会があれば積極的に採用していきたいなと思います。まだ使ったことがないという方はぜひ使ってみてください!