拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

智能C#脚本编辑器源码解析:基于Roslyn的.NET 4.0补全与编译实践

智能C#脚本编辑器源码解析:基于Roslyn的.NET 4.0补全与编译实践 简介面向C#开发者的智能脚本编辑器源码包基于.NET 4.0环境专为需要为上位机、工控或自动化平台增加脚本扩展能力的工程师设计。它具备类似Visual Studio的智能提示与代码着色针对自定义类以及自建库加载后的成员字段、属性同样能够联想核心思路是采用脚本引擎加脚本编译器使平台底层无需频繁改动应用层通过脚本即可满足多变需求。压缩包仅1.89MB共76个文件以18个cs源码文件为核心辅以17个dll依赖库、4个exe示例程序、6个pdb调试符号及config与resx配置资源文件结构紧凑。目前已有1090人学习或下载内含完整VS2015解决方案、可直接运行的Demo与核心脚本提供器实现并附带自定义测试类用于验证联想效果。读者既可快速套用脚本引擎也可深入研究自定义类加载、成员提取与智能提示实现流程适合希望提升平台灵活性与交付效率的中高级C#开发人员借鉴。1. 智能C#脚本编辑器源码三层拆解先搞清楚补全是怎样工作的智能C#脚本编辑器源码这个方向真正难的不是“让C#跑成脚本”而是补全提示能不能在几百毫秒内响应并把 .NET 4.0 这种老目标处理好。做过上位机或工控项目的读者体会最深工控机里留一个可改脚本的宿主最怕的就是每次写代码都要切出去查方法签名引用和 using 拼不对。这个主题下最常见的翻车现场是编辑器能编译、能运行可输一个“.”要等半秒或是开发机上好好的部署到老环境里一跑就报 MissingMethodException。把这些事理清楚我习惯把这类源码拆成三层看第一层是文本编辑外壳第二层是编译执行引擎第三层是智能提示服务。三层边界画清楚后面所有配置、报错、卡顿都能顺着边界定位。2. 编译执行引擎先把“能跑”这件事做扎实2.1 引擎选型为什么弃用 CodeDOM 和 CS-Script“能跑”这个起点上有两条老路常被拿出来对比CodeDOM 和 CS-Script。CodeDOM 从 .NET 1.0 就有能把字符串编译成程序集但不会给你语法树和语义模型做一个纯“编译执行器”还可以要做“智能提示”则完全没有抓手。CS-Script 本身是相当完整的脚本宿主只是它的重心在运行机制、插件挂载和批处理上补全这一层仍然要自己找解析器。所以一旦标题里出现“智能提示”Roslyn 基本是唯一选项只有它把解析源码、绑定类型、给出补全候选这三件事当成标准能力开放给第三方。用 Roslyn 的代价是概念比 CodeDOM 多一层。你至少要理解 SyntaxTree、SemanticModel、Workspace 三个对象而不是像以前那样只管“编译、反射调用”。下面是个选型对照方便接到老代码里时判断值不值得换引擎。方案补全能力调整脚本目标 .NET 版本学习成本适合场景CodeDOM基本没有很难低只做一次性字符串编译CS-Script不强可中已有脚本宿主、优先稳定运行Roslyn完整补全服务可中高新编辑器、智能提示刚需2.2 最小编译宿主一段能直接复用的代码先看最少的可用实现。脚本输入之后我要做三件事把用户写的代码包成一个带 Main 的类、用 Roslyn 编译成程序集、再把结果载入内存并反射调用。using System; using System.IO; using System.Linq; using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; public class ScriptEngine { private readonly IListstring _referencePaths; public ScriptEngine(IListstring referencePaths) { _referencePaths referencePaths; } public object Run(string code) { // 把用户脚本包进一个带 Main 的类返回值统一走 object上层只需处理一个入口 string wrappedCode using System; using System.Collections.Generic; using System.Linq; public static class ScriptProgram { public static object Main() { code } }; SyntaxTree tree CSharpSyntaxTree.ParseText(wrappedCode); ListMetadataReference refs _referencePaths .Select(path MetadataReference.CreateFromFile(path)) .ToList(); CSharpCompilation compilation CSharpCompilation.Create( Script_Assembly_ Guid.NewGuid().ToString(N), new[] { tree }, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using (MemoryStream ms new MemoryStream()) { EmitResult result compilation.Emit(ms); if (!result.Success) { throw new Exception(string.Join(\r\n, result.Diagnostics .Where(d d.Severity DiagnosticSeverity.Error))); } byte[] assemblyBytes ms.ToArray(); Assembly asm Assembly.Load(assemblyBytes); Type type asm.GetType(ScriptProgram); return type.GetMethod(Main).Invoke(null, null); } } }这段代码有三个地方不建议按自己的习惯乱改。第一是包装方式不要用CSharpScript.EvalAsync替代那个接口对“脚本要反复编辑、要拿返回值、要自定义引用”都不友好拼一个Main方法反而是最可控的做法。第二是OutputKind.DynamicallyLinkedLibrary它决定程序集的入口形态用这个值才能保证后面反射拿到ScriptProgram.Main。第三是ms.ToArray()后再Assembly.Load这会把字节复制一份再交给运行时MemoryStream释放后程序集仍然可用不用担心中间资源被回收。引用路径_referencePaths由外部白名单传入脚本能用的类型面完全由这个白名单决定。脚本里写上using System.Data但白名单没有 System.Data.dll编译阶段就会报 CS0234这种错误看脚本本体是看不出原因的后面避坑清单会专门说。2.3 引用注入脚本能用哪些类型由白名单宽度决定引用注入最容易犯的错是两个引擎用两份引用清单。补全引擎和编译引擎如果各自维护一套最常见的结果就是智能提示里明明能弹出 DataTable点编译却说找不到类型或者反过来编译能过、补全永远少一截。我现在的做法是把引用路径收敛成一个静态清单补全和编译都读它。public static class ReferenceProvider { public static Liststring Net40 new Liststring { C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\mscorlib.dll, C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.dll, C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.Core.dll, C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.Data.dll }; }注意这里指向的是 Reference Assemblies 目录不是C:\Windows\Microsoft.NET\Framework\v4.0.30319下的运行时程序集。脚本要部署到老机器时这个区别是命门引用运行时程序集容易把开发机环境的依赖带进去引用程序集则能让编译器按 .NET 4.0 的契约来校验 API。开发机上没有v4.0目录的话通常是没装对应 Targeting Pack装完后路径就稳定了。3. 智能提示的实现把 CompletionService 接入编辑器的输入流3.1 补全请求从按下按键到弹出列表内部走了什么路智能提示和“关键字高亮”不是一回事。关键字高亮只需要正则匹配而补全需要知道光标当前位置能出现哪些符号。Roslyn 的做法是把编辑器文本当成一个 Document扔进 WorkspaceWorkspace 里挂着 Project 和 MetadataReference每次用户修改文本都要基于最新文本拿到 SyntaxTree再由 CompletionService 在光标位置做一次补全查询。这里有个必须接受的现实AdhocWorkspace 和它里面的 Project、Document 都是不可变对象。你用AddProject、AddDocument拿到的不是“修改后的同一个实例”而是新实例。新手写的代码常常是调完方法不接返回值结果文档内容永远停在第一版补全自然跟着翻车。任何一次文本更新都必须重新走一遍“建 Project、挂引用、建 Document”的流程或者用Workspace.TryApplyChanges去同步。3.2 最小补全先不管美观把候选列表捞出来下面这段代码是补全的最小闭环目标是确认 CompletionService 能出结果再谈过滤和排序。using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Text; using System.Threading.Tasks; public class CompletionEngine { private AdhocWorkspace _workspace new AdhocWorkspace(); private Document _document; public void AttachToDocument(string scriptText, Liststring referencePaths) { var project _workspace.CurrentSolution.AddProject( Script, Script, LanguageNames.CSharp); foreach (string path in referencePaths) { project project.AddMetadataReference( MetadataReference.CreateFromFile(path)); } _document project.AddDocument(script.cs, SourceText.From(scriptText)); } public async TaskListstring GetCandidates(int caretPosition) { var service CompletionService.GetService(_document); if (service null) return new Liststring(); var result await service.GetCompletionsAsync(_document, caretPosition); if (result null) return new Liststring(); return result.ItemsList.Select(i i.DisplayText).ToList(); } }这段代码最关键的是AttachToDocument里的循环。很多人只建了一个空 Project 就去取补全结果文档里明明using SystemSystem 里的类型一个都补不出来原因就是 Project 上没挂任何 MetadataReference。补全依赖语义模型语义模型能看到的类型范围与编译引擎的引用清单完全一致。GetCompletionsAsync返回的DisplayText是给用户看的名字比如Console的补全项里有WriteLine、ReadLine、Beep。现在这一步不做过筛一整个列表丢给 ListBox实际使用会很卡手所以下一节要加触发和过滤。3.3 触发时机与过滤排序决定“像不像 IDE”的开关触发没有统一答案我按三层来判断。第一层是按键层字母、数字、下划线、点号触发回车、退格不触发光标不在标识符中间时不触发。第二层是位置层光标在字符串里、注释里、或者行首空白处不触发这一层最好用 SyntaxTree 去判断而不是拿文本正则猜。第三层是频率层如果输入速度很快前一次请求还没回来直接用 CancellationToken 取消避免弹窗反复跳。过滤排序则集中在输入前缀上。用户停在Console.Wri时候选应该先精确匹配Wri再考虑忽略大小写匹配最后再按字母序排。string prefix GetCurrentWord(textBox.Text, caret); var filtered result.ItemsList .Where(item item.DisplayText.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(item item.DisplayText.StartsWith(prefix, StringComparison.Ordinal)) .ThenBy(item item.DisplayText) .Take(30) .ToList();这个.OrderByDescending的小技巧是让“大小写完全匹配”的排前面。比如输入consoleConsole也会被忽略大小写捞进来但精确匹配条件让Console优先出现在顶部。你的输入法、命名习惯固然不同但在脚本编辑器里尽量保持 IDE 习惯精确前缀优先于模糊匹配候选数量控制在 30 个以内超过 30 个用户基本不会逐个翻。注意退格删字符时也会触发文本变化这时候要重新取光标位置。事件回调里拿到的SelectionStart很可能还没更新到退格后的位置最好通过拦截文本变化事件后延迟取一次。4. 支持 .NET 4.0把编译目标钉到引用程序集而不是开发机4.1 标题里的“支持 .NET 4.0”其实是两个边界别混着谈第一种边界是编辑器宿主自身必须运行在 .NET 4.0 上。这种情况下 Roslyn 的新包很难直接落地因为较新的 Roslyn 宿主要求至少 .NET Framework 4.6 起步老版本 Roslyn 的功能和 API 又不如现在全。说实话编辑器本身要跑在纯 .NET 4.0 环境里还想要完整语义补全难度很大我一般会劝退要么把宿主升级到 .NET 4.5 以上要么接受一个“半智能补全”自己反射已加载程序集做前缀匹配。第二种边界更常见也是多数“智能C#脚本编辑器源码 支持.NET 4.0”想表达的事脚本编译出来的结果要能被 .NET 4.0 的老机器直接运行。开发机可以用新 Roslyn但编译时引用的程序集必须是 .NET 4.0 Reference Assemblies。只有把这个边界定清楚后面才不会指着运行时报错骂编译器。4.2 引用清单编译时指到哪里运行时就长什么样脚本要跑在 .NET 4.0 环境编译时不能拿开发机的 mscorlib 当基准。.NET Framework 4.0 安装后引用程序集默认在C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\下。只要编译引用指向这里Roslyn 就会按这个版本的程序集元数据做 API 校验。引用程序集主要用途备注mscorlib.dll基础类型、Console、String必引用运行时用的 GAC 版本不能代替System.dll集合、组件模型、IO必引用System.Core.dllLINQ、扩展方法必引用脚本里using System.Linq就靠它System.Data.dllDataTable、SqlClient上位机脚本处理数据库时常用System.Windows.Forms.dllMessageBox 等 UI按需脚本宿主要求严格就别给构建引擎时不要把这些路径写死在代码里更不要把运行时目录和引用程序集目录混在一起。运行时程序集带的是开发机的版本特征编译进脚本后会引入不该出现的 API 面引用程序集才是“目标契约”。如果你拿到一台连 .NET Framework 3.5 或 4.0 运行库都装不完整的旧机器先解决运行库安装问题再谈脚本环境。很多时候部署机的报错根本不是脚本问题而是系统组件缺失这两类问题要分开排查别混成同一个锅。4.3 异步化把补全耗时从 UI 线程上挪走补全请求在语法树重建、语义模型计算、候选列表生成三个阶段都会有开销。首轮甚至可能到几百毫秒。这个值放在 UI 线程上用户会明显感到打字被卡住放后台线程则只是弹窗晚一点出现。private CancellationTokenSource _cts new CancellationTokenSource(); private async void OnEditorTextChanged(object sender, EventArgs e) { if (_isProgrammaticChange) return; _cts.Cancel(); _cts new CancellationTokenSource(); int caret editor.SelectionStart; if (ShouldSkipCompletion(editor, caret)) return; try { var result await _service.GetCompletionsAsync( _document, caret, CompletionTrigger.Invoke, _cts.Token) .ConfigureAwait(false); if (result null) return; var filtered FilterItems(result.ItemsList, GetPrefix(editor.Text, caret)); BeginInvoke(new Action(() { ShowCompletionList(filtered, caret); })); } catch (OperationCanceledException) { // 用户继续输入旧结果直接丢弃 } }CancellationTokenSource.Cancel会取消上一轮还没跑完的补全请求这是“连续打字不卡顿”的关键。捕获OperationCanceledException时什么都不用做因为新的一轮已经在队列里。ConfigureAwait(false)让后台线程拿结果再通过BeginInvoke回到 UI 线程刷新控件避免跨线程抛异常。参数上不要太迷信CompletionTrigger.Invoke。这个触发参数更多是描述“手动调起”的场景连续输入时真正的驱动时机是自己维护的文本变化事件。触发参数给错不会让补全崩溃但会让候选偶尔不完整调试时多留意这里。5. 智能提示脚本编辑器的避坑清单5 条踩出来的经验5.1 补全弹窗没跟着输入光标走现象代码输入正常候选列表也能出来但弹窗固定显示在编辑器左上角或者停在第一次出现的位置输入到文件末尾时弹窗和光标之间隔了十万八千里。原因弹窗用的坐标是从文本控件拿到的字符索引没有换算成屏幕坐标就直接送给Show方法。带滚动条的编辑器还要额外考虑横向偏移GetPositionFromCharIndex返回的是文本框可视区域坐标不是屏幕坐标。解决取光标位置后先转成屏幕坐标再定位滚动线框带来的 X 偏移也要加上。Point pos editor.GetPositionFromCharIndex(editor.SelectionStart); pos.Offset(-editor.HorizontalScrollPosition, editor.LineHeight); popup.Show(editor, editor.PointToScreen(pos));LineHeight补偿的是光标底部到候选框顶部的视觉间距具体值跟随控件字体走不要写死 20。5.2 智能提示能弹出 DataTable点编译却报 CS0234现象补全列表里 DataTable、DataSet 都有运行时也用了它们结果编译引擎报“The type or namespace name DataTable could not be found”。原因补全和编译各自用了一套引用清单。补全引擎的 AdhocWorkspace 挂了 System.Data.dll编译引擎的白名单里没挂两个引擎各说各话。解决把引用清单收敛成单一来源两个引擎都从同一个ReferenceProvider取。不要相信“补全里能出现就一定能编译”的直觉补全服务只对它自己加载的 MetadataReference 负责。5.3 第一次输入提示响应接近一秒第二次就正常现象编辑器打开后第一次按键等很久后续按键明显变快重启编辑器后又回到一秒延迟。原因AdhocWorkspace 是第一次使用时才开始解析引用程序集。引用清单比较大时首轮要完成元数据读取、语法树建立、语义模型预热一次性开销就落在第一次补全上。解决做一个静态预热方法在编辑器启动时提前把常用引用放进一个长期复用的 AdhocWorkspace让语义模型先计算一次。public static void WarmUp() { var workspace new AdhocWorkspace(); var project workspace.CurrentSolution.AddProject( Warm, Warm, LanguageNames.CSharp); foreach (string path in ReferenceProvider.Net40) { project project.AddMetadataReference( MetadataReference.CreateFromFile(path)); } project.AddDocument(warm.cs, using System;\nclass C { }); }预热不是做做样子。它把引用程序集的元数据加载进进程缓存后续无论建多少新 Document只要 MetadataReference 来自相同程序集文件Roslyn 都会复用已解析的结果。预热代码放在编辑器Main方法里、UI 显示之前执行即可。5.4 编译通过拷到 .NET 4.0 老机器上 MissingMethodException现象开发机上脚本运行正常部署到工控机后运行时抛MissingMethodException或者更晦气地抛TypeLoadException。原因编译时引用的 mscorlib 是开发机上的新版脚本里调用了 .NET 4.0 以后才加入的 API。最典型的是Task.Run它从 .NET 4.5 开始提供编译器按 4.5 的 mscorlib 校验当然放行目标机上 4.0 没有这个方法。解决检查 ReferenceProvider 是否指向Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0而不是 GAC 或开发机运行目录。.NET 4.0 的引用程序集没安装时先在开发机装对应 Targeting Pack不要自己去 GAC 里拖一个 mscorlib 出来用。5.5 打开老项目的脚本文件中文乱码补全位置跟着全偏现象从老设备项目里拷来的 .cs 脚本打开后中文注释变成黑点光标停在中文后面时补全弹窗位置明显错位甚至补全内容都不对。原因文件不是 UTF-8 无 BOM 编码编辑器默认用 UTF-8 解码按字节换算的字符索引全部偏移。字符串内容错乱还会把后续标识符都带偏。解决读文件时不信任默认编码先探测 BOM再做回退兼容。Windows 客户端产生的老脚本常见 GB2312/GBK没有 BOM 时优先尝试按该编码解码而不是直接当乱码给用户看。public static string ReadWithEncodingProbe(string path) { var bytes File.ReadAllBytes(path); if (bytes.Length 3 bytes[0] 0xEF bytes[1] 0xBB bytes[2] 0xBF) { return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3); } try { // 严格 UTF-8 解码非法字节会抛异常 return new UTF8Encoding(false, true).GetString(bytes); } catch (DecoderFallbackException) { return Encoding.GetEncoding(GB2312).GetString(bytes); } }顺带一个细节字符串长度被错误解码后补全坐标是按字符数算的不是按字节算的所以乱码文件里光标在第 80 个字符语义模型认为它在第 71 个字符弹窗定在错的位置。把编码问题修掉后这个连带症状会自动消失。6. 进阶把补全和编译封成 ScriptPad 控件用压测数据说话6.1 面向宿主程序的封装接口编辑器做到能跑、有补全、扛得住 .NET 4.0 环境下一步通常是被集成进上位机主界面。我不建议把补全和编译逻辑裸在窗体里最好封成一个ScriptPad控件外部只暴露LoadScriptFile、Run、CompletionEnabled三个面。public partial class ScriptPad : UserControl { private readonly ScriptEngine _engine; private readonly CompletionEngine _completion; private TextBox _editor; public ScriptPad() { InitializeComponent(); _engine new ScriptEngine(ReferenceProvider.Net40); _completion new CompletionEngine(); } public void LoadScriptFile(string path) { string content ScriptFile.ReadWithEncodingProbe(path); _editor.Text content; _completion.AttachToDocument(content, ReferenceProvider.Net40); } public object Run() { try { return _engine.Run(_editor.Text); } catch (Exception ex) { statusLabel.Text ex.Message; return null; } } }Run()里吞异常是给宿主看的真正排错时把ex完整记录到日志。脚本编辑器这种组件错误越早暴露给使用者越好别在控件层做太多“友好包装”。6.2 压测补全延迟先定门槛再谈优化补全能不能用我给自己定的门槛是在普通 x86 工控机上首次补全不超过 300ms热补全稳定在 50ms 以内。超过这个值用户会明显感觉“弹窗跟不上手”。var sw Stopwatch.StartNew(); var result await _service.GetCompletionsAsync(_document, caret); sw.Stop(); logger.Debug($补全 {sw.ElapsedMilliseconds}ms, {result.ItemsList.Count} 项);压测时不要只测一次。连续输入 20 个字符记录每次耗时关注中间有没有超过 200ms 的尖峰。尖峰一般来自语义模型首次计算某个类型或者某个引用程序集首次被解析。出现尖峰时优先做预热而不是先换排序算法这块玄学很少主要时间都耗在懒加载上。6.3 我的一个习惯永远让引用清单成为唯一真相来源补全引擎、编译引擎、初始化预热三处代码都只从同一个ReferenceProvider取路径不单独在任意一个类里硬编码引用。这个习惯救过我一次上线前发现脚本里明明能调用System.Data编译也没问题部署后老机器上却报找不到程序集最后定位到是某次“顺手”修改只改在了编译引擎的白名单里补全引擎的缓存没同步。后来我把引用清单收敛成一个静态类代码里再也不出现第二份路径列表。脚本编辑器这类源码技术上不难懂难在让三个模块始终对同一套环境认知。补全能弹、编译能过、运行能稳这三件事分别代表的其实是语法树、语义模型、引用程序集三个层次的协同。把这个想通任何开源源码拿回来都能快速定位问题出在哪一层。这份笔记里写的坑我带过的人基本全都踩过至少一遍希望帮到你。本文还有配套的精品资源点击获取
返回列表