版本即契约:.NET 6 WebAPI 多版本治理与 Swagger 文档分治实践

Asp.Versioning 官方文档:

https://dotnet.github.io/aspnet-api-versioning

本文使用的是 Asp.Versioning.Mvc 6.4.1,对应 .NET 6 的稳定线。

先给结论:网上大量教程说"Swagger 多版本必须引 Asp.Versioning.Mvc.ApiExplorer"------这句话只在自动化分组路线 下成立,即依赖 ApiDescription.GroupName 或 IApiVersionDescriptionProvider 去枚举版本。本文走的是另一条 Swashbuckle 原生路线:DocInclusionPredicate 直接反射控制器上的 [ApiVersion] 完成归组,整条链路不经过 IApiExplorer 的版本增强层,所以那个包在这里是冗余的。

少一个依赖,筛选逻辑全部摊在明面上,版本清单只有一个来源。下面是完整代码。

装包:

一、Swagger 配置

cs 复制代码
using Asp.Versioning;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace Extensions_Api.Configurations;

/// <summary>Swagger 配置。</summary>
public static class SwaggerConfigExtensions
{
    /// <summary>注册 Swagger 的生成规则。</summary>
    public static IServiceCollection AddSwaggerConfig(this IServiceCollection services, string[] apiVersions)
    {
        services.AddSwaggerGen(options =>
        {
            // 有 XML 注释文件时才加载,没有文件不影响启动。
            var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
            var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
            if (File.Exists(xmlPath))
            {
                options.IncludeXmlComments(xmlPath, true);
            }

            // 给路径里的 version 参数填上默认值。
            options.ParameterFilter<VersionDefaultValueFilter>();

            // 每个版本单独生成一份 Swagger 文档。
            foreach (var version in apiVersions)
            {
                options.SwaggerDoc(version, new OpenApiInfo
                {
                    Title = $"API {version.ToUpper()}",
                    Version = version
                });
            }

            // 同一路径有多个接口时取第一个,避免文档生成失败。
            options.ResolveConflictingActions(api => api.First());

            // 按控制器上的 ApiVersion 把接口分到对应版本文档。
            options.DocInclusionPredicate((docName, apiDesc) =>
            {
                if (!apiDesc.TryGetMethodInfo(out var methodInfo))
                {
                    return false;
                }

                var versions = methodInfo.DeclaringType?
                    .GetCustomAttributes(true)
                    .OfType<ApiVersionAttribute>()
                    .SelectMany(attr => attr.Versions)
                    .Select(version => $"v{version.MajorVersion}")
                    .ToList();

                return versions?.Contains(docName) ?? false;
            });

            // Swagger 右上角显示 JWT 输入框,Token 放在 Authorization 请求头。
            options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
            {
                Description = "JWT授权",
                Name = "Authorization",
                In = ParameterLocation.Header,
                Type = SecuritySchemeType.ApiKey,
                Scheme = "Bearer"
            });

            // 所有接口都带上 Bearer 认证,否则调试时不会提交 Token。
            options.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                {
                    new OpenApiSecurityScheme
                    {
                        Reference = new OpenApiReference
                        {
                            Type = ReferenceType.SecurityScheme,
                            Id = "Bearer"
                        }
                    },
                    new List<string>()
                }
            });
        });

        return services;
    }

    /// <summary>启用 Swagger 和按版本切换的 Swagger UI。</summary>
    public static WebApplication UseSwaggerConfig(this WebApplication app, string[] apiVersions)
    {
        // 只在开发环境开放 Swagger。
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();

            // 右上角按 v1、v2 切换文档。
            app.UseSwaggerUI(options =>
            {
                foreach (var version in apiVersions)
                {
                    options.SwaggerEndpoint($"/swagger/{version}/swagger.json", version);
                }
            });
        }

        return app;
    }
}

/// <summary>Swagger 参数过滤器:为 API 版本号参数设置默认值。</summary>
public class VersionDefaultValueFilter : IParameterFilter
{
    /// <summary>为路径中的 version 参数填充默认值。</summary>
    public void Apply(OpenApiParameter parameter, ParameterFilterContext context)
    {
        if (parameter.Name == "version" && parameter.In == ParameterLocation.Path && parameter.Schema != null)
        {
            var version = context.DocumentName.StartsWith("v", StringComparison.OrdinalIgnoreCase)
                ? context.DocumentName[1..]
                : context.DocumentName;
            parameter.Schema.Default = new OpenApiString(version);
            parameter.Example = new OpenApiString(version);
        }
    }
}

要点 :DocInclusionPredicate 四步走------拿 MethodInfo、反查 DeclaringType、收集控制器上的 ApiVersionAttribute、用 MajorVersion 拼成 v1 / v2 比对。不碰 IApiVersionDescriptionProvider,所以不需要 Asp.Versioning.Mvc.ApiExplorer。VersionDefaultValueFilter 从 DocumentName 反推版本号,同时填 Default 和 Example,Swagger UI 里 Try it out 时 {version} 已经预填好,不用手动敲。apiVersions 是唯一版本清单,SwaggerDoc 和 SwaggerEndpoint 都从它派生,不存在两处列表不同步的隐患。IsDevelopment() 收在扩展内,生产环境天然不暴露文档。

二、API 版本控制配置

cs 复制代码
using Asp.Versioning;

namespace Extensions_Api.Configurations;

/// <summary>API 版本配置。</summary>
public static class ApiVersioningConfigExtensions
{
    /// <summary>
    /// 注册 API 版本规则。未传版本时默认使用 1.0,并在响应头中返回支持的版本。
    /// </summary>
    public static IServiceCollection AddApiVersioningConfig(this IServiceCollection services)
    {
        services.AddApiVersioning(options =>
        {
            // 请求未带版本号时使用的默认版本。
            options.DefaultApiVersion = new ApiVersion(1, 0);

            // 未传版本时按默认版本处理,避免直接返回 400。
            options.AssumeDefaultVersionWhenUnspecified = true;

            // 在响应头中返回当前接口支持的版本。
            options.ReportApiVersions = true;
        }).AddMvc();

        return services;
    }
}

要点 :DefaultApiVersion 配 AssumeDefaultVersionWhenUnspecified,不带版本的请求回退到 v1,而不是 400。ReportApiVersions 让响应头带上 api-supported-versions: 1.0, 2.0,调用方据此可以做灰度切换。末尾的 .AddMvc() 是整条链路的开关------这一句漏掉,前面所有配置全部失效,也是新手最常踩的坑。

三、Program.cs

cs 复制代码
using Extensions_Api.Configurations;

// 创建应用构建器,读取配置、环境和依赖注入容器。
var builder = WebApplication.CreateBuilder(args);

// 当前开放的 API 版本,Swagger 按这些版本分别生成文档。
var apiVersions = new[] { "v1", "v2" };

// 注册控制器。
builder.Services.AddControllers();

// 注册接口发现,供 Swagger 读取路由和参数。
builder.Services.AddEndpointsApiExplorer();

// 注册 API 版本规则,未传版本时默认 1.0。
builder.Services.AddApiVersioningConfig();

// 按版本生成 Swagger 文档,并加上 JWT 输入框。
builder.Services.AddSwaggerConfig(apiVersions);

// 构建应用,之后开始配置请求管道。
var app = builder.Build();

// 开发环境启用 Swagger,右上角可切换 v1、v2。
app.UseSwaggerConfig(apiVersions);

// HTTP 请求重定向到 HTTPS。
app.UseHttpsRedirection();

// 启用授权中间件。
app.UseAuthorization();

// 把控制器路由映射到终结点。
app.MapControllers();

// 启动应用并开始监听请求。
app.Run();

要点 :apiVersions 是全链路唯一的版本来源------版本控制默认值、Swagger 文档生成、UI 下拉端点全部从这里派生。这不是省事,是消除不一致的根因 。AddEndpointsApiExplorer() 提供 ApiDescription 所需的 MethodInfo,是 DocInclusionPredicate 正常工作的前提,不能漏。

四、控制器(示例)

cs 复制代码
// V1
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class WeatherForecastController : ControllerBase { /* 返回原始数组 */ }

// V2
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class WeatherForecastController : ControllerBase { /* 返回 WeatherForecastV2Result */ }

要点 :{version:apiVersion} 是路由约束,告诉路由系统这一段由版本控制逻辑解析,而不是当普通字符串。两个版本返回结构可以完全不同------V1 返回原始数组,V2 包一层 WeatherForecastV2Result 带 Version 字段。契约能独立演进而不炸老客户端,这才是多版本控制存在的意义 ,不然加个 v1 只是自欺欺人。

五、跑起来验证什么

访问 /swagger,右上角切版本。逐项验证:

  • 切到 v1 只看到 v1 接口,切到 v2 只看到 v2 接口 ,且 v2 的响应 schema 是 WeatherForecastV2Result;

  • 点开接口时 version 参数已自动预填,Try it out 不用手动敲;

  • 不带版本直接请求 /api/weatherforecast,回退到 v1,验证 AssumeDefaultVersionWhenUnspecified 生效;

  • 响应头带 api-supported-versions: 1.0, 2.0;

  • 把环境切到非 Development,Swagger 端点返回 404,验证环境隔离生效。

多版本控制的价值,从来不是路由里多一个 v1,而是它给"契约演进"提供了制度化的空间:老客户端继续走老契约,新客户端接入新契约,Swagger 文档如实反映当前版本的全貌,联调方不再靠猜。

这套方案的几个关键决策,值得带走:

决策点 选择 理由
版本控制库 Asp.Versioning.Mvc 6.4.1 .NET 6 下的现行主线
Swagger 分组 DocInclusionPredicate 手动筛选 依赖面小、逻辑显式、与版本清单天然对齐
是否引入 ApiExplorer 不需要 手动路线绕开 IApiExplorer 版本增强层
版本参数预填 IParameterFilter 对所有接口一次性生效,零侵入
版本来源 单一 apiVersions 数组 避免多处硬编码不同步
Swagger 暴露 扩展内 IsDevelopment() 隔离 生产不暴露文档,职责内聚

如果后续版本粒度从 1.0、2.0 扩展到次版本,只需调整 apiVersions 数组与 Select 的拼接方式,整体结构无需重写。一个自洽的体系,应该在需求演进时保持骨架不动------这比"能跑"值钱得多。

相关推荐
Jeff-Tsai2 小时前
Day 9:数据与定义分家,表单不必各自建类型
.net·架构设计·erp·definition-driven
步步为营DotNet2 小时前
.NET 可观测性选型:Metrics、ActivitySource、EventSource 到底该用哪个
c#·.net·metrics·可观测性
CSharp精选营2 小时前
我把 Ant Design Blazor 翻了一遍:Blazor 中后台组件库到底能不能用
.net·blazor·ant design blazor·中后台系统·mudblazor
IT规划师2 小时前
Redis 数据类型全解 - ZSet:两个编码,七种玩法(五)
redis·.net
rockey62714 小时前
AScript之字符串模板
c#·.net·script·expression·动态脚本
8Qi816 小时前
DDD架构演进
java·分布式·mvc·ddd
玩泥巴的1 天前
源生成与 AOT 就绪的微信生态 .NET SDK:结构、能力边界与 API 定位方法
ai·微信·.net·agent·aot·roslyn
1-1=01 天前
C# 泛型
c#·.net·.netcore
有志冬瓜1 天前
.NET Framework全部版本离线下载安装教程
安全·.net·软件需求