API 版本管理是微服务架构中一个看似简单、实则充满细节的话题。客户端很多,升级节奏也各不相同,一个"破坏性变更"稍有不慎就可能让线上支付或推送流程出问题。本文尝试梳理四种主流策略,并逐一对比 Spring Boot 4 原生支持与历史实现方案,最后总结生产环境中常见的问题。

为什么需要版本管理#

REST API 一旦对外发布,就变成了一份"隐式合同"。只要还有一个客户端在运行,你就不能随意更改字段含义、删除字段或修改响应结构。版本管理的本质,是为破坏性变更(Breaking Change)提供一条可控的演进通道,让新旧客户端能在同一套基础设施上共存。

什么算 Breaking Change?

  • 删除或重命名字段
  • 修改字段类型(如 amount: intamount: BigDecimal
  • 修改 HTTP 状态码语义
  • 业务流程从同步改为异步
  • 认证机制变更(如从 API Key 改为 OAuth2)

非破坏性变更(如新增可选字段、新增接口)通常不需要升版本。

四种主流策略#

1. URL 路径版本化#

版本号嵌入 URL 路径,是目前很常见的方案,Stripe、GitHub、PayPal 等公开 API 也都能看到这种风格。

GET /api/v1/payments
GET /api/v2/payments

优点: 直观、易调试、网关路由配置简单(前缀匹配)、Swagger 文档可以生成完全隔离的两套页面。

缺点: 粒度较粗。90% 的接口没有变化,但客户端却需要将所有调用路径从 /v1/ 改为 /v2/

关于 /api/v1/payments/api/payments/v1 的选择:前者将版本视为整个 API 服务的快照,网关路由更友好;后者将版本视为某个资源的属性,粒度更细但运维复杂度更高。在我接触到的大多数团队里,前者更常见。

2. 查询参数版本化#

版本号以 Query Parameter 的形式传递:

GET /api/payments?version=1
GET /api/payments?version=2

Spring MVC 中通过 @GetMappingparams 属性实现:

@GetMapping(value = "/payments", params = "version=1")
public List<Payment> getV1() { ... }

@GetMapping(value = "/payments", params = "version=2")
public List<Payment> getV2() { ... }

优点: URL 路径保持稳定,版本切换对 URL 的侵入性最小。

缺点: 在浏览器缓存、CDN 缓存配置上容易产生歧义;在 Swagger/OpenAPI 文档中不够直观。

3. 请求头版本化#

版本号通过自定义 HTTP Header 传递:

GET /api/payments
X-API-VERSION: 1

Spring MVC 中通过 headers 属性实现:

@GetMapping(value = "/payments", headers = "X-API-VERSION=1")
public List<Payment> getV1() { ... }

优点: URL 完全干净,不暴露版本细节,适合内部微服务间调用或对 URL 风格有严格要求的场景。

缺点: 测试和调试需要额外设置 Header,浏览器直接访问不方便;网关按版本做流量切分时,配置比路径匹配复杂。

4. 内容协商版本化(Media Type)#

通过标准 HTTP Accept 请求头携带版本信息:

GET /api/payments
Accept: application/vnd.mycompany.v1+json

Spring MVC 中通过 produces 属性实现:

@GetMapping(value = "/payments", produces = "application/vnd.mycompany.v1+json")
public List<Payment> getV1() { ... }

@GetMapping(value = "/payments", produces = "application/vnd.mycompany.v2+json")
public List<Payment> getV2() { ... }

优点: 从资源表达角度看更贴近很多 REST 风格讨论,URL 也能保持更稳定的资源语义。

缺点: 对客户端要求高,调试成本高,在 Swagger/OpenAPI 生成上支持程度参差不齐,实践中采用率相对较低。


四种策略的横向对比:

策略 URL 侵入性 网关路由 调试友好度 代表案例
URL 路径 极佳 极佳 Stripe、GitHub
查询参数 一般 -
请求头 一般 一般 内部微服务
内容协商 学术型/标准型项目

Spring Boot 4 之前的实现方案#

在 Spring Framework 7 / Spring Boot 4 引入原生支持之前,开发者需要用以下方式自行实现版本路由。

URL 路径:硬编码#

最简单直接,也是使用最广泛的方案:

@RestController
@RequestMapping("/api/v1/payments")
public class PaymentControllerV1 {
    @GetMapping
    public List<Payment> list() { ... }
}

@RestController
@RequestMapping("/api/v2/payments")
public class PaymentControllerV2 {
    @GetMapping
    public List<Payment> list() { ... }
}

缺点是当 V1 和 V2 大量逻辑相同时,代码冗余严重。常见解法是让 V2 Controller 继承 V1,仅覆盖有变化的方法,但继承深了同样难以维护。

通过 server.servlet.context-path 统一注入#

application.properties 中:

server.servlet.context-path=/api/v1

Controller 内只写资源路径:

@RestController
@RequestMapping("/payments")
public class PaymentController { ... }

迁移到 V2 时只改配置,不改代码。缺点是整个服务只能有一个版本,多版本并存需要额外部署多个服务实例。

自定义 RequestCondition(工程化方案)#

这是一个比较灵活、代码侵入性也相对较低的方案,更适合愿意投入一点基础设施成本的系统。核心思路是自定义路由匹配条件,将版本信息从注解中读取并注入到 Spring MVC 的路由机制中。

第一步: 自定义注解:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
    int value();
}

第二步: 实现 RequestCondition

public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {

    private final 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) {
        Matcher m = Pattern.compile("v(\\d+)").matcher(request.getRequestURI());
        if (m.find()) {
            int version = Integer.parseInt(m.group(1));
            if (version >= this.apiVersion) {
                return this;
            }
        }
        return null;
    }

    @Override
    public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
        return other.apiVersion - this.apiVersion; // 优先匹配高版本
    }
}

第三步: 重写 RequestMappingHandlerMapping,使 @ApiVersion 注解生效:

public class ApiVersionHandlerMapping extends RequestMappingHandlerMapping {

    @Override
    protected RequestCondition<ApiVersionCondition> getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion annotation = handlerType.getAnnotation(ApiVersion.class);
        return annotation != null ? new ApiVersionCondition(annotation.value()) : null;
    }

    @Override
    protected RequestCondition<ApiVersionCondition> getCustomMethodCondition(Method method) {
        ApiVersion annotation = method.getAnnotation(ApiVersion.class);
        return annotation != null ? new ApiVersionCondition(annotation.value()) : null;
    }
}

使用效果: 只需在 Controller 方法上打标签,URL 里的 /v2/payments 会自动路由到 @ApiVersion(2) 修饰的方法,其余未覆写的方法会自动降级到低版本实现:

@RestController
@RequestMapping("/api/v{version}/payments")
public class PaymentController {

    @GetMapping
    @ApiVersion(1)
    public List<Payment> listV1() { ... }

    @GetMapping
    @ApiVersion(2)
    public List<Payment> listV2() { ... }
}

这套方案的代码量不少,但一旦把基础设施搭好,业务开发者大多只需要关注注解,不必反复手写路由逻辑。

Spring Boot 4 / Spring Framework 7 原生支持#

Spring Framework 7 将 API 版本管理作为一等公民特性内置,核心设计思路与上面的自定义 RequestCondition 一致,但标准化了配置方式,并增加了语义化版本(Semantic Versioning)的支持。

开启版本管理#

WebMvcConfigurer 中通过 addVersioning 配置:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addVersioning(ApiVersioningConfigurer configurer) {
        configurer
            .addHeader("X-API-Version")         // 从 Header 读取版本
            .supportedVersions("1.0", "1.1", "2.0")
            .defaultVersion("1.0");
    }
}

也可以通过 application.properties 配置:

spring.mvc.api-versioning.header.name=X-API-Version
spring.mvc.api-versioning.supported-versions=1.0,1.1,2.0
spring.mvc.api-versioning.default-version=1.0

在 Controller 中声明版本#

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    @GetMapping(version = "1.0")
    public List<Payment> listV1() { ... }

    @GetMapping(version = "2.0")
    public List<Payment> listV2() { ... }
}

语义化版本与开放区间匹配#

Spring 7 默认支持 Major.Minor.Patch 格式。对我来说,比较值得关注的特性之一是开放区间匹配(Open Range Matching)

  • 你定义了 1.02.0 两个版本的接口
  • 客户端请求 1.5
  • Spring 自动将其路由到 1.0 的实现,直到你显式定义了 1.5 或更高版本的处理方法

这意味着对于没有发生变更的接口,你只需要写一次,所有更高版本的请求都会自动降级到这个实现。至少在框架层面,它确实能缓解“只有部分接口有 Breaking Change”时的维护压力。

全局路径前缀#

如果偏好 URL 路径风格,可以通过 PathMatchConfigurer 实现全局注入:

@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
    configurer.addPathPrefix(
        "/api/v{version}",
        c -> c.isAnnotationPresent(RestController.class)
    );
}

网关收到 /api/v2/payments 后,可以通过 StripPrefix 过滤器去掉 /v2,让内部微服务保持无版本感知,版本路由逻辑完全解耦在网关层。

测试支持#

Spring 7 同步升级了 MockMvcTester,提供流式 API 方便版本测试:

mockMvc.get()
    .uri("/api/payments")
    .header("X-API-Version", "2.0")
    .assertThat()
    .hasStatusOk();

常见问题与陷阱#

问题一:为非 Breaking Change 升版本#

最常见的错误。新增一个可选字段、增加一个新接口,这些都不需要升版本。随意升版本会让客户端频繁被迫升级,消耗大量联调成本。

一个比较稳妥的经验是:只有破坏性变更才考虑版本号升级。

问题二:接口丛林#

在同一个版本下,不断新增功能相近的接口来绕过 Breaking Change:

POST /api/v1/payments/create
POST /api/v1/payments/create-v2
POST /api/v1/payments/create-flexible
POST /api/v1/payments/create-final

几轮迭代后,新同事完全不知道该用哪个接口,文档也难以覆盖。

解法: 若接口改动影响参数结构,使用语义化命名(create-asynccreate-with-installments)并标记旧接口 @Deprecated;若积累超过三个类似接口,是时候正式升版本了。

问题三:Service 层缺少抽象导致代码重复#

V1 和 V2 的 Controller 各自独立,但内部 80% 的业务逻辑相同。一个 Bug 需要改两处,漏掉一处就是线上事故。

解法: Controller 层只负责参数映射和版本适配,核心逻辑下沉到 Service 层的单一方法。版本差异通过 Adapter 或参数转换在 Controller 层处理。

// Service 只有一份实现
public class PaymentService {
    public PaymentResult process(PaymentCommand cmd) { ... }
}

// V1 Controller 做参数转换
@GetMapping(version = "1.0")
public PaymentResponseV1 createV1(@RequestBody PaymentRequestV1 req) {
    PaymentCommand cmd = V1Adapter.toCommand(req);
    return V1Adapter.toResponse(service.process(cmd));
}

// V2 Controller 做参数转换
@GetMapping(version = "2.0")
public PaymentResponseV2 createV2(@RequestBody PaymentRequestV2 req) {
    PaymentCommand cmd = V2Adapter.toCommand(req);
    return V2Adapter.toResponse(service.process(cmd));
}

问题四:旧版本长期不退役#

版本号一直叠加,V1、V2、V3 同时在线,运维成本和安全风险都在增长。

解法: 在 API 文档和 HTTP 响应头中明确声明 Deprecation 时间线:

Deprecation: Sat, 01 Jan 2027 00:00:00 GMT
Sunset: Sat, 01 Jan 2027 00:00:00 GMT

配合监控,当旧版本流量降为零后,按时下线。

问题五:监控与可观测性碎片化#

/v1/payments/v2/payments 同时在线,Prometheus 的指标、ELK 的日志、链路追踪都会被拆散。想看"支付整体成功率"需要手工聚合多个指标。

解法: 在 Actuator 指标或日志中统一打上 api.version 标签,让监控系统能按版本维度聚合,也能按版本维度下钻。

总结#

阶段 更常见的选择
快速迭代 / 小团队 URL 路径硬编码,简单可靠
大型系统 / Spring Boot 3.x 自定义 RequestCondition,工程化程度高
Spring Boot 4+ 原生 addVersioning,开放区间匹配能覆盖不少场景
需要物理流量隔离 网关层路由(K8s Ingress / Spring Cloud Gateway)

API 版本管理没有银弹,选择适合当前团队规模和系统复杂度的方案才是关键。从 URL 路径起步,在系统复杂度增长到自定义 RequestCondition 值得投入的时候再迁移,等 Spring Boot 4 稳定后再享用原生支持——这条渐进路径在我看来对很多团队都比较务实。