ASP.NET MVC 插件化開發簡化方案

Web 管理系統能夠龐大到不可想像的地方,若是想就在一個 Asp.Net MVC 項目中完成開發,這個工程將會變得很是龐大,協做起來也會比較困難。爲了解決這個問題,Asp.Net MVC 引入了 Areas 的概念,將模塊劃分到 Area 中去——然而 Area 仍然是主項目的一部分,多人協做的時候仍然很容易形成 .csproj 項目文件的衝突。html

對於這類系統,比較好的解決辦法是採用 SOA 的方式,把一個大的 Web 系統劃分紅若干微服務,經過一個含受權中心的 Web 集散框架組織起來。不過這裏我要講的是另外一種方法,插件化的開發方案。git

完整的插件化開發會涉及到插件管理的方方面面,甚至還包括插件的熱插拔處理——固然這些都是能夠作到的——但今天我要說的是一個簡化方案,只是將業務模塊看成插件在單獨的項目中開發,然後在發佈的時候仍然以 Area 的形式集成到主 Web 項目當中。嚴格的說,這並非插件化,而只是模塊化,但它是插件化的第一步。web

第 1 個實驗

第一個實驗的目的是爲了把 Area 剝離出來做爲單獨的項目開發。因此先使用一樣版本的 .NET Framework 的 Asp.Net MVC Framework 建立兩個項目,這裏咱們選用了瀏覽器

  • .NET Framework 4.6
  • Microsoft.AspNet.Mvc 5.2.3

創建兩個 MVC 項目,分別名爲 PluginWebAppPlugin1緩存

clipboard.png

PluginWebApp 項目

這個項目做爲 Web 主項目,如今暫時不改它。但要檢查一下 Global.asax.cs 中,Application_Start 事件中有這麼一句:架構

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
    // ....
}

這是在註冊全部 Area。雖然如今 PluginWebApp 並無建 Area,可是這句話對於咱們來講是必不可少的。mvc

Plugin1 項目

這是做爲插件的項目,咱們把它看成一個 Area 來開發。因此先添加 Area。框架

操做:在「解決方案資源管理器」中「Plugin1」項目中點擊右鍵,選擇「添加→區域(A)」,輸入 Plugin1 爲做 Area 名稱asp.net

這樣,Plugin1 項目中就存在一個 Areas 目錄以及其目錄 Plugin1,再把這個項目中除 Areas 目錄、packages.configWeb.config 以外的全部其它目錄和文件刪除,以後整個項目看起來就像這樣:ide

clipboard.png

注意項目中存在一個 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 項目的編譯結果 Plugin1.dll 拷貝到 PluginWebAppbin 目錄下
  • 在 PluginWebApp 項目下建立 Areas 目錄,下建 Plugin1 目錄,再把 Plugin1 項目的 ~/Areas/Plugin1/Views 目錄拷貝過來

猜想作了這些操做以後,應該能夠運行 PluginWebApp,輸入正常的 url 路徑以後能夠訪問到 Plugin1 的 Test 頁面。

運行,並在瀏覽器中輸入 http://localhost:5760/plugin1/test (這裏的端口號是由 VS 自動分配的,請注意修改)——結果還不錯

clipboard.png

解耦

第一個實驗成功,實事證實猜測沒有問題。但於對開發來講,就有問題了。插件動態庫放在 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 和 PreApplicationStartMethodAttribute

上網查資料以後得知須要使用 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 以後,再次構建插件的並將插件內容 (binView) 拷貝到主項目 Areas 下面對應的插件目錄中時,會由於原來的 dll 文件在使用而不能覆蓋。

解決不能在 Web 運行狀態下更新插件的問題

在解決這個問題就不能讓 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)));
    }
}

搞定!

細節處理

解決 Controller 尋址衝突

主 Web 程序和多個插件之間若是存在同名的 Controller,就可能形成訪問 URL 的時候出現 Controller 尋址衝突,爲了解決這個問題,須要在註冊路徑的時候指定 Controller 的命名空間

主項目 PluginWebApp 的 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" }); // 加了這一句
}

處理刪除或拷貝 dll 文件時可能出現的異常

在做爲 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 項目,因此若是以前不刪除那麼多東西,並加以適當的調整,它是能夠獨立運行的,便於開發期調試。

固然這個框架要用於工做中還須要完善很多工做,包括:

  • 定義插件接口和抽象基類,提供初始化,注入上下文(好比應用配置等),註冊路由等接口方法。
  • 主項目或框架項目中定義插件管理器,管理插件的生命週期,實現熱插拔

    • 加載、註冊
    • 檢查更新、新增插件等
    • 卸載插件 Assembly 並從新加載
  • 使用 Plugins 代替 Areas 目錄,讓插件與 Area 區分開來,這須要

    • 在插件管理器中實現 AreaRegistration.RegisterAllAreas() 的一些功能
    • Plugins 目錄添加到 Razor 視圖搜索路徑中 (須要自定義 RazorViewEngine)
  • 設計插件間的資源共享和通訊機制
  • 插件管理的 UI 或 CLI

源代碼

參考

相關文章
相關標籤/搜索