ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#脚本编辑器实现:基于Roslyn的语法感知与安全执行

C#脚本编辑器实现:基于Roslyn的语法感知与安全执行

简介:本资源是一套基于C#实现的轻量级脚本编辑器完整源码,面向.NET开发者、自动化工具开发者及工业视觉软件二次开发人员,解决在自有应用中快速集成可编译、可调试脚本扩展能力的实际需求。压缩包共156个文件,含23个核心C#源文件(如TestScript.csproj、TestLib.csproj等)、71个运行依赖DLL、3个可执行EXE(含编辑器主程序)、以及PDB调试符号、XML文档、RESX本地化资源等,完整覆盖编译、语法高亮、错误提示、结果输出等VS风格功能,包体大小为30.37MB。目前已有944人学习下载,适合希望深入理解动态编译机制、嵌入式脚本引擎设计或快速复用到MES/视觉平台项目的中高级C#工程师。读者可直接导入VS解决方案,运行即见效果,并基于现有结构扩展第三方库引用与自定义API接口。

1. C#实现代码脚本编辑器的功能:不是做个TextBox,而是让C#自己“看懂”自己写的代码

你有没有试过在WinForm或WPF里拖一个TextBox,然后标上“脚本输入区”就交差?结果用户一粘贴for (int i = 0; i < list.Count; i++) { Console.WriteLine(i); },立刻报红、没语法高亮、按F12跳不到定义、Ctrl+空格不弹出List<T>的成员——这不是编辑器,这是高级记事本。C#实现代码脚本编辑器的功能,核心不在“能输”,而在“能理解”:它要实时解析C#语法树、识别符号作用域、支持基础语义导航、容忍不完整代码(比如用户正敲到var x = new List<还没打完>)、甚至能安全执行沙箱内脚本。这不是给程序员写IDE,而是给终端用户(比如测试工程师、配置人员)提供一个轻量、可控、带反馈的C#表达式入口。适合需要动态逻辑注入但又不能开放完整VS环境的场景:自动化测试用例参数化、规则引擎条件字段、工业HMI中的简易计算脚本、教育类编程沙盒前端。它不替代Roslyn全功能服务,但必须比CodeDomProvider更稳、比纯正则匹配更准、比硬编码关键词列表更可扩展。


2. 从零搭起语法感知骨架:用Microsoft.CodeAnalysis加载C#语法树并实时响应编辑

2.1 为什么弃用CodeDom,坚定选Roslyn——血泪经验换来的选型理由

早年用CSharpCodeProvider编译字符串脚本,看似简单:provider.CompileAssemblyFromSource()一行搞定。但问题接踵而至:每次编译都启新进程、无法获取AST节点位置、错误信息只有行号无列号、不支持async/await和C#9+特性,更致命的是——它根本无法做“编辑中”的实时分析。用户敲到var x = GetD时,CodeDom连GetD是不是方法都判断不了。而Microsoft.CodeAnalysis(即Roslyn)是微软官方编译器平台,它把编译过程拆成SyntaxTree(语法树)、SemanticModel(语义模型)、Compilation(编译单元)三层。我们只需监听文本变更,用CSharpSyntaxTree.ParseText()生成新语法树,再用CSharpCompilation.Create().AddSyntaxTrees()构建轻量Compilation,就能拿到带位置信息的语法节点和符号绑定。这才是“编辑器感”的底层支撑。

2.2 在WinForm中嵌入语法高亮:用RichTextBox + Roslyn解析实现逐词染色

WinForm原生控件不支持复杂渲染,但RichTextBox可通过Select()+SelectionColor实现基础高亮。关键在“选谁、染什么色”。我们不依赖正则(易漏string s = "class";这种),而是用Roslyn遍历语法树节点:

private void HighlightKeywords(RichTextBox rtb, string code) { var tree = CSharpSyntaxTree.ParseText(code); var root = tree.GetRoot(); // 清空原有高亮(重置为默认色) rtb.SelectAll(); rtb.SelectionColor = Color.Black; // 遍历所有标识符名称节点(变量名、类型名、方法名等) foreach (var node in root.DescendantNodes().OfType<IdentifierNameSyntax>()) { var pos = node.Span.Start; var len = node.Span.Length; if (pos + len <= code.Length) // 防越界 { rtb.Select(pos, len); // 根据语义判断:是关键字?类型?方法?变量? var model = CreateSemanticModel(tree); // 见2.3节 var symbol = model.GetSymbolInfo(node).Symbol; if (symbol is IMethodSymbol) rtb.SelectionColor = Color.Blue; else if (symbol is INamedTypeSymbol) rtb.SelectionColor = Color.Green; else if (node.Parent is UsingDirectiveSyntax) rtb.SelectionColor = Color.Purple; else rtb.SelectionColor = Color.Black; // 默认 } } }

提示:RichTextBox高亮性能敏感,实际项目中需加节流(如ThrottledInvoke),避免每按键都全量重绘;更优解是改用ScintillaNET或AvalonEdit(WPF),但本方案坚持纯WinForm+原生控件,验证最小可行路径。

2.3 构建轻量SemanticModel:绕过完整编译,只解析当前脚本依赖

SemanticModel需要Compilation,而Compilation需引用程序集。若用户脚本只用System.Collections.Generic,我们不必加载System.Drawing.dll。常见做法是预设白名单:

private static CSharpCompilation CreateSemanticModel(SyntaxTree tree) { var references = new MetadataReference[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), MetadataReference.CreateFromFile(typeof(List<>).Assembly.Location), MetadataReference.CreateFromFile(typeof(Task).Assembly.Location), // 支持async // 注意:此处不加项目自身dll!避免循环引用 }; return CSharpCompilation.Create( "ScriptCompilation", syntaxTrees: new[] { tree }, references: references, options: new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, allowUnsafe: false, concurrentBuild: true) ); }

参数说明:OutputKind.DynamicallyLinkedLibrary表示不生成物理DLL,仅内存编译;concurrentBuild: true启用多线程解析,提升大脚本响应速度;allowUnsafe: false强制禁用unsafe块,增强沙箱安全性。


3. 让编辑器“懂上下文”:实现智能提示(IntelliSense)与符号跳转

3.1 按Ctrl+Space触发提示:从光标位置反推可访问符号

用户在var x = new Lis处按下Ctrl+Space,编辑器需知道:Lis前是new,后面大概率是类型名;当前作用域有哪些List开头的类型?Roslyn提供GetCompletionListAsync(),但需构造正确Document。我们模拟一个临时文档:

private async Task<IList<CompletionItem>> GetCompletionsAsync(string code, int caretPos) { var workspace = new AdhocWorkspace(); var projectId = ProjectId.CreateNewId(); var solution = workspace.CurrentSolution .AddProject(projectId, "Script", "Script", LanguageNames.CSharp); var documentId = DocumentId.CreateNewId(projectId); var text = SourceText.From(code); var document = solution.AddDocument(documentId, "script.cs", text); // 获取光标所在位置的语法节点(如IdentifierNameSyntax) var semanticModel = await document.GetSemanticModelAsync(); var root = await document.GetSyntaxRootAsync(); var token = root.FindToken(caretPos); // 调用Roslyn内置补全提供器 var service = CompletionService.GetService(document); var serviceContext = new CompletionServiceContext( document, token.SpanStart, CompletionOptions.Default); var completionList = await service.GetCompletionsAsync(serviceContext); return completionList?.Items.ToList() ?? new List<CompletionItem>(); }

注意:AdhocWorkspace是轻量工作区,不持久化,适合单次分析;FindToken(caretPos)确保只取光标紧邻的token,避免误判List<int>中的int。

3.2 F12跳转到定义:定位符号源码位置(即使未编译)

用户右键Console.WriteLine选“转到定义”,编辑器应打开System.Console的元数据视图(非源码)。Roslyn通过ISymbol.Locations获取符号声明位置:

private async Task<Location?> FindDefinitionLocationAsync(string code, int caretPos) { var document = CreateTemporaryDocument(code); // 同3.1节 var model = await document.GetSemanticModelAsync(); var root = await document.GetSyntaxRootAsync(); var token = root.FindToken(caretPos); var symbolInfo = model.GetSymbolInfo(token); if (symbolInfo.Symbol != null && symbolInfo.Symbol.Locations.Length > 0) { var loc = symbolInfo.Symbol.Locations[0]; if (loc.IsInMetadata) // 元数据(如mscorlib.dll) { // 返回元数据文件路径 + 行号(行号为0,因元数据无行概念) return Location.Create(loc.SourceTree, new TextSpan(0, 0)); } else // 用户代码中定义 { return loc; } } return null; }

关键点:symbol.Locations可能为空(如未解析成功),需判空;IsInMetadata区分框架类型与用户自定义类型,决定跳转目标是反编译视图还是编辑器内定位。

3.3 实时错误诊断:将编译警告转为编辑器下划线

Roslyn编译后compilation.GetDiagnostics()返回Diagnostic集合,含错误ID(如CS0029)、消息、起止位置。我们将其映射为RichTextBox的下划线:

private void ShowDiagnostics(RichTextBox rtb, IEnumerable<Diagnostic> diagnostics) { // 清除旧下划线(用空格覆盖或重绘) foreach (var diag in diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)) { var span = diag.Location.GetLineSpan(); var start = span.StartLinePosition.Character; var length = span.EndLinePosition.Character - start; if (start >= 0 && length > 0 && start + length <= rtb.TextLength) { rtb.Select(start, length); // RichTextBox不支持下划线样式,用红色波浪线字符模拟(需字体支持) // 实际项目建议用ScintillaNET的IndicatorSetStyle rtb.SelectionColor = Color.Red; } } }

避坑:Diagnostic.Location.GetLineSpan()返回的是LinePositionSpan,需转换为TextSpan;RichTextBox无法画波浪线,生产环境务必替换为专业编辑控件。


4. 安全执行脚本:用AssemblyLoadContext隔离沙箱,防内存泄漏与恶意调用

4.1 为什么不用AppDomain——.NET Core/.NET 5+的替代方案

.NET Framework时代用AppDomain隔离脚本执行,但.NET Core起已废弃。现用AssemblyLoadContext(ALC)实现同级隔离:每个脚本在独立ALC中加载,卸载时释放所有相关Assembly和JIT代码。关键在IsCollectible = true:

private class ScriptLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; public ScriptLoadContext() : base(isCollectible: true) { _resolver = new AssemblyDependencyResolver(typeof(Program).Assembly.Location); } protected override Assembly Load(AssemblyName assemblyName) { var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); if (assemblyPath != null) { return this.LoadFromAssemblyPath(assemblyPath); } // 加载框架程序集(如System.Runtime) if (assemblyName.FullName.StartsWith("System.") || assemblyName.FullName.StartsWith("Microsoft.")) { return Default.LoadFromAssemblyName(assemblyName); } return null; } }

参数说明:isCollectible: true允许显式调用Unload();ResolveAssemblyToPath确保只加载项目明确依赖的DLL,阻断任意路径加载。

4.2 编译脚本为Assembly并执行:捕获异常与超时

编译后得到Assembly,但直接asm.CreateInstance()风险高(如构造函数死循环)。需封装执行逻辑:

public async Task<(bool success, string result, string error)> ExecuteScriptAsync(string code) { var context = new ScriptLoadContext(); try { var compilation = CreateCompilation(code); // 同2.3节 using var stream = new MemoryStream(); var emitResult = compilation.Emit(stream); if (!emitResult.Success) { var errors = string.Join("\n", emitResult.Diagnostics .Where(d => d.Severity == DiagnosticSeverity.Error) .Select(d => $"{d.Id}: {d.GetMessage()}")); return (false, "", errors); } stream.Seek(0, SeekOrigin.Begin); var asm = context.LoadFromStream(stream); // 执行入口:约定脚本必须有public static void Main()或Task Main() var entry = asm.GetType("Script")?.GetMethod("Main"); if (entry == null) return (false, "", "未找到Main方法"); // 设置超时(重要!防死循环) var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var task = Task.Run(() => { try { var result = entry.Invoke(null, null); return result?.ToString() ?? "void"; } catch (Exception ex) { return $"运行异常: {ex.InnerException?.Message ?? ex.Message}"; } }, cts.Token); var finalResult = await task; return (true, finalResult, ""); } catch (Exception ex) { return (false, "", $"编译异常: {ex.Message}"); } finally { context.Unload(); // 必须调用,否则内存泄漏 } }

注意:context.Unload()是释放内存的关键,必须放在finally;CancellationTokenSource超时保障脚本不卡死主线程。


5. 常见问题排查:5个真实踩坑记录与解决方案

5.1 现象:语法高亮闪烁严重,输入时文字疯狂变色

原因:HighlightKeywords()在TextChanged事件中被高频触发,每次调用都全量重绘RichTextBox,且Select()操作本身会触发滚动条重算。
解决:添加毫秒级节流。用Stopwatch记录上次执行时间,间隔小于50ms则跳过:

private Stopwatch _throttleStopwatch = Stopwatch.StartNew(); private void OnTextChanged(object sender, EventArgs e) { if (_throttleStopwatch.ElapsedMilliseconds < 50) return; _throttleStopwatch.Restart(); HighlightKeywords(richTextBox1, richTextBox1.Text); }

5.2 现象:GetCompletionsAsync()返回空列表,Ctrl+Space无反应

原因:Roslyn补全服务依赖Document的Project必须有正确的ParseOptions,特别是LanguageVersion。若未指定,默认用最低版本(C#7),导致var、async等关键字不被识别。
解决:创建Project时显式设置语言版本:

var project = solution.AddProject( projectId, "Script", "Script", LanguageNames.CSharp) .WithParseOptions(new CSharpParseOptions( languageVersion: LanguageVersion.CSharp10, // 或CSharpLatest kind: SourceCodeKind.Script)); // 关键!设为Script模式

5.3 现象:执行含Thread.Sleep(10000)的脚本后,UI线程卡死10秒

原因:Task.Run()虽在后台线程,但Invoke()调用Main()方法时,若方法内含Thread.Sleep或同步IO,仍会阻塞该线程。而UI更新(如显示结果)在主线程,等待后台任务完成。
解决:强制脚本方法签名返回Task,并在调用侧await:

// 脚本中要求: // public static async Task<string> Main() { ... } var task = (Task<string>)entry.Invoke(null, null); var result = await task; // 真正异步等待

5.4 现象:多次执行脚本后内存占用持续上涨,GC不回收

原因:AssemblyLoadContext未正确Unload(),或Compilation对象被静态缓存长期持有。
解决:

  • 确保context.Unload()在finally中执行;
  • 不缓存Compilation对象(每次执行都新建);
  • 检查是否意外持有SyntaxTree或SemanticModel的静态引用。

5.5 现象:Console.WriteLine("hello")输出不显示在UI

原因:脚本中Console输出写入进程默认stdout,但WinForm应用无控制台窗口,输出被丢弃。
解决:重定向Console.SetOut()到StringWriter,捕获输出:

var sw = new StringWriter(); Console.SetOut(sw); try { entry.Invoke(null, null); } finally { var output = sw.ToString(); Console.SetOut(TextWriter.Null); // 恢复 }

6. 进阶技巧:支持用户自定义类型与跨脚本引用,让脚本真正“活起来”

6.1 注入用户程序集:让脚本调用业务类库的方法

脚本不应只限于System命名空间。若某公司有BusinessLogic.dll含Calculator.Add(int a, int b),需将其注入编译环境:

private CSharpCompilation CreateCompilationWithCustomAsm(string code, string businessDllPath) { var references = new List<MetadataReference> { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(businessDllPath), // 关键:加入业务DLL }; var tree = CSharpSyntaxTree.ParseText(code); return CSharpCompilation.Create("Script") .AddReferences(references) .AddSyntaxTrees(tree); }

注意:businessDllPath必须是绝对路径;若DLL依赖其他程序集,需一并加入references,否则编译报CS0012。

6.2 实现脚本间引用:用#r指令支持类似C# Script的引用机制

C# Script支持#r "Newtonsoft.Json.dll",我们模拟此行为。解析脚本首行:

private (string cleanCode, List<string> referencedAssemblies) ParseScriptWithReferences(string code) { var lines = code.Split('\n'); var refs = new List<string>(); var cleanLines = new List<string>(); foreach (var line in lines) { if (line.TrimStart().StartsWith("#r ")) { var asmPath = line.TrimStart().Substring(3).Trim('"'); refs.Add(Path.GetFullPath(asmPath)); // 转为绝对路径 } else { cleanLines.Add(line); } } return (string.Join("\n", cleanLines), refs); }

编译时,将referencedAssemblies加入MetadataReference列表即可。

6.3 表格:脚本执行能力对照表(不同安全等级下的开放范围)

安全等级允许调用禁止操作适用场景
沙箱级System.*基础类、Math、DateTime反射、文件IO、网络、线程创建教育编程练习、简单计算器
受限业务级上述+指定业务DLL(如Calculator.*)System.IO.File、System.Net.HttpClient工业HMI参数计算、测试用例逻辑
可信开发级全部System.*、用户DLL、dynamic无(但仍用ALC隔离)内部工具脚本、自动化运维

6.4 我的血泪习惯:永远在ExecuteScriptAsync外层包一层try-catch,并记录原始脚本

曾因用户脚本中throw new Exception("配置错误")未捕获,导致整个WinForm窗体崩溃。现在我的标准模板是:

public async Task<(bool, string, string)> SafeExecute(string script) { try { // ... 执行逻辑 } catch (Exception ex) { // 记录原始脚本(用于事后审计) File.AppendAllText("script_error.log", $"[{DateTime.Now}] Script:\n{script}\nException: {ex}\n\n"); return (false, "", $"执行崩溃: {ex.Message}"); } }

这行日志救过我三次——一次是用户误删了分号,两次是DLL版本冲突。它不解决bug,但让debug从“猜”变成“看”。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进