由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(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<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所以找到另一個途徑解決問題。

👇歡迎掃碼關注我的公衆號👇
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章