You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring中Kotlin可空类型反序列化:批量收集空值违规方案咨询

解决方案

针对Kotlin非空属性反序列化时直接抛异常、无法批量收集空值违规的问题,以下是几种更优的实现方案:

方案1:全局配置Jackson跳过非空校验 + Spring Validation统一收集错误

核心思路是先让Jackson允许非空属性接收null(不直接抛异常),再通过Spring校验机制批量检查所有非空约束,最后统一抛出错误。

步骤1:全局配置Jackson关闭严格空检查

由于无法修改库类,通过全局配置让Jackson的Kotlin模块允许非空属性暂时接收null:

@Configuration
class JacksonConfig {
    @Bean
    fun jackson2ObjectMapperBuilderCustomizer(): Jackson2ObjectMapperBuilderCustomizer {
        return Jackson2ObjectMapperBuilderCustomizer { builder ->
            builder.modules(KotlinModule.Builder()
                .strictNullChecks(false) // 关闭严格空检查,避免反序列化时直接抛异常
                .build())
        }
    }
}

步骤2:自定义校验器检查Kotlin非空属性

如果库类无法添加@NotNull注解,通过反射检查属性的可空性,批量收集空值违规:

// 自定义校验注解
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [KotlinNonNullValidator::class])
annotation class KotlinNonNullCheck(
    val message: String = "存在非空属性为null",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

// 校验器实现
class KotlinNonNullValidator : ConstraintValidator<KotlinNonNullCheck, Any> {
    override fun isValid(value: Any?, context: ConstraintValidatorContext): Boolean {
        if (value == null) return true

        val violations = mutableListOf<String>()
        // 遍历所有属性,检查非空约束
        value::class.memberProperties.forEach { prop ->
            val isNullable = prop.returnType.isMarkedNullable
            val propValue = prop.get(value)
            if (!isNullable && propValue == null) {
                violations.add("属性${prop.name}不能为空")
                // 将错误绑定到校验上下文
                context.disableDefaultConstraintViolation()
                context.buildConstraintViolationWithTemplate("属性${prop.name}不能为空")
                    .addPropertyNode(prop.name)
                    .addConstraintViolation()
            }
        }
        return violations.isEmpty()
    }
}

步骤3:在Controller中触发校验并统一处理错误

通过Validator手动触发校验,或者给类添加@KotlinNonNullCheck注解配合@Valid:

@PostMapping("/test")
fun test(
    @RequestBody request: MyClass1,
    validator: Validator
) {
    val violations = validator.validate(request)
    if (violations.isNotEmpty()) {
        val errorMsg = violations.joinToString(", ") { "${it.propertyPath}: ${it.message}" }
        throw InvalidRequestException("请求参数错误:$errorMsg")
    }
    // 执行业务逻辑
}

// 全局异常处理器统一返回错误格式
@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(ConstraintViolationException::class)
    fun handleConstraintViolation(e: ConstraintViolationException): ResponseEntity<Map<String, Any>> {
        val errors = e.constraintViolations.map { "${it.propertyPath}: ${it.message}" }
        return ResponseEntity.badRequest().body(mapOf("code" to "INVALID_PARAM", "errors" to errors))
    }
}

class InvalidRequestException(message: String) : RuntimeException(message)

方案2:用MapStruct自动生成可空DTO并转换

如果不想手动维护副本类,用MapStruct自动生成属性全可空的DTO,校验完成后再转换为原始库类:

步骤1:添加MapStruct依赖

// build.gradle.kts
dependencies {
    implementation("org.mapstruct:mapstruct:1.5.5.Final")
    kapt("org.mapstruct:mapstruct-processor:1.5.5.Final")
}

步骤2:定义DTO和映射接口

// 可空DTO
data class MyClass1Dto(
    val someField1: String?,
    val myClass2: MyClass2Dto?,
)

data class MyClass2Dto(
    val someField2: String?,
    val someField3: String?,
)

// MapStruct自动生成映射实现
@Mapper(componentModel = "spring")
interface MyClassMapper {
    fun toMyClass1(dto: MyClass1Dto): MyClass1
    fun toMyClass2(dto: MyClass2Dto): MyClass2
}

步骤3:Controller中校验DTO并转换

@PostMapping("/test")
fun test(
    @RequestBody dto: MyClass1Dto,
    mapper: MyClassMapper
) {
    val violations = mutableListOf<String>()
    // 批量检查非空约束
    if (dto.someField1 == null) violations.add("someField1不能为空")
    dto.myClass2?.let {
        if (it.someField2 == null) violations.add("myClass2.someField2不能为空")
    }
    if (violations.isNotEmpty()) {
        throw InvalidRequestException(violations.joinToString(", "))
    }
    // 转换为原始库类
    val request = mapper.toMyClass1(dto)
    // 执行业务逻辑
}

方案3:修复自定义Deserializer的循环问题

针对之前遇到的反序列化循环问题,改用Jackson底层API解析JSON树,再委托默认反序列化器创建对象:

class CollectingDeserializer<T>(private val targetType: Class<T>) : JsonDeserializer<T>() {
    private val delegateDeserializer by lazy {
        val factory = BeanDeserializerFactory.instance
        val config = deserializationConfig
        val beanDesc = config.introspect(JavaType.constructType(targetType))
        factory.buildBeanDeserializer(config, beanDesc, null)
    }

    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T? {
        val violations = mutableListOf<String>()
        // 先解析为JSON树,避免循环调用
        val jsonNode = p.codec.readTree<JsonNode>(p)
        // 遍历字段检查非空约束
        targetType.declaredFields.forEach { field ->
            field.isAccessible = true
            val fieldName = field.name
            val node = jsonNode.get(fieldName)
            val isNullable = field.type.kotlin.isMarkedNullable
            if (!isNullable && (node == null || node.isNull)) {
                violations.add("字段$fieldName不能为空")
            }
        }
        if (violations.isNotEmpty()) {
            throw InvalidRequestException(violations.joinToString(", "))
        }
        // 重置解析器位置,用委托反序列化器创建对象
        p.codec.treeToValue(jsonNode, targetType)
        return delegateDeserializer.deserialize(p, ctxt)
    }
}

全局注册反序列化器:

@Configuration
class JacksonConfig {
    @Bean
    fun jackson2ObjectMapperBuilderCustomizer(): Jackson2ObjectMapperBuilderCustomizer {
        return Jackson2ObjectMapperBuilderCustomizer { builder ->
            builder.deserializerByType(MyClass1::class.java, CollectingDeserializer(MyClass1::class.java))
            builder.deserializerByType(MyClass2::class.java, CollectingDeserializer(MyClass2::class.java))
        }
    }
}

内容的提问来源于stack exchange,提问作者Elektor

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 19:24:56