简介:面向C#开发者的动态加载与动态编译实例源码包,聚焦运行时加载外部程序集和动态生成代码两大核心场景,适用于插件系统、模块化应用、自定义规则引擎等需要灵活扩展的项目。资源包共含18个文件,以8个.cs源码文件为主体,并包含项目解决方案文件、窗体资源、图标素材以及可直接运行的exe程序,压缩包仅82KB。实例覆盖了从Assembly.LoadFrom加载已有dll,到借助CSharpCodeProvider把字符串源码实时编译为程序集的关键路径,完整演示内存编译、错误收集、反射调用动态类型等操作,并配有WinForm界面便于输入代码和观察输出。已有395人学习下载,适合想快速上手C#动态编译、加深对反射机制理解的初中级开发者,既可作为入门范例,也能直接参考框架代码融入实际项目。工程结构完整,可直接打开调试并观察中间结果,适合边读边练。
1. 动态加载到底解决什么问题——先说清楚它值得你学
做了这么多年 C# 相关的东西,不管是桌面工具、上位机还是后端服务,我几乎在每个项目里都碰到过同一个需求:程序跑起来了,但我还想让它加载一段新代码。这听上去有点反直觉——代码不都是编译时定好的吗?还真不是。
C# 的动态加载,核心是拿反射(Reflection)和程序集(Assembly)机制做文章。简单说,你可以让程序在运行过程中,从某个 DLL 文件里把类型找出来、把方法调起来,甚至可以在运行时空编译生成一个全新的类。网络上那些“C#动态加载代码的实例源码”相关搜索,多半就是在找这套东西的落地写法。
那它到底能干什么?我举个最常碰到的场景:你做了一套上位机软件,客户今天说要接海康相机,明天说要接基恩士 PLC,后天又要加一个扫码枪。如果每加一个设备就重新编译一次、重新发布一次,那维护成本直接起飞。动态加载的方案是——把每种设备的通信逻辑单独做成一个 DLL,主程序启动时去指定目录扫描这些 DLL,动态加载并调用。以后加新设备,只需要往目录里丢一个新 DLL,主程序一行代码都不用改。
另一个典型场景是工控界的插件化架构,比如你有一个视觉检测框架,每个检测算法是独立的 DLL,业务流程里根据产品型号动态加载对应的检测插件。这比“一把梭”的把所有逻辑塞进主程序要优雅得多,也好维护得多。
这篇文章的目标读者,不是那种刚接触 C# 语法的小白,而是那些已经写过一些 C# 程序、但还没系统玩过反射和动态加载的人。你看完了,至少应该能独立实现:从磁盘加载一个 DLL,创建其中某个类型的实例,调用它的方法;更进一步,你还能用 Roslyn 在运行时把一段字符串代码编译成可执行的类型。这些都是非常实在的工程能力。
2. 反射机制:动态加载的地基
2.1 类型信息是怎么在程序集里存着的
要说动态加载,必须先聊反射,因为动态加载的底层几乎全靠反射撑着。C# 编译出来的程序集(DLL 或 EXE),里面不光有 IL 中间语言代码,还有完整的元数据(Metadata),这个元数据记录了每一个类型、每一个方法、每一个属性的名字、签名、访问级别等信息。
你可以把程序集想象成一本厚厚的字典,反射就是那个帮你查字典的工具。你不需要在编译时就“认识”某个类型,你只需要在运行时告诉反射:“给我找MyDll.MyPlugin这个类型”,它就能去元数据里检索,把类型的描述信息拿回来,然后你就能用这个信息创建对象、调用方法。
这里有一个新手很容易绕晕的概念:动态加载最终拿到的是一种“类型描述”,不是直接“new 出来的对象”。类似Type这个类型本身,就是运行时对某个 CLR 类型的描述。有了Type,你可以继续问它:“你的构造函数有哪些?”“你的Run方法在哪?”然后通过Activator.CreateInstance或者MethodInfo.Invoke去真正触发实例化和调用。
2.2 反射的性能开销没那么可怕
有些人对反射有偏见,觉得性能差,能不用就不用。这句话在“每秒调用上万次”的极端场景下是成立的,但在普通的上位机、管理软件场景里,反射的开销通常是可以忽略的。
那我实际怎么控制这个开销?我的做法是:反射只用在“加载”和“创建实例”这两步,一旦拿到对象实例,后续调用全部走接口或者抽象类的方法,不再碰反射。打个比方,你打电话给一个公司前台(反射)找人,前台帮你把目标同事叫到会议室(创建实例),之后你和这位同事直接面对面谈业务(接口调用),不会再每说一句话都跑一趟前台。
如果你非要追求极致性能,还有一个折中方案:用Expression表达式树把MethodInfo.Invoke编译成强类型委托,第一次反射之后把它缓存起来,后续直接调委托,速度可以逼近原生调用。这个思路在很多框架里都有应用,比如依赖注入容器就是这么做的。不过对绝大多数项目来说,这一步属于锦上添花,先把基础的搞明白更重要。
3. 从文件加载程序集——Assembly.LoadFrom 实操全流程
3.1 加载程序集的三种姿势,怎么选
C# 里加载程序集,最常用的是Assembly.LoadFrom、Assembly.LoadFile和Assembly.Load这三个。表面上都是“加载程序集”,行为差异非常大,选错会让你在排查问题时怀疑人生。
Assembly.LoadFrom:按路径加载程序集,会自动去加载目标程序集的依赖项(如果依赖项在同一个目录或在探测路径中能找到)。这是最省心的选择,也是我日常用得最多的。Assembly.LoadFile:只加载你指定的那一个文件,不会去解析它的依赖项,经常导致“加载成功了但一调用就报找不到依赖”的问题。我一般不推荐用它,除非你要加载的是完全自包含的独立程序集。Assembly.Load:按程序集名称(强名称)从全局程序集缓存或应用程序目录加载,适用于已经部署在已知位置的程序集,不适合动态目录扫描场景。
我在实际项目里,动态扩展模块基本都是LoadFrom一把梭,配合AssemblyResolve事件处理依赖项找不到的情况。后面第五部分我会详细展开讲。
3.2 一步步写出可运行的加载代码
为了让你能跟着操作,我给出一个最朴素的例子。假设你有一个类库项目PluginDemo,里面定义了一个接口:
namespace PluginDemo.Abstractions { public interface IPlugin { string Name { get; } string Execute(string input); } }然后你做了一个实现它的类库SamplePlugin:
using PluginDemo.Abstractions; namespace SamplePlugin { public class HelloPlugin : IPlugin { public string Name => "HelloPlugin"; public string Execute(string input) { return $"Hello, {input}! 当前时间:{DateTime.Now:HH:mm:ss}"; } } }现在,主程序要动态加载这个 DLL 并调用它:
using System; using System.Linq; using System.Reflection; using PluginDemo.Abstractions; class Program { static void Main(string[] args) { string dllPath = @"D:\PluginOut\SamplePlugin.dll"; // 1. 从文件加载程序集 Assembly assembly = Assembly.LoadFrom(dllPath); // 2. 在程序集中查找实现了 IPlugin 接口的类型 Type pluginType = assembly.GetTypes() .FirstOrDefault(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract); if (pluginType == null) { Console.WriteLine("没有找到实现 IPlugin 接口的类型"); return; } // 3. 创建实例 IPlugin plugin = (IPlugin)Activator.CreateInstance(pluginType); // 4. 调用业务方法 string result = plugin.Execute("张三"); Console.WriteLine($"插件名称:{plugin.Name}"); Console.WriteLine($"执行结果:{result}"); } }这段代码虽然简单,却把动态加载最核心的链路走通了:加载程序集 → 扫描类型 → 创建实例 → 接口调用。
有一点必须提醒你,主程序项目必须引用PluginDemo.Abstractions这个接口程序集,否则typeof(IPlugin)拿不到类型定义,后面整个IsAssignableFrom的判断就会失效。原因很简单:主程序和插件需要共享同一个接口程序集的“身份”。如果两边各自编译了一份相同命名空间的接口,在运行时 CLR 会认为它们是不同的类型,强制转换直接抛InvalidCastException。这也是很多新手第一次写动态加载最容易卡住的地方。
3.3 扫描目录里的所有插件
实际项目里你不可能每次加载都写死一个路径。更常见的做法是:固定一个插件目录,程序启动时扫描目录里所有 DLL,找到所有实现某个接口的类型。这段扫描代码我放在一个工具类里:
public static class PluginLoader { public static List<IPlugin> LoadPlugins(string pluginDirectory) { List<IPlugin> plugins = new List<IPlugin>(); if (!Directory.Exists(pluginDirectory)) { Directory.CreateDirectory(pluginDirectory); return plugins; } foreach (string dllFile in Directory.GetFiles(pluginDirectory, "*.dll")) { try { Assembly assembly = Assembly.LoadFrom(dllFile); var pluginTypes = assembly.GetTypes() .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract && t.IsClass); foreach (Type type in pluginTypes) { IPlugin plugin = (IPlugin)Activator.CreateInstance(type); plugins.Add(plugin); } } catch (Exception ex) { // 单个插件加载失败不要影响其他插件 Console.WriteLine($"加载 {dllFile} 失败:{ex.Message}"); } } return plugins; } }注意这里我在foreach里捕获异常,这是一个非常细节但极其重要的点——一个插件写崩了,不能把整个主程序拖死。我在某个工业项目里就是吃了这个亏,有个插件 DLL 在开发机上好好的,部署到现场 Win7 工控机上因为缺 VC++ 运行库直接抛异常,整个主程序启动闪退。后来把异常隔离在单个 DLL 级别,其他插件照常加载,故障定位也容易得多。
4. 更进一步——用 Roslyn 在运行时把字符串变成会跑的代码
4.1 什么场景需要运行时编译而不是加载 DLL
反射加载 DLL 虽然好用,但它有一个前提:插件代码必须先编译好。有些场景连这个前提都满足不了,比如用户想在软件界面里填一段公式让程序执行,又不想让软件集成了一个脚本语言解释器。这时候,直接在运行时把一段 C# 代码编译并执行就是一条更顺的路。
有人可能会说:“那我直接用 JavaScript 引擎不就行了?”可以,但有些项目里你希望保持整个技术栈统一,尤其是这套系统本身就有大量 C# 业务对象,你希望用户写的表达式能直接操作这些业务对象,C# 语法天然贴合。还有的情况是,配置文件或规则库里存了一段 C# 表达式,需要即时执行。这些场景就是 Roslyn 的用武之地。
4.2 引用 Microsoft.CodeAnalysis.CSharp 包
Roslyn 是 C# 的编译器平台,微软出品,你可以理解成“把编译器当库用”。用 NuGet 搜Microsoft.CodeAnalysis.CSharp,装进项目就能开始写代码。
创建一个最简单的“代码编译执行器”:
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using System; using System.Collections.Generic; using System.Linq; using System.Reflection; public static class RoslynRunner { public static T Run<T>(string code, string className, string methodName, object[] args = null) { // 1. 创建语法树 SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code); // 2. 收集引用程序集 var references = new List<MetadataReference> { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), MetadataReference.CreateFromFile(typeof(Activator).Assembly.Location) }; // 3. 编译 CSharpCompilation compilation = CSharpCompilation.Create( "DynamicAssembly", new[] { syntaxTree }, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using (var ms = new MemoryStream()) { var result = compilation.Emit(ms); if (!result.Success) { var errors = string.Join(Environment.NewLine, result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)); throw new Exception($"编译失败:{Environment.NewLine}{errors}"); } ms.Seek(0, SeekOrigin.Begin); Assembly assembly = Assembly.Load(ms.ToArray()); Type type = assembly.GetType(className); object instance = Activator.CreateInstance(type); MethodInfo method = type.GetMethod(methodName); return (T)method.Invoke(instance, args); } } }然后你就可以这样调用,把代码当普通字符串传进去:
string code = @" using System; namespace UserCode { public class Calc { public int Add(int a, int b) { return a + b; } public string Greet(string name) { return $""你好,{name}""; } } }"; int sum = RoslynRunner.Run<int>(code, "UserCode.Calc", "Add", new object[] { 3, 5 }); Console.WriteLine(sum); // 输出 8这里有一个细节值得多说一句:MetadataReference.CreateFromFile这一步非常关键,它决定了你动态编译出来的代码能“看到”哪些程序集。上面例子只加了一小部分,如果你要用的类型在某个 DLL 里,别忘了把那个 DLL 也加进引用列表,否则编译会报“找不到类型或命名空间”之类的错误。
我实际做上位机时,曾经用这个方案做了一套“公式编辑器”,客户可以自己写简单的数据处理逻辑,不用改主程序。他们里不少人没写过代码,但照着模板填几行还是会的,效果出乎意料的好。当然,这也带来一个安全问题——运行别人提供的代码等于让别人在你的程序里裸奔。如果做的是面向外部用户的软件,一定要做权限控制,最好放在沙箱里跑。如果是内网工控软件,客户都是自己人,风险相对可控,但你还是要在文档里写清楚:这个功能等同于远程代码执行,谨慎对外开放。
5. 插件化架构——动态加载发扬光大的地方
5.1 插件目录怎么规划不会乱
当你决定用动态加载做插件架构时,千万别把主程序和插件程序集混在一个输出目录里。混在一起的问题在你调试的时候就会冒出来:明明改了插件代码重新编译了,程序加载的却是旧 DLL,因为Assembly.LoadFrom有缓存,同一个路径的程序集不会重新加载。如果你把插件放在单独的plugins子目录里,更新插件时直接替换 DLL,问题就清晰很多。
一个典型的目录规划长这样:
MyApp/ ├── MyApp.exe ├── MyApp.exe.config ├── MyApp.Core.dll ├── plugins/ │ ├── CameraPlugin.dll │ ├── MotionPlugin.dll │ └── VisionPlugin.dll └── logs/每次启动时扫plugins目录,加载有效插件。更新插件时,直接把新的 DLL 复制进去覆盖掉就行。有的朋友会把版本号放在文件名里,比如CameraPlugin_1.2.0.dll,这也是一种做法,但会带来一个麻烦:旧版本文件不会自动清理,目录里的 DLL 会越积越多。我建议要么用固定文件名,要么在扫描时做版本过滤,按需选择。
5.2 让主程序和插件之间咬合得更稳
插件架构里最容易出问题的,就是“接口程序集”的版本一致性。假如主程序引用了IPlugin接口版本 1.0,而某个插件项目里引用的IPlugin接口是 2.0,那你加载这个插件时九成会失败。解决方案有两种:
一种是把接口层单独抽到一个稳定程序集里,主程序和插件都引用它,以后接口更新走“新增”而不是“修改老接口”的路线,尽量保持二进制兼容。另一种是用配置文件做强名称绑定,指定版本范围,但配置强名称又是一门学问,对小型项目来说性价比不高。我个人实践下来,把接口抽象层做得小、做得稳、尽量不变,是成本最低的路子。
插件里如果依赖了第三方库(比如某个工控厂商的 SDK),那这个第三方库也要出现在插件目录或者能被探测到的地方。主程序探测依赖路径包括:主程序所在目录、插件所在目录、当前工作目录、全局程序集缓存。最省心的做法是把第三方库也复制到plugins目录下,和插件 DLL 放一起。
5.3 热卸载是怎么回事,为什么它很麻烦
动态加载有一个绕不开的痛点:Assembly.LoadFrom加载进来的程序集不能卸载。你调用AppDomain.Unload也不行,除非你把整个程序集放在一个独立的AppDomain(应用程序域)里,然后卸载整个域。
.NET Framework 时代,插件系统做热卸载的标准姿势是“为每个插件创建一个子 AppDomain”,用完之后AppDomain.Unload把它整个卸掉。但这件事的复杂度不低:跨越 AppDomain 边界传递对象要经过“封送”(Marshal)处理,很多类型不能直接在域间传,调试起来也麻烦。到了 .NET Core / .NET 5+ 的时代,AppDomain虽然还在,但官方主推的方案变成了AssemblyLoadContext。
AssemblyLoadContext是更现代的加载上下文机制,它允许你创建一组可卸载的加载上下文。简单说,你可以把插件加载到一个独立的AssemblyLoadContext里,用完之后调用Unload()卸载。我在 .NET 6 的项目里试过这个方案,确实能解决热插拔问题,但前提是你的代码里没有把插件类型的实例到处乱传,否则底层引用没释放,卸载会失败。
热卸载是一个相当高级的话题,如果你的程序只是启动时加载一次插件,运行期间不需要动态增删插件,那么完全没必要碰AssemblyLoadContext,老老实实用Assembly.LoadFrom就好。
6. 避坑实录——类型加载异常、依赖地狱与版本冲突
6.1 最常见的三个异常,各自怎么破
第一个是FileNotFoundException。你以为它只是“找不到文件”,但在动态加载的上下文里,它往往不是“DLL 不存在”,而是“DLL 的某个依赖项找不到”。比如你的插件引用了Newtonsoft.Json,但Newtonsoft.Json.dll不在插件目录、不在主程序目录、也不在探测路径里,运行时就会在调用到相关代码时报FileNotFoundException。排查办法是先看异常里的FileName属性,它会明确指出缺的是哪个文件,然后把它复制到该去的位置。
第二个是FileLoadException。这个经常跟版本绑定的强名称程序集有关。如果一个程序集有强名称,那它的版本、公钥令牌、区域性都会被校验。插件里引用了一个强名称程序集的 1.0 版本,而主程序目录里放的是 2.0,加载时就会报错。解决办法是让所有项目统一依赖版本,或者在配置文件里配置程序集绑定重定向(bindingRedirect)。如果你用的是 SDK 风格的 csproj,很多时候 NuGet 会自动生成绑定重定向,但自建插件体系时仍需注意。
第三个是TypeLoadException或者MissingMethodException。这种情况通常是“接口程序集版本不一致”或者“插件编译时用的方法签名和主程序预期的不一致”。我通常的处理方式:在加载插件时,先把“能加载的类型”和“无法加载的类型”分别打日志,这样出问题时能快速定位是哪个类型、缺了哪个方法。日志里输出完整的异常堆栈,尤其是InnerException,很多第一层异常看不出来的信息都藏在里面。
6.2 依赖项处理的两个土办法
如果你不想引入复杂的程序集解析逻辑,但又经常碰到依赖项找不到的问题,我给你两个立竿见影的土办法。
第一个是“把所有依赖都丢进插件目录”。这个办法简单粗暴:打包插件时,把插件依赖的所有 DLL 全部复制到输出目录,然后整个目录作为插件发布。这样无论主程序的探测路径怎么变化,只要插件目录完整,依赖就能被找到。代价是 DLL 文件数量多,但以现在的磁盘空间和拷贝速度来说,这点代价完全值得。
第二个是“订阅AppDomain.CurrentDomain.AssemblyResolve事件”。这个事件在你找不到目标程序集时触发,你可以在事件处理函数里手动去指定目录找 DLL 并返回加载好的Assembly。这个方案更优雅,适合不想堆积大量 DLL 的场景,但你需要自己控制好查找逻辑,避免死循环(在事件处理函数里再次触发LoadFrom时要特别小心)。我一般先按需探测插件目录,如果还找不到就返回null,让 CLR 用默认逻辑处理。
6.3 调试动态加载代码的工具技巧
动态加载这种“运行起来才能知道结果”的东西,调试起来比普通代码更费劲。我的经验,首先确保异常日志足够详细,至少包含:程序集路径、加载上下文、关键 InnerException。其次,如果你用 Visual Studio,可以在AppDomain.CurrentDomain.AssemblyResolve和AppDomain.CurrentDomain.AssemblyLoad两个事件里打上日志断点,看看哪些程序集被加载了、哪些找不到。这个方法在排查复杂的依赖问题时比盲猜高效得多。
还有一个很实用的小技巧:在插件类型上打一个自定义特性(Attribute),把插件的版本、作者、用途等信息写在特性里,加载时读取特性并展示,可以在运行时一眼看出当前加载的是哪个版本的插件。这在排查“为什么功能还是旧的”这种问题时尤其管用,因为Assembly.LoadFrom的缓存机制会让你替换 DLL 之后不一定马上生效,而在界面上显示版本号可以立刻揭穿这个误会。
7. 聊到最后——我的实际体会
做动态加载这块,我踩过的坑不少。从最早的LoadFile导致依赖解析失败,到后来接口程序集版本不一致引发的TypeLoadException,再到插件目录里堆了一堆不明用途的 DLL 导致启动扫描变慢,这些问题最终都靠“理清楚依赖关系 + 做好日志记录 + 控制好异常范围”来解决。
实际写代码的时候,我还建议你给插件加载整体加一个开关配置,比如EnablePlugin=true的时候才扫描目录。这样如果某个现场环境出现了无法预料的插件问题,你至少可以让用户通过改配置文件临时关掉插件功能,而不是把整个软件回购到手里重新优化。这种“保留逃生通道”的思路,在我做上位机交付时救过我很多次。
如果你接下来想自己动手练,我的建议是:先用反射写一个最简单的 DLL 加载调用,跑通基础链路,再去玩 Roslyn 动态编译,最后才需要碰AssemblyLoadContext。不要一开始就追求花里胡哨的架构,先把最核心的机制吃透,后面往上堆东西自然水到渠成。
本文还有配套的精品资源,点击获取