尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringMVC内容协商机制解析:从Accept头到HttpMessageConverter的完整流程

SpringMVC内容协商机制解析:从Accept头到HttpMessageConverter的完整流程 1. 从一次“诡异”的接口响应说起最近在排查一个线上问题时遇到了一个挺有意思的现象。我们有一个对外提供数据服务的接口内部逻辑很简单就是查询数据库后返回一个标准的JSON对象。在Postman里测试一切正常返回的Content-Type是application/json数据格式工整。但前端同事反馈他们用axios调用这个接口时偶尔会收到一个406 Not Acceptable的错误或者更奇怪的是返回的数据变成了XML格式直接导致前端解析失败页面白屏。这让我有点懵。同一个接口同一个URL后端代码没动怎么返回的格式还能变呢经过一番排查问题的根源指向了HTTP请求头中的一个字段Accept。前端在某些场景下比如引入了某个第三方库或浏览器插件发出的请求其Accept头可能包含了application/xml的优先级高于application/json。而我们的SpringMVC应用在默认配置下默默“听从”了这个客户端的格式偏好试图返回XML但因为我们没有配置XML的转换器如JAXB2于是触发了406错误或者在某些配置下它成功返回了XML但内容却是Jackson序列化JSON对象后的奇怪文本根本不是合法的XML。这个“诡异”现象的背后就是SpringMVC一个强大但容易被忽视的机制内容协商Content Negotiation。它不是什么高深的新技术却是构建真正RESTful API、提升服务兼容性的基石。简单说内容协商就是服务端和客户端之间就“返回什么格式的数据”进行的一场友好有时也不那么友好的对话。客户端说“我想要这些格式按这个优先级。”服务端回应“好的我看看我能提供哪一种。”理解并正确配置内容协商不仅能解决上述的兼容性问题更能让你的API设计更加专业和灵活。它意味着你的同一个资源URI如/api/users/1可以根据客户端的不同需求动态地返回JSON、XML甚至PDF、Excel等不同表现形式的资源真正实现“表述性状态转移”REST中的“表述性”。接下来我们就深入SpringMVC的内容协商机制看看它是如何工作的以及如何驾驭它避免踩坑。2. 内容协商的核心机制客户端驱动与服务端能力匹配内容协商的本质是解决“一个资源多种表述”的问题。SpringMVC的内容协商策略主要基于HTTP/1.1规范中的相关内容其核心流程可以概括为检查请求 → 确定媒体类型 → 选择消息转换器 → 渲染响应。2.1 关键参与者HttpMessageConverter 与 ContentNegotiationStrategy在SpringMVC处理一个请求时有两个核心组件决定了最终响应的格式HttpMessageConverter (消息转换器)这是干活的“工人”。它负责将ResponseBody标注的控制器方法返回值或者ResponseEntity的body转换成HTTP响应体中的字节流同时设置正确的Content-Type。常见的转换器有MappingJackson2HttpMessageConverter: 处理JSON格式依赖Jackson库。Jaxb2RootElementHttpMessageConverter: 处理XML格式依赖JAXB。StringHttpMessageConverter: 处理文本。ByteArrayHttpMessageConverter: 处理字节流。服务端能返回什么格式根本上取决于你的应用中配置了哪些HttpMessageConverter。如果你没引入Jackson依赖那JSON转换器就不会存在没引入JAXB或配置XML支持XML转换器也不会工作。ContentNegotiationStrategy (内容协商策略)这是做决定的“经理”。它的职责是分析当前HTTP请求确定客户端期望的媒体类型Media Type。SpringMVC内置了多种策略最常用的是基于请求头的HeaderContentNegotiationStrategy和基于URL后缀的PathExtensionContentNegotiationStrategy。2.2 协商流程详解当一个请求到达DispatcherServlet并经过处理器映射找到对应的RestController或ResponseBody方法后内容协商的流程就启动了步骤一确定客户端接受的媒体类型列表ContentNegotiationManager内容协商管理器会委托其内部的一个或多个ContentNegotiationStrategy去分析请求。默认情况下它会按顺序尝试以下策略路径扩展名策略Path Extension检查请求URL的后缀。例如/api/user.json表示客户端期望JSON/api/user.xml表示期望XML。这是一种非常直观的方式但Spring Boot 2.x之后出于安全考虑防止通过文件扩展名进行攻击默认已禁用此策略从路径推导媒体类型除非显式配置。请求参数策略Parameter检查特定的请求参数。例如/api/user?formatjson。同样默认也是禁用的。Accept请求头策略Accept Header这是最标准、最推荐的RESTful方式。解析HTTP请求头中的Accept字段。例如Accept: application/json, text/html;q0.9, */*;q0.8。这里的q值q-factor表示权重值越高优先级越高。步骤二匹配服务端支持的媒体类型上一步会得到一个按客户端偏好排序的媒体类型列表如[application/json, text/html]。接下来SpringMVC会遍历这个列表并与当前处理器方法实际能产生的媒体类型进行匹配。“实际能产生的”类型由什么决定方法上RequestMapping的produces属性。方法的返回值类型以及已注册的HttpMessageConverter所支持的write类型。步骤三选择最佳匹配并调用对应转换器找到第一个既在客户端接受列表内又在服务端支持列表内的媒体类型即为本次协商的结果。然后SpringMVC会调用支持该媒体类型的HttpMessageConverter将方法返回值写入响应流。步骤四处理匹配失败如果遍历完客户端的所有接受类型都找不到服务端支持的那么SpringMVC会根据配置决定是返回一个406 Not Acceptable错误还是使用默认的媒体类型如果配置了的话。注意这里有一个常见的误解。内容协商不仅仅关于响应Accept头也关于请求Content-Type头。对于带有请求体如POST, PUT的请求SpringMVC同样会根据Content-Type头来选择对应的HttpMessageConverter来反序列化请求体到RequestBody参数。这可以看作是一种“请求内容协商”。但通常我们说的“内容协商”特指响应格式的协商。2.3 默认行为与潜在陷阱在Spring Boot的默认配置下通常只启用了Accept头策略。默认注册了MappingJackson2HttpMessageConverter如果classpath下有Jackson因此默认支持JSON。没有默认注册XML转换器除非你引入了相关依赖并可能需手动配置。这就解释了开头的案例前端请求的Accept头包含了application/xml服务端协商后认为客户端更想要XML但服务端没有XML转换器于是返回406或者如果某种配置下一个不合适的转换器被误用就可能产生格式错误的数据。3. 在Spring Boot中配置内容协商理解了原理配置就有了方向。Spring Boot通过WebMvcConfigurer接口提供了灵活的配置点。3.1 基础配置启用与禁用策略你可以通过实现WebMvcConfigurer并重写configureContentNegotiation方法来定制。Configuration public class WebConfig implements WebMvcConfigurer { Override public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { configurer // 忽略请求路径中的后缀完全依赖Accept头推荐用于纯API .ignoreAcceptHeader(false) // 默认false即不忽略。设为true则完全禁用Accept头协商。 // 是否支持通过请求参数指定格式例如 ?formatjson .favorParameter(false) // 默认false禁用 // 参数名如果favorParameter为true .parameterName(format) // 是否支持路径后缀例如 /data.json .favorPathExtension(false) // Spring Boot 2.x 默认false安全考虑 // 设置默认的媒体类型当无法协商出任何类型时使用 .defaultContentType(MediaType.APPLICATION_JSON) // 设置媒体类型与文件扩展名的映射 .mediaType(json, MediaType.APPLICATION_JSON) .mediaType(xml, MediaType.APPLICATION_XML); } }关键配置解析favorPathExtension(false)这是现代Spring Boot应用的安全最佳实践。避免攻击者通过构造类似/api/user.json的URL来试探或攻击。如果你确实需要此功能必须明确启用并知晓风险。favorParameter(false)同样为了避免API接口被随意格式化通常不建议开启。这会让你的API变得不够“纯粹”。defaultContentType这是一个重要的安全网。当客户端发送的Accept头是*/*接受任何类型或者服务器无法匹配任何客户端接受的类型时就会回退到这个默认类型。设置为APPLICATION_JSON是API服务的常见选择。mediaType如果你启用了路径后缀或参数策略这个映射告诉Spring后缀json对应application/json后缀xml对应application/xML。3.2 注册自定义的HttpMessageConverter内容协商决定了格式但最终渲染要靠HttpMessageConverter。如果你需要支持YAML、Protobuf、CSV等格式就需要注册对应的转换器。例如添加XML支持如果你使用JAXBConfiguration public class WebConfig implements WebMvcConfigurer { Override public void configureMessageConverters(ListHttpMessageConverter? converters) { // 确保Jackson转换器存在Spring Boot默认已添加 // 添加JAXB2转换器用于XML converters.add(new Jaxb2RootElementHttpMessageConverter()); // 注意添加转换器会覆盖Spring Boot的默认列表。 // 通常更推荐使用 extendMessageConverters 方法。 } Override public void extendMessageConverters(ListHttpMessageConverter? converters) { // 此方法用于在默认转换器列表基础上添加而不是覆盖。 // 例如确保XML转换器在JSON之后影响优先级 converters.add(new Jaxb2RootElementHttpMessageConverter()); } }转换器的优先级HttpMessageConverter列表是有顺序的。当有多个转换器都能处理同一种媒体类型时Spring会使用第一个匹配的。你可以通过调整converters列表中的顺序来控制优先级。3.3 使用produces属性进行精确控制在控制器方法级别你可以使用RequestMapping及其衍生注解如GetMapping的produces属性来明确声明该方法可以产生哪些媒体类型。这比全局配置优先级更高。RestController RequestMapping(/api/books) public class BookController { // 这个方法只产生JSON GetMapping(value /{id}, produces MediaType.APPLICATION_JSON_VALUE) public Book getBookJson(PathVariable Long id) { return bookService.findById(id); } // 这个方法可以产生JSON或XML由内容协商决定 GetMapping(value /{id}, produces {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE}) public Book getBook(PathVariable Long id) { return bookService.findById(id); } }使用produces的好处是意图明确并且SpringMVC会在内容协商阶段将服务端支持的类型严格限制在此列表内避免意外。4. 实战中的典型问题与解决方案理论配置之后让我们回到实战看看那些最容易“踩坑”的场景。4.1 问题一莫名其妙的406 Not Acceptable这是最常见的问题。可能的原因和解决方案客户端Accept头要求了服务端不支持的格式。排查查看客户端浏览器、Postman、代码发送的请求头。一个常见的“坑”是某些HTTP客户端库或浏览器默认的Accept头可能包含*/*之外的其他类型。解决服务端确保注册了对应的HttpMessageConverter如添加XML依赖和转换器。服务端配置一个合理的defaultContentType如JSON作为回退方案。客户端在发起请求时显式设置Accept: application/json。控制器方法的produces属性限制过死。排查检查你的RequestMapping注解。如果produces application/json那么即使服务端有XML转换器客户端请求Accept: application/xml也会得到406因为服务端已声明只“生产”JSON。解决根据需求调整produces列表或者移除它以使用全局协商策略。缺少必要的依赖。排查如果你期望支持XML项目pom.xml或build.gradle中必须包含JAXB或Jackson XML数据绑定依赖。解决添加依赖例如对于Spring Boot!-- 使用Jackson处理XML -- dependency groupIdcom.fasterxml.jackson.dataformat/groupId artifactIdjackson-dataformat-xml/artifactId /dependency添加此依赖后Spring Boot会自动注册MappingJackson2XmlHttpMessageConverter。4.2 问题二返回了错误的格式如JSON被解析为XML这种情况通常发生在内容协商策略的优先级和转换器匹配出现混乱时。路径后缀或参数策略被意外启用且优先级高于Accept头。场景你配置了favorPathExtension(true)并且请求的URL是/api/user无后缀但你的Accept头是application/xml。然而如果全局配置或某些过滤器意外添加了后缀或者客户端库行为不一致就可能出错。解决坚持使用Accept头作为唯一协商策略即保持favorPathExtension和favorParameter为false这是最符合HTTP标准和RESTful实践的方式也最清晰可控。HttpMessageConverter顺序问题。场景你同时注册了Jackson JSON和JAXB XML转换器。客户端Accept: */*。Spring会按转换器列表顺序选择第一个能处理返回对象类型的转换器。如果XML转换器排在前面且它能处理你的POJO比如有XmlRootElement注解那么就可能返回XML。解决在extendMessageConverters方法中调整顺序将你希望作为默认的转换器如JSON放在前面。或者更推荐使用defaultContentType来明确指定回退类型。4.3 问题三内容协商对ResponseBody和ResponseEntity的影响不同这是一个细微但重要的区别。ResponseBody其响应的Content-Type主要由内容协商结果决定。协商出的媒体类型会设置到响应头并选择对应的转换器。ResponseEntity你可以在构造ResponseEntity时直接设置Content-Type头例如return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(data);。这个显式设置的Content-Type优先级高于内容协商的结果。内容协商机制会尝试匹配这个类型如果匹配失败比如你设置了APPLICATION_XML但没有XML转换器同样会报错。最佳实践对于需要精确控制响应头的场景使用ResponseEntity。对于大多数遵循内容协商的通用API使用ResponseBody或RestController即可。5. 高级应用自定义内容协商策略与多格式导出掌握了基本配置和问题排查我们可以看看一些更高级的应用场景。5.1 自定义ContentNegotiationStrategy假设你的业务要求当请求来自某个特定的移动端APP通过自定义请求头X-Client-Type: MOBILE_APP标识时无论Accept头是什么都强制返回JSON格式。你可以实现一个自定义策略public class CustomHeaderContentNegotiationStrategy implements ContentNegotiationStrategy { Override public ListMediaType resolveMediaTypes(NativeWebRequest request) throws HttpMediaTypeNotAcceptableException { String clientType request.getHeader(X-Client-Type); if (MOBILE_APP.equalsIgnoreCase(clientType)) { // 移动端APP强制返回JSON return Collections.singletonList(MediaType.APPLICATION_JSON); } // 否则返回null让其他策略如默认的Accept头策略继续工作 return null; } }然后将其注册到ContentNegotiationConfigurer中并可以设置其顺序Ordered接口Override public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { configurer .defaultContentType(MediaType.APPLICATION_JSON) .strategies(Arrays.asList( new CustomHeaderContentNegotiationStrategy(), // 自定义策略优先 new HeaderContentNegotiationStrategy() // 默认的Accept头策略 )); }5.2 同一接口返回多种数据格式如JSON和CSV内容协商不仅限于JSON/XML。你可以很容易地让一个接口同时支持数据导出为CSV或Excel。添加依赖和转换器首先你需要一个能将对象列表转换为CSV的HttpMessageConverter。你可以使用像super-csv或opencsv这样的库并自己实现一个转换器或者使用Spring已有的一些扩展如AbstractHttpMessageConverter。注册媒体类型映射在配置中将.csv后缀或特定的媒体类型如text/csv映射到你的CSV转换器。控制器方法你的控制器方法返回一个对象列表如ListUser。当客户端请求Accept: text/csv或访问/api/users.csv时内容协商机制会匹配到CSV媒体类型并调用你注册的CSV转换器。示例片段// 1. 自定义CSV转换器 (简化示例) public class CsvHttpMessageConverter extends AbstractHttpMessageConverterList? { public CsvHttpMessageConverter() { super(new MediaType(text, csv)); } Override protected boolean supports(Class? clazz) { return List.class.isAssignableFrom(clazz); } Override protected void writeInternal(List? list, HttpOutputMessage outputMessage) throws IOException { // 使用OpenCSV等库将list写入outputMessage.getBody() // 设置响应头等 } } // 2. 注册转换器和媒体类型映射 Override public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { configurer .mediaType(csv, new MediaType(text, csv)); } Override public void extendMessageConverters(ListHttpMessageConverter? converters) { converters.add(new CsvHttpMessageConverter()); } // 3. 控制器 GetMapping(value /users, produces {application/json, text/csv}) public ListUser getUsers() { return userService.findAll(); }这样访问/api/users并设置Accept: text/csv就能直接下载CSV文件了。6. 测试策略如何验证你的内容协商配置配置好了如何测试是否按预期工作呢光靠浏览器不够因为浏览器Accept头比较复杂通常包含text/html。使用Postman或cURL这是最直接的方式。在请求中精确设置Accept头。curl -H Accept: application/json http://localhost:8080/api/usercurl -H Accept: application/xml http://localhost:8080/api/user观察响应状态码、Content-Type头和响应体格式。编写单元测试使用Spring的MockMvc可以方便地模拟请求并断言响应。SpringBootTest AutoConfigureMockMvc class UserControllerTest { Autowired private MockMvc mockMvc; Test void getUser_shouldReturnJson_whenAcceptJson() throws Exception { mockMvc.perform(get(/api/user/1) .header(Accept, application/json)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)); } Test void getUser_shouldReturn406_whenAcceptXmlButNotSupported() throws Exception { // 假设你的服务未配置XML支持 mockMvc.perform(get(/api/user/1) .header(Accept, application/xml)) .andExpect(status().isNotAcceptable()); // 406 } }集成测试使用TestRestTemplate或WebTestClient进行完整的集成测试更贴近真实HTTP调用。7. 总结与个人实践心得回顾内容协商的整个机制其核心思想是解耦资源与表述。同一个资源URI通过标准的HTTP协议主要是Accept头可以服务于需求各异的客户端。这对于构建长期演进、前后端分离的现代Web应用至关重要。在我自己的项目实践中有几点深刻的体会第一明确主次简化配置。对于绝大多数内部或对外的REST API我的建议是坚持使用且仅使用Accept请求头进行内容协商。禁用路径后缀和请求参数策略。这能保证API的纯粹性和安全性也最符合HTTP规范。在Spring Boot中这几乎就是默认行为无需额外配置。第二设置合理的默认值。务必配置defaultContentType。这能优雅地处理那些发送Accept: */*的“不挑剔”的客户端很多HTTP客户端库的默认行为或者处理一些边缘情况避免返回406。通常设置为MediaType.APPLICATION_JSON。第三依赖管理是关键。你的服务能输出什么格式不取决于你的愿望而取决于classpath里有什么HttpMessageConverter。想要支持XML引入jackson-dataformat-xml。想要自定义格式自己实现并注册转换器。在排查格式问题时首先检查依赖和转换器列表。第四善用produces属性进行接口契约强化。在编写控制器时如果某个接口明确只提供JSON那就加上produces MediaType.APPLICATION_JSON_VALUE。这不仅是文档也是一种约束可以提前避免一些意外的格式协商行为让接口的意图更加清晰。最后测试要覆盖多种Accept场景。不要只测试一种格式。为你的核心接口编写测试用例验证在Accept: application/json、Accept: application/xml如果支持以及Accept: */*下的行为是否符合预期。这能有效防止本文开头提到的“诡异”问题在线上发生。内容协商就像HTTP协议中一个优雅的握手礼仪。理解它配置它测试它能让你的SpringMVC应用在纷繁复杂的客户端环境中始终提供稳定、正确的数据表述这才是构建健壮服务接口的基础。
返回列表