Skip to content
Go back

ASP.NET Core API 版本管理完全指南(.NET 10)

ASP.NET Core API 版本管理

API 版本管理是那种你可以忽略的事——但只能忽略一次。

第一次遇到生产事故,因为一个”小小的响应字段改动”导致移动端应用崩溃、合作方集成凌晨三点报警,每个团队都会说同一句话:“我们当初应该做版本管理。”

Stefan Đokić(微软 MVP)在这篇文章里把他在多个生产项目中用的那套方案完整写了出来:从概念到代码,从废弃策略到迁移 Playbook。本文基于 .NET 10 Minimal API。

什么是 API 版本管理

做版本管理之前,先搞清楚你在版本化什么。

一个 API 版本代表一份契约。这份契约包含:

当这些内容有任何改动让现有客户端无法处理时,就是破坏性变更,就需要新版本。

版本管理让你可以发布新契约(v2)的同时保留旧契约(v1)继续运行,消费方按自己的节奏迁移,而不是被你强制同步升级。

为什么需要版本管理

几个实际理由:

如果你的 API 不是完全由你控制的单个前端在消费,版本管理就不是可选项。

破坏性变更 vs. 非破坏性变更

这是最重要的判断:这个改动需要新版本吗?

破坏性变更(需要新版本)

非破坏性变更(保持同一版本)

一个简单的判断标准:昨天还能正常工作的客户端,今天因为你的改动坏掉了,那就是破坏性变更。

版本策略对比

有几种常见的 API 版本策略:

策略示例优点缺点
URL 段/api/v1/products易读,便于测试、缓存、文档URL 随版本变化
查询字符串/api/products?api-version=1.0URL 基本稳定容易遗漏,污染查询参数
请求头X-Api-Version: 1.0URL 完全稳定隐蔽,测试和发现困难
媒体类型Accept: application/vnd.myapi.v1+json符合 HTTP 规范复杂,工具支持参差不齐

2026 年的建议:用 URL 段版本管理,只做主版本(v1、v2)。

原因:在日志、链路追踪、浏览器开发者工具里一眼可见;Swagger/OpenAPI 集成干净;curl、Postman 或任何 HTTP 客户端都很容易测;CDN 和缓存天然工作,因为不同版本 URL 不同;新来的团队成员能立刻看清他们在调哪个版本。

项目配置

安装三个 NuGet 包:

dotnet add package Asp.Versioning.Http
dotnet add package Asp.Versioning.Mvc.ApiExplorer
dotnet add package Swashbuckle.AspNetCore

Minimal API 版本管理:完整实现

第一步:配置版本管理服务

using Asp.Versioning;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

每个配置项的作用:

第二步:创建版本集合

版本集合定义你的 API 支持哪些版本,创建一个后在各端点组中共享:

var apiVersionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

这告诉框架:我的 API 有 v1 和 v2,两者都在响应头中报告。

第三步:定义 v1 端点

var productsV1 = app.MapGroup("/api/v{version:apiVersion}/products")
    .WithApiVersionSet(apiVersionSet)
    .MapToApiVersion(1, 0);

productsV1.MapGet("", async (AppDbContext db) =>
{
    var products = await db.Products
        .Select(p => new ProductResponseV1(p.Id, p.Name, p.Price))
        .ToListAsync();
    return Results.Ok(products);
})
.WithName("GetProductsV1")
.WithSummary("Get all products")
.WithDescription("Returns all products with basic details.");

productsV1.MapGet("/{id:int}", async (int id, AppDbContext db) =>
{
    var product = await db.Products.FindAsync(id);
    if (product is null) return Results.NotFound();
    return Results.Ok(new ProductResponseV1(product.Id, product.Name, product.Price));
})
.WithName("GetProductByIdV1")
.WithSummary("Get product by ID");

public record ProductResponseV1(int Id, string Name, decimal Price);

v1 返回简单响应:ID、名称、价格。

第四步:定义 v2 端点(不同的契约)

假设 v2 需要包含货币、分类和可用性信息:

var productsV2 = app.MapGroup("/api/v{version:apiVersion}/products")
    .WithApiVersionSet(apiVersionSet)
    .MapToApiVersion(2, 0);

productsV2.MapGet("", async (AppDbContext db) =>
{
    var products = await db.Products
        .Select(p => new ProductResponseV2(
            p.Id, p.Name, p.Price, p.Currency, p.Category, p.IsAvailable))
        .ToListAsync();
    return Results.Ok(products);
})
.WithName("GetProductsV2")
.WithSummary("Get all products (v2)")
.WithDescription("Returns all products with extended details including currency and availability.");

public record ProductResponseV2(
    int Id,
    string Name,
    decimal Price,
    string Currency,
    string Category,
    bool IsAvailable);

相同的端点路径,不同的响应契约,两者同时运行——这就是版本管理。

第五步:启动应用

app.UseSwagger();
app.UseSwaggerUI();

app.Run();

调用 GET /api/v1/products 得到 v1 响应,调用 GET /api/v2/products 得到 v2 响应。响应头会包含:

api-supported-versions: 1.0, 2.0

大规模项目的端点组织方式

真实项目里,你不会想把 200 行端点映射全堆在 Program.cs。把版本化的端点提取成扩展方法:

public static class ProductEndpoints
{
    public static void MapProductEndpointsV1(
        this IEndpointRouteBuilder app,
        ApiVersionSet apiVersionSet)
    {
        var group = app.MapGroup("/api/v{version:apiVersion}/products")
            .WithApiVersionSet(apiVersionSet)
            .MapToApiVersion(1, 0)
            .WithTags("Products");

        group.MapGet("", GetAllV1).WithName("GetProductsV1");
        group.MapGet("/{id:int}", GetByIdV1).WithName("GetProductByIdV1");
        group.MapPost("", CreateV1).WithName("CreateProductV1");
    }

    public static void MapProductEndpointsV2(
        this IEndpointRouteBuilder app,
        ApiVersionSet apiVersionSet)
    {
        var group = app.MapGroup("/api/v{version:apiVersion}/products")
            .WithApiVersionSet(apiVersionSet)
            .MapToApiVersion(2, 0)
            .WithTags("Products");

        group.MapGet("", GetAllV2).WithName("GetProductsV2");
        group.MapGet("/{id:int}", GetByIdV2).WithName("GetProductByIdV2");
        group.MapPost("", CreateV2).WithName("CreateProductV2");
    }

    // 处理方法...
    private static async Task<IResult> GetAllV1(AppDbContext db) { /* ... */ }
    private static async Task<IResult> GetAllV2(AppDbContext db) { /* ... */ }
    // ...
}

然后在 Program.cs 中:

app.MapProductEndpointsV1(apiVersionSet);
app.MapProductEndpointsV2(apiVersionSet);

app.Run();

两行代码,Program.cs 保持整洁。每个版本的端点在各自的方法里,易查找、易测试、废弃时也好删。

废弃一个版本

准备废弃 v1 时,在版本集合中将其标记为已废弃:

var apiVersionSet = app.NewApiVersionSet()
    .HasDeprecatedApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

注意:HasApiVersion 改成了 HasDeprecatedApiVersion

现在每个 v1 响应都会包含:

api-deprecated-versions: 1.0
api-supported-versions: 2.0

检查响应头的客户端(优秀的 API 消费方都会这样做)能自动收到废弃信号。

添加 Sunset 头

为了传达更明确的下线时间,用中间件添加 Sunset 日期头:

app.Use(async (context, next) =>
{
    await next();

    var apiVersion = context.GetRequestedApiVersion();
    if (apiVersion?.MajorVersion == 1)
    {
        context.Response.Headers["Sunset"] = "Sat, 01 Nov 2026 00:00:00 GMT";
        context.Response.Headers["Deprecation"] = "true";
        context.Response.Headers["Link"] =
            "</api/v2/products>; rel=\"successor-version\"";
    }
});

这告诉每个 v1 消费方:“这个版本将在 2026 年 11 月 1 日下线,替代版本在这里。“明确、机器可读、不容错过。

每个版本独立的 Swagger 文档

把所有版本混在同一个 Swagger 页面很混乱。配置独立的 Swagger 文档:

builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Products API",
        Version = "v1",
        Description = "Legacy product endpoints. Deprecated - migrate to v2."
    });

    options.SwaggerDoc("v2", new OpenApiInfo
    {
        Title = "Products API",
        Version = "v2",
        Description = "Current product endpoints with extended product details."
    });
});

以及中间件配置:

app.UseSwagger();
app.UseSwaggerUI(options =>
{
    options.SwaggerEndpoint("/swagger/v1/swagger.json", "Products API v1 (Deprecated)");
    options.SwaggerEndpoint("/swagger/v2/swagger.json", "Products API v2");
});

现在访问 Swagger UI 的开发者能看到清晰的下拉列表:v1(已废弃)和 v2(当前)。

Header 版本管理备选方案

有些团队倾向于 Header 版本管理,因为 URL 在版本间保持稳定。切换方式:

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
});

此时两个版本的 URL 都是 /api/products,客户端通过请求头指定版本:

GET /api/products
X-Api-Version: 2.0

适合使用 Header 版本管理的场景:

应该避免的场景:

同时支持多种读取方式

也可以同时支持 URL 段和 Header 两种方式:

options.ApiVersionReader = ApiVersionReader.Combine(
    new UrlSegmentApiVersionReader(),
    new HeaderApiVersionReader("X-Api-Version")
);

客户端可以选择任意一种方式,URL 段优先级更高。

测试版本化端点

在集成测试中测试版本化 API 非常直接,调用版本特定的 URL,断言响应契约:

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductsApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_V1_ReturnsBasicFields()
    {
        var response = await _client.GetAsync("/api/v1/products");

        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var products = await response.Content
            .ReadFromJsonAsync<List<ProductResponseV1>>();

        products.Should().NotBeEmpty();
        products![0].Id.Should().BeGreaterThan(0);
        products[0].Name.Should().NotBeNullOrEmpty();
    }

    [Fact]
    public async Task GetProducts_V2_ReturnsExtendedFields()
    {
        var response = await _client.GetAsync("/api/v2/products");

        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var products = await response.Content
            .ReadFromJsonAsync<List<ProductResponseV2>>();

        products.Should().NotBeEmpty();
        products![0].Currency.Should().NotBeNullOrEmpty();
        products[0].Category.Should().NotBeNullOrEmpty();
    }

    [Fact]
    public async Task V1_Response_Headers_Show_Deprecation()
    {
        var response = await _client.GetAsync("/api/v1/products");

        response.Headers.Should().ContainKey("api-deprecated-versions");
    }
}

关键测试策略:

迁移 Playbook:v1 到 v2

在多个项目中运行版本化 API 之后,固定的迁移流程如下:

第一阶段:构建并发布 v2

第二阶段:宣告废弃

第三阶段:监控与支持

第四阶段:退役 v1

最常见的错误:团队构建了 v2,宣告了废弃,然后就再也没有真正删除 v1。几年后维护着 4 个版本,毫无退役计划。定好日期,执行到底。

常见错误

版本管理太晚:等到有 20 个客户端依赖未版本化 API 时才开始,意味着所有人都要经历痛苦的迁移。

破坏性变更不创建新版本:在 v1 里悄悄重命名字段,寄希望于没人注意,这是一次有保障的生产事故。

每次变更都创建新版本:添加一个可选字段不需要 v3,版本只用于真正的破坏性变更。

没有下线策略:如果从不退役旧版本,你就是在永远维护多条代码路径。

没有使用量追踪:不知道谁还在调用 v1,就没办法安全地下线它。

资源间版本不一致:如果 /products 已经是 v2 但 /orders 还在 v1,消费方很快就会困惑。整个 API 应该一起版本化。

不记录变更差异:v1 到 v2 之间的变更日志是必要的,消费方需要知道究竟改了什么。

小结

API 版本管理是你可以忽略的事——但只能忽略一次。

第一次生产事故之后,每个团队都会希望从第一天就搭好这套基础设施。

好消息是:用 Asp.Versioning.Http 和 .NET 10 Minimal API,搭建过程并不复杂。你能得到基于 URL 的版本管理、自动废弃响应头、每版本独立的 Swagger 文档,以及保持 Program.cs 整洁的扩展方法模式。

建议:从第一天就设置版本管理,哪怕只有 v1。基础设施成本极低,等到已经有客户端依赖未版本化 API 之后再补,代价极高。

从 URL 段版本管理开始,只做主版本,用 Header 和日期废弃,积极执行下线计划。

参考


Tags


Previous

C# 功能切片:按业务功能组织代码

Next

C# 字符串完全指南:.NET 字符串操作全解析