Spring Boot 从“会用”到“精通”:内容协商原理

内容协商原理

一、是什么 ------ 内容协商到底在协商什么?

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/jsonapplication/json → 精确匹配 ✅
  • application/*application/json → 通配符匹配 ✅
  • */*application/json → 万能匹配 ✅

第五步:排序定音 ------ 选出最佳匹配

java 复制代码
// 按 Specificity(具体程度)和 Quality(q 值权重)排序
MediaType.sortBySpecificityAndQuality(mediaTypesToUse);

// 选第一个最具体的
for (MediaType mediaType : mediaTypesToUse) {
    if (mediaType.isConcrete()) {
        selectedMediaType = mediaType;
        break;
    }
}

排序规则(越靠前优先级越高):

  1. 更具体的类型application/json > application/* > */*
  2. 更高的 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() 中一气呵成,不存在独立的"第二次转换"。

相关推荐
Web打印2 小时前
HttpPrinter web打印控件 官方文档(https://wiki.httpprinter.com/)快速检索目录
java·javascript·chrome
cfm_29142 小时前
Java JVM 零基础入门
java·jvm
兰令水2 小时前
leecodecode【状态机DP】【2026.6.9打卡-java版本】
java·开发语言·算法
我是一颗柠檬2 小时前
【Java项目技术亮点】接口限流熔断:从Sentinel到令牌桶/漏桶,手把手教你构建高可用服务防护体系
java·数据库·sentinel
宸津-代码粉碎机2 小时前
Spring AI企业级实战|Agent长期记忆持久化落地,彻底解决多轮对话上下文丢失问题
java·开发语言·人工智能·后端·python·spring
开源推荐官2 小时前
2026 商城系统源码实测,真正适合二开的系统有哪些?
java·架构·开源
云烟成雨TD2 小时前
Spring AI 1.x 系列【58】提示词工程(Prompt Engineering)
java·人工智能·spring
總鑽風2 小时前
[特殊字符] Spring AI Alibaba企业级智能助手落地实践
java·人工智能·spring
Flittly2 小时前
【AgentScope Java新手村系列】(1)框架简介与环境搭建
java·spring boot·笔记·spring·ai