由ASP.NET Core WebApi新增Swagger報錯引發的探究

yi念之間發表於2021-07-08

緣起

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

?歡迎掃碼關注我的公眾號? 由ASP.NET Core WebApi新增Swagger報錯引發的探究

相關文章