简介:这份源码资源面向已掌握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 List<string>(); 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 List<string> { 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 List<string> Errors { get; private set; } public static CompileResult Ok(Assembly asm) => new CompileResult { Success = true, Assembly = asm, Errors = new List<string>() }; public static CompileResult Fail(List<string> 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 文件 + 元数据描述”,宿主侧维护一个插件清单,记录名称、版本、入口类型,加载时按清单反射,而不是硬编码类型名。第三,加一层缓存:相同源码哈希值不重复编译,直接复用已加载的程序集,这在插件数量多、刷新频繁的场景下能省不少时间。
验证改造是否成功,我一般用三步走:先跑通“编译一个只返回字符串的插件”,确认链路通;再故意写一段语法错误的代码,确认错误信息能定位到行列;最后写一个引用宿主业务类型的插件,确认引用白名单生效。三步都过,基本可以接真实业务了。
| 改造点 | 原实现 | 建议做法 |
|---|---|---|
| 编译器 | CSharpCodeProvider | Roslyn CSharpCompilation |
| 程序集管理 | 直接 Load | 唯一命名 + 可卸载上下文 |
| 安全边界 | 无 | 引用白名单 + 语法树检查 |
| 插件元数据 | 硬编码类型名 | 清单文件驱动 |
有个血泪经验:早期我图省事,把动态编译和业务逻辑写在同一个类里,结果一次编译参数调整牵动了半个模块,回归测试跑了一下午。从那以后我每次做这类扩展机制,都强制先把编译、加载、调用拆成三个独立类,接口先定死再写实现。希望这份拆解能帮你少走一遍我踩过的路,源码跑起来之后,先从替换编译器这一步开始改,收益最明显。
本文还有配套的精品资源,点击获取