多版本API管理:Spring Cloud Gateway的版本路由与滚动升级
多版本API管理:Spring Cloud Gateway的版本路由与滚动升级
适读人群:有微服务实战经验的后端工程师 | 阅读时长:约22分钟 | Spring Boot 3.2 / Spring Cloud 2023.0
开篇故事
我们有个对外开放的API,不同的合作商接入了不同版本。去年要上一个破坏性的接口变更(某个字段从String改成了Object),新格式对旧客户端完全不兼容。
如果直接改,所有合作商的客户端代码都会出错。如果不改,新功能没法上。运营那边催着要,技术这边左右为难。
最终的解决方案是多版本API共存:新接口走/api/v2/order,旧接口走/api/v1/order,v1的接口继续维护一段时间(6个月),同时通知合作商升级。Gateway层根据请求路径里的版本号,把流量路由到对应版本的服务实例上。
这套方案看起来简单,但实现细节里有很多坑:Controller里怎么优雅地管理多版本,Gateway的路由规则怎么配,版本号用URL路径还是Header,旧版本怎么平滑下线……今天把这套方案完整写出来。
一、核心问题分析
API版本管理有三种主流方案:
URL路径版本:/api/v1/orders、/api/v2/orders。最直观,SEO友好,但URL变了,客户端代码也要改。
Query参数版本:/api/orders?version=2。不改URL,但不够规范,容易被缓存污染。
Header版本:Accept: application/vnd.api+json; version=2。最符合RESTful规范,但客户端实现麻烦,对接成本高。
我们最终选择了URL路径版本,原因是最直观,合作商对接成本最低,也最容易在Gateway层做路由。
版本路由有两个维度:
- Gateway层路由:根据URL前缀把请求路由到对应版本的服务实例
- 应用层路由:同一服务内部,根据版本号执行不同的业务逻辑
二、原理深度解析
2.1 版本路由架构
2.2 滚动升级时的版本路由
2.3 应用层版本路由方案对比
三、完整代码实现
3.1 Gateway路由配置(URL路径版本)
spring:
cloud:
gateway:
routes:
# v2版本路由(新版本,优先级高)
- id: order-service-v2
uri: lb://order-service-v2
predicates:
- Path=/api/v2/order/**
filters:
- StripPrefix=2 # 去掉/api/v2前缀,后端服务接收/order/**
# 添加版本Header,让后端服务能识别版本
metadata:
response-timeout: 5000
connect-timeout: 2000
# v1版本路由(旧版本,维护期内)
- id: order-service-v1
uri: lb://order-service-v1
predicates:
- Path=/api/v1/order/**
filters:
- StripPrefix=2
# 添加Deprecated警告Header
- AddResponseHeader=X-API-Deprecated, "true"
- AddResponseHeader=X-API-Sunset-Date, "2025-06-30"
# 无版本前缀的请求,默认路由到最新版本(v2)
- id: order-service-default
uri: lb://order-service-v2
predicates:
- Path=/api/order/**
filters:
- StripPrefix=13.2 Gateway过滤器:版本降级与兼容性
package com.laozhang.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* API版本检查过滤器
* 当v1接口进入下线期时,返回适当的提示信息
*/
@Slf4j
@Component
public class ApiVersionGatewayFilterFactory
extends AbstractGatewayFilterFactory<ApiVersionGatewayFilterFactory.Config> {
public ApiVersionGatewayFilterFactory() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String path = exchange.getRequest().getURI().getPath();
String version = extractVersion(path);
if ("v1".equals(version) && config.isDeprecated()) {
// 在响应头中添加废弃警告
exchange.getResponse().getHeaders()
.add("Deprecation", "version=\"v1\"");
exchange.getResponse().getHeaders()
.add("Sunset", config.getSunsetDate());
exchange.getResponse().getHeaders()
.add("Link", "<" + config.getV2ApiDoc() + ">; rel=\"successor-version\"");
}
// 如果v1已经完全下线,返回410 Gone
if ("v1".equals(version) && config.isShutdown()) {
exchange.getResponse().setStatusCode(HttpStatus.GONE);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
};
}
private String extractVersion(String path) {
if (path.contains("/v1/")) return "v1";
if (path.contains("/v2/")) return "v2";
if (path.contains("/v3/")) return "v3";
return "latest";
}
public static class Config {
private boolean deprecated = false;
private boolean shutdown = false;
private String sunsetDate = "2025-12-31";
private String v2ApiDoc = "https://api.example.com/v2/docs";
// getters and setters
public boolean isDeprecated() { return deprecated; }
public void setDeprecated(boolean deprecated) { this.deprecated = deprecated; }
public boolean isShutdown() { return shutdown; }
public void setShutdown(boolean shutdown) { this.shutdown = shutdown; }
public String getSunsetDate() { return sunsetDate; }
public void setSunsetDate(String sunsetDate) { this.sunsetDate = sunsetDate; }
public String getV2ApiDoc() { return v2ApiDoc; }
public void setV2ApiDoc(String v2ApiDoc) { this.v2ApiDoc = v2ApiDoc; }
}
}3.3 应用层版本路由(策略模式)
package com.laozhang.order.controller;
import com.laozhang.order.dto.OrderQueryRequest;
import com.laozhang.order.dto.OrderResponse;
import com.laozhang.order.handler.OrderQueryHandler;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 统一版本路由Controller
* 支持在同一服务实例里处理多个版本的请求
* 适用于版本差异较小的场景(避免维护多套服务实例)
*/
@RestController
@RequestMapping("/order")
public class OrderController {
private final Map<String, OrderQueryHandler> queryHandlers;
public OrderController(Map<String, OrderQueryHandler> queryHandlers) {
this.queryHandlers = queryHandlers;
}
/**
* 统一接收所有版本的查询请求
* 从URL中提取版本号,分发到对应的Handler
*/
@GetMapping("/{id}")
public OrderResponse getOrder(
@PathVariable String id,
@RequestHeader(value = "X-API-Version", defaultValue = "v2") String version
) {
OrderQueryHandler handler = queryHandlers.getOrDefault(
version,
queryHandlers.get("v2") // 未知版本默认用v2
);
return handler.getOrder(id);
}
}package com.laozhang.order.handler;
import com.laozhang.order.dto.OrderResponse;
public interface OrderQueryHandler {
OrderResponse getOrder(String orderId);
}package com.laozhang.order.handler;
import com.laozhang.order.dto.OrderResponse;
import org.springframework.stereotype.Component;
/**
* v1版本查询Handler
* 返回旧格式的响应(String字段)
*/
@Component("v1") // Bean名称即版本号,注入到Controller的Map里
public class OrderQueryHandlerV1 implements OrderQueryHandler {
@Override
public OrderResponse getOrder(String orderId) {
// v1返回旧格式:status是String
return OrderResponse.v1Format(orderId, "PAID", "2024-01-01");
}
}
/**
* v2版本查询Handler
* 返回新格式的响应(Object字段)
*/
@Component("v2")
class OrderQueryHandlerV2 implements OrderQueryHandler {
@Override
public OrderResponse getOrder(String orderId) {
// v2返回新格式:status是Object,包含code和displayName
return OrderResponse.v2Format(orderId,
new StatusVO("PAID", "已支付"),
"2024-01-01T10:30:00"
);
}
}3.4 多版本API文档配置(SpringDoc)
package com.laozhang.order.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 OpenApiConfig {
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("v1")
.pathsToMatch("/api/v1/**")
.addOpenApiCustomizer(openApi ->
openApi.info(new Info()
.title("Order Service API v1")
.description("已废弃,请迁移到v2")
.version("1.0")
.deprecated(true)
)
)
.build();
}
@Bean
public GroupedOpenApi v2Api() {
return GroupedOpenApi.builder()
.group("v2")
.pathsToMatch("/api/v2/**")
.build();
}
}3.5 Nacos元数据标记版本(配合标签路由)
spring:
cloud:
nacos:
discovery:
metadata:
version: v2 # 标记当前实例的版本
api-version: "2" # API版本四、生产配置与调优
4.1 版本过渡期的监控
// 在Gateway过滤器里统计v1/v2的流量占比
// 当v1流量降到一定阈值后,可以考虑下线v1
meterRegistry.counter("api.requests",
"version", version,
"path", path
).increment();4.2 版本下线的灰度策略
# 分阶段下线v1
# 第一阶段:v1返回Deprecation警告,但仍然正常工作
# 第二阶段:v1返回503,建议迁移
# 第三阶段:v1返回410 Gone
routes:
- id: order-service-v1
filters:
- name: ApiVersion
args:
deprecated: true
sunsetDate: "2025-06-30"
# shutdown: true # 完全下线时开启五、踩坑实录
坑一:StripPrefix数量不对,后端服务收到了带版本前缀的路径。
Gateway配置了StripPrefix=2,意思是去掉路径的前2段(/api/v2)。但如果忘了改,后端服务收到的是/v2/order/123而不是/order/123,所有接口404。
一定要在测试环境验证StripPrefix配置,确认后端服务接收到的路径是正确的。
坑二:多版本Controller冲突,同一个接口被两个Controller声明。
在同一个Spring应用里有OrderControllerV1和OrderControllerV2,两个类里都有@GetMapping("/order/{id}"),启动时报Ambiguous handler methods错误。
解决方案:不同版本的Controller必须用不同的@RequestMapping前缀,或者用策略模式把版本处理逻辑抽到Handler里,只有一个Controller入口。
坑三:v1接口废弃但合作商没有迁移,强制关闭导致线上故障。
某次直接关闭了v1路由,但有几个合作商没有及时迁移,导致他们的系统报错。下线旧版本接口必须有充分的通知期(至少3-6个月),并在响应头里持续提示,通过邮件/短信通知合作商,不能突然关闭。
六、总结
多版本API管理的核心是:URL路径版本最简单直观,Gateway层配置版本路由,应用层用策略模式处理版本差异,通过响应头提供废弃警告,给合作商充足的迁移时间。版本下线要分阶段、有通知,不能突然关闭。
