標(biāo)題:從零開(kāi)始實(shí)現(xiàn)ASP.NET Core MVC的插件式開(kāi)發(fā)(六) - 如何加載插件引用。
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/11717254.html
源代碼:https://github.com/lamondlu/DynamicPlugins

前景回顧
- 從零開(kāi)始實(shí)現(xiàn)ASP.NET Core MVC的插件式開(kāi)發(fā)(一) - 使用Application Part動(dòng)態(tài)加載控制器和視圖
- 從零開(kāi)始實(shí)現(xiàn)ASP.NET Core MVC的插件式開(kāi)發(fā)(二) - 如何創(chuàng)建項(xiàng)目模板
- 從零開(kāi)始實(shí)現(xiàn)ASP.NET Core MVC的插件式開(kāi)發(fā)(三) - 如何在運(yùn)行時(shí)啟用組件
- 從零開(kāi)始實(shí)現(xiàn)ASP.NET Core MVC的插件式開(kāi)發(fā)(四) - 插件安裝
- 從零開(kāi)始實(shí)現(xiàn)ASP.NET Core MVC的插件式開(kāi)發(fā)(五) - 使用AssemblyLoadContext實(shí)現(xiàn)插件的升級(jí)和刪除
簡(jiǎn)介
在前一篇中,我給大家演示了如何使用.NET Core 3.0中新引入的AssemblyLoadContext來(lái)實(shí)現(xiàn)運(yùn)行時(shí)升級(jí)和刪除插件。完成此篇之后,我得到了很多園友的反饋,很高興有這么多人能夠參與進(jìn)來(lái),我會(huì)根據(jù)大家的反饋,來(lái)完善這個(gè)項(xiàng)目。本篇呢,我將主要解決加載插件引用的問(wèn)題,這個(gè)也是反饋中被問(wèn)的最多的問(wèn)題。
問(wèn)題用例
在之前做的插件中,我們做的都是非常非常簡(jiǎn)單的功能,沒(méi)有引入任何的第三方庫(kù)。但是正常情況下,我們所創(chuàng)建的插件或多或少的都會(huì)引用一些第三方庫(kù),那么下面我們來(lái)嘗試一下,使用我們先前的項(xiàng)目,加載一個(gè)使用第三方程序集, 看看會(huì)的得到什么結(jié)果。
這里為了模擬,我創(chuàng)建了一個(gè)新的類庫(kù)項(xiàng)目DemoReferenceLibrary, 并在之前的DemoPlugin1項(xiàng)目中引用DemoReferenceLibrary項(xiàng)目。
在DemoReferenceLibrary中,我新建了一個(gè)類Demo.cs文件, 其代碼如下:
public class Demo
{
public string SayHello()
{
return "Hello World. Version 1";
}
}
這里就是簡(jiǎn)單的通過(guò)SayHello方法,返回了一個(gè)字符串。
然后在DemoPlugin1項(xiàng)目中,我們修改之前創(chuàng)建的Plugin1Controller,從Demo類中通過(guò)SayHello方法得到需要在頁(yè)面中顯示的字符串。
[Area("DemoPlugin1")]
public class Plugin1Controller : Controller
{
public IActionResult HelloWorld()
{
var content = new Demo().SayHello();
ViewBag.Content = content;
return View();
}
}
最后我們打包一下插件,重新將其安裝到系統(tǒng)中,訪問(wèn)插件路由之后,就會(huì)得到以下錯(cuò)誤。

這里就是大部分同學(xué)遇到的問(wèn)題,無(wú)法加載程序集DemoReferenceLibrary。
如何加載插件引用?
這個(gè)問(wèn)題的原因很簡(jiǎn)單,就是當(dāng)通過(guò)AssemblyLoadContext加載程序集的時(shí)候,我們只加載了插件程序集,沒(méi)有加載它引用的程序集。
例如,我們以DemoPlugin1的為例,在這個(gè)插件的目錄如下

在這個(gè)目錄中,除了我們熟知的DemoPlugin1.dll,DemoPlugin1.Views.dll之外,還有一個(gè)DemoReferenceLibrary.dll文件。 這個(gè)文件我們并沒(méi)有在插件啟用時(shí)加載到當(dāng)前的AssemblyLoadContext中,所以在訪問(wèn)插件路由時(shí),系統(tǒng)找不到這個(gè)組件的dll文件。
為什么Mystique.Core.dll、System.Data.SqlClient.dll、Newtonsoft.Json.dll這些DLL不會(huì)出現(xiàn)問(wèn)題呢?
在.NET Core中有2種LoadContext。 一種是我們之前介紹的AssemblyLoadContext, 它是一種自定義LoadContext。 另外一種就是系統(tǒng)默認(rèn)的DefaultLoadContext。當(dāng)一個(gè).NET Core應(yīng)用啟動(dòng)的時(shí)候,都會(huì)創(chuàng)建并引用一個(gè)DefaultLoadContext。
如果沒(méi)有指定LoadContext, 系統(tǒng)默認(rèn)會(huì)將程序集都加載到DefaultLoadContext中。這里我們可以查看一下我們的主站點(diǎn)項(xiàng)目,這個(gè)項(xiàng)目我們也引用了Mystique.Core.dll、System.Data.SqlClient.dll、Newtonsoft.Json.dll。

在.NET Core的設(shè)計(jì)文檔中,對(duì)于程序集加載有這樣一段描述
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.
這里簡(jiǎn)單來(lái)說(shuō),意思就是當(dāng)在一個(gè)自定義LoadContext中加載程序集的時(shí)候,如果找不到這個(gè)程序集,程序會(huì)自動(dòng)去默認(rèn)LoadContext中查找,如果默認(rèn)LoadContext中都找不到,就會(huì)返回null。
由此,我們之前的疑問(wèn)就解決了,這里正是因?yàn)橹髡军c(diǎn)已經(jīng)加載了所需的程序集,雖然在插件的AssemblyLoadContext中找不到這個(gè)程序集,程序依然可以通過(guò)默認(rèn)LoadContext來(lái)加載程序集。
那么是不是真的就沒(méi)有問(wèn)題了呢?
其實(shí)我不是很推薦用以上的方式來(lái)加載第三方程序集。主要原因有兩點(diǎn)
- 不同插件可以引用不同版本的第三方程序集,可能不同版本的第三方程序集實(shí)現(xiàn)不同。 而默認(rèn)
LoadContext只能加載一個(gè)版本,導(dǎo)致總有一個(gè)插件引用該程序集的功能失效。 - 默認(rèn)
LoadContext中可能加載的第三方程序集與其他插件都不同,導(dǎo)致其他插件功能引用該程序集的功能失效。
所以這里最正確的方式,還是放棄使用默認(rèn)LoadContext加載程序集,保證每個(gè)插件的AssemblyLoadContext都完全加載所需的程序集。
那么如何加載這些第三方程序集呢?我們下面就來(lái)介紹兩種方式
- 原始方式
- 使用插件緩存
原始方式
原始方式比較暴力,我們可以選擇加載插件程序集的同時(shí),加載程序集所在目錄中所有的dll文件。
這里首先我們創(chuàng)建了一個(gè)插件引用庫(kù)加載器接口IReferenceLoader。
public interface IRefenerceLoader
{
public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context,
string folderName,
string excludeFile);
}
然后我們創(chuàng)建一個(gè)默認(rèn)的插件引用庫(kù)加載器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);
}
}
}
}
代碼解釋
- 這里我是為了排除當(dāng)前已經(jīng)加載插件程序集,所以添加了一個(gè)
excludeFile參數(shù)。 -
folderName即當(dāng)前插件的所在目錄,這里我們通過(guò)DirectoryInfo類的GetFiles方法,獲取了當(dāng)前指定folderName目錄中的所有dll文件。 - 這里我依然通過(guò)文件流的方式加載了插件所需的第三方程序集。
完成以上代碼之后,我們還需要修改啟用插件的兩部分代碼
- [MystiqueStartup.cs] - 程序啟動(dòng)時(shí),注入
IReferenceLoader服務(wù),啟用插件 - [MvcModuleSetup.cs] - 在插件管理頁(yè)面,觸發(fā)啟用插件操作
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();
}
現(xiàn)在我們重新運(yùn)行之前的項(xiàng)目,并訪問(wèn)插件1的路由,你會(huì)發(fā)現(xiàn)頁(yè)面正常顯示了,并且頁(yè)面內(nèi)容也是從DemoReferenceLibrary程序集中加載出來(lái)了。

使用插件緩存
原始方式雖然可以幫助我們成功加載插件引用程序集,但是它并不效率,如果插件1和插件2引用了相同的程序集,當(dāng)插件1的AssemblyLoadContext加載所有的引用程序集之后,插件2會(huì)將插件1所干的事情重復(fù)一遍。這并不是我們想要的,我們希望如果多個(gè)插件同時(shí)使用了相同的程序集,就不需要重復(fù)讀取dll文件了。
如何避免重復(fù)讀取dll文件呢?這里我們可以使用一個(gè)靜態(tài)字典來(lái)緩存文件流信息,從而避免重復(fù)讀取dll文件。
如果大家覺(jué)著在ASP.NET Core MVC中使用靜態(tài)字典來(lái)緩存文件流信息不安全,可以改用其他緩存方式,這里只是為了簡(jiǎn)單演示。
這里我們首先創(chuàng)建一個(gè)引用程序集緩存容器接口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方法會(huì)在后續(xù)使用,用來(lái)獲取系統(tǒng)中加載的所有引用程序集 -
Exist方法判斷了指定版本程序集的文件流是否存在 -
SaveStream是將指定版本的程序集文件流保存到靜態(tài)字典中 -
GetStream是從靜態(tài)字典中拉取指定版本程序集的文件流
然后我們可以創(chuàng)建一個(gè)引用程序集緩存容器的默認(rèn)實(shí)現(xiàn)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;
}
}
這個(gè)類比較簡(jiǎn)單,我就不做太多解釋了。
完成了引用緩存容器之后,我修改了之前創(chuàng)建的IReferenceLoader接口,及其默認(rèn)實(shí)現(xiàn)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參數(shù),即當(dāng)前插件程序集。 - 這里我通過(guò)
GetReferencedAssemblies方法,獲取了插件程序集引用的所有程序集。 - 如果引用程序集在引用容器中不存在,我們就是用文件流加載它,并將其保存到引用容器中, 如果引用程序集已存在于引用容器,就直接加載到當(dāng)前插件的
AssemblyLoadContext中。這里為了檢驗(yàn)效果,如果程序集來(lái)自緩存,我使用日志組件輸出了一條日志。 - 由于插件引用的程序集,有可能是來(lái)自
Shared Framework, 這種程序集是不需要加載的,所以這里我選擇跳過(guò)這類程序集的加載。(這里我還沒(méi)有考慮Self-Contained發(fā)布的情況,后續(xù)這里可能會(huì)更改)
最后我們還是需要修改MystiqueStartup.cs和MvcModuleSetup.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();
}
完成代碼之后,為了檢驗(yàn)效果,我創(chuàng)建了另外一個(gè)插件DemoPlugin2, 這個(gè)項(xiàng)目的代碼和DemoPlugin1基本一樣。程序啟動(dòng)時(shí),你會(huì)發(fā)現(xiàn)DemoPlugin2所使用的引用程序集都是從緩存中加載的,而且DemoPlugin2的路由也能正常訪問(wèn)。

添加頁(yè)面來(lái)顯示加載的第三方程序集
這里為了顯示一下系統(tǒng)中加載了哪些程序集,我添加了一個(gè)新頁(yè)面Assembilies, 這個(gè)頁(yè)面就是調(diào)用了IReferenceContainer接口中定義的GetAll方法,顯示了靜態(tài)字典中,所有加載的程序集。
效果如下:

幾個(gè)測(cè)試場(chǎng)景
最后,在編寫完成以上代碼功能之后,我們使用以下幾種場(chǎng)景來(lái)測(cè)試一下,看一看AssemblyLoadContext為我們提供的強(qiáng)大功能。
場(chǎng)景1
2個(gè)插件,一個(gè)引用DemoReferenceLibrary的1.0.0.0版本,另外一個(gè)引用DemoReferenceLibrary的1.0.1.0版本。其中1.0.0.0版本,SayHello方法返回的字符串是"Hello World. Version 1", 1.0.1.0版本, SayHello方法返回的字符串是“Hello World. Version 2”。

啟動(dòng)項(xiàng)目,安裝插件1和插件2,分別運(yùn)行插件1和插件2的路由,你會(huì)得到不同的結(jié)果。這說(shuō)明AssemblyLoadContext為我們做了很好的隔離,插件1和插件2雖然引用了相同插件的不同版本,但是互相之間完全沒(méi)有影響。
場(chǎng)景2
當(dāng)2個(gè)插件使用了相同的第三方庫(kù),并加載完成之后,禁用插件1。雖然他們引用的程序集相同,但是你會(huì)發(fā)現(xiàn)插件2還是能夠正常訪問(wèn),這說(shuō)明插件1的AssemblyLoadContext的釋放,對(duì)插件2的AssemblyLoadContext完全沒(méi)有影響。

總結(jié)
本篇我為大家介紹了如何解決插件引用程序集的加載問(wèn)題,這里我們講解了兩種方式,原始方式和緩存方式。這兩種方式的最終效果雖然相同,但是緩存方式的效率明顯更高。后續(xù)我會(huì)根據(jù)反饋,繼續(xù)添加新內(nèi)容,大家敬請(qǐng)期待。