资讯详情

C#动态编译与加载实战:从源码到插件机制

📅 2026/10/26 21:17:28 | 华诺云谱 👁 阅读
C#动态编译与加载实战:从源码到插件机制
简介这份源码资源面向已掌握C#基础、希望进一步理解运行时动态加载与动态编译机制的开发者可用于插件系统、模块化程序及自定义扩展等场景的学习与实验。压缩包共18个文件约82KB以8个cs源码文件为核心配合3个resx资源文件、2个ico图标、1个csproj项目文件与1个sln解决方案另含可执行程序及配置项构成一个可直接打开运行的完整示例工程。资源围绕Assembly.LoadFrom加载程序集与CodeDomProvider动态编译两条主线展开读者可从中了解如何从字符串编译代码、检查编译错误并获取编译后的程序集进而理解动态执行代码的完整链路。目前已有396人学习适合作为动手实践的参考材料帮助读者把反射、动态编译等概念落到可调试的代码中并在此基础上思考动态加载带来的安全边界问题。1. 从一份“能改自己”的源码说起C# 动态加载与动态编译到底能干什么线上跑着一个数据采集服务客户临时加了一种报文格式按常规流程得改代码、编译、发版、重启一套下来半小时起步。但如果程序能在运行时把一段新写的 C# 代码编译成程序集再加载进当前进程调用这件事就变成了“上传一个 .cs 文件点一下刷新”。这份实例源码干的就是这件事它把CSharpCodeProvider编译、Assembly.Load加载、反射调用这条链路完整跑通并给出了可复用的封装。适合两类人一是想给现有系统加插件机制、脚本扩展点的后端开发者二是刚接触反射和动态编译、想找一个能直接跑起来的最小可运行样例的新手。下面按“能干什么、怎么跑、核心代码长什么样、怎么改”的顺序拆开讲。2. 项目结构与运行链路从源码文件到可执行方法2.1 目录与文件构成这类实例源码通常是一个控制台解决方案结构不复杂但每个文件职责清晰。按常见组织方式大致是这样DynamicLoadDemo/ ├── DynamicLoadDemo.sln ├── DynamicLoadDemo/ │ ├── Program.cs // 入口演示编译加载调用 │ ├── Compiler/ │ │ ├── DynamicCompiler.cs // 封装 CSharpCodeProvider 编译逻辑 │ │ └── CompileResult.cs // 编译结果程序集 错误列表 │ ├── Loader/ │ │ └── AssemblyLoader.cs // 加载程序集、反射创建实例 │ ├── Contracts/ │ │ └── IPlugin.cs // 插件接口约定 │ └── Scripts/ │ └── SamplePlugin.cs.txt // 待编译的源码文本示例Contracts/IPlugin.cs是整个机制的“契约层”动态代码和宿主程序都实现或引用这个接口才能在不引用具体类型的情况下完成调用。Scripts目录放的是源码文本注意扩展名常写成.txt而不是.cs避免被主项目一起编译进去——这是个容易忽略的细节后面避坑章节会展开。2.2 运行链路编译、加载、调用三步整条链路可以拆成三个动作每一步都有明确的输入输出编译把字符串形式的 C# 源码交给编译器得到内存中的程序集字节流byte[]或临时 DLL 路径。加载用Assembly.Load(byte[])把字节流加载进当前应用程序域得到Assembly对象。调用通过反射找到实现IPlugin的类型Activator.CreateInstance创建实例转成接口后直接调方法。先看编译这一步的核心代码这是整个项目最值得抄的部分using System.CodeDom.Compiler; using Microsoft.CSharp; public class DynamicCompiler { public CompileResult Compile(string sourceCode, string[] referencedAssemblies) { // 编译器参数输出为内存中的 DLL不落地文件 var options new CompilerParameters { GenerateInMemory true, // 关键内存编译避免磁盘 IO GenerateExecutable false, // 生成类库而非可执行文件 TreatWarningsAsErrors false, IncludeDebugInformation false }; // 把宿主已加载的程序集路径加进引用动态代码才能用到这些类型 foreach (var asm in referencedAssemblies) options.ReferencedAssemblies.Add(asm); using (var provider new CSharpCodeProvider()) { // 编译源码字符串返回结果对象 var results provider.CompileAssemblyFromSource(options, sourceCode); if (results.Errors.HasErrors) { var errors new Liststring(); foreach (CompilerError err in results.Errors) errors.Add(${err.FileName}({err.Line},{err.Column}): {err.ErrorText}); return CompileResult.Fail(errors); } return CompileResult.Ok(results.CompiledAssembly); } } }逻辑说明GenerateInMemory true让编译产物留在内存里省去临时文件清理的麻烦ReferencedAssemblies必须显式补齐否则动态代码里using System.Linq之类会直接报“找不到命名空间”。参数sourceCode是完整源码字符串referencedAssemblies一般传typeof(object).Assembly.Location、typeof(Enumerable).Assembly.Location以及宿主自己的契约程序集路径。编译失败时把CompilerError的行列号拼出来这是排查动态代码语法错误最有效的信息别只返回一句“编译失败”。2.3 加载与反射调用编译成功后拿到Assembly接下来是加载和调用public IPlugin CreatePlugin(Assembly assembly, string typeName) { // 按类型全名查找找不到返回 null var type assembly.GetType(typeName); if (type null) throw new InvalidOperationException($类型 {typeName} 不存在); // 校验是否实现了契约接口避免拿到不兼容的类型 if (!typeof(IPlugin).IsAssignableFrom(type)) throw new InvalidOperationException(${typeName} 未实现 IPlugin); // 创建实例并转成接口后续调用不依赖具体类型 return (IPlugin)Activator.CreateInstance(type); }参数typeName必须是带命名空间的完整类型名比如SamplePlugin.HelloPlugin只写类名会查不到。IsAssignableFrom这层校验很关键动态代码是外部输入的不能假设它一定实现了约定接口提前拦截比运行时抛InvalidCastException好排查得多。调用侧就简单了plugin.Execute(input)即可宿主完全不需要知道动态类型的任何细节。3. 核心模块拆解编译封装、契约设计与错误处理3.1 编译封装为什么要单独抽一层把CSharpCodeProvider的调用散落在业务代码里是这类项目最常见的坏味道。单独抽DynamicCompiler的价值在于编译参数集中管理、错误信息统一格式化、后续换编译器比如 Roslyn时只改一个类。实际项目里我一般会把引用程序集列表也做成可配置的而不是每次调用都手写一遍// 收集当前已加载程序集中需要暴露给动态代码的部分 public static string[] GetDefaultReferences() { var refs new Liststring { typeof(object).Assembly.Location, // System.Private.CoreLib / mscorlib typeof(Enumerable).Assembly.Location, // System.Linq typeof(List).Assembly.Location // System.Collections }; // 契约程序集必须加否则动态代码无法实现 IPlugin refs.Add(typeof(IPlugin).Assembly.Location); return refs.Distinct().ToArray(); }这里Distinct()不是可有可无的装饰重复引用在某些运行环境下会触发编译警告甚至报错。typeof(IPlugin).Assembly.Location这一行是契约层和编译层的连接点漏掉它动态代码里写: IPlugin就会编译失败报“找不到类型或命名空间 IPlugin”。3.2 契约接口的设计要点IPlugin的设计直接决定这套机制好不好用。最小可用版本大概是这样public interface IPlugin { string Name { get; } string Execute(string input); }Name用于宿主侧做插件识别和日志标记Execute是统一入口。注意接口里不要放太多个性化方法否则每加一个插件就要改契约动态加载的灵活性就打了折扣。如果业务需要传复杂参数常见做法是定义一个PluginContext类作为唯一入参后续扩展字段不影响已有插件签名。另外接口所在的程序集必须被动态编译引用且版本要稳定——宿主升级契约程序集时已编译的旧插件可能加载失败这是设计阶段就要想清楚的兼容性问题。3.3 错误处理与编译结果对象CompileResult这种结果对象看似简单但它是错误处理的核心载体public class CompileResult { public bool Success { get; private set; } public Assembly Assembly { get; private set; } public Liststring Errors { get; private set; } public static CompileResult Ok(Assembly asm) new CompileResult { Success true, Assembly asm, Errors new Liststring() }; public static CompileResult Fail(Liststring errors) new CompileResult { Success false, Assembly null, Errors errors }; }用结果对象而不是抛异常是因为“用户提交的代码有语法错误”属于预期内的业务分支不是程序异常。调用方拿到Errors列表可以直接展示给用户定位到具体行列。如果这里直接throw上层还得 catch 再解析异常消息反而更绕。这套模式在插件上传、脚本编辑这类场景里几乎是标配。4. 避坑与常见问题动态编译最容易翻车的几个点4.1 现象编译报“找不到 System.Linq”但代码里明明写了 using原因CompilerParameters.ReferencedAssemblies默认只带最基础的程序集System.Linq、System.Collections这些不会自动加。解决按 3.1 的方式显式收集并加入引用列表尤其是契约程序集和常用基础库缺一个就报一个。4.2 现象动态代码编译通过加载时抛“无法加载文件或程序集”原因GenerateInMemory true时程序集没有物理路径某些依赖解析逻辑会失败或者动态代码引用了宿主没加载的程序集。解决优先用内存编译配合Assembly.Load(byte[])如果必须落地文件用Path.GetTempPath()下的唯一文件名并在加载后安排清理别把临时 DLL 堆在运行目录里。4.3 现象改了源码文本重新编译后调用的还是旧逻辑原因程序集一旦加载进应用程序域就无法卸载同名程序集重复加载会命中缓存。解决这是 .NET 动态加载的硬限制常见做法是每次编译生成唯一程序集名比如拼上时间戳或 GUID或者把动态执行放到独立的可卸载上下文里。实例源码里如果只是演示通常不处理这点但上生产必须面对。4.4 现象动态代码里using了宿主业务类型编译报“类型未定义”原因只加了契约程序集引用没加业务程序集。解决把动态代码允许访问的程序集做成白名单配置编译前统一加入ReferencedAssemblies。白名单同时是安全边界别图省事把整个AppDomain的程序集全塞进去。4.5 现象编译成功但Activator.CreateInstance抛异常原因目标类型没有无参构造函数或者构造函数里抛了异常。解决契约里约定插件必须有无参构造创建实例时用 try-catch 包住把内部异常信息透出来否则只能看到一句笼统的“创建实例失败”。提示动态代码本质上是“可执行的外部输入”除了编译错误还要考虑它可能死循环、占满内存。生产环境里给执行加超时和资源限制比事后排查划算得多。5. 二次开发与改造把演示代码变成可用的插件机制拿到这份源码后最直接的改造方向有三个。第一把CSharpCodeProvider换成 Roslyn 的CSharpCompilation后者支持更细的编译选项、更好的错误诊断也便于做语法树分析——如果要做代码安全检查比如禁止System.IO操作Roslyn 的语法树遍历是绕不开的。第二把“上传源码文本”改成“上传 .cs 文件 元数据描述”宿主侧维护一个插件清单记录名称、版本、入口类型加载时按清单反射而不是硬编码类型名。第三加一层缓存相同源码哈希值不重复编译直接复用已加载的程序集这在插件数量多、刷新频繁的场景下能省不少时间。验证改造是否成功我一般用三步走先跑通“编译一个只返回字符串的插件”确认链路通再故意写一段语法错误的代码确认错误信息能定位到行列最后写一个引用宿主业务类型的插件确认引用白名单生效。三步都过基本可以接真实业务了。改造点原实现建议做法编译器CSharpCodeProviderRoslyn CSharpCompilation程序集管理直接 Load唯一命名 可卸载上下文安全边界无引用白名单 语法树检查插件元数据硬编码类型名清单文件驱动有个血泪经验早期我图省事把动态编译和业务逻辑写在同一个类里结果一次编译参数调整牵动了半个模块回归测试跑了一下午。从那以后我每次做这类扩展机制都强制先把编译、加载、调用拆成三个独立类接口先定死再写实现。希望这份拆解能帮你少走一遍我踩过的路源码跑起来之后先从替换编译器这一步开始改收益最明显。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑