HackToTech

Hack To Technology

SpringBoot + KotlinでListの要素にConstraintsをかけたかった話

tl;dr コンパイラオプションに -Xemit-jvm-type-annotationsを指定する必要がある
あとの文章は個人的なメモ

リストの要素にConstraintsをかけたかったが、
なんか一見正しく見えるのに正しく動かなかった

package com.example.exampleboot3

import org.hibernate.validator.constraints.Length

data class Request(val values: List<@Length(min = 1) String>)
package com.example.exampleboot3

import jakarta.validation.Valid
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@RestController("/")
class SampleController {
    @PostMapping
    fun post(@RequestBody @Valid r: Request): String {
        return "ok"
    }
}

そのまま投げると、通ってしまう

curl -XPOST localhost:8080 -H "Content-Type: application/json" -d '{"values": ["test", ""] }'
ok 

でまあ、公式を見て -Xemit-jvm-type-annotations をコンパイルオプションに付与すればいいという話だった

kotlinlang.org

# build.gradle.kts
tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf(
            "-Xjsr305=strict",
            "-Xemit-jvm-type-annotations",
        )
        jvmTarget = "17"
    }
}

で、期待した通りに動くようにはなった

curl -XPOST localhost:8080 -H "Content-Type: application/json" -d '{"values": ["test", ""] }'
{"timestamp":"2023-10-28T17:51:52.475+00:00","status":400,"error":"Bad Request","path":"/"}

で、なんでそもそもJava8以降をターゲットとしてコンパイルする必要があるんだっけと思ったら

JSR 308 Explained: Java Type Annotations

JSR 308, Annotations on Java Types, has been incorporated as part of Java SE 8. This JSR builds upon the existing annotation framework, allowing type annotations to become part of the language. Beginning in Java SE 8, annotations can be applied to types in addition to all of their existing uses within Java declarations. This means annotations can now be applied anywhere a type is specified, including during class instance creation, type casting, the implementation of interfaces, and the specification of throws clauses. This allows developers to apply the benefits of annotations in even more places.

そもそもJSR308がJava8からの話だった(この辺よく理解してなかった)
1.8 よりも前を指定したらコンパイルコケるのか試したかったが、 1.6 指定したら Unknown Kotlin JVM target: 1.6 とか言われたので、古い環境用意するのも面倒になって諦めた

最近の環境ならデフォルトで有効でも良いんじゃないか?と思ってissueを眺めてたが、
基本的な使い方をする分には困らないけど、反変/共変での射影とかまだ色々と問題を抱えているから各自で有効にしてくれという話だと理解した https://youtrack.jetbrains.com/issue/KT-35843

There are a bunch of open questions that should be carefully discussed and full support of type annotations in JVM bytecode can't be fully implemented without discussing them.

でまあ一応中身の違いがどうなっているのかも気になったので、オプションの無効/有効で生成したclassファイルもみてみた

無効

public com.example.exampleboot3.Request(java.util.List<java.lang.String>);
descriptor: (Ljava/util/List;)V
flags: (0x0001) ACC_PUBLIC
Code:
  stack=2, locals=2, args_size=2
     0: aload_1
     1: ldc           #10                 // String values
     3: invokestatic  #16                 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
     6: aload_0
     7: invokespecial #19                 // Method java/lang/Object."<init>":()V
    10: aload_0
    11: aload_1
    12: putfield      #22                 // Field values:Ljava/util/List;
    15: return
  LineNumberTable:
    line 5: 6
  LocalVariableTable:
    Start  Length  Slot  Name   Signature
        0      16     0  this   Lcom/example/exampleboot3/Request;
        0      16     1 values   Ljava/util/List;
Signature: #7                           // (Ljava/util/List<Ljava/lang/String;>;)V
RuntimeInvisibleParameterAnnotations:
  parameter 0:
    0: #9()
      org.jetbrains.annotations.NotNull
MethodParameters:
  Name                           Flags
  values

有効

public com.example.exampleboot3.Request(java.util.List<java.lang.String>);
descriptor: (Ljava/util/List;)V
flags: (0x0001) ACC_PUBLIC
Code:
  stack=2, locals=2, args_size=2
     0: aload_1
     1: ldc           #13                 // String values
     3: invokestatic  #19                 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
     6: aload_0
     7: invokespecial #22                 // Method java/lang/Object."<init>":()V
    10: aload_0
    11: aload_1
    12: putfield      #25                 // Field values:Ljava/util/List;
    15: return
  LineNumberTable:
    line 5: 6
  LocalVariableTable:
    Start  Length  Slot  Name   Signature
        0      16     0  this   Lcom/example/exampleboot3/Request;
        0      16     1 values   Ljava/util/List;
Signature: #7                           // (Ljava/util/List<Ljava/lang/String;>;)V
RuntimeVisibleTypeAnnotations:
  0: #9(#10=I#11): METHOD_FORMAL_PARAMETER, param_index=0, location=[TYPE_ARGUMENT(0)]
    org.hibernate.validator.constraints.Length(
      min=1
    )
RuntimeInvisibleParameterAnnotations:
  parameter 0:
    0: #12()
      org.jetbrains.annotations.NotNull
MethodParameters:
  Name                           Flags
  values

RuntimeVisibleTypeAnnotations を見れば分かる通り、オプションによって有無の違いが見て取れる

docs.oracle.com

-Xmsと-Xmxを指定しても必ずしもすぐに物理メモリ上で確保されるわけではないという話

最初から確保されるものだと思い込んでいたら、
そういうわけではなかったので自分用に備忘録

理由はStackOverflowで解説されているので、それを読むのが良い stackoverflow.com

どうやらAlwaysPreTouchを指定すると(その分起動は遅くなるが)、
JVMの初期化時に強制的にマッピングされるらしいのでSpringのdemoアプリケーションで試した

AlwaysPreTouchあり

./gradlew bootRun -PjvmArgs="-Xms2g -Xmx2g -XX:+AlwaysPreTouch"

> Task :bootRun

2023-09-24 01:03:59.352  INFO 8070 --- [           main] com.example.demo.DemoApplicationKt       : Starting DemoApplicationKt using Java 11.0.20 on local with PID 8070 (/home/atr0phy/IdeaProjects/demo/build/classes/kotlin/main started by atr0phy in /home/atr0phy/IdeaProjects/demo)
<==========---> 83% EXECUTING [1m 33s]

AlwaysPreTouchなし

./gradlew bootRun -PjvmArgs="-Xms2g -Xmx2g"

> Task :bootRun

2023-09-24 01:05:51.778  INFO 8208 --- [           main] com.example.demo.DemoApplicationKt       : Starting DemoApplicationKt using Java 11.0.20 on local with PID 8208 (/home/atr0phy/IdeaProjects/demo/build/classes/kotlin/main started by atr0phy in /home/atr0phy/IdeaProjects/demo)
<==========---> 83% EXECUTING [3m 32s]

違いはRESを見れば分かる通り、2.2GBと284MBになっている

個人的にはXmsとXmxを同じ値で指定することが多いが、一応↓見た感じXmsのサイズが確保されるらしいとのことだったので、試した stackoverflow.com

一長一短あるので常に指定するのが良いとは限らないが、
起動後に少ししてから大してメモリをまだ使ってないにも関わらず、
GCがはしってCPUの使用率が高くなってレイテンシが悪化する(OS上のメモリの使用量がXmsに向けて増加しつつあったので恐らく裏側でページフォールトしてる)、みたいなことが解消できたので個人的には助かった

dependabotのgradleにKotlin DSLでpluginsとdependenciesで同じバージョンを指定してPRを作らせたい

残念ながら、gradleのバージョンが古くて(6.9系)version catalogが使えなかったので、その場合にしか役に立たない備忘録

おそらくgradleのバージョンが7.2以上なら多分version catalog使って、こんな面倒なことしなくても色々解決する気がしている developer.android.com Add support for Gradle Version Catalogs (Gradle 7.0) · Issue #3121 · dependabot/dependabot-core · GitHub

後は、最近GAされたdependabotの grouped version を使っても出来るような気もするが、個人的にはktsで完結している方が好みではある github.blog

以下は、通常であればそれぞれで指定する意味はないケースだが、今回は例としてあげる
できればバージョンを一箇所で指定して読み取るのがベストだが、
後述の理由によりそれができないので springBootVersion の変数をそれぞれ指定して作ることによって、dependabotのPRとしては1つ作成させるようにしている

  • build.gradle.kts
plugins {
  val springBootVersion = "3.1.1"
  id("org.springframework.boot") version springBootVersion 
}
  • module/build.gradle.kts
dependencies {
  val springBootVersion = "3.1.1"
  implementation("org.springframework.boot:spring-boot-starter:$springBootVersion")
}

そうすると↓みたいな感じで、変数に対してまとめてPRを出してくれる

で、なぜ一箇所で定義して読み取る方式ができないかというと、
古い gradle のージョンで恐らく出来ることといえば、
gradle.properties を使って pluginManagement 側で指定しつつ、 dependencies 側は inejct することになると思う

  • gradle.properties
springBootVersion=3.1.1
  • settings.gradle.kts
pluginManagement {
  plugins {
    val springBootVersion: String by settings
    id("org.springframework.boot") version springBootVersion
  }
}
  • build.gradle.kts
plugins {
  // pluginManagement 側で指定しているので特に指定しない
  id("org.springframework.boot")
}
  • module/build.gradle.kts
dependencies {
  val springBootVersion: String  by project
  implementation("org.springframework.boot:spring-boot-starter:$springBootVersion")
}

ただこれだと、gradleは正しく読み取って処理を行えるが、
dependabotは gradle.properties で指定して埋め込んだバージョンを理解できなくてPRを作ってくれないので、
最初に紹介した変数を複数定義する方式を今回は採用した

ちなみにこの変数を使う方式だと同じバージョンを複数のライブラリに使ってまとめてアップデートすることも可能だったりする

  • build.gradle.kts
dependencies {
  val awsSdkVersion = "2.20.135"
  implementation("software.amazon.awssdk:sts:$awsSdkVersion")
  implementation("software.amazon.awssdk:s3:$awsSdkVersion")
  implementation("software.amazon.awssdk:cognitoidentityprovider:$awsSdkVersion")
}

APIの権限チェックに@PreAuthorizeを使うのをやめた話

tl;dr

@PreAuthorize@Valid を同時に使用した場合に、評価の順番に対して直感的にはわかりづらい問題がある
Issueとしては↓があたるが、
要はコントローラーのリクエストハンドラーの変数の評価よりもあとに @PreAuthorize の評価がされる為、
本来であれば権限的に叩けないようなAPIに対してもリクエストボディなどの検証が先に動き、403で返すべきところを400で返す問題がある github.com

Issueにコメントしている人もいる通り、 BindingResultをハンドラー側で受け取れば回避も出来るだろうからそうするのも勿論ありではあると思う
ただ既に @Valid を大半に使っていて作りを変える気がない場合などは、 @PreAuthorize をそのまま使うことは控えたほうが個人的には良いと思う

@PreAuthorizeをやめてどうすることにしたか

自前でInterceptor書いてリクエストハンドラーの前に割り込むことにした
とりあえず対象のパスに来たらInterceptorを呼ばせる

@Configuration
class WebMcvConfig(
    private val authorizationInterceptor: AuthorizationInterceptor,
): WebMvcConfigurer {
    override fun addInterceptors(register: InterceptorRegistry) {
        registry.addInterceptor(authorizationInterceptor).addPathPatterns("<対象とするパス>")
    }
}

詳細はドキュメントを見たほうが早いが、
これでControllerのリクエストハンドラーより先に評価させることが出来る
なぜ HandlerMethod かどうかを確認しているかというと、 ResourceHttpRequestHandler が入るケースがあって、今回はその場合は評価させたくなかったのでこうしている

@Component
class AuthorizationInterceptor(
    private val authorizationEvaluator: AuthorizationEvaluator,
): HandlerInterceptor {
    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
    ): Boolean {
        if (handler is HandlerMethod) authorizationEvaluator.evaluate(handler.method)
        return true
    }
}
@Component
class AuthorizationEvaluator {
    fun evaluate(method: Method): Boolean {
        // MethodからAnnotationを取得するなりして、権限が足りていればTrueを返して足りなければFalseを返す
    }
}

あとは自前でアノテーションを作って好きに評価することにした

今のところ特に問題がなくうまくいっているが、
もちろん @PreAuthorize のほうが便利だし、
今回はあくまで扱いたいケースが単純だったので楽に実装出来ただけなので、その辺もちゃんと見極めた上でAPIの権限チェックに何を使うかは考えたほうが良いと思う
ただ、ライブラリを使うのに比べれば自前で後で捨てやすいように作るほうが、個人的には好きなので今回はこうしてみた

ConstraintValidatorのisValidでエラーメッセージを変更する

isValid 内でValidationMessagseのキー指定して切り替えって出来るんだっけ?ってなったので、個人的なメモ書き

ドキュメントを読んでいたら出来そうだったので試した
あんまり使う機会はなさそうではある(基本的にはConstraintsは分けるし、相関チェックみたいなパターンぐらいの認識)

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [CustomConstraints.CustomConstraintValidator::class])
annotation class CustomConstraints(
    val message: String = "{com.example.customconstraintssample.constraints.CustomConstraints.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
) {
    class CustomConstraintValidator : ConstraintValidator<CustomConstraints, IndexRequest> {
        override fun isValid(value: IndexRequest, context: ConstraintValidatorContext): Boolean {
            return when (value.type) {
                IndexType.TYPE1 -> {
                    if (value.type1Value != null && value.type2Value == null) return true
                    context.disableDefaultConstraintViolation()
                    context.buildConstraintViolationWithTemplate("{com.example.customconstraintssample.constraints.CustomConstraints.type1.message}")
                        .addConstraintViolation()
                    false
                }
                IndexType.TYPE2 -> {
                    if (value.type1Value == null && value.type2Value != null) return true
                    context.disableDefaultConstraintViolation()
                    context.buildConstraintViolationWithTemplate("{com.example.customconstraintssample.constraints.CustomConstraints.type2.message}")
                        .addConstraintViolation()
                    false
                }
            }
        }
    }
}

使う側

enum class IndexType {
    TYPE1,
    TYPE2,
}

@CustomConstraints
data class IndexRequest(
    val type: IndexType,
    val type1Value: String?,
    val type2Value: Int?,
)

試したソースはここ github.com