Web 管理系統能夠龐大到不可想像的地方,若是想就在一個 Asp.Net MVC 項目中完成開發,這個工程將會變得很是龐大,協做起來也會比較困難。爲了解決這個問題,Asp.Net MVC 引入了 Areas 的概念,將模塊劃分到 Area 中去——然而 Area 仍然是主項目的一部分,多人協做的時候仍然很容易形成 .csproj
項目文件的衝突。html
對於這類系統,比較好的解決辦法是採用 SOA 的方式,把一個大的 Web 系統劃分紅若干微服務,經過一個含受權中心的 Web 集散框架組織起來。不過這裏我要講的是另外一種方法,插件化的開發方案。git
完整的插件化開發會涉及到插件管理的方方面面,甚至還包括插件的熱插拔處理——固然這些都是能夠作到的——但今天我要說的是一個簡化方案,只是將業務模塊看成插件在單獨的項目中開發,然後在發佈的時候仍然以 Area 的形式集成到主 Web 項目當中。嚴格的說,這並非插件化,而只是模塊化,但它是插件化的第一步。web
第一個實驗的目的是爲了把 Area 剝離出來做爲單獨的項目開發。因此先使用一樣版本的 .NET Framework 的 Asp.Net MVC Framework 建立兩個項目,這裏咱們選用了瀏覽器
創建兩個 MVC 項目,分別名爲 PluginWebApp
和 Plugin1
。緩存
這個項目做爲 Web 主項目,如今暫時不改它。但要檢查一下 Global.asax.cs
中,Application_Start
事件中有這麼一句:架構
protected void Application_Start() { AreaRegistration.RegisterAllAreas(); // .... }
這是在註冊全部 Area。雖然如今 PluginWebApp 並無建 Area,可是這句話對於咱們來講是必不可少的。mvc
這是做爲插件的項目,咱們把它看成一個 Area 來開發。因此先添加 Area。框架
操做:在「解決方案資源管理器」中「Plugin1」項目中點擊右鍵,選擇「添加→區域(A)」,輸入
Plugin1
爲做 Area 名稱asp.net
這樣,Plugin1 項目中就存在一個 Areas
目錄以及其目錄 Plugin1
,再把這個項目中除 Areas
目錄、packages.config
和 Web.config
以外的全部其它目錄和文件刪除,以後整個項目看起來就像這樣:ide
注意項目中存在一個 Plugin1AreaRegistration.cs
文件,在向 Web 應用中註冊 Area 的時候須要它。
如今在 Controllers
目錄下面添加控制器 TestController
,相應的在 Views
下面添加 Test/Index.cshtml
視圖文件。內容都不重要,只要能識別出來就行,因此在 Test/Index.cshtml
中修改 <h2>
中的內容爲
<h2>Testing Page Index</h2>
AreaRegistration.RegisterAllAreas()
會在加載的 Assembly 中查找全部 Area 定義(AreaRegistration
的子類),完成 Area 的註冊。因此咱們能夠幹兩件事情來安裝 Plugin
Plugin1.dll
拷貝到 PluginWebApp
的 bin
目錄下Areas
目錄,下建 Plugin1
目錄,再把 Plugin1 項目的 ~/Areas/Plugin1/Views
目錄拷貝過來猜想作了這些操做以後,應該能夠運行 PluginWebApp,輸入正常的 url 路徑以後能夠訪問到 Plugin1 的 Test 頁面。
運行,並在瀏覽器中輸入 http://localhost:5760/plugin1/test
(這裏的端口號是由 VS 自動分配的,請注意修改)——結果還不錯
第一個實驗成功,實事證實猜測沒有問題。但於對開發來講,就有問題了。插件動態庫放在 PluginWebApp/bin
中,與 PluginWebApp 的編譯結果混在一塊兒了,這在之後發佈、更新的時候可能形成麻煩。並且既然是插件,彷佛應該獨立一點,若是 Plugin1 發佈的全部東西都只在 PluginWebApp/Areas/Plugin1
目錄下就行了。
基於這個設想,PluginWebApp/Areas/Plugin1
目錄應該會是這樣一個結構:
Plugin1 |---bin `---Views
固然,把 Plugin1.dll
拷貝到 bin
目錄中去很容易,但還得讓 Asp.Net 加載它。因而嘗試在 Application_Start
中寫了幾句代碼來加載
// 先不考慮任意插件的問題,只加載 Plugin1 做爲實驗 var dll = Sever.MapPath("~/Areas/Plugin/bin/Plugin1.dll"); Assembly.LoadFile(dll);
加載是加載了,可是 http://localhost:5760/plugin1/test
打不開,失敗!
上網查資料以後得知須要使用 BuildManager.AddReferencedAssembly()
將加載的 Assembly 添加到引用集合中,而這個事情彷佛必須在 Application_Start
以前完成。
文檔裏說應該在 Application_PreStartInit
階段,不過我準備使用 PreApplicationStartMethodAttribute
來完成。爲此,在 PluginWebApp 項目的 App_Start
下添加了一個 PluginInitializer
類來幹這個事情:
using System.Web; using System.Web.Hosting; using System.Web.Compilation; [assembly: PreApplicationStartMethod(typeof(PluginWebApp.PluginInitializer), "Initialize")] namespace PluginWebApp { public static partial class PluginInitializer { public static void Initialize() { var dll = HostingEnvironment("~/Areas/Plugin1/bin/Plugin1.dll"); var assembly = Assembly.LoadFile(dll); BuildManager.AddReferencedAssembly(assembly); } } }
再次運行,成功!
到目前爲止仍是直接加載的 Plugin1 插件,實際工做中應該去檢查 Areas
下面的子目錄,加載其 bin
目錄下的動態庫。因此還須要修改 PluginInitializer
,讓它動態搜索各插件目錄的 bin/*.dll
,並加載。
爲此,不妨專門寫一個 PluginLoader
類,由於這個類如今只由 PluginInitializer
使用,因此直接寫成它的嵌套類
public static partial class PluginInitializer { public sealed class PluginLoader { public void Load() { FindPluginDll(HostingEnvironment("~/Areas")) // 並行處理不是必須的,但在插件多的時候可能會更快 .AsParallel() .ForAll(file => BuildManager.AddReferencedAssembly(Assembly.Load(file))); } // 從指定的插件根目錄 (這裏是 Areas) 搜索帶 bin 目錄的插件目錄 // 並將其中的 *.dll 找出來 private static string[] FindPluginDll(string root) { return Directory.EnumerateDirectories(root) .Select(dir => Path.Combine(dir, "bin")) // 若是沒有 bin 目錄就忽略 .Where(Directory.Exists) // 將 bin 目錄下的全部 dll 加載到集合中 .SelectMany(bin => Directory .EnumerateFiles(bin, "*.dll", SearchOption.AllDirectories)) .ToArray(); } } }
動態檢索的問題解決了,但在實際開發中又存在另外一個問題:運行 Web 以後,再次構建插件的並將插件內容 (bin
和 View
) 拷貝到主項目 Areas
下面對應的插件目錄中時,會由於原來的 dll 文件在使用而不能覆蓋。
在解決這個問題就不能讓 Web 直接加載插件目錄中的 dll。採用 Asp.Net 的 Shadow Copy 的思想,咱們能夠在 App_Data
目錄中建立一個 PluginCache
目錄,而後在加載插件 dll 以前把全部 dll 拷貝到這個目錄下來,再從這個目錄加載 dll。
再來改造一下 PluginLoader
:
建立目錄和清空緩存都很簡單,這裏就不展現這兩個步驟的代碼了。
FindPluginDll
的代碼在前面能夠找到
public sealed class PluginLoader { string PluginFolder { get; } = HostingEnvironment.MapPath("~/Areas"); string PluginCacheFolder { get; } = HostingEnvironment.MapPath("~/App_Data/PluginCache"); public void Load() { // 上述兩個目錄不存在,則建立,保證目錄存在 MakeSureFolderExists(); // 先清空緩存,避免已廢棄的插件還緩存在這裏 ClearCacheFolder(); // 從各插件目錄把 dll 拷貝到緩存目錄 CachePlugins(); // 從緩存目錄加載全部 dll LoadAssemblies(); } private void CachePlugins() { // 找到全部插件的 dll FindPluginDll(PluginFolder) // 並行處理 .AsParallel() .ForAll(file => { var target = Path.Combine(PluginCacheFolder, Path.GetFileName(file)); // 拷貝到緩存目錄 File.Copy(file, target, true); }); } private void LoadAssemblies() { // 在緩存目錄中查找全部 dll Directory.EnumerateFiles(PluginCacheFolder, "*.dll", SearchOption.AllDirectories) // 並行 .AsParallel() // 加載全部 assembly .ForAll(file => BuildManager.AddReferencedAssembly(Assembly.LoadFile(file))); } }
搞定!
主 Web 程序和多個插件之間若是存在同名的 Controller,就可能形成訪問 URL 的時候出現 Controller 尋址衝突,爲了解決這個問題,須要在註冊路徑的時候指定 Controller 的命名空間
App_Start/RouteConfig.cs
public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute( name: "Default", url: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }, namespaces: new[] { "PluginWebApp.Controllers" } // 加了這句話 ); }
Plugin1AreaRegistration.cs
public override void RegisterArea(AreaRegistrationContext context) { context .MapRoute( "Plugin1_default", "Plugin1/{controller}/{action}/{id}", new { controller = "Home", action = "Index", id = UrlParameter.Optional }, new[] { "Plugin1.Areas.Plugin1.Controllers" }); // 加了這一句 }
在做爲 ForAll
的 Lambda 表達式中,每次刪除文件或拷貝文件都有可能出現異常,而出現這些異常的時候,不該該中斷整個處理過程,因此須要使用 try ... catch
來處理異常。正常的處理方式應該是記錄日誌,這裏偷個懶,直接忽略(生產環境嚴重不推薦忽略異常)。
因爲這個操做在幾個地方都會用到,因此寫一個 IgnoreError
來封裝 Lambda:
private static Action<T> IgnoreError<T>(Action<T> action) { return arg => { try { action(arg); } catch { // ignore exceptions, // should log the error in production environment } }; }
而後在 ForAll
中這樣使用:
.ForAll(IgnoreError<string>(file => DealWithFile(file)));
上述內容充其量只是一個插件化開發的簡化方案。不過這個方案基本上也把一個插件化框架的結構介紹清楚了。並且採用這種方式開發還有一個好處:Plugin1 自己就是一個 Web 項目,因此若是以前不刪除那麼多東西,並加以適當的調整,它是能夠獨立運行的,便於開發期調試。
固然這個框架要用於工做中還須要完善很多工做,包括:
主項目或框架項目中定義插件管理器,管理插件的生命週期,實現熱插拔
使用 Plugins 代替 Areas 目錄,讓插件與 Area 區分開來,這須要
AreaRegistration.RegisterAllAreas()
的一些功能Plugins
目錄添加到 Razor 視圖搜索路徑中 (須要自定義 RazorViewEngine
)