API 版本、分页与审计:演进中的接口如何保持可证明兼容
API 演进不是给 URL 加一个 v2 就结束。版本解析必须早于 handler 选择;分页必须在并发写入下仍有稳定顺序;审计必须说明谁在何时以哪个契约读写了什么资源。三者共同决定客户端能否安全重试、升级和追责。
接口版本和兼容性
版本策略先从简单可控开始。
路径版本:
/api/v1/orders
/api/v2/orders优点是直观,网关、日志、权限、文档都容易区分。大部分团队用它就够了。
如果使用 Spring MVC 新版本的 API Versioning 能力,可以用请求头:
spring:
mvc:
apiversion:
default: 1.0.0
supported:
- 1.0.0
- 2.0.0
required: false
use:
header: X-API-VersionController 可以按版本映射:
@GetMapping(path = "/api/orders/{orderId}", version = "1.0.0")
OrderResponse getV1(@PathVariable Long orderId) {
return orderQueryService.getOrder(orderId);
}
@GetMapping(path = "/api/orders/{orderId}", version = "2.0.0")
OrderDetailResponse getV2(@PathVariable Long orderId) {
return orderQueryService.getOrderDetail(orderId);
}请求:
curl -i http://127.0.0.1:8080/api/orders/1001 -H 'X-API-Version: 2.0.0'这套配置背后是 ApiVersionStrategy:它先通过 ApiVersionResolver 从 header、query、media type 参数或 path segment 取版本,再用 ApiVersionParser 把原始版本解析成可比较对象,最后参与 RequestMappingInfo 匹配。版本不支持或必填版本缺失时会进入 400 错误分支。你也可以用 WebMvcConfigurer#configureApiVersioning 控制多种解析策略的顺序,避免 header 和 query 同时存在时行为不稳定。
版本下线也要让客户端看得见。Spring MVC 的版本配置可以设置废弃版本处理器,标准处理器会给废弃版本响应增加 Deprecation、Sunset、Link 这类提示头。不要只在群里通知“v1 下个月删”,要让调用方的日志、网关采样和契约测试都能捕获到下线信号。
@Configuration
class WebApiVersionConfiguration implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useRequestHeader("X-API-Version")
.addSupportedVersions("1.0.0", "2.0.0")
.setVersionRequired(false);
// 废弃版本的响应头配置按当前 Spring Framework API 调整。
// 重点是让 Deprecation / Sunset / Link 进入响应和测试断言。
}
}验证方式不要只测 200:
curl -i http://127.0.0.1:8080/api/orders/1001 -H 'X-API-Version: 1.0.0'
curl -i http://127.0.0.1:8080/api/orders/1001 -H 'X-API-Version: 9.9.9'
curl -i http://127.0.0.1:8080/api/orders/1001你要分别确认:废弃版本能返回下线提示头,不支持版本返回明确 400,未传版本时走默认版本还是拒绝请求,这和 required 配置一致。detect-supported 这类自动探测能力也不要滥用;生产更稳的是显式登记支持版本、废弃日期、下线日期、负责人和兼容测试样例。
版本策略不要一开始就做得太复杂。真正重要的是兼容性规则:
新增响应字段通常兼容
删除响应字段通常不兼容
修改字段类型不兼容
枚举新增可能不兼容,要看客户端是否兜底
必填请求字段新增不兼容
错误码语义变化不兼容
分页默认排序变化可能不兼容接口上线前建议写契约检查:
curl -s http://127.0.0.1:8080/api/v1/orders/1001 | jq .如果有 OpenAPI 文档或契约测试,发布前要比对 schema,不能只跑单元测试。
写 Web 层测试
接口不是 Postman 手点一下就算测试。至少给核心 Controller 写 MockMvc 测试。
package com.example.order.api;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void getOrder() throws Exception {
mockMvc.perform(get("/api/v1/orders/{orderId}", 1001L))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderId").value(1001));
}
@Test
void createOrderRejectsInvalidBody() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"skuCode\":\"\",\"amount\":0}"))
.andExpect(status().isBadRequest());
}
}运行:
mvn -q -Dtest=OrderControllerTest testMockMvc 测什么:
路径和方法是否匹配
状态码是否正确
请求体校验是否生效
错误响应是否稳定
响应字段是否符合接口契约MockMvc 不适合替代所有集成测试。涉及真实数据库、网关、序列化全链路、鉴权链路时,还要补集成测试。
[ ] Controller 路径、HTTP 方法、版本策略明确
[ ] 废弃版本是否返回 `Deprecation` / `Sunset` / `Link` 头,是否有下线日期和负责人
[ ] query / path / header / body 参数来源清楚
[ ] 请求体和方法参数校验已覆盖
[ ] 成功响应和错误响应结构稳定
[ ] 错误码有文档和归属人
[ ] 400 / 401 / 403 / 404 / 405 / 415 / 500 语义正确
[ ] traceId 在请求头、响应头、日志、错误响应中可串起来
[ ] Filter 和 Interceptor 职责没有混乱
[ ] 鉴权没有散落在每个 Controller
[ ] 写接口有幂等策略
[ ] 超时、重试、分页上限、请求大小上限明确
[ ] MockMvc 或契约测试覆盖核心接口
[ ] Actuator mappings/env/loggers 不对公网暴露
[ ] 不在错误响应和日志里暴露堆栈、SQL、内网地址、密钥用可运行模型固定边界
两个模型不依赖 Spring 运行时,而是把策略选择和状态迁移压缩成 Java 17 程序。它们不是框架源码替身;价值在于先固定不变量,再回到真实应用观察哪个扩展点破坏了不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-mvc/api-version-pagination-audit/ApiVersionSelectionDemo.java examples/backend-development/spring-mvc/api-version-pagination-audit/PaginationAuditDemo.java
java -cp examples/backend-development/spring-mvc/api-version-pagination-audit ApiVersionSelectionDemo
java -cp examples/backend-development/spring-mvc/api-version-pagination-audit PaginationAuditDemorequested=2.0 selected=currentHandler deprecated=false
stableOrder=updatedAt,id pageSize=2 nextCursor=101:44 auditEvent=orders.list第一条输出验证正常路径,第二条刻意暴露分支或失败路径。把这两条输出放进持续集成,能防止重构只保持“接口能返回”,却改变匹配优先级、清理时机或错误契约。
状态图强调“选择先于执行、提交限制补救、所有分支最终清理”。真实框架包含更多策略对象,但任何自定义扩展都不应打破这三个约束。
把故障定位到第一个发生偏差的阶段
生产观测要控制基数。handler 模板可以作为指标标签,原始 URL、用户 ID、游标和异常消息不能直接成为标签。细节进入带采样和脱敏的日志或 trace;指标只回答哪一阶段、哪类结果、耗时分布是否偏离基线。
建立可回归的完成标准
当一个问题出现时,先判断它属于 Servlet 入口、MVC 策略选择、业务 handler 还是响应输出,再选择证据和修复点。这样扩展 Spring MVC 时增加的是明确策略,而不是更多互相覆盖的全局钩子。
