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