一、一次接口变更让30个客户端崩溃
2018年,后端团队修改了一个返回字段的名字,把userName改成了username。
他们觉得这只是个小改动,没有通知客户端团队,直接上线了。
结果30个客户端全部崩溃------iOS、Android、H5、小程序,全部报错。
那天下午,全公司都在紧急修复,光回归测试就跑了一整天。
从那以后,我们对API版本管理有了血的教训:接口一旦发布,就是契约,不能随便改。
二、API版本管理策略
2.1 版本管理方式
┌─────────────────────────────────────────────────────────────────┐
│ API版本管理方式 │
│ │
│ 1. URL路径版本 │
│ /api/v1/users │
│ /api/v2/users │
│ 优点:直观、简单 │
│ 缺点:路由膨胀 │
│ │
│ 2. 请求头版本 │
│ GET /api/users │
│ Header: X-API-Version: 2 │
│ 优点:URL不变 │
│ 缺点:不够直观 │
│ │
│ 3. Content-Type版本 │
│ Content-Type: application/vnd.company.v2+json │
│ 优点:RESTful │
│ 缺点:复杂 │
│ │
│ 4. 查询参数版本 │
│ /api/users?version=2 │
│ 优点:简单 │
│ 缺点:不够规范 │
│ │
└──────────────────────────────────────────────────────────────────┘
2.2 版本演进规则
版本号规则:MAJOR.MINOR.PATCH
MAJOR:不兼容的变更
- 删除字段
- 修改字段类型
- 修改接口语义
MINOR:向后兼容的变更
- 新增字段
- 新增接口
- 新增枚举值
PATCH:Bug修复
- 不影响接口行为
三、Spring Boot实现
3.1 URL路径版本
java
/**
* API版本控制配置
*/
@Configuration
public class ApiVersionConfig {
/**
* 自定义版本注解
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
int value() default 1;
}
/**
* 版本路由条件
*/
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private int apiVersion;
public ApiVersionCondition(int apiVersion) {
this.apiVersion = apiVersion;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
return new ApiVersionCondition(other.apiVersion);
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
String path = request.getRequestURI();
Matcher matcher = Pattern.compile("/v(\\d+)/").matcher(path);
if (matcher.find()) {
int version = Integer.parseInt(matcher.group(1));
if (version >= apiVersion) {
return this;
}
}
return null;
}
@Override
public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
return other.apiVersion - apiVersion;
}
}
}
/**
* V1版本接口
*/
@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {
@GetMapping("/{id}")
public UserV1VO getUser(@PathVariable Long id) {
User user = userService.getById(id);
return UserV1VO.builder()
.id(user.getId())
.userName(user.getName()) // V1字段名
.email(user.getEmail())
.build();
}
}
/**
* V2版本接口(新增字段、修改字段名)
*/
@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {
@GetMapping("/{id}")
public UserV2VO getUser(@PathVariable Long id) {
User user = userService.getById(id);
return UserV2VO.builder()
.id(user.getId())
.username(user.getName()) // V2字段名(修改)
.email(user.getEmail())
.phone(user.getPhone()) // V2新增字段
.avatar(user.getAvatar()) // V2新增字段
.build();
}
}
3.2 版本兼容策略
java
/**
* 版本兼容适配器
*/
@Service
@Slf4j
public class UserApiAdapter {
/**
* 根据版本号返回对应VO
*/
public Object adapt(User user, int apiVersion) {
switch (apiVersion) {
case 1:
return UserV1VO.builder()
.id(user.getId())
.userName(user.getName())
.email(user.getEmail())
.build();
case 2:
return UserV2VO.builder()
.id(user.getId())
.username(user.getName())
.email(user.getEmail())
.phone(user.getPhone())
.avatar(user.getAvatar())
.build();
default:
return UserV2VO.from(user);
}
}
}
/**
* 统一用户接口(自动适配版本)
*/
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserApiAdapter adapter;
@GetMapping("/{id}")
public Object getUser(@PathVariable Long id,
@RequestHeader(value = "X-API-Version", defaultValue = "2")
int apiVersion) {
User user = userService.getById(id);
return adapter.adapt(user, apiVersion);
}
}
四、版本迁移策略
4.1 迁移流程
┌─────────────────────────────────────────────────────────────────┐
│ 版本迁移流程 │
│ │
│ 1. 新版本上线(与旧版本并存) │
│ - 新版本标记为Beta │
│ - 旧版本继续服务 │
│ │
│ 2. 通知客户端迁移 │
│ - 发布迁移文档 │
│ - 设置迁移截止日期 │
│ │
│ 3. 监控旧版本使用量 │
│ - 记录每个版本的调用量 │
│ - 通知未迁移的客户端 │
│ │
│ 4. 旧版本下线 │
│ - 确认所有客户端已迁移 │
│ - 旧版本返回410 Gone │
│ │
└──────────────────────────────────────────────────────────────────┘
4.2 版本监控
java
/**
* API版本监控
*/
@Aspect
@Component
@Slf4j
public class ApiVersionMonitor {
@Autowired
private MeterRegistry meterRegistry;
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " +
"@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PostMapping)")
public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest();
String apiVersion = request.getHeader("X-API-Version");
if (apiVersion == null) {
apiVersion = "1"; // 默认版本
}
String uri = request.getRequestURI();
// 记录版本使用量
meterRegistry.counter("api.version.calls",
"uri", uri,
"version", apiVersion)
.increment();
return joinPoint.proceed();
}
}
五、踩坑实录
坑1:没有版本控制
接口直接改了,所有客户端报错。
解决:所有接口必须有版本号,变更走新版本。
坑2:版本太多维护不过来
同时维护5个版本,代码重复严重。
解决:限定同时支持的版本数量(最多2-3个),加速旧版本下线。
坑3:迁移期太长
旧版本一直在用,新版本没人迁移,维护成本越来越高。
解决:设置明确的下线时间,过期返回410。
坑4:内部接口没有版本管理
内部微服务间调用没有版本控制,一方改了接口,另一方就挂。
解决:内部接口也要版本管理,使用Feign的fallback。
坑5:文档和代码不同步
API文档还是旧版本的,代码已经改了。
解决:使用Swagger/SpringDoc自动生成文档。
六、总结
API版本管理要点:
| 原则 | 说明 |
|---|---|
| 契约精神 | 接口一旦发布,不可随意修改 |
| 向后兼容 | 新版本要兼容旧版本 |
| 版本共存 | 新旧版本并存,平滑迁移 |
| 及时下线 | 旧版本定期清理 |
| 文档同步 | 代码和文档保持一致 |
最佳实践:
- URL路径版本最实用
- 同时支持的版本不超过3个
- 监控每个版本的使用量
- 设置明确的下线时间
- 内部接口也要版本管理
血的教训:
API是团队之间的契约。改一行代码前,想想会影响谁。版本管理不是负担,是保护伞。
思考题: 你的API有版本管理吗?有没有因为接口变更导致的问题?
个人观点,仅供参考