【Kotlin】アノテーションを自作する【SpringBoot】

この記事は[MicroAd Advent Calendar](https://qiita.com/advent-calendar/2018/microad)の7日目の記事です。

## TL;DR
Kotlinでバリデーション用のアノテーションを自作します。

## 前書き
この記事は以下の記事の続きです。コントローラーやモデルは前回の状況を引き続き利用しています。

- [【Kotlin】バリデーションの書き方の基礎【SpringBoot】](https://qiita.com/wrongwrong/items/8120faeb9f7498f925c5)

以下の記事を元に書いています。

- [【Kotlin】バリデーション用のアノテーションを自作する【SpringBoot】 \- wrongwrongな開発日記](https://wrongwrong163377.hatenablog.com/entry/2018/12/03/191623)

## アノテーションの自作
大概のバリデーションは既存のアノテーションを利用して解決でき、そこから漏れる内容もAssertTrueを使えば実装できるので、余程理由が無ければ自作する必要は無いと個人的に思ってますが、せっかくの機会なので1つの内容を処理するアノテーションと、相関チェックを行うアノテーションの両方を作ります。

### 1つの内容を処理するアノテーション
文字列が半角スペースで2分割できるかどうかをチェックする[^pattern]`@CanSplitBySpace`アノテーションを作成します。
[^pattern]: これは既存のPatternアノテーションで実装が可能です。今回はサンプルとして自力で全て実装しています。
作成は以下の順で行います。

1. アノテーションクラスを用意する
2. バリデーターを作る
3. 付与して使う

#### アノテーションクラスを用意する
`annotation class`は、Javaでは`@interface`に当たります。
完成したアノテーションクラスは以下の通りです。

```kotlin:CanSplitBySpace
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [CanSplitBySpaceValidator::class])
annotation class CanSplitBySpace(
        val message: String = "message",
        val groups: Array<KClass<out Any>> = [],
        val payload: Array<KClass<out Payload>> = []
)
```
付与しているアノテーション関連は大体Javaと同じです。個々のアノテーションの説明は長くなるので省略します。
Javaとの相違点としては、`@Target`や`@Retention`辺りがKotlin独自のクラスになっている点と、`message`や`groups`等の指定の方法が異なる点があります。
また、後述しますが、Kotlinで作成したアノテーションはfield:を指定しなくても正常に動作します。
`@Constraint(validatedBy = [CanSplitBySpaceValidator::class])`に指定しているクラスは、次に実装するバリデーター本体です。
今回はフィールド以外に付与するつもりが無かったので、`@Target`には`AnnotationTarget.FIELD`のみを指定しています。

#### バリデーターを作る
完成したバリデーターは以下の通りです。

```kotlin:CanSplitSpaceValidator
class CanSplitBySpaceValidator: ConstraintValidator<CanSplitBySpace, String>{
    override fun initialize(constraintAnnotation: CanSplitBySpace) {}

    override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
        //nullなら何もしない
        if(value == null) return true
        //スペースで2分割できなければいけない
        return value.split(" ").size == 2
    }
}
```
こちらはJavaとそう変わりありません。`isValid`内の実装がバリデーション時に呼び出される部分です。
`value`にはバリデーション対象が入ります。
`ConstraintValidator`に指定しているのは、作成したアノテーションと、アノテーションが処理する対象のクラスです。

#### ここまでを合わせて
ここまで作ったアノテーションクラスとバリデーターを合わせたものが以下です。
Kotlinは1ファイルに複数クラスを書けるので、ファイル数を減らせるのはいいですね。

```kotlin:CanSplitBySpace.kt
import javax.validation.*
import kotlin.reflect.KClass

//アノテーション
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [CanSplitBySpaceValidator::class])
annotation class CanSplitBySpace(
        val message: String = "message",
        val groups: Array<KClass<out Any>> = [],
        val payload: Array<KClass<out Payload>> = []
)

//バリデーター本体
class CanSplitBySpaceValidator: ConstraintValidator<CanSplitBySpace, String>{
    override fun initialize(constraintAnnotation: CanSplitBySpace) {}

    override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
        //nullなら何もしない
        if(value == null) return true
        //スペースで2分割できなければいけない
        return value.split(" ").size == 2
    }
}
```

#### 付与して使う
前述の通り、`field:`を付ける必要はありません（付けても問題なく動きます）。

```kotlin:MyModel.kt
//import com.wrongwrong.modeltest.annotation.CanSplitBySpace
import java.util.*
import javax.validation.constraints.AssertTrue
import javax.validation.constraints.NotNull

data class MyModel(
        @field:NotNull(message = "idはnull不許可")
        val id: Long?,
        @CanSplitBySpace(message = "名前が半角スペースで2つに分割できない")
        val name: String?,
        val create: Date?,
        val update: Date?
) {
    @AssertTrue(message = "updateがcreateより過去")
    fun isLater(): Boolean {
        if(create == null || update == null) return true
        return create.before(update) || create == update
    }
}
```
```bash:エラー有り
$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name":"wrongwrong", "create":"2018-11-01", "update":"2018-11-02"}' localhost:8080/my
[Field error in object 'myModel' on field 'name': rejected value [wrongwrong]; codes [CanSplitBySpace.myModel.name,CanSplitBySpace.name,CanSplitBySpace.java.lang.String,CanSplitBySpace]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [myModel.name,name]; arguments []; default message [name]]; default message [名前が半角スペースで2つに分割できない]]
```

### 相関チェックを行うアノテーション
2つの時間を比較し、片方がもう片方よりも過去であることをチェックする`@IsLater`アノテーションを作り、AssertTrueのメソッドを置き換えます。
こちらも1つの内容を処理するアノテーション同様以下の3ステップで作成します。

1. アノテーションクラスを用意する
2. バリデーターを作る
3. 付与して使う

#### アノテーションクラスを用意する
完成したアノテーションクラスは以下の通りです。

```kotlin:IsLater
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [IsLaterValidator::class])
annotation class IsLater(
        val message: String = "message",
        val groups: Array<KClass<out Any>> = [],
        val payload: Array<KClass<out Payload>> = [],
        val before: String,
        val after: String
)
```
今回は付与対象がクラスなので、`@Target`には`AnnotationTarget.CLASS`を指定しています。
また、クラスの引数に`before`と`after`という2つが増えていますが、これはバリデーション対象のクラスが比較対象としたい2つのフィールドの名前です。

#### バリデーターを作る
完成したバリデーターは以下の通りです。

```kotlin:IsLaterValidator
class IsLaterValidator: ConstraintValidator<IsLater, Any> {
    lateinit var beforeName: String
    lateinit var afterName: String

    override fun initialize(constraintAnnotation: IsLater) {
        beforeName = constraintAnnotation.before
        afterName = constraintAnnotation.after
    }

    override fun isValid(value: Any?, context: ConstraintValidatorContext?): Boolean {
        //valueがnullなら何もしない
        if(value == null) return true
        val beanWrapper = BeanWrapperImpl(value)

        //入力から値を取り出す
        val before = beanWrapper.getPropertyValue(beforeName) as Date?
        val after = beanWrapper.getPropertyValue(afterName) as Date?

        //比較結果を返却
        if(before == null || after == null) return true
        return before.before(after) || before == after
    }
}
```
1つの内容を処理するアノテーションと比べると、以下の点が異なっています。

* `ConstraintValidator`と`isValid`に取る`value`が`Any`となっている
* `initialize`で受け取った値を元に`BeanWrapper`を使って`value`から値を取り出している

これは対象がクラスであることによる違いです。
入力となる型にどのようなフィールドが含まれているのかは分からないので、使い手側でフィールド名を指定し、取り出しています。

#### ここまでを合わせて
ここまで作ったアノテーションクラスとバリデーターを合わせたものが以下です。

```kotlin:IsLater.kt
package com.wrongwrong.modeltest.annotation

import org.springframework.beans.BeanWrapperImpl
import java.util.*
import javax.validation.*
import kotlin.reflect.KClass

//アノテーション
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ReportAsSingleViolation
@Constraint(validatedBy = [IsLaterValidator::class])
annotation class IsLater(
        val message: String = "message",
        val groups: Array<KClass<out Any>> = [],
        val payload: Array<KClass<out Payload>> = [],
        val before: String,
        val after: String
)

//バリデーター本体
class IsLaterValidator: ConstraintValidator<IsLater, Any> {
    lateinit var beforeName: String
    lateinit var afterName: String

    override fun initialize(constraintAnnotation: IsLater) {
        beforeName = constraintAnnotation.before
        afterName = constraintAnnotation.after
    }

    override fun isValid(value: Any?, context: ConstraintValidatorContext?): Boolean {
        //valueがnullなら何もしない
        if(value == null) return true
        val beanWrapper = BeanWrapperImpl(value)

        //入力から値を取り出す
        val before = beanWrapper.getPropertyValue(beforeName) as Date?
        val after = beanWrapper.getPropertyValue(afterName) as Date?

        //比較結果を返却
        if(before == null || after == null) return true
        return before.before(after) || before == after
    }
}
```

#### 付与して使う
AssertTrueのメソッドを置き換えたのが以下です。

```kotlin:MyModel.kt
//import com.wrongwrong.modeltest.annotation.CanSplitBySpace
//import com.wrongwrong.modeltest.annotation.IsLater
import java.util.*
import javax.validation.constraints.NotNull

@IsLater(before = "create", after = "update", message = "updateがcreateより過去")
data class MyModel(
        @field:NotNull(message = "idはnull不許可")
        val id: Long?,
        @CanSplitBySpace(message = "名前が半角スペースで2つに分割できない")
        val name: String?,
        val create: Date?,
        val update: Date?
)
```
`before`として`create`を、`after`として`update`を指定しています。

```bash:エラーあり
$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "name":" ", "create":"2018-11-03", "update":"2018-11-02"}' localhost:8080/my
[Error in object 'myModel': codes [IsLater.myModel,IsLater]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [myModel.,]; arguments []; default message [],org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@5d7a875b,org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@731471]; default message [updateがcreateより過去]]
```

## 後書き
この記事含めKotlinについて2記事を投稿しましたが、MicroAdではKotlinをまだ使ってません。
これと次の記事のために丸2日も業務外のことをやらせて頂きありがとうございました（ごめんなさい、反省してます。。。）。

## 参考にさせていただいた記事
- [入力チェック：複数項目の相関チェックをするアノテーションの作り方　STS ＋Spring Boot＋thymeleaf \- アラカン"BOKU"のITな日常](https://arakan-pgm-ai.hatenablog.com/entry/2017/04/08/003859)
- [Custom Bean Validation \(JSR\-303\) Annotations \- Kotlin Discussions](https://discuss.kotlinlang.org/t/custom-bean-validation-jsr-303-annotations/6743)
- [Spring書き込み編\.独自のバリデーションを作って入力チェックをする。 \- Qiita](https://qiita.com/shibafu/items/104e920ad87fe9763b45)
- [型に汎用的なアノテーションを作成したい \- Qiita](https://qiita.com/neriudon/items/f92ea4388fe7f19cc68c)