緣起
在使用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其實就是咱們常用HttpGet
、HttpPost
、HttpPut
、HttpDelete
相關的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所以找到另一個途徑解決問題。
