您好,登錄后才能下訂單哦!
Web 管理系統(tǒng)可以龐大到不可想像的地方,如果想就在一個 Asp.Net MVC 項(xiàng)目中完成開發(fā),這個工程將會變得非常龐大,協(xié)作起來也會比較困難。為了解決這個問題,Asp.Net MVC 引入了 Areas 的概念,將模塊劃分到 Area 中去——然而 Area 仍然是主項(xiàng)目的一部分,多人協(xié)作的時候仍然很容易造成 .csproj
項(xiàng)目文件的沖突。
對于這類系統(tǒng),比較好的解決辦法是采用 SOA 的方式,把一個大的 Web 系統(tǒng)劃分成若干微服務(wù),通過一個含授權(quán)中心的 Web 集散框架組織起來。不過這里我要講的是另一種方法,插件化的開發(fā)方案。
完整的插件化開發(fā)會涉及到插件管理的方方面面,甚至還包括插件的熱插拔處理——當(dāng)然這些都是可以做到的——但今天我要說的是一個簡化方案,只是將業(yè)務(wù)模塊當(dāng)作插件在單獨(dú)的項(xiàng)目中開發(fā),而后在發(fā)布的時候仍然以 Area 的形式集成到主 Web 項(xiàng)目當(dāng)中。嚴(yán)格的說,這并不是插件化,而只是模塊化,但它是插件化的第一步。
第一個實(shí)驗(yàn)的目的是為了把 Area 剝離出來作為單獨(dú)的項(xiàng)目開發(fā)。所以先使用同樣版本的 .NET Framework 的 Asp.Net MVC Framework 創(chuàng)建兩個項(xiàng)目,這里我們選用了
.NET Framework 4.6
Microsoft.AspNet.Mvc 5.2.3
建立兩個 MVC 項(xiàng)目,分別名為 PluginWebApp
和 Plugin1
。
這個項(xiàng)目作為 Web 主項(xiàng)目,現(xiàn)在暫時不改它。但要檢查一下 Global.asax.cs
中,Application_Start
事件中有這么一句:
protected void Application_Start() { AreaRegistration.RegisterAllAreas(); // .... }
這是在注冊所有 Area。雖然現(xiàn)在 PluginWebApp 并沒有建 Area,但是這句話對于我們來說是必不可少的。
這是作為插件的項(xiàng)目,我們把它當(dāng)作一個 Area 來開發(fā)。所以先添加 Area。
操作:在“解決方案資源管理器”中“Plugin1”項(xiàng)目中點(diǎn)擊右鍵,選擇“添加→區(qū)域(A)”,輸入
Plugin1
為作 Area 名稱
這樣,Plugin1 項(xiàng)目中就存在一個 Areas
目錄以及其目錄 Plugin1
,再把這個項(xiàng)目中除 Areas
目錄、packages.config
和 Web.config
之外的所有其它目錄和文件刪除,之后整個項(xiàng)目看起來就像這樣:
注意項(xiàng)目中存在一個 Plugin1AreaRegistration.cs
文件,在向 Web 應(yīng)用中注冊 Area 的時候需要它。
現(xiàn)在在 Controllers
目錄下面添加控制器 TestController
,相應(yīng)的在 Views
下面添加 Test/Index.cshtml
視圖文件。內(nèi)容都不重要,只要能識別出來就行,所以在 Test/Index.cshtml
中修改 <h3>
中的內(nèi)容為
<h3>Testing Page Index</h3>
AreaRegistration.RegisterAllAreas()
會在加載的 Assembly 中查找所有 Area 定義(AreaRegistration
的子類),完成 Area 的注冊。所以我們可以干兩件事情來安裝 Plugin
把 Plugin1 項(xiàng)目的編譯結(jié)果 Plugin1.dll
拷貝到 PluginWebApp
的 bin
目錄下
在 PluginWebApp 項(xiàng)目下創(chuàng)建 Areas
目錄,下建 Plugin1
目錄,再把 Plugin1 項(xiàng)目的 ~/Areas/Plugin1/Views
目錄拷貝過來
猜測做了這些操作之后,應(yīng)該可以運(yùn)行 PluginWebApp,輸入正常的 url 路徑之后可以訪問到 Plugin1 的 Test 頁面。
運(yùn)行,并在瀏覽器中輸入 http://localhost:5760/plugin1/test
(這里的端口號是由 VS 自動分配的,請注意修改)——結(jié)果還不錯
第一個實(shí)驗(yàn)成功,實(shí)事證明猜想沒有問題。但于對開發(fā)來說,就有問題了。插件動態(tài)庫放在 PluginWebApp/bin
中,與 PluginWebApp 的編譯結(jié)果混在一起了,這在以后發(fā)布、更新的時候可能造成麻煩。而且既然是插件,似乎應(yīng)該獨(dú)立一點(diǎn),如果 Plugin1 發(fā)布的所有東西都只在 PluginWebApp/Areas/Plugin1
目錄下就好了。
基于這個設(shè)想,PluginWebApp/Areas/Plugin1
目錄應(yīng)該會是這樣一個結(jié)構(gòu):
Plugin1 |---bin `---Views
當(dāng)然,把 Plugin1.dll
拷貝到 bin
目錄中去很容易,但還得讓 Asp.Net 加載它。于是嘗試在 Application_Start
中寫了幾句代碼來加載
// 先不考慮任意插件的問題,只加載 Plugin1 作為實(shí)驗(yàn)var dll = Sever.MapPath("~/Areas/Plugin/bin/Plugin1.dll"); Assembly.LoadFile(dll);
加載是加載了,但是 http://localhost:5760/plugin1/test
打不開,失??!
上網(wǎng)查資料之后得知需要使用 BuildManager.AddReferencedAssembly()
將加載的 Assembly 添加到引用集合中,而這個事情似乎必須在 Application_Start
之前完成。
文檔里說應(yīng)該在 Application_PreStartInit
階段,不過我準(zhǔn)備使用 PreApplicationStartMethodAttribute
來完成。為此,在 PluginWebApp 項(xiàng)目的 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); } } }
再次運(yùn)行,成功!
到目前為止還是直接加載的 Plugin1 插件,實(shí)際工作中應(yīng)該去檢查 Areas
下面的子目錄,加載其 bin
目錄下的動態(tài)庫。所以還需要修改 PluginInitializer
,讓它動態(tài)搜索各插件目錄的 bin/*.dll
,并加載。
為此,不妨專門寫一個 PluginLoader
類,因?yàn)檫@個類現(xiàn)在只由 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(); } } }
動態(tài)檢索的問題解決了,但在實(shí)際開發(fā)中又存在另一個問題:運(yùn)行 Web 之后,再次構(gòu)建插件的并將插件內(nèi)容 (bin
和 View
) 拷貝到主項(xiàng)目 Areas
下面對應(yīng)的插件目錄中時,會因?yàn)樵瓉淼?dll 文件在使用而不能覆蓋。
在解決這個問題就不能讓 Web 直接加載插件目錄中的 dll。采用 Asp.Net 的 Shadow Copy 的思想,我們可以在 App_Data
目錄中創(chuàng)建一個 PluginCache
目錄,然后在加載插件 dll 之前把所有 dll 拷貝到這個目錄下來,再從這個目錄加載 dll。
再來改造一下 PluginLoader
:
創(chuàng)建目錄和清空緩存都很簡單,這里就不展示這兩個步驟的代碼了。
FindPluginDll
的代碼在前面可以找到
public sealed class PluginLoader { string PluginFolder { get; } = HostingEnvironment.MapPath("~/Areas"); string PluginCacheFolder { get; } = HostingEnvironment.MapPath("~/App_Data/PluginCache"); public void Load() { // 上述兩個目錄不存在,則創(chuàng)建,保證目錄存在 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 的時候出現(xiàn) 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 表達(dá)式中,每次刪除文件或拷貝文件都有可能出現(xiàn)異常,而出現(xiàn)這些異常的時候,不應(yīng)該中斷整個處理過程,所以需要使用 try ... catch
來處理異常。正常的處理方式應(yīng)該是記錄日志,這里偷個懶,直接忽略(生產(chǎn)環(huán)境嚴(yán)重不推薦忽略異常)。
由于這個操作在幾個地方都會用到,所以寫一個 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)));
上述內(nèi)容充其量只是一個插件化開發(fā)的簡化方案。不過這個方案基本上也把一個插件化框架的結(jié)構(gòu)介紹清楚了。而且采用這種方式開發(fā)還有一個好處:Plugin1 本身就是一個 Web 項(xiàng)目,所以如果之前不刪除那么多東西,并加以適當(dāng)?shù)恼{(diào)整,它是可以獨(dú)立運(yùn)行的,便于開發(fā)期調(diào)試。
當(dāng)然這個框架要用于工作中還需要完善不少工作,包括:
定義插件接口和抽象基類,提供初始化,注入上下文(比如應(yīng)用配置等),注冊路由等接口方法。
主項(xiàng)目或框架項(xiàng)目中定義插件管理器,管理插件的生命周期,實(shí)現(xiàn)熱插拔
加載、注冊
檢查更新、新增插件等
卸載插件 Assembly 并重新加載
使用 Plugins 代替 Areas 目錄,讓插件與 Area 區(qū)分開來,這需要
在插件管理器中實(shí)現(xiàn) AreaRegistration.RegisterAllAreas()
的一些功能
把 Plugins
目錄添加到 Razor 視圖搜索路徑中 (需要自定義 RazorViewEngine
)
設(shè)計插件間的資源共享和通信機(jī)制
插件管理的 UI 或 CLI
on Gitee.com
ASP.NET 插件化機(jī)制
ASP.NET MVC 4 插件化架構(gòu)簡單實(shí)現(xiàn)-實(shí)例篇
歡迎關(guān)注作者的開發(fā)技術(shù)微信公眾號
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。