1. 为什么 API 需要版本管理
一句话总结: 只要存在无法与你在同一时刻升级的客户端,接口就一定会发生不兼容演进,版本管理把「破坏性变更」从线上事故变成可排期的流程。
移动端应用要等应用商店审核,第三方集成方按季度发版,内部微服务由别的团队维护——这些客户端都不可能与你同步升级。当你在响应体里删掉一个字段、把 int 改成 long、把分页默认值从 20 改成 50,甚至只是修正了一个错误码语义,都可能让某个客户端在生产环境崩掉。
破坏性变更的判定往往比想象中隐蔽。下面这张表列出实践中真正会伤到调用方的几类改动:
| 变更 | 是否破坏 | 说明 |
|---|---|---|
| 删除响应字段 | 是 | 反序列化可能抛异常或静默丢数据 |
| 新增必填请求字段 | 是 | 老客户端不会发送 |
| 收紧校验规则 | 是 | 原本合法的请求开始返回 400 |
| 改字段类型 | 是 | 反序列化失败或精度丢失 |
| 改错误码语义 | 是 | 客户端的分支逻辑走错路径 |
| 新增可选响应字段 | 否 | 忽略未知字段即可 |
| 新增可选请求字段 | 否 | 老客户端不传时走默认值 |
| 新增端点 | 否 | 老客户端不会调用 |
// 看似无害的「优化」,对老客户端却是破坏性变更
// 变更前
public record OrderDto(int Id, decimal Total, string Status);
// 变更后:Total 从 decimal 变 double,且删掉了 Status
public record OrderDto(int Id, double Total);
避坑: 很多团队以为「加字段安全、删字段危险」,但真正的重灾区是语义变更——同一个字段含义从「含税价」变成「不含税价」,字段名和类型都没动,任何静态检查都发现不了。语义变更必须走版本升级,而不是靠文档备注。
2. 版本策略与实现
一句话总结: 路径版本(
/api/v2/orders)最直观、最好调试、对缓存与网关最友好,绝大多数公开 API 应优先选择它。
四种主流版本载体各有取舍:URL 路径、查询字符串、自定义请求头、媒体类型(Accept 头)。路径版本会「污染」URI(同一个资源有多个地址),但它可被浏览器地址栏、日志、网关路由、CDN 缓存直接识别,排错成本最低。
| 方案 | 示例 | 可缓存 | 可调试 | 适用场景 |
|---|---|---|---|---|
| 路径 | /api/v2/orders | 好 | 最好 | 公开 API、多版本长期共存 |
| 查询串 | /api/orders?api-version=2.0 | 差 | 好 | 内部 API、快速试验 |
| 请求头 | X-Api-Version: 2.0 | 差 | 差 | 不希望暴露版本 |
| 媒体类型 | Accept: application/vnd.app.v2+json | 中 | 差 | 严格 REST 纯度追求者 |
// Program.cs:注册版本服务,统一用查询串作为默认读取方式
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // 响应头回带支持的版本
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/...
new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("X-Api-Version"));
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV"; // 分组名形如 v1、v2
options.SubstituteApiVersionInUrl = true; // 文档里把 {version} 替换成真实值
});
// 控制器:声明自己属于哪个版本
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet("{id:int}")]
[MapToApiVersion("1.0")]
public ActionResult<OrderV1> GetV1(int id) => Ok(OrderV1.From(_repo.Find(id)));
[HttpGet("{id:int}")]
[MapToApiVersion("2.0")]
public ActionResult<OrderV2> GetV2(int id) => Ok(OrderV2.From(_repo.Find(id)));
}
2.1 让旧版本只维护不演进
一个健康的版本策略里,只有最新版接收新功能,旧版本只接受安全修复与致命缺陷修复。要做到这一点,必须能用工具回答「哪些端点属于哪个版本」。
// 用 ApiVersionDescriptionProvider 枚举所有版本,供文档与门禁脚本消费
public class ApiVersionInfo(IApiVersionDescriptionProvider provider)
{
public IEnumerable<string> AllVersions =>
provider.ApiVersionDescriptions.Select(d => d.GroupName);
public bool IsDeprecated(string group) =>
provider.ApiVersionDescriptions
.First(d => d.GroupName == group).IsDeprecated;
}
| 版本状态 | 新功能 | 缺陷修复 | 安全修复 | 对外承诺 |
|---|---|---|---|---|
| 预览版 | 是 | 是 | 是 | 无 |
| 当前稳定版 | 是 | 是 | 是 | 至少 12 个月 |
| 维护版 | 否 | 仅致命 | 是 | 至弃用日 |
| 已弃用 | 否 | 否 | 是 | 已公告下线时间 |
避坑:
AssumeDefaultVersionWhenUnspecified = true会让未带版本的请求落到默认版本,这在过渡期很友好,但一旦默认版本从 v1 切到 v2,所有没显式带版本的调用方会在一夜之间被迁移到新契约。上线前务必先确认没有任何客户端依赖「无版本」这个隐含行为,否则应显式返回 400。
3. OpenAPI 文档生成
一句话总结: OpenAPI 文档不是给人看的附件,而是客户端生成、契约测试与网关配置的机器可读事实来源,必须与代码同源同版本。
.NET 9 起提供了内置的 Microsoft.AspNetCore.OpenApi,可按文档名生成多份规范;生态里 Swashbuckle 与 NSwag 依然成熟,功能更全。关键点是每个 API 版本产出一份独立文档,而不是把版本差异挤进同一份。
// 每个版本生成一份 OpenAPI 文档,路径为 /openapi/v1.json、/openapi/v2.json
builder.Services.AddOpenApi("v1", options =>
{
options.AddDocumentTransformer((doc, ctx, ct) =>
{
doc.Info = new OpenApiInfo
{
Title = "订单服务",
Version = "v1",
Description = "订单域公开接口,v1 已进入维护期",
};
return Task.CompletedTask;
});
});
builder.Services.AddOpenApi("v2");
app.MapOpenApi("/openapi/{documentName}.json");
// 自定义 Schema 转换器:把 decimal 输出为 string,避免前端浮点精度问题
public class DecimalAsStringTransformer : IOpenApiSchemaTransformer
{
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext ctx,
CancellationToken ct)
{
if (ctx.JsonTypeInfo.Type == typeof(decimal))
{
schema.Type = JsonSchemaType.String;
schema.Format = "decimal";
}
return Task.CompletedTask;
}
}
| 生成器 | 包 | 特点 |
|---|---|---|
| 内置 OpenApi | Microsoft.AspNetCore.OpenApi | 轻量、AOT 友好、可扩展 |
| Swashbuckle | Swashbuckle.AspNetCore | UI 成熟、过滤器生态丰富 |
| NSwag | NSwag.AspNetCore | 与客户端生成器同源、可生成 TS |
| Scalar | Scalar.AspNetCore | 现代 UI,替代 Swagger UI |
避坑: 文档里出现的
{version}占位符如果不替换,生成的客户端方法签名会带一个没意义的版本参数。启用SubstituteApiVersionInUrl = true后,文档中会按版本展开成真实路径。另外枚举类型默认输出为整数,前端拿到0/1/2无法阅读,应配置JsonStringEnumConverter并让文档反映字符串枚举。
4. 客户端 SDK 生成
一句话总结: 从 OpenAPI 生成客户端 SDK 能把「契约漂移」暴露在编译期,比手写 HttpClient 调用更安全,但必须把生成物纳入版本与 CI 流程。
三种路线:NSwag 生成 C# 客户端、Microsoft Kiota 生成多语言客户端、Refit 以接口声明式手写。NSwag 与 Kiota 都能直接从构建产物里的 OpenAPI 文档生成,适合对外 SDK;Refit 适合团队内部调用,灵活但需要人工维护契约一致性。
// Refit:接口即契约,方法签名与 OpenAPI 一一对应
public interface IOrdersApi
{
[Get("/api/v1/orders/{id}")]
Task<OrderV1> GetOrderAsync(int id, CancellationToken ct = default);
[Post("/api/v1/orders")]
Task<OrderV1> CreateOrderAsync([Body] CreateOrderRequest req, CancellationToken ct = default);
}
builder.Services.AddRefitClient<IOrdersApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.example.com"))
.AddStandardResilienceHandler(); // 复用重试、熔断、超时策略
# Kiota:从线上文档生成强类型客户端,锁定版本号保证可复现
dotnet tool install --global Microsoft.OpenApi.Kiota
kiota generate \
--language CSharp \
--openapi /openapi/v2.json \
--class-name OrdersClient \
--namespace-name Contoso.Orders.Client \
--output ./src/Contoso.Orders.Client
| 方案 | 契约来源 | 多语言 | 可定制性 | 适合 |
|---|---|---|---|---|
| NSwag | OpenAPI 文件 | C#、TS | 高(模板) | 对外 C# SDK |
| Kiota | OpenAPI 文件 | 多语言 | 中 | 跨语言 SDK |
| Refit | 手写接口 | 仅 C# | 最高 | 内部服务调用 |
| 手写 HttpClient | 无 | 无 | 最高 | 一次性脚本 |
避坑: 生成的客户端必须提交到仓库并锁定版本,不要在每次构建时重新生成——否则上游一次不兼容的文档改动会静默改变所有调用方行为,且 diff 淹没在生成代码里无法评审。正确做法是独立仓库或独立目录 + 显式的「升级 SDK」提交,并在 PR 里展示生成的 diff。
5. 契约测试
一句话总结: 契约测试的核心是「用上一版文档校验当前实现」,把破坏性变更拦在合并之前,而不是等客户端报障。
契约测试有两类:向后兼容性检查(对比两个版本的 OpenAPI 文档,找出破坏性差异)与消费者驱动契约(消费者声明期望,提供者验证)。前者成本低、收益高,应该成为每个 API 仓库的 CI 门禁。
# 用 openapi-diff 对比主干与当前分支的文档,破坏性变更直接失败
npm install -g openapi-diff
openapi-diff baseline/openapi-v2.json current/openapi-v2.json \
--fail-on-incompatible
// 在测试里断言「老客户端仍能反序列化新响应」
[Fact]
public async Task V2响应仍兼容V1客户端()
{
var v2Json = await _client.GetStringAsync("/api/v2/orders/42");
// 用 v1 的模型反序列化 v2 的响应,未知字段应被忽略
var v1 = JsonSerializer.Deserialize<OrderV1>(v2Json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(v1);
Assert.True(v1!.Id > 0);
}
// 契约快照:把当前文档写入基线文件,PR 中人工评审差异
[Fact]
public async Task OpenApi文档与基线一致()
{
var current = await _client.GetStringAsync("/openapi/v2.json");
var baselinePath = Path.Combine("specs", "openapi-v2.baseline.json");
if (Environment.GetEnvironmentVariable("UPDATE_SNAPSHOT") == "1")
{
await File.WriteAllTextAsync(baselinePath, current);
return;
}
Assert.Equal(await File.ReadAllTextAsync(baselinePath), current);
}
| 检查手段 | 拦住的变更 | 成本 |
|---|---|---|
| openapi-diff | 删除端点、删字段、改类型 | 低 |
| 快照对比 | 任何文档层面的漂移 | 低 |
| 反序列化兼容测试 | 运行时行为不兼容 | 中 |
| Pact 消费者契约 | 消费者真实期望 | 高 |
| 生产流量影子回放 | 语义变更 | 高 |
避坑: 快照对比会因字段顺序、描述文案、示例值的微小变化而失败,噪声很大。实践里应先对文档做规范化(排序属性、剔除描述与示例),再比对结构。否则团队会因为频繁误报而把门禁改成「警告」,等于没做。
6. 弃用与迁移
一句话总结: 弃用是一个有明确时间表、有可观测数据、有沟通渠道的过程,只发一封公告邮件就下线接口是事故的常见起点。
弃用的关键动作有三:在响应里带上标准的 Deprecation 与 Sunset 头、在文档里标记 deprecated、用遥测统计每个版本的调用量与调用方。
// 中间件:对所有命中已弃用版本的响应补充标准头
public class DeprecationMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, ApiVersionInfo versions)
{
ctx.Response.OnStarting(() =>
{
var group = ctx.GetRequestedApiVersion()?.ToString("'v'VVV");
if (group is not null && versions.IsDeprecated(group))
{
ctx.Response.Headers["Deprecation"] = "true";
ctx.Response.Headers["Sunset"] = "Sat, 31 Jan 2026 23:59:59 GMT";
ctx.Response.Headers["Link"] =
"</api/v3/orders>; rel=\"successor-version\"";
}
return Task.CompletedTask;
});
await next(ctx);
}
}
// 遥测:按版本 + 调用方维度计数,判断何时可以安全下线
public class VersionMetricsMiddleware(RequestDelegate next, Meter meter)
{
private readonly Counter<long> _counter =
meter.CreateCounter<long>("api.requests.by_version");
public async Task InvokeAsync(HttpContext ctx)
{
_counter.Add(1, new KeyValuePair<string, object?>(
"version", ctx.GetRequestedApiVersion()?.ToString() ?? "none"),
new KeyValuePair<string, object?>(
"client", ctx.Request.Headers.UserAgent.ToString()));
await next(ctx);
}
}
| 阶段 | 时长 | 动作 |
|---|---|---|
| 公告 | T+0 | 文档标记 deprecated,发公告与变更日志 |
| 双写 | T+0~T+3 月 | 响应带 Sunset 头,引导迁移 |
| 观察 | T+3~T+6 月 | 监控旧版本调用量,联系剩余调用方 |
| 冻结 | T+6 月 | 旧版本只修安全缺陷 |
| 下线 | T+6 月后 | 返回 410 Gone 并保留路由一段时间 |
避坑: 直接返回 404 会让调用方以为是自己 URL 写错了,浪费排查时间。下线时返回 410 Gone 并在响应体里给出替代端点,语义明确。同时保留路由至少一个月,只回 410 不删代码——真正删除代码要等到确认调用量归零之后。
7. 治理与工具链
一句话总结: 契约优先还是代码优先不是信仰问题,关键是选定一种并把「契约变更必须评审」写进 CI,否则文档会在一周内腐烂。
代码优先(从控制器生成文档)上手快,适合内部服务;契约优先(先写 YAML 再生成骨架)协作好,适合对外与多方集成。无论哪种,都要有 CI 门禁:文档必须能生成、必须通过 lint、破坏性变更必须显式标记。
# CI 中的契约门禁
name: contract
on: [pull_request]
jobs:
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- run: dotnet build -c Release
- name: 导出当前文档
run: dotnet run --project tools/ExportOpenApi -- --output ./specs
- name: 破坏性变更检查
run: npx openapi-diff ./specs/baseline.json ./specs/current.json --fail-on-incompatible
- name: 规范 lint
run: npx spectral lint ./specs/current.json --ruleset .spectral.yaml
| 治理项 | 工具 | 门禁强度 |
|---|---|---|
| 文档可生成 | dotnet build + 导出脚本 | 阻断 |
| 规范合规 | Spectral | 阻断(error 级) |
| 破坏性变更 | openapi-diff | 阻断,除非 PR 标注 breaking |
| 示例可运行 | 文档示例测试 | 警告 |
| 版本号规范 | 自定义脚本 | 警告 |
避坑: 门禁最容易死的方式是「允许 override」——一旦
skip-contract-check标签被滥用,门禁就形同虚设。更稳的设计是:破坏性变更不允许跳过,但允许 PR 作者在specs/breaking-approved.json里显式登记本次变更的端点与理由,由 reviewer 审批。让例外可见,而不是让检查可关闭。
8. 总结
| 环节 | 要点 |
|---|---|
| 版本载体 | 路径版本可缓存、可调试,公开 API 优先 |
| 版本实现 | Asp.Versioning 统一注册,控制器用 MapToApiVersion 绑定 |
| 文档生成 | 每个版本一份 OpenAPI,与代码同源同步发布 |
| SDK 生成 | NSwag/Kiota 生成并提交,锁版本,PR 评审 diff |
| 契约测试 | openapi-diff + 快照 + 反序列化兼容测试三重门禁 |
| 弃用流程 | Deprecation/Sunset 头 + 版本遥测 + 410 Gone 下线 |
| 治理 | 契约变更必须评审,例外要可见不可绕过 |
API 版本管理本质上是一场与时间的协商:客户端需要时间迁移,服务端需要空间演进。把版本策略、文档生成、SDK 与契约测试串成一条自动化链路之后,「这次改动会不会破坏调用方」就不再依赖个人记忆,而是由 CI 给出确定答案。契约稳定了,服务间的协作成本才真正降下来,下一篇我们要讨论的正是「如何在契约不变的前提下安全地把新功能放出去」——特性开关与渐进发布。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。