.NET Core WebApi热插拔

1,114 阅读7分钟

前言

什么是热插拔?相信大家也不陌生,那WebApi的热插拔又是什么意思呢?其实实现的功能也很简单,就是在我们WebApi项目启动运行之后,对系统进行添加和删除接口,同时也不影响系统的运行。

介绍

微软系列的开发,最终都是生成的dll文件,也就是动态链接库,而我们想要实现热插拔呢,其实就是操作动态连接池,在系统中操作动态链接库加载和卸载。动态链接库一般可能会单独写,但是我们目前有一个业务场景,就是在做接口平台的时候,会通过配置动态的去生成代码,再生成动态链接库,最后再加载到系统中,动态生成代码本文就不写了,这篇文章主要包含了两个部分,第一个部分是如何把代码生成动态链接库,第二个部分就是如何操作动态链接库的池

开始

我们先创建一个WebApi的项目,就使用目前最新的.NET 6框架来演示

我们先创建一个最简单的接口

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

namespace EPN.Api.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TestOneController : ControllerBase
    {
        [HttpGet("Test")]
        public string Test(string str)
        {
            return "Test:" + str;
        }
    }
}

执行的效果就是这样的

那我们先用这个最简单的接口方法来生成个新的动态链接库吧

生成动态链接库

如果大家看过我前面WPF的文章应该就知道如何生成动态链接库了
# WPF动态生成页面,生成动态链接库

这里其实也差不多,就是使用的 Microsoft.CodeAnalysis 这个库,直接上代码

using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;

namespace ApiPlug
{
    public class DynamicLinkLibraryExtensions
    {
        public static EmitResult GenerateDynamicLinkLibrary(List<string> codes,string DynamicLinkLibraryName)
        {
            List<MetadataReference> References = new List<MetadataReference>();
            References.Add(MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location));
            References.Add(MetadataReference.CreateFromFile(typeof(ControllerBase).GetTypeInfo().Assembly.Location));
            References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("netstandard")).Location));
            References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("Microsoft.AspNetCore.Mvc")).Location));
            //References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("Newtonsoft.Json")).Location));
            References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.Threading")).Location));
            References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System")).Location));
            References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.IO")).Location));
            References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.Reflection")).Location));
            References.Add(MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.Runtime")).Location));
            var compilation = CSharpCompilation.Create(DynamicLinkLibraryName)
                .WithOptions(
                new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
                .AddReferences(References.ToArray());
            foreach (string code in codes)
            {
                var tree = SyntaxFactory.ParseSyntaxTree(code);
                compilation = compilation.AddSyntaxTrees(tree);
            }
            string path = Path.Combine(Directory.GetCurrentDirectory(), "DynamicLinkLibrary/" + DynamicLinkLibraryName+".dll");
            EmitResult compilationResult = compilation.Emit(path);
            return compilationResult;
        }
    }
}

好的,那我们调用这个方法来看看

在控制器中添加个方法试试

/// <summary>
/// 生成动态链接库
/// </summary>
/// <param name="code">动态链接库代码</param>
/// <param name="name">动态链接库名称</param>
[HttpGet("GenerateDynamicLinkLibrary")]
public void GenerateDynamicLinkLibrary(string code, string name)
{
    //code就用TestOneController的代码做测试,name就用TestOne,控制器名不要和其他的控制器名重复
    List<string> codes = new List<string>();
    codes.Add(code);
    DynamicLinkLibraryExtensions.GenerateDynamicLinkLibrary(codes, name);
}

接下来我们就能看到在这个目录下生成了新的动态链接库

我们先手动添加下引用看看能否正常显示新的接口,可以看到原有的TestOne和TestTwo都显示在Swagger中了

操作动态链接库

前面如果生成动态链接库没问题了,那就是下一步如何在运行的状态下操作动态链接库,其实最主要的库就是

AssemblyLoadContextApplicationPartManager

AssemblyLoadContext

官方文档往下翻,可以看到这个类有一个属性IsCollectible是值上下文能否被回收的,同时,构造函数中也为我们提供了方法去设置这个值,我们想要卸载动态链接库的化就必须要把这个熟悉设置成true

我们自己创建一个对象去继承此类并调用父类的构造函数设置IsCollectible为true

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading.Tasks;

namespace ApiPlug
{
    /// <summary>
    /// 可回收的程序集加载上下文,我们首先基于AssemblyLoadcontext创建一个CollectibleAssemblyLoadContext类。
    /// 其中我们将IsCollectible属性通过父类构造函数,将其设置为true
    /// 主要用于加载程序集
    /// </summary>
    public class CollectibleAssemblyLoadContext: AssemblyLoadContext
    {
        /// <summary>
        /// 将IsCollectible属性设置为true
        /// </summary>
        public CollectibleAssemblyLoadContext(): base(isCollectible: true)
        {
        }
    }
}

ApplicationPartManager

这个库尤为重要,你可以把他理解为一个动态链接库的管理类,管理所有的动态链接库,我们想要在运行时加载动态链接库就全靠他了,使用起来也很简单,只需要注入进IOC容器中就可以了,我这里写的是加载一个文件夹下的所有动态链接库(代码不是最终的,不要复制)

var mvcBuilders = builder.Services.AddMvc();
mvcBuilders.ConfigureApplicationPartManager(apm =>
{
    var context = new CollectibleAssemblyLoadContext();
    DirectoryInfo DirInfo = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "DynamicLinkLibrary"));
    FileInfo[] DynamicLinkLibrarys = DirInfo.GetFiles();
    foreach (FileInfo DynamicLinkLibrary in DynamicLinkLibrarys)
    {
        using (FileStream fs = new FileStream(DynamicLinkLibrary.FullName, FileMode.Open))
        {
            var assembly = context.LoadFromStream(fs);
            var controllerAssemblyPart = new AssemblyPart(assembly);
            apm.ApplicationParts.Add(controllerAssemblyPart);
        }
    }
});

IActionDescriptorChangeProvider

但是如果真的就这么简单,那我也不会写这篇文章了,因为我在使用的过程中发现,我通过这个库把动态链接库加载上来之后,我的Api接口并没有发生变化,也就是我们的Controller,我们知道其实WebApi就是MVC框架少了View层,所以我在AspNetCore.Mvc的库下找到了刷新控制器的方法IActionDescriptorChangeProvider

可以看到此类包含了一个方法,GetChangeToken,注解里也说明了可以通过此方法去通知ActionDescriptor更改,那同样的我们也继承这个类封装一个我们自己的监听token变更的实现

可以看到我们实现了这个接口必须要实现GetChangeToken的方法,那我们就简单实现以下

using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Primitives;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace ApiPlug
{
    /// <summary>
    /// 用于刷新Controllers,否则操作动态链接库之后没有效果
    /// </summary>
    public class ActionDescriptorChangeProvider : IActionDescriptorChangeProvider
    {
        public static ActionDescriptorChangeProvider Instance { get; } = new ActionDescriptorChangeProvider();

        public CancellationTokenSource? TokenSource { get; private set; }

        public bool HasChanged { get; set; }
        /// <summary>
        /// 监听Token改变
        /// </summary>
        /// <returns></returns>

        public IChangeToken GetChangeToken()
        {
            TokenSource = new CancellationTokenSource();
            return new CancellationChangeToken(TokenSource.Token);
        }
    }
}

我们在更新token的时候只需要更改TokenSource就行了

ActionDescriptorChangeProvider.Instance.HasChanged = true;
ActionDescriptorChangeProvider.Instance.TokenSource!.Cancel();

我们再写个记录所有新添加的动态链接库的集合,为了和原有的动态链接库区分开,不让他卸载掉默认的库

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace ApiPlug
{
    /// <summary>
    /// 用于操作动态链接库
    /// </summary>
    public static class PluginsLoadContexts
    {
        private static Dictionary<string, CollectibleAssemblyLoadContext>? _pluginContexts = null;

        static PluginsLoadContexts()
        {
            _pluginContexts = new Dictionary<string, CollectibleAssemblyLoadContext>();
        }
        /// <summary>
        /// 判断是否在动态链接库中
        /// </summary>
        /// <param name="pluginName"></param>
        /// <returns></returns>
        public static bool Any(string pluginName)
        {
            return _pluginContexts!.ContainsKey(pluginName);
        }
        /// <summary>
        /// 移除动态链接库
        /// </summary>
        /// <param name="pluginName"></param>
        public static void RemovePluginContext(string pluginName)
        {
            if (_pluginContexts!.ContainsKey(pluginName))
            {
                _pluginContexts[pluginName].Unload();
                _pluginContexts.Remove(pluginName);
            }
        }
        /// <summary>
        /// 获取动态链接库
        /// </summary>
        /// <param name="pluginName"></param>
        /// <returns></returns>
        public static CollectibleAssemblyLoadContext GetContext(string pluginName)
        {
            return _pluginContexts![pluginName];
        }
        /// <summary>
        /// 添加动态链接库
        /// </summary>
        /// <param name="pluginName"></param>
        /// <param name="context"></param>
        public static void AddPluginContext(string pluginName,
             CollectibleAssemblyLoadContext context)
        {
            _pluginContexts!.Add(pluginName, context);
        }
    }
}

修改下刚才依赖注册的代码

#region 插件化开发
//依赖注入监听动态连接池变化
builder.Services.AddSingleton<IActionDescriptorChangeProvider>(ActionDescriptorChangeProvider.Instance);
builder.Services.AddSingleton(ActionDescriptorChangeProvider.Instance);
//启动载入所有动态连接池
var mvcBuilders = builder.Services.AddMvc();
mvcBuilders.ConfigureApplicationPartManager(apm =>
{
    var context = new CollectibleAssemblyLoadContext();
    DirectoryInfo DirInfo = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "DynamicLinkLibrary"));
    FileInfo[] DynamicLinkLibrarys = DirInfo.GetFiles();
    foreach (FileInfo DynamicLinkLibrary in DynamicLinkLibrarys)
    {
        using (FileStream fs = new FileStream(DynamicLinkLibrary.FullName, FileMode.Open))
        {
            var assembly = context.LoadFromStream(fs);
            //var assembly = Assembly.LoadFile(DynamicLinkLibrary.FullName);
            var controllerAssemblyPart = new AssemblyPart(assembly);
            apm.ApplicationParts.Add(controllerAssemblyPart);
            PluginsLoadContexts.AddPluginContext(DynamicLinkLibrary.Name, context);
        }
    }
});

mvcBuilders.SetCompatibilityVersion(CompatibilityVersion.Latest);
#endregion

测试

我们再添加两个方法测试一下是否能够正常加载和卸载动态链接库了

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Infrastructure;

namespace ApiPlug.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ApisController : ControllerBase
    {

        ApplicationPartManager _partManager;
        public ApisController( ApplicationPartManager partManager)
        {
            _partManager = partManager;
        }
        /// <summary>
        /// 生成动态链接库
        /// </summary>
        /// <param name="code">动态链接库代码</param>
        /// <param name="name">动态链接库名称</param>
        [HttpGet("GenerateDynamicLinkLibrary")]
        public void GenerateDynamicLinkLibrary(string code, string name)
        {
            //code就用TestOneController的代码做测试,name就用TestOne,控制器名不要和其他的控制器名重复
            List<string> codes = new List<string>();
            codes.Add(code);
            DynamicLinkLibraryExtensions.GenerateDynamicLinkLibrary(codes, name);
        }
        /// <summary>
        /// 删除动态链接库
        /// </summary>
        /// <param name="name">动态链接库名称</param>
        /// <returns></returns>
        [HttpGet("RemovePluginContext")]
        public string RemovePluginContext(string name)
        {
            if (PluginsLoadContexts.Any(name + ".dll"))
            {
                //先操作ApplicationParts动态链接库
                var matchedItem = _partManager.ApplicationParts.FirstOrDefault(p => p.Name == name);
                if (matchedItem != null)
                {
                    _partManager.ApplicationParts.Remove(matchedItem);
                    matchedItem = null;
                }
                //更新Controllers
                ActionDescriptorChangeProvider.Instance.HasChanged = true;
                ActionDescriptorChangeProvider.Instance.TokenSource!.Cancel();
                //移除数组中的动态链接库
                PluginsLoadContexts.RemovePluginContext(name + ".dll");
                //GC.Collect();如果挂载时候用了using就不需要使用GC了
                return "移除动态链接库成功";
            }
            else
            {
                return "未找到动态链接库";
            }
        }
        /// <summary>
        /// 新增动态链接库
        /// </summary>
        /// <param name="name">动态链接库名称</param>
        /// <returns></returns>
        [HttpGet("AddPluginContext")]
        public string AddPluginContext(string name)
        {
            if (!PluginsLoadContexts.Any(name + ".dll"))
            {
                //先操作ApplicationParts动态链接库
                //读取动态链接库
                var context = new CollectibleAssemblyLoadContext();
                FileInfo FileInfo = new FileInfo(Path.Combine(Directory.GetCurrentDirectory(), "DynamicLinkLibrary/" + name + ".dll"));
                using (FileStream fs = new FileStream(FileInfo.FullName, FileMode.Open))
                {
                    var assembly = context.LoadFromStream(fs);
                    var controllerAssemblyPart = new AssemblyPart(assembly);
                    //插入动态链接库
                    _partManager.ApplicationParts.Add(controllerAssemblyPart);
                    //插入数组
                    PluginsLoadContexts.AddPluginContext(name + ".dll", context);
                    //更新Controllers
                    ActionDescriptorChangeProvider.Instance.HasChanged = true;
                    ActionDescriptorChangeProvider.Instance.TokenSource!.Cancel();
                }
                return "添加动态链接库成功";
            }
            else
            {
                return "动态链接库已存在";
            }
        }

    }
}

刚启动是这样的,这时还没有TestTwo

加载刚才生成的TestTwo的动态链接库

这时候就有TestOne和TestTwo了

再卸载掉TestTwo的动态链接库

TestTwo消失了

总结

热插拔的功能就基础的实现就是这么多了,感兴趣的可以继续深入研究下,目前我的业务场景就是通过热插拔去实现通过配置动态的控制接口的暴露和执行的事件

开源地址

此Demo已经在GitHub上开源,想要的可以自行下载
XiangLiAn627/ApiPlug