ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C#动态加载代码实战:反射、Roslyn与AssemblyLoadContext全解析

C#动态加载代码实战:反射、Roslyn与AssemblyLoadContext全解析 简介C#动态加载代码是构建插件系统与模块化应用的重要技术。这份资源面向具备基础C#语法、希望掌握反射与动态编译的开发者提供一套可直接运行的WinForm实例源码解决运行时按需加载、编译与执行代码的需求提升应用灵活性。资源共18个文件以8个.cs核心源码为主配合resx界面资源、ico图标及完整工程配置文件压缩包仅82KB轻量便于快速部署研读。已有395人学习下载。代码内部通过Assembly.LoadFrom演示程序集加载并借助CSharpCodeProvider与CompilerParameters实现字符串代码的内存编译涵盖错误收集和调用逻辑。编译后的Assembly可进一步反射调用与实例化便于二次封装。工程包含多个窗体文件清晰展示动态执行代码的交互流程与界面设计。通过调试实例可直观观察动态程序集从生成、加载到调用的完整链路理解编译错误处理与反射调用的关键细节。实例虽然小巧但结构完整既是教学演示的良好范本也可作为插件式架构的起步模板。 其实真正的避坑内容被瀑布式填在正文里了。我先从实际场景说起。做C#上位机开发的人早晚会碰上一个需求客户说“这个功能下周才能定你先做整体框架”或者“算法那边还在调你留个口子让我下个月把新逻辑放进去”。如果你还在用“改代码—重编译—让现场停线—把整个EXE替换掉”这套流程一次两次还行三次四次客户就不再信任你了。动态加载代码就是专门解决这种“系统运行中把新代码塞进去”的问题。这篇文章围绕C#动态加载代码的实例源码来写覆盖三种典型实现路径反射加载DLL、Roslyn运行时编译源码、AssemblyLoadContext隔离加载与卸载。适合正在做上位机、设备控制、视觉检测、可扩展桌面应用开发的C#工程师尤其是项目已经出现插件化、模块化迹象但还没找到合适落地方式的朋友。1. 动态加载到底在解决什么问题——先把动机想明白1.1 不停机更新和插件化架构的刚需我见过太多上位机项目一开始只有一个EXE所有功能都堆在主程序里。通讯协议写死、视觉算法写死、流程逻辑写死。等设备出货到现场问题来了客户要求换一种扫码枪协议或者PLC地址表有变动你不得不抱着笔记本去现场重新发布整个程序还要祈祷现场操作员别在更新期间误触设备。动态加载代码的核心价值就是把“容易变的部分”从主程序里剥离出来变成一个个可以独立更新、独立替换的模块。主程序只需要定义好接口运行时就按照约定去找模块、加载模块、调用模块。这样一来通讯协议升级只替换通讯插件DLL视觉算法迭代只替换算法插件DLL流程逻辑调整只替换流程插件DLL主程序完全不用动现场停机时间从“半天”缩短到“几分钟”1.2 “加载代码”其实包含两个层面很多初学者听到“动态加载代码”第一反应是反射也就是Assembly.Load Activator.CreateInstance这套组合。没错这是最基础、最常用的一层。但严格来说动态加载代码还包含另一个层面在运行时把C#源码字符串编译成程序集再加载执行。前者是“加载已经编译好的DLL”后者是“现写代码现编译现执行”。两者有本质区别维度加载程序集运行时编译源码代码来源已编译DLL文件C#源码字符串核心APIAssembly.Load / Assembly.LoadFromRoslyn的CSharpCompilation典型场景插件化架构、模块热替换规则引擎、用户自定义脚本调试难度相对低相对高和反射的关系反射是调用手段编译完还是要反射调用这篇文章会两条线都讲透因为实际项目里你很可能两条线都要用。比如主程序用反射加载插件机制但插件内部又用Roslyn实现一些用户可编辑的规则逻辑。2. 动态加载的基础机制——程序集、反射、加载上下文先理清楚2.1 程序集是动态加载的最小单元C#代码编译后生成的是程序集Assembly对于桌面应用来说最直观的形态就是DLL文件。一个DLL内部包含元数据Metadata和中间语言IL。元数据里记录了这个程序集里有哪些类、哪些方法、哪些字段IL才是真正执行逻辑的地方。动态加载就是让CLR在运行时读取这些元数据并把IL交给JIT编译为本机代码执行。你能动态加载什么取决于这个程序集是不是“托管程序集”。像C写的那种原生DLL虽然有DLL后缀但里面没有.NET元数据不能用Assembly.Load直接加载。这也是很多上位机工程师第一次把C算法库改成插件机制时踩坑的地方——你想动态加载的那个东西根本不满足托管控件的条件。2.2 反射是连接加载与调用的桥梁加载一个DLL之后你面对的是一堆类型元数据。怎么实例化怎么调方法全靠反射。核心就这么几行// 加载程序集传完整路径或文件名 Assembly assembly Assembly.LoadFrom(D:\plugins\MyPlugin.dll); // 找到目标类型 Type pluginType assembly.GetType(MyPlugin.MainEntry); // 创建实例 object instance Activator.CreateInstance(pluginType); // 调用方法强制转换为接口后再调用而不是用InvokeMember var plugin (IMyPlugin)instance; string result plugin.Execute(hello);反射本身不复杂复杂的是“怎么组织你的代码让反射之后的调用变得干净”。这也是为什么我极度推荐用接口来做插件约定——主程序定义一个IMyPlugin接口插件DLL引用同一个接口程序集并实现它。这样反射只需要负责“加载程序集、找类型、创建实例”这一小段后续所有调用都走强类型接口代码可读性和可维护性会好很多。2.3 加载上下文一个容易被忽略的关键角色.NET程序集加载不是从磁盘读个文件那么简单CLR内部有一套复杂的加载逻辑。默认情况下你的主程序运行在一个默认加载上下文DefaultContext里。当你用Assembly.LoadFrom加载插件时它会带着自己的依赖进入这个上下文。问题来了如果两个插件依赖同一个DLL的不同版本或者主程序和插件引用了不同版本的某个公共库就容易出现版本冲突。更麻烦的是默认上下文里加载的程序集理论上卸载不掉。就算你把引用全部置空这个DLL也会一直占着文件句柄导致你无法覆盖它。要解决“可替换”必须用到AssemblyLoadContext——一会儿在第5章专门讲。3. 第一个实例扫描目录、加载插件DLL、反射调用——最经典的上位机插件架构3.1 先定义插件接口我习惯的工程结构是这样MainApp.exe // 主程序 PluginContract.dll // 接口程序集主程序和插件都引用 Plugins/ MyPlugin.dll // 具体插件 AnotherPlugin.dll接口程序集是关键它不包含任何业务逻辑只包含一组约定。比如这个最小例子namespace PluginContract; public interface IMyPlugin { string Name { get; } string Execute(string input); }注意接口这块程序集要尽量保持稳定。一旦接口变了主程序和所有插件都要同步编译更新那就失去热更新的意义了。3.2 主程序扫描Plugins目录加载并调用下面是一段能直接跑通的最小例程。它的逻辑很简单启动时扫描Plugins文件夹下的所有DLL逐个加载找出实现了IMyPlugin接口的类型实例化后调一遍using System.Reflection; using PluginContract; string pluginDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Plugins); if (!Directory.Exists(pluginDir)) { Console.WriteLine(Plugins目录不存在); return; } var plugins new ListIMyPlugin(); foreach (string dllPath in Directory.GetFiles(pluginDir, *.dll)) { try { Assembly assembly Assembly.LoadFrom(dllPath); Type[] types assembly.GetTypes(); foreach (Type type in types) { if (type.IsClass !type.IsAbstract typeof(IMyPlugin).IsAssignableFrom(type)) { var plugin (IMyPlugin)Activator.CreateInstance(type); plugins.Add(plugin); } } } catch (ReflectionTypeLoadException ex) { // 插件DLL依赖缺失时会出现该异常实际项目中通常只记录 Console.WriteLine($跳过 {dllPath}: {ex.LoaderExceptions[0]?.Message}); } catch (Exception ex) { Console.WriteLine($加载 {dllPath} 失败: {ex.Message}); } } foreach (var plugin in plugins) { Console.WriteLine($插件 {plugin.Name} 返回: {plugin.Execute(world)}); }这段代码相当于整个动态加载插件的骨架我后面的项目基本都在它基础上扩展。两个地方值得专门说一下第一个是Assembly.LoadFrom的路径问题。LoadFrom会把程序集添加到默认加载上下文同时会把DLL所在目录加入探测路径。这意味着如果两个插件都依赖同一个公共第三方库你最好保证它们引用的版本一致否则可能出现“你以为加载了A版本实际运行用了B版本”的诡异情况。第二个是GetTypes()的稳定性。你预期插件里都是合法类型但实际调试时经常遇到一个DLL里混了一些依赖其他缺失程序集的类型导致GetTypes抛出ReflectionTypeLoadException。所以这里的try-catch不是防御性写法是刚需。3.3 目录扫描的几个细节不要用Assembly.LoadFile除非你清楚它的后果。LoadFile不会把DLL所在目录加入探测路径也不会和同名的已加载程序集合并。两个插件如果都依赖同一个公共库而版本不同LoadFile会给你加载两份造成类型不匹配。加载前的DLL锁定问题。用LoadFrom加载DLL后文件会被锁定无法覆盖删除。开发调试阶段特别烦人每次改完插件想复制新版本到Plugins目录就被拒绝。解决思路要么是每次调试用一个副本目录要么干脆上第5章的AssemblyLoadContext。4. 更进一步用Roslyn动态编译并执行C#源码——从头写代码到运行只需要几行4.1 什么时候真的需要运行时编译说实话反射加载DLL能覆盖80%的场景。但有一个场景它搞不定如果你希望用户在不编写C#项目、不安装编译环境的情况下只给一段C#代码文本系统就能执行它那就必须用Roslyn在运行时编译。典型例子是设备流程规则、算法参数联动逻辑、产品换型时用户自定义的检测决策结构。我之前做过一个视觉检测上位机检测完一个产品后判定逻辑不是固定的——有的客户要求面积超限就NG有的要求定位偏移加灰度平均超阈值才NG。与其为每个客户改主程序不如把判定逻辑做成可编辑的源码文本用户界面里改代码保存后系统动态编译加载。本质上是把“产品判定”这块从系统发布周期中摘出去。4.2 CSharpScript与CSharpCompilation的选择Roslyn提供的API有两条路方式特点适用场景CSharpScript.EvaluateAsync轻量直接执行表达式或语句块不产生DLL文件简单表达式、短逻辑CSharpCompilation编译成完整的程序集可返回Assembly对象可加载执行复杂多类型、多次调用、需要缓存场景CSharpScript用起来爽但对复杂场景支持有限而且每次执行都要重新编译解析性能一般。CSharpCompilation虽然代码多一些但编译产物可以复用也更贴近“动态加载”这个主题。下面是一个用CSharpCompilation把源码字符串编译成程序集再反射调用的完整示例。这一步就用上了上一章的反射知识因为编译出来的东西终究还是一个Assemblyusing Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; using System.Reflection; public static class InMemoryCompiler { public static Assembly Compile(string code) { // 1. 解析源码 SyntaxTree syntaxTree CSharpSyntaxTree.ParseText(code); // 2. 指定目标框架的引用 string runtimeDir Path.GetDirectoryName(typeof(object).Assembly.Location); var refs new ListMetadataReference { MetadataReference.CreateFromFile(Path.Combine(runtimeDir, System.Private.CoreLib.dll)), MetadataReference.CreateFromFile(Path.Combine(runtimeDir, System.Runtime.dll)), MetadataReference.CreateFromFile(Path.Combine(runtimeDir, System.Console.dll)), MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }; // 3. 创建编译单元 CSharpCompilation compilation CSharpCompilation.Create( DynamicAssembly_ Guid.NewGuid().ToString(N), new[] { syntaxTree }, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); // 4. 编译到内存流 using var ms new MemoryStream(); EmitResult result compilation.Emit(ms); if (!result.Success) { var errors string.Join(Environment.NewLine, result.Diagnostics.Where(d d.Severity DiagnosticSeverity.Error)); throw new InvalidOperationException($编译失败:{Environment.NewLine}{errors}); } ms.Seek(0, SeekOrigin.Begin); return Assembly.Load(ms.ToArray()); } }调用时把用户输入的完整类代码交给Compile拿到Assembly后SOP就回到反射了string code using System; namespace UserRules { public class MyRule { public bool Check(double value) { return value 80; } } } ; Assembly asm InMemoryCompiler.Compile(code); Type ruleType asm.GetType(UserRules.MyRule); dynamic rule Activator.CreateInstance(ruleType); bool pass rule.Check(88.5);4.3 引用缺失是运行时编译的头号坑上面示例里refs列表只是“够跑通”的最小集合。实际项目中用户代码往往要访问自己的业务类库、Newtonsoft.Json、Log4Net等第三方库你需要把那些DLL路径一个个加进去。汇总来说就是程序集引用不是越多越好每个引用都会影响编译速度用AppDomain.CurrentDomain.GetAssemblies()遍历当前已加载程序集并转换引用是个省事的思路但要注意有些动态加载的程序集物理文件已不在预期路径编译报错的定位信息需要从Diagnostic里筛出有Location的行号信息否则用户面对错误信息会一头雾水5. AssemblyLoadContext——让插件真正可以卸载和覆盖的完整方案5.1 AppDomain时代的遗留问题.NET Framework时代大家想卸载动态加载的DLL只能通过创建额外的AppDomain在子域里加载再卸载整个AppDomain。这办法能工作但AppDomain之间互相通信要用MarshalByRefObject做代理跨域访问非常别扭而且一个进程创建一堆AppDomain的开销也不小。到了.NET Core / .NET 5微软提供了AssemblyLoadContextALC这是更彻底的方案。ALC允许你创建多个独立的加载上下文每个上下文可以自己解析程序集依赖关键是可以整体卸载从而释放文件句柄和内存。5.2 一个可卸载的插件加载器示例using System.Reflection; using System.Runtime.Loader; public class PluginLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; public PluginLoadContext(string pluginPath) : base(isCollectible: true) { _resolver new AssemblyDependencyResolver(pluginPath); } protected override Assembly? Load(AssemblyName assemblyName) { // 先尝试按插件目录解析依赖 string? path _resolver.ResolveAssemblyToPath(assemblyName); if (path ! null) { return LoadFromAssemblyPath(path); } return null; } protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { string? path _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); if (path ! null) { return LoadUnmanagedDllFromPath(path); } return IntPtr.Zero; } }把加载动作封装到这个Context里public class PluginHost : IDisposable { private PluginLoadContext? _context; private Assembly? _assembly; public void Load(string dllPath) { _context new PluginLoadContext(dllPath); _assembly _context.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); } public IMyPlugin? CreateInstance() { Type? type _assembly?.GetTypes() .FirstOrDefault(t typeof(IMyPlugin).IsAssignableFrom(t) t.IsClass !t.IsAbstract); return type null ? null : (IMyPlugin)Activator.CreateInstance(type); } public void Unload() { _context?.Unload(); _context null; _assembly null; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } public void Dispose() Unload(); }5.3 卸载不彻底通常是你自己的代码还抱着引用ALC的Unload不是同步立即生效的。它会标记上下文为不可用等待所有引用都被释放后由GC回收。所以Unload之后调用GC.Collect是必须的但也不是万能的。最常见的问题是插件实例还被某个静态字段引用着所有引用该实例的地方都必须置空插件内部创建的事件处理器挂在主程序的静态事件上这个委托会阻止卸载插件DLL里用了第三方的原生DLL原生层有没有释放资源也会影响整体回收写这类代码时我一般会在主程序侧维护一个ListIDisposable插件实现IDisposable先把业务资源释放干净再清引用最后才Unload。顺序乱了卸载就是碰运气。6. 动态加载实战中的高发问题排查——都是踩过的坑6.1 FileNotFoundException和FileLoadException要分开看很多人一看到这两个异常就晕。其实它们的含义完全不同异常含义常见根因FileNotFoundException找不到程序集依赖DLL没拷贝到运行目录、路径参数写错FileLoadException找到了但加载失败强命名程序集版本不匹配、已加载同名称程序集、CPU架构不匹配排查时先看InnerException60%的情况下根因在它里面。还有一点是事件查看器和Debug输出里常常有更有价值的绑定日志别只盯着异常消息。6.2 同一个DLL加载两次得到的是两个不同的类型这一点最容易被忽视。如果你用两个ALC分别加载同一份插件DLL或者先用默认上下文LoadFrom一次又用ALC加载一次那么同一个类名依赖的程序集会指向两套不同的类型对象。它们不会因为长得一模一样就被CLR认为是同一个类型。这在回调、事件、依赖注入场景下会引发莫名其妙的类型转换异常。解决办法是统一走同一个加载上下文或者想办法让主程序与插件共同依赖的程序集特别是接口程序集只在默认上下文中加载一次。一个偏方做法把PluginContract.dll放在主程序目录让ALC的Load方法对Contract开头的程序集返回null从而回退到默认上下文。6.3 插件引用版本冲突导致线上行为诡异插件A引用了Newtonsoft.Json 12.0插件B引用了13.0。默认托管上下文下加载顺序先到先得第二个插件加载时如果类型分布差异大编译期没问题运行期就可能遇到缺失方法或者行为差异。通用取舍是尽量让主程序统一管理公共依赖版本插件只写业务逻辑不自己打包第三方大件依赖。如果插件确实需要独立依赖就上ALC由插件自己解析这是最干净的路径。6.4 文件被占用覆盖不了插件DLL这是反射加载方案最讨厌的问题。代码在默认上下文里LoadFrom一次DLL整个进程生命周期内该文件都无法覆盖。调试时你改了插件代码编译输出拷不进Plugins目录只能重启主程序。解决办法排序如下开发期每次调试将插件复制到一个临时目录主程序指向临时目录加载交付期主程序捕获插件版本号用版本号做子目录隔离老版本目录留作回滚终极方案ALC 周期性检测文件变更 Unload重载实现不停机替换6.5 加载前做一次安全检查动态加载代码意味着你执行的代码可能来自第三方甚至用户自己。安全风险必须考虑。简单做法有要求插件DLL带强名称加载前验证公钥令牌计算DLL文件的SHA256哈希与白名单比对插件加载后启动在受限权限的线程上执行涉及文件、网络操作自行约束我见过不止一个上位机项目因为随意加载插件导致现场设备被一个带恶意代码的“测试插件”把系统参数改乱了。插件机制是方便但该做的防护不能省。7. 一段我自己反复用的插件宿主最小骨架最后分享一个我在多个桌面端项目中反复使用的骨架。它不算什么高深设计比很多开源插件框架轻量得多关键是没有多余的东西适合小团队自己把控。public class PluginManager : IDisposable { private readonly List(string Path, PluginLoadContext Ctx, IMyPlugin Instance) _plugins new(); public void LoadAll(string pluginDir) { UnloadAll(); foreach (string dll in Directory.GetFiles(pluginDir, *.dll)) { try { var ctx new PluginLoadContext(Path.GetFullPath(dll)); Assembly asm ctx.LoadFromAssemblyPath(Path.GetFullPath(dll)); Type? pluginType asm.GetTypes() .FirstOrDefault(t typeof(IMyPlugin).IsAssignableFrom(t) t.IsClass !t.IsAbstract); if (pluginType null) { ctx.Unload(); continue; } var instance (IMyPlugin)Activator.CreateInstance(pluginType); instance.Initialize(); _plugins.Add((dll, ctx, instance)); Console.WriteLine($[PluginManager] 已加载: {instance.Name}); } catch (Exception ex) { Console.WriteLine($[PluginManager] 加载失败 {Path.GetFileName(dll)}: {ex.Message}); } } } public void ReplacePlugin(string dllPath) { // 找到旧插件并移除 var old _plugins.FirstOrDefault(p p.Path dllPath); if (old.Ctx ! null) { _plugins.Remove(old); old.Instance.Shutdown(); old.Ctx.Unload(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } // 重新加载 var ctx new PluginLoadContext(Path.GetFullPath(dllPath)); Assembly asm ctx.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); Type? pluginType asm.GetTypes() .FirstOrDefault(t typeof(IMyPlugin).IsAssignableFrom(t) t.IsClass !t.IsAbstract); if (pluginType null) { ctx.Unload(); return; } var instance (IMyPlugin)Activator.CreateInstance(pluginType); instance.Initialize(); _plugins.Add((dllPath, ctx, instance)); Console.WriteLine($[PluginManager] 替换完成: {instance.Name}); } public void UnloadAll() { foreach (var p in _plugins) { p.Instance.Shutdown(); p.Ctx.Unload(); } _plugins.Clear(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } public void Dispose() UnloadAll(); }配套接口public interface IMyPlugin : IDisposable { string Name { get; } void Initialize(); void Shutdown(); }使用方只需要using var manager new PluginManager(); manager.LoadAll(D:\app\plugins);这个骨架扩充方向很清晰加配置热更新、加插件间通讯、加UI集成都可以基于这个结构做扩展。动态加载代码这块说实话没有太多高深的算法全靠基础API拼装真正决定成败的是你有没有把加载上下文、接口约定、卸载时机、依赖冲突这几件事想清楚。我早期做插件功能时也踩过文件锁、类型不匹配的坑后来把ALC方案用熟这些问题基本都被框架层面消化掉了。你上手之后建议先拿一个真实模块做试点别一上来就把整个系统拆成插件化否则调试成本会非常高。本文还有配套的精品资源点击获取
返回列表