news 2026/10/26 5:48:32

C#动态编译实战:CodeDom与Roslyn运行时加载源码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#动态编译实战:CodeDom与Roslyn运行时加载源码

简介:这份源码资源面向已掌握C#基础语法、希望进一步理解运行时动态加载与动态编译机制的中级开发者,可用于插件系统、模块化程序与自定义扩展等场景的学习与二次开发。压缩包共18个文件,约82KB,以8个cs源码文件为核心,配合3个resx资源文件、2个ico图标、1个csproj项目文件与1个sln解决方案,另含可执行程序及配置项,构成一个可直接打开运行的完整示例工程。资源围绕Assembly.LoadFrom加载程序集与CodeDomProvider动态编译字符串代码两条主线展开,读者可从中获得可调试的完整工程结构、编译参数配置与错误处理思路,并对照示例理解内存中生成程序集、引用依赖及执行入口的组织方式。目前已有396人学习,适合作为动手实践动态编译技术的入门参考,同时需留意动态执行代码带来的安全边界问题。

1. 动态加载不是玄学:一份能跑通的 C# 运行时编译源码

线上服务跑得好好的,突然要加一条业务规则,改完还得停服、编译、发版、重启,一套流程下来半小时没了。更难受的是某些客户现场根本不允许你重新部署,只能远程连上去想办法。这时候“运行时把一段 C# 代码编译成程序集再加载执行”就不是炫技,而是实打实的救命手段。这份实例源码围绕CSharpCodeProvider与Roslyn两条路线,把字符串形式的代码怎么变成可调用的对象、怎么传参、怎么拿到返回值、异常怎么兜住,拆成了可以直接对照的工程结构。适合已经会写 C#、但没系统做过动态编译的中高级开发者,也适合想给自家规则引擎、插件系统找一条轻量落地路径的从业者。下面按“先跑起来、再搞懂、最后避坑”的顺序走一遍。

2. 两条技术路线怎么选:CodeDom 与 Roslyn 的取舍

2.1 先看清动态加载到底在做什么

所谓动态加载代码,本质是三步:把一段源码字符串交给编译器,得到一个内存中的程序集,再通过反射创建类型实例、调用方法。难点不在“编译”这个动作,而在编译产物怎么和宿主程序交换数据。源码里对这两种方式都给了封装,核心差异在于编译器前端。

CSharpCodeProvider属于 CodeDom 体系,随 .NET Framework 一起提供,调用方式简单,编译产物是一个CompilerResults,从中取CompiledAssembly即可。它的短板很明显:只支持 C# 5 及更早的语法,async/await、字符串插值、元组、模式匹配这些统统不认,而且在新版 .NET 上需要额外引入兼容包,跨平台支持也不完整。

Roslyn 是现在的官方编译器平台,Microsoft.CodeAnalysis.CSharp包提供CSharpCompilation,语法支持跟当前 SDK 对齐,能拿到完整的诊断信息(行号、列号、错误码),还能做语法树分析。代价是 API 更啰嗦,引用管理要自己处理。

选型上我的建议很直接:新项目一律 Roslyn,老 .NET Framework 项目如果只是拼几行简单表达式、又不想加包,CodeDom 够用。源码里两条路线并存,正好可以对照着看同一件事两种写法差在哪。

2.2 CodeDom 路线的最小可运行骨架

先看 CodeDom 的写法,它最适合拿来理解“编译—加载—调用”这条链路:

using System; using System.CodeDom.Compiler; using System.Reflection; using Microsoft.CSharp; public static object RunByCodeDom(string source, string typeName, string methodName, object[] args) { // 1. 指定编译器,并传入编译参数 var provider = new CSharpCodeProvider(); var options = new CompilerParameters { GenerateInMemory = true, // 产物留在内存,不落盘 GenerateExecutable = false, // 生成 DLL 而非 EXE TreatWarningsAsErrors = false, CompilerOptions = "/optimize" }; // 2. 补上宿主已加载的程序集引用,否则源码里用不了 System.Linq 等 foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies()) { if (!asm.IsDynamic && !string.IsNullOrEmpty(asm.Location)) options.ReferencedAssemblies.Add(asm.Location); } // 3. 编译 CompilerResults results = provider.CompileAssemblyFromSource(options, source); if (results.Errors.HasErrors) { var msg = string.Join("\n", results.Errors.Cast<CompilerError>() .Select(e => $"行{e.Line}: {e.ErrorText}")); throw new InvalidOperationException("动态编译失败:\n" + msg); } // 4. 反射创建实例并调用 Assembly assembly = results.CompiledAssembly; Type type = assembly.GetType(typeName, throwOnError: true); object instance = Activator.CreateInstance(type); MethodInfo method = type.GetMethod(methodName); return method.Invoke(instance, args); }

几个参数值得单独说。GenerateInMemory = true让程序集只存在于内存,适合一次性执行的场景;如果要做插件缓存、需要多次复用,改成false并指定OutputAssembly路径更划算,避免每次调用都重新编译。ReferencedAssemblies那段循环是血泪经验——不补引用的话,源码里写个List<int>都可能报“未定义类型”,因为动态编译的上下文默认只认mscorlib和少数基础程序集。GetType的throwOnError一定要开,否则类型名拼错时返回 null,后面Activator.CreateInstance抛的是空引用,排查起来完全找不到方向。

2.3 Roslyn 路线的编译与诊断

Roslyn 的写法长一些,但每一步都可控:

using System; using System.IO; using System.Linq; using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; public static Assembly CompileWithRoslyn(string source, out string diagnostics) { // 1. 解析语法树,指定语言版本 SyntaxTree tree = CSharpSyntaxTree.ParseText( source, new CSharpParseOptions(LanguageVersion.Latest)); // 2. 收集当前运行时的基础引用 var refs = AppDomain.CurrentDomain.GetAssemblies() .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) .Select(a => MetadataReference.CreateFromFile(a.Location)) .Cast<MetadataReference>() .ToList(); // 3. 组装编译单元 var compilation = CSharpCompilation.Create( assemblyName: "Dynamic_" + Guid.NewGuid().ToString("N"), syntaxTrees: new[] { tree }, references: refs, options: new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release)); // 4. 输出到内存流 using var ms = new MemoryStream(); var result = compilation.Emit(ms); diagnostics = string.Join("\n", result.Diagnostics .Where(d => d.Severity == DiagnosticSeverity.Error) .Select(d => d.ToString())); if (!result.Success) return null; ms.Seek(0, SeekOrigin.Begin); return Assembly.Load(ms.ToArray()); }

LanguageVersion.Latest决定了你能用多新的语法,如果宿主运行时版本较低,这里要显式降级,否则编译能过、运行时会因为缺少运行时支持而炸。OutputKind.DynamicallyLinkedLibrary对应生成 DLL,做可执行入口才用ConsoleApplication。Emit返回的diagnostics里带行列号和错误码,比 CodeDom 的ErrorText信息量大得多,线上排查时把这段原样打到日志里,定位效率差一个量级。注意Assembly.Load(byte[])加载的程序集无法卸载,反复编译同一段代码会持续占内存,这是后面避坑章节要展开的点。

2.4 宿主与动态代码之间的数据交换

编译成功只是第一步,真正麻烦的是参数怎么传、结果怎么拿。源码里统一约定:动态代码实现一个固定签名的接口,宿主通过接口调用,而不是靠反射硬找方法名。常见做法是在宿主工程里定义一个契约:

public interface IDynamicRule { string Name { get; } object Execute(object input); }

动态源码里写public class MyRule : IDynamicRule,编译时把宿主程序集加进引用列表,加载后assembly.GetTypes().First(t => typeof(IDynamicRule).IsAssignableFrom(t))就能拿到实现类。这样做的价值在于:方法名、参数类型在编译期就被约束住,不会出现“字符串拼错方法名、运行时才报错”的翻车。参数用object传递虽然丢了类型安全,但换来的是宿主不需要为每条规则单独定义 DTO,规则数量多的时候维护成本低得多。如果对性能敏感,可以在接口里加泛型版本,或者用表达式树缓存委托,避免每次调用都走MethodInfo.Invoke的反射开销。

3. 把源码跑起来:工程结构与调用链

3.1 工程分层与依赖准备

拿到源码包后不要急着全量编译,先看清分层。典型结构是三层:宿主控制台或 Web 项目负责发起调用,编译服务层封装 CodeDom 与 Roslyn 两套实现,契约层放IDynamicRule这类接口。依赖上,CodeDom 路线在 .NET Framework 下零额外包,在 .NET 6+ 需要引入Microsoft.CodeDom.Providers.DotNetCompilerPlatform;Roslyn 路线需要Microsoft.CodeAnalysis.CSharp,版本跟目标框架对齐,别盲目追最新。

路线所需包适用框架语法支持
CodeDom无(Framework)/ DotNetCompilerPlatform(Core+)全版本C# 5 及以前
RoslynMicrosoft.CodeAnalysis.CSharp全版本跟随 SDK

建工程时把动态源码放在独立目录,用EmbeddedResource或直接读文件都行,别硬编码在 C# 字符串里——转义字符能把人逼疯。我一般会建一个Rules/目录放.cs.txt文件,运行时File.ReadAllText读入,改规则不用重新编译宿主。

3.2 一次完整调用的执行顺序

把链路串起来看,一次动态执行包含这些动作:读取源码文本 → 选择编译器路线 → 补引用 → 编译 → 检查诊断 → 加载程序集 → 找实现类型 → 创建实例 → 调用方法 → 处理返回值与异常。源码里的入口方法大致长这样:

public object ExecuteRule(string sourceCode, object input) { // 编译阶段:失败直接抛,带上诊断信息 Assembly asm = CompileWithRoslyn(sourceCode, out string diag); if (asm == null) throw new InvalidOperationException("编译失败:" + diag); // 加载阶段:找第一个实现契约的类型 Type ruleType = asm.GetTypes() .FirstOrDefault(t => typeof(IDynamicRule).IsAssignableFrom(t) && !t.IsAbstract); if (ruleType == null) throw new InvalidOperationException("未找到 IDynamicRule 实现"); // 执行阶段:反射创建并调用,异常要包一层保留原始堆栈 var rule = (IDynamicRule)Activator.CreateInstance(ruleType); try { return rule.Execute(input); } catch (Exception ex) { throw new InvalidOperationException($"规则 {rule.Name} 执行异常", ex); } }

GetTypes()在程序集加载失败时会抛ReflectionTypeLoadException,它的LoaderExceptions属性才是真正原因,直接看Message往往只有一句“无法加载一个或多个类型”。执行阶段的 try-catch 不是多余,动态代码里抛的异常如果不包一层,堆栈里全是反射帧,根本看不出是哪条规则出的问题。把rule.Name拼进异常消息,日志里一眼就能定位。

3.3 传参、返回值与多次调用的缓存

单次调用跑通后,下一步是复用。每次ExecuteRule都重新编译,规则一多性能立刻塌方。常见做法是加一层字典缓存,key 用源码的哈希值,value 存编译好的Type:

private static readonly ConcurrentDictionary<string, Type> _cache = new(); public IDynamicRule GetRule(string sourceCode) { string key = Convert.ToHexString( System.Security.Cryptography.SHA256.HashData( System.Text.Encoding.UTF8.GetBytes(sourceCode))); Type t = _cache.GetOrAdd(key, _ => { Assembly asm = CompileWithRoslyn(sourceCode, out string diag); if (asm == null) throw new InvalidOperationException(diag); return asm.GetTypes().First(x => typeof(IDynamicRule).IsAssignableFrom(x) && !x.IsAbstract); }); return (IDynamicRule)Activator.CreateInstance(t); }

用 SHA256 而不是GetHashCode,是因为后者在不同进程间不稳定,做持久化缓存时会失效。ConcurrentDictionary保证并发下只编译一次,但要注意GetOrAdd的工厂委托可能被执行多次(并发竞争时),如果编译开销大,可以换成Lazy<Type>包一层。返回值统一用object,调用方按约定强转,规则内部返回复杂对象时建议序列化成 JSON 字符串再传,避免跨程序集类型不一致导致的转换异常。

4. 避坑与排查:动态编译最容易翻车的五处

4.1 报“未定义类型或命名空间”

现象是编译诊断里一堆CS0246,明明宿主里using System.Linq写得好好的。原因在于动态编译的上下文是独立的,默认引用集里没有你项目里引的那些包。解决办法是把宿主已加载程序集的Location全部加进引用列表,注意过滤IsDynamic和空Location,否则会抛异常。如果依赖的是 NuGet 包且尚未被加载,需要手动Assembly.Load一次再取Location。

4.2 程序集加载了但找不到类型

现象是GetType返回 null 或GetTypes抛ReflectionTypeLoadException。原因通常是命名空间写错、类没声明public,或者动态源码里引用了宿主没有的程序集导致类型加载失败。解决方式是先看LoaderExceptions里的内层异常,再确认动态源码的namespace和class修饰符。调试期可以在编译成功后把asm.GetTypes().Select(t => t.FullName)打出来,对照着找。

4.3 反复编译导致内存持续上涨

现象是压测跑一段时间后内存曲线只涨不跌。原因是Assembly.Load(byte[])加载的程序集进入默认加载上下文后无法卸载,每次编译都是一份新程序集。解决办法是控制编译次数,用缓存复用;确实需要热更新的场景,改用可卸载的AssemblyLoadContext(.NET Core 起支持),用完调Unload(),但要注意卸载后不能再持有该上下文里创建的对象引用,否则卸载不生效。

4.4 动态代码里的异常堆栈看不清

现象是日志里只有TargetInvocationException,内层才是真实异常,堆栈里全是反射帧。原因是MethodInfo.Invoke会把目标异常包一层。解决办法是捕获后取InnerException再抛,或者干脆走接口调用而不是反射调用,异常就能原样冒出来。日志里同时记录规则名和源码哈希,方便回查是哪一版代码。

4.5 语法版本不匹配导致编译能过运行报错

现象是 Roslyn 编译零错误,运行到某一行抛MissingMethodException或TypeLoadException。原因是用了当前运行时没有的语法糖或 API,比如在低版本运行时上用记录类型、用新版 BCL 才有的方法。解决办法是把LanguageVersion降到目标运行时对应的版本,并在 CI 里用最低支持版本的运行时跑一遍冒烟测试,别只在开发机上验证。

5. 进阶:把动态编译收进可控边界

跑通之后真正决定这套东西能不能上生产的,是边界控制。我一般会做三件事。第一,给动态源码加白名单校验,编译前用 Roslyn 的语法树扫一遍,禁止System.IO、System.Diagnostics.Process、反射调用Assembly.Load这类危险命名空间,扫到直接拒绝,别等编译完再靠运行时兜。第二,给编译和执行分别设超时,编译放独立线程加Task.Wait(timeout),执行侧用CancellationToken传递,防止某条规则写出死循环把整个宿主拖死。第三,把诊断信息结构化落库,字段包括源码哈希、编译耗时、错误码、规则名,出问题时按哈希一查就知道是哪次提交引入的。

验证这套机制是否可靠,有个简单办法:准备一组“坏样本”,包括语法错误、缺引用、死循环、抛异常、返回 null 五种,写成一个测试用例批量跑,确认每种都能被正确拦截且宿主不崩。这个习惯是从一次线上事故之后养成的——当时一条规则里写了while(true),宿主线程直接卡死,重启才恢复。从那以后我每次接入动态编译,都强制先把这组坏样本跑一遍再上线。希望这些内容帮到你,少走几段弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 5:48:31

AI Agent智能床头柜:多模态传感器与边缘计算的睡眠唤醒系统

1. 项目概述&#xff1a;当AI Agent遇上智能床头柜清晨6:30&#xff0c;你的智能床头柜通过枕下压力传感器检测到微小的身体移动&#xff0c;毫米波雷达捕捉到呼吸频率变化&#xff0c;环境光传感器感知到窗帘缝隙透入的晨光。这些数据实时传输给内置的AI Agent&#xff0c;经过…

作者头像 李华
网站建设 2026/10/26 5:48:17

亚马逊仓储机器人项目失败的技术启示

1. 项目背景与事件概述2023年初&#xff0c;亚马逊突然宣布终止其仓储机器人"蓝鸟"&#xff08;Bluebird&#xff09;项目&#xff0c;这个曾被内部寄予厚望的自动化设备&#xff0c;从立项到上线仅用常规研发周期的一半时间&#xff0c;却在正式部署几个月后遭遇&qu…

作者头像 李华
网站建设 2026/10/26 5:47:18

AIGC检测与论文降AI率技术全解析

1. 论文AIGC检测的现状与挑战最近两年&#xff0c;学术界对AI生成内容&#xff08;AIGC&#xff09;的检测越来越严格。我帮学弟学妹修改论文时发现&#xff0c;超过70%的初稿都会被Turnitin、iThenticate等系统标记为"疑似AI生成"。最夸张的一次&#xff0c;某篇经我…

作者头像 李华
网站建设 2026/10/26 5:47:17

工业机器人租售业务的三阶段出场流程设计

1. 机器人租售业务中的出场流程设计在机器人租赁和销售业务中&#xff0c;出场流程的设计直接影响着客户体验和运营效率。不同于传统商品的简单交付&#xff0c;机器人作为智能化设备&#xff0c;其出场往往需要分阶段完成&#xff0c;每个环节都需要专业人员的精准配合。我经手…

作者头像 李华
网站建设 2026/10/26 5:46:46

Qt C++日志自动切分实战:按日期与大小分文件,解决2GB日志卡顿

简介&#xff1a;这份资源面向使用 QT 框架进行 C 开发的初中级开发者&#xff0c;聚焦日志记录这一常见但易被忽视的工程环节&#xff0c;解决日志数据如何持久化保存、日志文件如何按条件自动切分的问题。压缩包共 6 个文件&#xff0c;约 6KB&#xff0c;以 cpp 与 h 源码为…

作者头像 李华
网站建设 2026/10/26 5:46:19

学术写作智能排版工具:千笔·专业学术智能体解析

1. 项目概述&#xff1a;学术写作的痛点与智能解决方案写论文最让人头疼的从来不是内容创作本身&#xff0c;而是那些看似简单却总能让你熬夜改到崩溃的格式问题。从参考文献标号错位到页眉页脚消失&#xff0c;从莫名其妙的缩进混乱到突然变身的字体字号——每个经历过学术写作…

作者头像 李华