最近刚开一个新项目,以asp.net core webapi为app提供接口后台,当然就需要添加版本控制。同时,为了更好的生成文档,也要添加swagger,但是在结合这两者的过程中遇到了一些问题,在此记录下来以便后期查找,也希望能帮助到遇到同类问题的同学。
废话不多说,让我们开始。
1、新建webapi项目
此步跳过,我是用的是3.1的SDK。
2、添加版本控制
添加版本控制包
dotnet add package Microsoft.AspNetCore.Mvc.Versioning
修改 Startup
中 ConfigureServices
添加如下代码:
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返回如图:
输入版本号后可以正常访问。
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;
}
}
然后修改 Startup
中 AddSwaggerGen
添加如下代码:
c.OperationFilter();
c.DocumentFilter();
运行程序后浏览器访问https://localhost:5001/swagger返回如图:
已经不需要在手动输入版本号。
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返回如图:
选择对应版本,将只展示对应版本的api。
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
http://localhost:5000/api/v1.1/WeatherForecast/Hello
返回结果正如期待。
然后让我们再次浏览器访问https://localhost:5001/swagger返回如图:
很明显,报错了,让我们看一下调试控制台的输出
错误提示说有两个方法的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返回如图:
结果正如预期。