Asp.Net Core WebApi使用Swagger结合Versioning实现按版本分类显示与请求

最近刚开一个新项目,以asp.net core webapi为app提供接口后台,当然就需要添加版本控制。同时,为了更好的生成文档,也要添加swagger,但是在结合这两者的过程中遇到了一些问题,在此记录下来以便后期查找,也希望能帮助到遇到同类问题的同学。
废话不多说,让我们开始。

1、新建webapi项目

此步跳过,我是用的是3.1的SDK。

2、添加版本控制

添加版本控制包

dotnet add package Microsoft.AspNetCore.Mvc.Versioning

修改 StartupConfigureServices 添加如下代码:

services.AddApiVersioning(options =>
{
    options.ReportApiVersions = false;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
});

该文使用的版本控制参数为url链接的方式,具体关于Versioning包的使用方法不多赘述,可以 查看这篇文章,或者官网。
然后在项目中生成的 WeatherForecastController 修改控制器路由为 [Route("api/v{version:apiVersion}/[controller]/[action]")],并添加两个如下方法:

// route: /api/v1.0/WeatherForecast/Hello
[HttpGet]
[ApiVersion("1.0")]
public string Hello()
{
    return "Hello world from Hello!";
}

// route: /api/v1.1/WeatherForecast/Hello2
[HttpGet]
[ApiVersion("1.1")]
public string Hello2()
{
    return "Hello world from Hello2!";
}

3、添加Swagger

dotnet add package Swashbuckle.AspNetCore

使用官网提供的quick start方式添加如下代码:

services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
});

app.UseSwagger();
app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
});

具体添加位置可以查看上方链接,因为不是最终版本,故不做赘述。
运行程序后浏览器访问https://localhost:5001/swagger返回如图:

swagger 1.png

输入版本号后可以正常访问。

4、自动替换版本号

但是每次调试都需要输入版本号就很麻烦,有没有什么方法不输入呢?
答案是肯定的。
首先添加如下两个类:

public class RemoveVersionFromParameter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        var versionParameter = operation.Parameters.Single(p => p.Name == "version");
        operation.Parameters.Remove(versionParameter);
    }
}
public class ReplaceVersionWithExactValueInPath : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        var newPaths = new OpenApiPaths();
        foreach(var item in swaggerDoc.Paths)
        {
            var arr = item.Key.Split('/');
            var controller = arr[arr.Length - 2];
            var action = arr[arr.Length - 1];
            var version = typeof(Program).Assembly
                    .GetTypes()
                    .FirstOrDefault(x => typeof(ControllerBase).IsAssignableFrom(x) &&
                        x.Name == $"{controller}Controller")
                    .GetMethods()
                    .FirstOrDefault(x => x.IsPublic && x.Name == action)
                    .GetCustomAttribute().Versions.First().ToString();
                    
            newPaths.Add(item.Key.Replace("v{version}", $"v{version}"), item.Value);
        }
        swaggerDoc.Paths = newPaths;
    }
}

然后修改 StartupAddSwaggerGen 添加如下代码:

c.OperationFilter();
c.DocumentFilter();

运行程序后浏览器访问https://localhost:5001/swagger返回如图:

swagger 2.png

已经不需要在手动输入版本号。

5、按版本分类

让我们把话题再深入一些,上述两个api分别为1.0和1.1版本,但是出现在了一起,有没有办法分开显示呢?
首先添加如下Model类来缓存反射的数据:

public class ReflectionCache
{
    public IEnumerable AllControllers { get; set; }

    public IEnumerable AllApiVersions { get; set; }
}

并在 Startup 类中添加如下属性:

public ReflectionCache ReflectionCache { get; set; }

并向构造方法中添加如下代码:

ReflectionCache = new ReflectionCache();
ReflectionCache.AllControllers = typeof(Program).Assembly
                .GetTypes()
                .Where(x => typeof(ControllerBase).IsAssignableFrom(x));
ReflectionCache.AllApiVersions = ReflectionCache.AllControllers.SelectMany(x => x.GetMethods()
                .Where(x => x.IsPublic && x.GetCustomAttribute() != null)
                .SelectMany(x => x.GetCustomAttribute().Versions))
                .GroupBy(x => x.ToString())
                .Select(x => x.Key);

并将其以 Singleton 方式注入:

services.AddSingleton(provider => ReflectionCache);

然后修改调用 AddSwaggerGen 方法如下:

c.OperationFilter();
c.DocumentFilter();

foreach (var version in ReflectionCache.AllApiVersions)
{
    c.SwaggerDoc($"v{version}", new OpenApiInfo() { Title = "My API", Version = $"v{version}" });
}

以及 UseSwaggerUI 修改如下:

foreach (var version in ReflectionCache.AllApiVersions)
{
    c.SwaggerEndpoint($"/swagger/v{version}/swagger.json", $"My API V{version}");
}

最后一步,修改 ReplaceVersionWithExactValueInPath 类中的 Apply 方法中 newPaths.Add 部分如下:

if (swaggerDoc.Info.Version == $"v{version}")
{
    newPaths.Add(item.Key.Replace("v{version}", $"v{version}"), item.Value);
}

运行程序后浏览器访问https://localhost:5001/swagger返回如图:

swagger by version.png

选择对应版本,将只展示对应版本的api。
swagger v1.0.png

swagger v1.1.png

6、出现问题了

上述结果并不是我们想要的结果,我们想要的路由应该是如下方式:

/api/v1.0/WeatherForecast/Hello
/api/v1.1/WeatherForecast/Hello

Action应是一样大,不同的是版本号。
我们修改 Hello2 方法如下:

// route: /api/v1.1/WeatherForecast/Hello
[HttpGet]
[ApiVersion("1.1")]
[ActionName("Hello")]
public string Hello2()
{
    return "Hello world from Hello2!";
}

运行程序后使用postman或其他工具GET请求如下两个链接:
http://localhost:5000/api/v1.0/WeatherForecast/Hello

response from Hello v1.0.png

http://localhost:5000/api/v1.1/WeatherForecast/Hello

response from Hello v1.1.png

返回结果正如期待。
然后让我们再次浏览器访问https://localhost:5001/swagger返回如图:
swagger 2.png

很明显,报错了,让我们看一下调试控制台的输出
error.png

错误提示说有两个方法的action是一样的,导致生成swagger.json的时候冲突了,细心的同学应该发现此时错误的原因是因为我们给 Hello2 方法加了一个显式ActionName:Hello。

7、解决方法

首先来理一下解决思路:
1)添加一个自定义的ActionName特性,对于需要使用其他Action名称的方法打上这个特性并提供Action名称。
2)生成swagger.json时,以版本号+自定义的ActionName(如果有,否在就用方法名)作为key值。
3)真正进行Http访问时,添加管道方法将请求转到真正的Action上。
开工!
首先添加一个新类:

public class ActionNameAttribute : Attribute
{
    public string Name { get; set; }

    public ActionNameAttribute(string name)
    {
        Name = name;
    }
}

并将控制器中的ActionName特性显式强制改为使用上述添加的类,而不是 Microsoft.AspNetCore.Mvc 中的。
修改 ReplaceVersionWithExactValueInPath.Apply 方法如下:

var newPaths = new OpenApiPaths();
foreach (var item in swaggerDoc.Paths)
{
    var arr = item.Key.Split('/');
    // route as /api/[controller]/[action] mode
    if (_reflectionCache.AllControllers.Any(x => x.Name == $"{arr[arr.Length - 2]}Controller"))
    {
        var methods = _reflectionCache.AllControllers.FirstOrDefault(x => x.Name == $"{arr[arr.Length - 2]}Controller")
                        .GetMethods();
        var action = arr[arr.Length - 1];

        var version = "v" + methods
            .FirstOrDefault(x => x.Name == action &&
                x.IsPublic &&
                x.GetCustomAttribute() != null)
            .GetCustomAttribute()?.Versions
            .FirstOrDefault()
            .ToString();
        var settedAction = methods
            .FirstOrDefault(x => x.Name == action &&
                x.IsPublic &&
                x.GetCustomAttribute() != null)
            .GetCustomAttribute()?.Name;
        action = settedAction ?? action;

        if (swaggerDoc.Info.Version == version)
        {
            newPaths.Add($"/api/{version}/{arr[arr.Length - 2]}/{action}", item.Value);
        }
    }
}

swaggerDoc.Paths = newPaths;

上述代码逻辑如下:
首先通过生成的path中的控制器名称以及action名字通过反射找到对应的action,然后检查有没有手动设置Action名称,如果有使用设置的action替换掉原来的。
然后修改 Configure 方法签名,添加注入

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ReflectionCache reflectionCache)

并添加一个中间件

app.Use(async (context, next) =>
{
        // Do work that doesn't write to the Response.
        if (context.Request.Path.HasValue &&
            context.Request.Path.Value.StartsWith("/api/"))
        {
            // arr as this:
            // api, version, controller, action
            var arr = context.Request.Path.Value.Split("/")
                .Where(x => !string.IsNullOrEmpty(x))
                    .ToArray();
            var version = arr[1];
            var controller = arr[2];
            var action = arr[3];

            // trying to get all actions with this name
            var realAction = reflectionCache.AllControllers.FirstOrDefault(x => x.Name == $"{controller}Controller")
                .GetMethods()
                .Where(x => x.IsPublic &&
                    x.GetCustomAttribute() != null &&
                    Convert.ToDouble(x.GetCustomAttribute().Versions.FirstOrDefault()?.ToString()) <= Convert.ToDouble(version.TrimStart('v')) &&
                    (x.Name == action || x.GetCustomAttribute()?.Name == action))
                .OrderByDescending(x => x.GetCustomAttribute().Versions.FirstOrDefault()?.ToString())
                .First();
            var realVersion = $"{realAction.GetCustomAttribute().Versions.FirstOrDefault()?.ToString()}";

            if (realAction != null)
            {
                context.Request.Path = new Microsoft.AspNetCore.Http.PathString($"/api/v{realVersion}/{controller}/{realAction.Name}");
            }
        }

        // Do logging or other work that doesn't write to the Response.
        await next.Invoke();
});

其中,在 <= 判断版本号的地方实现了如果访问的方法没有该版本,将使用现在有的最大的版本,比如访问 /api/v1.2/WeatherForecast/Hello 将会访问到 /api/v1.1/WeatherForecast/Hello
当然,如果需要判断当前使用的版本号系统中是否存在,如果不存在进行错误提示等,可以添加如下代码:

if(!reflectionCache.AllApiVersions.Contains(version.TrimStart('v')))
{
    // 自定义错误处理
}

好了, 现在让我们启动程序在浏览器访问https://localhost:5001/swagger返回如图:

swagger v1.0 try it out.png

swagger v1.1 try it out.png

结果正如预期。

你可能感兴趣的:(Asp.Net Core WebApi使用Swagger结合Versioning实现按版本分类显示与请求)