代码改变世界

由ASP.NET Core WebApi添加Swagger报错引发的探究

缘起

    在使用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.0
at 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其实就是咱们常用HttpGetHttpPostHttpPutHttpDelete相关的Attribute。
正常逻辑来说那就给每个Action添加HttpMethod呗,但是往往情况就出现在不正常的时候。因为项目是迁移的老项目,先不说私自改了别人代码带来的甩锅问题,公司的WebApi项目很多,这意味着Action很多,如果一个项目一个项目的去找Action添加HttpMethod可是一个不小的工作量,而且开发人员工作繁忙,基本上不会抽出来时间去修改这些的,因为这种只是Swagger不行,但是对于WebApi本身来说这种写法没有任何的问题,也不会报错,只是看起来不规范。那该怎么办呢?

探究源码

又看了看异常决定从源码入手,通过控制台报出的异常可以看到报错的最初位置是在Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GenerateOperations(IEnumerable'1 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<ApiDescription>集合,顺着调用关系往上找,最后发现ApiDescription来自IApiDescriptionGroupCollectionProvider而它来自于构造函数注入进来的

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所以找到另一个途径解决问题。

👇欢迎扫码关注我的公众号👇
posted @ 2021-07-08 08:56  yi念之间  阅读(5720)  评论(17编辑  收藏  举报