简介这份源码资源面向已掌握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.CastCompilerError() .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那段循环是血泪经验——不补引用的话源码里写个Listint都可能报“未定义类型”因为动态编译的上下文默认只认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)) .CastMetadataReference() .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.DotNetCompilerPlatformRoslyn 路线需要Microsoft.CodeAnalysis.CSharp版本跟目标框架对齐别盲目追最新。路线所需包适用框架语法支持CodeDom无Framework/ DotNetCompilerPlatformCore全版本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 存编译好的Typeprivate static readonly ConcurrentDictionarystring, 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的工厂委托可能被执行多次并发竞争时如果编译开销大可以换成LazyType包一层。返回值统一用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)宿主线程直接卡死重启才恢复。从那以后我每次接入动态编译都强制先把这组坏样本跑一遍再上线。希望这些内容帮到你少走几段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?