基于Roslyn的C#智能脚本编辑器:兼容.NET 4.0的嵌入式实现 简介C#作为一种成熟的编程语言在工业自动化和上位机开发中应用广泛。在动态脚本化需求日益增长的背景下如何实现一个可嵌入、低依赖的C#脚本编辑环境成为工程实践中的关键问题。Roslyn编译器服务提供了完整的语法树与语义模型支持使得开发者能够构建具备智能提示功能的脚本编辑器。本文基于Roslyn 2.x版本通过降级策略、AppDomain隔离和依赖裁剪等技术实现了一个可在.NET 4.0环境下运行的C#脚本编辑器支持语法高亮、语义级补全和跨域安全执行。该方案兼顾了老系统的兼容性与开发效率适用于工业现场参数调整、通讯协议解析等实时脚本场景。通过探索Roslyn与.NET Framework的适配过程为同类工具的开发提供了可复用的工程参考。 做这个项目的起因很简单我手上有一套长期维护的工业上位机系统部署到客户现场后算法参数、通讯协议、报表规则经常要调。每次调整都走“改代码—重新编译—发布更新”这套流程少则半小时多则一天客户等着用我们疲于奔命。后来我意识到这套系统需要的不是“提供更多配置项”而是一个能让使用者直接写C#脚本、改完立即执行的环境。于是就有了这个智能C#脚本编辑器内置智能提示支持在.NET 4.0环境下运行同时以源码形式开放方便自己裁剪和集成。这篇文章想把整个项目的设计思路、核心实现、兼容性处理和踩过的坑完整记录下来给正在做类似工具的朋友一个可参考的样本。先明确一下这个编辑器到底解决什么问题。它不是一个和Visual Studio竞争的通用IDE而是作为宿主程序的嵌入式脚本引擎。使用者打开编辑器用C#写一段逻辑比如“根据当前温度值调整PID参数”“解析一段自定义报文并写入数据库”然后点运行这段代码就在当前进程里被执行还能直接访问宿主程序暴露出来的对象。智能提示负责降低编码门槛输入对象名之后点一下“.”所有可用的属性和方法就列出来了不用翻文档。底层基于Roslyn的编译服务但对外暴露的是完整编辑器界面不是命令行工具。1. 为什么我坚持要在.NET 4.0上跑这个编辑器很多朋友听到“.NET 4.0”第一反应是皱眉这都什么年代的老古董了直接用.NET 6/8不好吗但在实际工业场景里“能用”远比“新潮”重要。我这边维护的客户系统不少还跑在Windows 7或者Windows Server 2008 R2上操作系统自带的是.NET Framework 4.0没有管理员权限给你升级运行时也不允许随便安装新组件。如果要让脚本编辑器在这种环境里跑起来唯一现实的做法就是让程序本身基于.NET 4.0编译并且所有依赖库都必须在4.0下兼容。另一个原因是集成成本。宿主程序本身就是.NET 4.0写的如果脚本编辑器要求.NET 4.7.2甚至.NET 6那意味着整套系统都要跟着升级牵一发动全身。反过来编辑器主动降级到4.0宿主程序只需要添加几个DLL引用就能对接不需要改项目目标框架这是最稳妥的集成路径。当然这里的“支持.NET 4.0”有一个前提需要说清楚编辑器本身基于.NET 4.0编译和运行没有问题但Roslyn编译器服务在2.x版本之后官方要求的最低运行时是.NET Framework 4.5。我的做法是引用Roslyn 2.x版本最后一版能跑在.NET 4.5上的系列然后在安装包和启动检测里明确区分“编辑器宿主运行版本”和“脚本编译运行版本”。如果检测到当前机器只有.NET 4.0编辑器会进入降级模式关闭语义级智能提示只保留基于缓存的词法提示如果检测到.NET 4.5或更高版本则启用完整功能。这个设计让编辑器可以在最低配置下运行又能在条件允许时发挥完整性能。从实际使用效果看这套妥协方案非常有效。客户现场的旧机器虽然跑不了完整的Roslyn语义分析但关键字提示、已加载类型列表、代码片段插入这些基础能力已经覆盖了80%的写码场景。等到客户换了新机器系统普遍带.NET 4.5以上编辑器自动切换全功能模式不需要任何代码改动。2. 智能提示的选型思路为什么最终锁定Roslyn版本智能提示的核心难点不是弹出一个候选列表而是候选列表里的每一项都要“懂C#”。如果只是做一个字符串匹配式的提示器办法多了去了把C#关键字放在数组里用户在输入时逐个匹配再拿正则扫描一下宿主程序里注册的对象把对象名和成员名塞进字典。但这种方式只能返回“名字看起来对的”候选项根本不知道这段代码在当前上下文里到底合不合法。举个例子假设你写了这样一段代码var result device.GetTemp(); result.这个时候智能提示应该列出什么理想情况下它应该知道GetTemp()返回的是一个TemperatureReading类型的对象于是把这个类型里所有的属性比如Value、Unit、IsValid显示出来。但“正则反射”方案做不到这一点因为它没有“类型推断”的能力它只知道result是一个变量名不知道它是什么类型。要往更深一层走必须把代码解析成语法树再通过语义分析拿到每个符号的实际类型信息。这就是Roslyn的价值所在。Roslyn把C#编译器的完整能力作为一组可调用的API暴露出来。它可以把任意一段代码编译成语法树SyntaxTree你可以从语法树里精确拿到每个表达式、每个位置对应的符号Symbol再通过语义模型SemanticModel查询某个位置有哪些可用的成员。智能提示本质上是基于语义查询的可视化层。所以在项目初期我就确定了一个原则智能提示必须是语义级的不能拿字典和正则拼凑。只有语义级提示才能让用户真正“不查文档直接写代码”这也是这个编辑器区别于普通示例代码的核心竞争力。接下来的问题是用哪个版本的Roslyn。当时我手上同时评估过1.x、2.x和3.xRoslyn版本最低.NET版本要求稳定性结论1.x.NET Framework 4.5功能较基础API不完善不推荐2.x.NET Framework 4.5API趋于稳定功能完整最终选择3.x及以上.NET Standard 2.0通常在4.7.2以上实际可用功能更全但依赖更高与4.0目标冲突选择2.x是纯粹的“够用且能跑”它提供了AdhocWorkspace、CompletionService、SyntaxTree等全套接口支持C# 7.0的语法对我们写脚本来说绰绰有余。3.x以后虽然加了更多新语法支持但要求.NET Framework 4.7.2这在老系统上直接判死刑所以不考虑。3. 兼容.NET 4.0的完整技术路线降依赖、加探测、分模式3.1 依赖列表的删减与替换项目初期最痛苦的环节就是NuGet依赖链。因为目标框架是.NET 4.0很多看似无害的包根本装不上或者装上了但运行时缺方法。比如最新的System.Collections.Immutable要求.NET Standard 2.0而.NET 4.0不满足这个条件。最后我用的是一套当时的依赖组合Microsoft.CodeAnalysis2.8.2Microsoft.CodeAnalysis.CSharp2.8.2Microsoft.CodeAnalysis.CSharp.Scripting2.8.2Microsoft.CodeAnalysis.Features2.8.2System.Collections.Immutable1.3.1System.Reflection.Metadata1.4.2System.IO.Compression自带FastColoredTextbox第三方编辑器控件里面有几个绕不开的坑。Roslyn 2.8.2虽然在NuGet上标注支持.NET Framework 4.5但它内部的多个DLL实际上引用了一些4.0没有的API。我的解决方案很简单在.4.5的环境里用完整版在4.0环境里用降级模式。所以程序集加载时要做一个运行时探测用Environment.Version和Type.GetType(System.Threading.Tasks.Task, mscorlib)检查关键类型是否存在再决定加载哪套策略。3.2 AppDomain隔离与程序集卸载脚本编辑器不仅要做编辑还要跨域执行用户写的代码。这里有个很致命的限制在.NET Framework里一个AppDomain一旦加载了某个程序集这个程序集就无法卸载除非整个AppDomain被销毁。如果用户在编辑器里反复修改并执行脚本每次编译都会生成一个新的内存程序集这些程序集会一直堆积最终导致内存耗尽。解决思路是“每个脚本会话一个AppDomain”。脚本要被编译成一个程序集Assembly然后在一个独立的AppDomain里创建并运行。脚本执行完之后整个AppDomain被卸载加载进去的程序集随之释放。核心代码如下public class ScriptSandbox { public object Execute(string scriptCode, string[] references, object[] args) { // 创建独立的AppDomain隔离脚本程序集 var setup new AppDomainSetup { ApplicationBase AppDomain.CurrentDomain.BaseDirectory, PrivateBinPath ScriptBin }; var scriptDomain AppDomain.CreateDomain(ScriptDomain_ Guid.NewGuid().ToString(N), null, setup); try { // 在子域里创建执行器实例 var executorType typeof(ScriptExecutor); var executor (ScriptExecutor)scriptDomain.CreateInstanceAndUnwrap( executorType.Assembly.FullName, executorType.FullName); executor.Initialize(references); // 在子域内完成编译和调用 return executor.Run(scriptCode, args); } finally { AppDomain.Unload(scriptDomain); } } }3.3 4.0环境降级策略降级模式不是一个“开关”而是一整套功能裁剪策略。在我的实现里降级模式仍然保留三类能力关键字提示从固定C#关键字列表里做前缀匹配。类型/对象名提示从已加载程序集里反射公开类型把宿主注册的常用对象名缓存起来。代码片段预置for、foreach、try-catch等常用模板Tab键补全。完整模式增加语义分析、成员自动补全、参数帮助、符号信息查看。降级模式的检测时机放在编辑器加载时硬编码在入口处如果机器是.NET 4.0就在初始化阶段跳过Roslyn的Workspace创建。这个分层设计还有一个额外的好处低配机器上编辑器启动只要几十毫秒而完整模式首次启动可能要一两秒。用户体验上降级模式虽然提示不精确但至少编辑器能打开不至于完全不能用。4. 编辑器核心模块拆解从文本控件到语义补全的完整链路4.1 编辑器控件选型FastColoredTextbox我先看了一圈可以作为RichTextBox替代的第三方控件。有人建议用Scintilla.NET有人建议直接内嵌一个完整IDE但考虑到“宿主程序是WinForms .NET 4.0”这个前提最终选了FastColoredTextbox。原因有三纯WinForms控件没有原生窗口依赖部署简单。自带语法高亮、行号、自动缩进、括号匹配省去大量底层工作。文本访问接口直接暴露string和Range和Roslyn的SourceText转换非常顺手。FastColoredTextbox功能虽然不如Scintilla强大但在这个项目里“够用”比“强大”更重要。它自带高亮方案可以自定义而我的高亮逻辑是从Roslyn过来的所以控件本身的高亮只是兜底最终生效的是语义高亮。4.2 把Roslyn接入文本编辑的桥接层Roslyn不是编辑控件它只处理代码文本。要让文本编辑和语义分析实时联动需要一个桥接层。核心思路每次文本内容变化都更新Roslyn的AdhocWorkspace里的Document然后基于新的Document做语义查询。private void txtEditor_TextChanged(object sender, TextChangedEventArgs e) { // 拿到最新文本 var codeText txtEditor.Text; // 更新Workspace里的Document _document _workspace.CurrentSolution.WithDocumentText( _document.Id, SourceText.From(codeText, Encoding.UTF8)).GetDocument(_document.Id); // 触发延迟的补全请求 _completionCancellation?.Cancel(); _completionCancellation new CancellationTokenSource(); var token _completionCancellation.Token; _ Task.Delay(250).ContinueWith(t { if (t.Status TaskStatus.RanToCompletion !token.IsCancellationRequested) { PerformCompletionCheck(codeText, token); } }, TaskScheduler.Default); }这里有一个很容易被忽略的性能问题如果每次击键都做一次完整的语义分析在.NET 4.0的老机器上会卡到无法输入。所以必须加防抖debounce用户停止输入250毫秒后再触发补全检查同时取消上一个未完成的补全请求避免旧结果覆盖新结果。4.3 智能提示弹窗的实现基于SemanticModel的补全服务当用户输入到“点”号或者开始输入一个标识符时编辑器会在光标位置弹出一个候选框。这个候选框的数据来源是Roslyn的CompletionService但和官方示例不同的是我没有用GetCompletionsAsync直接返回的一整包数据而是先做一个本地过滤和排序。public async TaskListCompletionItem GetCompletionItems(int caretPosition) { var document _workspace.CurrentSolution.GetDocument(_document.Id); var completionService CompletionService.GetService(document); var completions await completionService.GetCompletionsAsync( document, caretPosition, CompletionTrigger.Invoke, CancellationToken.None); var result new ListCompletionItem(); foreach (var item in completions.ItemsList) { // 过滤只保留包含当前输入前缀的选项 if (item.DisplayText.StartsWith(_currentInputPrefix, StringComparison.OrdinalIgnoreCase)) { // 按类型权重排序关键字 类型 成员 扩展方法 符号 result.Add(item); } } return result; }补全条目的分类显示也做了优化不同类型成员使用不同背景色后缀追加类型信息例如“int Value”而不是光秃秃的“Value”扩展方法标注“[扩展]”字样。用户在弹窗里可以用方向键浏览Tab确认Esc取消。这里有一个细节当CompletionItem拿到后需要从CompletionService里再请求一次GetDescriptionAsync才能在弹窗右侧显示方法签名或属性说明。出于性能考虑我只在用户选中某一项时才加载描述而不是一次性加载全部。4.4 结果落地机制从“选中的提示”到实际插入代码用户从弹窗里选中一项后编辑器要做的不是简单替换文本而是要正确处理上下文。比如你输入了device.GetTemp()然后在这个表达式后面开始输入“。”这时候弹出的候选是TemperatureReading的成员一旦用户选中Value最终插入的文本不应该是device.GetTemp()Value而应该是device.GetTemp().Value。这个逻辑需要在补全触发时记录当前光标前的原始文本结合CompletionItem的SpanToReplace计算真正要替换的区间。private void InsertCompletion(CompletionItem item) { var document _workspace.CurrentSolution.GetDocument(_document.Id); var completionService CompletionService.GetService(document); var change await completionService.GetChangeAsync(document, item, null, CancellationToken.None); var textChange change.TextChange; // 在编辑器里执行替换 txtEditor.SelectionStart textChange.Span.Start; txtEditor.SelectionLength textChange.Span.Length; txtEditor.SelectedText textChange.NewText; }5. 脚本执行引擎编译、运行、跨域通信、结果回传5.1 编译阶段从文本到内存程序集在完整模式下编译流程是这样的把用户输入的全部代码统一包装在一个类里这个类名固定比如ScriptEntry然后把这段代码交给Roslyn的CSharpCompilation。编译输出不落盘直接用Emit到MemoryStream里再Assembly.Load(ms.ToArray())。代码大致如下public Assembly CompileToAssembly(string scriptCode, IEnumerableMetadataReference references) { var syntaxTree CSharpSyntaxTree.ParseText(scriptCode); var compilation CSharpCompilation.Create( assemblyName: DynamicScript_ Guid.NewGuid().ToString(N), references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using (var ms new MemoryStream()) { var emitResult compilation.Emit(ms); if (!emitResult.Success) { var errors string.Join(Environment.NewLine, emitResult.Diagnostics .Where(d d.Severity DiagnosticSeverity.Error) .Select(d d.ToString())); throw new ScriptCompilationException(errors); } ms.Seek(0, SeekOrigin.Begin); return Assembly.Load(ms.ToArray()); } }5.2 为什么不直接使用CSharpScript很多用Roslyn做脚本功能的朋友第一反应是CSharpScript.EvalAsync。这个API用起来确实方便几行代码就能跑一段脚本但我在实际项目中否掉了它原因有两条第一CSharpScript的返回结果是一个object你拿到的只是最后一个表达式的值但如果脚本里定义了很多函数、类、变量这些内容在后续编辑中无法复用。它面向的是“一次性计算器”场景而不是“整套程序逻辑脚本化”。第二CSharpScript在执行时会自动加载并缓存它认为需要的程序集这个行为和宿主程序的依赖控制策略冲突。我用CSharpCompilation可以直接控制引用了哪些程序集避免脚本意外引用到不该引用的模块。5.3 脚本执行结果的结构化返回脚本执行完毕后宿主程序关心的不只是“有没有抛异常”还有“运行的日志”“耗时”“返回值”。所以我设计了一个统一的ScriptExecutionResult结构public class ScriptExecutionResult { public bool Success { get; set; } public object ReturnValue { get; set; } public string ReturnTypeName { get; set; } public string ConsoleOutput { get; set; } public double ElapsedMilliseconds { get; set; } public string ErrorMessage { get; set; } public string StackTrace { get; set; } }每次执行脚本先把Console输出重定向到StringWriter脚本跑完后再恢复这样用户脚本里的Console.WriteLine也能被捕获并显示在编辑器下方的输出面板里。异常会被捕获并写入ErrorMessage和编辑器行号的映射做一套粗粒度的提示例如“第X行附近的代码抛出了异常”。5.4 跨AppDomain通信的注意事项前面提到脚本执行放在独立AppDomain里但跨域调用本身有几条容易踩的规则。最简单的一条从跨域对象里返回的对象如果是MarshalByRefObject得到的是代理如果不是会被序列化复制一份过来如果既不能继承MarshalByRefObject又不可序列化就只能返回null或抛出异常。所以在实际项目中脚本执行器的Run方法返回一个包装好的ScriptExecutionResult这个类标了[Serializable]而不是返回用户脚本的某个对象。6. 避坑实录让我浪费一周的六个细节问题6.1 引用了错误版本的System.Collections.ImmutableRoslyn 2.x依赖System.Collections.Immutable但NuGet上同时存在1.x和2.x版本。一开始我装了最新版2.0.0运行时报MissingMethodException找不到ImmutableArray的某个构造函数。排查了半天才意识到Roslyn 2.8.2编译时引用的是1.3.1版的System.Collections.Immutable运行时的强命名绑定要求版本严格一致。最后把依赖锁定到1.3.1并在App.config里加上程序集绑定重定向问题才消失。6.2 SourceText的编码与偏移错位Roslyn的SourceText默认行为会根据文本内容检测编码。我用SourceText.From(scriptText, Encoding.UTF8)时遇到一个很隐蔽的bug当脚本里包含中文注释、中文字符串时Roslyn报告的光标位置和编辑器里的实际位置对不上。原因是Roslyn内部用UTF16计算TextSpan偏移但某些环境下SourceText把编码检测成UTF8导致GetCompletionsAsync传入的caretPosition参数落到错误的位置。解决方法是显式指定编码为Encoding.UTF8或Encoding.Unicode避免自动检测。6.3 智能提示弹窗拦截了Tab键FastColoredTextbox本身用Tab做缩进而补全弹窗把Tab当作“确认选中项”。用户一按Tab弹窗虽然关掉了但光标前面的缩进也被删掉了。解决方法是弹窗打开时禁止默认Tab行为只让Tab在“弹窗处于打开状态”时作为确认键关掉弹窗立即恢复编辑器默认Tab逻辑。还有一个更隐蔽的问题如果用户按了Tab但光标位于弹窗列表内默认行为会把选中的项插入到文本中这本身是合理的但如果用户本意是想继续缩进就会很困扰。所以我的方案是Tab确认Enter也确认但Enter会同时换行ShiftTab关闭弹窗且保持原始缩进。6.4 高亮线程和UI线程不同步导致崩溃最初我把语义高亮放在后台线程里计算然后通过Invoke更新文本框。但Roslyn计算出高亮结果后文本可能已经被用户改过了于是后台线程拿着旧文本来更新新文本的高亮越界错误频发。后来改用前台异步模型文本变化后启动防抖计时器计时器到期后在工作线程拿Document做分类分析等分析完拿到结果先判断当前文本是否和分析时一致不一致就丢弃结果。6.5 AppDomain执行器的残留引用AppDomain.Unload不是什么情况都能成功。.NET Framework的线程池线程如果还在执行代码引用着脚本程序集里的类型这个AppDomain会拒绝卸载抛CannotUnloadAppDomainException。为了防止脚本里出现长期运行的线程比如Task.Delay(10000)或者一个死循环我在执行器外层包了一个超时控制超过5秒就从线程池主动终止并标记为执行失败再强制卸载AppDomain。6.6 低配机器上语义分析导致界面卡死降级的补全策略也救不了所有的机器。我遇到过一台2GB内存的工控机跑的是完整版智能提示每次击键的语义分析耗时超过800毫秒流畅度全无。后来加了“自动降级”策略如果连续10次补全请求的响应时间都超过500毫秒就自动切换到降级模式只保留关键字和缓存类型提示。这算是对实际环境的一种妥协但用户反馈反而好了因为至少不那么卡了。7. 这个项目后续还能怎么用编辑器做出来后我顺手把它扩展成了几个不同形态的工具。一个是独立桌面版通过配置文件指定程序集路径和入口方法就能作为通用C#脚本工具用另一个是WinForms宿主里的嵌入式面板把编辑器控件直接拖进现有窗口通过ScriptRunner类的公开接口注册宿主对象。如果你也要做一个类似的脚本编辑器我的建议是从小处切入先把“编辑文本执行C#代码”跑通再做智能提示最后再考虑跨AppDomain隔离。别一上来就想做个全功能的IDE那会陷入无止境的细节里。关于源码这个项目完全以源码形式开放目录组织按“编辑器宿主、补全服务、执行引擎、降级策略、示例宿主程序”五块展开。编译顺序是先编译依赖库再编译编辑器最后编译示例宿主。如果你在.NET 4.0机器上部署记得带上配置文件里那一堆程序集绑定重定向那个东西省不掉。如果你在.NET 4.5以上部署直接改成完整的Roslyn补全模式体验会好很多。本文还有配套的精品资源点击获取