缘起
在使用ASP.NET Core进行WebApi项目开发的时候,相信很多人都会使用Swagger作为接口文档呈现工具。相信大家也用过或者了解过Swagger,这里咱们就不过多的介绍了。本篇文章记录一下,笔者在使用ASP.NET Core开发Api的过程中,给接口整合Swagger过程中遇到的一个异常,笔者抱着好奇的心态研究了一下异常的原因,并解决了这个问题。在这个过程中笔者学到了一些新的技能,得到了一些新的知识,便打算记录一下,希望能帮助到更多的人。
示例
从项目渊源上说起,笔者所在项目,很多都是从.Net FrameWork的老项目迁移到ASP.NET Core上来的,这其中做了很多兼容的处理,来保证尽量不修改原有的业务代码,这其中就包含了WebApi相关的部分,这里我们用简单的示例描述现有WebApi的Controller的情况,大致写法如下
[Route("api/[controller]/[action]")][ApiController]public class OrderController : ControllerBase{ private List<OrderDto> orderDtos = new List<OrderDto>(); public OrderController() { orderDtos.Add(new OrderDto { Id = 1,TotalMoney=222,Address="北京市",Addressee="me",From="淘宝",SendAddress="武汉" }); orderDtos.Add(new OrderDto { Id = 2, TotalMoney = 111, Address = "北京市", Addressee = "yi", From = "京东", SendAddress = "北京" }); orderDtos.Add(new OrderDto { Id = 3, TotalMoney = 333, Address = "北京市", Addressee = "yi念之间", From = "天猫", SendAddress = "杭州" }); } /// <summary> /// 获取订单数据 /// </summary> public OrderDto Get(long id) { return orderDtos.FirstOrDefault(i => i.Id == id); } /// <summary> /// 添加订单数据 /// </summary> public IActionResult Add(OrderDto orderDto) { orderDtos.Add(orderDto); return Ok(); } /// <summary> /// 添加订单数据 /// </summary> public IActionResult Edit(long id, OrderDto orderDto) { var order = orderDtos.FirstOrDefault(i => i.Id == id); if (order == null) { return NotFound(); } order.Address = orderDto.Address; order.From = orderDto.From; return Ok(); } /// <summary> /// 删除订单数据 /// </summary> public IActionResult Delete(long id) { var order = orderDtos.FirstOrDefault(i=>i.Id==id); if (order == null) { return NotFound(); } orderDtos.Remove(order); return Ok(); }}
虽然是笔者写的demo,但是大致是这种形式,而且直接通过ASP.NET Core运行起来也没有任何的问题,调用也不会出现任何异常。当项目开发完成后,给项目添加Swagger,笔者用的是Swashbuckle.AspNetCore这个组件,添加Swagger的方式大致如下,首先是在Startup类的ConfigureServices方法中添加以下代码
services.AddSwaggerGen(c =>{ c.SwaggerDoc("v1", new OpenApiInfo { Title = "OrderApi", Description = "订单服务接口" }); var xmlCommentFile = $"{AppContext.BaseDirectory}OrderApi.xml"; if (File.Exists(xmlCommentFile)) { c.IncludeXmlComments(xmlCommentFile); }});
添加完成之后,在Configure方法中开启Swagger中间件,具体代码如下
app.UseSwagger();app.UseSwaggerUI(c =>{ c.SwaggerEndpoint("/swagger/v1/swagger.json", "OrderApi");});
添加完成之后,运行起来项目打开Swagger地址http://localhost:5000/swagger结果直接弹出了一个红色浮窗,看样子有异常,打开.Net Core控制台窗口看到了如下异常
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1] An unhandled exception has occurred while executing the request.Swashbuckle.AspNetCore.SwaggerGen.SwaggerGeneratorException: Ambiguous HTTP method for action OrderApi.Controllers.OrderController.Get (OrderApi). Actions require an explicit HttpMethod binding for Swagger/OpenAPI 3.0at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GenerateOperations(IEnumerable`1 apiDescriptions, SchemaRepository schemaRepository)at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GeneratePaths(IEnumerable`1 apiDescriptions, SchemaRepository schemaRepository)at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GetSwagger(String documentName, String host, String basePath)at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)
其中核心的关键词汇就是Ambiguous HTTP method for action OrderApi.Controllers.OrderController.Get (OrderApi). Actions require an explicit HttpMethod binding for Swagger/OpenAPI 3.0笔者用尽毕生的英语修为,了解到其大概意思是Swagger/OpenAPI 3.0要求Action上必须绑定HttpMethod相关Attribute,否则就报这一大堆错误。这里的HttpMethod其实就是咱们常用HttpGet、HttpPost、HttpPut、HttpDelete相关的Attribute。
正常逻辑来说那就给每个Action添加HttpMethod呗,但是往往情况就出现在不正常的时候。因为项目是迁移的老项目,先不说私自改了别人代码带来的甩锅问题,公司的WebApi项目很多,这意味着Action很多,如果一个项目一个项目的去找Action添加HttpMethod可是一个不小的工作量,而且开发人员工作繁忙,基本上不会抽出来时间去修改这些的,因为这种只是Swagger不行,但是对于WebApi本身来说这种写法没有任何的问题,也不会报错,只是看起来不规范。那该怎么办呢?探究源码
又看了看异常决定从源码入手,通过控制台报出的异常可以看到报错的最初位置是在Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GenerateOperations(IEnumerable1 apiDescriptions, SchemaRepository schemaRepository)`那就从这里准备入手了。
Swashbuckle.AspNetCore入手
在GitHub上找到Swashbuckle.AspNetCore仓库位置,近期GitHub不太稳定,除了梯子貌似也没有很好的办法,多刷新几次将就着用吧,由异常信息可知抛出异常所在的位置SwaggerGenerator类的GenerateOperations方法直接找到源码位置[点击查看源码👈]代码如下
private IDictionary<OperationType, OpenApiOperation> GenerateOperations(IEnumerable<ApiDescription> apiDescriptions, SchemaRepository schemaRepository){ //根据HttpMethod分组 var apiDescriptionsByMethod = apiDescriptions .OrderBy(_options.SortKeySelector) .GroupBy(apiDesc => apiDesc.HttpMethod); var operations = new Dictionary<OperationType, OpenApiOperation>(); foreach (var group in apiDescriptionsByMethod) { var httpMethod = group.Key; if (httpMethod == null) //异常位置在这里 throw new SwaggerGeneratorException(string.Format( "Ambiguous HTTP method for action - {0}. " + "Actions require an explicit HttpMethod binding for Swagger/OpenAPI 3.0", group.First().ActionDescriptor.DisplayName)); if (group.Count() > 1 && _options.ConflictingActionsResolver == null) throw new SwaggerGeneratorException(string.Format( "Conflicting method/path combination \"{0} {1}\" for actions - {2}. " + "Actions require a unique method/path combination for Swagger/OpenAPI 3.0. Use ConflictingActionsResolver as a workaround", httpMethod, group.First().RelativePathSansQueryString(), string.Join(",", group.Select(apiDesc => apiDesc.ActionDescriptor.DisplayName)))); var apiDescription = (group.Count() > 1) ? _options.ConflictingActionsResolver(group) : group.Single(); operations.Add(OperationTypeMap[httpMethod.ToUpper()], GenerateOperation(apiDescription, schemaRepository)); }; return operations;}
httpMethod属性的数据源来自IEnumerable
private readonly IApiDescriptionGroupCollectionProvider _apiDescriptionsProvider;private readonly ISchemaGenerator _schemaGenerator;private readonly SwaggerGeneratorOptions _options;public SwaggerGenerator( SwaggerGeneratorOptions options, IApiDescriptionGroupCollectionProvider apiDescriptionsProvider, ISchemaGenerator schemaGenerator){ _options = options ?? new SwaggerGeneratorOptions(); _apiDescriptionsProvider = apiDescriptionsProvider; _schemaGenerator = schemaGenerator;}
看名字也知道IApiDescriptionGroupCollectionProvider是专门服务于Api描述相关的,在Swashbuckle.AspNetCore仓库中造了下没发现相关定义,于是用VS找到引用发现定义如下
namespace Microsoft.AspNetCore.Mvc.ApiExplorer{ public interface IApiDescriptionGroupCollectionProvider { ApiDescriptionGroupCollection ApiDescriptionGroups { get; } }}
转战aspnetcore
看命名空间IApiDescriptionGroupCollectionProvider居然是AspNetCore.Mvc下的,也就是说来自AspNetCore自身,跑到AspNetCore的核心仓库搜索了一下代码找到如下位置代码[点击查看源码👈]
internal static void AddApiExplorerServices(IServiceCollection services){ services.TryAddSingleton<IApiDescriptionGroupCollectionProvider, ApiDescriptionGroupCollectionProvider>(); services.TryAddEnumerable( ServiceDescriptor.Transient<IApiDescriptionProvider, DefaultApiDescriptionProvider>());}
而AddApiExplorerServices方法是在当前类的AddApiExplorer扩展方法中被调用的
public static IMvcCoreBuilder AddApiExplorer(this IMvcCoreBuilder builder){ AddApiExplorerServices(builder.Services); return builder;}
看到IMvcCoreBuilder接口,我们就应该感觉到这是Mvc的核心接口扩展方法,但是趋于好奇心还是往上找了一下,发现确实是跟着ASP.NET Core土生土长的实现,最终位置如下[点击查看源码👈]
private static IMvcCoreBuilder AddControllersCore(IServiceCollection services){ return services .AddMvcCore() .AddApiExplorer() .AddAuthorization() .AddCors() .AddDataAnnotations() .AddFormatterMappings();}
微软想的还是比较周到的,居然在ASP.NET Core的核心位置,加入了IApiDescriptionGroupCollectionProvider这种操作,在IApiDescriptionGroupCollectionProvider的示例中包含了当前Api项目有关Controller和Action相关的信息,而Swagger的Doc文档也就是咱们看到的swagger.json正是基于这些数据信息组装而来。
IApiDescriptionGroupCollectionProvider还是比较实用,如果在不知道这个操作存在的情况下,我们获取WebApi的Controller或Action相关的信息,首先想到的就是反射Controller得到这些,如今有了IApiDescriptionGroupCollectionProvider我们可以在IOC容器中直接获取这个接口的实例,获取Controller和Action的信息。
解决问题
我们找到了问题的根源,可以下手解决问题了,其本质问题是Swagger通过ApiDescription获取Action的HttpMethod信息,但是我们项目由于各种原因,在Action上并没有添加HttpMethod相关的Attribute,所以我们只能从ApiDescription入手,好在我们可以在IOC容器中获取到IApiDescriptionGroupCollectionProvider的实例,从这里入手扩展一个方法,具体实现如下
/// <summary>/// action没有httpmethod attribute的情况下根据action的开头名称给与默认值/// </summary>/// <param name="app">IApplicationBuilder</param>/// <param name="defaultHttpMethod">默认给定的HttpMethod</param>public static void AutoHttpMethodIfActionNoBind(this IApplicationBuilder app, string defaultHttpMethod = null){ //从容器中获取IApiDescriptionGroupCollectionProvider实例 var apiDescriptionGroupCollectionProvider = app.ApplicationServices.GetRequiredService<IApiDescriptionGroupCollectionProvider>(); var apiDescriptionGroupsItems = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items; //遍历ApiDescriptionGroups foreach (var apiDescriptionGroup in apiDescriptionGroupsItems) { foreach (var apiDescription in apiDescriptionGroup.Items) { if (string.IsNullOrEmpty(apiDescription.HttpMethod)) { //获取Action名称 var actionName = apiDescription.ActionDescriptor.RouteValues["action"]; //默认给定POST string methodName = defaultHttpMethod ?? "POST"; //根据Action开头单词给定HttpMethod默认值 if (actionName.StartsWith("get", StringComparison.OrdinalIgnoreCase)) { methodName = "GET"; } else if (actionName.StartsWith("put", StringComparison.OrdinalIgnoreCase)) { methodName = "PUT"; } else if (actionName.StartsWith("delete", StringComparison.OrdinalIgnoreCase)) { methodName = "DELETE"; } apiDescription.HttpMethod = methodName; } } }}
写完上面的代码后,抱着试试看的心情,因为不清楚这波操作好不好使,将扩展方法引入到Configure方法中,为了清晰和Swagger中间件放到一起后,效果如下
if (!env.IsProduction()){ app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "OrderApi"); }); //给没有配置httpmethod的action添加默认操作 app.AutoHttpMethodIfActionNoBind();}
加完之后重新运行项目,打开swagger地址http://localhost:5000/swagger没有异常,在Swagger上调用了接口试了一下,没有任何问题。这样的话可以做到只添加一个扩展方法就能解决问题,而不需要挨个Action进行添加HttpMethod。如果想需要更智能的判断Action默认的HttpMethod需要如何定位,直接修改AutoHttpMethodIfActionNoBind扩展方法,因为我们WebApi项目的Action大部分调用方式都是HttpPost,所以这里的逻辑我写的比较简单。
后续小插曲
通过上面的方式解决了Swagger报错之后,在后来无意中翻看Swashbuckle.AspNetCore文档的时候发现了IDocumentFilter这个Swagger过滤器,想着如果能通过过滤器的方式去解决这个问题会更优雅。我们都知道过滤器的作用,而这个过滤器通过看名字我们可以知道他是在生成SwaggerDoc的时候可以对Doc数据进行处理,于是尝试写了一个过滤器,实现如下
public class AutoHttpMethodOperationFitler : IDocumentFilter{ private readonly string _defaultHttpMethod; public AutoHttpMethodOperationFitler(string defaultHttpMethod = null) { _defaultHttpMethod = defaultHttpMethod; } public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) { //通过DocumentFilterContext上下文可以获取到ApiDescription集合 foreach (var apiDescription in context.ApiDescriptions) { //为null说明没有给Action添加HttpMethod if (string.IsNullOrEmpty(apiDescription.HttpMethod)) { //这些逻辑是和AutoHttpMethodIfActionNoBind扩展方法保持一致的 var actionName = apiDescription.ActionDescriptor.RouteValues["action"]; string methodName = "POST"; if (actionName.StartsWith("get", StringComparison.OrdinalIgnoreCase)) { methodName = "GET"; } else if (actionName.StartsWith("put", StringComparison.OrdinalIgnoreCase)) { methodName = "PUT"; } else if (actionName.StartsWith("delete", StringComparison.OrdinalIgnoreCase)) { methodName = "DELETE"; } apiDescription.HttpMethod = methodName; } } }}
编写完成之后再AddSwaggerGen方法中注册AutoHttpMethodOperationFitler过滤器,如下所示
services.AddSwaggerGen(c =>{ c.SwaggerDoc("v1", new OpenApiInfo { Title = "OrderApi", Description = "订单服务接口" }); //这里注册DocumentFilter c.DocumentFilter<AutoHttpMethodOperationFitler>(); var xmlCommentFile = $"{AppContext.BaseDirectory}OrderApi.xml"; if (File.Exists(xmlCommentFile)) { c.IncludeXmlComments(xmlCommentFile); }});
忙活完这一波之后注释掉AutoHttpMethodOperationFitler扩展方法,添加AutoHttpMethodOperationFitler过滤器,然后运行一波,打开Swagger地址。不过很遗憾还是会报Actions require an explicit HttpMethod binding for Swagger/OpenAPI 3.0这个异常,想了想为啥还会报这个异常无果后,决定还是翻看源码看一下,这一看果然找到了原因,代码如下[点击查看源码👈]
var swaggerDoc = new OpenApiDocument{ Info = info, Servers = GenerateServers(host, basePath), //出现异常的代码方法在这里被调用 Paths = GeneratePaths(applicableApiDescriptions, schemaRepository), Components = new OpenApiComponents { Schemas = schemaRepository.Schemas, SecuritySchemes = new Dictionary<string, OpenApiSecurityScheme>(_options.SecuritySchemes) }, SecurityRequirements = new List<OpenApiSecurityRequirement>(_options.SecurityRequirements)};//执行IDocumentFilter Apply方法的地方在这里var filterContext = new DocumentFilterContext(applicableApiDescriptions, _schemaGenerator, schemaRepository);foreach (var filter in _options.DocumentFilters){ filter.Apply(swaggerDoc, filterContext);}
通过上面的源码可以看到,针对数据源信息是否规范的校验,是在执行IDocumentFilter过滤器的Apply方法之前进行的,所以我们在DocumentFilter处理HttpMethod的问题是解决不了的。到这里自己也明白了AutoHttpMethodOperationFitler目前是解决这个问题能想到的最好方式,暂时算是没啥遗憾了。
总结
本篇文章讲解了在给ASP.NET Core添加Swagger的时候遇到的一个异常而引发的对相关源码的探究,并最终解决这个问题,这里我们Get到了一个比较实用的技能,ASP.NET Core内置了IApiDescriptionGroupCollectionProvider实现,通过它我们可以很便捷的获取到WebApi中关于Controller和Action的元数据信息,而这些信息方便我们生成帮助文档或者生成调用代码是非常实用的。如果你对源码感兴趣,或者有通过看源码解决问题的意识的话,这种方式还是比较有效的,因为我们作为程序员最懂的还是代码,而代码的报错当然也得看着代码解决。解决这类问题也没啥特别好的技巧,通过异常堆栈找到报错的原始位置,顺序需要用到的代码一步一步的往上找,直到找到源头。而这也正是看源码的乐趣,要么好奇驱使,要么解决问题。更好的理解代码,就有更好的方式解决问题,就比如我没办法挨个给Action添加HttpMethod所以找到另一个途径解决问题。
👇欢迎扫码关注我的公众号👇