
简介C#动态加载与动态编译是构建插件系统、模块化应用和热更新机制的核心技术。这套实例源码面向有一定C#基础、希望深入掌握程序集反射与运行时代码生成的开发者解决了“如何在程序运行过程中动态识别并执行尚未编译或新加入的代码”这一典型问题。资源完整演示了通过Assembly反射加载外部dll、使用CSharpCodeProvider与CompilerParameters在内存中编译源码、再以反射调用动态类型和方法的全流程。资源共18个文件8个C#源文件与3个资源文件构成主要逻辑辅以项目工程配置、窗口图标以及可直接运行的exe示例压缩包整体仅82KB结构精炼适合直接在Visual Studio中打开查看。已有395人学习该资源。通过实例可以快速理清Assembly.LoadFrom与CompileAssemblyFromSource的配合方式同时掌握常见编译报错的定位与处理思路借助WinForm演示界面开发者还能直观看到动态类如何被实例化并输出结果为后续编写插件框架或脚本化扩展提供了实用参考。 做上位机或者工控软件的兄弟一定遇到过这个尴尬程序在客户现场跑得好好的突然说要改个检测阈值、换一种PLC的通讯方式你只能停掉整个客户端、重新编译、再拷一遍DLL过去。要是客户产线不能停那就得等夜班或者周末一来二去一个“小改动”也能耗掉半天。C#里的动态加载就是专门对付这种场景的。动态加载说白了就是让程序在运行过程中才去读取程序集DLL、创建对象、调用方法而不是编译期就把所有逻辑焊死在一起。这样主程序可以保持稳定需要变化的业务逻辑通过外部DLL、配置文件甚至一段C#代码字符串随时替换和更新。这篇内容我会结合一个上位机场景把从反射加载、程序集扫描到CSharpScript动态编译的完整玩法拆开讲并给出可以直接抄走的实例源码。适合正在做上位机集成、插件化架构、或者想理解C#反射机制的人参考。1. 为什么需要动态加载需求场景与技术路线1.1 先理解“编译期绑定”和“运行期绑定”的差别咱们平时写的C#代码大部分是静态编译的。你在代码里 new 一个类调用它的方法编译时编译器就把这些调用关系定死了。这个模式在单体应用里没什么问题但一旦到了产品需要频繁定制、算法需要快速迭代的场景就会非常难受。我举个典型例子。一套视觉检测上位机主窗口负责相机采集、图片显示、数据报表检测算法是单独一个DLL。客户A要求用边缘梯度算法客户B要求用灰度匹配客户C昨天说精度不够想换成深度学习模型。如果你的程序是硬编译的每次都得改引用、重新发布整个安装包现场升级还容易出岔子。但如果你用动态加载把检测算法做成插件放到Plugins目录只需把新的算法DLL丢进去配置文件里写死加载哪个类主程序不用动重启就能生效。这个过程不是“把类库改成动态链接库”这么简单关键区别在于运行时程序才去查类型、建对象、绑方法。C#里这个过程有一套完整机制支撑核心就是System.Reflection命名空间和程序集加载器。1.2 两条主流路线反射加载程序集与脚本编译动态加载具体怎么做业内基本分成两条路线各有利弊。第一条是反射加载程序集。你先把插件类编译成DLL主程序运行后用Assembly.LoadFrom或Assembly.LoadFile把DLL读进内存再用Type.GetType拿到目标类型最后用Activator.CreateInstance创建对象。优点是执行效率和原生代码完全一样适合算法、通讯、业务服务这类对性能有要求的模块缺点是插件必须预先编译好而且要处理好版本、依赖和接口约定。第二条是脚本动态编译。用Roslyn的CSharpScript可以把一段C#代码字符串直接编译执行。适合规则表达式、公式计算、临时判断逻辑。好处是业务人员改个规则可以不用重编译DLL比如把“当温度大于80度时报警”这条规则做成脚本存数据库缺点是每次执行都要走一遍编译管线性能差一些各种调试、类型检查也不如写死的代码舒服。这两条路不是互斥的很多时候可以混着用。比如主体功能用反射程序集加载某些细粒度配置用CSharpScript表达式处理。下面这张表是我选型时的参考依据技术方案运行效率部署灵活性适用场景上手成本Assembly.LoadFrom 反射高中需预编译DLL插件化、模块化业务低CSharpScript 动态编译低高纯文本随时改规则引擎、表达式计算中AssemblyLoadContext 隔离加载高高支持热卸载大型插件系统高2. 程序集加载的核心Assembly、Type与反射机制2.1 Assembly加载器是怎么工作的在C#里程序集是代码编译后的物理载体也就是DLL或EXE文件。程序集里面装着若干个类型Type类型里面又包含方法、属性、字段等元数据。反射做的事情就是在程序运行的时候反向去解析这些元数据。加载程序集常用三个方法Assembly.Load、Assembly.LoadFrom、Assembly.LoadFile。其中Assembly.LoadFrom是实战中最常用的它会根据路径自动加载程序集并且会尝试去探测依赖项的位置比如同目录下被引用的其他DLL。Assembly.LoadFile的语义是不加载依赖只加载指定那一个文件看起来简单实际踩坑不少APPHost和类型解析经常对不上新手不推荐。// 最常见加载方式按物理路径加载 Assembly assembly Assembly.LoadFrom(D:\Plugins\MyAlgorithm.dll); // 拿程序集里所有类型 Type[] types assembly.GetTypes(); foreach (Type type in types) { Console.WriteLine($发现类型{type.FullName}); }这么一跑MyAlgorithm.dll里所有的public类都会列出来。拿到Type对象之后接下来就是最关键的“造对象”环节了。2.2 Activator.CreateInstance 与构造函数的那些事Type只是类型描述真正要调用它得把它变成实例。Activator.CreateInstance干的就是这个活儿。它本质上是根据Type里的元数据信息动态调用构造函数来创建对象。// 拿到类型 Type targetType assembly.GetType(MyAlgorithm.DefectDetector); // 创建一个无参构造函数的实例 object obj Activator.CreateInstance(targetType);这里有个明显的坑CreateInstance默认调用的是无参构造函数。如果你插件类里构造函数需要传参数就会抛MissingMethodException。解决方案是重载一个重载版本把参数作为object数组传进去object[] args new object[] { config_001.xml, 88.5f }; object obj Activator.CreateInstance(targetType, args);我自己的习惯是插件类一律只留无参构造函数配置参数通过Init方法或者公共属性来传递。这样反射调用的形态是统一的逻辑也清晰不会因为构造函数签名五花八门导致加载器没法通用。2.3 为什么必须有接口契约很多初学者做动态加载时只加载程序集然后直接反射调用方法写出来的代码全是字符串方法名改一个方法名就要连带改加载器维护起来很痛苦。正确的做法是定义一个公共接口主程序和插件都引用它插件类实现接口主程序加载完直接强转接口调用。接口契约就像是USB接口的规范。鼠标、键盘、U盘外形各异但只要它们遵守同一套USB协议插上电脑就能用。插件可以不限数量、不限功能但它必须实现IPlugin接口主程序才能用统一的方式驱动它。3. 实例源码一个可复制的插件化加载方案3.1 定义插件契约接口新建一个类库项目命名为PluginContract不需要任何第三方依赖只放接口定义namespace PluginContract { public interface IPlugin { // 插件名称比如“缺陷检测算法” string Name { get; } // 插件描述说明这个插件是干什么的 string Description { get; } // 统一执行入口input为传入参数返回处理结果 string Execute(string input); } }这个项目编译出来的PluginContract.dll只有几KB主程序和插件都会引用它。它是整个动态加载体系的“公约”。3.2 写一个真正的插件模拟视觉检测算法新建一个类库项目命名为VisionPlugins引用PluginContract。这里我做了一个模拟的缺陷检测插件故意在构造函数里打印初始化日志方便观察加载过程using System; using System.Threading; using PluginContract; namespace VisionPlugins { public class DefectDetectPlugin : IPlugin { public DefectDetectPlugin() { Console.WriteLine(DefectDetectPlugin 正在初始化...); } public string Name 缺陷检测算法; public string Description 基于边缘梯度的缺陷定位算法; public string Execute(string input) { // 模拟算法耗时真实场景这里会调用图像处理SDK Thread.Sleep(120); return $缺陷检测完成图源{input}判定结果OK置信度98.2%; } } }编译这个项目得到VisionPlugins.dll。为了让主程序好找我在bin目录下建了一个Plugins文件夹专门放插件DLL。3.3 写通用加载器扫描、创建、转换一条龙加载器是核心。它要做四件事把DLL加载进内存、按类型名拿到Type、检查是否实现了IPlugin接口、创建实例并强转接口。using System; using System.IO; using System.Reflection; using PluginContract; namespace PluginHost { public class PluginLoader { /// summary /// 加载程序集并创建插件实例 /// /summary /// param nameassemblyPathDLL文件路径/param /// param nametypeName完整类型名含命名空间/param /// returnsIPlugin实例/returns public IPlugin Load(string assemblyPath, string typeName) { // 第1步把DLL加载到当前AppDomain Assembly assembly Assembly.LoadFrom(assemblyPath); // 第2步按完整类型名从程序集元数据中提取Type Type type assembly.GetType(typeName); if (type null) { throw new Exception($在程序集 {assemblyPath} 中找不到类型 {typeName}); } // 第3步校验类型是否实现了IPlugin接口避免强转时报错 if (!typeof(IPlugin).IsAssignableFrom(type)) { throw new Exception(${typeName} 没有实现 IPlugin 接口); } // 第4步创建实例并强转接口 IPlugin plugin (IPlugin)Activator.CreateInstance(type); return plugin; } } }这里第3步的IsAssignableFrom容易被忽略但在我看来必须加。因为Assembly.GetType只是找到类型并不能保证它能塞进IPlugin这个“模子”。没有这层校验一旦某个DLL里的类没实现接口强转时直接抛InvalidCastException错误信息非常难定位。前置校验至少能把异常信息写得明明白白。3.4 主程序调用一行代码拉起来主程序引用PluginContract和PluginLoader然后写一个控制台入口验证效果using System; using System.IO; namespace PluginHost { class Program { static void Main(string[] args) { string dllPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Plugins, VisionPlugins.dll); PluginLoader loader new PluginLoader(); try { IPlugin plugin loader.Load(dllPath, VisionPlugins.DefectDetectPlugin); Console.WriteLine($插件名{plugin.Name}); Console.WriteLine($插件描述{plugin.Description}); string result plugin.Execute(camera_20240607153000.jpg); Console.WriteLine($执行结果{result}); } catch (Exception ex) { Console.WriteLine($加载失败{ex.Message}); } Console.ReadKey(); } } }跑起来之后控制台会依次输出“DefectDetectPlugin 正在初始化...”、插件名、插件描述、执行结果。后面不管客户想换哪个算法你只需要让插件实现IPlugin接口、丢到Plugins目录然后改一下配置文件里的类型名就能热切换。3.5 动态编译一段C#代码CSharpScript实战如果只是加载本地DLL还没完全发挥“动态”的威力。再来看看CSharpScript。它属于Roslyn编译器技术能把代码字符串在运行时编译成程序集再执行。需要安装NuGet包Microsoft.CodeAnalysis.CSharp.Scripting。场景比如工业现场有一条规则“温度超过80度且湿度低于30%时触发声光报警”。这种规则变化频繁写死在代码里不合适放在配置文件里又没法做复杂逻辑。最实用的做法就是存一段C#表达式或脚本片段改动它甚至不需要重启程序using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; public class RuleEngine { public class RuleContext { public double Temperature { get; set; } public double Humidity { get; set; } } public async Taskbool Evaluate(string expression, RuleContext context) { // 把C#代码字符串编译并执行传入全局上下文 bool result await CSharpScript.EvaluateAsyncbool(expression, globals: context); return result; } }调用方式这样写RuleEngine engine new RuleEngine(); string expr Temperature 80 Humidity 30; RuleEngine.RuleContext data new RuleEngine.RuleContext { Temperature 85.5, Humidity 22 }; bool shouldAlarm await engine.Evaluate(expr, data); Console.WriteLine($是否触发报警{shouldAlarm});这里不用if、不用switch直接把表达式作为字符串传进去引擎就帮你算出结果。需要注意EvaluateAsync的返回值类型是泛型参数写bool就得到bool写int就得到int而且脚本的最后一条表达式就是它的返回值不需要写return关键字。4. 常见问题与排查技巧实录动态加载看着不难实际用起来坑比想象中多。我把自己踩过的和帮别人排查过的典型问题整理成一张速查表报错或现象原因解决办法FileLoadException未能加载文件或程序集依赖的DLL不在插件目录把依赖DLL拷到插件旁边或用探测路径FileNotFoundException找不到指定文件程序集路径不对或依赖项缺失检查路径拼接用Path.Combine不要手工拼字符串InvalidCastException无法将类型转换为IPlugin主程序和插件引用了不同版本的程序集统一引用同一套PluginContract.dll或者用接口项目单独编译更新DLL时报“文件正由另一进程使用”主程序已经锁定DLL文件使用AssemblyLoadContext实现程序集隔离与卸载重复加载出现两个同名类型LoadFrom重复加载同一程序集用Assembly.Load或判断是否已加载避免重复装载4.1 程序集锁定问题动态更新的头号敌人经典场景客户现场要升级算法你把新DLL拷到Plugins目录系统提示“文件正在被另一进程使用无法替换”。这是因为默认的Assembly.LoadFrom会把程序集加载进默认的LoadContext一旦加载DLL文件就被进程锁住了删不掉也覆盖不了。这在桌面应用里尤其明显。一个治标不治本的办法是重启程序再覆盖但如果程序里有长时间占用的设备资源重启成本很高。治本的办法是.NET Core/.NET 5里用AssemblyLoadContext创建一个可收集的上下文专门用来加载插件这样能单独卸载程序集。代码如下using System.Runtime.Loader; var loadContext new AssemblyLoadContext(PluginContext, isCollectible: true); Assembly assembly loadContext.LoadFromAssemblyPath(dllPath); // ... 使用完插件后 loadContext.Unload();注意要真正释放DLL文件必须确保所有该程序集创建的实例都被释放否则Unload会失败。而且程序集一旦卸载里面创建的对象引用就失效了不能再用。这套机制适合插件系统但复杂度上升不少如果只是偶尔更新直接重启进程加覆盖DLL是最快的方案。4.2 接口版本不一致思维盲区我自己犯过的一个错插件项目引用的PluginContract.dll版本比较旧主程序引用的是新版本两边都编译通过但运行时强转IPlugin永远失败。因为CLR加载程序集是全名的版本号不同就认为是两个不同的程序集。一边的接口和另一边的接口即使长得一样运行时也不会认为是同一个类型。解决办法有两个。要么把PluginContract单独管理版本严格统一不许随便变更要么在插件和主程序之间通过字符串方法来解耦但这又回到了维护地狱。我的建议是第一种把接口项目当API对待变更必须走版本评审。4.3 动态编译脚本的性能与安全注意CSharpScript用起来很爽但不是万能的。我测试过一段简单表达式的首次编译耗时在几十毫秒到上百毫秒不等虽然之后有缓存但高频调用场景仍然不适合。更需要注意的是让你的人随便改脚本等于把代码执行能力开放给他们安全问题必须考虑。内网工具可以适度放开外网系统千万别这么干这是底线。如果担心首次编译的延迟可以用ScriptCache把脚本字符串作为Key编译好的Script对象缓存起来只执行不重复编译。这样既能保持灵活性又能把后续调用开销压下去。5. 一点实操经验我自己做上位机项目总结出一个习惯先定接口再写实现最后才考虑加载方式。很多人一上来就想用反射去“黑魔法”调用所有方法结果集成的时候到处遇到类型转换异常。你只要把接口设计得足够稳定后续的反射、加载、脚本替换都只是实现细节。如果你想把这个方案落地到实际项目建议从今天开始就把可变的业务逻辑抽到IPlugin后面主程序里哪怕只有一个ExternalCall入口也好。以后不管是加算法、换通讯协议还是改判定规则都是往某个目录丢DLL的事上线效率会上来一大截。另外动态加载的代码里务必加详细日志打印出加载路径、程序集版本、类型全名这样现场出问题你才能远程指导客户定位不然光靠报错信息猜太折磨人。本文还有配套的精品资源点击获取