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

Spring Reactive WebClient未遵循KotlinFeature.NullIsSameAsDefault问题

Kotlin数据类WebClient反序列化null值问题

问题现象

将Spring Boot应用重写为Kotlin后,使用响应式WebClient时,带默认值的数据类反序列化出现异常:

  • 单独调用objectMapper.readValue<List<EsportMatchVm>>(mockJsonWithNullValues)可正常解析含null值的JSON
  • 通过WebClient的bodyToFlux<EsportMatchVm>解析时,name字段为null会触发非空错误;status字段通过@JsonSetter(nulls = Nulls.AS_EMPTY)解决了null问题,但name字段同样配置却不生效

相关代码

数据类定义

data class EsportMatchVm(
    val id: Long? = null,
    val name: String = "",
    @field:JsonProperty("begin_at")
    val beginAt: String? = null,
    @JsonSetter(nulls = Nulls.AS_EMPTY) val status: String = "",
)

ObjectMapper配置类

@Configuration(proxyBeanMethods = false)
class JsonCustomizerConfig {

    @Bean
    fun jsonCustomizer(): Jackson2ObjectMapperBuilderCustomizer =
        Jackson2ObjectMapperBuilderCustomizer { builder: Jackson2ObjectMapperBuilder ->
            builder.modulesToInstall(
                KotlinModule.Builder() // 替换现有KotlinModule
                    .configure(KotlinFeature.NullIsSameAsDefault, true)
                    .build()
            )
        }
}

WebClient客户端实现

@Service
class EsportClient(
    private val webClient: WebClient,
    @Value("\${esport.token}") private val token: String
) {
    fun getMatches(matchType: MatchType): Flux<EsportMatchVm> = webClient
        .get()
        .uri("$PANDASCORE_BASE_URL${matchType.name.lowercase()}")
        .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
        .header(HttpHeaders.AUTHORIZATION, "$BEARER_PREFIX$token")
        .retrieve()
        .bodyToFlux<EsportMatchVm>()

    companion object {
        private const val PANDASCORE_BASE_URL = "https://api.pandascore.co/csgo/matches/"
        private const val BEARER_PREFIX = "Bearer "
    }
}

测试代码

@SpringBootTest
class EsportClientTest(
    objectMapper: ObjectMapper,
    builder: WebClient.Builder
) : StringSpec({

    val exchangeFunction: ExchangeFunction = mockk()

    val client = builder
        .exchangeFunction(exchangeFunction)
        .build()

    val sut = EsportClient(client, "~token~")

    "getMatches当匹配项含null值时应返回正确结果" {
        val mockJsonWithNullValues = """
                    [{
                        "id": null,
                        "name": null,
                        "begin_at": "",
                        "status": null
                    }]""".trimIndent()


        // 此方法正常工作
        val parsed = objectMapper.readValue<List<EsportMatchVm>>(mockJsonWithNullValues)
        parsed.shouldNotBeNull()

        every { exchangeFunction.exchange(any()) } answers {
            Mono.just(
                createClientResponse(mockJsonWithNullValues)
            )
        }

        val runningMatches = sut.getMatches(MatchType.RUNNING)

        val expectedMatch = EsportMatchVm(
            id = null,
            name = "",
            beginAt = "",
            status = ""
        )

        StepVerifier.create(runningMatches)
            // 抛出org.springframework.core.codec.DecodingException: JSON解码错误
            .expectNext(expectedMatch)
            .verifyComplete()
}) {
    companion object {
        private fun createClientResponse(body: String) =
            ClientResponse.create(HttpStatus.OK)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .body(body)
                .build()
    }
}

错误栈追踪

Suppressed: org.springframework.core.codec.DecodingException: JSON decoding error: Instantiation of [simple type, class no.vicx.backend.esport.vm.EsportMatchVm] value failed for JSON property name due to missing (therefore NULL) value for creator parameter name which is a non-nullable type
    at org.springframework.http.codec.json.AbstractJackson2Decoder.processException(AbstractJackson2Decoder.java:282)
    Suppressed: The stacktrace has been enhanced by Reactor, refer to additional information below: 

环境信息

  • Spring Boot版本:3.5.0
  • Kotlin版本:2.1.21
  • Jackson KotlinModule版本:2.19.1
  • JDK:21

解决方案

核心原因

WebClient默认使用的Jackson解码器未加载自定义配置的ObjectMapper,导致KotlinModule的NullIsSameAsDefault配置和@JsonSetter注解在WebClient反序列化时不生效,而单独调用ObjectMapper时配置是正常的。

解决步骤

  1. 给WebClient配置自定义Jackson解码器
    修改WebClient构建逻辑,传入配置好的ObjectMapper:

    @Configuration
    class WebClientConfig {
        @Bean
        fun webClient(objectMapper: ObjectMapper): WebClient {
            val jacksonDecoder = Jackson2JsonDecoder(objectMapper)
            return WebClient.builder()
                .codecs { configurer ->
                    // 替换默认的Jackson解码器
                    configurer.defaultCodecs().jackson2JsonDecoder(jacksonDecoder)
                }
                .build()
        }
    }
    
  2. 测试代码同步配置解码器
    测试中手动构建WebClient时,也需传入自定义解码器,否则测试环境仍用默认配置:

    val client = builder
        .codecs { configurer ->
            configurer.defaultCodecs().jackson2JsonDecoder(Jackson2JsonDecoder(objectMapper))
        }
        .exchangeFunction(exchangeFunction)
        .build()
    
  3. 可选:给name字段添加@JsonSetter注解
    确保name字段也能处理null值,和status字段保持一致:

    data class EsportMatchVm(
        val id: Long? = null,
        @JsonSetter(nulls = Nulls.AS_EMPTY) val name: String = "",
        @field:JsonProperty("begin_at")
        val beginAt: String? = null,
        @JsonSetter(nulls = Nulls.AS_EMPTY) val status: String = "",
    )
    

说明

通过上述配置,WebClient会使用与ObjectMapper一致的序列化规则,null值会被正确映射为数据类的默认值,解决反序列化时的非空错误。

内容的提问来源于stack exchange,提问作者Roar S.

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.12 21:34:57