资讯动态

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

发布时间:2026/10/16 18:33:50 来源:尧图企业网站定制
简介本资源是一套基于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空格不弹出ListT的成员——这不是编辑器这是高级记事本。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().OfTypeIdentifierNameSyntax()) { 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或AvalonEditWPF但本方案坚持纯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 按CtrlSpace触发提示从光标位置反推可访问符号用户在var x new Lis处按下CtrlSpace编辑器需知道Lis前是new后面大概率是类型名当前作用域有哪些List开头的类型Roslyn提供GetCompletionListAsync()但需构造正确Document。我们模拟一个临时文档private async TaskIListCompletionItem 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 ListCompletionItem(); }注意AdhocWorkspace是轻量工作区不持久化适合单次分析FindToken(caretPos)确保只取光标紧邻的token避免误判Listint中的int。3.2 F12跳转到定义定位符号源码位置即使未编译用户右键Console.WriteLine选“转到定义”编辑器应打开System.Console的元数据视图非源码。Roslyn通过ISymbol.Locations获取符号声明位置private async TaskLocation? 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, IEnumerableDiagnostic 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需转换为TextSpanRichTextBox无法画波浪线生产环境务必替换为专业编辑控件。4. 安全执行脚本用AssemblyLoadContext隔离沙箱防内存泄漏与恶意调用4.1 为什么不用AppDomain——.NET Core/.NET 5的替代方案.NET Framework时代用AppDomain隔离脚本执行但.NET Core起已废弃。现用AssemblyLoadContextALC实现同级隔离每个脚本在独立ALC中加载卸载时释放所有相关Assembly和JIT代码。关键在IsCollectible trueprivate 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()是释放内存的关键必须放在finallyCancellationTokenSource超时保障脚本不卡死主线程。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()返回空列表CtrlSpace无反应原因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 Taskstring Main() { ... } var task (Taskstring)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 ListMetadataReference { 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, Liststring referencedAssemblies) ParseScriptWithReferences(string code) { var lines code.Split(\n); var refs new Liststring(); var cleanLines new Liststring(); 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从“猜”变成“看”。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑