资讯动态

从406说起:Spring MVC响应消息转换与处理全链路解析

发布时间:2026/10/14 10:26:02 来源:尧图企业网站定制
1. 从一次406排查说起返回值和响应消息不是一回事接手一个老项目时我遇到过一件挺迷惑的事。某个接口之前用字符串拼HTML返回一直跑得好好的后来改成REST风格返回一个详情对象方法上加了ResponseBody结果前端一调就报406。当时第一反应是“代码明明没报错为什么状态码不可接受”后来把DispatcherServlet到HttpMessageConverter的整条链路翻了一遍才明白Spring Web MVC里“方法的返回值”和“真正写到浏览器的响应消息”之间隔着好几层委派和协商任何一层没配对响应就会以各种方式失败。这篇文章我不会只讲配置怎么写而是把Spring Web MVC处理响应消息的完整链路拆开方法返回值如何被识别、消息转换器怎么被选中、字节流最后怎么落到ServletOutputStream上。适合刚接触Spring MVC的后端开发也适合那些“接口能跑但说不出原理”的CRUD选手。读完之后再遇到406、中文乱码、响应体为空、状态码不对这类问题你会有一个明确的排查顺序而不是靠猜。2. 第一站分流HandlerMethodReturnValueHandler怎么决定走哪条路2.1 请求进入后端后方法调用结果的处理顺序先看一次正常的请求处理DispatcherServlet根据请求路径找到某个HandlerMethod也就是Controller里那个方法然后交给HandlerAdapter执行。真正执行方法的地方在ServletInvocableHandlerMethod.invokeAndHandle它做两件事调用目标方法拿到返回值然后把返回值交给一组“能处理它”的处理器。这里的“处理器”就是HandlerMethodReturnValueHandler翻译过来是“返回值处理器”。整个处理模式是责任链式遍历系统维护了一个返回值处理器列表从头到尾逐个询问“你能处理这个返回值吗”第一个回答“能”的处理器接活。这个过程比大多数人以为的要早。先看方法签名再看方法上的注解最后看返回类型本身——不是像直觉那样“根据返回类型判断”而是注解优先。举个例子RestController public class DemoController { GetMapping(/string) public String plainString() { return hello; } GetMapping(/object) public UserInfo getUser() { return new UserInfo(1, 张三); } }plainString()返回String但因为类上有RestController实际上等价于方法标记了ResponseBody所以走的是RequestResponseBodyMethodProcessor——这个处理器一看“方法上有ResponseBody”就直接把返回值当成响应体根本不走视图解析。而如果去掉RestControllerplainString()返回的String就会被当成“视图名称”Spring转而去找对应的模板页面。同一个String返回值走的是完全不同的两条路这就是“注解优先”最典型的表现。2.2 常见返回类型对应的处理器我把Spring MVC内置的核心返回值处理器整理成了一张表大家排查时可以直接对照返回类型或场景对应的ReturnValueHandler响应消息的最终去向String方法无ResponseBodyViewNameMethodReturnValueHandler当作视图名交给ViewResolverModelAndView / Model / MapModelAndViewMethodReturnValueHandler等填充模型后交给视图渲染方法或类上有ResponseBodyRequestResponseBodyMethodProcessor通过HttpMessageConverter写响应体HttpEntity或其子类ResponseEntityHttpEntityMethodProcessor通过HttpMessageConverter写响应体并可定制状态码和响应头StreamingResponseBodyStreamingResponseBodyReturnValueHandler直接在异步线程里向输出流写字节ResponseBodyEmitter / SseEmitterResponseBodyEmitterReturnValueHandler分块或SSE推送如果你是自己实现的HandlerMethodReturnValueHandler记得实现supportsReturnType和handleReturnValue两个方法。前者决定“这个返回值归我管吗”后者做真正的写出动作。很多框架级封装比如自定义的统一返回包装就是在这一层动手脚的。2.3 为什么ResponseBody和ResponseEntity走的是两条平行路径RequestResponseBodyMethodProcessor和HttpEntityMethodProcessor都继承自同一个抽象父类核心写操作几乎一样差别在于两点第一状态码的载体不同。ResponseBody配合ResponseStatus才能指定状态码而ResponseEntity本身就携带状态码语义更自然。比如一个新增接口直接return ResponseEntity.status(HttpStatus.CREATED).body(result)比在方法上放ResponseStatus更灵活尤其是状态码需要在运行期动态决定的时候。第二泛型信息是否完整。HttpEntityMethodProcessor可以从ResponseEntityUserInfo这样的声明里拿到UserInfo这个泛型类型对需要泛型信息的HttpMessageConverter友好ResponseBody场景则主要依赖方法返回类型的解析。理解这个分工后很多接口设计决策会变得清晰单纯返回数据用ResponseBody就够需要动态状态码、自定义响应头、条件化响应时用ResponseEntity要在大文件下载时做流式输出StreamingResponseBody更合适。3. 第二步大考HttpMessageConverter的选型逻辑以及406的真相3.1 从“设备充电器”看消息转换器的匹配机制给我一个参数我给你一个转换器。HttpMessageConverter做的事情是把“Java对象”翻译成“HTTP消息体”或者反过来。它的匹配可以类比成充电器协议你的手机支持QC快充但充电头只支持PD协议插上去要么充不进电要么慢得要死HTTP侧更严格不匹配就直接拒绝。在AbstractMessageConverterMethodProcessor.writeWithMessageConverters里Spring会做这么几件事从请求里解析客户端可接受的媒体类型来源是Accept头。从方法或类上的RequestMapping(produces ...)、以及当前转换器支持的媒体类型中收集服务端能产出的媒体类型。两者求交集选出一个最合适的MediaType作为“选中的响应类型”。遍历注册好的HttpMessageConverter列表找第一个canWrite(返回值类型, 选中的MediaType)返回true的转换器。第1步是关键也是最容易忽略的。如果客户端没带Accept头Spring默认按*/*处理这时候服务端产出什么类型通常都能匹配上。但如果客户端明确写了Accept: application/xml而你的方法上标了produces application/json两边没有交集运行期就会抛HttpMediaTypeNotAcceptableException最终表现为406 Not Acceptable。406和415要分清406是“你要的我给不了”问题出在Accept和produces的匹配415是“你给我的我看不懂”问题出在Content-Type和consumes的匹配。很多人把这两个状态码混为一谈排查方向就全错了。3.2 转换器的注册顺序为什么String不会被JSON转换器抢走Spring Web MVC默认注册的消息转换器是有顺序的。简化版是ByteArrayHttpMessageConverter在最前面然后是StringHttpMessageConverter再到MappingJackson2HttpMessageConverterJSON、JAXB等。这个顺序不是随便排的是因为canWrite返回true的第一个转换器要负责到底。拿返回String举例遍历到StringHttpMessageConverter时它能处理String也能处理选中的text/plain或JSON它其实可以写JSON字符串所以直接接住。MappingJackson2HttpMessageConverter根本没有机会插手。而返回UserInfo对象时StringHttpMessageConverter的canWrite返回false它只支持String、CharSequence等类型遍历继续往下走最后落到Jackson转换器上。这里有个容易忽略的细节转换器的选择跟返回值类型和Content-Type是双条件关系。就算Jackson转换器支持写对象但如果服务端最终选中的媒体类型是text/plainJackson的canWrite大概率还是false因为它的supportedMediaTypes主要是application/json。两个条件都满足才会输出。3.3 乱码问题的根源StringHttpMessageConverter的字符集陷阱中文乱码是响应消息里最经典的问题之一。很多人一上来就加CharacterEncodingFilter但还是乱。原因在于StringHttpMessageConverter在早期版本里默认使用ISO-8859-1编码这个字符集里没有中文写出去当然乱。解法有几种按优先级排序方法上用RequestMapping(produces application/json;charsetUTF-8)显式声明字符集最直接。在配置类里注册Bean重新定义StringHttpMessageConverter并setDefaultCharset(StandardCharsets.UTF_8)。如果用的是Spring Boot大多数场景下已经通过HttpMessageConverters自动配置把字符集问题处理好了但如果你手动覆盖了转换器列表就得自己把字符集带进去。从响应消息的角度理解charsetUTF-8最终会落到Content-Type头里也就是Content-Type: application/json;charsetUTF-8。浏览器或前端框架解析响应体时会优先按这个字符集解码所以头信息写对了乱码往往就解决了。4. 第三步落地从POJO到字节流响应体写出的最后几跳4.1 一个JSON对象是怎么变成字节的选中了MappingJackson2HttpMessageConverter之后写出的过程大致是这样拿到要写的对象比如UserInfo。调用内部持有的ObjectMapper把对象序列化成JSON字符串或字节数组。设置响应头的Content-Type可能带上charset如果可以确定长度还会设置Content-Length。从HttpServletResponse拿到ServletOutputStream把字节写进去。第2步里ObjectMapper的可配置项会影响响应消息长什么样日期格式、空值是否输出、字段命名策略等。很多人发现“我加的JsonFormat没生效”往往是因为项目里注入的ObjectMapper不是Spring MVC正在用的那个。Spring MVC从容器里找MappingJackson2HttpMessageConverter时如果存在名为jacksonObjectMapper的Bean会用你自定义的那个否则才自己new。所以自定义Jackson配置要格外留意Bean类型和名字。4.2 值得注意的内部缓冲块数组带来的内存优势Spring在写响应体的过程中内部会用一个FastByteArrayOutputStream来做缓冲。它和JDK自带的ByteArrayOutputStream不同ByteArrayOutputStream是单块数组扩容时要整体复制FastByteArrayOutputStream内部是块状结构一块满了再挂新块不用反复复制已有数据对大响应体更友好。对大多数接口来说这个差异不明显——几百KB的响应体ByteArrayOutputStream也能扛。但如果你做批量导出、返回几MB甚至几十MB内容高频并发下块数组的缓冲方式能明显减少内存复制开销。这也是为什么Spring Math内部用这么个不太常见的东西而不是直接拿标准库凑合。4.3 流式输出与普通响应的区别普通对象经过转换器时Spring已经拿到了完整的序列化结果写完就结束。但StreamingResponseBody不是这么玩的它把ServletOutputStream交给你的业务代码由你在异步工作线程里一块一块写。这个模式适合大文件下载、长列表导出因为不需要在内存里构建完整响应体。SseEmitter则更进一步它维护了一条“事件通道”服务端可以多次向客户端推送数据每次推送都是一条独立的消息。对响应消息处理框架来说这两类都属于“边写边推”跟一次性转换后输出在生命周期上有本质差异。如果你在拦截器里给响应头做了统一设置注意流式输出场景可能不经过HttpMessageConverter头信息的注入时机要提前。5. 实战中的响应盲区包装体、状态码和手动写回的三个坑5.1 全局响应体包装导致状态码被“看起来”吞掉很多团队会在返回处理层做统一包装类似{ code: 0, message: ok, data: {...} }。这本身没错但容易滋生一个问题HTTP状态码和业务code语义分家。举个例子某个接口返回ResponseEntity.status(HttpStatus.NOT_FOUND).body(包装体)期望的HTTP状态码是404但前端只判断包装体里的code看到code0就当成功处理。排查过后发现业务上这个404是有意为之只是包装层把错误信息塞进了data而HTTP状态码被前端忽略了。反过来有些团队在全局异常处理器里一律返回HTTP 200只在业务code里区分成败这种做法对客户端不友好——监控系统按HTTP状态码统计可用性时会漏掉大量真实错误。我的建议能用HTTP状态码表达的错误语义就不要靠包装体里的字段硬扛。包装体撑死承载业务层面的错误码协议层面的成功失败交给正确的状态码表达。这样前端、网关、监控各取所需不容易互相踩脚。5.2 一个接口不要同时让两套逻辑写响应还有一种非常典型的现场Controller方法签名里有HttpServletResponse参数业务代码里直接response.getWriter().write(...)方法上又标了ResponseBody返回一个对象。运行时手动写入的内容和Spring转换器写出的内容会争夺同一个输出流结果轻则输出顺序错乱重则抛IllegalStateException: getWriter() has already been called。这是响应消息链路里“多路人马汇入一条河”导致的冲突。正确做法是二选一要么用HttpServletResponse手动写方法返回值设成void别再用ResponseBody。要么老老实实返回对象让转换器统一写响应体。需要精细控制时用ResponseEntitybyte[]配合HttpHeaders设置Content-Disposition、Content-Type等既保留控制力又不绕过转换器机制。5.3 拦截器改响应头时机比写法更重要有人会在HandlerInterceptor的afterCompletion里改响应头结果发现改了不生效。原因在于afterCompletion执行时视图渲染和响应提交可能已经完成很多容器对已提交的响应禁止再修改头部。正确的“改头”时机是preHandle请求还没进Controller此时可以往相同的HttpServletResponse对象上setHeader当然后续Controller也能再覆盖。Controller方法内部通过ResponseEntity或注入HttpServletResponse设置。postHandle如果还有未提交响应或过滤器里在最外层设置。用过滤器配合OncePerRequestFilter做统一响应头比如CORS头是最不容易踩坑的路径。因为过滤器包裹整个请求处理只要在filterChain.doFilter之前设置头基本不会遇到“提交后不可修改”的问题。技术细节之外我更想提醒的是排查响应消息类问题有一个万能的顺序——先看HTTP状态码再看Content-Type头然后定位到对应的消息转换器最后确认返回值类型有没有走对处理器分支。我自己很多次排障都是从这个顺序里找到突破口。如果这篇文章能帮你少走两个小时的弯路那它就没白写。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑