.Net之项目插件编写

实现一个可以动态加载,动态更新服务的插件需求。插件的好处是什么?我们可以编写代码来动态去替换或者增加现有服务接口等,使用得当的情况下风险小、操作方便。

AssemblyLoadContext

AssemblyLoadContext 类是在 .NET Core 中引入的,在 .NET Framework 中不可用。在.Net5+和.NetCore的应用程序中均隐式使用它,它是运行时的提供程序,用来定位和加载依赖项,只要加载了依赖项,就会调用它的示例来定位该依赖项目。

版本控制

单个 AssemblyLoadContext 实例限制为每个简单程序集名称只加载 Assembly 的一个版本。 当针对已加载同名程序集的 AssemblyLoadContext 实例解析程序集引用时,会将请求的版本与加载的版本进行比较。 仅当加载的版本等于或高于所请求的版本时,解析才会成功。

操作

本文示例环境:VS2022、.Net6

思路:通过将插件文件(类库的dll)保存在文件存储等地方,然后在项目启动的时候下载插件,然后加载到当前宿主的服务内进行实现需求。

注意:下面的示例为毛坯房版本

准备

创建插件基础类库

首先我们需要先创建一个插件基础服务的类库DefaultPluginsExternalProvider,为了提高该类库扩展性,我们引用框架

<ItemGroup>
  <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

该类库被插件服务所引用,该类库中包含一个插件服务基础类IProviderBase接口,内容如下

/// <summary>
/// 插件提供基础类
/// </summary>
public interface IProviderBase
{
    /// <summary>
    /// 插件名称
    /// </summary>
    string Name { get; }

    /// <summary>
    /// 插件显示名
    /// </summary>
    string DisplayName { get; }

    /// <summary>
    /// 插件描述
    /// </summary>
    string Description { get; }

    /// <summary>
    /// 插件服务注册
    /// </summary>
    /// <param name="services">ioc容器</param>
    /// <param name="configuration">插件配置</param>
    void PluginsConfigureService(IServiceCollection services, IConfiguration configuration);
}

这里的PluginsConfigureService用来注册插件的服务,然后再创建保存插件信息的类Plugin

/// <summary>
/// 插件服务类(保存插件加载信息)
/// </summary>
public class Plugin
{
    /// <summary>
    /// 插件地址信息
    /// </summary>
    public string Path { get; set; }

    /// <summary>
    /// 程序集名称
    /// </summary>
    public string AssemblyName { get; set; }

    /// <summary>
    /// 程序集版本
    /// </summary>
    public string Version { get; set; }

    /// <summary>
    /// 默认程序集
    /// </summary>
    public Assembly Assembly { get; set; }

    /// <summary>
    /// 服务提供基础类
    /// </summary>
    public IProviderBase ProvideInstance { get; set; }

    /// <summary>
    /// 错误信息
    /// </summary>
    public string Error { get; set; }

    /// <summary>
    /// 配置
    /// </summary>
    public string Configuration { get; set; }

    /// <summary>
    /// 插件元数据信息
    /// </summary>
    public PluginMatadata Matadata { get; set; }
}

增加一个插件元数据信息的类

/// <summary>
/// 插件元数据配置
/// </summary>
public class PluginMatadata
{
    /// <summary>
    /// 名称
    /// </summary>
    [JsonPropertyName("name")]
    public string Name { get; set; }

    /// <summary>
    /// 显示名
    /// </summary>
    [JsonPropertyName("displayName")]
    public string DisplayName { get; set; }

    /// <summary>
    /// 描述
    /// </summary>
    [JsonPropertyName("description")]
    public string Description { get; set; }
}

该类用来映射上面IProviderBase实现类中配置的信息。

[图片上传中...(image-2a444e-1676555892925-7)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

创建宿主程序

新建一个.NetCoreWebApi程序DefaultSample,然后创建插件加载帮助类PluginHelper

/// <summary>
/// 插件帮助类
/// </summary>
public static class PluginHelper
{
    /// <summary>
    /// 获取插件加载的信息
    /// </summary>
    /// <param name="rootPath"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentException"></exception>
    public static List<Plugin> GetPluginLoaders(string rootPath)
    {
        var loaders = new List<Plugin>();
        Directory.CreateDirectory(Path.Combine(rootPath, "Plugins"));
        var pluginsDir = Path.Combine(rootPath, "Plugins");
        foreach (var dir in Directory.GetDirectories(pluginsDir))
        {
            var dirName = Path.GetFileName(dir);
            var pluginDll = Path.Combine(dir, dirName + ".dll");
            if (!File.Exists(pluginDll))
            {
                continue;
            }

            var context = new AssemblyLoadContext(pluginDll);
            using var stream = File.OpenRead(pluginDll);
            var assembly = context.LoadFromStream(stream);

            var plugin = new Plugin
            {
                AssemblyName = assembly.GetName().Name,
                Version = assembly.GetName().Version.ToString(),
                Path = dir,
                Assembly = assembly,
            };
            try
            {
                // 判断插件中是否存在IProviderBase的实现类
                var instance = assembly.GetTypes()
                    .Where(t => typeof(IProviderBase).IsAssignableFrom(t) && !t.IsAbstract)
                    .Select(x => Activator.CreateInstance(x) as IProviderBase)
                    .FirstOrDefault();
                if (instance == null)
                {
                    throw new ArgumentException("No Matching Plugin Entry");
                }
                plugin.ProvideInstance = instance;
                plugin.Matadata = new PluginMatadata
                {
                    Name = instance.Name,
                    DisplayName = instance.DisplayName,
                    Description = instance.Description
                };
                var configurationRaw = "";
                if (File.Exists(Path.Join(dir, "configurations.json")))
                {
                    using var sr = new StreamReader(Path.Join(dir, "configurations.json"));
                    configurationRaw = sr.ReadToEnd();
                }
                plugin.Configuration = configurationRaw;

                loaders.Add(plugin);
            }
            catch (Exception ex)
            {
                plugin.Error = ex.Message;
                Console.WriteLine($"插件 {plugin.AssemblyName} 不兼容于当前版本,请升级到该插件的最新版本, Error: {ex.Message}");
            }
        }
        return loaders;
    }
}

在项目启动的时候加载插件

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var loaders = PluginHelper.GetPluginLoaders(builder.Environment.ContentRootPath);

/*
  注册当前宿主服务的服务
 */

// 读取插件
foreach (var p in loaders)
{
    var pluginInstance = p.ProvideInstance;
    // 映射配置文件
    var configurationRoot = (new ConfigurationBuilder()).SetBasePath(p.Path).AddJsonFile("configurations.json").Build();
    pluginInstance?.PluginsConfigureService(builder.Services, configurationRoot);
    Console.WriteLine($"加载插件{p.AssemblyName} {p.Matadata.Name} 成功,版本号:{p.Version} ");
}

var app = builder.Build();

完整结构如下

[图片上传中...(image-f1237c-1676555892925-6)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

动态替换服务

在.NetCore中,我们都是通过IOC容器注册服务然后通过构造函数等来获取服务,那么我们也可以通过依赖注入的方式替换宿主服务默认注册的实现,来实现替换的功能。

在上面的.NetCore服务中来实现一个发送消息通知的接口,因为我们要使用插件来替换默认实现,所以需要将接口类放在一个共享的类库中,新建类库DefaultSample.IService,在该类库中新建服务IMsgService

/// <summary>
/// 消息服务
/// </summary>
public interface IMsgService
{
    /// <summary>
    /// 发送消息服务
    /// </summary>
    /// <returns></returns>
    bool SendMsg();
}

完整结构如下

[图片上传中...(image-c9c3e7-1676555892925-5)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

在宿主程序中引用该类库并且创建消息通知的实现类DefaultMsgService

public class DefaultMsgService : IMsgService
{
    private readonly ILogger<DefaultMsgService> _logger;

    public DefaultMsgService(ILogger<DefaultMsgService> logger)
    {
        _logger = logger;
    }

    public bool SendMsg()
    {
        _logger.LogInformation("使用邮箱发送消息成功");
        return true;
    }
}

新建控制器MsgController,来创建WebApi接口SendMsg

[ApiController]
[Route("[controller]")]
public class MsgController : ControllerBase
{
    private readonly IMsgService _msgService;

    public MsgController(IMsgService msgService)
    {
        _msgService = msgService;
    }

    /// <summary>
    /// 发送消息通知
    /// </summary>
    /// <returns></returns>
    [HttpGet("sendMsg")]
    public bool SendMsg()
    {
        return _msgService.SendMsg();
    }
}

启动项目调用该接口输出信息,呕吼居然是错误信息

System.InvalidOperationException: Unable to resolve service for type 'DefaultSample.IService.IMsgService' while attempting to activate 'DefaultSample.Controllers.MsgController'.

居然是忘了注入服务了,那么我们在program中注入短信服务

/*
  注册当前宿主服务的服务
 */
builder.Services.AddScoped<IMsgService, DefaultMsgService>();

再次启动调用接口可以看到日志中输出了:使用邮箱发送消息成功

这个时候,我们正常的服务已经好了,那么下面开始编写插件来替换默认的服务,新建类库DefaultPluginsExternalProvider.Sample1来实现短信消息通知的功能,在该项目中,继承自类库DefaultPluginsExternalProvider和DefaultSample.IService(这里可以做成nuget包),并且默认应该包含以下文件

configurations.json:当前插件的配置文件
PluginProvider:插件的入口程序,继承自接口IProviderBase
{插件名}Options:用来将configurations的配置映射到模型

因为我们要发送短信,那么就弄一个短信服务的伪配置吧,修改configurations.json文件

{
  "SmsUrl": "http://www.baidu.com"
}

修改Sample1Options文件

/// <summary>
/// 配置文件
/// </summary>
public class Sample1Options
{
    /// <summary>
    /// 短信服务地址
    /// </summary>
    public string SmsUrl { get; set; }
}

修改PluginProvider类的文件内容

public class PluginProvider : IProviderBase
{
    public string Name => "Sample1";

    public string DisplayName => "短信服务";

    public string Description => "发送短信服务";

    public void PluginsConfigureService(IServiceCollection services, IConfiguration configuration)
    {
        // 固定写法
        services.AddOptions();
        services.Configure<Sample1Options>(options => configuration.Bind(options));

        //自定义服务
        services.AddScoped<IMsgService, SmsService>();
    }
}

增加发送短信的实现类SmsService

public class SmsService : IMsgService
{
    private readonly ILogger<SmsService> _logger;
    private readonly Sample1Options _sample1Options;

    public SmsService(ILogger<SmsService> logger, IOptions<Sample1Options> options)
    {
        _logger = logger;
        _sample1Options = options.Value;
    }

    public bool SendMsg()
    {
        _logger.LogInformation($"使用短信发送消息,服务地址 {_sample1Options.SmsUrl}");
        return true;
    }
}

完整项目结构如下

[图片上传中...(image-71d4e6-1676555892925-4)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

然后将该类库Release发布,并拷贝configurations.json、DefaultPluginsExternalProvider.Sample1.dll文件,并且宿主服务的Plugins文件夹下新建DefaultPluginsExternalProvider.Sample1文件夹,拷贝插件文件放到该目录下,如图所示

[图片上传中...(image-3db8d5-1676555892924-3)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

这个时候启动宿主项目并调用接口我们可以看到输出信息为:使用短信发送消息,服务地址 http://www.baidu.com

到此,动态替换服务功能完成。

动态增加接口

有时候,我们需要实现另外开放一些对外提供的WebApi接口,那我该如何使用插件实现那?

因为我们的插件基类已经引用了Microsoft.AspNetCore.App,所以我们直接在插件中创建控制器就可以了

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
    private readonly IMsgService _msgService;
    private readonly ILogger<TestController> _logger;

    public TestController(IMsgService msgService, ILogger<TestController> logger)
    {
        _msgService = msgService;
        _logger = logger;
    }

    /// <summary>
    /// 发送消息通知
    /// </summary>
    /// <returns></returns>
    [HttpGet("sendMsg")]
    public bool SendMsg()
    {
        _logger.LogInformation("我是插件中新添加的控制器");
        return _msgService.SendMsg();
    }
}

项目结构如下

[图片上传中...(image-96ca8-1676555892924-2)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

然后按照上面的步骤,重新发布,然后拷贝到宿主程序的插件目录下,然后按照之前文章的经验,修改宿主服务注册代码为

var mvcBuilder = builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var loaders = PluginHelper.GetPluginLoaders(builder.Environment.ContentRootPath);

/*
  注册当前宿主服务的服务
 */

builder.Services.AddScoped<IMsgService, DefaultMsgService>();

// 读取插件
foreach (var p in loaders)
{
    var loader = p.Assembly;
    // 注册控制器
    mvcBuilder.AddApplicationPart(loader);

    var pluginInstance = p.ProvideInstance;
    // 映射配置文件
    var configurationRoot = (new ConfigurationBuilder()).SetBasePath(p.Path).AddJsonFile("configurations.json").Build();
    pluginInstance?.PluginsConfigureService(builder.Services, configurationRoot);
    Console.WriteLine($"加载插件{p.AssemblyName} {p.Matadata.Name} 成功,版本号:{p.Version} ");
}

var app = builder.Build();

重点就是这一句:mvcBuilder.AddApplicationPart(loader);

然后再启动项目后就可以看到插件中添加的控制器了

[图片上传中...(image-e2bea4-1676555892924-1)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

调用接口后会输出内容表示接口添加成功。

创建带依赖项的插件

使用 System.Runtime.Loader.AssemblyDependencyResolver 类型来允许插件具有依赖项,在宿主项目新建文件PluginLoadContext

/// <summary>
/// 可以加载依赖项的AssemblyLoadContext
/// </summary>
public class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath)
    {
        // 使用.net类库的路径构建的,她根据类库的deps.json文件将程序集和本机库解析为他们的相对路径
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        if (assemblyPath != null)
        {
            return LoadFromAssemblyPath(assemblyPath);
        }

        return null;
    }

    /// <summary>
    /// 加载非托管的dll
    /// </summary>
    /// <param name="unmanagedDllName"></param>
    /// <returns></returns>
    protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
    {
        string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        if (libraryPath != null)
        {
            return LoadUnmanagedDllFromPath(libraryPath);
        }

        return IntPtr.Zero;
    }
}

然后修改我们的PluginHelper文件中加载程序集的方法,修改为

/// <summary>
/// 加载程序集
/// </summary>
/// <param name="pluginLocation"></param>
/// <returns></returns>
private static Assembly LoadPlugin(string pluginLocation)
{
    // 不能加载带依赖项的
    //var context = new AssemblyLoadContext(Guid.NewGuid().ToString());
    //using var stream = File.OpenRead(pluginLocation);
    //return context.LoadFromStream(stream);

    var loadContext = new PluginLoadContext(pluginLocation);
    return loadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginLocation)));
}

var assembly = LoadPlugin(pluginDll);

然后我们修改我们的插件项目(DefaultPluginsExternalProvider.Sample2)引用第三方包并设置项目配置为

<PropertyGroup>
  <TargetFramework>net6.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
  <!--插件项目使用该配置,以便它们将其所有依赖项复制到 dotnet build 的输出中。 使用 dotnet publish 发布类库也会将其所有依赖项复制到发布输出-->
  <EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="AzrngCommon" Version="1.3.0-beta9" />
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="..\..\share\DefaultPluginsExternalProvider\DefaultPluginsExternalProvider.csproj">
    <!--配置是否将该引用项目输出,如果输出那么就不是从上下文继承IProviderBase,会导致找不到IProviderBase-->
    <private>false</private>
    <!--如果PluginBase引用其他包,则当前元素也很重要。 此设置与Private的效果相同,但适用于 PluginBase 项目或它的某个依赖项可能包括的包引用-->
    <ExcludeAssets>runtime</ExcludeAssets>
  </ProjectReference>
  <ProjectReference Include="..\..\share\DefaultSample.IService\DefaultSample.IService.csproj">
    <private>false</private>
    <ExcludeAssets>runtime</ExcludeAssets>
  </ProjectReference>
</ItemGroup>

在该配置中,引用了一个第三方的包,并设置了ProjectReference,这时候生成项目,查看输出的dll已经不包含项目引用的dll文件

[图片上传中...(image-bc350d-1676555892924-0)]

<figcaption style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; line-height: 1.75; color: rgb(136, 136, 136); font-size: 0.8em;">img</figcaption>

将该目录的dll文件拷贝到插件文件夹下,调该插件的接口,返回结果

{
  "data": true,
  "isSuccess": true,
  "code": "200",
  "message": "success",
  "errors": []
}

该返回结构代表第三方包已经加载成功了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,589评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,615评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,933评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,976评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,999评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,775评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,474评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,359评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,854评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,007评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,146评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,826评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,484评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,029评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,153评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,420评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,107评论 2 356

推荐阅读更多精彩内容