深入 Spring 29:返回值消息转换与响应提交边界
方法返回对象以后仍可能失败
控制器正常返回一个对象,不代表客户端已经收到成功响应。对象可能找不到满足 Accept 的表示,getter 可能在序列化时抛出异常,写入网络时也可能断开连接。方法返回、转换完成与 HTTP 交换结束,是三个可分别失败的阶段。
反方向也成立:服务端后来抛出 IOException,客户端仍可能已经收到 200 和部分正文。响应头一旦提交,异常处理器无法把已经传出的 200 改成 500。仅靠控制器异常率或最终 Java 异常类型,不能还原客户端看到的协议结果。
本篇固定 Spring Framework 6.2.11,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2,JDK 21、Tomcat 10.1.46、Jackson 2.18.4。MvcLab 29 分别使用 JDK HttpClient 与原始 Socket 验证完整响应及提交后的部分响应,完整代码在下载工程 mvc-lab。
返回值处理器先于消息转换器
ServletInvocableHandlerMethod 得到 Java 返回值后,交给 HandlerMethodReturnValueHandler 集合选择处理方式。ResponseEntity、标注 ResponseBody 的对象、视图名称、Callable 与流式返回值等有不同处理器。Java 类型和控制器注解共同决定语义,不能仅按“返回 String”推断响应正文。ServletInvocableHandlerMethod
本实验 /hello 的 String 带 ResponseBody,所以直接写出 hello;/view 的 String 没有该注解,返回 receipt 后通过显式注册的 ViewResolver 选择视图,最终写出 <p>receipt</p>。这里没有 JSP,也没有 Boot 的模板自动配置,视图对象直接实现 render,便于只观察返回值语义。
/entity 返回 ResponseEntity,响应状态为 201,额外头 X-Order 为 7,正文为 JSON。/object 返回标注 ResponseBody 的 Map,得到默认 200 与 JSON 正文。ResponseEntity 能携带状态和头,但它的 body 仍需要相应的消息转换器,不能绕过内容协商与序列化失败。
返回值处理器解决“怎样解释方法结果”,HttpMessageConverter 解决“怎样读写某种 Java 类型与媒体类型”。把两者合并成“Spring 自动转 JSON”会遗漏视图、空值、流式处理以及返回值状态控制等分支。
Accept 与可写表示的交集
对于正文返回路径,AbstractMessageConverterMethodProcessor 考虑客户端可接受类型、服务器可生成类型以及已经设置的 Content-Type,选择最终表示并查找能够写出该值的转换器。若不存在可接受表示,正常返回的对象也可能转换成 406。固定版本消息转换选择
实验默认请求 /object 得到 application/json。同一路径显式发送 Accept: application/xml,当前模块没有对应的 XML 对象转换能力,响应为 406。处理器返回 Map 的业务路径没有因此变成“接口不存在”;失败在表示选择上。
该结论依赖类路径与 MVC 配置。加入 XML 转换器后可能出现新的合法表示;为映射增加 produces 也会让协商失败更早发生在映射条件阶段。改变依赖或 WebMvcConfigurer 时,应把 HTTP 表示集合视为公开接口的一部分,避免无意扩大或缩小客户端可接受的格式。
请求 Content-Type 与响应 Content-Type 也不能混用。请求头说明客户端发送了什么正文,响应头说明服务器实际写出了什么。一个 JSON POST 完全可以得到其他媒体类型的错误页;本实验 406 由默认异常解析与 Tomcat 错误响应完成,实际体是 HTML,而不是应用 code JSON。只有实际抓取响应,才能知道错误体是否也遵守了约定。
ResponseBodyAdvice 能修改什么
本模块注册 ResponseBodyAdvice,在转换写入前增加 X-Mvc-Advice 头。/object 的实际响应含 visited 值,证明 Advice 确实进入当前正文处理路径。它返回原对象,不做统一包装,因此没有引入“字符串转换器选定后改成任意对象”的类型不匹配问题。
Advice 的位置在转换器选择之后、正文写出之前。若修改 body 的运行时类型,应确认与已选择的转换器兼容。统一包装还可能改变 Content-Length、缓存语义和客户端 schema;它不是在任何返回值上都无条件安全的装饰。RequestResponseBodyAdviceChain
视图和 StreamingResponseBody 有自己的处理路径,不能因为普通 JSON 响应经过 Advice,就推断所有 HTTP 响应都经过相同包装。统一日志、认证、错误协议与 JSON 外层结构,是不同需求,应该各自选择能覆盖目标边界的扩展点。
null 不自动表示 204
/nothing 标注 ResponseBody,返回 null。本次响应状态为 200,正文为空,没有 Content-Type。这个组合是实际观测,不是所有 null 返回值的通用约定:方法上的状态注解、ResponseEntity、直接操作响应或视图语义都可能改变结果。
若业务需要表达“成功且无正文”,应明确返回 204,或采用其他稳定的协议约定。依赖 null 恰好产生的默认状态,会使客户端、缓存和生成的接口文档对契约产生不同理解。测试应同时检查状态、正文长度与必要响应头,而不能仅断言控制器返回 null。
上游 ServletInvocableHandlerMethodTests 对 void、ResponseStatus、已处理请求和并发结果都设置了不同用例。这些分支说明空值并不是一条单独规则。本篇阅读对应测试源码作为反例入口,实际验收仍由真实容器运行完成,没有声明执行上游 Gradle 套件。
序列化失败发生在提交之前
/broken 返回 Broken 对象,其 getter 抛出 IllegalStateException。Jackson 写出对象时失败,Spring 包装为 HttpMessageNotWritableException。由于这个小响应尚未提交,实验的异常处理方法可以返回 500 和 {"code":"WRITE_ERROR"}。
控制器方法已经正常结束,因此包围控制器内部逻辑的 try/catch 不能处理该 getter 异常。服务层事务也可能在序列化前就已提交。把对象延迟加载、远程调用或数据库访问隐藏在 getter 中,会让读取响应的阶段承担额外失败点,甚至在业务事务完成后再次访问外部资源。
本例成功生成 500 不意味着所有序列化错误都能被重写。响应大小、缓冲、flush 行为与转换器实现决定异常发生前是否已有字节提交。错误处理策略应首先查看 response.isCommitted(),并承认无法撤回已发送内容;继续尝试输出另一种 JSON 可能只会拼出损坏的混合正文。
flush 之后不能重新发送状态行
/stream 返回 StreamingResponseBody。任务先写 first\n 并 flush,再故意抛出 IOException。原始 Socket 保存的响应以 HTTP/1.1 200 开始,并包含 first;服务器日志同时记录后续写入处理失败。HTTP 客户端若期望完整分块结束,还可能把截断视为传输错误。
该负例用 Socket 而不是只查看异常处理器返回值,因为真正要证明的是客户端已经接收到什么。状态行出现 200,不能保证流完整结束;服务端最后记录异常,也不能改变已发出的 200。监控应把响应状态、正文完成与传输错误分开统计。
流式协议若需要逐条结果与最终完成标记,应在协议中明确这些语义,客户端也要检查是否收到完成标记或合法终止。把“读到第一块数据”视为整个操作成功,无法识别中途失败;把失败后附加的一块任意文本视为错误 JSON,又可能破坏原媒体类型。
本实验只观察本机直连 Tomcat,未加入反向代理和压缩缓冲。代理可能延后首字节转发,也可能把上游截断转换成别的外部结果。线上结论需要在实际链路上复验,不能用本机抓到的字节时序估算所有部署的提交时点。
复现与练习
在实验工程执行:
1 | |
12 条断言与原始响应保存在 evidence/29/local-20261002/。其中四条检查生产执行器、MVC 执行器、上下文和端口关闭。负例打印的 IOException 栈是预期观察,最终成功条件仍是退出码 0 与 CHAPTER 29 PASS,不能把异常日志整体过滤后声称“无错误”。
推导题:将 /nothing 改成 ResponseEntity.noContent().build(),客户端仍应收到 200 吗?不应。现在状态已被明确设为 204,返回值处理器得到的信息与裸 null 不同。
改动练习:移除 /stream 中的 flush,再分别写入很小与超过容器缓冲的正文后抛异常。记录实际状态行和已收到字节,不预设“没手动 flush 就一定没提交”。缓冲区写满也可能触发提交,必须用客户端证据确定边界。
完整工程下载见 深入 Spring(00):从手动组装到可验证的容器实验。六章共用独立的 mvc-lab 模块,单章参数决定执行场景;正文中的改动练习未计入已通过断言。

