この記事は筆者のソロ Advent Calendar 2022 8日目の記事です。
今回はJava製のバリデーションライブラリであるYAVI(ヤバイ)の基本的な使い方を紹介します。
Java製のバリデーションライブラリYAVI(ヤバイ)を使ってみた <- 今ここ
Java製のバリデーションライブラリYAVI(ヤバイ)を使ってみた[応用編]
Java製のバリデーションライブラリYAVI(ヤバイ)を使ってみた[API導入編]
本記事で作成したサンプルコードはこちら
YAVIとは??
Javaでバリデーション実装する際にはよくBeanValidationが使用されると思いますが、BeanValidationはアノテーションベースのライブラリになります。YAVIはアノテーションベースではなく、ラムダ式でバリデーション実装をし、グループやカスタマイズ機能なども備えた型安全なバリデーションライブラリです!
もともとどこかの勉強会で知り、興味があったのと以前業務でBeanValidationを使用したバリデーション処理に非常に苦労したことがあったため今回記事を書いてみることにしました。
Getting Started
以下の依存関係を追加するだけ。準備が簡単でヤバイ。
implementation("am.ik.yavi:yavi:0.11.3")
使い方は以下のような感じ。
package com.example;
import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.Validator;
public class Car {
private final String manufacturer;
private final String licensePlate;
private final int seatCount;
public static final Validator<Car> validator = ValidatorBuilder.<Car>of()
.constraint(Car::getManufacturer, "manufacturer", c -> c.notNull())
.constraint(Car::getLicensePlate, "licensePlate", c -> c.notNull().greaterThanOrEqual(2).lessThanOrEqual(14))
.constraint(Car::getSeatCount, "seatCount", c -> c.greaterThanOrEqual(2))
.build();
public Car(String manufacturer, String licencePlate, int seatCount) {
this.manufacturer = manufacturer;
this.licensePlate = licencePlate;
this.seatCount = seatCount;
}
public String getManufacturer() {
return manufacturer;
}
public String getLicensePlate() {
return licensePlate;
}
public int getSeatCount() {
return seatCount;
}
}
ValidatorBuilder.ofメソッドの型パラメーターに対象のクラス(今回の例でいうとCarクラス)を指定し、constraintメソッドでバリデーション条件を指定していく。
constraintメソッドは第一引数がどのフィールドに対しての検証かをメソッド参照で指定し、第二引数ではバリデーションnameを文字列で指定。第三引数がCharSequenceConstraint
を引数にとるラムダ式内に検証内容を記述する。
kotlinで書くとこんな感じで書ける。
package com.example.yavi.demo
import am.ik.yavi.builder.ValidatorBuilder
class Car(
private val manufacturer: String?,
private val licensePlate: String?,
private val seatCount: Int
) {
companion object {
val validator = ValidatorBuilder.of<Car>()
.constraint(Car::manufacturer, "manufacturer") { it.notNull() }
.constraint(Car::licensePlate, "licensePlate") { it.notNull().greaterThanOrEqual(2).lessThanOrEqual(14) }
.constraint(Car::seatCount, "seatCount") { it.greaterThanOrEqual(2) }
.build()
}
}
実際にバリデーションを実行する時は上記で宣言したvalidatorを使用する。テストフレームワークにKotestを使用し上記のCarクラスのテストを書くと以下のようになる。
package com.example.yavi.demo
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
internal class CarTest : FunSpec({
test("manufacturerIsNull") {
val car = Car(null, "DD-AB-123", 4)
val violations = Car.validator.validate(car)
violations.isValid shouldBe false
violations.size shouldBe 1
violations[0].message() shouldBe """
"manufacturer" must not be null
""".trimIndent()
}
test("licensePlateTooShort") {
val car = Car("Morris", "D", 4)
val violations = Car.validator.validate(car)
violations.isValid shouldBe false
violations.size shouldBe 1
violations[0].message() shouldBe """
The size of "licensePlate" must be greater than or equal to 2. The given size is 1
""".trimIndent()
}
test("seatCountTooLow") {
val car = Car("Morris", "DD-AB-123", 1)
val violations = Car.validator.validate(car)
violations.isValid shouldBe false
violations.size shouldBe 1
violations[0].message() shouldBe """
"seatCount" must be greater than or equal to 2
""".trimIndent()
}
test("carIsValid") {
val car = Car("Morris", "DD-AB-123", 2)
val violations = Car.validator.validate(car)
violations.isValid shouldBe true
violations.size shouldBe 0
}
})
validatorはスレッドセーフなためstaticに使用することができ、validate()の引数に検証対象のインスタンスを指定する。何かしらの検証に引っかかっていればisValidがfalseを返す。検証内容に違反した内容は全て返され、message()でメッセージを取得することができる。
Using YAVI
nest
以下のように検証対象がネストした構造を持っている場合はnest()を使用することで以下のように書くことができる。
package com.example.yavi.demo
import am.ik.yavi.builder.ValidatorBuilder
import am.ik.yavi.builder.validator
data class Country(val name: String)
data class City(val name: String)
class Address(val country: Country, val city: City) {
companion object {
private val countryValidator = ValidatorBuilder.of<Country>().constraint(Country::name, "name") { it.notBlank().lessThanOrEqual(20) }.build()
private val cityValidator = ValidatorBuilder.of<City>().constraint(City::name, "name") { it.notBlank().lessThanOrEqual(100) }.build()
val validator = ValidatorBuilder.of<Address>()
.nest(Address::country, "country", countryValidator)
.nest(Address::city, "city", cityValidator)
.build()
}
}
Collection
以下のようにListやMapのようなCollectionをフィールドに持つ場合はforEach()を使用することで以下のように書くことができる。
package com.example.yavi.demo
import am.ik.yavi.builder.ValidatorBuilder
data class History(val revision: Int?)
data class Histories(val value: List<History>)
val historyValidator = ValidatorBuilder.of<History>()
.constraint(History::revision, "revision") { it.notNull().greaterThanOrEqual(1) }
.build()
val historiesValidator = ValidatorBuilder.of<Histories>()
.forEach(Histories::value, "histories", historyValidator)
.build()
Specific Condition
特定条件下での検証条件はconstraintOnCondition()を使用することで以下のように書くことができる。
data class User(val id: Long?, val name: String, val email: String)
val userValidator = ValidatorBuilder.of<User>()
.constraintOnCondition({ user, _ -> user.name.isNotEmpty() }) {
it.constraint(User::email, "email") { c ->
c.email().notEmpty()
}
}
.build()
上記の場合emailのnotEmptyの検証はnameフィールドが空でない時のみ検証される。
Groups
検証内容を指定のグループによって変更したい場合はConstraintGroupを実装したクラスを用意することで実現することができる。
sealed class Group : ConstraintGroup {
object CREATE : Group()
object UPDATE : Group()
object DELETE : Group()
override fun name() = this.toString()
}
val userGroupValidator = ValidatorBuilder.of<User>()
.constraintOnGroup(Group.CREATE) { it.constraint(User::id, "id") { c -> c.isNull } }
.constraintOnGroup(Group.UPDATE) { it.constraint(User::id, "id") { c -> c.notNull() } }
.build()
使用する時は以下のように定義したgroupをvalidate()の第二引数に指定する。
context("test group pattern") {
data class TestPattern(val group: Group, val isValid: Boolean)
val user = User(null, "user", "test@test.com")
withData(
nameFn = { "when group: ${it.group}, isValid: ${it.isValid}" },
TestPattern(Group.CREATE, true), // create の時はid はnull
TestPattern(Group.UPDATE, false), // updateの時はidはnot null
TestPattern(Group.DELETE, true) // deleteは検証なし
) { (group, isValid) ->
val violations = userGroupValidator.validate(user, group)
violations.isValid shouldBe isValid
}
}
Javaの場合、以下のようにenumにConstraintGroupを実装して使用していたがkotlinで同じように書くとConstraintGroupインターフェイスで用意されているname()と多分enumで用意されているname()がコンフリクトしてコンパイルエラーになってしまった。そのため、今回はenumではなくsealed classに実装し、name()をoverrideして使用した。
enum Group implements ConstraintGroup {
CREATE, UPDATE, DELETE
}
Validator<User> validator = ValidatorBuilder.<User> of()
.constraintOnCondition(Group.CREATE.toCondition(), b -> b.constraint(User::getId, "id", c -> c.isNull()))
.constraintOnCondition(Group.UPDATE.toCondition(), b -> b.constraint(User::getId, "id", c -> c.notNull()))
.build();
Custom
カスタムした検証を導入したい場合はCustomConstraintインターフェイスを実装したクラスを作成することで実現できる。
data class Book(val title: String, val isbn: String)
object ISBNValidator {
private val ISBN_REGEX = Regex("^ISBN\\d{3}-\\d-\\d{6}-\\d{2}-\\d$")
fun isISBN13(str: String?): Boolean {
return str?.let { ISBN_REGEX.containsMatchIn(it) } ?: false
}
}
object IsbnConstraint : CustomConstraint<String?> {
override fun test(t: String?): Boolean {
return ISBNValidator.isISBN13(t)
}
override fun messageKey(): String {
return "string.isbn13"
}
override fun defaultMessageFormat(): String {
return "\"{0}\" must be ISBN13 format"
}
}
overrideする必要のあるメソッドは3つでtest()に検証処理を記載する。メッセージ内容はdefaultMessageFormat()に記載し、messageKey()には検証のkeyを記載する。
validatorを作成する際にはpredicate()に指定することでこのカスタムConstraintを使用することができる。
val bookValidator = ValidatorBuilder.of<Book>()
.constraint(Book::title, "title") { it.notBlank().lessThanOrEqual(64) }
.constraint(Book::isbn, "isbn") { it.notBlank().predicate(IsbnConstraint) }
.build()
違反メッセージに最初に使用できるのはフィールド名であり、最後に使用できるのは違反した値である。その他の値を使用したい場合は以下のようにargument()をoverrideすることで使用できる。
class InstantRangeConstraint(private val start: Instant, private val end: Instant) : CustomConstraint<Instant> {
override fun arguments(violatedValue: Instant?): Array<Any> {
return arrayOf(this.start /* {1} */, this.end /* {2} */)
}
override fun defaultMessageFormat(): String {
return "Instant value \"{0}\" must be between \"{1}\" and \"{2}\"."
}
override fun messageKey(): String {
return "instant.range"
}
override fun test(t: Instant?): Boolean {
return t?.isAfter(this.start) ?: false && t?.isBefore(this.end) ?: false
}
}
Cross-field validation
フィールドをまたぐような制約をかけたい場合は、以下のようにconstraintOnTargetを使用することで実現することができる。
data class Range(val from: Int, val to: Int)
val rangeValidator = ValidatorBuilder.of<Range>()
.constraint(Range::from, "from") { it.greaterThan(0) }
.constraint(Range::to, "to") { it.greaterThan(0) }
.constraintOnTarget({ it.to > it.from }, "to", "to.isGreaterThanFrom", "\"to\" must be greater than \"from\"")
.build()
violation messages
違反メッセージは以下のように上書きすることが可能です。
Validator<User> validator = ValidatorBuilder.<User> of()
.constraint(User::getName, "name", c -> c.notNull().message("{0} is required!")
.greaterThanOrEqual(1).message("{0} is too small!")
.lessThanOrEqual(20).message("{0} is too large!"))
.build()
また、以下のようにMessageFormatterインターフェイスを実装したカスタムクラスを用意することで違反メッセージの言語切り替えのようなカスタムをすることもできる。
object ResourceBundleMessageFormatter : MessageFormatter {
override fun format(
messageKey: String,
defaultMessageFormat: String,
args: Array<out Any>,
locale: Locale
): String {
val resourceBundle = ResourceBundle.getBundle("messages", locale)
val format = try {
resourceBundle.getString(messageKey)
} catch (e: MissingResourceException) {
defaultMessageFormat
}
return MessageFormat(format, locale).format(args)
}
}
上記の例はmessage.propertyのメッセージ内容を違反メッセージとして表示させている。
object.notNull=test {0} require!
object.notNull=test {0} 必須です!
test("test en message") {
val user = User(null, "user", "test@test.com")
val violations = userMessageValidator.validate(user, Locale.ENGLISH)
violations.isValid shouldBe false
violations[0].message() shouldBe "test id require!"
}
test("test ja message") {
val user = User(null, "user", "test@test.com")
val violations = userMessageValidator.validate(user, Locale.JAPAN)
violations.isValid shouldBe false
violations[0].message() shouldBe "test id 必須です!!"
}
上記のようにvalidate()の第二引数にLocaleを指定することで違反メッセージを切り替えることが可能。
Fail fast mode
YAVIのデフォルトの動作としては途中で違反があったとしても全ての検証を実施する。この動作を変え、違反があったらそれ以降の検証をやめるためには以下のようにfail fastモードを指定する。
Validator<User> validator = ValidatorBuilder.<User> of()
.constraint(User::getName, "name", c -> c.notNull().lessThanOrEqual(20))
.constraint(User::getEmail, "email", c -> c.notNull().greaterThanOrEqual(5).lessThanOrEqual(50).email())
.constraint(User::getAge, "age", c -> c.notNull().greaterThanOrEqual(0).lessThanOrEqual(200))
.failFast(true) // <-- Enable the fail fast mode
.build();
Kotlin Support
YAVIはJava製のライブラリであるがKotlinサポートに対応しており、Kotlin DSLで記述することもできる。
前述したBookクラスのvalidatorはDSLを使用し以下のようにも書ける。
val bookValidator = ValidatorBuilder.of<Book>()
.constraint(Book::title, "title") { it.notBlank().lessThanOrEqual(64) }
.constraint(Book::isbn, "isbn") { it.notBlank().predicate(IsbnConstraint) }
.build()
val bookValidatorKt = validator<Book> {
Book::title {
notBlank()
lessThanOrEqual(64)
}
Book::isbn {
notBlank()
predicate(IsbnConstraint)
}
}
どちらでも動作するので好きな方で書ける。
まとめ
今回はJava製のバリデーションツールであるYAVIの基本的な使い方を紹介しました。validatorの実装が非常に簡単に書け、書き心地がすごいよかったです。
導入自体も非常に簡単にできるので機会があれば積極的に使っていきたいなと思いました!まだ使ったことないという方はどのくらいヤバイかを使ってみてください!