API版本管理策略:URL路径、请求头、Content-Type三种方案对比
2026/4/30大约 6 分钟
API版本管理策略:URL路径、请求头、Content-Type三种方案对比
适读人群:需要维护多版本API、向后兼容设计的Java开发者 | 阅读时长:约17分钟
开篇故事
做过对外 API 的人都懂这个痛苦:v1 版本发布后,有了新的需求,改了某个字段的格式,结果老客户端全挂了。
版本管理这件事,"做了"和"做对了"差距很大。三种主流方案各有适用场景,没有绝对的最优解。今天把三种方案都实现一遍,带上各自的优缺点和选型建议。
一、三种方案概览
1.1 URL 路径版本(最常见)
GET /api/v1/users/{id}
GET /api/v2/users/{id}1.2 请求头版本(RESTful 风格)
GET /api/users/{id}
X-API-Version: 21.3 Content-Type 版本(媒体类型版本)
GET /api/users/{id}
Accept: application/vnd.laozhang.v2+json1.4 直观对比
| 方案 | 可见性 | 缓存友好 | RESTful程度 | URL 干净 | 推荐场景 |
|---|---|---|---|---|---|
| URL 路径 | 高(一眼看出版本) | 好(URL不同) | 低(资源URL变了) | 差(版本污染URL) | 大多数场景 |
| 请求头 | 低(需看Header) | 差(同URL不同版本) | 中 | 好 | 内部服务 |
| Content-Type | 最低 | 差 | 高(学术正确) | 好 | 几乎不用 |
二、完整代码实现
2.1 方案一:URL 路径版本
package com.laozhang.version.controller;
import org.springframework.web.bind.annotation.*;
/**
* v1 Controller:老版本(UserDTO.phone 返回明文)
*/
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserDTOV1 getUser(@PathVariable Long id) {
User user = userService.getById(id);
return UserDTOV1.builder()
.id(user.getId())
.name(user.getName())
.phone(user.getPhone()) // 明文手机号
.build();
}
}
/**
* v2 Controller:新版本(phone 脱敏,新增 email 字段)
*/
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserDTOV2 getUser(@PathVariable Long id) {
User user = userService.getById(id);
return UserDTOV2.builder()
.id(user.getId())
.name(user.getName())
.phone(maskPhone(user.getPhone())) // 脱敏:138****1234
.email(user.getEmail()) // 新增字段
.build();
}
private String maskPhone(String phone) {
if (phone == null || phone.length() < 11) return phone;
return phone.substring(0, 3) + "****" + phone.substring(7);
}
}Spring Boot 配置:无需特殊配置,@RequestMapping 路径不同即可区分。
优点:
- 实现最简单
- URL 直观,开发者一眼看出版本
- 方便不同版本独立部署
- 浏览器缓存、CDN 缓存友好(URL 是缓存的 key)
缺点:
- 违反 RESTful 设计原则(资源是同一个,URL 不该不同)
- 版本数量多时,Controller 类爆炸
2.2 方案二:请求头版本
package com.laozhang.version.controller;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
/**
* v1 版本:headers 条件匹配
*/
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserDTOV1 getUserV1(@PathVariable Long id) {
// v1 逻辑
return userService.getDTOV1(id);
}
/**
* v2 版本
*/
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public UserDTOV2 getUserV2(@PathVariable Long id) {
// v2 逻辑
return userService.getDTOV2(id);
}
/**
* 默认版本(没有指定版本时,返回最新稳定版)
*/
@GetMapping("/{id}")
public UserDTOV2 getUser(@PathVariable Long id) {
return userService.getDTOV2(id);
}
}测试:
# 使用 v1 版本
curl -H "X-API-Version: 1" http://api.example.com/api/users/123
# 使用 v2 版本(或不带 header,默认最新版)
curl -H "X-API-Version: 2" http://api.example.com/api/users/1232.3 方案三:Content-Type 媒体类型版本
@GetMapping(
value = "/{id}",
produces = "application/vnd.laozhang.v1+json" // 生产 v1 格式
)
public UserDTOV1 getUserV1(@PathVariable Long id) {
return userService.getDTOV1(id);
}
@GetMapping(
value = "/{id}",
produces = "application/vnd.laozhang.v2+json" // 生产 v2 格式
)
public UserDTOV2 getUserV2(@PathVariable Long id) {
return userService.getDTOV2(id);
}# 请求 v1 格式
curl -H "Accept: application/vnd.laozhang.v1+json" http://api.example.com/api/users/1232.4 自定义版本注解(统一管理版本)
package com.laozhang.version.annotation;
import java.lang.annotation.*;
/**
* 自定义版本注解,让代码更清晰
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiVersion {
int value() default 1;
}package com.laozhang.version.config;
import com.laozhang.version.annotation.ApiVersion;
import org.springframework.web.servlet.mvc.condition.RequestCondition;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import jakarta.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.regex.Pattern;
/**
* 自定义 RequestMapping 处理器
* 支持 URL 路径版本(/api/v{n}/)和 Header 版本(X-API-Version)
*/
public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
private static final Pattern VERSION_PREFIX = Pattern.compile("/v(\\d+)/");
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
ApiVersion apiVersion = method.getAnnotation(ApiVersion.class);
return apiVersion != null ? new ApiVersionCondition(apiVersion.value()) : null;
}
// ApiVersionCondition 实现略
}2.5 版本协商:统一版本处理中间件
对于复杂场景,可以在过滤器层统一解析版本:
package com.laozhang.version.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* API 版本协商过滤器
* 将多种版本指定方式统一转换为 URL 路径版本
* 支持:URL路径、请求头、查询参数
*/
@Slf4j
@Component
public class ApiVersionNegotiationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
String version = extractVersion(request);
if (version != null && !path.contains("/v" + version + "/")) {
// 将版本信息注入到路径
String newPath = path.replace("/api/", "/api/v" + version + "/");
RequestDispatcher dispatcher = request.getRequestDispatcher(newPath);
dispatcher.forward(request, response);
return;
}
filterChain.doFilter(request, response);
}
/**
* 版本优先级:URL路径 > X-API-Version Header > Accept Header > 查询参数 > 默认值
*/
private String extractVersion(HttpServletRequest request) {
// 1. URL 路径里已有版本,直接用
String path = request.getRequestURI();
if (path.matches(".*/v\\d+/.*")) {
return null; // 已有版本,不处理
}
// 2. 检查请求头
String headerVersion = request.getHeader("X-API-Version");
if (headerVersion != null && !headerVersion.isEmpty()) {
return headerVersion;
}
// 3. 检查 Accept Header(vnd.laozhang.v2+json → 2)
String accept = request.getHeader("Accept");
if (accept != null && accept.contains("vnd.laozhang.v")) {
// 解析 application/vnd.laozhang.v2+json
java.util.regex.Matcher m = Pattern.compile("vnd\\.laozhang\\.v(\\d+)").matcher(accept);
if (m.find()) return m.group(1);
}
// 4. 查询参数 ?version=2
String paramVersion = request.getParameter("version");
if (paramVersion != null) {
return paramVersion;
}
// 5. 默认版本
return "1";
}
}三、版本迁移策略
3.1 渐进式迁移(推荐)
3.2 Swagger/OpenAPI 多版本文档配置
package com.laozhang.version.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("v1")
.displayName("API v1 (维护中)")
.pathsToMatch("/api/v1/**")
.build();
}
@Bean
public GroupedOpenApi v2Api() {
return GroupedOpenApi.builder()
.group("v2")
.displayName("API v2 (当前版本)")
.pathsToMatch("/api/v2/**")
.build();
}
}四、踩坑实录
坑1:旧版本代码删除太早,还有客户端在用
教训:v1 下线前一定要:
- 通过
Deprecated响应头告知客户端 - 设置明确的 Sunset 日期(
Sunset: Sat, 01 Jan 2025 00:00:00 GMT) - 监控 v1 流量,确认为零再下线
坑2:版本号和数据结构变更混在一起
症状:从 v1 升级到 v2,同时改了接口路径、字段名、错误码,调用方不知道改哪里。
原则:每次版本升级只做向后不兼容的最小改动,其他功能增强在同版本内以向后兼容方式添加。
坑3:忘记更新版本,直接改了 v1 的行为
症状:v1 接口某个字段格式悄悄变了,老客户端出错了。
建议:v1、v2 的 Controller 用单独的包,禁止在旧版本 Controller 里做破坏性改动:
com.laozhang.api.v1.controller.UserController // 冻结,只修 bug
com.laozhang.api.v2.controller.UserController // 当前活跃开发版本五、选型建议
根据 15 年的实践经验,给出简单直接的建议:
- 对外公开 API:URL 路径版本(
/api/v1/),最简单、最直观 - 内部服务间调用:请求头版本(
X-API-Version),URL 干净 - 追求 RESTful 正确性(如 API 平台):Content-Type 版本
80% 的场景用 URL 路径版本就够了,不要过度设计。最重要的是:无论选哪种方案,文档要清晰,迁移要提前通知,老版本要有明确的下线时间表。
