[ASP.NET Core MVC] 如何實現運行時動態定義Controller類型?

昨天有個朋友在微信上問我一個問題:他希望通過動態腳本的形式實現對ASP.NET Core MVC應用的擴展,比如在程式運行過程中上傳一段C#腳本將其中定義的Controller類型註冊到應用中,問我是否有好解決方案。我當時在外邊,回復不太方便,所以只給他說了兩個介面/類型:IActionDescriptorProvider和ApplicationPartManager。這是一個挺有意思的問題,所以回家後通過兩種方案實現了這個需求。源程式碼從這裡下載。

一、實現的效果

我們先來看看實現的效果。如下所示的是一個MVC應用的主頁,我們可以在文本框中通過編寫C#程式碼定義一個有效的Controller類型,然後點擊「Register」按鈕,定義的Controller類型將自動註冊到MVC應用中

image

由於我們採用了針對模板為「{controller}/{action}」的約定路由,所以我們採用路徑「/foo/bar」就可以訪問上圖中定義在FooController中的Action方法Bar,下圖證實了這一點。

image

二、動態編譯源程式碼

要實現如上所示的「針對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框架建立在通過EndpointRoutingMiddlewareEndpointMiddleare這兩個中間件構成的終結點路由系統上。此路由系統維護著一組路由終結點,該終結點體現為一個路由模式(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進行註冊。針對應用組成部分的註冊不是靜態的(在應用啟動的時候進行),而是動態的(在運行的任意時刻都可以進行)。

八、再扯幾句

toupiao從提供的程式碼來看,兩種解決方案所需的成本都是很少的,但是能否找到解決方案,取決於我們是否對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框架的設計與實現[下篇]:參數綁定