1. 程式人生 > >從零開始實現ASP.NET Core MVC的外掛式開發(六) - 如何載入外掛引用

從零開始實現ASP.NET Core MVC的外掛式開發(六) - 如何載入外掛引用

標題:從零開始實現ASP.NET Core MVC的外掛式開發(六) - 如何載入外掛引用。
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/11717254.html
原始碼:https://github.com/lamondlu/DynamicPlugins

前景回顧

  • 從零開始實現ASP.NET Core MVC的外掛式開發(一) - 使用Application Part動態載入控制器和檢視
  • 從零開始實現ASP.NET Core MVC的外掛式開發(二) - 如何建立專案模板
  • 從零開始實現ASP.NET Core MVC的外掛式開發(三) - 如何在執行時啟用元件
  • 從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝
  • 從零開始實現ASP.NET Core MVC的外掛式開發(五) - 使用AssemblyLoadContext實現外掛的升級和刪除

簡介

在前一篇中,我給大家演示瞭如何使用.NET Core 3.0中新引入的AssemblyLoadContext來實現執行時升級和刪除外掛。完成此篇之後,我得到了很多園友的反饋,很高興有這麼多人能夠參與進來,我會根據大家的反饋,來完善這個專案。本篇呢,我將主要解決載入外掛引用的問題,這個也是反饋中被問的最多的問題。

問題用例

在之前做的外掛中,我們做的都是非常非常簡單的功能,沒有引入任何的第三方庫。但是正常情況下,我們所建立的外掛或多或少的都會引用一些第三方庫,那麼下面我們來嘗試一下,使用我們先前的專案,載入一個使用第三方程式集, 看看會的得到什麼結果。

這裡為了模擬,我建立了一個新的類庫專案DemoReferenceLibrary, 並在之前的DemoPlugin1專案中引用DemoReferenceLibrary專案。

DemoReferenceLibrary中,我新建了一個類Demo.cs檔案, 其程式碼如下:

    public class Demo
    {
        public string SayHello()
        {
            return "Hello World. Version 1";
        }
    }

這裡就是簡單的通過SayHello方法,返回了一個字串。

然後在DemoPlugin1專案中,我們修改之前建立的Plugin1Controller,從Demo類中通過SayHello方法得到需要在頁面中顯示的字串。

    [Area("DemoPlugin1")]
    public class Plugin1Controller : Controller
    {
        public IActionResult HelloWorld()
        {
            var content = new Demo().SayHello();
            ViewBag.Content = content;
            return View();
        }
    }

最後我們打包一下外掛,重新將其安裝到系統中,訪問外掛路由之後,就會得到以下錯誤。

這裡就是大部分同學遇到的問題,無法載入程式集DemoReferenceLibrary

如何載入外掛引用?

這個問題的原因很簡單,就是當通過AssemblyLoadContext載入程式集的時候,我們只加載了外掛程式集,沒有載入它引用的程式集。

例如,我們以DemoPlugin1的為例,在這個外掛的目錄如下

在這個目錄中,除了我們熟知的DemoPlugin1.dll,DemoPlugin1.Views.dll之外,還有一個DemoReferenceLibrary.dll檔案。 這個檔案我們並沒有在外掛啟用時載入到當前的AssemblyLoadContext中,所以在訪問外掛路由時,系統找不到這個元件的dll檔案。

為什麼Mystique.Core.dllSystem.Data.SqlClient.dllNewtonsoft.Json.dll這些DLL不會出現問題呢?

在.NET Core中有2種LoadContext。 一種是我們之前介紹的AssemblyLoadContext, 它是一種自定義LoadContext。 另外一種就是系統預設的DefaultLoadContext。當一個.NET Core應用啟動的時候,都會建立並引用一個DefaultLoadContext

如果沒有指定LoadContext, 系統預設會將程式集都載入到DefaultLoadContext中。這裡我們可以檢視一下我們的主站點專案,這個專案我們也引用了Mystique.Core.dllSystem.Data.SqlClient.dllNewtonsoft.Json.dll

在.NET Core的設計文件中,對於程式集載入有這樣一段描述

If the assembly was already present in A1's context, either because we had successfully loaded it earlier, or because we failed to load it for some reason, we return the corresponding status (and assembly reference for the success case).

However, if C1 was not found in A1's context, the Load method override in A1's context is invoked.

  • For Custom LoadContext, this override is an opportunity to load an assembly before the fallback (see below) to Default LoadContext is attempted to resolve the load.
  • For Default LoadContext, this override always returns null since Default Context cannot override itself.

這裡簡單來說,意思就是當在一個自定義LoadContext中載入程式集的時候,如果找不到這個程式集,程式會自動去預設LoadContext中查詢,如果預設LoadContext中都找不到,就會返回null

由此,我們之前的疑問就解決了,這裡正是因為主站點已經載入了所需的程式集,雖然在外掛的AssemblyLoadContext中找不到這個程式集,程式依然可以通過預設LoadContext來載入程式集。

那麼是不是真的就沒有問題了呢?

其實我不是很推薦用以上的方式來載入第三方程式集。主要原因有兩點

  • 不同外掛可以引用不同版本的第三方程式集,可能不同版本的第三方程式集實現不同。 而預設LoadContext只能載入一個版本,導致總有一個外掛引用該程式集的功能失效。
  • 預設LoadContext中可能載入的第三方程式集與其他外掛都不同,導致其他外掛功能引用該程式集的功能失效。

所以這裡最正確的方式,還是放棄使用預設LoadContext載入程式集,保證每個外掛的AssemblyLoadContext都完全載入所需的程式集。

那麼如何載入這些第三方程式集呢?我們下面就來介紹兩種方式

  • 原始方式
  • 使用外掛快取

原始方式

原始方式比較暴力,我們可以選擇載入外掛程式集的同時,載入程式集所在目錄中所有的dll檔案。

這裡首先我們建立了一個外掛引用庫載入器介面IReferenceLoader

    public interface IRefenerceLoader
    {
        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, 
            string folderName, 
            string excludeFile);
    }

然後我們建立一個預設的外掛引用庫載入器DefaultReferenceLoader,其程式碼如下:

    public class DefaultReferenceLoader : IRefenerceLoader
    {
        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, 
            string folderName, 
            string excludeFile)
        {
            var streams = new List<Stream>();
            var di = new DirectoryInfo(folderName);
            var allReferences = di.GetFiles("*.dll").Where(p => p.Name != excludeFile);

            foreach (var file in allReferences)
            {
                using (var sr = new StreamReader(file.OpenRead()))
                {
                    context.LoadFromStream(sr.BaseStream);
                }
            }
        }
    }

程式碼解釋

  • 這裡我是為了排除當前已經載入外掛程式集,所以添加了一個excludeFile引數。
  • folderName即當前外掛的所在目錄,這裡我們通過DirectoryInfo類的GetFiles方法,獲取了當前指定folderName目錄中的所有dll檔案。
  • 這裡我依然通過檔案流的方式載入了外掛所需的第三方程式集。

完成以上程式碼之後,我們還需要修改啟用外掛的兩部分程式碼

  • [MystiqueStartup.cs] - 程式啟動時,注入IReferenceLoader服務,啟用外掛
  • [MvcModuleSetup.cs] - 在外掛管理頁面,觸發啟用外掛操作

MystiqueStartup.cs

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
    {

        ...
            
        services.AddSingleton<IReferenceLoader, DefaultReferenceLoader>();

        var mvcBuilder = services.AddMvc();

        var provider = services.BuildServiceProvider();
        using (var scope = provider.CreateScope())
        {
            ...

            foreach (var plugin in allEnabledPlugins)
            {
                var context = new CollectibleAssemblyLoadContext();
                var moduleName = plugin.Name;
                var filePath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}\\{moduleName}.dll";
                var referenceFolderPath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}";

                _presets.Add(filePath);
                using (var fs = new FileStream(filePath, FileMode.Open))
                {
                    var assembly = context.LoadFromStream(fs);
                    loader.LoadStreamsIntoContext(context, 
                          referenceFolderPath,
                          $"{moduleName}.dll");

                   ...
                }
            }
        }

        ...
    }

MvcModuleSetup.cs

    public void EnableModule(string moduleName)
    {
        if (!PluginsLoadContexts.Any(moduleName))
        {
            var context = new CollectibleAssemblyLoadContext();

            var filePath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}\\{moduleName}.dll";
            var referenceFolderPath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}";
            using (var fs = new FileStream(filePath, FileMode.Open))
            {
                var assembly = context.LoadFromStream(fs);
                _referenceLoader.LoadStreamsIntoContext(context, 
                      referenceFolderPath, 
                      $"{moduleName}.dll");

                ...
            }
        }
        else
        {
            var context = PluginsLoadContexts.GetContext(moduleName);
            var controllerAssemblyPart = new MystiqueAssemblyPart(context.Assemblies.First());
            _partManager.ApplicationParts.Add(controllerAssemblyPart);
        }

        ResetControllActions();
    }

現在我們重新執行之前的專案,並訪問外掛1的路由,你會發現頁面正常顯示了,並且頁面內容也是從DemoReferenceLibrary程式集中加載出來了。

使用外掛快取

原始方式雖然可以幫助我們成功載入外掛引用程式集,但是它並不效率,如果外掛1和外掛2引用了相同的程式集,當外掛1的AssemblyLoadContext載入所有的引用程式集之後,外掛2會將外掛1所幹的事情重複一遍。這並不是我們想要的,我們希望如果多個外掛同時使用了相同的程式集,就不需要重複讀取dll檔案了。

如何避免重複讀取dll檔案呢?這裡我們可以使用一個靜態字典來快取檔案流資訊,從而避免重複讀取dll檔案。

如果大家覺著在ASP.NET Core MVC中使用靜態字典來快取檔案流資訊不安全,可以改用其他快取方式,這裡只是為了簡單演示。

這裡我們首先建立一個引用程式集快取容器介面IReferenceContainer, 其程式碼如下:

    public interface IReferenceContainer
    {
        List<CachedReferenceItemKey> GetAll();

        bool Exist(string name, string version);

        void SaveStream(string name, string version, Stream stream);

        Stream GetStream(string name, string version);
    }

程式碼解釋

  • GetAll方法會在後續使用,用來獲取系統中載入的所有引用程式集
  • Exist方法判斷了指定版本程式集的檔案流是否存在
  • SaveStream是將指定版本的程式集檔案流儲存到靜態字典中
  • GetStream是從靜態字典中拉取指定版本程式集的檔案流

然後我們可以建立一個引用程式集快取容器的預設實現DefaultReferenceContainer類,其程式碼如下:

    public class DefaultReferenceContainer : IReferenceContainer
    {
        private static Dictionary<CachedReferenceItemKey, Stream> _cachedReferences = new Dictionary<CachedReferenceItemKey, Stream>();

        public List<CachedReferenceItemKey> GetAll()
        {
            return _cachedReferences.Keys.ToList();
        }

        public bool Exist(string name, string version)
        {
            return _cachedReferences.Keys.Any(p => p.ReferenceName == name
                && p.Version == version);
        }

        public void SaveStream(string name, string version, Stream stream)
        {
            if (Exist(name, version))
            {
                return;
            }

            _cachedReferences.Add(new CachedReferenceItemKey { ReferenceName = name, Version = version }, stream);
        }

        public Stream GetStream(string name, string version)
        {
            var key = _cachedReferences.Keys.FirstOrDefault(p => p.ReferenceName == name
                && p.Version == version);

            if (key != null)
            {
                _cachedReferences[key].Position = 0;
                return _cachedReferences[key];
            }

            return null;
        }
    }

這個類比較簡單,我就不做太多解釋了。

完成了引用快取容器之後,我修改了之前建立的IReferenceLoader介面,及其預設實現DefaultReferenceLoader

    public interface IReferenceLoader
    {
        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, string moduleFolder, Assembly assembly);
    }
    public class DefaultReferenceLoader : IReferenceLoader
    {
        private IReferenceContainer _referenceContainer = null;
        private readonly ILogger<DefaultReferenceLoader> _logger = null;

        public DefaultReferenceLoader(IReferenceContainer referenceContainer, ILogger<DefaultReferenceLoader> logger)
        {
            _referenceContainer = referenceContainer;
            _logger = logger;
        }

        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, string moduleFolder, Assembly assembly)
        {
            var references = assembly.GetReferencedAssemblies();

            foreach (var item in references)
            {
                var name = item.Name;

                var version = item.Version.ToString();

                var stream = _referenceContainer.GetStream(name, version);

                if (stream != null)
                {
                    _logger.LogDebug($"Found the cached reference '{name}' v.{version}");
                    context.LoadFromStream(stream);
                }
                else
                {

                    if (IsSharedFreamwork(name))
                    {
                        continue;
                    }

                    var dllName = $"{name}.dll";
                    var filePath = $"{moduleFolder}\\{dllName}";

                    if (!File.Exists(filePath))
                    {
                        _logger.LogWarning($"The package '{dllName}' is missing.");
                        continue;
                    }

                    using (var fs = new FileStream(filePath, FileMode.Open))
                    {
                        var referenceAssembly = context.LoadFromStream(fs);

                        var memoryStream = new MemoryStream();

                        fs.Position = 0;
                        fs.CopyTo(memoryStream);
                        fs.Position = 0;
                        memoryStream.Position = 0;
                        _referenceContainer.SaveStream(name, version, memoryStream);

                        LoadStreamsIntoContext(context, moduleFolder, referenceAssembly);
                    }
                }
            }
        }

        private bool IsSharedFreamwork(string name)
        {
            return SharedFrameworkConst.SharedFrameworkDLLs.Contains($"{name}.dll");
        }
    }

程式碼解釋:

  • 這裡LoadStreamsIntoContext方法的assembly引數,即當前外掛程式集。
  • 這裡我通過GetReferencedAssemblies方法,獲取了外掛程式集引用的所有程式集。
  • 如果引用程式集在引用容器中不存在,我們就是用檔案流載入它,並將其儲存到引用容器中, 如果引用程式集已存在於引用容器,就直接載入到當前外掛的AssemblyLoadContext中。這裡為了檢驗效果,如果程式集來自快取,我使用日誌元件輸出了一條日誌。
  • 由於外掛引用的程式集,有可能是來自Shared Framework, 這種程式集是不需要載入的,所以這裡我選擇跳過這類程式集的載入。(這裡我還沒有考慮Self-Contained釋出的情況,後續這裡可能會更改)

最後我們還是需要修改MystiqueStartup.csMvcModuleSetup.cs中啟用外掛的程式碼。

MystiqueStartup.cs

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
    {

        ...
        services.AddSingleton<IReferenceContainer, DefaultReferenceContainer>();
        services.AddSingleton<IReferenceLoader, DefaultReferenceLoader>();
        ...

        var mvcBuilder = services.AddMvc();

        var provider = services.BuildServiceProvider();
        using (var scope = provider.CreateScope())
        {
            ...

            foreach (var plugin in allEnabledPlugins)
            {
                ...
               
                using (var fs = new FileStream(filePath, FileMode.Open))
                {
                    var assembly = context.LoadFromStream(fs);
                    loader.LoadStreamsIntoContext(context, referenceFolderPath, assembly);

                    ...
                }
            }
        }

        ...
    }

MvcModuleSetup.cs

    public void EnableModule(string moduleName)
    {
        if (!PluginsLoadContexts.Any(moduleName))
        {
            ...
            using (var fs = new FileStream(filePath, FileMode.Open))
            {
                var assembly = context.LoadFromStream(fs);
                _referenceLoader.LoadStreamsIntoContext(context, referenceFolderPath, assembly);
               ...
            }
        }
        else
        {
            ...
        }

        ResetControllActions();
    }

完成程式碼之後,為了檢驗效果,我建立了另外一個外掛DemoPlugin2, 這個專案的程式碼和DemoPlugin1基本一樣。程式啟動時,你會發現DemoPlugin2所使用的引用程式集都是從快取中載入的,而且DemoPlugin2的路由也能正常訪問。

新增頁面來顯示載入的第三方程式集

這裡為了顯示一下系統中載入了哪些程式集,我添加了一個新頁面Assembilies, 這個頁面就是呼叫了IReferenceContainer介面中定義的GetAll方法,顯示了靜態字典中,所有載入的程式集。

效果如下:

幾個測試場景

最後,在編寫完成以上程式碼功能之後,我們使用以下幾種場景來測試一下,看一看AssemblyLoadContext為我們提供的強大功能。

場景1

2個外掛,一個引用DemoReferenceLibrary的1.0.0.0版本,另外一個引用DemoReferenceLibrary的1.0.1.0版本。其中1.0.0.0版本,SayHello方法返回的字串是"Hello World. Version 1", 1.0.1.0版本, SayHello方法返回的字串是“Hello World. Version 2”。

啟動專案,安裝外掛1和外掛2,分別執行外掛1和外掛2的路由,你會得到不同的結果。這說明AssemblyLoadContext為我們做了很好的隔離,外掛1和外掛2雖然引用了相同外掛的不同版本,但是互相之間完全沒有影響。

場景2

當2個外掛使用了相同的第三方庫,並載入完成之後,禁用外掛1。雖然他們引用的程式集相同,但是你會發現外掛2還是能夠正常訪問,這說明外掛1的AssemblyLoadContext的釋放,對外掛2的AssemblyLoadContext完全沒有影響。

總結

本篇我為大家介紹瞭如何解決外掛引用程式集的載入問題,這裡我們講解了兩種方式,原始方式和快取方式。這兩種方式的最終效果雖然相同,但是快取方式的效率明顯更高。後續我會根據反饋,繼續新增新內容,大家敬請期待