ARTICLE DETAIL

资讯详情

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

一个智能C#脚本编辑器:解析动态编译、提示引擎与安全执行实现

一个智能C#脚本编辑器:解析动态编译、提示引擎与安全执行实现 简介这是一套面向工业自动化与软件开发工程师的C#脚本引擎核心实现基于.NET Framework 4.0构建专为提升系统灵活性而设计。它提供类VS的智能代码提示能力不仅支持语法高亮、关键字识别更能动态加载用户自定义类库如TestDll并实时解析其字段、属性与方法实现跨项目上下文的精准联想显著降低非专业开发人员编写业务脚本的门槛。资源包共76个文件含18个核心C#源码如ScriptProvider.cs、MyTest2.cs、17个依赖DLL、4个可执行程序含Demo主程序、3个配置文件及完整VS2015解决方案.sln/.csproj结构清晰开箱即用总大小仅1.89MB。已有1085人学习下载适用于需长期维护平台架构、通过脚本快速响应现场需求的自动化控制系统项目开发者可直接复用其语法分析、编译执行与反射调用模块快速集成到自有平台中。1. 项目定位为什么还要自己写一个C#脚本编辑器1.1 这个东西到底解决什么问题这套「智能C#脚本编辑器」说白了就是一个嵌入式脚本环境你把C#当成脚本语言塞进自己的上位机、工具软件或者内部平台里。用户不用装Visual Studio不用重新编译整个主程序直接在界面里写C#代码点一下运行就能执行而且编辑器自带语法高亮和智能提示。听起来有点绕我举个具体场景。我之前做设备上位机的时候现场经常改逻辑传感器阈值、报警条件、数据上报格式甚至某些业务流程客户一句“需求变了”就得改代码重新发版。次数多了实在扛不住就做了一个脚本模块。现场工程师打开一个脚本窗口改两行判断逻辑保存运行不用重启程序问题当场解决。后来这个模块越用越顺手干脆独立整理成一套可复用的源码就是标题里说的「智能C#脚本编辑器」。这类东西适合谁用如果你在做上位机软件、工控人机界面、游戏外挂工具、数据采集平台或者只是想在个人项目里加一个灵活的脚本能力这套设计思路都值得参考。它解决的痛点很明确把“改代码—编译—部署—重启”这个重流程压缩成“改代码—运行”两步。1.2 为什么死磕.NET 4.0而不追新说实话2025年还纠结.NET 4.0乍一听确实有点老古董。但真正做过工控和上位机的人应该懂很多客户的机器就是老旧的Windows 7甚至Windows XP上面预装的环境就是.NET Framework 4.0你让他装个.NET 8根本不现实。设备采购周期长系统不能随便升级这种约束不是技术问题是现实的业务问题。所以我做这套编辑器时定了一个硬性指标宿主程序本身跑在.NET 4.0上不依赖任何更高级的运行时。这意味着很多现代库用不了比如新版Roslyn的语义分析、新版ScintillaNET的编辑器功能全都得自己想办法绕过去。这里有一个重要认知.NET 4.0限制的是宿主和编辑器的运行时版本并不代表你写的代码就得用2009年的风格。C# 4.0本身已经支持dynamic、命名参数、协变逆变等特性做脚本编辑器足够用。真正需要花心思的是如何在这么老的框架约束下把“智能提示”这件事做得还算好用。1.3 和现有成熟方案对比有人肯定会问市面上不是有CS-Script有Roslyn Scripting还有VS Code的C#插件为什么还要自己造轮子我也纠结过这个问题花了一周时间把主流方案都试了一遍结论如下表方案优点缺点适合场景CS-Script成熟稳定可直接执行C#脚本老版本UI较弱提示体验一般新版依赖更新运行时纯后台脚本执行不需要编辑器界面Roslyn Scripting官方支持语义分析准确对.NET 4.0支持不好包体积大宿主能升级到.NET 5的新项目VS Code C#插件提示和调试体验顶级集成太重不适合嵌到自己程序里开发者在外部编写脚本自研脚本编辑器可控性强无依赖可深度定制开发成本高提示精度有限需要嵌入自己产品、环境老旧的项目做完对比之后我反而想清楚了如果只是要一个能跑代码的库CS-Script够用但我要的是“编辑器提示执行宿主API注入”一整套体验而且必须能在老环境里跑那就只能自研核心部分然后有选择地借鉴开源组件的思路。2. 整体架构设计与方案选型2.1 把编辑器拆开看核心模块划分我把整个系统拆成了四个相互独立的部分这样后期维护和替换都方便编辑器控件层负责文本显示、光标管理、语法高亮、代码折叠智能提示引擎负责根据当前输入位置分析上下文生成补全列表编译执行器负责把编辑器里的C#源码编译成程序集并执行脚本宿主桥负责把主程序的功能暴露给脚本调用比如日志、读写配置、操作硬件设备。这四个模块的依赖关系是单向的编辑器层只做展示和交互不关心提示数据怎么来提示引擎只需要拿到当前文本和光标位置就能返回候选列表编译执行器完全不关心界面只要给它一段源码和一个入口类名它就能编译运行。这个拆法的好处很明显界面层可以替换。比如我最初用RichTextBox实现了一版后来发现性能瓶颈就换成ScintillaNET但提示引擎和编译执行器一个字符都不用改因为它们只依赖抽象接口。2.2 编辑器控件选型自研RichTextBox还是ScintillaNET这是做脚本编辑器第一个绕不开的决策。我的实际结论是在.NET 4.0的限制下纯自研就用RichTextBox省心就上ScintillaNET 3.x但新版ScintillaNET 4.x/5.x就别想了它们要求.NET Framework 4.6。先说RichTextBox方案。它的优势是零依赖但劣势也很明显Select和SelectionColor是O(n)级别的操作文本一长就卡。实测下来几百行代码还勉强能用超过1000行高亮刷新就能明显感觉到延迟。所以我在代码里做了一个分页优化只处理当前可视区域的20行文本配合TextChanged事件防抖实际体验还算流畅。再说ScintillaNET方案。它封装了Scintilla原生组件性能好得多支持真正的代码折叠、标记、自动缩进。老版本3.x可以在.NET 2.0/4.0上运行功能比RichTextBox强了不止一个档次。缺点是需要额外引入原生DLL发布打包时要多带一个SciLexer.dll。我最终的做法是做了一个控件接口抽象默认用RichTextBox实现保证源码自包含同时也保留了一个ScintillaNET实现类作为可选升级。这样谁拿到源码都能跑不会一上来就被依赖卡住。2.3 智能提示的两套引擎设计智能提示是整个项目的技术难点。一开始我想着直接上Roslyn的语义模型后来查了一圈资料发现Roslyn的托管包运行需求是.NET Framework 4.6以上.NET 4.0根本跑不起来。这条路堵死之后我只好自己实现一套轻量级的提示引擎。整体设计思路是双引擎策略基础引擎BassEngine纯.NET 4.0代码实现基于词法分词和反射。它不解析语法树而是通过“当前行的token序列”推断用户想输入什么然后从已引用程序集和当前作用域变量里找候选成员。优点是零依赖、任何环境都能跑缺点是无法理解复杂的语法结构比如LINQ查询中间变量、Lambda参数类型推断这些只能靠模糊匹配。增强引擎BoostEngine在宿主机满足.NET Framework 4.6时启用直接接入Roslyn的C#语法分析和语义分析。提示质量基本能到“够用”级别类型推断、方法重载都能给出较准确的候选列表。两个引擎通过一个接口统一对外启动时自动探测当前环境能用增强就用增强不能用就退回基础版。这个设计被很多人问过其实一句话就能解释越老的运行环境越需要“保底方案”增强只是加分项。3. 实现细节智能提示的核心代码怎么拆3.1 词法分词与上下文识别不管用哪套引擎第一步都是要从编辑器当前文本里提取出“用户正在输入的位置”以及“这个位置前文在表达什么”。我实现了一个轻量词法解析器用正则把一行文本拆分成标识符变量名、类型名、方法名关键字if、for、while、public等字符串字面量...注释//...和/*...*/运算符分隔符.、(、)、;、{、}等上面这一步很关键因为后续判断“用户是按了.还是按了空格”决定了要不要弹提示。如果当前光标前一个字符是.说明用户在访问某个对象的成员此时需要回溯出点号左侧的表达式文本。我的回溯逻辑大概是这样private string ExtractTargetExpression(string line, int caretIndex) { // 从光标位置向前找取到最近一个分隔符为止 int pos caretIndex - 1; while (pos 0 IsIdentifierCharOrDot(line[pos])) { pos--; } string expr line.Substring(pos 1, caretIndex - pos - 1); // 去掉首尾空白 return expr.Trim().TrimEnd(.); }这个函数把obj.Field.SubMethod.这种链式调用里的obj.Field.SubMethod提取出来然后交给变量表查询类型。这里的变量表从哪里来我在监视TextChanged事件时用正则扫描了当前脚本里所有变量声明比如ListKeyValuePairstring, Type variables new ListKeyValuePairstring, Type(); Regex regex new Regex((?type\b[\w\.]\b)\s(?name[A-Za-z_]\w*)\s*(?:|;));通过这种方式得到“变量名→类型”的映射。查询时如果表达式是一个简单变量名直接查表如果是链式调用就先用第一个变量的类型开始逐层用反射获取成员类型。3.2 补全列表的组装与排序拿到目标类型之后智能提示的补全列表就很好产出了用反射获取目标类型的所有公开属性、方法、事件、字段再过滤掉一些噪音成员比如编译器生成的、显式接口实现的然后组装成提示项。基础模板每个提示项包含四部分显示名、类型类别方法/属性/字段/事件、返回值类型、描述文本。public class CompletionItem { public string DisplayText { get; set; } public string Detail { get; set; } public int Kind { get; set; } // 0字段 1属性 2方法 3事件 4类型 public string ReturnType { get; set; } }排序规则其实挺讲究不能简单按字母序。我的优先级是与用户已输入前缀精确匹配的放在最前面区分大小写优先包含匹配的其次然后再看成员类别字段和属性排在方法前面因为脚本使用场景里查配置、读变量更频繁最后才是字母序兜底。这里有个小细节提示列表弹出的条件要宽松消失的条件要严格。用户输入一个字母就弹哪怕匹配列表很长也比不弹强但一旦用户输入了空格、分号、括号说明当前上下文已经结束了提示窗口要立刻关闭否则会很烦人。3.3 提示弹窗的交互设计弹窗实现我直接用了一个ListBox放在自定义Form上设置了TopMost属性但特别注意一点不能让它抢焦点。抢焦点的话用户在编辑器里输入字符焦点却跑到弹窗上键盘事件就丢了。正确做法是用ShowWithoutActivation重写让弹窗永远不会激活这样编辑器继续接收键盘输入通过方向键和回车对弹窗做选择。protected override bool ShowWithoutActivation { get { return true; } } protected override CreateParams CreateParams { get { CreateParams cp base.CreateParams; cp.ExStyle | 0x00000008; // WS_EX_TOPMOST return cp; } }接收编辑器键盘事件时我重写了编辑器的ProcessCmdKey方法在用户按下上下方向键或回车时如果弹窗可见且列表有选中项就先让弹窗消费这个按键而不是传给编辑器。3.4 高亮与折叠的轻量实现编辑器界面的另一个基础功能是语法高亮。RichTextBox方案下高亮逻辑放在每次文本变化后的定时器里用200毫秒防抖然后逐行处理可视区域。高亮规则我用正则实现虽然简单但很有效元素类型正则示例高亮颜色关键字\b(publicprivate字符串(?:\.[^\])*注释//[^\n]*绿色数字\b\d(\.\d)?\b紫色类名/方法名后续反射识别深青色代码折叠在RichTextBox方案下比较难实现我没有做完整的折叠只做了#region / #endregion的折叠。实现思路是扫描文本记录每个#region和#endregion的行号区间生成折叠区域列表折叠时用Select选中区间替换成一个占位符字符串。这个做法有一个知名的坑替换文本会改变原始文本内容所以必须维护一份“折叠映射表”记录每个折叠区域折叠后占据的行号展开时再按映射反向替换回来。我处理时直接在TextChanged时全量重建映射虽然效率不高但脚本文件通常就几百行完全够用。4. 编译执行与脚本宿主让C#真正跑起来4.1 用CSharpCodeProvider做运行时编译.NET 4.0里最成熟的动态编译方案就是CSharpCodeProvider它属于System.CodeDom.Compiler命名空间。它做的事情很简单把一段C#源码编译成exe或dll既可以输出到磁盘文件也可以直接生成到内存。我选择的是GenerateInMemory true这样不会在用户目录里留下垃圾文件也避免杀毒软件误报。核心编译代码可以精简成这样CSharpCodeProvider provider new CSharpCodeProvider(); CompilerParameters parameters new CompilerParameters(); parameters.GenerateInMemory true; parameters.GenerateExecutable false; parameters.ReferencedAssemblies.Add(System.dll); parameters.ReferencedAssemblies.Add(System.Core.dll); parameters.ReferencedAssemblies.Add(System.Windows.Forms.dll); parameters.ReferencedAssemblies.Add(Application.ExecutablePath); // 引用主程序集 CompilerResults results provider.CompileAssemblyFromSource(parameters, scriptCode); if (results.Errors.HasErrors) { foreach (CompilerError error in results.Errors) { Console.WriteLine($第{error.Line}行: {error.ErrorText}); } return null; } Assembly assembly results.CompiledAssembly;这里有一个我踩过不少次的大坑引用程序集列表必须完整。脚本里用到了ListT但你没引System.Core.dll编译必报错“找不到类型或命名空间”。为了避免用户在使用时遇到这种莫名其妙的问题我在UI上做了一个“可用引用”勾选区默认全选基础程序集并预留接口让开发者把自己业务程序集加进去。另外要特别注意CSharpCodeProvider在.NET 4.0里对应C# 4.0语法。如果用户脚本里写了C# 6以上的语法比如字符串插值$...、空值传播?.编译器会报语法错误。我在编辑器上特意加了一个“脚本语言版本”显示提示用户当前环境支持到C# 4.0。4.2 AppDomain隔离脚本崩溃不拖垮主程序脚本这玩意儿最大的风险就一个问题跑崩了怎么办用户写的代码可能死循环、可能OutOfMemoryException、甚至可能执行Environment.Exit(0)把整个程序退出。如果直接在宿主进程里执行这基本就是灾难。为了防止这种情况我把脚本执行隔离在一个新的AppDomain里。创建隔离域的核心代码AppDomainSetup setup new AppDomainSetup(); setup.ApplicationName ScriptSandbox; setup.ApplicationBase AppDomain.CurrentDomain.BaseDirectory; PermissionSet permissions new PermissionSet(PermissionState.None); permissions.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution)); permissions.AddPermission(new ReflectionPermission(ReflectionPermissionFlag.MemberAccess)); AppDomain sandbox AppDomain.CreateDomain(ScriptSandbox, null, setup, permissions);然后通过一个MarshalByRefObject的桥接类在子域里加载并调用脚本public class ScriptRunner : MarshalByRefObject { public string Run(byte[] assemblyBytes, string className, string methodName, object[] args) { Assembly assembly Assembly.Load(assemblyBytes); Type type assembly.GetType(className); object instance Activator.CreateInstance(type); MethodInfo method type.GetMethod(methodName); object result method.Invoke(instance, args); return result null ? null : result.ToString(); } }主程序这边通过CreateInstanceAndUnwrap拿到桥接对象然后调用。注意参数传递时自定义对象必须可跨域简单方案是传字符串或byte[]。跨域调用的性能损耗很小几次反射调用完全可以忽略。脚本运行结束后主动AppDomain.Unload(sandbox)就能释放脚本里创建的各类表、缓存和临时对象。这个设计基本杜绝了“脚本把主程序搞垮”的问题哪怕脚本里写了while(true)死循环主程序也能在超时后卸掉整个域再新建一个域重新跑。4.3 脚本API注入与参数传递脚本光能跑还不够它得能和主程序交互。我的做法是定义一个宿主上下文接口脚本里通过这个接口调用主程序暴露的功能public interface IScriptHost { void Log(string message); void LogWarning(string message); void SetValue(string key, object value); object GetValue(string key); string ReadConfig(string section, string key); void WriteConfig(string section, string key, string value); }脚本端约定要有一个入口类和一个入口方法比如public class MyScript { public void Main(IScriptHost host) { host.Log(脚本开始执行); int threshold int.Parse(host.ReadConfig(Param, Threshold)); if (thisValue threshold) { host.LogWarning(超出阈值当前值 thisValue); } } }入口方法的反射调用我依据参数类型自动匹配不强制要求参数名。如果传参失败会返回一个很有用的错误信息“入口方法需要接收IScriptHost类型参数但实际传入了XXX类型”。4.4 超时控制与资源回收死循环是脚本最容易出的事故。我在执行器里专门做了一个超时机制用一个后台定时器监测脚本执行时间超过设定阈值默认30秒就触发超时处理。处理方案不是简单Thread.Abort这个API在.NET 4.0虽然能用但很暴力可能引发不可预知问题而是直接卸载整个AppDomain彻底回收脚本状态。卸载完之后把“脚本执行超时已自动终止”的信息返回给界面。用户看到这个提示就知道要去检查是逻辑死循环还是算法太慢。我在实际使用中还发现一个技巧脚本退出前要显式清理非托管资源。比如脚本里打开了串口、写了文件如果脚本域被强制卸载这些非托管资源可能不会及时释放。所以我在IScriptHost接口里加了一个RegisterDisposable(IDisposable obj)方法在脚本结束时统一调用Dispose。5. 常见问题排查与避坑实录5.1 .NET 4.0下最容易踩的编译坑字符串插值用不了。C# 4.0不支持$必须写成string.Format。我在编辑器里加了一个“编译前检查器”用正则扫描$如果有就提前提示用户改写法省得编译报错了再去改。异步和Lambda表达式受限。C# 4.0不支持async/await这是最大的痛点。脚本里有异步逻辑时我一般建议用户用BackgroundWorker或者Task.Factory.StartNew配合Wait。不过这会同步阻塞线程在脚本场景里大多数情况可以接受。引用丢失导致编译失败。这是新手用户遇到最多的问题。比如脚本里用了File.WriteAllText没引System.IO命名空间也没引System.dll实际上File类在mscorlib里但很多情况下仍需要显式添加。我的处理方式是在错误列表里自动带上“缺少引用”的提示用户在界面上勾一下就能补上。5.2 智能提示不弹或漏项排查智能提示不弹绝大多数时候是上下文识别出了问题而不是引擎本身坏了。我按这个顺序排查确认光标位置的前一个字符确实是.检查点号左侧表达式能否正常提取如果提取结果为空或者包含非法字符直接返回检查变量表能否解析出目标类型如果变量声明在另一行或者用了var基础引擎可能查不到这是已知限制最后才看反射有没有异常比如目标类型是泛型的私有嵌套类型这些反射可能拿不到公开成员。有个典型情况我特别说明一下var声明的变量基础引擎没法直接知道类型。我的临时解决方案是在变量表里额外存一份“赋值表达式右侧的文本”如果右侧是new SomeClass()或者某个已知类型的方法调用就尝试推断一下。效果马马虎虎但聊胜于无。5.3 程序集卸载失败问题AppDomain.Unload偶尔会抛出CannotUnloadAppDomainException这通常是因为有后台线程还驻留在域里跑着。而且这个域已经不能被安全卸载了只能等进程退出。这个问题非常让人头疼我摸索了几天之后给出两个应对策略不让脚本直接开线程。如果脚本有异步需求通过IScriptHost宿主API去发起宿主自己控制线程生命周期卸载前先尝试调用Thread.Sleep(100)给域内线程一些响应时间然后捕捉异常给出提示。这两个策略虽然不能100%解决但实际使用中能把异常率降到很低。5.4 从源码继续扩展的方向这套代码完成之后我后来在几个方向上都做过扩展分享给大家参考脚本模板管理把常用脚本片段保存成模板新脚本可以直接插入模板再改现场效率很高变量监视面板脚本执行时动态查看IScriptHost里保存的全局变量有调试器的味道了远程脚本下发配合上位机平台把脚本通过网络下发到各设备端执行这就是一个真正的分布式脚本框架了脚本加密混淆对外分发时用混淆工具处理编译好的dll防止客户直接反编译看到业务逻辑。最后想多说一句。我在实际使用这套编辑器时最深的体会是脚本编辑器不是越复杂越好而是越贴近用户的使用场景越好。你不需要让用户写出工程级的代码只需要让他们能安全、快速、方便地把一小段逻辑跑起来。这个平衡点把握好了哪怕用的是.NET 4.0哪怕智能提示只是基础引擎的水平在实际项目中也能发挥很大的价值。本文还有配套的精品资源点击获取
返回列表