多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

.NET Emit 模块构建:DefineDynamicModule 参数详解与持久化实践

.NET Emit 模块构建:DefineDynamicModule 参数详解与持久化实践 1. 从 AssemblyBuilder 到 ModuleBuilder为什么模块是 Emit 的必经之路很多人第一次接触 .NET Emit注意力几乎都放在AssemblyBuilder上——毕竟程序集是最终产物能保存成 dll、能加载运行看起来它才是主角。但真正动手写过动态生成代码的人很快会发现AssemblyBuilder本身几乎不提供任何往里面塞东西的能力。你没法直接往程序集里加一个类也没法直接定义方法体。中间必须经过一层ModuleBuilder。这个设计不是 .NET 独有的怪癖而是沿用了 CLI 规范里程序集包含模块、模块包含类型、类型包含成员的经典分层。一个程序集可以包含一个或多个模块模块才是真正承载元数据和 IL 的容器。在绝大多数场景下我们只用一个模块所以很容易忽略它的存在但只要你开始做多模块程序集、或者需要精细控制元数据表模块这一层就绕不过去。这篇是 Emit 系列第三部分专门讲模块的构建。我会把AssemblyBuilder.DefineDynamicModule的每个参数、模块与程序集的关系、动态模块和持久化模块的区别、以及实际写代码时容易踩的坑全部拆开讲清楚。如果你已经能跑通定义程序集那一步但卡在怎么往里面加类型上这篇就是给你准备的。读完之后你应该能独立完成一个模块的创建、命名、持久化配置并且知道什么情况下该用Run、什么情况下该用RunAndSave。先说结论模块是程序集和类型之间的桥梁DefineDynamicModule是 Emit 流程里第一个真正需要你做出决策的 API。决策点包括模块名、是否持久化、是否只跑不存。这些选择会直接影响后续能不能保存、能不能调试、能不能被其他程序集正常引用。下面逐层展开。2. DefineDynamicModule 的参数到底在控制什么2.1 方法签名与两个重载AssemblyBuilder上定义模块的方法有两个重载先看签名public ModuleBuilder DefineDynamicModule(string name); public ModuleBuilder DefineDynamicModule(string name, string fileName); public ModuleBuilder DefineDynamicModule(string name, bool emitSymbolInfo); public ModuleBuilder DefineDynamicModule(string name, string fileName, bool emitSymbolInfo);实际常用的就是带fileName的那个。参数含义如下name模块的逻辑名称。这个名字会写进元数据的 Module 表运行时通过Module.Name能读到。它不要求是合法文件名但强烈建议用有意义的标识比如MyDynamicModule。fileName模块持久化到磁盘时的文件名。只有传了这个参数模块才具备可保存的资格。不传的话模块只能存在于内存中。emitSymbolInfo是否生成调试符号PDB。设为true时配合AssemblyBuilderAccess.RunAndSave或Save可以生成 PDB方便调试动态生成的代码。这里有个反直觉的点name和fileName是两个独立的概念。你可以让模块名叫Logic文件名叫Logic.dll也可以让它们完全不一致。运行时读Module.Name拿到的是name而磁盘上的文件叫fileName。我见过有人把两者写成一样结果在反射时对不上号排查半天。2.2 AssemblyBuilderAccess 决定了模块的命运模块能不能保存根子上取决于创建AssemblyBuilder时选的访问模式。这个枚举在第二部分应该讲过但和模块强相关必须再强调一次访问模式能否保存能否运行典型用途Run否是纯内存动态代理、临时计算RunAndSave是是生成后立即用同时落盘Save是否只生成不执行离线产出 dllRunAndCollect否是可回收的动态程序集配合 collectible 场景关键规则如果程序集是Run模式调用DefineDynamicModule时传fileName会直接抛NotSupportedException。这个异常信息不算特别友好很多人第一次遇到会懵。记住这条对应关系就不会踩Run/RunAndCollect→ 只能定义内存模块不能带 fileName。RunAndSave/Save→ 可以带 fileName模块可持久化。我个人的习惯是只要有一丝可能需要保存就直接上RunAndSave。它的开销比Run大不了多少但省去了后期改模式的麻烦。因为访问模式是在DefineDynamicAssembly时定死的中途改不了改就得重建整个程序集。2.3 模块名重复会怎样同一个程序集里模块名必须唯一。如果你用同一个name调两次DefineDynamicModule第二次会抛ArgumentException提示模块已存在。这个约束在单模块场景下无所谓但如果你在做插件系统、需要动态往一个程序集里塞多个模块就得自己维护一个命名计数器或者用 GUID 后缀。var asm AssemblyBuilder.DefineDynamicAssembly( new AssemblyName(MultiModuleDemo), AssemblyBuilderAccess.RunAndSave); var mod1 asm.DefineDynamicModule(Core, Core.dll); var mod2 asm.DefineDynamicModule(Extensions, Extensions.dll); // 再来一次 asm.DefineDynamicModule(Core, Core2.dll) 会抛异常顺带一提fileName在同一个程序集内也建议保持唯一虽然规范上没强制但保存时如果两个模块指向同一个文件名后写的会覆盖先写的结果就是丢模块。3. 单模块与多模块什么时候真的需要拆3.1 默认单模块就够了99% 的 Emit 场景一个程序集配一个模块完全够用。你定义的所有类型、方法、字段全塞进这一个模块里保存出来就是一个正常的 dll。C# 编译器自己也是这么干的——你写一个 csproj编译出来就是一个程序集一个模块。所以如果你只是想做动态代理、表达式树替代、或者运行时生成 DTO不要纠结多模块直接单模块走到底。多模块带来的复杂度跨模块引用、保存顺序、加载顺序在收益面前完全不划算。3.2 多模块的真实使用场景那什么时候需要多模块我总结了几类按功能切分的大型动态程序集比如一个规则引擎核心逻辑和用户自定义规则分属不同模块便于单独更新。需要独立 PDB 的场景不同模块可以生成各自的符号文件调试时定位更清晰。历史遗留的 netmodule 链接早期 .NET 支持把多个 .netmodule 文件链接成一个程序集这种模式下每个 netmodule 就是一个模块。需要提醒的是多模块程序集在 .NET Core / .NET 5 上的支持是有限的。AssemblyBuilder.Save在 .NET Core 上曾经长期不可用后来虽然恢复了部分能力但多模块保存的兼容性依然不如 .NET Framework 时代。如果你在 .NET Core 上做多模块务必先写个最小 demo 验证保存和加载都能跑通再投入正式开发。3.3 模块与类型的归属关系一个类型只能属于一个模块。ModuleBuilder.DefineType创建的类型其Module属性就指向这个模块。跨模块引用类型时元数据里记录的是模块 类型名的组合。这意味着如果你把类型 A 放在模块 M1类型 B 放在模块 M2B 引用 A 时运行时需要能同时解析到 M1 和 M2。在单程序集多模块的情况下这个解析由程序集内部完成通常没问题。但如果模块被单独保存成 netmodule 再链接解析链就复杂了。我的建议是除非你明确知道自己在做什么否则让所有类型待在同一个模块里。4. 持久化模块的完整操作链路4.1 从定义到保存的最小可运行示例光说理论没用直接上一个能跑的完整例子。目标动态生成一个程序集里面有一个模块模块里有一个类类里有一个返回字符串的方法最后保存成 dll。using System; using System.Reflection; using System.Reflection.Emit; class Program { static void Main() { // 1. 定义程序集选择 RunAndSave 模式 var asmName new AssemblyName(PersistDemo); var asm AssemblyBuilder.DefineDynamicAssembly( asmName, AssemblyBuilderAccess.RunAndSave); // 2. 定义模块指定文件名 var mod asm.DefineDynamicModule(PersistDemoModule, PersistDemo.dll); // 3. 在模块里定义类型 var tb mod.DefineType(HelloWorld, TypeAttributes.Public | TypeAttributes.Class); // 4. 定义方法 var mb tb.DefineMethod(SayHello, MethodAttributes.Public | MethodAttributes.Static, typeof(string), Type.EmptyTypes); // 5. 写 IL直接返回一个字符串 var il mb.GetILGenerator(); il.Emit(OpCodes.Ldstr, Hello from Emit!); il.Emit(OpCodes.Ret); // 6. 完成类型创建 var helloType tb.CreateType(); // 7. 立即调用验证 var result helloType.GetMethod(SayHello).Invoke(null, null); Console.WriteLine(result); // 8. 保存到磁盘 asm.Save(PersistDemo.dll); Console.WriteLine(Saved.); } }这段代码有几个细节值得单独拎出来说。4.2 CreateType 的时机不能乱tb.CreateType()是一个分水岭。在它之前你往TypeBuilder上加方法、字段、属性都是待定状态调用之后类型被真正固化进模块的元数据再想改就晚了。常见错误是先CreateType然后想起来还有个方法没加回去调DefineMethod直接抛InvalidOperationException。所以正确的顺序永远是定义类型 → 定义所有成员 → 写所有 IL → 最后 CreateType。如果类型之间有继承或引用关系被引用的类型要先CreateType否则引用它的类型在创建时会找不到目标。4.3 Save 的路径与覆盖行为asm.Save(PersistDemo.dll)里的路径是相对于当前工作目录的。如果你传的是相对路径保存位置取决于进程的Environment.CurrentDirectory这在服务端程序里经常不是你以为的那个目录。稳妥做法是用绝对路径var outputPath Path.Combine(AppContext.BaseDirectory, PersistDemo.dll); asm.Save(outputPath);另外Save会直接覆盖同名文件不会提示。如果你在循环里反复生成记得先清理旧文件或者用带时间戳的文件名避免读到上一次的残留产物。4.4 保存后怎么加载回来保存出来的 dll 就是一个普通程序集可以用Assembly.LoadFrom加载var loaded Assembly.LoadFrom(outputPath); var type loaded.GetType(HelloWorld); var method type.GetMethod(SayHello); Console.WriteLine(method.Invoke(null, null));这里有个坑如果动态程序集还在当前进程里以RunAndSave模式存活直接LoadFrom同一个文件可能拿到的是内存里的那个程序集而不是磁盘上的。因为程序集加载有身份缓存机制同名程序集只会加载一次。要验证磁盘产物最好另起一个进程或者用Assembly.Load(File.ReadAllBytes(outputPath))这种字节数组方式强制从磁盘读。5. 动态模块与符号信息调试动态代码的正确姿势5.1 emitSymbolInfo 打开后发生了什么当你在DefineDynamicModule里把emitSymbolInfo设为true并且程序集是RunAndSave或Save模式时保存模块会额外生成一个 PDB 文件。这个 PDB 里记录了 IL 偏移和源代码行号的映射——但问题是Emit 生成的代码哪来的源代码行号答案是需要你手动通过ILGenerator.MarkSequencePoint来标注。不标注的话PDB 里没有行号信息调试器只能显示反编译的 IL体验很差。var mod asm.DefineDynamicModule(DebugModule, DebugModule.dll, true); var tb mod.DefineType(DebugType, TypeAttributes.Public); var mb tb.DefineMethod(Calc, MethodAttributes.Public | MethodAttributes.Static, typeof(int), new[] { typeof(int), typeof(int) }); var il mb.GetILGenerator(); var sp mb.DefineParameter(1, ParameterAttributes.None, a); // 标注序列点模拟第 10 行 il.MarkSequencePoint(/* ISymbolDocumentWriter */ doc, 10, 1, 10, 20); il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldarg_1); il.Emit(OpCodes.Add); il.Emit(OpCodes.Ret);MarkSequencePoint需要一个ISymbolDocumentWriter通过ModuleBuilder.DefineDocument获取。这套东西配置起来比较繁琐实际项目中除非你确实需要单步调试动态代码否则不建议开。开了之后保存变慢、文件变大收益有限。5.2 不开符号时的调试替代方案大多数时候调试动态代码靠的是把生成的 IL 反编译出来看。保存 dll 之后用 ildasm 或者第三方反编译工具打开逐条核对 IL 是否符合预期。这比配 PDB 快得多。另一个实用技巧是在生成 IL 的同时把每条 Emit 调用和对应的意图用日志打出来。比如封装一个EmitLogger每次il.Emit都记录 opcode 和操作数。这样出问题时日志就是你的源代码。void EmitLogged(ILGenerator il, OpCode op, object operand null) { Console.WriteLine(${il.ILOffset:X4}: {op.Name} {operand}); if (operand null) il.Emit(op); else il.Emit(op, (dynamic)operand); }这个习惯我保持了多年尤其在写复杂方法体时日志能帮你快速定位是哪条指令的栈平衡出了问题。6. 模块构建中的高频异常与排查链路6.1 NotSupportedExceptionRun 模式带 fileName现象调DefineDynamicModule(M, M.dll)抛NotSupportedException消息类似该程序集不支持保存。根因程序集创建时用了AssemblyBuilderAccess.Run这种模式下的模块不能有文件名。排查回头看DefineDynamicAssembly的第二个参数。改成RunAndSave即可。注意这个改动必须在定义程序集时就做事后无法补救。6.2 ArgumentException模块名重复现象第二次用相同 name 定义模块时抛异常。根因同一程序集内模块名必须唯一。排查检查是否有重复调用或者用命名策略序号、GUID保证唯一。如果是在循环里定义模块几乎肯定是这里出的问题。6.3 InvalidOperationExceptionCreateType 后修改类型现象CreateType之后再调DefineMethod或DefineField抛异常。根因类型已固化元数据不可再变。排查调整代码顺序把所有成员定义和 IL 生成放在CreateType之前。如果类型之间有依赖先创建被依赖的类型。6.4 TypeLoadException保存后加载失败现象Save成功但LoadFrom时抛TypeLoadException提示找不到某个类型或方法。根因通常是 IL 里引用了不存在的成员或者跨模块引用没解析到。Emit 阶段不会校验 IL 的语义正确性只有加载时才会暴露。排查链路先用反编译工具打开保存的 dll确认类型和方法都在。检查 IL 里Call、Callvirt、Newobj的目标方法签名是否和实际定义一致。如果是跨模块引用确认被引用模块也被正确保存和加载。用il.Emit时传错MethodInfo是高频原因尤其是重载方法选错了。我遇到过一次动态生成的类型继承自一个动态生成的基类基类在模块 A子类在模块 B保存时只保存了 B加载时自然找不到基类。解决办法是把两个类型放同一个模块或者确保两个模块都被保存。6.5 栈不平衡导致的 InvalidProgramException现象类型能创建、能保存但调用方法时抛InvalidProgramException。根因IL 的求值栈不平衡。比如Ldarg_0压了一个 intAdd需要两个 int栈上不够运行时直接判定程序非法。排查这是 Emit 里最考验基本功的错误。我的做法是每写几条指令就画一次栈状态用注释标出当前栈深度和类型。对于复杂方法先在纸上把栈变化推演一遍再写代码。工具方面ILGenerator本身不提供栈校验但反编译工具通常会标出异常位置结合日志能定位到具体指令。7. 模块层面的性能与内存考量7.1 动态程序集的内存占用Run模式创建的动态程序集默认会一直驻留在内存里直到 AppDomain 卸载.NET Core 里没有 AppDomain 卸载的概念基本就是进程生命周期。如果你在长时间运行的服务里反复生成动态类型内存会持续增长。RunAndCollect模式就是为解决这个问题引入的。它创建的程序集可以被 GC 回收前提是没有强引用。用法var asm AssemblyBuilder.DefineDynamicAssembly( new AssemblyName(Collectible), AssemblyBuilderAccess.RunAndCollect); var mod asm.DefineDynamicModule(CollectibleModule); // 用完置空引用等待 GC注意RunAndCollect不能保存也不能带 fileName。它适合生成即用、用完即弃的场景比如临时序列化代理。7.2 模块数量对加载速度的影响单模块程序集的加载是最快的。多模块程序集在加载时运行时需要解析模块间的引用关系模块越多解析开销越大。我做过一个粗略测试同样数量的类型拆成 10 个模块比放在 1 个模块里首次加载慢大约 15% 到 30%具体取决于引用密度。所以从性能角度能单模块就单模块。多模块只在有明确架构需求时才用。7.3 保存大模块的耗时Save是一个同步的、全量写盘操作。模块越大耗时越长。如果你在 Web 请求里动态生成并保存程序集很可能成为性能瓶颈。我的建议是把生成和保存放到后台任务里或者启动时一次性生成好运行时只加载不生成。动态生成本身不慢慢的是写盘和后续的 JIT 编译。8. 把模块构建封装成可复用的工厂写多了会发现每次定义程序集、模块、类型的代码高度重复。封装一个工厂能省不少事。下面是我自己在用的一版简化实现public sealed class DynamicAssemblyFactory { private readonly AssemblyBuilder _asm; private readonly ModuleBuilder _mod; public DynamicAssemblyFactory(string asmName, string modName, string fileName) { _asm AssemblyBuilder.DefineDynamicAssembly( new AssemblyName(asmName), AssemblyBuilderAccess.RunAndSave); _mod _asm.DefineDynamicModule(modName, fileName); } public TypeBuilder DefineClass(string name, Type? baseType null) { return _mod.DefineType( name, TypeAttributes.Public | TypeAttributes.Class, baseType ?? typeof(object)); } public void Save(string path) _asm.Save(path); }用起来就清爽很多var factory new DynamicAssemblyFactory(MyAsm, MyMod, MyAsm.dll); var tb factory.DefineClass(MyClass); // ... 定义成员、写 IL ... tb.CreateType(); factory.Save(MyAsm.dll);封装时要注意几点工厂持有AssemblyBuilder和ModuleBuilder的引用生命周期要和生成过程一致Save只能调一次重复调会抛异常如果要做多模块工厂需要暴露DefineModule方法而不是在构造里定死一个模块。9. 关于模块这一层我踩过的几个真实坑第一个坑是模块名和文件名混用。早期我图省事把两者写成一样结果在做反射查找时用Module.Name去匹配文件名偶尔对不上。后来统一约定模块名用 PascalCase 的逻辑名文件名用对应的 dll 名两者通过一个映射表关联再没出过问题。第二个坑是在Run模式下试图保存。这个前面讲过但值得再提一次因为它太容易犯了。尤其是从别人的示例代码里抄的时候示例用的是Run你直接拿来改成保存就炸了。养成习惯只要涉及保存第一件事就是确认访问模式。第三个坑是忘记CreateType就保存。Save不会报错但保存出来的 dll 里没有你定义的类型因为类型还没固化。这个错误很隐蔽因为保存本身成功了只有加载时才发现类型缺失。我的对策是在工厂的Save方法里加一个断言检查所有已定义类型是否都已CreateType。第四个坑是多模块保存顺序。如果模块之间有引用被引用的模块要先保存。虽然AssemblyBuilder.Save内部会处理大部分顺序问题但在某些边界情况下先保存引用方会导致元数据解析失败。稳妥做法是按依赖顺序保存或者干脆合并成单模块。10. 模块构建之后下一步该往哪走模块建好之后接下来的工作全部围绕ModuleBuilder.DefineType展开——定义类型、字段、方法、属性、事件然后往方法体里写 IL。这是 Emit 最核心也最繁琐的部分也是后面几部分要重点讲的内容。在进入类型构建之前建议你先把手头的模块构建流程跑通能定义一个程序集、一个模块、一个空类型保存成 dll再用反编译工具打开确认结构正确。这个最小闭环打通了后面的类型和成员构建才有稳固的地基。我个人在实际操作中的体会是模块这一层看似简单但它决定了你后续所有操作的边界。访问模式选错后面全白搭模块名和文件名没规划好反射和加载时处处别扭符号信息开不开影响的是整个调试体验。花十分钟把这一层的决策想清楚比后面花两小时排查异常划算得多。最后分享一个小技巧如果你不确定某个 Emit API 的行为最快的验证方式不是查文档而是写一个五行代码的最小 demo 跑一遍。Emit 的很多行为在不同 .NET 版本上有细微差异文档往往滞后实测才是唯一可信的来源。
返回列表