内容协商原理
一、是什么 ------ 内容协商到底在协商什么?
1. 一句话定义
内容协商(Content Negotiation)就是客户端和服务端"商量"用什么数据格式(JSON、XML、还是自定义协议)来传输响应数据的过程。
2. 用大白话理解
就像两个人见面先说用什么语言交流:
- 客户端(浏览器 / Postman / App):"我想要用户数据。我能听懂的格式有 JSON(最想要)、XML(也可以)、HTML(勉强接受)。"
- 服务端(Spring Boot):"收到。让我看看我能提供什么......我的工具箱里有 JSON 转换器、XML 转换器、自定义协议转换器。既然你最想要 JSON,我就把数据打包成 JSON 发给你。"
最终服务端返回的 Response Header 中会有:
Content-Type: application/json;charset=UTF-8
明确告诉客户端:"我实际返回的是 JSON 格式。"
3. 协商什么内容?
协商的核心是 HTTP 报文 Body 的媒体类型(Media Type / MIME Type):
| 格式 | MIME Type |
|---|---|
| JSON | application/json |
| XML | application/xml |
| 纯文本 | text/plain |
| HTML | text/html |
| 自定义 | application/x-guigu(项目中的自定义例子) |
二、为什么 ------ 为什么需要内容协商?
1. 同一个数据,不同客户端需要不同格式
GET /test/person
↓
服务端有一个 Person 对象:{userName: "zhangsan", age: 28, birth: "2000-01-01"}
↓
浏览器想要 → JSON:{"userName":"zhangsan","age":28,"birth":"2000-01-01"}
Postman 调试想要 → XML:<Person><userName>zhangsan</userName><age>28</age></Person>
特定 App 想要 → 自定义协议:zhangsan;28;2000-01-01
同一个 Controller 方法,同一个返回值,但根据客户端的需求输出不同格式 ------ 这就是内容协商要解决的问题。
2. 不这么做会怎样?
如果没有协商机制,你需要为每种格式写不同的 Controller 方法:getPersonJson()、getPersonXml()、getPersonGuigu()。代码重复,维护成本高。
三、怎么做 ------ 内容协商的六大步骤
一切发生在 AbstractMessageConverterMethodProcessor.writeWithMessageConverters() 中。
第一步:检查响应头是否已预设 Content-Type
java
MediaType contentType = outputMessage.getHeaders().getContentType();
boolean isContentTypePreset = contentType != null && contentType.isConcrete();
if (isContentTypePreset) {
selectedMediaType = contentType; // 直接锁定,跳过协商
}
如果你在 Controller 里写了 response.setContentType("application/pdf"),Spring 就尊重你的选择,不再自己去协商。
第二步:获取客户端能接受的类型(需求侧)
java
HttpServletRequest request = inputMessage.getServletRequest();
// 解析 Accept 请求头
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
底层调用 ContentNegotiationManager.resolveMediaTypes(),默认使用 HeaderContentNegotiationStrategy 解析请求头:
Accept: application/json, application/xml;q=0.9, */*;q=0.8
解析结果:
application/json(优先级最高)application/xml;q=0.9*/*;q=0.8(兜底,任何格式都行)
第三步:统计服务端能生产的类型(供给侧)
java
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
遍历所有 HttpMessageConverter,挨个调用 canWrite(Person.class, null):
MappingJackson2HttpMessageConverter → 我能写!支持 application/json
MappingJackson2XmlHttpMessageConverter → 我能写!支持 application/xml
StringHttpMessageConverter → 我能写!支持 text/plain
GuiguMessageConverter → 我能写!支持 application/x-guigu
最终 producibleTypes = [application/json, application/xml, text/plain, application/x-guigu]
第四步:双向匹配 ------ 找交集
java
List<MediaType> mediaTypesToUse = new ArrayList<>();
// 外层循环:客户端能接受的
for (MediaType requestedType : acceptableTypes) {
// 内层循环:服务端能生产的
for (MediaType producibleType : producibleTypes) {
// ★ 判断是否兼容
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(
getMostSpecificMediaType(requestedType, producibleType)
);
}
}
}
兼容规则:
application/json和application/json→ 精确匹配 ✅application/*和application/json→ 通配符匹配 ✅*/*和application/json→ 万能匹配 ✅
第五步:排序定音 ------ 选出最佳匹配
java
// 按 Specificity(具体程度)和 Quality(q 值权重)排序
MediaType.sortBySpecificityAndQuality(mediaTypesToUse);
// 选第一个最具体的
for (MediaType mediaType : mediaTypesToUse) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
}
}
排序规则(越靠前优先级越高):
- 更具体的类型 :
application/json>application/*>*/* - 更高的 q 值 :
q=1.0>q=0.9>q=0.8
第六步:找到对应 Converter,执行写出
java
for (HttpMessageConverter<?> converter : this.messageConverters) {
if (converter.canWrite(valueType, selectedMediaType)) {
// 【扩展点】调用 ResponseBodyAdvice 进行拦截增强
body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType, ...);
if (body != null) {
// ★ 最终写出:Java 对象 → JSON 字符串 → OutputStream
converter.write(body, selectedMediaType, outputMessage);
}
return; // 任务完成!
}
}
四、基于请求参数的内容协商
1. 为什么需要参数协商?
基于 Accept 请求头的协商虽然标准,但在浏览器手动调试时不方便------你没法随便改浏览器的 Accept 头。
Spring Boot 支持通过 URL 参数指定想要的格式:
http://localhost:8080/test/person?format=json → 返回 JSON
http://localhost:8080/test/person?format=xml → 返回 XML
http://localhost:8080/test/person?format=gg → 返回自定义协议
2. 如何开启
yaml
# 源码位置:springboot2-master/boot-05-web-01/src/main/resources/application.yaml
spring:
mvc:
contentnegotiation:
favor-parameter: true # ★ 开启基于请求参数的内容协商
开启后,ContentNegotiationManager 的策略链中会增加一个 ParameterContentNegotiationStrategy,并且它会被放在策略列表的第一位(优先级高于 Accept 请求头):
java
// ContentNegotiationManager.resolveMediaTypes()
for (ContentNegotiationStrategy strategy : this.strategies) {
// 第一次循环:ParameterContentNegotiationStrategy(优先)
List<MediaType> mediaTypes = strategy.resolveMediaTypes(request);
if (mediaTypes != null && !mediaTypes.equals(MEDIA_TYPE_ALL_LIST)) {
return mediaTypes; // ★ 参数有值直接返回,不再查 Accept 头!
}
}
3. ParameterContentNegotiationStrategy 的工作流程
java
public class ParameterContentNegotiationStrategy extends AbstractMappingContentNegotiationStrategy {
private String parameterName = "format"; // 默认参数名
@Override
protected String getMediaTypeKey(NativeWebRequest request) {
return request.getParameter(getParameterName()); // 取 ?format=xxx
}
// 在父类中:
public List<MediaType> resolveMediaTypeKey(NativeWebRequest webRequest, String key) {
if (StringUtils.hasText(key)) {
MediaType mediaType = lookupMediaType(key); // 查映射表
if (mediaType != null) {
return Collections.singletonList(mediaType);
}
}
return MEDIA_TYPE_ALL_LIST; // 没找到 → 返回兜底
}
}
内置映射表(Spring Boot 根据 classpath 自动填充):
| 参数值 | MediaType |
|---|---|
json |
application/json |
xml |
application/xml |
五、自定义内容协商策略(项目实例)
在 boot-05-web-01 项目中,我们实现了一套完整的内容协商增强:
java
// 源码位置:springboot2-master/boot-05-web-01/.../config/WebConfig.java
@Bean
public WebMvcConfigurer webMvcConfigurer() {
return new WebMvcConfigurer() {
// 1. 自定义内容协商策略
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
Map<String, MediaType> mediaTypes = new HashMap<>();
mediaTypes.put("json", MediaType.APPLICATION_JSON);
mediaTypes.put("xml", MediaType.APPLICATION_XML);
mediaTypes.put("gg", MediaType.parseMediaType("application/x-guigu")); // ★ 自定义!
ParameterContentNegotiationStrategy paramStrategy =
new ParameterContentNegotiationStrategy(mediaTypes);
HeaderContentNegotiationStrategy headerStrategy =
new HeaderContentNegotiationStrategy();
// ★ 参数策略在前,请求头策略兜底
configurer.strategies(Arrays.asList(paramStrategy, headerStrategy));
}
// 2. 追加自定义 HttpMessageConverter
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new GuiguMessageConverter());
}
};
}
完整流程验证:
请求:GET /test/person?format=gg
↓
① ParameterContentNegotiationStrategy 命中 "gg"
→ 查找映射表 → "application/x-guigu"
→ 返回 [application/x-guigu]
↓
② getProducibleMediaTypes()
→ GuiguMessageConverter.canWrite(Person.class) → true → 支持 application/x-guigu
↓
③ 匹配:需求 x-guigu = 供给 x-guigu ✅
↓
④ GuiguMessageConverter.write(person, x-guigu, output)
→ 写出:"zhangsan;28;2026-01-01"
⚠️ 注意事项
一旦手动调用
configurer.strategies(...),Spring 就完全听你的。如果你忘了把HeaderContentNegotiationStrategy放在最后兜底,那么标准的Accept请求头协商就会彻底失效,只能通过 URL 参数来协商格式。
六、请求头协商 vs 请求参数协商 对比
| 维度 | 请求头协商(Accept) | 请求参数协商(format) |
|---|---|---|
| 触发媒介 | HTTP Header Accept |
URL 参数 ?format=xxx |
| 核心类 | HeaderContentNegotiationStrategy |
ParameterContentNegotiationStrategy |
| 优先级 | 较低(参数策略存在时作为兜底) | 最高(只要参数存在就优先) |
| 适用场景 | 标准 REST API(Postman、移动端) | 浏览器调试、无法设置 Header 的场景 |
| 默认状态 | 始终启用 | 需手动开启 favor-parameter: true |
| 映射关系 | 直接解析 MIME 类型字符串 | 转换简写(json/xml)到标准 MIME 类型 |
七、总结 ------ 内容协商全流程图
Controller 返回 Person 对象
↓
RequestResponseBodyMethodProcessor.handleReturnValue()
↓
writeWithMessageConverters()
↓
┌─────────────────────────────────────────────┐
│ 第一步:检查响应头是否预设 Content-Type? │
│ 有 → 跳过协商,直接用 │
│ 没有 → 进入协商 │
├─────────────────────────────────────────────┤
│ 第二步:获取客户端需求 │
│ ContentNegotiationManager.resolveMediaTypes │
│ ├── 参数策略(优先):?format=json → JSON │
│ └── 请求头策略(兜底):Accept: app/json │
├─────────────────────────────────────────────┤
│ 第三步:统计服务端能力 │
│ 遍历所有 HttpMessageConverter.canWrite() │
│ → [application/json, application/xml, ...] │
├─────────────────────────────────────────────┤
│ 第四步:双向匹配 │
│ 需求 ∩ 供给 → 候选列表 │
├─────────────────────────────────────────────┤
│ 第五步:排序定音 │
│ 候选列表按 Specificity + Quality 排序 │
│ → selectedMediaType = application/json │
├─────────────────────────────────────────────┤
│ 第六步:执行写出 │
│ HttpMessageConverter.write(person, json) │
│ → JSON 字符串 → OutputStream │
└─────────────────────────────────────────────┘
↓
HTTP 响应:Content-Type: application/json
↓
客户端收到 JSON 数据
一句话总结:
内容协商 = 客户端需求(Accept 头 /
?format=参数) ∩ 服务端能力(HttpMessageConverter 支持的类型) → 排序取最优 → Converter 序列化写出。整个协商和写出过程在handleReturnValue()中一气呵成,不存在独立的"第二次转换"。