2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Kotlinの入力バリデーションについての試論

Last updated at Posted at 2018-08-13

入力バリデーション

Javaだと入力バリデーションはBean Validationを使うことになることが多いとは思う。ただ、単項目検証にはいいけど、相関チェックになると面倒だったり、変なプロパティ定義したりしなくて、使い勝手はあまりよくない。

意外と簡単な部品をつくってFWやらに依存しないで入力検証をやったほうがよいのではないかと思う。

こんなのも
Creating a Kotlin DSL for validation
KotlinでValidationライブラリを作ってみた

柔軟性だったり、再利用可能な部品つくれるようにしたいな、と思いちと作ってみた。

Kotlin-ValidationSpec

使い方

最初にValidation対象のクラスごとにチェック仕様を定義する。

ValidatorSample.kt

//チェック対象のクラス
data class SampleUser(val id: Int = 0, val name: String = "", val password: String = "", val confirmPassword: String = "")

//チェック仕様の定義 defineSpecs関数を呼び出す。「validatorSpec」から変更
val sampleValidationSpec = defineSpecs<SampleUser> {
 
    //一番シンプルなやり方。T.()->Booleanの関数を渡すだけ。エラーメッセージはデフォルト
    shouldBe { id > 0 }

    //入力仕様に名前をつけるには、最初に文字列を渡す。独自定義のエラーメッセージはshouldBeメソッドの戻り値からerrorMessageメソッドを呼び出す。エラーメッセージも「T.()-String」型のラムダなので、対象クラスのプロパティが呼べる
    shouldBe("id max spec") { id < 100 }.errorMessage { "id $id is invalid. should be less than 100" }

    //フィールド名を指定できる。指定した効果は、エラーオブジェクトにフィールド名がつくだけ。メッセージをごにょごにょするときに使う
    fieldNames("password") {
        shouldBe("password not blank") { password.isNotBlank() }
        shouldBe("password length range") { password.length in 10..15 }
    }

    //相関チェック
    fieldNames("password", "confirmPassword") {
        shouldBe("password confirmPassword same") { password == confirmPassword }
    }


}

対象クラスのインスタンスをバリデーションする

ValidatorSample.kt


fun main(args: Array<String>) {
    val sampleUser = SampleUser()

    //全てのチェック仕様を実行してチェック結果を取得する
    val result: ValidationErrors = sampleValidationSpec.validateAll(sampleUser)
    println(result)
//    ValidationErrors(errors=[
//        ValidationError(specName=, errorMessage=validation failed, fieldNames=[])
//        , ValidationError(specName=password not blank, errorMessage=validation failed, fieldNames=[password])
//        , ValidationError(specName=password length range, errorMessage=validation failed, fieldNames=[password])
//        , ValidationError(specName=com.deffence1776.validationSpec.specs.ShouldNotBeBlank, errorMessage=NAME should not be blank., fieldNames=[name])
//        , ValidationError(specName=name length check, errorMessage=NAME should be in range 1..10., fieldNames=[name])])
//


    //チェックエラーがあれば、やめる場合はvalidateUntilFirstメソッドを使う
    val result2: ValidationErrors = sampleValidationSpec.validateUntilFirst(sampleUser)
    println(result2)
//    ValidationErrors(errors=[ValidationError(specName=, errorMessage=validation failed, fieldNames=[])])
}

サンプル

スペックの再利用

confirmメソッドに対象フィールドとフィールドを検証するスペックを渡せばネストさせて検証できる。
Specオブジェクトの代わりにこれにした。

Sample.kt

//String型のスペックを定義
val strForNumberSpec = defineSpecs<String> {
    shouldBe { this.isNotBlank() }
    shouldBe { this.toIntOrNull() != null }
}

//もし、柔軟性が欲しければ関数にしてもよい。スペック関数と呼ぶ
fun shouldBeGreaterThan(fieldName: String, greaterThan: Int) = defineSpecs<Int> {
    shouldBe { this > greaterThan }.errorMessage { "$fieldName should be greater than $greaterThan" }
}

//作成したスペックはconfirmメソッドで再利用可能
data class SampleModel(val id: Int = 0, val numStr: String = "")

val sampleModelSpec = defineSpecs<SampleModel> {
    fieldNames("id") {
        confirm({ id }, shouldBeGreaterThan("ID", 0))

        //if you want use target's propeties for parameter add block
       // confirm({ id }, {shouldBeGreaterThan("ID", 0)})
    }

    fieldNames("numStr") {
        confirm("strForNumberSpec rule",{ numStr }, strForNumberSpec)
    }
}

複雑なクラスの例

//バリューオブジェクトの定義
class UserId(private val value: String) {
   companion object {
       //プライベートフィールドにアクセスできるようにcompanion objectでスペックを定義
       val spec = defineSpecs<UserId> {
           shouldBe("user id length rule") { value.length == 5 }.errorMessage { "user id's length should be 5" }
       }
   }
}


//バリューオブジェクトの定義
class UserName(private val value: String) {
   companion object {
       val spec = defineSpecs<UserName> {
           shouldBe("user name length rule") { value.length in 1..10 }.errorMessage { "username's length should be in 1..10" }
       }
   }
}

//イミュータブルな Entity
class User(private val userId: UserId, private val userName: UserName) {
   init{
       //アサーションにも便利
       assert(spec.isValid(this))
   }
   
   companion object {
       //define spec for innerValue
       val spec = defineSpecs<User> {
           fieldNames("userId") {
               confirm({ userId }, UserId.spec )
           }

           fieldNames("userName") {
               confirm({ userName }, UserName.spec)
           }
       }
   }
   
   //スペックを直接つかうのではなく、クラスにバリデーションを実装してもよい 
   fun validate()=spec.validateAll(this)
}

fun main(args: Array<String>) {

   val user = User(UserId("abc"), UserName("12345678901"))
   val result = User.spec.validateAll(user)
   //or 
   //val result = user.validate()
   
   println(result)

}

スペックによるデータの定義

バリューオブジェクトはフレームワークや外部連携時に多少面倒。プリミティブ型でエンティティやフォームをつくるときに、データの定義域をスペックを利用して定義できる。

//プリミティブ型のスペック定義.スペックに一意の名前がつく
val applicationItemIdSpec = defineSpecs<String> {
   shouldBe { this.isNotBlank() }
   shouldBe { this.toIntOrNull() != null }
}

//定義したスペックをエンティティやフォームなどの様々なクラスに利用する

//Entity
class Item(val itemId:String){
   companion object {
       val spec= defineSpecs<Item> {
           //このコードをみると、プログラマは値の定義域が分かる。
           confirm({itemId}, applicationItemIdSpec)
       }
   }
   init{
       //アサーションつけとくと便利
       assert(spec.isValid(this)){"doesn't satisfy the spec:\n"+ spec.validateAll(this)}
   }
}

//Form
class RegisterForm(){
   var itemId=""
       set(value){
           //assert
           assert(spec.isValid(this)){"doesn't satisfy the spec:\n"+ spec.validateAll(this)}
           field = value
       }

   companion object {
       val spec= defineSpecs<RegisterForm> {
           //別のクラスのSring型でもスペックをつけると、値の種類が分かる。ソースコードの可読性、理解性が上がる(気がする)
           confirm({itemId}, applicationItemIdSpec)
       }
   }
}

# Spec オブジェクト
FieldValidationSpecクラスを継承したら作れる。
非推奨とした。

Sample.kt

open class ShouldBeGreaterThan<T>
   //specメソッドに渡すパラメータとして、コンストラクタを定義
   (targetFun: T.()->Int, //チェック対象のフィールドを取得するためのラムダ。戻り値の型はチェック対象フィールドの型
    fieldNameInMessage: String,//あとはチェックロジックやメッセージに必要なパラメータを付ける
        greaterThan: Int
    )
    : FieldValidationSpec<T, Int>(
        "com.deffence1776.validationSpec.specs.ShouldBeGreaterThan" //チェック結果を解析しやすいように名前をつける。JVMに依存しないようにリフレクションは使わなかった。
        ,targetFun //サブクラスから直接渡す
        , { field-> field > greaterThan },//チェックロジックを実装したラムダ「targetFun」の戻り値を引数、戻り値はBooleanにする
        { "$fieldNameInMessage should be greater than $greaterThan." }//メッセージ。サブクラスから受け取った値も使える
)

他の例とかはこちら

使い方はテストコード参照

性能

最初はリフレクション使ってたけど、やはりそれなりに遅くなっていたので使わないようにした。
hibernate validationと比較してもそこそこ早い

テストコード

チェックロジックがシンプルならそんなに心配することはなさそう。

2
3
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?