简介: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 name="assemblyPath">DLL文件路径</param> /// <param name="typeName">完整类型名(含命名空间)</param> /// <returns>IPlugin实例</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 Task<bool> Evaluate(string expression, RuleContext context) { // 把C#代码字符串编译并执行,传入全局上下文 bool result = await CSharpScript.EvaluateAsync<bool>(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的事,上线效率会上来一大截。另外,动态加载的代码里务必加详细日志,打印出加载路径、程序集版本、类型全名,这样现场出问题你才能远程指导客户定位,不然光靠报错信息猜,太折磨人。
本文还有配套的精品资源,点击获取