C#动态编译与动态加载:实现不停机热更新的完整指南

发布时间:2026/10/21 21:53:43
C#动态编译与动态加载:实现不停机热更新的完整指南 简介C#动态加载与动态编译的完整实例源码包适合掌握基础C#语法、希望深入理解反射及CodeDom应用的中级开发者。包内共18个文件包含8个cs源码文件、3个resx资源文件、2个ico图标、1个csproj工程文件、1个exe可执行程序及sln解决方案等完整覆盖从源码编写到编译运行的整个项目结构。示例中演示了通过Assembly.LoadFrom加载已有程序集并使用CSharpCodeProvider在内存中编译字符串代码最终通过CompilerResults获取程序集实例可帮助读者理解插件化扩展、运行时生成代码等典型场景。资源包大小仅82KB轻量易用已有396人学习下载作为入门到进阶的参考项目具有较强的实用价值。1. 动态加载代码到底能解决什么给C#程序一个不停机的补丁能力我维护过的一个订单系统中二十多条促销规则由运营配置每条规则本质都是一段独立计算逻辑。过去每调整一条都要改代码、编译、发布、重启进程最频繁的一天下午上线了六次。后来我把这些规则改成C#源码字符串存入配置表运行时用Roslyn动态编译成程序集再加载执行规则改完立刻生效服务全程不需要重启。这套C#动态加载动态编译的实例源码核心价值就是解决这类“逻辑频繁变化”的痛点。它覆盖了字符串编译、程序集装载、反射调用、脚本引擎以及插件化改装的完整链路适合正在做规则引擎、自定义报表公式、插件系统的开发者拿来即用。2. 技术选型与整体架构三条动态编译路线一次看懂2.1 三条路线对比CodeDom / CSharpScript / CSharpCompilationC#做动态加载市面上常见的有三套方案。我最早入坑时选的CodeDom后来迁移到跨平台版本时发现这条路走不通。第一套是CodeDom出自System.CodeDom.Compiler。它是.NET Framework时代的产物很多老代码生成器都用它。但到了.NET Core和后续的跨平台版本这套编译器服务基本废弃。如果你的项目还停留在.NET Framework 4.x上可以用它只要往新框架上迁赶紧换。第二套是CSharpScript来自Microsoft.CodeAnalysis.CSharp.Scripting。它是Roslyn提供的高层脚本API擅长执行表达式或一段短逻辑比如订单金额超过阈值且会员等级达标这类判断。代码量极小三四行就能跑起来但局限性也很明显脚本模型有自己的会话机制定义完整类、多方法甚至多个文件时很别扭返回值也受脚本模型约束。第三套是CSharpCompilation来自Microsoft.CodeAnalysis.CSharp。这是Roslyn的编译层API从语法树、编译选项、引用集合到Emit产物全部由调用方自己控制。说白了就是自己造编译流水线。它能输出完整程序集加载后能用反射拿到类型、创建实例、调用方法。插件系统基本都是走这条路。三套方案怎么选我带过几个项目后通常按这张表判断使用场景推荐方案理由简单表达式、动态规则CSharpScript开发成本最低表达式天然匹配.NET Framework旧项目CodeDom兼容性是唯一选择完整类、方法、多文件插件CSharpCompilation能输出独立程序集需要卸载、隔离、热替换CSharpCompilation LoadContext只有程序集级别能谈回收这套源码里覆盖了CSharpCompilation和CSharpScript两条线核心实现走CSharpCompilation因为它能承载完整的类定义还能和宿主程序之间传对象。2.2 从源码字符串到可调用对象的完整数据流动态加载不是“黑匣子”它是一条有明确节点的流水线。我习惯把它拆成四段编译、装载、类型解析、调用。第一段源码字符串进入编译器。用CSharpSyntaxTree.ParseText解析成语法树再交给CSharpCompilation.Create组装编译单元。这个阶段有两个关键参数OutputKind.DynamicallyLinkedLibrary指定输出类库格式MetadataReference引用集合决定编译器能看见哪些程序集引用不全直接编译失败。第二段Emit把编译结果写入内存流。这一步返回的CompilationResult里有个Success标志失败时Diagnostics集合里全是错误信息包含了错误码、代码位置、详细描述我一般直接拼字符串抛出来调试时一眼定位。第三段程序集装载。最简单的做法是Assembly.Load(byte[])一把梭把整个程序集载入默认上下文。代价是无法卸载进程内永远驻留。要支持热更新得用AssemblyLoadContext的可回收能力让GC在无引用时回收程序集占用的内存。第四段反射调用。Assembly.GetType找类型Activator.CreateInstance建实例MethodInfo.Invoke触发方法。动态代码和宿主之间的类型检查全部发生在运行时所以动态代码必须实现一个宿主早就定义好的接口调用端才能用强类型转换而不是靠字符串匹配去找方法。2.3 先定契约再写代码动态执行接口的设计在写任何编译工具类之前先定义一个接口。这是整个设计里最不能跳的一步。动态代码之所以能被安全调用靠的不是反射能力而是有了一个双方共享的强类型契约。public interface IDynamicExecutor { object? Execute(IDictionarystring, object? context); }逻辑说明宿主程序在动态编译前会先检查目标类型是否实现了这个接口实现后才允许调用。动态代码的作者看到这个接口就能明确两件事能拿到什么数据该返回什么类型。接口就是宿主和插件之间的协议协议稳定插件才好写。参数说明context是个通用字典订单金额、会员等级、商品分类这些业务数据都往里面塞。返回值设计成object?一切类型都能接纳具体语义由调用方按约定强转。接口方法越稳定动态代码的编写成本越低这是插件化体系能长期存活的核心原因。3. 核心模块拆解从源码字符串到可执行对象的完整链路3.1 编译前准备引用集合是第一个坑动态编译出来的程序集要引用的不是源码文件而是程序集元数据。Roslyn需要一个MetadataReference列表把所有运行时程序集的路径登记进去。常见做法是读取运行时配置里的TRUSTED_PLATFORM_ASSEMBLIES把框架程序集全部加入引用池。private static ListMetadataReference BuildReferencePool() { var refs new ListMetadataReference(); // 读取运行时自带的全部程序集路径 var tpa ((string)AppContext.GetData(TRUSTED_PLATFORM_ASSEMBLIES)!) .Split(Path.PathSeparator); foreach (var path in tpa) { refs.Add(MetadataReference.CreateFromFile(path)); } // 宿主自身的程序集也要加入动态代码要引用契约接口 refs.Add(MetadataReference.CreateFromFile( typeof(IDynamicExecutor).Assembly.Location)); return refs; }逻辑说明TRUSTED_PLATFORM_ASSEMBLIES是运行时暴露的系统程序集清单把清单里的每个dll路径都包装成MetadataReference加入引用池。这一招能覆盖掉大部分“找不到类型”的编译错误。宿主程序集单独补进去否则动态代码里写IDynamicExecutor直接报错。参数说明MetadataReference.CreateFromFile的参数是dll路径。每次编译都重新CreateFromFile有IO开销我一般在引擎初始化时建一次引用池之后所有编译共享这份列表。3.2 语法树解析与内存编译Emit到MemoryStream编译阶段是整条链路的心脏。源码字符串在这个阶段变成语法树再变成程序集字节流。using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using System.Reflection; public static class DynamicCompiler { public static Assembly Compile(string code, ListMetadataReference refs) { // 1. 源码字符串转语法树允许使用最新语法特性 var syntaxTree CSharpSyntaxTree.ParseText( code, new CSharpParseOptions(LanguageVersion.Latest)); // 2. 编译选项输出类库不生成可执行文件 var options new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary); // 3. 创建编译单元程序集名用Guid避免冲突 var compilation CSharpCompilation.Create( assemblyName: Dynamic_ Guid.NewGuid().ToString(N), syntaxTrees: new[] { syntaxTree }, references: refs, options: options); // 4. 编译结果写入内存流 using var ms new MemoryStream(); var emitResult compilation.Emit(ms); // 5. 编译失败时把错误信息拼成异常抛出 if (!emitResult.Success) { var errors string.Join(\n, emitResult.Diagnostics .Where(d d.Severity DiagnosticSeverity.Error) .Select(d d.ToString())); throw new InvalidOperationException(动态编译失败\n errors); } // 6. 从字节流加载程序集 ms.Seek(0, SeekOrigin.Begin); return Assembly.Load(ms.ToArray()); } }逻辑说明ParseText只做语法分析不做语义分析类型绑定和引用解析在Create阶段完成。Emit执行结束后检查Diagnostics只取Error级别的诊断警告不会阻断产物生成。最后从内存流里读出字节数组交给Assembly.Load加载成程序集。参数说明assemblyName每次用Guid生成随机名避免动态程序集重名冲突。CSharpParseOptions里的LanguageVersion.Latest允许动态代码使用较新的C#语法比如记录类型、模式匹配这些特性避免代码里写var结果编译不过。MemoryStream用using包住Emit完成后字节已经复制到托管数组流关闭不影响程序集使用。3.3 程序集装载Assembly.Load和AssemblyLoadContext的取舍Assembly.Load加载程序集进入默认上下文简单直接但没法卸载。每编译一次内存里就多一份程序集。规则改个几十次内存占用明显上涨。要支持热更新得用AssemblyLoadContext的可回收上下文。using System.Runtime.Loader; public class DynamicLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; public DynamicLoadContext(string pluginPath) : base(isCollectible: true) { _resolver new AssemblyDependencyResolver(pluginPath); } protected override Assembly? Load(AssemblyName assemblyName) { // 先从插件目录解析依赖 string? path _resolver.ResolvePathToAssembly(assemblyName); if (path ! null) { return LoadFromAssemblyPath(path); } // 解析不到就回退默认加载上下文 return Default.LoadFromAssemblyName(assemblyName); } }逻辑说明isCollectible设为true这个上下文可以被GC回收。AssemblyDependencyResolver负责把插件目录下的依赖dll解析进来宿主主目录下的程序集回退到默认上下文。这样插件目录里自带一份第三方库宿主有另一份两边的版本互不干扰。参数说明pluginPath传的是动态代码生成的dll所在目录。Load方法里ResolvePathToAssembly优先找插件目录的同名程序集找到就用LoadFromAssemblyPath加载找不到就回退默认上下文。跑在Windows和Linux上的行为一致跨平台没问题。3.4 反射调用找到类型、创建实例、执行方法程序集有了接下来的步骤是从程序集里找到目标类型创建实例通过接口调用方法。public static object? Run(string code, string typeName, IDictionarystring, object? context) { // 1. 编译拿到程序集 var assembly DynamicCompiler.Compile(code, BuildReferencePool()); // 2. 从程序集里找目标类型 var type assembly.GetType(typeName) ?? throw new InvalidOperationException($找不到类型{typeName}); // 3. 创建实例 var instance Activator.CreateInstance(type); // 4. 强转成契约接口后调用 if (instance is IDynamicExecutor executor) { return executor.Execute(context); } throw new InvalidOperationException(动态类型未实现 IDynamicExecutor); }逻辑说明instance is IDynamicExecutor这一步是安全边界把动态世界的对象和静态世界的接口钉在一起。所有动态代码都只能通过契约入口收发数据反射的最终目的是找到那个能强转成接口的对象而不是直接反射调用任意方法。这样即使动态代码本身写得再野宿主能触及的也只有Execute这一个入口。参数说明typeName是全限定名称比如MyRules.DiscountRule。GetType找不到类型时返回null异常信息直接显示目标类型名方便核对。context在第一步就准备好订单金额、会员等级这些数据在执行前填进去。4. 脚本引擎与参数传递让动态代码不再是孤岛4.1 三行代码跑起CSharpScript如果场景只是轻量规则判断不想定义完整类CSharpScript这种脚本API更直接。它把语法树构建、编译、执行全封装了开发成本最低。using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; public class ScriptEngine { private readonly ScriptOptions _options; public ScriptEngine() { var tpa ((string)AppContext.GetData(TRUSTED_PLATFORM_ASSEMBLIES)!) .Split(Path.PathSeparator); _options ScriptOptions.Default .AddReferences(tpa) .AddImports(System, System.Linq, System.Collections.Generic); } public async Taskobject? RunAsyncT(string code, T globals) { var script CSharpScript.Create(code, _options, typeof(T)); script.Compile(); var state await script.RunAsync(globals); return state.ReturnValue; } }逻辑说明CSharpScript.Create把源码和配置绑定第三个参数typeof(T)指定globals类型脚本里能直接访问该类型的公开属性。Compile()提前编译避免首次执行时的编译开销。RunAsync返回ScriptState对象。ReturnValue是脚本里最后一个表达式的值。参数说明AddReferences加的是程序集路径数组上次踩过的缺引用坑在这里同样成立。AddImports给脚本预置using脚本里不用再写using System.Linq。泛型T是globals上下文类型脚本里所有参数访问都绑定在它身上编译期就能查出属性拼写错误。4.2 业务上下文注入把订单和会员信息传给脚本脚本引擎和宿主的数据交换全靠globals对象。常规做法是定义一个业务上下文类属性全部按业务含义命名公开给脚本访问。public class RuleContext { // 订单金额单位元 public decimal OrderAmount { get; set; } // 会员等级Normal / Silver / Gold / Platinum public string MemberLevel { get; set; } string.Empty; // 历史订单次数 public int OrderCount { get; set; } // 脚本里可以直接调用的判断方法 public bool IsVip() MemberLevel Gold || MemberLevel Platinum; }逻辑说明脚本里可以直接用OrderAmount、MemberLevel、IsVip()Roslyn会在编译期绑定到RuleContext的属性和方法上。属性名写错、类型不匹配编译阶段直接报错不会拖到运行时才炸。脚本返回的object?再强转成decimal或bool宿主拿到结果后决定走哪条业务分支。参数说明属性名就是脚本里的变量名命名规则要提前定好。我一般会在规则编写文档里规定清楚订单金额必须叫OrderAmount会员等级必须叫MemberLevel避免业务方写规则时反复问该传什么字段。4.3 模板代码一个订单折扣脚本的完整示例脚本存在数据库或配置文件里下面是一段实际能跑的折扣规则模板// 脚本可以直接使用 RuleContext 的属性做逻辑判断 if (OrderAmount 1000 IsVip()) { return OrderAmount * 0.85m; // VIP 85折 } else if (OrderCount 5) { return OrderAmount * 0.9m; // 老客户9折 } else { return OrderAmount; }逻辑说明这段代码不是拼字符串塞进主程序而是被Roslyn当作独立脚本编译执行。规则调整时只改数据库里的这段文本删掉对应缓存下次请求自动用新逻辑整个流程宿主进程不重启。参数说明0.85m是decimal字面量脚本返回值会按decimal强转。如果写成0.85double字面量返回到宿主再强转decimal会有精度差异。规则编写规范里我一般强制要求所有金额运算统一decimal宁多写一个m后缀不吃精度的亏。5. 避坑指南动态编译五个最常见的翻车现场5.1 编译报错CS0246引用的类型找不到现象动态代码里用了System.Text.Json或宿主自定义的DTO类型编译阶段报CS0246“找不到类型或命名空间”。原因动态编译的引用集合不完整。Roslyn不会自动帮你找引用没加进MetadataReference列表的程序集编译器就是看不见。解决引用池不要手动一个个建用TRUSTED_PLATFORM_ASSEMBLIES把框架程序集全部拉进来。宿主自己的程序集单独加。每新增一个宿主内部DTO类就把所在程序集路径加入引用池加完跑一条冒烟测试验证。5.2 程序集加载后无法卸载内存持续上涨现象动态代码被反复更新进程内存曲线一路上扬用Assembly.Load加载的旧程序集怎么都释放不掉。原因Assembly.Load把程序集放进默认加载上下文生命周期和进程一致。GC无法回收它持有的类型、静态字段和方法表。解决改用AssemblyLoadContextisCollectible设为true。更新规则时卸载旧上下文新代码进新上下文。注意一点ALC里只要还有一个被引用的对象Unload就会失败。调试时想办法把业务侧所有接口引用置空调用Unload后等待GC真正回收。5.3 双引号嵌套地狱动态代码里写字符串字面量现象动态代码本身是字符串存在配置表里代码内部又写了字符串字面量两层引号嵌套还能靠转义硬扛三层嵌套直接让人看不下去。原因源码存入数据库是字符串动态代码内部的字符串字面量又要引号两三层嵌套叠加转义符可读性归零。解决存储端用C# 11的原生字符串字面量或verbatim字符串动态代码内部避免嵌套字面量。如果确实有很多常量要传把这些值提到globals上下文里作为属性注入动态代码只做逻辑判断不写常量定义。代码量少了转义问题自然消失。5.4 匿名类型跨程序集返回object拿不回来现象脚本里写return new { Name x, Score 90 }主程序拿ReturnValue后想访问Name属性反射都找不到。原因匿名类型的可见性是internal跨程序集只能看到object。编译期无法访问它的属性运行时反射也拿不到非public类型的成员。解决动态代码和脚本统一返回明确类型。简单场景返回decimal或bool复杂结构返回预定义的DTO或字典。绝不返回匿名对象。5.5 首次编译慢线上接口被拖垮现象规则脚本第一次执行时接口明显卡顿耗时超过1秒第二次执行才恢复正常。原因动态编译每次触发完整语法分析、语义绑定、IL生成脚本API不预编译的话首次RunAsync会现场编译再加上JIT预热第一个请求注定慢。解决规则代码进来就预编译编译结果放进内存缓存键为代码内容的哈希值。运行时先查缓存命中直接用编译好的Script对象执行。规则变更时删掉对应缓存项更新瞬间有一次性编译开销其余请求全部直接执行。6. 二次开发实战把示例改造成一个可插拔的规则引擎6.1 定义插件目录约定与执行协议把动态编译代码再往上抽象一层可以变成插件系统。目录结构我一般固定下来主程序下建Plugin目录每个插件一个子目录里面放着插件dll和它专属的依赖。插件入口类型实现IPlugin接口接口里包含插件名、版本号、初始化方法和核心执行方法。宿主启动时扫描目录用自定义ALC逐个加载加载失败的插件单独跳过不影响主程序启动。6.2 热替换与失败隔离的完整路径更新插件时把插件目录下标识文件里的版本号改一下宿主后台线程定期检查发现新版本就创建新ALC加载新dll。旧ALC等所有在途请求结束后再卸。失败隔离逻辑也简单动态代码执行时统一捕获异常异常信息里带上插件名、规则ID、代码行号写入日志后返回预设的兜底值不让任何一条动态代码异常拖垮整个业务流程。6.3 编译缓存与规则回滚的配合规则表里存规则ID、代码内容、版本号和生效状态。启动时把启用中的规则全部拉出来代码内容做哈希生成缓存键存编译好的Script对象。发布新规则时先把旧规则退回上一版本再把新规则标记生效并刷新对应缓存。这样出了故障一键回滚到上一版本不需要改代码重新部署。这套方案踩过的最大坑是动态代码的异常必须统一捕获并在日志中附带规则ID否则线上规则出错时没有任何线索排查等于大海捞针。从那以后我每次写动态编译的catch分支都会强制走一遍规则ID加错误信息的日志格式再遇到问题也能按图索骥。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询