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<ApiVersionAttribute>().Versions.First().ToString();
                    
            newPaths.Add(item.Key.Replace("v{version}", $"v{version}"), item.Value);
        }
        swaggerDoc.Paths = newPaths;
    }
}

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

c.OperationFilter<RemoveVersionFromParameter>();
c.DocumentFilter<ReplaceVersionWithExactValueInPath>();

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

swagger 2.png

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

5、按版本分类

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

public class ReflectionCache
{
    public IEnumerable<Type> AllControllers { get; set; }

    public IEnumerable<string> 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<ApiVersionAttribute>() != null)
                .SelectMany(x => x.GetCustomAttribute<ApiVersionAttribute>().Versions))
                .GroupBy(x => x.ToString())
                .Select(x => x.Key);

并将其以 Singleton 方式注入:

services.AddSingleton<ReflectionCache>(provider => ReflectionCache);

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

c.OperationFilter<RemoveVersionFromParameter>();
c.DocumentFilter<ReplaceVersionWithExactValueInPath>();

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<ApiVersionAttribute>() != null)
            .GetCustomAttribute<ApiVersionAttribute>()?.Versions
            .FirstOrDefault()
            .ToString();
        var settedAction = methods
            .FirstOrDefault(x => x.Name == action &&
                x.IsPublic &&
                x.GetCustomAttribute<ApiVersionAttribute>() != null)
            .GetCustomAttribute<ActionNameAttribute>()?.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<ApiVersionAttribute>() != null &&
                    Convert.ToDouble(x.GetCustomAttribute<ApiVersionAttribute>().Versions.FirstOrDefault()?.ToString()) <= Convert.ToDouble(version.TrimStart('v')) &&
                    (x.Name == action || x.GetCustomAttribute<ActionNameAttribute>()?.Name == action))
                .OrderByDescending(x => x.GetCustomAttribute<ApiVersionAttribute>().Versions.FirstOrDefault()?.ToString())
                .First();
            var realVersion = $"{realAction.GetCustomAttribute<ApiVersionAttribute>().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

结果正如预期。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,264评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,549评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,389评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,616评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,461评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,351评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,776评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,414评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,722评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,760评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,537评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,381评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,787评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,030评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,304评论 1 252
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,734评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,943评论 2 336