代码动态生成全解析:从表达式树到Roslyn动态编译
代码动态生成我第一次接触是在一个ORM框架的源码里一个类根据数据库表结构在运行时生成一堆读写的IL指令。当时的感觉就是代码像活的一样还能在内存里自己长出新的代码来。后来做的东西多了才发现这个技术不是用来炫技的它能解决一组非常具体的问题——运行时才知道结构、动态规则、反射性能瓶颈、重复模板代码。这篇文章我会把代码动态生成技术拆开讲从概念到选型再给一个可以直接跑的例子最后说说我踩过的坑。适合刚接触元编程、动态编译的开发者也适合正在做代码生成器选型的人参考。1. 代码动态生成到底是什么先搞懂它解决什么问题1.1 三种常见的“动态生成”形态很多人一听到“代码动态生成”立刻想到的是用字符串拼SQL或者写个模板把一堆代码生出来。我不认为那是完整意义上的动态生成技术。真正的动态生成至少分成三种形态它们发生的时间和背后的思路完全不同。第一种是编译期生成。代表是.NET的Source GeneratorJava生态的Annotation Processor也类似。它们在你项目编译的时候读取已有的源代码、属性、语法树信息然后生成额外的源码文件和你的代码一起参与编译。这种生成发生在程序运行之前结果直接“写死”在程序集里运行时性能最好。第二种是运行时源码编译。程序跑起来之后拿到一段字符串代码用Roslyn这类编译器API把它编译成程序集再通过反射或者接口加载使用。这就像在运行中的汽车里现场装发动机非常灵活但是非常贵必须慎重使用。第三种是运行时结构化生成。不直接编译字符串而是在运行时构造语法树、表达式树或者直接发出IL指令然后让JIT编译成机器码。比如表达式树中的Compile()还有System.Reflection.Emit里的DynamicMethod都属于这一类。这种生成方式的安全性比字符串拼接好得多因为代码被构建成对象结构很多错误可以被类型系统拦下来。这三种形态共同指向一个核心痛点有些代码的逻辑要到运行那一刻才知道。比如规则引擎要根据用户的配置动态构造判断条件ORM要根据表结构生成映射框架要替组件生成代理方法。手写是写不出来的因为那段代码在写项目的时候还不存在。1.2 动态生成 vs 手写代码 vs 静态模板我见过不少团队一提到重复代码就考虑上代码生成。其中一部分人用的只是静态模板比如T4、Razor模板或者最原始的字符串拼接。这没什么不好但和动态生成并不是一回事选错方向会带来麻烦。静态模板生成代码时机在开发期。跑一次模板引擎生成一堆.cs、.java文件放到项目里然后开发再手动检查、提交、入库。这种方式适合“生成一次之后基本不动”的场景比如DTO、简单的数据存取层。好处是生成结果显式可见编译期问题能提前暴露。缺点是灵活性有限不能依据运行时数据随机应变生成一次之后代码就“僵”了。手写代码在所有方式中是最好控制的类型完全确定调试直观语言特性随便用。但它解决不了运行时结构未知的问题而且大量重复的手写代码容易因为复制粘贴改错一两个字段出了问题特别难排查。动态生成则综合考虑了灵活性和运行效率。因为代码是程序里长出来的可以在运行时根据配置、元数据、用户输入来定制行为不需要提前把所有路径写死。代价也很明确调试更麻烦、内存模型更复杂、安全隐患更明显。用一个表格来总结这三者的选择差异方式生成时机灵活性运行性能调试难度典型场景手写代码开发期低最高最低核心业务逻辑静态模板开发期中高中DTO、数据层样板动态生成运行期/编译期高中到高高映射、代理、规则引擎动态生成不是用来“省打字”的它是用来解决手写代码和静态模板都解决不了的问题。如果你只是为了少写几个字段那我劝你直接用静态模板如果你需要的行为在运行前根本不存在那才轮到动态生成上场。2. 我常用的四种代码动态生成技术选型2.1 文本模板最简单的生成方式但别乱拼接先从最朴素的讲起。文本模板就是拿着字符串干活把业务数据填充到一个固定模板里生成新的代码文本然后再把文本丢给编译器或者脚本引擎处理。很多代码生成工具、脚手架就是这么干的比如微服务框架生成Controller平台生成CRUD页面。文本模板的优势是门槛极低胜在直观。string template public int Add(int a, int b) a b;改一下数字拼接一下就完了。配合Razor模板甚至可以直接把C#代码嵌套在HTML风格的标记里生成效率很高。但到了运行时动态生成这个层面纯文本模板有几道坎。第一是转义地狱。代码字符串里有大量引号、反斜杠、大括号模板和最终代码混在一起错一个转义就生成出非法代码。第二是没有语法检查。拼接出来的代码只有在编译那一刻才会报错如果你只在运行时才编译那错误也只会发生在运行时。第三是注入风险。如果模板里允许拼接一些不可信的输入等于把“代码注入”的大门打开了。所以我的建议是文本模板适合在开发期做代码生成器不适合直接在运行时当常规武器用。如果你真的要在运行时使用模板生成源码再编译那请把模板内容从代码逻辑里拆出去并且对所有填入的值做严格的合法性校验。2.2 表达式树把代码当数据玩在.NET生态里我有很长一段时间最常用的动态生成技术是表达式树。它好就好在对C#开发者相对友好一个Lambda表达式既是一段可执行的代码又可以是一个可以被查看、修改、组合的数据结构。看一个最简单但非常典型的例子ParameterExpression age Expression.Parameter(typeof(int), age); ConstantExpression threshold Expression.Constant(18, typeof(int)); BinaryExpression body Expression.GreaterThan(age, threshold); ExpressionFuncint, bool isAdultExp Expression.LambdaFuncint, bool(body, age); Funcint, bool isAdult isAdultExp.Compile(); Console.WriteLine(isAdult(20)); // True Console.WriteLine(isAdult(15)); // False这段代码在运行时动态构造了一个x x 18的表达式然后调用Compile()把它编译成一个委托。整个过程没有反射的频繁调用消耗编译一次之后就是普通方法调用性能非常可观。表达式树最适合的场景是动态查询。比如做高级筛选时用户在前端勾选了“年龄大于18并且城市等于上海”后端不可能提前写好所有组合条件这时候就用表达式树一层一层把查询条件拼出来然后交给EF Core翻译成SQL。甚至还能对表达式树做重写比如把数据库中的软删除标记自动加到查询表达式的Where条件里。表达式树的缺点也很明显。它只能表达一部分C#语法逻辑复杂到一定程度就开始别扭比如循环、try-catch、switch都不好构造。安全上比字符串拼接好一个档次因为你是通过API构造节点而不是解析用户提供的任意代码但仍然要提防传入的字段名、类型名是否合法。2.3 IL.Emit在运行时“焊死”一个方法比表达式树更底层的是直接发出IL指令通过DynamicMethod或者更复杂的AssemblyBuilder在运行时捏造出方法甚至完整的类。这相当于你在内存里开了一个“编译器工厂”自己当汇编器使用。网上有个非常经典的例子动态生成一个两个整数相加的方法var method new DynamicMethod( Add, typeof(int), new[] { typeof(int), typeof(int) }); var il method.GetILGenerator(); il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldarg_1); il.Emit(OpCodes.Add); il.Emit(OpCodes.Ret); var add (Funcint, int, int)method.CreateDelegate(typeof(Funcint, int, int)); Console.WriteLine(add(3, 5));这本质上就是把编译器最终要生成的中间代码提前手写出来。IL.Emit的最大优势是性能天花板极高稳定之后没有任何反射开销很多对象映射器、AOP框架、动态代理库都在用这层技术。但代价也非常直接极难写、极难调试。IL是栈式虚拟机指令一个Ldarg_0、Ldarg_1、Add、Ret顺序错一点运行时就是InvalidProgramException而且你还没有对应的源码行号去排查。我见过有同事因为生成代理类时忘了在分支路径前平衡栈排查了整整两天最后实在看不出问题只好换成表达式树重写。所以我的经验是能上表达式树就别直接上IL.Emit。除非你对CLI验证规则非常熟悉并且有充分的性能测试数据支撑否则没必要为了少掉几十纳秒把人搭进去。2.4 Roslyn 与 Source Generator编译期生成代码的另一条路如果你既想要动态生成的灵活性又不想在运行时承担编译成本和反射开销那可以看看Roslyn和Source Generator。Roslyn本身是C#编译器它把语法树、语义分析、编译打包这些API全部公开了。你可以CSharpSyntaxTree.ParseText解析一段代码用CSharpCompilation.Create构建编译最后Emit到一个内存流里。这是运行时动态编译的完整路径我在下一章会给一个完整例子。Source Generator是更“优雅”的编译期生成方式。它跑在编译器内部可以读取项目里现有类型的属性、字段、方法信息然后生成新的源码文件。简单来说你只需要写一个标记特性生成器就能自动生成一大段实现代码而且这些生成代码是跟着项目编译一起走的运行时完全不需要再动态处理。比如有人写过自动生成强类型HttpClient调用、自动生成INotifyPropertyChanged实现、自动生成DTO到ViewModel的映射代码。每次编译的时候生成器会检查最新代码及时同步生成结果这比手动维护模板文件要靠谱得多。Source Generator目前最大的限制是只能生成代码不能直接修改已有代码。有些AOP需求比如给方法自动加日志就不是它的主战场得靠别的方式配合。不过对于重复模板型问题它已经成为我首选的“静态但智能”的方案。3. 亲手写一个“动态生成源码 动态编译”的完整例子3.1 搭建最小控制台项目纸上谈兵没意思我来带大家跑一个完整的例子。思路是程序运行时用字符串构造一段C#类的源码再用Roslyn把这段源码编译成一个内存中的程序集最后反射创建对象、调用方法。环境准备很简单只需要一个.NET控制台项目然后引入Roslyn的C#编译包。我用的是.NET 8其他版本也没问题。dotnet new console -n DynCodeDemo cd DynCodeDemo dotnet add package Microsoft.CodeAnalysis.CSharp因为接下来要调用CSharpCompilation、MetadataReference这些API装好包之后打开Program.cs即可。3.2 动态生成一个 Calculator 类并编译加载下面这段代码是完整示例。注意我特意用AppContext.GetData(TRUSTED_PLATFORM_ASSEMBLIES)来获取运行时所有受信任的程序集路径这个技巧能避免很多“缺少引用导致编译失败”的坑。using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; class Program { static void Main() { string code using System; namespace DynamicGen.Sample { public class Calculator { public int Multiply(int a, int b) a * b; } } ; SyntaxTree tree CSharpSyntaxTree.ParseText(code, path: Generated_Calculator.cs); string assemblyName DynGen_ Guid.NewGuid().ToString(N); string[] trustedAssemblies ((string)AppContext.GetData(TRUSTED_PLATFORM_ASSEMBLIES)) .Split(Path.PathSeparator); var refs trustedAssemblies .Where(p p.EndsWith(.dll)) .Select(p MetadataReference.CreateFromFile(p)); var compilation CSharpCompilation.Create( assemblyName, new[] { tree }, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using var ms new MemoryStream(); var sw Stopwatch.StartNew(); var emitResult compilation.Emit(ms); sw.Stop(); if (!emitResult.Success) { foreach (var diag in emitResult.Diagnostics.Where(d d.Severity DiagnosticSeverity.Error)) { Console.WriteLine(diag); } return; } ms.Seek(0, SeekOrigin.Begin); Assembly assembly Assembly.Load(ms.ToArray()); Type? calcType assembly.GetType(DynamicGen.Sample.Calculator); object? calcInstance Activator.CreateInstance(calcType!); MethodInfo multiply calcType!.GetMethod(Multiply)!; object? output multiply.Invoke(calcInstance, new object[] { 6, 7 }); Console.WriteLine($Multiply(6,7) {output}Emit耗时约 {sw.ElapsedMilliseconds} ms); } }直接运行应该会输出类似Multiply(6,7) 42Emit耗时约 62 ms的结果。第一次编译偏慢很正常因为Roslyn要加载各种引用和做语法树分析。这个例子虽然简单但已经包含了动态生成技术的完整链路拼源码、跑编译、出程序集、加载类型、创建对象、调用方法。你在真实项目里遇到的动态编译无非是把这个流程做得更封装化、更优化。3.3 关键点引用怎么传、结果怎么缓存看完示例我猜你一定会有一个疑问为什么不能用更简单的方式传引用比如只传typeof(object).Assembly.Location问题在于C#源码里不只是用到System.Object还隐含了一堆运行时的引用。比如使用Console的时候需要System.Console程序集使用Task的时候需要System.Runtime和System.Threading.Tasks。在.NET Core的时代程序集被拆得非常散手动一个个列引用很容易漏。直接用TRUSTED_PLATFORM_ASSEMBLIES把所有运行时用的程序集全部列出来是省事又稳妥的方案。代价是和运行时环境耦合较紧现在用没问题未来如果.net裁剪或单文件发布这里要再想办法。另一个更实际的问题是缓存。动态编译开销不小每次编译几十毫秒甚至更多在Web接口里每请求编译一次分分钟打爆CPU。正确的做法是把编译结果缓存起来按源码的哈希、编译参数做键用ConcurrentDictionary保存编译好的Assembly或者委托。如果有多个实例部署最好再共享一份生成结果只把动态编译当作兜底路径。还有一点这个示例里用反射调用方法反射本身也不是免费的。真正在意性能时可以定义一个接口比如ICalculator让动态生成的Calculator实现这个接口生成成功后用Assembly.GetType拿到类型再Activator.CreateInstance转成接口调用。这样后续调用就和普通接口调用一样快没有Invoke的反射包装。4. 动态生成代码最容易翻车的几个现场4.1 性能坑没有缓存导致每次请求都编译我见过最惨的线上事故就是一个规则引擎项目把用户请求参数拼成代码每次请求都动态编译。上线后一开始没感觉等到流量上来CPU直接被打满响应时间从几十毫秒涨到几秒。原因很简单编译是CPU密集型操作Roslyn光解析代码、做语义检查就要做大量工作再加上没有缓存每个请求都从头走一遍数据库都还闲着CPU先崩了。解决思路分三层。第一层缓存编译结果同一段源码、同一套引用参数只编译一次。第二层缓存委托/对象编译出一个Func...或者类型实例后永久复用不要每次Activator.CreateInstance。第三层从源头减少动态编译次数如果动态代码内容和静态模板相关可以考虑编译期生成把运行时编译变成开发期/启动期一次性工作。动态生成是好工具但一定要意识到它是有成本的。把成本控制在一处而不是让它在热路径上反复发生这比微调编译参数重要得多。4.2 调试坑动态代码报错看不清行号无论动态生成的是源码字符串、表达式树还是IL报错信息都很反人类。字符串源码编译失败时报错会在你生成的那段代码上但如果没有给SyntaxTree指定path行号概念不强有时候你根本不知道是哪个字符出了问题。表达式树抛异常时堆栈里只会出现lambda_method这类名字洗不清到底哪里逻辑错。IL.Emit就更别说了经常是InvalidProgramException堆栈上一片乱码。我的建议是动态生成的代码尽量留下“日志”。生成源码时把最终代码写到一个日志文件里万一编译失败你手里有完整的源文本生成表达式树时用ToString()或者查看DebugView确认结构使用Roslyn时一定给ParseText传path参数这样编译诊断信息里至少能指出文件名。另外给动态类和方法起有意义的名字不要清一色叫c__DisplayClass或者自动生成名。排查问题的时候一个清晰的方法名能救你一命。4.3 安全坑用户输入拼接成代码是自杀行为动态生成技术最危险的地方就是把一段来自外部的字符串当作代码处理。如果用户输入能直接拼进你生成的代码里对方就可以在你的进程里执行任意逻辑。比如动态生成规则时把配置里的字段名直接拼成表达式字符串那么配置里写一句System.IO.File.Delete(xxx)也不是不可能。在运行时动态生成代码必须默认所有外部输入都不可信。第一选表达式树而不是字符串拼接因为表达式树只能按你设定的API构造特定逻辑用户没法塞进一段任意代码。如果必须使用Roslyn编译源码那就得对代码字符串做两层过滤一层是白名单检查只允许出现预设的命名空间和方法名另一层是运行时沙箱把动态程序集加载到自定义的AssemblyLoadContext里限制它对文件、网络、环境的访问权限。不要觉得内部系统就可以不设防。内部系统的攻击面更复杂一旦有人拿到配置项或者接口参数动态生成就可能成为最顺手的攻击入口。4.4 内存坑动态程序集为什么卸载不掉很多人不知道Assembly.Load加载出来的程序集在进程里是“钉子户”不会因为不再使用就被GC回收。你动态生成一千个不同的类它们就占着一千份程序集的元数据内存清都清不掉除非整个进程退出。这个特性和普通对象完全不同也是动态生成套件隐藏的“内存泄漏”来源。微软给出的解法是AssemblyLoadContext可以通过AssemblyLoadContext(true)创建可回收的加载上下文然后把动态生成的程序集Load进去。这样当上下文里的类型实例、委托、类都不再被外部引用时整个上下文可以被GC回收。但这里有一个陷阱哪怕那里面的一个委托还被你的静态集合引用着整个上下文和里面的所有程序集都会被认为是活跃的卸载不到。因此使用可回收加载上下文时一定要记住动态生成对象的引用生命周期。缓存容器里不要无限塞动态类型塞的同时要考虑什么时候移除代理对象不要长期挂在事件上静态字段里尽量不要直接持有动态程序集里的类型。这些细节不注意你可能用了一年可回收加载上下文内存还是在悄悄涨。5. 选型时的几条务实判断标准5.1 什么时候该用动态生成什么时候坚决别用做技术选型最忌讳“因为帅所以用”。我给自己定的判断标准很朴素只有在运行时结构确实不可预知或者反射性能已经成了瓶颈、而常规写法无法解决时才考虑动态生成。适合用动态生成的场景数据驱动的规则引擎配置项可能随时增加表达式ORM/对象映射要按表结构动态读写字段AOP框架要给任意方法生成代理还有高性能比对工具要避开反复的PropertyInfo.GetValue。这些场景的共同点是“必须等运行时才确定”。坚决别用的场景项目里大部分代码其实是固定的只是你嫌DTO字段太多、Controller太啰嗦这种应该用模板生成器或者Source Generator团队没有元编程经验出问题没人能接安全审计严格系统面向不可信用户加沙箱成本和维护成本都很高。这里的“别用”不是说动态生成技术不行而是它的成本在特定场景下不值。5.2 我的几条长期实操心得最后分享几条我自己常年积累的习惯。先讲最重要的一条从表达式树入手而不是一上来就写IL.Emit。表达式树有类型检查可读性好调试也容易能覆盖绝大多数动态逻辑。只有当你确定了表达式树满足不了性能需求才去考虑IL。第二条动态生成代码一定要“里外分开”。生成逻辑和业务逻辑分开生成结果和缓存逻辑分开生成过程中的日志和异常也要单独管理。否则用不了几个月维护的人就会开始诅咒当初把它写进业务代码核心的那个人。第三条把生成代码的输入、生成出的源码、编译诊断信息都记下来。我几乎所有涉及动态编译的模块都会在环境变量开关打开时把生成的源码写到临时目录。线上出问题时这可能就是唯一的排查线索。第四条选择代码动态生成技术时先问一句“这个场景是不是可以前置到编译期”。能前置就别拖到运行时。Source Generator在编译期就把代码生成了运行时零开销比运行时编译优雅得多。只有那些前置不了的场景才需要运行时动态生成。代码动态生成是一套强大但需要敬畏的技术。用好了它能帮你把重复劳动和运行时不确定性一起碾碎用不好它能让你的系统变得又慢又难调。我的体会是别被那些花哨的底层技巧迷住先想清楚问题发生在哪个阶段再决定让代码在什么时候、以什么方式生出来。这是最关键的。