解密 `StringHttpMessageConverter` 如何破坏你的 `Result<T>` 封装

前言

在现代 Spring Boot 应用中,我们通常会定义一个统一的响应结构,如 Result<T>,来封装所有的 API 返回值。为了避免在每个 Controller 方法中手动包装,我们(正确地)转向了 ResponseBodyAdvice

// 我们的统一响应体
public class Result<T> {
    private Integer code;
    private String message;
    private T data;
    // ... 构造函数和 success/fail 方法
}

当我们兴高采烈地实现 GlobalResponseAdvice 后,奇迹发生了:

  • getUser() 返回 User 对象 →\rightarrow 正常包装为 Result<User> (JSON)
  • getMap() 返回 Map →\rightarrow 正常包装为 Result<Map> (JSON)
  • getInt() 返回 Integer →\rightarrow 正常包装为 Result<Integer> (JSON)
  • getString() 返回 String →\rightarrow 灾难发生!

前端没有收到预期的 {"code":0, "message":"success", "data":"hello"},而是收到了一个 Content-Typetext/plain 的、奇形怪状的字符串:Result(code=0, message=success, data=Hello)

为什么?为什么 String 如此特殊?

问题的根源:HttpMessageConverter 的“抢答”

这个问题的核心不在于 StringInteger 的类型差异,而在于 Spring MVC 用来处理返回值的 HttpMessageConverter(消息转换器)链

Spring 在决定如何将你的 Controller 返回值写入 HTTP 响应时,会遍历一个转换器列表。

1. “快乐路径”:当您返回 UserIntegerList
  1. Controller 返回: 一个 User 对象。
  2. Spring 选择转换器: Spring 遍历列表,StringHttpMessageConverter 拒绝了它(“我只处理 String”)。最终,MappingJackson2HttpMessageConverter(JSON 转换器)“中标”了(“我能处理 POJO!”)。
  3. ResponseBodyAdvice 执行: 你的 beforeBodyWrite 方法被调用。
    • bodyUser 对象。
    • selectedConverterTypeMappingJackson2HttpMessageConverter.class
  4. 你的逻辑: 你的代码返回 Result.success(user)
  5. Spring 最终处理: Spring 拿到这个新的 Result<User> 对象,并继续使用它最初选择的 MappingJackson2HttpMessageConverter 将其序列化为 JSON。
  6. 结果: 完美!Content-Type: application/json,响应体是 Result<User> 的 JSON。
2. “灾难路径”:当您返回 String
  1. Controller 返回: 一个 String 对象。
  2. Spring 选择转换器: Spring 遍历列表。StringHttpMessageConverter 立即“抢答”:“这是一个 String,归我了!”。
    • 关键点: StringHttpMessageConverter优先级非常高,并且它的默认 Content-Typetext/plain
  3. ResponseBodyAdvice 执行: 你的 beforeBodyWrite 方法被调用。
    • bodyString 对象。
    • selectedConverterTypeStringHttpMessageConverter.class
  4. 你的逻辑(如果未处理): 假设你没有特殊处理,你同样返回 Result.success("Hello")
  5. Spring 最终处理(灾难!): Spring 拿到了这个新的 Result<String> 对象,但它被告知必须使用最初选择的 StringHttpMessageConverter 来处理它。
  6. StringHttpMessageConverter 的工作很简单:在它拿到的任何对象上调用 .toString(),然后以 text/plain 格式输出。
  7. 结果: Spring 在 Result<String> 对象上调用了 toString(),导致你看到了那个丑陋的 Result(code=0, message=success, data=Hello) 字符串,并且 Content-Typetext/plain

解决方案:识别“陷阱”并“手动接管”

要解决这个“弯弯绕”,我们必须在 beforeBodyWrite 中识别出这个“陷阱”(即 StringHttpMessageConverter 被选中时),并手动完成本该由 JSON 转换器做的工作。

这就是你的 GlobalResponseAdvice 中那段“神奇”代码的真正含义:

// 你的 GlobalResponseAdvice.java (已是正确实现)
@Override
@Nullable
public Object beforeBodyWrite(@Nullable Object body, @NonNull MethodParameter returnType, 
                              @NonNull MediaType selectedContentType,
                              @NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType,
                              @NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response) {
    
    // 步骤 1: 识别陷阱
    // 检查 Spring 是否错误地选择了 StringHttpMessageConverter
    if (StringHttpMessageConverter.class.isAssignableFrom(selectedConverterType)) {

        // 步骤 2: 手动“纠正” Content-Type
        // 强制将 Content-Type 从 text/plain 改回 application/json
        HttpHeaders headers = response.getHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);

        try {
            // 步骤 3: 手动“纠正” 响应体
            // 1. 将 body 包装成 Result.success(body)
            // 2. 手动使用 ObjectMapper 将其序列化为 JSON 字符串
            return objectMapper.writeValueAsString(Result.success(body)); 
            
        } catch (JsonProcessingException e) {
            // 处理序列化异常
            String declaringClass = returnType.getContainingClass().getName();
            String returnTypeName = returnType.getParameterType().getName();
            // 抛出平台异常,让全局异常处理器接管
            throw PlatformException.of("platform.log.response.serialization", e,
                    declaringClass,
                    returnTypeName
            );
        }
    }
    
    // 如果不是 StringHttpMessageConverter(即快乐路径),正常包装
    return Result.success(body);
}

解决方案如何工作

通过上述代码,当 Controller 返回 String 时,流程被修正为:

  1. beforeBodyWrite 捕获到 StringHttpMessageConverter
  2. 它将 Content-Type 强制改回 application/json
  3. 它手动将 Result<String> 序列化为 JSON 字符串(例如:"{\"code\":0,\"data\":\"Hello\"}")。
  4. 它将这个新的 JSON 字符串返回给 Spring。
  5. Spring 将这个新字符串交给 StringHttpMessageConverter
  6. StringHttpMessageConverter 看到自己拿到的还是一个 String,非常高兴,它原封不动地将这个(已经是 JSON 格式的)字符串写入响应体。

我们“欺骗”了 StringHttpMessageConverter,让它在不知不觉中帮我们完成了 JSON 的传输。

总结

ResponseBodyAdvice 中对 String 类型的特殊处理,不是 Bug,而是应对 Spring MVC 消息转换器链高优先级机制的必要手段IntegerBoolean 之所以不需要,只是因为它们足够幸运,默认被 MappingJackson2HttpMessageConverter(JSON 转换器)处理了。

理解了 HttpMessageConverter 的选择机制,这个“弯弯绕”也就不再神奇了。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值