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/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;
    }
}

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

  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() 中一气呵成,不存在独立的"第二次转换"。

相关推荐
云和数据.ChenGuang3 小时前
用 Claude Code 重构遗留系统:从评估到落地的完整指南
java·人工智能·spring boot·后端·重构·springai
霸道流氓气质3 小时前
AI 应用异常检测与根因分析:语义漂移检测、因果图推理与LLM-as-Judge的 Java 生产级实现
java·开发语言·人工智能
xuxigifxfh3 小时前
牛客:BISHI2 【模板】栈的操作
java·开发语言·windows·算法
房开民3 小时前
通俗总结 Octree(八叉树)
java·服务器·数据库
豆角焖肉3 小时前
SpringBoot 集成腾讯 AI 人脸识别
人工智能·spring boot·后端·人脸识别·腾讯 ai·base64 图片
付威20233 小时前
如果你想拥有自己的 Agent,为什么值得从 rpi 开始
后端
木头科技3 小时前
【AI 工程化第十五篇】Spring AI 流式输出高并发性能优化实战:SSE、背压、限流、缓存与灰度发布
人工智能·spring·性能优化
2302_805771073 小时前
Day23:POM设计模式(封装)
java·开发语言·设计模式
SL_staff3 小时前
APS降本增效的关键:在JVS中实现‘人-机-料-法-环’全要素耦合调度
java·github·全栈
十年Java程序媛3 小时前
Java String、StringBuilder、StringBuffer 底层原理|字符串拼接性能与线程安全
java·开发语言·spring boot·安全·面试