最近在项目中遇到了一个令人困惑的问题,涉及到Kotlin的非空类型和Spring Boot的校验注解。在处理JSON请求时,我发现Kotlin的非空类型和JSR380的@NotNull注解都没有如预期那样生效。这让我深入探索了Kotlin与Spring Boot在反序列化和校验过程中的一些细节。

问题场景

我有一个SignInDTO数据类,用于处理用户的签到请求。代码如下:

1
2
3
4
5
6
7
8
9
10
class SignInDTO(
@field:Schema(description = "项目ID", example = "1")
@field:NotNull(value = 1, message = "项目ID不能为空")
val projectId: Int,

@field:Schema(description = "坐标", example = "POINT(116.404 39.915)")
@field:NotNull(message = "坐标不能为空")
@field:JsonDeserialize(using = GeometryDeserializer::class)
val coordinate: Point
)

在控制器中,我使用了@Validated@RequestBody注解来接收和校验请求体:

1
2
3
4
5
6
7
@PostMapping("/sign-in")
@Operation(summary = "签到")
fun signIn(
@Validated @RequestBody signInDTO: SignInDTO,
): Result<Nothing> {
// 处理逻辑
}

当我发送一个缺少projectIdcoordinate字段的JSON请求时,预期应该抛出校验异常。然而,projectId没有抛出异常,而是给了一个0的默认值。coordinate字段却能正常校验缺失。这让我感到疑惑。

补充:预期的情况下,@NotNull是冗余的,因为如果存在null值,DTO对象在实例化的过程中就会抛出异常。而JSR380的校验则是发生在传入Controller方法的阶段。

原因分析

经过调试和查阅资料,我发现问题的根源在于Kotlin的非空基本类型和Java的基本类型之间的映射。

  • Kotlin的非空基本类型(如Int)会被编译成Java的基本类型(如int。在Java中,基本类型无法为null,如果JSON中缺少对应的字段,反序列化时会使用默认值(0)进行填充,这导致JSR380中@NotNull注解同时也失效了。
  • coordinate字段是一个对象类型(Point,在Kotlin中,它被处理为非空的引用类型。如果在JSON中缺少该字段,反序列化时会抛出MissingKotlinParameterException,这正是我们期望的行为。

因此,projectId字段即使在请求中缺失,也会被默认为0,导致@NotNull@NotBlank等注解无法生效。

解决方案

知道了问题的原因,解决方法也就清晰了。主要有两种思路:

将基本类型改为可空类型

Int改为Int?,使其在Kotlin中编译为包装类型Integer,从而可以为null。这样,@NotNull注解就能在反序列化时正常校验。

1
2
3
4
5
6
class SignInDTO(
@field:Schema(description = "项目ID", example = "1")
@field:NotNull(message = "项目ID不能为空")
val projectId: Int?,
// 其他字段
)

缺点:这会在代码中引入不必要的空值检查,因为在设计上projectId确实是非null的。

使用@Min@Max注解校验默认值

利用@Min(1)注解,确保projectId的值必须大于等于1。由于默认值为0,当请求中缺少projectId时,校验将无法通过。

1
2
3
4
5
6
class SignInDTO(
@field:Schema(description = "项目ID", example = "1")
@field:Min(value = 1, message = "项目ID不能为空")
val projectId: Int,
// 其他字段
)

优点:无需更改类型,也不引入额外的空值检查。

缺点:使用@Min@Max判空是违反直觉的。

完善全局异常处理

同时,为了捕获对象类型字段缺失时抛出的MissingKotlinParameterException,我还完善了全局异常处理器:

1
2
3
4
5
6
7
8
9
10
11
@ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): Result<Nothing> {
val cause = e.cause
if (cause is MissingKotlinParameterException) {
val missingField = cause.parameter.name
val message = "字段 $missingField 不能为空"
return fail(HttpCode.BAD_REQUEST.code, message)
}
return fail(HttpCode.BAD_REQUEST.code, e.message ?: HttpCode.BAD_REQUEST.message)
}

这样,当对象类型字段缺失时,程序会返回明确的错误提示,指明具体是哪个字段缺失。

顺便贴上应对JSR380校验失败时抛出MethodArgumentNotValidException的异常处理器:

1
2
3
4
5
6
7
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun illegalParameterException(e: MethodArgumentNotValidException): Result<Nothing> {
val message = e.bindingResult.fieldErrors
.joinToString("; ") { "${it.defaultMessage}" }
return fail(HttpCode.BAD_REQUEST.code, message)
}

思考

Kotlin中的基本类型,如IntInt?在编译时分别对应Java中的基本类型int和包装类型Integer,这在性能和空值安全性之间做了一个权衡。

  • 性能考量:基本类型int的设计初衷是为了更高效的内存使用和性能优化。Java虚拟机处理基本类型时,不需要进行额外的装箱和拆箱操作,这在大量数据处理或性能敏感的场景下显得尤为重要。因此,Kotlin将非空的Int直接编译为int,继承了Java的这一优势。对于不涉及空值的场景,这种设计是合理且高效的。
  • 空值安全:然而,Kotlin的设计哲学之一是空值安全,即通过类型系统在编译期尽可能地消除空指针异常(NullPointerException)。因此,Kotlin提供了可空类型Int?,对于可空类型,Kotlin强制要求开发者显式地处理 null 值,从而在编译时捕获潜在的空引用错误,避免了 Java 中常见的 NullPointerException 问题。
  • 设计权衡:这一设计在性能和安全性之间做了权衡。虽然Int的非空设计在大多数情况下保障了性能,但在特定场景下(如反序列化),它可能会导致意外的行为,比如我们遇到的反序列化时默认值为0的问题。在这些场景下,编译后的int由于不能为null,反而打破了我们对数据校验的期望。相比之下,Int?虽然增加了空值处理的复杂性,但允许更灵活的空值校验。

在StackOverflow上,我发现了一个类似的问题,并将我的解决方案分享了上去:Kotlin + Spring Boot default value for Int field from JSON