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

作者:微信公众号:【架构师老卢】
11-10 19:13
85

在 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
  • 卸载时,请确保没有对该上下文加载的程序集的引用,否则可能会导致卸载失败或内存泄漏。这通常意味着避免将插件实例或类型传递给主应用程序的其他部分,除非这些部分被明确地配备了来处理这些实例或类型的生命周期。
相关留言评论
昵称:
邮箱:
阅读排行