Spring Boot 4.0 REST API 版本管理:URL、Header 与媒体类型
前言
接口一旦被 App、小程序、合作伙伴或内部服务调用,就不能随意改字段。新增一个字段通常兼容,删除字段、修改含义或更换结构却可能让旧客户端立刻出错。
版本管理不是为了保留无数份代码,而是为了让不兼容变化有明确的迁移边界。本章会让同一个订单资源同时提供三种入口:
/api/v1/orders/{orderNo}:URL 版本。X-Api-Version: 2:请求头版本。Accept: application/vnd.susan.order.v3+json:媒体类型版本。
配套源码:dvsusan/spring-boot4-demo。
最终结果
先创建一笔订单,然后分别调用:
curl "http://localhost:8080/api/v1/orders/SO685E417C3C4F"
curl "http://localhost:8080/api/orders/SO685E417C3C4F" \
-H "X-Api-Version: 2"
curl "http://localhost:8080/api/orders/SO685E417C3C4F" \
-H "Accept: application/vnd.susan.order.v3+json"三次请求访问同一笔订单,但响应契约不同:
- v1 返回基础订单字段。
- v2 增加
statusLabel。 - v3 增加
links,提供相邻资源入口。

图 1:三种版本方式最终都必须落到明确的响应契约,不能只换 URL 却返回同一不确定结构。
第 1 步:先判断什么时候真的需要新版本
下面这些变化通常可以直接在旧接口中完成:
- 新增可选响应字段。
- 新增可选请求字段。
- 增加更明确的错误信息。
- 增加新的独立接口。
下面这些变化通常需要新版本:
- 删除已有字段。
- 修改字段类型,例如
amount从数字变成对象。 - 改变字段含义,例如
status从枚举名称变成数字。 - 改变必填规则,使旧客户端请求突然失败。
版本号不是按发布日期递增。只有契约发生不兼容变化时,才需要创建新的主版本。
第 2 步:保留 URL 版本作为 v1
v1 继续使用上一章路径:
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
// v1 的路径、字段和状态码在这里保持稳定。
}URL 版本最直观。调试时从路径就能看出契约,日志和网关规则也容易按前缀配置。
代价是 URL 被版本占据。未来版本切换时,路由、文档和客户端配置可能都要一起变化。
第 3 步:用请求头选择 v2
先定义 v2 响应对象:
/**
* v2 响应在 v1 基础上增加面向展示的状态文本。
*/
public record OrderViewV2(
String orderNo,
String customerId,
BigDecimal amount,
String status,
String statusLabel
) {
}再在同一个 Controller 中增加 Header 条件:
@RestController
@RequestMapping("/api/orders")
public class OrderVersionController {
private final OrderService orderService;
public OrderVersionController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping(value = "/{orderNo}", headers = "X-Api-Version=2")
public ApiResponse<OrderViewV2> findV2(
@PathVariable String orderNo,
HttpServletResponse response
) {
// 同一 URL 的结果依赖请求头,缓存层必须按该头区分。
response.addHeader("Vary", "X-Api-Version");
OrderView order = orderService.findByOrderNo(orderNo);
return ApiResponse.success(new OrderViewV2(
order.orderNo(),
order.customerId(),
order.amount(),
order.status().name(),
"已创建"
));
}
}调用:
curl -i "http://localhost:8080/api/orders/SO685E417C3C4F" \
-H "X-Api-Version: 2"响应头应包含:
Vary: X-Api-Version这个头很容易漏。没有 Vary 时,CDN 或反向代理可能把 v2 响应缓存下来,再错误地返回给没有版本头的 v1 请求。
Header 版本的优点是 URL 保持稳定,适合内部服务。缺点是浏览器直接调试不方便,网关和缓存也必须理解版本头。
第 4 步:用厂商媒体类型选择 v3
v3 增加资源链接:
/**
* v3 响应增加可发现的资源链接。
*/
public record OrderViewV3(
String orderNo,
String customerId,
BigDecimal amount,
String status,
Map<String, String> links
) {
}媒体类型版本通过 produces 和 Accept 协商:
// 只有 Accept 与该厂商媒体类型匹配时才进入 v3 方法。
@GetMapping(
value = "/{orderNo}",
produces = "application/vnd.susan.order.v3+json"
)
public ApiResponse<OrderViewV3> findV3(@PathVariable String orderNo) {
OrderView order = orderService.findByOrderNo(orderNo);
return ApiResponse.success(new OrderViewV3(
order.orderNo(),
order.customerId(),
order.amount(),
order.status().name(),
Map.of(
"self", "/api/orders/" + orderNo,
"events", "/api/v1/events/orders/" + orderNo
)
));
}调用时必须设置 Accept:
curl -i "http://localhost:8080/api/orders/SO685E417C3C4F" \
-H "Accept: application/vnd.susan.order.v3+json"这种方式的 HTTP 语义最完整,调用方明确声明自己接受的表示格式。代价是配置复杂,浏览器、网关和 API 调试工具都要正确携带 Accept。
第 5 步:让版本选择可测试
测试类位于:
demo-web/src/test/java/com/susan/training/web/OrderApiIntegrationTest.java核心断言:
@Test
void headerAndMediaTypeSelectDifferentRepresentations() throws Exception {
String orderNo = createOrder("C3003", "66.00");
// Header 版本必须返回状态文案,并声明缓存差异来源。
mockMvc.perform(get("/api/orders/{orderNo}", orderNo)
.header("X-Api-Version", "2"))
.andExpect(status().isOk())
.andExpect(header().string("Vary", "X-Api-Version"))
.andExpect(jsonPath("$.data.statusLabel").value("已创建"));
// Accept 选择厂商媒体类型,响应中应包含 v3 链接。
mockMvc.perform(get("/api/orders/{orderNo}", orderNo)
.accept("application/vnd.susan.order.v3+json"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.links.self")
.value("/api/orders/" + orderNo));
}执行:
mvn test两类版本请求都应该通过。若第二个请求返回 406,先检查 Accept 是否与 produces 完全一致。
第 6 步:三种方案怎么选
| 方案 | 直观性 | 缓存配置 | 客户端改造成本 | 常见场景 |
|---|---|---|---|---|
| URL | 高 | 低 | 中 | 公开 API、长期维护接口 |
| Header | 中 | 中 | 中 | 内部服务、已有 URL 不能变 |
| 媒体类型 | 低 | 中高 | 高 | 强调 HTTP 语义和内容协商 |
本章实现三种方案,是为了让你理解差异。真实项目通常只选择一种主方案,避免同一资源出现四套版本规则。
如果团队没有成熟的网关、缓存和 SDK 治理能力,URL 版本通常最容易落地。版本策略不是越“纯正”越好,关键是团队能长期维护。
常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| Header 请求返回 404 | 缺少 X-Api-Version: 2 | 检查请求头和控制器条件 |
| v3 请求返回 406 | Accept 与 produces 不一致 | 使用完整厂商媒体类型 |
| 两个版本随机返回错误结果 | 缓存没有按版本头区分 | 增加 Vary,同时检查网关缓存键 |
| 启动时报映射冲突 | 同路径、同参数条件重复 | 检查 headers 和 produces 是否真正不同 |
| 所有客户端必须同时升级 | 把不兼容变化塞进旧版本 | 新建版本并制定迁移窗口 |
本章小结
API 版本是兼容性工具,不是发布记录。先判断变化是否真的破坏契约,再决定是否新建版本。
三种方式没有绝对优劣:
- URL 版本最清楚。
- Header 版本保持 URL 稳定。
- 媒体类型版本最符合 HTTP 内容协商。
选一种作为主策略,再为缓存、文档、测试和旧版本下线制定统一规则。
练习题
为 v2 增加 Accept-Version: 2 的灰度规则:
- 优先读取新请求头。
- 旧客户端继续使用
X-Api-Version。 - 两个头同时存在且值冲突时返回 400。
- 为响应增加
Vary: X-Api-Version, Accept-Version。
上一节:Spring MVC 接口设计
下一节:全局异常处理与错误码体系
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
