内容协商
本章聚焦 Spring MVC 的内容协商机制。在已经掌握请求映射、参数绑定、返回值处理之后,必须回答一个 RESTful API 设计的核心问题:同一个接口如何根据客户端的偏好,自动返回 JSON、XML 或 HTML 等不同格式的响应? 理解 ContentNegotiationManager 的决策流程和三种协商策略的优先级,是设计规范 RESTful 接口的基础。
定义与作用
内容协商(Content Negotiation)是 HTTP 协议的核心机制之一,指客户端和服务器就响应资源的表示格式达成一致的过程。在 Spring MVC 中,内容协商体现为:同一个 Controller 方法返回同一个 Java 对象,Spring MVC 根据客户端的偏好,自动选择将其序列化为 JSON、XML 或 HTML。
没有内容协商时,开发者需要为每种格式写独立的接口或手动判断:
// 丑陋的手动判断方式
@GetMapping("/user")
public Object getUser(@RequestParam String format) {
User user = userService.findById(1);
if ("json".equals(format)) {
return jsonConverter.write(user); // 手动转 JSON
} else if ("xml".equals(format)) {
return xmlConverter.write(user); // 手动转 XML
}
return user; // 默认
}
Spring MVC 的内容协商机制消除了这种手动判断。Controller 只需返回业务对象,"用什么格式"由协商策略决定,"怎么序列化"由 HttpMessageConverter 执行。
生活类比:多语言餐厅的点餐系统
想象一家国际化餐厅,菜单有中文、英文、日文三个版本:
- 没有内容协商:客人必须明确说"我要中文菜单",服务员才能给对。如果客人没说,服务员随机给一份,客人可能看不懂。
- 有内容协商:客人进门时,服务员观察客人的衣着、语言、护照(Accept 头),自动推断最可能需要的语言版本。如果推断不出来,再礼貌询问(fallback 策略)。
Spring MVC 的内容协商就是这位"自动推断的服务员"——它根据客户端发送的信号,选择最合适的响应格式,让 Controller 无需关心格式细节。
核心原理
内容协商决策流程
上图展示了 Spring MVC 内容协商的完整决策流程。注意三个策略的检查顺序:路径扩展 → 请求参数 → Accept 头。这个顺序不是随意的,而是由 ContentNegotiationManager 中注册的 ContentNegotiationStrategy 顺序决定。Spring Boot 默认配置下,路径扩展策略优先,但 RESTful 最佳实践建议关闭它。
三种协商策略对比
| 策略 | 触发方式 | 优先级(默认) | RESTful 推荐度 | 示例 |
|---|---|---|---|---|
| 路径扩展 | URL 后缀 | 最高(默认开启) | ❌ 不推荐 | /users/1.json |
| 请求参数 | URL 查询参数 | 中(默认关闭) | ⚠️ 谨慎使用 | /users/1?format=json |
| Accept 头 | HTTP 请求头 | 最低(默认) | ✅ 强烈推荐 | Accept: application/json |
路径扩展策略(默认开启,但应关闭)
// 请求:GET /users/1.json
// Spring MVC 从 .json 推断需要 application/json
// 问题:与 RESTful 资源定位冲突,且暴露实现细节
为什么不推荐:
/users/1.json暗示"1.json"是一个资源,但实际上资源是/users/1,JSON 只是表示格式- 路径扩展与内容类型耦合,更换序列化方式需要改 URL
- 存在安全风险(路径遍历与扩展名解析的边界情况)
请求参数策略(可选开启)
// 请求:GET /users/1?format=json
// 适合需要让非技术用户(如测试人员)快速切换格式的场景
// 但不应作为 API 的主要协商方式
Accept 头策略(RESTful 标准)
// 请求:GET /users/1
// Accept: application/json
// 或 Accept: application/xml
// 或 Accept: text/html
为什么推荐:
- 符合 HTTP 协议设计初衷:资源标识(URL)与表示格式(Accept)分离
- 同一 URL 可以有多种表示,不破坏资源定位的语义
- 前端框架、浏览器、API 测试工具都原生支持
Accept 头解析过程
关键理解:
- q 值(质量因子):客户端用
q=0.x表达偏好强度,越大越优先。application/json;q=0.9, application/xml;q=0.8表示优先 JSON - 通配符:
Accept: */*表示接受任何格式,通常返回默认格式(JSON) - 无 Accept 头:浏览器通常发送
Accept: text/html,application/xhtml+xml...,API 客户端可能不发送。此时使用默认格式
ContentNegotiationConfigurer 配置
Spring Boot 2.x / Spring 5.x 中,通过 WebMvcConfigurer 关闭路径扩展、启用 Accept 头策略:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
// 关闭路径扩展策略(不推荐在 RESTful API 中使用)
.favorPathExtension(false)
// 关闭 URL 参数策略(可选,根据团队规范决定)
.favorParameter(false)
// 设置默认内容类型,当无法协商时返回 JSON
.defaultContentType(MediaType.APPLICATION_JSON)
// 明确注册支持的媒体类型
.mediaType("json", MediaType.APPLICATION_JSON)
.mediaType("xml", MediaType.APPLICATION_XML);
}
}
与 HttpMessageConverter 的关系
内容协商和消息转换是分工协作的两个层次:
| 层次 | 职责 | 代表组件 |
|---|---|---|
| 内容协商 | 决定"用什么格式" | ContentNegotiationManager、ContentNegotiationStrategy |
| 消息转换 | 决定"怎么序列化" | MappingJackson2HttpMessageConverter、Jaxb2RootElementHttpMessageConverter |
关键边界:内容协商只负责"选格式",如果选中的格式没有对应的 HttpMessageConverter 支持,Spring MVC 会抛出 HttpMediaTypeNotAcceptableException(406 Not Acceptable)。
完整示例
场景
飞翔科技对外提供开发者开放平台 API。CTO 大翔要求 API 严格遵循 RESTful 规范,架构师白歌负责设计内容协商策略。前端黄俪的 Web 管理后台需要 JSON 数据,部分企业客户的老系统只支持 XML 解析,运维李眉需要偶尔用浏览器直接访问接口查看原始数据。
白歌决定:统一使用 Accept 头进行内容协商,关闭路径扩展策略,避免 URL 混乱。
配置内容协商
@Configuration
public class OpenApiConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.favorPathExtension(false) // 禁用 /user.json 这种写法
.favorParameter(false) // 禁用 ?format=json
.ignoreAcceptHeader(false) // 必须解析 Accept 头
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("json", MediaType.APPLICATION_JSON)
.mediaType("xml", MediaType.APPLICATION_XML);
}
// 注册 XML 转换器(Spring Boot 默认只有 JSON)
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// Jackson 处理 JSON(Spring Boot 已自动配置)
// 手动添加 JAXB XML 转换器
converters.add(new Jaxb2RootElementHttpMessageConverter());
}
}
支持 XML 的实体类
@XmlRootElement(name = "developer")
@XmlAccessorType(XmlAccessType.FIELD)
public class Developer {
private Long id;
private String name;
private String email;
// JAXB 需要无参构造器
public Developer() {}
public Developer(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// getters/setters...
}
Controller 代码(与格式完全解耦)
@RestController
@RequestMapping("/api/v1/developers")
public class DeveloperController {
@Autowired
private DeveloperService developerService;
@GetMapping("/{id}")
public Developer getDeveloper(@PathVariable Long id) {
// 完全不需要关心返回 JSON 还是 XML
// 由客户端的 Accept 头决定
return developerService.findById(id);
}
@GetMapping
public List<Developer> listDevelopers() {
return developerService.findAll();
}
}
不同客户端的请求与响应
黄俪的前端(JSON):
curl -H "Accept: application/json" http://localhost:8080/api/v1/developers/1
{
"id": 1,
"name": "白歌",
"email": "baige@feixiang.tech"
}
企业客户的老系统(XML):
curl -H "Accept: application/xml" http://localhost:8080/api/v1/developers/1
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<developer>
<id>1</id>
<name>白歌</name>
<email>baige@feixiang.tech</email>
</developer>
李眉用浏览器直接访问:
浏览器默认发送 Accept: text/html,...,如果服务器不支持 HTML 表示,会返回 406。白歌为此添加了 HTML 回退:
@Controller
@RequestMapping("/api/v1/developers")
public class DeveloperHtmlController {
@Autowired
private DeveloperService developerService;
// 当 Accept 包含 text/html 时命中此方法
@GetMapping(value = "/{id}", produces = MediaType.TEXT_HTML_VALUE)
public String getDeveloperHtml(@PathVariable Long id, Model model) {
model.addAttribute("developer", developerService.findById(id));
return "developer/detail"; // Thymeleaf 模板
}
}
变化分析:
- 小崔新增接口时,无需考虑格式问题,专注业务逻辑
- 黄俪前端调用时显式指定
Accept: application/json,获得紧凑的 JSON - 企业客户的老系统用
Accept: application/xml获得兼容格式 - 李眉用浏览器访问时自动渲染 HTML 页面,无需安装 Postman
- 白歌 review 时发现所有接口 URL 统一,没有
.json或.xml后缀,符合 RESTful 规范
易错场景与面试考点
误区一:路径扩展策略是 RESTful 的推荐做法
错误认知:"很多教程用 /user.json,所以这是标准做法。"
纠正:/user.json 是早期 Rails 等框架的习惯,不是 RESTful 规范。Roy Fielding 的 REST 论文明确指出:资源标识符(URL)应与表示格式分离。正确的 RESTful 设计是:
- 资源:
/users/1 - 格式偏好:
Accept: application/json
路径扩展的问题:
- 暴露技术实现细节到 URL
- 一个资源有 N 种格式就有 N 个 URL,违背"统一接口"原则
- 与缓存策略冲突(
/users/1.json和/users/1.xml被视为不同资源)
误区二:Content-Type 请求头决定响应格式
错误认知:"客户端发送 Content-Type: application/json,服务器就应该返回 JSON。"
纠正:Content-Type 描述的是请求体的格式,不是期望的响应格式。决定响应格式的是 Accept 请求头。混淆两者的典型错误:
# 错误:用 Content-Type 要求响应格式
POST /users
Content-Type: application/json ← 这表示"我发送的是 JSON"
Accept: application/xml ← 这才表示"我希望收到 XML"
GET 请求没有请求体,此时 Content-Type 毫无意义,不应发送。
误区三:协商失败时 Spring MVC 会自动 fallback
错误认知:"如果客户端要求 XML 但服务器不支持,会自动返回 JSON。"
纠正:默认行为是抛出异常,返回 406 Not Acceptable:
// 客户端:Accept: application/xml
// 服务器:没有配置 XML 转换器
// 结果:406 Not Acceptable
如果需要 fallback 到默认格式,需要显式配置:
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.defaultContentType(MediaType.APPLICATION_JSON) // 协商失败时返回 JSON
.ignoreAcceptHeader(false);
}
面试高频:内容协商的完整流程
标准回答:
- 请求到达,DispatcherServlet 调用 ContentNegotiationManager
- 按注册策略顺序尝试解析请求的媒体类型偏好:路径扩展 → 请求参数 → Accept 头
- 获取 Controller 返回值和候选 HttpMessageConverter 列表
- 遍历协商出的媒体类型,找到第一个支持该类型的 Converter
- Converter 将 Java 对象序列化为对应格式,写入响应体
- 设置
Content-Type响应头
面试高频:为什么 RESTful API 应该用 Accept 头
标准回答:
- 语义分离:URL 标识资源,Accept 标识格式,职责清晰
- 协议原生:Accept 头是 HTTP/1.1 标准的一部分,所有 HTTP 客户端都支持
- 缓存友好:同一 URL 配合 Vary: Accept 头,缓存系统可以正确区分不同表示
- 扩展性:新增格式无需改 URL,只需客户端改 Accept 头,服务端加 Converter
小结
Spring MVC 的内容协商机制通过 ContentNegotiationManager 协调多种策略,根据客户端偏好自动选择响应格式。RESTful API 的最佳实践是关闭路径扩展和参数策略,依赖 Accept 请求头,让 URL 专注资源定位,格式偏好通过标准 HTTP 机制表达。
核心分工记忆:
- 内容协商决定 "用什么格式"
- HttpMessageConverter 决定 "怎么序列化"
本章与全局的关系:本章讲解了 Spring MVC 如何根据客户端偏好选择响应格式。下一章"自定义参数解析器"将深入讲解 Spring MVC 的扩展机制——如何通过 HandlerMethodArgumentResolver 将自定义对象(如 JWT 解析后的 CurrentUser)直接注入 Controller 方法参数。