[ASP.NET Core MVC] 如何實現執行時動態定義Controller型別?
昨天有個朋友在微信上問我一個問題:他希望通過動態指令碼的形式實現對ASP.NET Core MVC應用的擴充套件,比如在程式執行過程中上傳一段C#指令碼將其中定義的Controller型別註冊到應用中,問我是否有好解決方案。我當時在外邊,回覆不太方便,所以只給他說了兩個介面/型別:IActionDescriptorProvider和ApplicationPartManager。這是一個挺有意思的問題,所以回家後通過兩種方案實現了這個需求。原始碼從這裡下載。
一、實現的效果
我們先來看看實現的效果。如下所示的是一個MVC應用的主頁,我們可以在文字框中通過編寫C#程式碼定義一個有效的Controller型別,然後點選“Register”按鈕,定義的Controller型別將自動註冊到MVC應用中
由於我們採用了針對模板為“{controller}/{action}”的約定路由,所以我們採用路徑“/foo/bar”就可以訪問上圖中定義在FooController中的Action方法Bar,下圖證實了這一點。
二、動態編譯原始碼
要實現如上所示的“針對Controller型別的動態註冊”,首先需要解決的是針對提供原始碼的動態編譯問題,我們知道這個可以利用Roslyn來解決。具體來說,我們定義瞭如下這個ICompiler介面,它的Compile方法將會對引數sourceCode提供的原始碼進行編譯。該方法返回原始碼動態編譯生成的程式集,它的第二個引數代表引用的程式集。
public interface ICompiler { Assembly Compile(string text, params Assembly[] referencedAssemblies); }
如下所示的Compiler型別是對ICompiler介面的預設實現。
public class Compiler : ICompiler { public Assembly Compile(string text, params Assembly[] referencedAssemblies) { var references = referencedAssemblies.Select(it => MetadataReference.CreateFromFile(it.Location)); var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); var assemblyName = "_" + Guid.NewGuid().ToString("D"); var syntaxTrees = new SyntaxTree[] { CSharpSyntaxTree.ParseText(text) }; var compilation = CSharpCompilation.Create(assemblyName, syntaxTrees, references, options); using var stream = new MemoryStream(); var compilationResult = compilation.Emit(stream); if (compilationResult.Success) { stream.Seek(0, SeekOrigin.Begin); return Assembly.Load(stream.ToArray()); } throw new InvalidOperationException("Compilation error"); } }
三、自定義IActionDescriptorProvider
解決了針對提供原始碼的動態編譯問題之後,我們可以獲得需要註冊的Controller型別,那麼如何將它註冊MVC應用上呢?要回答這個問題,我們得對MVC框架的執行原理有一個大致的瞭解:ASP.NET Core通過一個由伺服器和若干中介軟體構成的管道來處理請求,MVC框架建立在通過EndpointRoutingMiddleware和EndpointMiddleare這兩個中介軟體構成的終結點路由系統上。此路由系統維護著一組路由終結點,該終結點體現為一個路由模式(Route Pattern)與對應處理器(通過RequestDelegate委託表示)之間的對映。
由於針對MVC應用的請求總是指向某一個Action,所以MVC框架提供的路由整合機制體現在為每一個Action建立一個或者多個終結點(同一個Action方法可以註冊多個路由)。針對Action方法的路由終結點是根據描述Action方法的ActionDescriptor物件構建而成的。至於ActionDescriptor物件,則是通過註冊的一組IActionDescriptorProvider物件來提供的,那麼我們的問題就迎刃而解:通過註冊自定義的IActionDescriptorProvider從動態定義的Controller型別中解析出合法的Action方法,並建立對應的ActionDescriptor物件即可。
那麼ActionDescriptor如何建立呢?我們能想到簡單的方式是呼叫如下這個Build方法。針對該方法的呼叫存在兩個問題:第一,ControllerActionDescriptorBuilder是一個內部(internal)型別,我們指定以反射的方式呼叫這個方法,第二,這個方法接受一個型別為ApplicationModel的引數。
internal static class ControllerActionDescriptorBuilder { public static IList<ControllerActionDescriptor> Build(ApplicationModel application); }
ApplicationModel型別涉及到一個很大的主題:MVC應用模型,目前我們現在只關注如何建立這個物件。表示MVC應用模型的ApplicationModel物件是通過對應的工廠ApplicationModelFactory建立的。這個工廠會自動註冊到MVC應用的依賴注入框架中,但是這依然是一個內部(內部)型別,所以還得反射。
internal class ApplicationModelFactory { public ApplicationModel CreateApplicationModel(IEnumerable<TypeInfo> controllerTypes); }
我們定義瞭如下這個DynamicActionProvider型別實現了IActionDescriptorProvider介面。針對提供的原始碼向ActionDescriptor列表的轉換體現在AddControllers方法中:它利用ICompiler物件編譯原始碼,並在生成的程式集中解析出有效的Controller型別,然後利用ApplicationModelFactory創建出代表應用模型的ApplicationModel物件,後者作為引數呼叫ControllerActionDescriptorBuilder的靜態方法Build創建出描述所有Action方法的ActionDescriptor物件。
public class DynamicActionProvider : IActionDescriptorProvider { private readonly List<ControllerActionDescriptor> _actions; private readonly Func<string, IEnumerable<ControllerActionDescriptor>> _creator; public DynamicActionProvider(IServiceProvider serviceProvider, ICompiler compiler) { _actions = new List<ControllerActionDescriptor>(); _creator = CreateActionDescrptors; IEnumerable<ControllerActionDescriptor> CreateActionDescrptors(string sourceCode) { var assembly = compiler.Compile(sourceCode, Assembly.Load(new AssemblyName("System.Runtime")), typeof(object).Assembly, typeof(ControllerBase).Assembly, typeof(Controller).Assembly); var controllerTypes = assembly.GetTypes().Where(it => IsController(it)); var applicationModel = CreateApplicationModel(controllerTypes); assembly = Assembly.Load(new AssemblyName("Microsoft.AspNetCore.Mvc.Core")); var typeName = "Microsoft.AspNetCore.Mvc.ApplicationModels.ControllerActionDescriptorBuilder"; var controllerBuilderType = assembly.GetTypes().Single(it => it.FullName == typeName); var buildMethod = controllerBuilderType.GetMethod("Build", BindingFlags.Static | BindingFlags.Public); return (IEnumerable<ControllerActionDescriptor>)buildMethod.Invoke(null, new object[] { applicationModel }); } ApplicationModel CreateApplicationModel(IEnumerable<Type> controllerTypes) { var assembly = Assembly.Load(new AssemblyName("Microsoft.AspNetCore.Mvc.Core")); var typeName = "Microsoft.AspNetCore.Mvc.ApplicationModels.ApplicationModelFactory"; var factoryType = assembly.GetTypes().Single(it => it.FullName == typeName); var factory = serviceProvider.GetService(factoryType); var method = factoryType.GetMethod("CreateApplicationModel"); var typeInfos = controllerTypes.Select(it => it.GetTypeInfo()); return (ApplicationModel)method.Invoke(factory, new object[] { typeInfos }); } bool IsController(Type typeInfo) { if (!typeInfo.IsClass) return false; if (typeInfo.IsAbstract) return false; if (!typeInfo.IsPublic) return false; if (typeInfo.ContainsGenericParameters) return false; if (typeInfo.IsDefined(typeof(NonControllerAttribute))) return false; if (!typeInfo.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) && !typeInfo.IsDefined(typeof(ControllerAttribute))) return false; return true; } } public int Order => -100; public void OnProvidersExecuted(ActionDescriptorProviderContext context) { } public void OnProvidersExecuting(ActionDescriptorProviderContext context) { foreach (var action in _actions) { context.Results.Add(action); } } public void AddControllers(string sourceCode) => _actions.AddRange(_creator(sourceCode)); }
四、讓應用感知到變化
DynamicActionProvider 解決了將提供的原始碼向對應ActionDescriptor列表的轉換,但是MVC預設情況下對提供的ActionDescriptor物件進行了快取。如果框架能夠使用新的ActionDescriptor物件,需要告訴它當前應用提供的ActionDescriptor列表發生了改變,而這可以利用自定義的IActionDescriptorChangeProvider來實現。為此我們定義瞭如下這個DynamicChangeTokenProvider型別,該型別實現了IActionDescriptorChangeProvider介面,並利用GetChangeToken方法返回IChangeToken物件通知MVC框架當前的ActionDescriptor已經發生改變。從實現實現程式碼可以看出,當我們呼叫NotifyChanges方法的時候,狀態改變通知會被髮出去。
public class DynamicChangeTokenProvider : IActionDescriptorChangeProvider { private CancellationTokenSource _source; private CancellationChangeToken _token; public DynamicChangeTokenProvider() { _source = new CancellationTokenSource(); _token = new CancellationChangeToken(_source.Token); } public IChangeToken GetChangeToken() => _token; public void NotifyChanges() { var old = Interlocked.Exchange(ref _source, new CancellationTokenSource()); _token = new CancellationChangeToken(_source.Token); old.Cancel(); } }
五、應用構建
到目前為止,核心的兩個型別DynamicActionProvider和DynamicChangeTokenProvider已經定義好了,接下來我們按照如下的方式將它們註冊到MVC應用的依賴注入框架中。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(web => web .ConfigureServices(svcs => svcs .AddSingleton<ICompiler, Compiler>() .AddSingleton<DynamicActionProvider>() .AddSingleton<DynamicChangeTokenProvider>() .AddSingleton<IActionDescriptorProvider>(provider => provider.GetRequiredService<DynamicActionProvider>()) .AddSingleton<IActionDescriptorChangeProvider>(provider => provider.GetRequiredService<DynamicChangeTokenProvider>()) .AddRouting().AddControllersWithViews()) .Configure(app => app .UseRouting() .UseEndpoints(endpoints => endpoints.MapControllerRoute( name: default, pattern: "{controller}/{action}" )))) .Build() .Run(); } }
然後我們定義瞭如下這個HomeController。針對GET請求的Index方法會將上圖所示的檢視呈現出來。當我們點選“Register”按鈕之後,提交的原始碼會通過針對POST請求的Index方法進行處理。如下面的程式碼片段所示,在將將提交的原始碼作為引數呼叫了DynamicActionProvider物件的 AddControllers方法之後,我們呼叫了DynamicChangeTokenProvider物件的 NotifyChanges方法。
public class HomeController : Controller { [HttpGet("/")] public IActionResult Index() => View(); [HttpPost("/")] public IActionResult Index( string source, [FromServices]DynamicActionProvider actionProvider, [FromServices] DynamicChangeTokenProvider tokenProvider) { try { actionProvider.AddControllers(source); tokenProvider.NotifyChanges(); return Content("OK"); } catch (Exception ex) { return Content(ex.Message); } } }
如下所示的是View的定義。
<html> <body> <form method="post"> <textarea name="source" cols="50" rows="10">Define your controller here...</textarea> <br/> <button type="submit">Register</button> </form> </body> </html>
六、換一種實現方式
接下來我們提供一種更加簡單的解決方案。通過上面的介紹我們知道,用來描述Action方法的ActionDescriptor列表是由一組IActionDescriptorProvider物件提供的,對於針對Controller的MVC程式設計模型(另一種是針對Razor Page的程式設計模型)來說,對應的實現型別為ControllerActionDescriptorProvider。
當ControllerActionDescriptorProvider在提供對應ActionDescriptor物件之前,會從作為當前應用組成部分(ApplicationPart)的程式集中解析出所有Controller型別。如果我們能夠讓動態提供給原始碼程式設計生成的程式整合為其合法的組成部分,那麼我們面對的問題自然就能迎刃而解。新增應用組成部分其實很簡單,我們只需要按照如下的方式呼叫ApplicationPartManager物件的Add方法就可以了。為了讓MVC框架感知到提供的ActionDescriptor列表已經發生改變,我們還是需要呼叫DynamicChangeTokenProvider物件的NotifyChanges方法。
public class HomeController : Controller { [HttpGet("/")] public IActionResult Index() => View();
[HttpPost("/")] public IActionResult Index(string source, [FromServices] ApplicationPartManager manager, [FromServices] ICompiler compiler, [FromServices] DynamicChangeTokenProvider tokenProvider) { try { manager.ApplicationParts.Add(new AssemblyPart(compiler.Compile(source, Assembly.Load(new AssemblyName("System.Runtime")), typeof(object).Assembly, typeof(ControllerBase).Assembly, typeof(Controller).Assembly))); tokenProvider.NotifyChanges(); return Content("OK"); } catch (Exception ex) { return Content(ex.Message); } } }
由於我們不在需要自定義的DynamicActionProvider,自然也就不需要對應的服務註冊了。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(web => web .ConfigureServices(svcs => svcs .AddSingleton<ICompiler, Compiler>() .AddSingleton<DynamicChangeTokenProvider>() .AddSingleton<IActionDescriptorChangeProvider>(provider => provider.GetRequiredService<DynamicChangeTokenProvider>()) .AddRouting().AddControllersWithViews()) .Configure(app => app .UseRouting() .UseEndpoints(endpoints => endpoints.MapControllerRoute( name: default, pattern: "{controller}/{action}" )))) .Build() .Run(); } }
七、這其實不是一個小問題
有人可能覺得上面我們所做的好像只是一些“奇淫巧計”,其實不然,這裡涉及到MVC應用一個重大的主題,我個人將它稱為“動態模組化”。對於一個面向Controller的MVC應用來說,Controller型別是應用基本的組成單元,所以其應用模型(通過上面提到的ApplicationModel物件表示)呈現出這樣的結構:Application->Controller->Action。如果一個MVC應用需要拆分為多個獨立的模組,意味著需要將Controller型別分別定義在不同的程式集中。為了讓這些程式整合為應用的一個有效組成部分,程式集需要封裝成ApplicationPart物件並利用ApplicationPartManager進行註冊。針對應用組成部分的註冊不是靜態的(在應用啟動的時候進行),而是動態的(在執行的任意時刻都可以進行)。
八、再扯幾句
從提供的程式碼來看,兩種解決方案所需的成本都是很少的,但是能否找到解決方案,取決於我們是否對MVC框架的架構設計和實現原理的瞭解。對於很大一部分.NET 開發人員來說,他們的知識領域大都僅限於對基本程式設計模型的瞭解,他們可能知道Controller的所有API,也瞭解各種Razor View的各種定義方式,能夠熟練使用各種過濾器已經算是很不錯的了。但是這是不夠的。
正如我在《ASP.NET Core 3框架揭祕》中所說,“不論我們從事何種層次的工作,最根本的目的只有一個,那就是解決問題。解決方案分兩種,一種叫做“揚湯止沸”,另一種被稱為“釜底抽薪”。如果之關注於程式設計模型,我們只能看到鍋裡的滾水,只有對框架具有了深層次的瞭解,我們才能看到鍋下面的薪火。
順便做一下廣告:《ASP.NET Core 3框架揭祕》京東100-50的活動(這應該是本書歷史上的最低價格)還有最後一天(4月7日),如果有需要,請抓住最後的機會。如果覺得本書對你確實有幫助,希望能夠為本書投票(京東V閱讀時代投票截止到4月20日,每人每天有三次投票機會,參與投票有機會獲得滿100-10,200-20,400-50優惠券,以及面值100/500/1000元京東全品類電子E卡)。
擴充套件閱讀
通過極簡模擬框架讓你瞭解ASP.NET Core MVC框架的設計與實現[上篇]:路由整合
通過極簡模擬框架讓你瞭解ASP.NET Core MVC框架的設計與實現[中篇]: 請求響應
通過極簡模擬框架讓你瞭解ASP.NET Core MVC框架的設計與實現[下篇]:參