在 .NET Core 中构建热重载插件系统

科技   2024-11-27 05:11   上海  


在 ASP.NET 应用程序中,插件系统允许模块化功能,使其更易于扩展和维护。此外,我们的 .NET 插件允许在应用程序运行时动态加载和卸载插件,无需重新启动整个应用程序即可更换插件。该技术显著提高了系统的灵活性和可用性。

听起来很酷,对吧?让我们尝试实现一个简单的插件系统。

1. 插件接口定义

首先,创建一个项目:MyPlugin.Base

接下来,定义一个插件接口,插件开发人员可以遵循该接口来开发他们的插件。

public interface IPlugin    
{
IPlugin Init(IPluginOptions? options = null);

Task<string> Execute();
}

此外,我们需要定义一个 class 来映射插件配置参数,确保与加载插件时使用的 JSON 文件数据结构保持一致。

public class PluginOptions : IPluginOptions  
{
/// <summary>
/// Namespace
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// Version information
/// </summary>
public string Version { get; set; }
/// <summary>
/// Version code
/// </summary>
public int VersionCode { get; set; }
/// <summary>
/// Plugin description
/// </summary>
public string Description { get; set; }
/// <summary>
/// Plugin dependencies
/// </summary>
public string[] Dependencies { get; set; }
/// <summary>
/// Other parameter options
/// </summary>
public Dictionary<string, string> Options { get; set; }

public virtual bool TryGetOption(string key, out string value)
{
value = "";
return Options?.TryGetValue(key, out value) ?? false;
}
}

2. 插件开发

在 ASP.NET Core 中,插件通常是实现特定接口或从基类继承的独立类库项目 (.dll)。这允许主应用程序通过接口或基类调用插件中的函数。

因此,我们需要创建一个类库项目:MyPlugin.Plugins.TestPlugin

然后,实现本工程中的接口方法,完成插件功能开发。MyPlugin.Base.IPlugin

public sealed class MainPlugin : IPlugin  
{
private IPluginOptions _options;
public IPlugin Init(IPluginOptions? options)
{
_options = options;
return this;
}

public async Task<string> Execute()
{
Console.WriteLine($"Start Executing {_options.Namespace}");
Console.WriteLine($"Description {_options.Description}");
Console.WriteLine($"Version {_options.Version}");
await Task.Delay(1000);
Console.WriteLine($"Done.");

return JsonSerializer.Serialize(new { code = 0, message = "ok" });
}
}

此外,我们需要添加一个配置文件来设置插件的启动参数。settings.json

{    
"namespace": "MyPlugin.Plugins.TestPlugin",
"version": "1.0.0",
"versionCode": 1,
"description": "This is a sample plugin",
"dependencies": [
],
"options": {
"Option1": "Value1",
}
}

在编译和发布插件之前,这里有一个有用的提示:

  • 打开插件项目文件 。MyPlugin.Plugins.TestPlugin.csproj

  • 添加输出目录配置:

<OutputPath>..\plugins\MyPlugin.Plugins.TestPlugin</OutputPath>  
<OutDir>$(OutputPath)</OutDir>

这样,当你在 IDE 中编译插件工程时,它会直接将编译好的 DLL 和配置文件输出到应用程序可以访问插件的目录下。无需手动复制,省时省力。

3. 插件管理类

回到我们的应用程序,我们需要添加两个 class 来管理和使用插件:

  1. PluginLoader.cs实现插件 DLL 及其配置参数的加载和卸载。

internal class PluginLoader  
{
private AssemblyLoadContext _loadContext { get; set; }
private readonly string _pluginName;
private readonly string _pluginDir;
private readonly string _rootPath;
private readonly string _binPath;

public string Name => _pluginName;

private IPlugin? _plugin;
public IPlugin? Plugin => _plugin;

internal const string PLUGIN_SETTING_FILE = "settings.json";
internal const string BIN_PATH = "bin";

public PluginLoader(string mainAssemblyPath)
{
if (string.IsNullOrEmpty(mainAssemblyPath))
{
throw new ArgumentException("Value must be null or not empty", nameof(mainAssemblyPath));
}

if (!Path.IsPathRooted(mainAssemblyPath))
{
throw new ArgumentException("Value must be an absolute file path", nameof(mainAssemblyPath));
}

_pluginDir = Path.GetDirectoryName(mainAssemblyPath);
_rootPath = Path.GetDirectoryName(_pluginDir);
_binPath = Path.Combine(_rootPath, BIN_PATH);
_pluginName = Path.GetFileNameWithoutExtension(mainAssemblyPath);

if (!Directory.Exists(_binPath)) Directory.CreateDirectory(_binPath);

Init();
}

private void Init()
{
// Read
var fileBytes = File.ReadAllBytes(Path.Combine(_rootPath, _pluginName, PLUGIN_SETTING_FILE));
var setting = JsonSerializer.Deserialize<PluginOptions>(fileBytes);
if (setting == null) throw new Exception($"{PLUGIN_SETTING_FILE} Deserialize Failed.");
if (setting.Namespace == _pluginName) throw new Exception("Namespace not match.");

var mainPath = Path.Combine(_binPath, _pluginName,_pluginName+".dll");
CopyToRunPath();
using var fs = new FileStream(mainPath, FileMode.Open, FileAccess.Read);

_loadContext ??= new AssemblyLoadContext(_pluginName, true);
var assembly = _loadContext.LoadFromStream(fs);
var pluginType = assembly.GetTypes()
.FirstOrDefault(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract);
if (pluginType == null) throw new NullReferenceException("IPlugin is Not Found");
_plugin = Activator.CreateInstance(pluginType) as IPlugin ??
throw new NullReferenceException("IPlugin is Not Found");
// Initialize with configuration from settings.json
_plugin.Init(setting);
}

private void CopyToRunPath()
{
var assemblyPath = Path.Combine(_binPath, _pluginName);
if(Directory.Exists(assemblyPath)) Directory.Delete(assemblyPath,true);
Directory.CreateDirectory(assemblyPath);
var files = Directory.GetFiles(_pluginDir);
foreach (string file in files)
{
string fileName = Path.GetFileName(file);
File.Copy(file, Path.Combine(assemblyPath, fileName));
}
}

public bool Load()
{
if (_plugin != null) return false;
try
{
Init();
Console.WriteLine($"Load Plugin [{_pluginName}]");
}
catch (Exception ex)
{
Console.WriteLine($"Load Plugin Error [{_pluginName}]:{ex.Message}");
}
return true;
}

public bool Unload()
{
if (_plugin == null) return false;
_loadContext.Unload();
_loadContext = null;
_plugin = null;
return true;
}

}
  1. PluginManager.cs,一个插件管理服务类,提供用于初始化和管理操作的插件池。

public class PluginManager  
{
private static PluginManager _instance;
public static PluginManager Instance => _instance ??= new PluginManager();

private static readonly ConcurrentDictionary<string, PluginLoader> _loaderPool = new ConcurrentDictionary<string, PluginLoader>();

private string _rootPath;
PluginManager()
{
_rootPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins");
}

// Set the plugin directory path
public void SetRootPath(string path)
{
if (Path.IsPathRooted(path))
{
_rootPath = path;
}
else
{
_rootPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, path);
}
}

// Load and initialize all plugins in the plugin directory
public void LoadAll()
{
if (!Directory.Exists(_rootPath)) return;
var rootDir = new DirectoryInfo(_rootPath);
foreach (var pluginDir in rootDir.GetDirectories())
{
if(pluginDir.Name==PluginLoader.BIN_PATH )continue;
var files = pluginDir.GetFiles();
var hasBin = files.Any(f => f.Name == pluginDir.Name + ".dll");
var hasSettings = files.Any(f => f.Name == PluginLoader.PLUGIN_SETTING_FILE);
if (hasBin && hasSettings)
{
LoadPlugin(pluginDir.Name);
}
}
}

// Load and initialize a single plugin
private void LoadPlugin(string name)
{
var srcPath = Path.Combine(_rootPath, name, name + ".dll");
try
{
var loader =new PluginLoader(srcPath);
_loaderPool.TryAdd(name, loader);
Console.WriteLine($"Load Plugin [{name}]");
}
catch (Exception ex)
{
Console.WriteLine($"Load Plugin Error [{name}]:{ex.Message}");
}
}
// Get a plugin
public IPlugin? GetPlugin(string name)
{
_loaderPool.TryGetValue(name, out var loader);
return loader?.Plugin;
}
// Remove and unload a plugin
public bool RemovePlugin(string name)
{
if (!_loaderPool.TryRemove(name, out var loader)) return false;
return loader.Unload();
}
// Reload a plugin
public bool ReloadPlugin(string name)
{
if (!_loaderPool.TryGetValue(name, out var loader)) return false;
loader.Unload();
return loader.Load();
}

}

4. 应用程序集成

我将这个插件系统功能集成到一个 WebAPI 应用程序中。首先,我添加了一个 Controller 类,然后实现了几个测试 Action 方法来测试插件调用。这是代码:

[Controller(BaseUrl = "/plugin/test")]  
public class PluginController
{
private readonly PluginManager _pluginManager;
public PluginController()
{
_pluginManager = PluginManager.Instance;
_pluginManager.SetRootPath("../plugins");
}

[Get(Route = "load")]
public ActionResult Load()
{
_pluginManager.LoadAll();
return GetResult("ok");
}

[Get(Route = "execute")]
public async ActionResult Execute(string name)
{
var plugin= _pluginManager.GetPlugin(name);
await plugin?.Execute();
return GetResult("ok");
}

[Get(Route = "unload")]
public ActionResult Unload(string name)
{
var res = _pluginManager.RemovePlugin(name);
return res ? GetResult("ok") : FailResult("failed");
}

[Get(Route = "reload")]
public ActionResult Reload(string name)
{
var res = _pluginManager.ReloadPlugin(name);
return res ? GetResult("ok") : FailResult("failed");
}
}

5. 插件功能测试

最后,现在是激动人心的测试阶段了。测试方法很简单:只需调用 WebAPI 接口即可。

## Load all plugins
curl "http://localhost:3000/plugin/test/load"

## Execute a plugin
curl "http://localhost:3000/plugin/test/execute?name=MyPlugin.Plugins.TestPlugin"

## Reload a plugin
curl "http://localhost:3000/plugin/test/reload?name=MyPlugin.Plugins.TestPlugin"

## Unload a plugin
curl "http://localhost:3000/plugin/test/unload?name=MyPlugin.Plugins.TestPlugin"

我使用了最简单的代码示例来演示如何开发和实现 ASP.NET 插件功能。此外,该系统支持插件的热加载,这意味着即使插件版本更新,您也可以在不重新启动应用程序的情况下热加载插件。

读完这篇文章后,您是否很高兴尝试并将其应用于您自己的产品?

最后,使用插件功能时需要记住一些事项:

  • 插件 DLL 热加载主要用于开发或测试环境,无需重启应用程序即可进行快速的插件测试和迭代。在生产环境中,频繁加载和卸载 DLL 可能会导致性能问题或内存泄漏。

  • 使用热加载插件 DLL 时,请确保插件及其依赖正确加载,注意版本冲突和依赖管理。AssemblyLoadContext

  • 卸载时,请确保没有对该上下文加载的程序集的引用,否则可能会导致卸载失败或内存泄漏。这通常意味着避免将插件实例或类型传递给主应用程序的其他部分,除非这些部分被明确地配备了来处理这些实例或类型的生命周期。

如果你喜欢我的文章,请给我一个赞!谢谢


架构师老卢
资深软件架构师, 分享编程、软件设计经验, 教授前沿技术, 分享技术资源(每天发布电子书),每天进步一点点...
 最新文章