從零開始實現ASP.NET Core MVC的插件式開發(六) - 如何加載插件引用

標題:從零開始實現ASP.NET Core MVC的插件式開發(六) - 如何加載插件引用。
做者:Lamond Lu
地址:http://www.javashuo.com/article/p-uwscqkie-y.html
源代碼:https://github.com/lamondlu/DynamicPluginshtml

前景回顧

簡介

在前一篇中,我給你們演示瞭如何使用.NET Core 3.0中新引入的AssemblyLoadContext來實現運行時升級和刪除插件。完成此篇以後,我獲得了不少園友的反饋,很高興有這麼多人可以參與進來,我會根據你們的反饋,來完善這個項目。本篇呢,我將主要解決加載插件引用的問題,這個也是反饋中被問的最多的問題。git

問題用例

在以前作的插件中,咱們作的都是很是很是簡單的功能,沒有引入任何的第三方庫。可是正常狀況下,咱們所建立的插件或多或少的都會引用一些第三方庫,那麼下面咱們來嘗試一下,使用咱們先前的項目,加載一個第三方程序集, 看看會的獲得什麼結果。github

這裏爲了模擬,我建立了一個新的類庫項目DemoReferenceLibrary, 並在以前的DemoPlugin1項目中引用DemoReferenceLibrary項目。sql

DemoReferenceLibrary中,我新建了一個類Demo.cs文件, 其代碼以下:json

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

這裏就是簡單的經過SayHello方法,返回了一個字符串。c#

而後在DemoPlugin1項目中,咱們修改以前建立的Plugin1Controller,從Demo類中經過SayHello方法獲得須要在頁面中顯示的字符串。緩存

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

最後咱們打包一下插件,從新將其安裝到系統中,訪問插件路由以後,就會獲得如下錯誤。安全

這裏就是大部分同窗遇到的問題,沒法加載程序集DemoReferenceLibrarymvc

如何加載插件引用?

這個問題的緣由很簡單,就是當經過AssemblyLoadContext加載程序集的時候,咱們只加載了插件程序集,沒有加載它引用的程序集。ide

例如,咱們以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徹底沒有影響。

總結

本篇我爲你們介紹瞭如何解決插件引用程序集的加載問題,這裏咱們講解了兩種方式,原始方式和緩存方式。這兩種方式的最終效果雖然相同,可是緩存方式的效率明顯更高。後續我會根據反饋,繼續添加新內容,你們敬請期待。

相關文章
相關標籤/搜索