
简介面向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); } }然后你做了一个实现它的类库SamplePluginusing 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 ListIPlugin LoadPlugins(string pluginDirectory) { ListIPlugin plugins new ListIPlugin(); 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 RunT(string code, string className, string methodName, object[] args null) { // 1. 创建语法树 SyntaxTree syntaxTree CSharpSyntaxTree.ParseText(code); // 2. 收集引用程序集 var references new ListMetadataReference { 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.Runint(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 导致启动扫描变慢这些问题最终都靠“理清楚依赖关系 做好日志记录 控制好异常范围”来解决。实际写代码的时候我还建议你给插件加载整体加一个开关配置比如EnablePlugintrue的时候才扫描目录。这样如果某个现场环境出现了无法预料的插件问题你至少可以让用户通过改配置文件临时关掉插件功能而不是把整个软件回购到手里重新优化。这种“保留逃生通道”的思路在我做上位机交付时救过我很多次。如果你接下来想自己动手练我的建议是先用反射写一个最简单的 DLL 加载调用跑通基础链路再去玩 Roslyn 动态编译最后才需要碰AssemblyLoadContext。不要一开始就追求花里胡哨的架构先把最核心的机制吃透后面往上堆东西自然水到渠成。本文还有配套的精品资源点击获取