kotlin-validation
KotlinのためのタイプセーフでDSLベースのバリデーションライブラリ
概要
kotlin-validationは、Kotlinのための表現力豊かでタイプセーフなバリデーションDSLを提供するライブラリです。このライブラリは、ドメインクラスをバリデーションロジックから分離し、クリーンアーキテクチャの原則に従った設計を可能にします。
主な特徴
- タイプセーフなDSL: Kotlinの型システムを活用した安全なバリデーション定義
- 流暢なAPI: 読みやすく、書きやすいメソッドチェーン
- カスタムバリデータ: 独自のバリデーションルールを簡単に追加
- エラーメッセージのカスタマイズ: 詳細なエラー情報の提供
- 複合バリデーション: 複数のプロパティを跨いだバリデーション
- 非同期バリデーション: suspendファンクションを使用した非同期検証
インストール
Gradle (Kotlin DSL)
dependencies {
implementation("ch.fhnw:kotlin-validation:1.0.0")
}
Gradle (Groovy)
dependencies {
implementation 'ch.fhnw:kotlin-validation:1.0.0'
}
Maven
<dependency>
<groupId>ch.fhnw</groupId>
<artifactId>kotlin-validation</artifactId>
<version>1.0.0</version>
</dependency>
基本的な使い方
シンプルなバリデーション
data class User(
val name: String,
val email: String,
val age: Int
)
val userValidator = validator<User> {
User::name {
notBlank()
minLength(3)
maxLength(50)
}
User::email {
notBlank()
email()
}
User::age {
min(18)
max(120)
}
}
// バリデーションの実行
val user = User("太郎", "[email protected]", 25)
val result = userValidator.validate(user)
if (result.isValid) {
println("バリデーション成功")
} else {
result.errors.forEach { error ->
println("${error.property}: ${error.message}")
}
}
バリデータクラスの作成
class UserValidator : Validator<User>() {
init {
rules {
property(User::name) {
notBlank() message "名前は必須です"
minLength(3) message "名前は3文字以上で入力してください"
pattern("^[ぁ-んァ-ヶー一-龠]+$") message "名前は日本語で入力してください"
}
property(User::email) {
notBlank() message "メールアドレスは必須です"
email() message "有効なメールアドレスを入力してください"
}
property(User::age) {
notNull() message "年齢は必須です"
min(18) message "18歳以上である必要があります"
max(120) message "年齢が不正です"
}
}
}
}
バリデーションルール
文字列バリデーション
validator<Product> {
Product::name {
notBlank() // 空白文字のみでないことを検証
notEmpty() // 空でないことを検証
minLength(5) // 最小文字数
maxLength(100) // 最大文字数
pattern("[A-Za-z0-9]+") // 正規表現パターン
alphanumeric() // 英数字のみ
alphabetic() // アルファベットのみ
numeric() // 数字のみ
email() // メールアドレス形式
url() // URL形式
creditCard() // クレジットカード番号形式
phoneNumber() // 電話番号形式
}
}
数値バリデーション
validator<Order> {
Order::quantity {
min(1) // 最小値
max(100) // 最大値
between(1, 100) // 範囲内
positive() // 正の数
negative() // 負の数
nonNegative() // 0以上
nonPositive() // 0以下
multipleOf(5) // 倍数
}
Order::price {
min(0.01)
scale(2) // 小数点以下の桁数
precision(10) // 全体の桁数
}
}
コレクションバリデーション
validator<ShoppingCart> {
ShoppingCart::items {
notEmpty() // 空でない
minSize(1) // 最小要素数
maxSize(10) // 最大要素数
unique() // 重複なし
each { // 各要素に対するバリデーション
CartItem::productId {
notNull()
positive()
}
CartItem::quantity {
between(1, 99)
}
}
}
}
日付・時刻バリデーション
validator<Event> {
Event::startDate {
notNull()
future() // 未来の日付
past() // 過去の日付
after(LocalDate.now()) // 特定日付より後
before(endDate) // 特定日付より前
between(start, end) // 期間内
}
Event::duration {
min(Duration.ofMinutes(30))
max(Duration.ofHours(8))
}
}
カスタムバリデータ
シンプルなカスタムバリデータ
fun ValidationRules<String>.hiragana() = addRule { value ->
if (value.matches(Regex("^[ぁ-ん]+$"))) {
Valid
} else {
Invalid("ひらがなで入力してください")
}
}
// 使用例
validator<Person> {
Person::furigana {
notBlank()
hiragana()
}
}
パラメータ付きカスタムバリデータ
fun <T : Comparable<T>> ValidationRules<T>.between(
min: T,
max: T
) = addRule { value ->
when {
value < min -> Invalid("$min 以上の値を入力してください")
value > max -> Invalid("$max 以下の値を入力してください")
else -> Valid
}
}
非同期カスタムバリデータ
suspend fun ValidationRules<String>.uniqueEmail(
userRepository: UserRepository
) = addAsyncRule { email ->
val exists = userRepository.existsByEmail(email)
if (exists) {
Invalid("このメールアドレスは既に使用されています")
} else {
Valid
}
}
高度な使い方
条件付きバリデーション
validator<User> {
User::phoneNumber {
// 国コードが日本の場合のみ検証
whenValue { user -> user.countryCode == "JP" } then {
pattern("^0[0-9]{9,10}$")
}
}
User::postalCode {
// アドレスが入力されている場合は必須
when { user -> user.address.isNotBlank() } then {
notBlank()
pattern("^[0-9]{3}-[0-9]{4}$")
}
}
}
複合バリデーション
validator<PasswordChangeRequest> {
// 複数のプロパティを跨いだバリデーション
crossValidation {
val password = it.newPassword
val confirmation = it.passwordConfirmation
if (password != confirmation) {
addError("passwordConfirmation", "パスワードが一致しません")
}
if (password == it.currentPassword) {
addError("newPassword", "新しいパスワードは現在のパスワードと異なる必要があります")
}
}
}
ネストされたオブジェクトのバリデーション
data class Address(
val street: String,
val city: String,
val postalCode: String
)
data class Company(
val name: String,
val address: Address,
val employees: List<Employee>
)
val companyValidator = validator<Company> {
Company::name {
notBlank()
maxLength(100)
}
// ネストされたオブジェクトのバリデーション
Company::address validates addressValidator
// コレクション内の各要素のバリデーション
Company::employees {
notEmpty()
each validates employeeValidator
}
}
グループバリデーション
interface Create
interface Update
validator<User> {
User::id {
onGroup<Update> {
notNull()
positive()
}
}
User::email {
notBlank()
email()
onGroup<Create> {
uniqueEmail(userRepository)
}
}
User::password {
onGroup<Create> {
notBlank()
minLength(8)
strongPassword()
}
}
}
// グループを指定してバリデーション実行
val createResult = userValidator.validate(user, Create::class)
val updateResult = userValidator.validate(user, Update::class)
エラーハンドリング
カスタムエラーメッセージ
validator<Product> {
Product::price {
min(0) message "価格は0円以上で入力してください"
max(1_000_000) message { value ->
"価格は100万円以下で入力してください(入力値: ${value}円)"
}
}
}
エラーの集約
val result = validator.validate(data)
// すべてのエラーを取得
val allErrors = result.errors
// プロパティごとのエラーを取得
val nameErrors = result.errorsFor("name")
// エラーメッセージのマップを作成
val errorMap = result.errors
.groupBy { it.property }
.mapValues { entry ->
entry.value.map { it.message }
}
例外としてのエラー
// バリデーションエラーを例外として投げる
try {
validator.validateOrThrow(user)
} catch (e: ValidationException) {
e.errors.forEach { error ->
logger.error("Validation failed: ${error.property} - ${error.message}")
}
}
Spring Frameworkとの統合
@RestController
@RequestMapping("/api/users")
class UserController(
private val userValidator: Validator<User>
) {
@PostMapping
fun createUser(@RequestBody user: User): ResponseEntity<*> {
val result = userValidator.validate(user)
if (!result.isValid) {
return ResponseEntity.badRequest().body(
mapOf("errors" to result.errors)
)
}
// ユーザー作成処理
return ResponseEntity.ok(user)
}
}
// バリデータをBeanとして登録
@Configuration
class ValidatorConfig {
@Bean
fun userValidator() = validator<User> {
// バリデーションルールの定義
}
}
テスト
class UserValidatorTest {
private val validator = UserValidator()
@Test
fun `有効なユーザーデータの検証`() {
val user = User(
name = "山田太郎",
email = "[email protected]",
age = 30
)
val result = validator.validate(user)
assertTrue(result.isValid)
assertTrue(result.errors.isEmpty())
}
@Test
fun `無効なメールアドレスの検証`() {
val user = User(
name = "山田太郎",
email = "invalid-email",
age = 30
)
val result = validator.validate(user)
assertFalse(result.isValid)
assertEquals(1, result.errors.size)
assertEquals("email", result.errors[0].property)
assertEquals("有効なメールアドレスを入力してください", result.errors[0].message)
}
}
パフォーマンス最適化
// バリデータのキャッシュ
object ValidatorCache {
private val cache = ConcurrentHashMap<KClass<*>, Validator<*>>()
@Suppress("UNCHECKED_CAST")
fun <T : Any> getValidator(klass: KClass<T>): Validator<T> {
return cache.getOrPut(klass) {
createValidator(klass)
} as Validator<T>
}
}
// 遅延評価
validator<Order> {
Order::items {
// 大量のアイテムがある場合、遅延評価で効率化
lazy {
notEmpty()
maxSize(1000)
}
}
}
ベストプラクティス
- バリデータの再利用: 同じ型に対するバリデータは一度作成して再利用する
- 適切なエラーメッセージ: ユーザーが理解しやすい具体的なメッセージを提供
- 早期リターン: 最初のエラーで停止するオプションを活用してパフォーマンスを向上
- テスタビリティ: バリデーションロジックを独立したクラスに分離してテストしやすくする
- 国際化対応: エラーメッセージの多言語対応を考慮した設計
// エラーメッセージの国際化
validator<User> {
User::name {
notBlank() message {
i18n("validation.user.name.required")
}
minLength(3) message {
i18n("validation.user.name.minLength", 3)
}
}
}