1. 程式人生 > >Asp.Net MVC 插件化開發簡化方案

Asp.Net MVC 插件化開發簡化方案

asp.net 插件化

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

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

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

第 1 個實驗

第一個實驗的目的是為了把 Area 剝離出來作為單獨的項目開發。所以先使用同樣版本的 .NET Framework 的 Asp.Net MVC Framework 創建兩個項目,這裏我們選用了

  • .NET Framework 4.6

  • Microsoft.AspNet.Mvc 5.2.3

建立兩個 MVC 項目,分別名為 PluginWebAppPlugin1

技術分享

PluginWebApp 項目

這個項目作為 Web 主項目,現在暫時不改它。但要檢查一下 Global.asax.cs 中,Application_Start 事件中有這麽一句:

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

這是在註冊所有 Area。雖然現在 PluginWebApp 並沒有建 Area,但是這句話對於我們來說是必不可少的。

Plugin1 項目

這是作為插件的項目,我們把它當作一個 Area 來開發。所以先添加 Area。

操作:在“解決方案資源管理器”中“Plugin1”項目中點擊右鍵,選擇“添加→區域(A)”,輸入 Plugin1

為作 Area 名稱

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

技術分享

註意項目中存在一個 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 自動分配的,請註意修改)——結果還不錯

技術分享

解耦

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

源代碼

  • on Gitee.com

參考

  • ASP.NET 插件化機制

  • ASP.NET MVC 4 插件化架構簡單實現-實例篇



歡迎關註作者的開發技術微信公眾號



技術分享


本文出自 “邊城客棧 學海無涯” 博客,請務必保留此出處http://jamesfancy.blog.51cto.com/2516291/1962363

Asp.Net MVC 插件化開發簡化方案