序言:遠程工作已經一個月了,最近也算是比較閑,每天早上起床打個卡,快速弄完當天要做的工作之後就快樂摸魚去了。之前在用 ABP 框架(舊版)的時候就覺得應用服務層寫起來真的爽,為什麼實現了個 IApplicationService 的空介面就可以變成 Web API,可惜的是之前一直沒空去研究這一塊的 ...
序言:
遠程工作已經一個月了,最近也算是比較閑,每天早上起床打個卡,快速弄完當天要做的工作之後就快樂摸魚去了。之前在用 ABP 框架(舊版)的時候就覺得應用服務層寫起來真的爽,為什麼實現了個 IApplicationService 的空介面就可以變成 Web API,可惜的是之前一直沒空去研究這一塊的原理及其實現,園子里也找不到相關實現原理的文章(舊版 ABP 的倒是有,但是 asp.net core 無法參考)。最近閑起來,就看了一下 abp vnext 的源碼,並且也參考了一下曉晨Master 介紹的 Panda.DynamicWebApi。我自己也簡單實現了一遍動態 Web API,不禁感嘆 asp.net core 設計之精妙。
abp vnext:https://abp.io
Panda.DynamicWebApi:https://github.com/pdafx/Panda.DynamicWebApi
這裡先感謝這兩個庫的相關人員,沒有他們的工作,本文也出現不了。另外在此聲明,本文意在探究其實現原理並實現一個簡易版本,若無把握請勿用於生產環境。
正文:
首先先創建我們的解決方案如下:
因為動態 Web API 這一功能是與業務無關的,而且為了復用,我們應該把這一功能的實現寫到一個單獨的類庫當中。上圖中 Demo 項目是 asp.net core 3.1 版本的 Web API 項目,用於演示我們的簡易動態 Web API,而 SimpleDynamicWebAPI 的 .net standard 2.0 項目則是我們的簡易動態 Web API 項目。
要實現動態 Web API,首先要做的第一件事情就是要有一個規則,來判定一個類是不是動態 Web API。在 abp vnext 當中,主要提供兩種方式,一個是實現 IRemoteService 介面(實際開發過程中一般都是實現 IApplicationService 介面),另一種方式標記 RemoteServiceAttribute。而在 Panda.DynamicWebApi 中,則是實現 IDynamicWebApi 介面並且標記 DynamicWebApi。因為本文是要實現簡易版本,因此只選空介面方式。在 SimpleDynamicWebAPI 項目中創建如下空介面:
namespace SimpleDynamicWebAPI { public interface IApplicationService { } }
接下來,我們有了 IApplicationService 介面,我們也知道實現了這個介面的類是要成為動態 Web API 的,但這個是我們所知道的規則,asp.net core 框架它是不知道的,我們需要把這個規則告訴它。
這一塊 abp vnext 有點複雜,我們參考 Panda.DynamicWebAPI 的實現:
上面圖中 DynamicWebApiControllerFeatureProvider 的 IsController 方法很明顯了。查看 msdn:
粗俗點翻譯過來就是判斷一個類是不是控制器。
接下來開始依樣畫葫蘆。首先一點 ControllerFeatureProvider 類是屬於 asp.net core 的,理論上是位於 Microsoft.AspNetCore.Mvc.Core 這個 nuget 包的,但是這個包的 3.x 版本並沒有發佈在 nuget 上。如果我們的 SimpleDynamicWebAPI 引用 2.x 版本的,而 Demo 項目又是 3.x 版本的,則很可能會引起衝突。保險起見,我們修改 SimpleDynamicWebAPI 為一個 asp.net core 的類庫。反正這個庫本來也不可能會被其它類型諸如 WPF 的項目引用。
修改 SimpleDynamicWebAPI.csproj 如下:
<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>netcoreapp3.1</TargetFramework> <OutputType>Library</OutputType> </PropertyGroup> </Project>
接下來創建 ApplicationServiceControllerFeatureProvider 類,並修改代碼如下:
using Microsoft.AspNetCore.Mvc.Controllers; using System.Reflection; namespace SimpleDynamicWebAPI { public class ApplicationServiceControllerFeatureProvider : ControllerFeatureProvider { protected override bool IsController(TypeInfo typeInfo) { if (typeof(IApplicationService).IsAssignableFrom(typeInfo)) { if (!typeInfo.IsInterface && !typeInfo.IsAbstract && !typeInfo.IsGenericType && typeInfo.IsPublic) { return true; } } return false; } } }
首先先要判斷是不是實現了 IApplicationService 介面,這個是我們一開始所定下的規則。
接下來,1、如果一個介面即使它實現了 IApplicationService,但它仍然不能是一個控制器,那是因為介面是無法實例化的;2、抽象類同理,也是因為無法實例化;3、泛型類也不允許,因為需要確切的類型才能實例化;4、public 代表著公開,可被外界訪問,如果一個類不是 public 的,那麼就不應該成為一個動態 Web API 控制器。
接下來就是要把這個 ApplicationServiceControllerFeatureProvider 加入到 asp.net core 框架中。
創建 SimpleDynamicWebApiExtensions 擴展類,修改代碼如下:
using Microsoft.Extensions.DependencyInjection; using System; namespace SimpleDynamicWebAPI { public static class SimpleDynamicWebApiExtensions { public static IMvcBuilder AddDynamicWebApi(this IMvcBuilder builder) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } builder.ConfigureApplicationPartManager(applicationPartManager => { applicationPartManager.FeatureProviders.Add(new ApplicationServiceControllerFeatureProvider()); }); return builder; } public static IMvcCoreBuilder AddDynamicWebApi(this IMvcCoreBuilder builder) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } builder.ConfigureApplicationPartManager(applicationPartManager => { applicationPartManager.FeatureProviders.Add(new ApplicationServiceControllerFeatureProvider()); }); return builder; } } }
因為 ConfigureApplicationPartManager 擴展方法分別在 IMvcBuilder 和 IMvcCoreBuilder 上都有,所以我們也只能寫兩遍。當然參照 abp vnext 或 Panda.DynamicWebApi 從 services 中獲取 ApplicationPartManager 對象實例也是可行的。
接下來回到 Demo 項目,在 AddControllers 後面加上 AddDynamicWebApi:
public void ConfigureServices(IServiceCollection services) { services.AddControllers().AddDynamicWebApi(); }
現在我們已經完成第一步了,實現了 IApplicationService 介面的類將被視作控制器處理。但僅僅這樣並不足夠,假設有多個類同時實現 IApplicationService 介面,那應該如何映射呢,如果沒錯的話,這個時候你應該會想到是——路由。我們還需要做的工作就是把這些控制器與路由配置起來。
abp vnext 這塊為了在配置過程中獲取 services 而延遲載入導致包了一層,有點複雜。這裡參考 Panda.DynamicWebApi
註釋告訴了我們這裡是配置控制器的路由,感謝作者大大。
繼續畫葫蘆,創建 ApplicationServiceConvention 類並實現 IApplicationModelConvention 介面:
using Microsoft.AspNetCore.Mvc.ApplicationModels; using System; namespace SimpleDynamicWebAPI { public class ApplicationServiceConvention : IApplicationModelConvention { public void Apply(ApplicationModel application) { throw new NotImplementedException(); } } }
Apply 方法的實現等下再考慮,先把它註冊到 asp.net core 框架,修改 SimpleDynamicWebApiExtensions 擴展類如下:
using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using System; namespace SimpleDynamicWebAPI { public static class SimpleDynamicWebApiExtensions { public static IMvcBuilder AddDynamicWebApi(this IMvcBuilder builder) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } builder.ConfigureApplicationPartManager(applicationPartManager => { applicationPartManager.FeatureProviders.Add(new ApplicationServiceControllerFeatureProvider()); }); builder.Services.Configure<MvcOptions>(options => { options.Conventions.Add(new ApplicationServiceConvention()); }); return builder; } public static IMvcCoreBuilder AddDynamicWebApi(this IMvcCoreBuilder builder) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } builder.ConfigureApplicationPartManager(applicationPartManager => { applicationPartManager.FeatureProviders.Add(new ApplicationServiceControllerFeatureProvider()); }); builder.Services.Configure<MvcOptions>(options => { options.Conventions.Add(new ApplicationServiceConvention()); }); return builder; } } }
對服務容器中的 MvcOptions 進行配置,添加上 ApplicationServiceConvention。ok,接下來回到考慮 Apply 方法實現的問題了。
這裡參考 abp vnext:
上圖中的 ApplyForControllers 方法的方法體關鍵部分很好懂,foreach 遍歷了所有的控制器,如果控制器實現了 IRemoteService 介面或者標記了 RemoteServiceAttribute,則調用 ConfigureRemoteService 進一步處理。因為我們的簡易版本是只有介面,else 部分的我們就不需要了。
修改 ApplicationServiceConvention 代碼如下:
using Microsoft.AspNetCore.Mvc.ApplicationModels; using System; namespace SimpleDynamicWebAPI { public class ApplicationServiceConvention : IApplicationModelConvention { public void Apply(ApplicationModel application) { foreach (var controller in application.Controllers) { if (typeof(IApplicationService).IsAssignableFrom(controller.ControllerType)) { ConfigureApplicationService(controller); } } } private void ConfigureApplicationService(ControllerModel controller) { throw new NotImplementedException(); } } }
那 abp vnext 的 ConfigureRemoteService 方法中又幹了什麼呢?跳轉到 ConfigureRemoteService 的實現:
做了三件事情。
1、ConfigureApiExplorer。ApiExplorer,簡單點說就是 API 是否可被髮現。舉個慄子,加入你寫了一個 Web API,項目又配置了 swagger,而且你又想 swagger 不顯示這個 Web API 的話,那麼可以在 Action 上加上:
[ApiExplorerSettings(IgnoreApi = true)]
具體這裡就不說了,大家可以自行 google。
2、ConfigureSelector。Selector,選擇器,可能不太好理解。但是第三個明顯是配置參數,那麼第二這個只能是配置路由了,這個方法將會是我們的關鍵。
3、ConfigureParameters。第二點說了,配置參數。
那麼繼續修改我們的 ApplicationServiceConvention 類並且實現我們的 ConfigureApiExplorer:
using Microsoft.AspNetCore.Mvc.ApplicationModels; using System; namespace SimpleDynamicWebAPI { public class ApplicationServiceConvention : IApplicationModelConvention { public void Apply(ApplicationModel application) { foreach (var controller in application.Controllers) { if (typeof(IApplicationService).IsAssignableFrom(controller.ControllerType)) { ConfigureApplicationService(controller); } } } private void ConfigureApplicationService(ControllerModel controller) { ConfigureApiExplorer(controller); ConfigureSelector(controller); ConfigureParameters(controller); } private void ConfigureApiExplorer(ControllerModel controller) { if (!controller.ApiExplorer.IsVisible.HasValue) { controller.ApiExplorer.IsVisible = true; } foreach (var action in controller.Actions) { if (!action.ApiExplorer.IsVisible.HasValue) { action.ApiExplorer.IsVisible = true; } } } private void ConfigureSelector(ControllerModel controller) { throw new NotImplementedException(); } private void ConfigureParameters(ControllerModel controller) { throw new NotImplementedException(); } } }
ConfigureApiExplorer 這塊,IsVisible 只要沒有值,就無腦設為 true 好了。
接下來 ConfigureSelector 看 abp vnext 的實現:
首先第一行 RemoveEmptySelectors 這是一個關鍵點。雖然我們的動態 Web API 控制器一開始並沒有配置路由,但實際上 asp.net core 框架會為此生成一些空白信息。abp vnext 在這裡就抹除掉了這些空白信息。而 Panda.DynamicWebApi 雖然沒有這樣乾,但是後面的判斷邏輯就相對複雜了一些(大大別打我)。
實抄現襲我們的 RemoveEmptySelectors:
private void RemoveEmptySelectors(IList<SelectorModel> selectors) { for (var i = selectors.Count - 1; i >= 0; i--) { var selector = selectors[i]; if (selector.AttributeRouteModel == null && (selector.ActionConstraints == null || selector.ActionConstraints.Count <= 0) && (selector.EndpointMetadata == null || selector.EndpointMetadata.Count <= 0)) { selectors.Remove(selector); } } }
使用倒序刪除小技巧,就不需要擔心下標越界的問題了。
if 第一行明顯可以看出判斷路由信息是否存在,第二行判斷的 Action 的約束,而約束則是指 HttpGet、HttpPost 這種約束,第三行判斷了端點元數據信息,例如標記了什麼 Attribute 之類的。假如這些都沒有,那麼這條 selector 就可以斷定為空白信息了。
接下來回到 abp vnext 代碼截圖的 181 行:
假如移除過空白信息後仍然有路由的話,則後續不進行處理。
接下來的 foreach 就開始處理 Action 了。先完善我們的代碼,再開始處理 Action 的路由:
using Microsoft.AspNetCore.Mvc.ApplicationModels; using System; using System.Collections.Generic; using System.Linq; namespace SimpleDynamicWebAPI { public class ApplicationServiceConvention : IApplicationModelConvention { public void Apply(ApplicationModel application) { foreach (var controller in application.Controllers) { if (typeof(IApplicationService).IsAssignableFrom(controller.ControllerType)) { ConfigureApplicationService(controller); } } } private void ConfigureApplicationService(ControllerModel controller) { ConfigureApiExplorer(controller); ConfigureSelector(controller); ConfigureParameters(controller); } private void ConfigureApiExplorer(ControllerModel controller) { if (!controller.ApiExplorer.IsVisible.HasValue) { controller.ApiExplorer.IsVisible = true; } foreach (var action in controller.Actions) { if (!action.ApiExplorer.IsVisible.HasValue) { action.ApiExplorer.IsVisible = true; } } } private void ConfigureSelector(ControllerModel controller) { RemoveEmptySelectors(controller.Selectors); if (controller.Selectors.Any(temp => temp.AttributeRouteModel != null)) { return; } foreach (var action in controller.Actions) { ConfigureSelector(action); } } private void ConfigureSelector(ActionModel action) { throw new NotImplementedException(); } private void ConfigureParameters(ControllerModel controller) { throw new NotImplementedException(); } private void RemoveEmptySelectors(IList<SelectorModel> selectors) { for (var i = selectors.Count - 1; i >= 0; i--) { var selector = selectors[i]; if (selector.AttributeRouteModel == null && (selector.ActionConstraints == null || selector.ActionConstraints.Count <= 0) && (selector.EndpointMetadata == null || selector.EndpointMetadata.Count <= 0)) { selectors.Remove(selector); } } } } }
開始處理 Action 的路由,參考 abp vnext 的 194 行到 212 行:
第一行仍然是移除空白信息。
關鍵在最後的判斷,假如沒有 selector 的話,加上就是了。但是如果已經有了呢?那就修改唄。舉個慄子,假如我們實現 IApplicationService 介面的類的一個方法標記了 HttpGet,那麼這個 Action 是有約束的,但是它卻是沒有路由的。這幾行無論是 abp vnext 還是 Panda.DynamicWebApi 都是一樣的。
初步實現添加 selector 方法,這裡我叫它 AddApplicationServiceSelector:
private void AddApplicationServiceSelector(ActionModel action) { var selector = new SelectorModel(); selector.AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(CalculateRouteTemplate(action))); selector.ActionConstraints.Add(new HttpMethodActionConstraint(new[] { GetHttpMethod(action) })); action.Selectors.Add(selector); } private string CalculateRouteTemplate(ActionModel action) { throw new NotImplementedException(); } private string GetHttpMethod(ActionModel action) { throw new NotImplementedException(); }
接下來我們需要添加路由並且配置約束。
要計算路由,我們先舉個慄子(嗯,第三顆慄子了)。假設我們有一個叫 BookController 的 API 控制器,有一個叫 Save 的 Action,那麼它的路由一般就是:
api/books/{id}/save
也就是說,一般 API 控制器的路由如下:
api/[controller]s(/{id})?(/[action])?
那麼我們大概能寫出如下代碼:
private string CalculateRouteTemplate(ActionModel action) { var routeTemplate = new StringBuilder(); routeTemplate.Append("api"); // 控制器名稱部分 var controllerName = action.Controller.ControllerName; if (controllerName.EndsWith("ApplicationService")) { controllerName = controllerName.Substring(0, controllerName.Length - "ApplicationService".Length); } else if (controllerName.EndsWith("AppService")) { controllerName = controllerName.Substring(0, controllerName.Length - "AppService".Length); } controllerName += "s"; routeTemplate.Append($"/{controllerName}"); // id 部分 if (action.Parameters.Any(temp => temp.ParameterName == "id")) { routeTemplate.Append("/{id}"); } // Action 名稱部分 var actionName = action.ActionName; if (actionName.EndsWith("Async")) { actionName = actionName.Substring(0, actionName.Length - "Async".Length); } var trimPrefixes = new[] { "GetAll","GetList","Get", "Post","Create","Add","Insert", "Put","Update", "Delete","Remove", "Patch" }; foreach (var trimPrefix in trimPrefixes) { if (actionName.StartsWith(trimPrefix)) { actionName = actionName.Substring(trimPrefix.Length); break; } } if (!string.IsNullOrEmpty(actionName)) { routeTemplate.Append($"/{actionName}"); } return routeTemplate.ToString(); }
以 api 開頭。
控制器部分,如果名字結尾是 ApplicationService 或者 AppService,那就裁掉。並且變為複數。因為這裡是簡易版,直接加 s 了是。實際建議使用 Inflector 等之類的庫。不然 bus 這種詞直接加 s 就太奇怪了。
id 部分沒啥好說的。
最後是 Action 部分,假如是 Async 結尾的,裁掉。接下來看開頭是不是以 Get、Post、Create 等等這些開頭,是的話也裁掉,註意要先判斷 GetAll 和 GetList 然後再判斷 Get。因為最後裁掉之後有可能是空字元串,所以還需要判斷一下再確定是否添加到路由中。
通過 Action 部分的計算,之前我們剩下的 GetHttpMethod 方法也很好寫了:
private string GetHttpMethod(ActionModel action) { var actionName = action.ActionName; if (actionName.StartsWith("Get")) { return "GET"; } if (actionName.StartsWith("Put") || actionName.StartsWith("Update")) { return "PUT"; } if (actionName.StartsWith("Delete") || actionName.StartsWith("Remove")) { return "DELETE"; } if (actionName.StartsWith("Patch")) { return "PATCH"; } return "POST"; }
根據 Action 名開頭返回 Http 方法就是了,如果什麼都匹配不上就假定 POST。
添加 Selector 總算寫完了,修改 Selector 還難麽?實現我們自己的 NormalizeSelectorRoutes 方法:
private void NormalizeSelectorRoutes(ActionModel action) { foreach (var selector in action.Selectors) { if (selector.AttributeRouteModel == null) { selector.AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(CalculateRouteTemplate(action))); } if (selector.ActionConstraints.OfType<HttpMethodActionConstraint>().FirstOrDefault()?.HttpMethods?.FirstOrDefault() == null) { selector.ActionConstraints.Add(new HttpMethodActionConstraint(new[] { GetHttpMethod(action) })); } } }
沒有路由就給它補路由,沒有約束就給它補約束。
現在我們的 ApplicationServiceConvention 的代碼應該如下:
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApplicationModels; using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace SimpleDynamicWebAPI { public class ApplicationServiceConvention : IApplicationModelConvention { public void Apply(ApplicationModel application) { foreach (var controller in application.Controllers) { if (typeof(IApplicationService).IsAssignableFrom(controller.ControllerType)) { ConfigureApplicationService(controller); } } } private void ConfigureApplicationService(ControllerModel controller) { ConfigureApiExplorer(controller); ConfigureSelector(controller); ConfigureParameters(controller); } private void ConfigureApiExplorer(ControllerModel controller) { if (!controller.ApiExplorer.IsVisible.HasValue) { controller.ApiExplorer.IsVisible = true; } foreach (var action in controller.Actions) { if (!action.ApiExplorer.IsVisible.HasValue) { action.ApiExplorer.IsVisible = true; } } } private void ConfigureSelector(ControllerModel controller) { RemoveEmptySelectors(controller.Selectors); if (controller.Selectors.Any(temp => temp.AttributeRouteModel != null)) { return; } foreach (var action in controller.Actions) { ConfigureSelector(action); } } private void ConfigureSelector(ActionModel action) { RemoveEmptySelectors(action.Selectors); if (action.Selectors.Count <= 0) { AddApplicationServiceSelector(action); } else { NormalizeSelectorRoutes(action); } } private void ConfigureParameters(ControllerModel controller) { throw new NotImplementedException(); } private void NormalizeSelectorRoutes(ActionModel action) { foreach (var selector in action.Selectors) { if (selector.AttributeRouteModel == null) { selector.AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(CalculateRouteTemplate(action))); } if (selector.ActionConstraints.OfType<HttpMethodActionConstraint>().FirstOrDefault()?.HttpMethods?.FirstOrDefault() == null) { selector.ActionConstraints.Add(new HttpMethodActionConstraint(new[] { GetHttpMethod(action) })); } } } private void AddApplicationServiceSelector(ActionModel action) { var selector = new SelectorModel(); selector.AttributeRouteModel =