API 版本管理与 OpenAPI 契约

系统讲解 ASP.NET Core 中的 API 版本管理策略与 OpenAPI 契约驱动开发,涵盖版本方案选择、文档生成与转换、客户端 SDK 生成、契约测试与弃用治理。

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;
    }
}
生成器包特点
内置 OpenApiMicrosoft.AspNetCore.OpenApi轻量、AOT 友好、可扩展
SwashbuckleSwashbuckle.AspNetCoreUI 成熟、过滤器生态丰富
NSwagNSwag.AspNetCore与客户端生成器同源、可生成 TS
ScalarScalar.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
方案契约来源多语言可定制性适合
NSwagOpenAPI 文件C#、TS高(模板)对外 C# SDK
KiotaOpenAPI 文件多语言中跨语言 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 给出确定答案。契约稳定了,服务间的协作成本才真正降下来,下一篇我们要讨论的正是「如何在契约不变的前提下安全地把新功能放出去」——特性开关与渐进发布。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排