ASP.NET 开发支持热重载的插件系统

时间:11/01/2024 17:42:57   作者:ChenReal    阅读:25

标题:ASP.NET 开发支持热重载的插件系统

在 ASP.NET 应用程序中,使用插件系统可以实现功能的模块化,便于扩展和维护。并且,我们的.NET插件还可以实现在应用程序运行过程中,动态地加载或卸载插件,更换插件无需重启整个应用程序。这种技术可以大大提高系统的灵活性和可用性。

听起来,是不是非常Cool呢?好吧,下面我们就尝试实现一个简单的插件系统。

一、插件接口定义

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

image.png

然后,定义一个插件接口,以便插件实现者能够遵循这个接口来开发插件。

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

    Task<string> Execute();  
}

另外,需要再定义一个用于插件配置参数映射的类,与加载插件时所读取json文件数据结构一致。。

public class PluginOptions : IPluginOptions  
{  
    /// <summary>  
    /// 命名空间  
    /// </summary>  
    public string Namespace { get; set; }  
    /// <summary>  
    /// 版本信息  
    /// </summary>  
    public string Version { get; set; }  
    /// <summary>  
    /// 版本号  
    /// </summary>  
    public int VersionCode { get; set; }  
    /// <summary>  
    /// 插件描述  
    /// </summary>  
    public string Description { get; set; }  
    /// <summary>  
    /// 插件依赖库  
    /// </summary>  
    public string[] Dependencies { get; set; }  
    /// <summary>  
    /// 其他参数选项  
    /// </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;  
    }  
}

二、插件开发

在 ASP.NET Core 中,插件通常是一个独立的类库项目(.dll),它可以实现特定的接口或继承自某个基类。这样,主应用程序就可以通过接口或基类来调用插件中的功能。

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

image.png

然后在该项目实现接口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以及配置文件都输出到应用程序可以调用插件目录。而不需要我们再去手工Copy,省时省力。

三、插件管理类

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

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()  
    {
        // 读取  
        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");  
        //使用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;  
    }  

}

2、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");  
    }  

    // 设置插件目录的路径
    public void SetRootPath(string path)  
    {  
        if (Path.IsPathRooted(path))  
        {  
            _rootPath = path;  
        }  
        else  
        {  
            _rootPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, path);  
        }  
    }  

    // 加载并初始插件目录下的所有插件
    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);  
            }  
        }  
    }

    // 并初始化单个插件
    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}");  
        }  
    }  
    // 获取插件
    public IPlugin? GetPlugin(string name)  
    {  
        _loaderPool.TryGetValue(name, out var loader);  
        return loader?.Plugin;  
    }  
    // 移除并卸载插件
    public bool RemovePlugin(string name)  
    {  
        if (!_loaderPool.TryRemove(name, out var loader)) return false;  
        return loader.Unload();  
    }  
    // 重新加载插件
    public bool ReloadPlugin(string name)  
    {  
        if (!_loaderPool.TryGetValue(name, out var loader)) return false;  
        loader.Unload();  
        return loader.Load();  
    }

}

四、应用集成

我将上面这个插件系统功能集成到一个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");  
    }  
}

五、插件功能测试

最后,到了激动人心的测试环节。测试方法也很简单,直接调用WebAPI接口即可。

## 加载所有插件
curl "http://localhost:3000/plugin/test/load"

## 执行插件
curl "http://localhost:3000/plugin/test/execute?name=MyPlugin.Plugins.TestPlugin"

## 重新载入插件
curl "http://localhost:3000/plugin/test/reload?name=MyPlugin.Plugins.TestPlugin"

## 卸载插件
curl "http://localhost:3000/plugin/test/unload?name=MyPlugin.Plugins.TestPlugin"

总结

我用最简单的代码示例,展示了ASP.NET的插件功能是如何开发实现的。而且该系统也具备支持热加载插件的功能,即便插件版本更新,也能够不重启应用直接热加载插件。

看完了以上内容,大家心理是否也在跃跃欲试,想将它应用自己的产品上呢?

最后,关于插件功能的使用有几个注意事项,我需要提醒一下的:

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

  • 当使用 AssemblyLoadContext 进行 插件DLL 热加载时,需要确保插件与其依赖项都被正确加载,并且要注意版本冲突和依赖项管理。

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

 

评论
0/200