ASPNETCORE6.0开发插件系统
上一次简单分析了目前公司后台任务存在的一些问题以及解决方案,主要是一些概念,没有涉及到任何技术实现,最近花了一些时间,看了部分aspnetcore6的源码,结合自己的理解,写了一个简单插件系统,那么今天就来简单聊聊技术上的具体实现吧。
在介绍插件服务实现之前,我们先了解一下,.Net这个大一统全平台的整体架构布局,为什么要了解这个呢?因为我们有很多早期Framework版本的后台任务,如461、462、472...,而且这些后台任务需要平移到插件系统里面,这就会涉及到版本的兼容问题,所以我们对底层需要有一定的知识储备,如图
上图就是我对.Net整体结构布局的一个简单理解,2020年之前.Netcore在BCL这层定义了Corefx,之后微软把它整合到了Runtime里面。在全新的.Net全平台里面,.Netframework任然是这个全平台上面的三大分支之一,没办法因为Winforms和Wpf这两个GUI框架还需要支持,而且以微软目前的操作,也没有跨平台的可能,所以这个分支应该会持续更新下去。往下就是标准库.Net standard,.NET Standard 是针对多个 .NET 实现推出的一套正式的 .NET API 规范。 推出 .NET Standard 的背后动机是要提高 .NET 生态系统中的一致性。简单来说它是Api接口规范,并且在BCL层提供了多平台复用(程序集级别的),给上层三大平台提供了强大的复用能力,这里有个细节需要注意,.Netstandard2.1不再支持.Netframework。再往下就是Runtime运行时了,包含了不同OS下的vm虚拟机,jit编译器...。Clrvm虚拟机其实也是一个运行在操作系统OS之上的一个程序,微软针对不同os实现了不同的虚拟机,虚拟机分两种,VMware类的完整指令集架构的虚拟机,另一种就是虚拟指令集架构的虚拟机,如我们的coreclr、jvm等,这个vm程序有点特别,它能给我们的.net程序提供运行时环境,也就是说我们编写的应用程序是跑在它这个大容器里面的。 Runtime运行时的能力,远不止我上面提到的这些,有兴趣的朋友可以自己去研究,题外话,其实在.Netframework时代,微软要实现跨平台能力其实还是蛮简单的,只需要实现不同平台的vm虚拟机,就连jit编译器大部分能力都能复用,可气的是微软在.Netframework诞生起就遵循了跨平台规范CLI,也设计了中间语言IL,是格局小了还是太过于自信了?有了以上对全平台.Net简单的认知,接下来我们通过一个简单实例,具体看看微软是如何做到多版本兼容的。
我们简单创建两个工程,一个core6.0的控制台,一个是framwork4.0的lib,通过ildasm打开app。
core6.0 // core工程直接打印在frmework4.0里面定义的list程序集名称 Console.WriteLine(ClassLibrary1.Class1.getAssemblyName()); Console.ReadLine(); framework4.0 lib // 定义 public static string getAssemblyName() { return typeof(List<string>).Assembly.FullName; } console // 打印结果 System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
以上代码非常简单,也做了简单说明,看到这个输出结果,是不是比较疑惑?这个System.Private.CoreLib程序集是被定义在Runtime的Coreclr里面的,理论上来说.Netframework下的List应该是被定义在一个叫mscorlib的程序集里面,带着这个疑问,我们通过ildasm对这个程序集看个究竟。
.assembly extern System.Runtime { .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....: .ver 6:0:0:0 } .assembly extern ClassLibrary1 { .ver 1:0:0:0 } .assembly extern System.Console { .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....: .ver 6:0:0:0 }
好家伙,System.Runtime在作怪,接着看看它的真面目,
// Metadata version: v4.0.30319 .assembly noplatform System.Runtime
不好意思,打开了引用程序集,这是一种特殊类型的程序集,简单理解为api就行了,接下来我们打开具体的实现程序集,理论上来说它也只是一个共享程序集。
// Metadata version: v4.0.30319 .assembly extern System.Private.CoreLib { .publickeytoken = (7C EC 85 D7 BE A7 79 8E ) // |.....y. .ver 6:0:0:0 } .assembly extern System.Private.Uri { .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....: .ver 6:0:0:0 } .assembly System.Runtime
好家伙,引用了System.Private.CoreLib、System.Private.Uri 两个程序集,这两程序集都是.Netcore上的私有核心程序集,源码在Coreclr里面,这是啥情况?难道我们.Netframework的程序集通过某种手段被转移到了当前运行时.Netcore?接着往下看这份代码。
.class extern forwarder System.Collections.Generic.IEnumerator`1 { .assembly extern System.Private.CoreLib } .class extern forwarder System.Collections.Generic.IEqualityComparer`1 { .assembly extern System.Private.CoreLib } .class extern forwarder System.Collections.Generic.IList`1 { .assembly extern System.Private.CoreLib }
看到这气人不?绕这么大圈子,最终通过forwarder技术转移到了CoreLib程序集,也就完成了我们的版本兼容问题,到此第一个问题集成老版本的后台任务理论有了支撑,接下来具体看看技术上的实现。
先简单介绍下整个技术栈吧,Aspnetcoremvc6.0、Linq2db、Mysql、Eventbus、Autofac、Hangfire...,技术栈大概就是这些,没有啥特别的,这里推荐一下ORM框架Linq2db,这个框架我也是第一次用,给我的感觉就是简单、开发效率高,整个插件系统目前实现的功能比较简单,支持ALC程序集隔离,插件和Host之间通信,插件Page路由,热插拔,插件管理(上传、安装、卸载、删除等等),子插件版本支持461-6.0,标准版全支持。我们通过一张简图,看看整个解决方案结构,下图就是插件服务工程结构图。
简单说明一下上图的目录结构,Plugins文件夹里面包含的是一些插件,里面写了几个不同版本库的Demo。Eventbus工程是一个极简的事件总线。Abstraction是插件抽象出来的接口,所有插件必须要引用该程序集,并且实现IPlugin接口。Core里面主要是一些核心实现,包括Assemblyloadcontext上下文,加载卸载,插件管理等。Data是对Linq2db访问Mysql的简单封装。Services工程里面包含的就是具体业务服务实现。Web就是UI层,标准的Aspnetcoremvc工程。
Plugins文件夹里面的插件必须包含一个自描述文件plugin.json,同时需要实现有如下定义的插件接口IPlugin。
public interface IPlugin { void Register(CancellationToken cancellationToken,params object[] args); void Configure(IApplicationBuilder applicationBuilder); void Stop(CancellationToken cancellationToken); }
以上就是IPlugin接口的全貌,定义了三个方法,分别为注册,停止,配置,这里简单提一下注册,因为我这边实现主要是后台定时任务,所以在Host主程序集会通过Hangfire定时任务来管理这些插件任务,这也就有了register方法。配置主要是提供PageMap路由,相对来说比较简单。插件有了,接下来我们需要把它动态加载到.Netcore运行时环境,并通过Host主程对其进行管理,也就是程序集级别的隔离,原来我们基于.Netframework对程序集的加载并隔离,我们可以通过Appdomain这么一个程序集来管理,在.Net6平台上面这个对象基本被弃用了,那么在.Net6里面运行时程序集加载隔离是怎么做呢?一个全新的上下文对象Assemblyloadcontext。Assemblyloadcontext简称alc,默认情况下,在当前主程运行时环境下面,会创建一个名叫default的alc(后续我直接简称),我们的Host主程序集里面的所有引用和依赖程序集会在Runtime初始化阶段完成加载。alc有个限制,单个 alc实例限制为每个简单程序集名称 AssemblyName.Name 只加载 Assembly 的一个版本,当动态加载程序集模块时,此限制可能会有一个问题,版本冲突。如插件A和B分别引用了三方两个不通版本的程序集,那怎么解决?好在微软帮我们实现了同一个运行时环境支持多个alc管理上下文,这样我们就可以通过创建自定义alc上下文来完成我们的插件加载问题。
private Assembly LoadPluginAssembly(Func<PluginConfig> action, string pluginId) { // 其他代码 var pluginConfig = action(); // 插件配置 var guid = Guid.NewGuid().ToString("N"); var contextName = $"{pluginConfig.MainPluginName}-{guid}"; var context = new LoaderContext(pluginConfig.BasePluginPath, pluginConfig.MainPluginlyPath, contextName, _defaultLoadContext); // 创建插件上下文实列 if (pluginConfig.SharedAssemblies.Any()) foreach (var sharedAssmbley in pluginConfig.SharedAssemblies) // 加载共享程序集 context.LoadSharedAssembly(sharedAssmbley); var pluginAssembly = context.LoadAssemblyFromFilePath(pluginConfig.MainPluginlyPath); // 加载插件程序集 _contexts.TryAdd(pluginId, context); // 添加到字典 return pluginAssembly; }
以上代码就是插件程序集加载的主逻辑,做了相应注释,基于aspnetcore6简单的插件系统大概就完成了。上面注释里面提到了共享程序集,共享程序集其实就是把整个插件系统需要用到的程序集加载到Default的alc上下文里面,给整个插件系统环境提供程序集共享。如插件与Host主程序集,插件与插件之间需要交互,这里面就会涉及到一个问题,同类型转换的问题。在插件初始化阶段,host主程序集里面需要创建插件的实列并转换为IPlugin接口,如果IPlugin接口所在的程序集被每个插件的alc加载了(包括默认alc也加载了自己的Plugin程序集),那么在Host主程序集里面是无法完成IPlugin和插件之间的转换,在alc看来他们不是同一个类型。有了共享程序集,那么共享程序集是怎么提供服务的呢?插件服务首先会通过assemblyName在自己的alc上下文加载,如果没有,此时会从default的alc上下文里面加载,如果有直接返回,没有则返回null。
整个插件系统的技术实现大概就写这么多吧,功能可能有点少,毕竟我们目前的需求也就是实现后台任务的统一部署管理,后期有时间可能会考虑把插件功能完善,如插件支持独立的web站点,支持完整的路由系统,支持view视图的预编译和运行时编译等等。其实实现这些功能都不难,有兴趣的朋友研究一下aspnetcore6的源码,aspnetcore6框架整个view视图编译这块还是比较复杂的,同时它还支持两种编译,所以需要借助源码,找到视图加载、更新、编译的机制,找到合适的切入点,好了就聊这么多吧。