上次研究Asp.Net MVC 插件化開發簡化方案只是一個開始,最近發佈了 .NET Core 2.0,正好趁熱打鐵,研究 .NET Core 2.0 下的插件化開發方案。html
研究環境
- Windows 10 Home
- Viusal Studio 2017 Community 15.3.3
- .NET Core 2.0
.NET Core 和 ASP.NET Core 都不是新鮮事,但版本 2 絕對是新鮮事。按微軟及其它一些軟件公司的習慣,一般都是第 2 個版本才趨於穩定(好比 WinMe 後的 WinXP,Office 2007 後的 2010,還有 Sun 的 Java 2 等)。之前對 .NET Core 1.x 和 ASP.NET Core 1.x 一直是學習、瞭解和觀望的態度,隨着 2.0 的發佈,是時候將態度變得主動一些了。git
ASP.NET 2.0 帶來了一個使人激動的新特性,Razor Page。建立 ASP.NET Core Web 應用程序的時候,能夠選擇基於 Razor Page 的 Web 應用程序和加入了 MVC 框架的 Web 應用程序 (模型視圖控制器)。github
若是選擇前者,以後想引入 MVC 框架,能夠經過 NuGet 安裝 MVC 相關的程序包便可。若是選擇後者,又想使用 Razor Page,那隻須要創建合適的 Pages 目錄結構就好。總的來講,給 MVC 應用支持 Razor Page 支持更爲容易一些。本文中咱們從一個 MVC 應用開始。segmentfault
現建立兩個 ASP.NET Core 2.0 MVC 應用,分別命名以下緩存
很顯然,前者是主程序,後者是一個插件。mvc
咱們的插件項目是一個完整的 ASP.NET Core MVC 項目,因此它是能夠獨立運行的。將其設置爲啓動項目,使用 F5(調試) 或 Ctrl+F5(不調試) 就能運行起來。app
咱們能夠先在插件自身這個項目中調試好了再把它集成到平臺項目(主 Web 項目)中去。框架
按照以前的經驗,咱們使用區域(Areas)來做爲插件的基礎。所在在 Plugin1 項目中創建以下目錄結構:asp.net
[Plugin1 項目目錄] \---Areas \---Plugin1 \---Controllers
和 Controllers
同一級的原本應該還有 Views
、Models
等,咱們在須要的時候再建立。ide
而後在 Controllers
下建立一個控制器,懶得更名,就叫 DefaultController
好了,文件內容是從模板生成的,咱們只須要給它加個 [Area("Plugin1")]
屬性,聲明它屬於 Plugin1
這個區域就好:
using Microsoft.AspNetCore.Mvc; namespace Plugin1.Areas.Plugin1.Controllers { [Area("Plugin1")] public class DefaultController : Controller { public IActionResult Index() { return View(); } } }
而後還要添加 Razor 視圖。在 Index()
方法中任意位置點擊右鍵,選擇「添加視圖」,VS 會在 Areas/Plugin1
下建立 Views/Default
目錄,並添加 Index.cshtml
視圖文件。修改這個文件的標題,使之顯示「Plugin1 默認頁面」
@{ ViewData["Title"] = "Index"; } <h2>Plugin1 默認頁面</h2>
有了控制器和視圖,運行起來仍然不能訪問,由於缺乏路由。
來到 Startup.cs
文件中,找到 app.UseMvc()
一行,這裏已經包含了一個普通的控制器路由,咱們再添加一個區域控制器路由。
app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); // ☛ 添加區域控制器路由 ↴ routes.MapRoute( name: "areas", template: "{area:exists}/{controller=Default}/{action=Index}/{id?}"); });
既然咱們偷懶直接使用了 DefaultController
做爲控制器名稱,配置路由的時候,記得將默認控制器寫成 {controller=Default}
,而不是 Home
。
如今運行,並修改訪問的 URL 到 /plugin1
或 /plugin1/default
或 /plugin1/default/index
均可以看到正確的結果:
Plugin1 項目已經準備好了,不妨先試試把它拷貝到 WebPlatform 中是否能正確運行:
Areas/Plugin1
目錄Areas/Plugin1/Views
目錄拷貝到 WebPlatform 中的相同位置bin/Debug/netcoreapp2.0
下的 plugin1.dll
拷貝到 WebPlatform 項目的相同位置Startup
中添加 Area 路由 (前面已經講過如何添加)將 WebPlatform 設置爲啓動項目,運行,並在地址修改目標 URL 爲 /plugin1
。滿杯期待的等待,等到的倒是一個悲劇……好吧,看來得註冊 plugin1 的 Assembly。
註冊插件的 Assembly 在上一次研究Asp.Net MVC 插件化開發簡化方案的時候已經幹過。其過程大體是:
bin
) 下的 .dll
.dll
文件拷貝到一個緩存目錄,稱爲 Shadow Copy.dll
,獲得 Assembly 對象既然要走註冊過程,原來拷貝到 WebPlatform/bin/Debug/netcoreapp2.0
下的 plugin1.dll
就不要啦,移到 WebPlatform/Areas/Plugin1/bin
中去,使之更符合單一職責原則。
定位插件目錄須要使用 IHostingEnvironemnt
的 ContentRootPath
屬性來獲取網站根目錄的物理路徑。在 Startup
中只有 ConfigureServices()
(配置 DI) 以後,Configure()
中能夠取到 IHostingEnvironment
,因此咱們在 Startup
中寫一個 LoadPlugins()
方法,在 Configure()
中調用。
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { LoadPlugins(env); .... } private void LoadPlugins(IHostingEnvironment env) { // TODO 找到插件 Assembly,並進行 Shadow Copy // TODO 加載插件 Assemlby 並註冊到 MvcBuilder 中 }
咱們須要使用 IMvcBuilder.AddApplicationPart 來註冊插件 Assembly。而 IMvcBuilder
是從 service.AddMvc()
返回的,這是在配置 DI 服務的時候,尚未拿到 IHostingEnvironment
實例,因此只好給 Startup
定義一個私有成員來暫存 IMvcBuilder
實例了
public class Startup { IMvcBuilder mvcBuilder; ... public void ConfigureServices(IServiceCollection services) { mvcBuilder = services.AddMvc(); } ... private void LoadPlugins(IHostingEnvironment env) { // 定位到插件目錄 Areas var plugins = env.ContentRootFileProvider.GetFileInfo("Areas"); // 準備 Shadow Copy 的目標目錄 var target = Path.Combine(env.ContentRootPath, "app_data", "plugins-cache"); Directory.CreateDirectory(target); // 找到插件目錄下 bin 目錄中的 .dll,拷貝 Directory.EnumerateDirectories(plugins.PhysicalPath) .Select(path => Path.Combine(path, "bin")) .Where(Directory.Exists) .SelectMany(bin => Directory.EnumerateFiles(bin, "*.dll")) .ForEach(dll => File.Copy(dll, Path.Combine(target, Path.GetFileName(dll)), true)); // 從 Shadow Copy 目錄加載 Assembly 並註冊到 Mvc 中 Directory.EnumerateFiles(target, "*.dll") .Select(AssemblyLoadContext.Default.LoadFromAssemblyPath) .ForEach(mvcBuilder.AddApplicationPart); } }
小插曲:
IEnumerable<T>.ForEach()
上面代碼中用到的
.ForEach()
擴展來自 Viyi.Util,其代碼開源,託管在 Gitee 上。這是做者從平時的開發中提取出來的一些工具方法和擴展,歡迎你們使用、提議、貢獻代碼以及指出缺陷。
在 ASP.NET MVC 5 中,咱們的插件都放在 Areas 目錄,由於若是要改目錄須要本身實現視圖引擎來重寫查找視圖的邏輯。不過在 ASP.NET Core 中方便得多,咱們能夠經過配置 RazorViewEngineOptions
來配置從哪些位置去查找視圖。這個配置寫在 ConfigureServices()
中。咱們準備把插件目錄改成 /Plugins
,對應的配置以下:
public void ConfigureServices(IServiceCollection services) { ..... services.Configure<RazorViewEngineOptions>(options => { options.AreaViewLocationFormats.Clear(); options.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml"); options.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml"); options.AreaViewLocationFormats.Add("/Views/Shared/{0}.cshtml"); }); }
模板字符串中的 {2}
表示區域(Area),{1}
表示控制器(Controller),{0}
則是視圖名稱。
固然相應的須要把 WebPlatform/Areas
更名爲 WebPlatform/Plugins
。相應的 LoadPlugins()
中搜索插件的根位置也須要修改(順便再加上目錄檢查):
private void LoadPlugins(IHostingEnvironment env) { // --------------------------------------------------- ⇣⇣⇣⇣⇣⇣⇣⇣⇣ -- var plugins = env.ContentRootFileProvider.GetFileInfo("Plugins"); if (!(plugins.Exists && plugins.IsDirectory)) // ☚ { return; } ...... }
如今插件相關的目錄結構以下
[WebPlatform] \---Plugins +---Plugin1 | +---bin | \---Views +---Plugin2 | \---... \---...
運行,失敗!由於找到多個 HomeController
,不知道該用哪個。固然,咱們是知道的,Plugin 中的 HomeController
是多餘的,刪掉它以後從新編譯,從新把 Plugin1 部署到 Plugins 目錄中便可。
乍一看,很好。仔細一想,不對。不是應該使用 WebPlatform 的 _Layout
嗎?怎麼會沒有樣式。
還須要把 Plugins/Views
下面的 _ViewImports.cshtml
和 _ViewStart.cshtml
拷貝過來,放在 WebPlatform/Plugins/Plugin1/Views
下面。再看效果,好了:
到這裏,咱們以前在 ASP.NET MVC 5 中研究的插件式開發內容,在 ASP.NET MVC Core 2 中都已經實現了。可是既然 ASP.NET Core 2 中引入了 Razor Page,是否可使用 Razor Page 實現的插件呢?
此次新建 ASP.NET Core 應用的時候,選擇Web 應用程序,這樣它不會添加對 MVC 相關的程序包。並且建立出來的項目中沒有 MVC 相關的目錄結構,只有一個 Pages
目錄。
[Plugin2/Pages] +---_Layout.cshtml +---_ValidationScriptsPartial.cshtml +---_ViewImports.cshtml +---_ViewStart.cshtml +---About.cshtml \---About.cshtml.cs +---Contact.cshtml \---Contact.cshtml.cs +---Error.cshtml \---Error.cshtml.cs \---Index.cshtml \---Index.cshtml.cs
咱們只把其中 Index.cshtml 改一下,讓顯示的內容簡單一點
@page @model IndexModel @{ ViewData["Title"] = "Plugin 2 Home"; } <div> Plugin2 Page 頁面內容 </div>
理所固然地,咱們應該在 WebPlatform/Plugins
下建個 Plugin2
目錄,而後把 Plugin2 編譯出來的 .dll
部署在其 bin
子目錄下,再把 Plguin2/Pages
拷貝到其 Pages
目錄。
部署完了,運行時並不能訪問 /plugin2
。須要訪問 /plugin2/pages/
或 /plugin2/pages/index
才能訪問到頁面。得想辦法把路徑中間的 pages
去掉才行。經過對源碼的研究,發現能夠本身實現 IPageRouteModelProvider
來解決這個問題。
IPageRouteModelProvider
這裏實現一個 RazorPluginPageRouteModelProvider
,代碼參考了默認使用的 RazorProjectPageRouteModelProvider
(固然,因爲項目是活動的,因此你看到的代碼和我當時看到的可能會有點不一樣)。
處理的關鍵點在於創建 PageRouteModel
的時候,要將其 viewEnginePath
中的 pages/
去掉。所以,咱們在 RazorPluginPageRouteModelProvider
中寫一個 RemovePagesOfPlugins
來去掉路由中的 pages/
。
private static string RemovePagesOfPlugins(string path) { var index = path.IndexOf("Pages/", StringComparison.OrdinalIgnoreCase); return index > 0 ? path.Remove(index, 6) : path; }
RazorPluginPageRouteModelProvider
中的重點方法是 OnProvidersExecuting
事件方法。對其進行改進,使其即能按原來的邏輯處理 WebPlatform/Pages
,又能處理 Plugins/.../Pages
。這兩個處理過程大體相同,區分在於兩點:
WebPlatform/Pages
,另外一個是 WebPlatform/Plugins
;pages/
。因此提取一個公共方法 ExecutePageProvider()
,在 `OnProvidersExecuting() 中調用:
public void OnProvidersExecuting(PageRouteModelProviderContext context) { ExecutePagesProvider(context, _pagesOptions.RootDirectory, s => s); ExecutePagesProvider(context, "/Plugins", RemovePagesOfPlugins); // ☚ }
而 ExecutePageProvider()
幾乎就是原來 OnProvidersExecuting()
中的內容,除了指定 root
和處理虛擬路徑:
private void ExecutePagesProvider( PageRouteModelProviderContext context, string root, Func<string, string> viewPathResolver) { // ----------------------------------------- ⇣⇣⇣⇣⇣ - foreach (var item in _project.EnumerateItems(root)) { ...... var routeModel = new PageRouteModel( relativePath: item.CombinedPath, viewEnginePath: viewPathResolver(item.FilePathWithoutExtension)); // ---------------- ⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡⇡ ------------------------------- ...... } }
最後別忘了在 Startup
的 ConfigureServices()
中去註冊
public void ConfigureServices(IServiceCollection services) { ...... services.RemoveAll<IPageRouteModelProvider>() .TryAddEnumerable( ServiceDescriptor.Singleton<IPageRouteModelProvider, RazorPluginPageRouteModelProvider>()); }
效果不錯,但要注意一點:因爲 .NET Core 項目在 Visual Studio 2017 中會自動加入項目目錄下產生的新目錄和新文件,因此 Plugins/Plugin2
中的全部 .cs
文件會被編譯在 WebPlatform.dll
中。這會形成 LoadPlugin()
加載 .dll
的時候出現類型衝突。
解決辦法是手工將 Plguins
目錄從項目中移除,同時最好在拷貝 Plugin2
視圖的時候,去掉 .cs
源代碼問題。
整個研究過程仍是很愉快的,不過 .NET Core 的文檔雖然完整,卻並不細緻,有些東西仍是要從源碼去分析。