
前年冬天在热处理车间调一套上位机工艺参数表三天一小改、五天一改每次改完都要重新编译发布现场停线等我们装包那滋味真不好受。也就是从那时候起我开始认真琢磨C# 脚本引擎这件事——让公式、规则、判定逻辑从硬编码里搬出来变成可以热更新的文本。市面上能用的东西不多翻了一圈最后落在AScript和Flee这两个名字上前后在三个项目里都用过也踩过不少坑。这篇东西写给正在做 C# 上位机、工控采集、业务规则引擎或者做工具类程序、需要让用户自己写公式的朋友。如果你只是写一个固定逻辑的桌面小工具那确实用不上但只要你的程序里出现了参数可能会变阈值可能会调客户想要自己配公式这类需求脚本引擎就是个绕不开的选项。Flee 和 AScript 看着都能执行一段字符串但它们的定位差了十万八千里选错了前面的代码全得推倒重来。下面我把两个引擎的能力边界、编译机制、性能表现、线程模型和实际落地架构一条条拆开讲。1. 先想清楚你的项目到底需要哪种脚本能力1.1 三类典型需求对应三种不同的技术选择在动手选引擎之前我习惯先把需求归类因为脚本这个词被用得太滥了不同的人说脚本指的压根不是一回事。第一类是纯公式计算。比如热处理炉的升温速率补偿、视觉检测里的像素到毫米换算、金融里的利息计算。这类需求的特征是输入一堆数值输出一个数值或者一个布尔值没有分支流程、没有状态、没有循环。它的本质是一个数学表达式用表达式引擎就够了上完整脚本引擎反而是杀鸡用牛刀。第二类是规则判定。比如报警分级温度超过 80 且持续 3 秒判定为二级报警、电流大于额定值 1.2 倍且运行时间超过 10 分钟触发维护提醒。这类需求里有if/else、有逻辑组合、有时还要查一下设备状态表表达式引擎用三元运算符硬凑也能写但可读性会很差维护的人会骂你。第三类是业务逻辑热更新。这类需求最重比如上位机里的工艺流程上料、加热、保温、冷却每一步的判定条件、跳转条件、异常处理都希望现场工程师能自己改改完不用重新发版。这就必须上真正的脚本引擎了需要类、方法、闭包、异常处理甚至需要脚本类继承宿主的抽象类。我的经验是先按第三类去想再按实际需求降级。很多项目一开始说要热更新最后其实只用到公式计算也有些项目一开始说就填个公式半年后就要写流程了。所以选型时留一点余量是值得的但没必要一步到位上重型引擎。1.2 表达式引擎和脚本引擎本质差别在哪这两个东西最核心的区别在编译期能看到什么。表达式引擎Flee 属于这类拿到的是一个表达式字符串它需要把字符串解析成语法树然后翻译成 .NET 的表达式树最后编译成委托。这个过程中所有变量的名字和类型必须在编译之前就确定下来因为类型一旦确定编译出来的 IL 就是强类型的、高效的了。代价是它没有声明这个概念——你不能在表达式里var x 1;因为编译器不打算处理作用域。脚本引擎AScript 属于这类拿到的是一个完整的源码文本它有词法分析、语法分析、语义分析、类型推断一整条链路能识别class、public、return这些关键字能在编译期建立符号表然后生成 IL 或者解释执行。它能做表达式引擎做不了的事定义类型、定义方法、建立闭包、捕获外部变量、甚至继承宿主类型。打个比方表达式引擎像一台计算器你按进去一个式子它给你结果但你没法定一个函数下次再用脚本引擎像一门小语言你可以写一个完整的模块编译一次反复调用其中不同的入口。这个区别直接决定了你后面所有的架构设计。用 Flee 的时候你要维护的是一个公式字符串 一组变量用 AScript 的时候你维护的是一份脚本源码 一个引擎实例能玩的花样多得多但要操心的东西也多得多。2. Flee把表达式这一件事做到极致的轻量方案2.1 十分钟上手Flee 的最小可用代码Flee 的全称是 Fast Lightweight Expression Evaluator直译就是快速轻量表达式求值器。它的设计目标非常纯粹把一段字符串表达式求值快到几乎和手写 C# 没区别。引用方式也简单一个 NuGet 包搞定没有额外配置文件、没有代码生成步骤。最短的用法大概长这样把一个表达式编译成强类型委托之后反复调用using Flee; using System.Globalization; var ctx new ExpressionContext(); ctx.Options.ParseCulture CultureInfo.InvariantCulture; // 关键点变量必须在 Compile 之前注入编译期就要知道类型 ctx.Variables[inletTemp] 0.0; ctx.Variables[flow] 0.0; IGenericExpressiondouble expr ctx.Compiledouble( inletTemp * 1.8 32 flow * 0.5); // 复用同一个上下文改值再求值就行 ctx.Variables[inletTemp] 82.5; ctx.Variables[flow] 12.0; double result expr.Evaluate();这段代码里有个新手最常踩的坑ctx.Variables[...]必须在Compile之前赋值。很多人习惯先把表达式编译好缓存起来运行的时候再往里塞变量结果直接抛ExpressionCompileException提示找不到变量。原因是 Flee 在编译阶段就要推断变量类型同名变量如果第一次是int后面塞double也会出问题。所以我的做法是在引擎初始化阶段就把变量名和默认值全部声明好形成一个变量契约运行期只改变量的值不改类型。这个契约一旦定下来后面维护起来反而舒服因为谁都能一眼看出这个公式能用到哪些参数。Flee 还有一个很实用的能力是绑定宿主对象。如果你的表达式要访问某个对象的属性不需要一个个往Variables里塞直接把对象传进上下文表达式里就能像写 C# 一样访问它的公开成员var ctx new ExpressionContext(deviceService); // 宿主对象 ctx.Options.OwnerType typeof(DeviceService); var expr ctx.Compilebool(LastTemp AlarmThreshold IsOnline); bool needAlarm expr.Evaluate();这种写法在工控上位机里特别顺手因为设备对象本来就是现成的不用再写一层映射代码。但要注意能访问的只有 public 成员私有字段访问不到另外属性 getter 里的逻辑会在每次求值时执行如果那个 getter 里有数据库查询或者串口通信表达式求值就会变成性能黑洞。2.2 它的编译路径和性能账Flee 的执行链路是字符串 → 语法解析 → .NET 表达式树System.Linq.Expressions→ 动态方法 → 委托。默认情况下它走的是Expression.Compile()产出的是一个轻量级动态方法如果打开EmitToAssembly之类的选项它会把表达式直接编译成 IL 写进一个动态程序集性能会更好但会带来动态程序集的加载开销和卸载困难的问题。这里必须说一个很多人忽略的点Flee 的编译开销是不小的第一次编译一个中等复杂度的表达式我实测大概在 3 到 8 毫秒。如果你在做数据采集每秒来一条数据、每条数据都Compile一次那你的 CPU 基本上就在做语法分析根本不干正事。正确姿势只有一条编译一次缓存委托反复调用。缓存键就是表达式文本本身用ConcurrentDictionarystring, IGenericExpressiondouble就够了。关于速度我先给个量级感受一个简单的a b * 2手写 C# 大概是几纳秒级别Flee 编译后的委托调用大概在几十到两百纳秒之间具体取决于参数类型和表达式的复杂程度。也就是说它比原生慢一到两个数量级但绝对值依然很小——在每秒几万次求值的量级上完全无感。真正拖慢你的永远不会是求值本身而是编译、装箱、字典查找这些周边开销。顺便提一个容易被忽略的性能陷阱Flee 的Evaluate()返回object会装箱。如果你的循环里是expr.Evaluate()每次都会产生一个装箱对象几十万次之后 GC 压力就很明显了。所以只要类型确定一律用EvaluateT()或者用CompileT()编译成强类型接口能省掉大量无谓的堆分配。这个小细节我在一个每秒两千次求值的项目里改过GC 的 Gen0 回收次数直接降了差不多三分之一。2.3 Flee 明确不打算做的事Flee 最值得称赞的地方是它对自己的边界非常清楚。它不是脚本引擎所以下面这些事它做不了也不打算做不能声明变量。表达式里没有var你只能用外部注入的变量。不能定义方法。想复用一段逻辑只能在宿主代码里注册成函数ctx.Functions[Round2] new Funcdouble, double(x Math.Round(x, 2));这样注册进去再在表达式里调用。不能写语句块。if/else语句、for、while这些都不支持只能用三元运算符和逻辑运算组合出等价效果。不能定义类型。更别谈继承和接口了。这些限制听起来很硬但换个角度想这也是它的优点攻击面小、行为可预测、资源消耗可控。一段 Flee 表达式写不出无限循环也开不了线程最坏情况就是抛个异常。如果你的场景只是让客户配个计算公式一个表达式引擎带来的安全感比一个功能齐全的脚本引擎强太多了。我个人的判断标准很简单如果你能用一行三元表达式描述清楚逻辑就用 Flee如果非要写三行以上还带嵌套就说明需求已经越界了硬塞进表达式只会得到一个半年后没人看得懂的怪物字符串。3. AScript一个贴着 C# 语法写的脚本引擎3.1 它能写到什么程度AScript 是国产的 C# 脚本引擎定位和 Flee 完全不在一个层级——它是一门完整的脚本语言语法大量借鉴 C#让你用写 C# 的肌肉记忆去写脚本。它的部署特点是比较干净核心就是几个程序集文件拷进去引用一下就能用不需要额外的构建工具链这一点对于需要离线部署的工控现场很友好。它能支持的东西包括类定义、字段和属性、方法、构造函数、静态成员、泛型部分、委托和 Lambda、闭包捕获、异常抛出与捕获、using和资源释放、运算符重载、索引器。基本上你在 C# 里写业务逻辑的常用语法它都能接住。这意味着你可以把一整块业务逻辑搬到脚本里在宿主里只留一个调用入口。举一个我实际项目里的例子。那套上位机需要把报警分级逻辑开放给工艺工程师原来是一堆硬编码的if后来整块搬进了脚本using AScript; var engine new ScriptEngine(); // 把宿主服务注入脚本环境脚本里可以直接用它 engine.Context.SetGlobalValue(device, deviceService); var script engine.Compile( public class AlarmRule { private int _consecutiveCount 0; public int Check(double temp, double rate) { if (temp 80.0 rate 2.0) { _consecutiveCount; if (_consecutiveCount 3) return 2; // 二级报警 return 1; } _consecutiveCount 0; return 0; } public bool IsDeviceHealthy() { return device.IsOnline device.ErrorCode 0; } } ); script.Run();上面这段 API 命名我会明说一句AScript 不同版本之间类名、方法名有出入ScriptEngine、Compile、Run这几个是我手上 1.1.x 的写法你照着自己版本里的示例改一下名字就行思路是一样的。这一点很重要因为脚本引擎这类库版本迭代快网上抄来的代码跑不通八成是版本对不上不是你的问题。注意上面这个AlarmRule类里有一个_consecutiveCount字段它是有状态的。这是 Flee 绝对做不到的事情——表达式引擎是无状态的每次求值都是一次纯函数调用。而脚本引擎可以持有状态、可以跨调用累积这让连续三次超限才报警这种业务逻辑变得非常自然不用在宿主里维护一堆计数器。3.2 宿主和脚本怎么互相调用双向互操作是脚本引擎真正值钱的地方我把它拆成两个方向说。宿主 → 脚本宿主把对象注入脚本环境上面的SetGlobalValue就是干这个的脚本里直接按名字访问。这里有个细节值得注意注入的对象最好设计成一个窄接口不要图省事把整个窗口对象或者整个数据访问层丢进去。我在一个项目里见过有人把MainForm直接注入脚本结果脚本作者访问到了this.Invoke、this.Controls之类的成员写出了直接操作 UI 的代码一旦脚本里开了个循环去刷新控件整个界面就卡死了。后来我们把注入对象收敛成一个只有十来个只读属性和三个方法的接口脚本能做的事情被限制在业务范围内问题少了一大半。脚本 → 宿主宿主调用脚本里定义的函数。常见做法是把脚本函数取出成一个强类型委托var script engine.Compile( public double Compensate(double raw, double ambient) { double factor 1.0 (ambient - 25.0) * 0.002; return raw * factor; } ); script.Run(); // 按名字把脚本函数取出来当委托用 var compensate script.GetDelegateFuncdouble, double, double(Compensate); double fixedValue compensate(100.5, 38.0);这种写法很舒服脚本负责算法宿主负责调度和数据采集两边通过一个明确的函数签名对接。签名的类型一旦确定脚本里写错返回类型编译期就会报错不用等到运行期才发现。还有一种是脚本继承宿主类型。AScript 支持脚本类继承宿主定义的抽象类或者实现接口这个能力在做插件式架构时非常好用——宿主定义一个IPlugin脚本实现它宿主通过反射或者引擎提供的接口把脚本类实例化当成普通对象调用。这套机制配合 C# 的接口抽象可以把脚本的侵入性降到最低。3.3 编译到 IL 的执行路径与热更新AScript 的执行方式是把脚本编译成 IL 再执行而不是逐行解释。这一点非常关键因为它决定了性能上限编译成 IL 之后实际执行的就是 JIT 编译后的机器码性能跟手写 C# 在一个量级上只是多了一些边界检查、类型转换和上下文访问的开销。我实测下来一个简单的数值计算脚本函数比同等逻辑的原生 C# 方法慢三到八倍左右绝对值也就是每次调用几十纳秒在每秒几万次调用以下的场景基本无感。编译过程本身是有成本的。一个中等规模、几百行的脚本首次编译大概在 5 到 20 毫秒这个区间脚本越复杂、类型越多编译越慢。所以跟上一条同样的道理脚本编译一次把引擎对象和脚本对象缓存起来别每次调用都重新编译。热更新是它的另一个核心卖点。思路是保留脚本源文件路径和一份编译好的Script对象当文件发生变化用FileSystemWatcher监听或者提供重新加载按钮重新Compile出一个新的Script对象把引用原子性地替换掉。旧对象交给 GC 回收新对象承接后续调用。这里有一个容易被忽略的问题旧脚本对象持有的状态会丢。比如上面那个_consecutiveCount热更新之后归零了。如果你的业务逻辑依赖状态连续性就得在热更新时做状态迁移——把旧对象里需要保留的字段读出来塞进新对象。这件事没有银弹只能按业务设计。我的建议是尽量把状态放在宿主侧脚本侧只放纯计算逻辑这样热更新就是无痛的。实在要放状态就在脚本里实现一个ExportState/ImportState的约定方法宿主在替换时调用。4. 硬碰硬对比七个维度拉平来看4.1 能力边界对照表说得再热闹不如拉一张表出来。下面这张是我自己整理的能力对照基于我实际用过的版本具体细节可能随版本更新有出入但大方向是稳的。对比维度FleeAScript定位表达式求值器完整脚本语言语法范围单个表达式 三元运算符类、方法、字段、属性、异常、闭包变量声明不支持必须宿主注入支持脚本内部自由声明定义方法不支持只能宿主注册函数支持脚本内直接定义状态保持无状态支持脚本类持有字段状态继承与接口不支持支持继承宿主类型、实现接口循环与分支不支持语句只能运算组合支持 if/for/while/foreach执行方式表达式树编译为委托编译为 IL 后执行部署体积单个程序集若干程序集文件学习成本半小时熟悉 C# 的人基本零成本失控风险低写不出死循环中需要主动加超时与资源限制适合场景公式、判据、配置化阈值流程编排、规则引擎、插件化逻辑这张表里有两行是我认为最关键、也最容易被忽视的一个是状态保持一个是失控风险。前者决定了你能不能把带累积逻辑的业务搬进脚本后者决定了你敢不敢让客户自己写脚本。4.2 性能实测我跑的几组数据性能这部分我强调一句下面的数字只是数量级参考不是基准结论。表达式复杂度、参数类型、机器主频、是否开启激进优化都会让结果相差数倍。我跑的环境是一台普通的开发机Release 模式、无调试器附加、循环内做预热取多次运行的中位数。场景原生 C#Flee表达式树Flee编译到程序集AScript编译执行单次加法表达式求值几纳秒约 150-250 纳秒约 30-60 纳秒约 60-120 纳秒100 万次求值累计约 5-10 毫秒约 180-260 毫秒约 40-70 毫秒约 80-140 毫秒首次编译开销0约 3-8 毫秒约 8-20 毫秒约 5-20 毫秒带字符串拼接 10 万次约 20 毫秒约 150 毫秒约 60 毫秒约 70 毫秒从这张表能读出几个结论。第一原生 C# 永远是最快的脚本引擎的价值不在性能在灵活性。你上脚本引擎付出的代价是一到两个数量级的性能损失换来的是不用重新发版就能改逻辑。这笔账划不划算取决于你的发布成本和业务变化频率。工业现场停线一小时的损失可能够买十台服务器那这个性能损失就完全值得。第二Flee 在性能上是有优势的前提是打开编译到程序集的选项。表达式树的Expression.Compile()本身已经很快了但走动态程序集的路径更快缺点是动态程序集一旦加载就难以卸载在需要频繁更新表达式的场景下会造成内存缓慢增长。我的做法是表达式集合在启动时基本固定就开启程序集编译如果表达式是运行期高频新增的就老实用表达式树模式。第三AScript 的性能落点很接近 Flee 的编译模式考虑到它要做类型转换、上下文查找、边界检查这个结果已经很不错了。真正影响体验的不是求值速度而是编译速度和并发争用。这里还要补一句关于编译缓存命中率的经验。我在某个项目里做过统计实际生产环境中同一批表达式的重复编译率高达 95% 以上也就是说你只要加一层缓存就能干掉绝大多数的编译开销。这一层缓存代码不超过二十行收益却是数量级的是所有优化里性价比最高的一条。4.3 部署、依赖与可维护性部署这块两个引擎走的是两条路。Flee 是标准 NuGet 包dotnet add package一句就完事依赖清晰升级有版本记录。AScript 通常是若干程序集文件直接引用这在离线环境、老框架项目里很有优势——不需要联网拉包拷贝到 lib 目录加引用就行。但如果你的项目已经全面转向现代包管理这种方式在依赖解析和维护上会略微麻烦一点特别是多个项目共享的时候要保证程序集版本一致。可维护性上我的观察是Flee 的表达式难维护但容易限制。表达式写长了会变成面包屑可读性断崖式下降但它的能力上限低写不出多危险的东西。AScript 的脚本好维护但容易失控。脚本能写成完整的类可以加注释、可以分文件、可以用设计模式可读性好得多但同时也意味着脚本能写出死循环、能阻塞线程、能持有大量内存。所以我的取舍是Flee 用于配置化AScript 用于逻辑化。配置化意味着数量多、单个体量小、变化频繁逻辑化意味着数量少、单个体量大、需要较强的表达能力和维护性。还有一点关于老项目的如果你的项目还停在比较老的 .NET Framework 版本上选型时要先确认引擎的目标框架支持情况别选完了发现程序集加载不了那时候改起来就麻烦了。这个坑我帮别人填过一次好在只是换引擎没有动业务代码。5. 选型与落地三套可以直接抄的架构5.1 只用 Flee 的场景与配置如果你的需求就是让用户自己配公式那整套架构可以非常简单我直接给一个可以直接抄的结构。核心是一个公式管理服务启动时从配置数据库或 JSON 文件把所有公式读进来逐条编译缓存到一个ConcurrentDictionary里键是公式名字。运行期调用时从字典里取出表达式对象写变量求值。刷新时清空字典重新编译即可。public sealed class FormulaService { private readonly ConcurrentDictionarystring, IGenericExpressiondouble _cache new(); public void Load(IEnumerable(string Name, string Body, string[] Vars) defs) { _cache.Clear(); foreach (var def in defs) { var ctx new ExpressionContext(); ctx.Options.ParseCulture CultureInfo.InvariantCulture; ctx.Imports.AddType(typeof(Math)); // 变量契约编译前必须声明 foreach (var v in def.Vars) ctx.Variables[v] 0.0; _cache[def.Name] ctx.Compiledouble(def.Body); } } public double Eval(string name, IDictionarystring, double values) { if (!_cache.TryGetValue(name, out var expr)) throw new KeyNotFoundException($公式未定义: {name}); lock (expr) // 表达式对象不保证线程安全简单起见加锁 { var ctx GetContext(expr); foreach (var kv in values) ctx.Variables[kv.Key] kv.Value; return expr.Evaluate(); } } }这里有几个点值得展开说。加锁这件事。Flee 的表达式对象和上下文不是为并发设计的多个线程同时改同一个ctx.Variables再求值结果可能串号——A 线程写的值被 B 线程覆盖了。简单做法是每个线程一个上下文或者干脆加锁。如果求值都在采集线程里串行做那加锁几乎没开销。Vars契约的检查。我建议在加载阶段做一次严格校验解析公式文本找出里面用到的所有标识符跟声明的变量列表比对多出来的直接报错。这一步能拦掉 90% 的配置错误比运行期报异常友好得多。数值精度的选择。工控场景里用double还是decimal要认真想一下。double会有浮点误差做累积计算的时候误差会放大decimal精确但慢。我的经验是跟物理量相关的用double跟金额、计数、比例相关的用decimal。Flee 支持两种类型但同一个表达式里混用要注意显式转换。5.2 用 AScript 的场景与线程模型AScript 的落地要复杂一些最需要想清楚的是线程模型。脚本引擎实例通常不是线程安全的一个ScriptEngine加上它产出的Script对象最好只在一个线程上使用。那多线程怎么办三种方案第一种是每个线程一个引擎。用ThreadLocalScriptHost或者按线程 ID 建字典每个线程自己持有引擎和脚本实例。好处是完全无锁坏处是编译开销乘以线程数内存也乘以线程数。适合线程数量固定且不多的场景比如采集线程、UI 线程、后台计算线程三四个就顶天了。第二种是引擎池。搞一个对象池借用、使用、归还跟数据库连接池一个思路。这个方案能控制编译次数但要小心借出去忘了还导致的耗尽问题务必配合using或者 try-finally 使用。第三种是单线程队列。所有脚本调用都投递到一个专门的调度线程上去执行串行处理。这个方案最简单也最安全缺点是吞吐量受单线程限制。但对于大多数上位机场景脚本调用频率远没到需要榨干多核的程度我倾向于优先选这个。public sealed class ScriptHost : IDisposable { private readonly BlockingCollectionAction _queue new(); private readonly Thread _worker; private readonly ScriptEngine _engine new(); public ScriptHost() { _worker new Thread(ProcessLoop) { IsBackground true, Name ScriptHost }; _worker.Start(); } public T InvokeT(FuncScript, T action, TimeSpan timeout) { T result default; using var done new ManualResetEventSlim(false); Exception error null; _queue.Add(() { try { result action(_currentScript); } catch (Exception ex) { error ex; } finally { done.Set(); } }); if (!done.Wait(timeout)) throw new TimeoutException(脚本执行超时); if (error ! null) throw new ScriptInvocationException(脚本执行失败, error); return result; } }这段代码的骨架很土但非常实用所有脚本调用都带超时。这件事我在生产环境里强调过无数次因为脚本一旦写出死循环宿主线程就彻底卡死界面失去响应、采集停止、报警不发后果比脚本本身出错严重得多。加上超时之后最坏情况是丢弃这一次调用、报个错、把脚本标记为异常并禁用程序还能继续跑。要注意的是超时只能保护宿主不退让不能真的杀死脚本线程。因为脚本编译成 IL 之后就是普通托管代码Thread.Abort在现代 .NET 里已经不可用了。所以真要让脚本可中断得在脚本语言层面支持协作式取消——也就是引擎在循环回边、方法调用点检查一下取消标志。这需要引擎本身支持。如果你的版本没有这个能力那就只能在注入的宿主对象上做手脚所有耗时的宿主方法内部都做超时控制脚本再怎么转卡住的也是它自己那个线程不影响主流程。5.3 表达式加脚本的双层架构在几个项目里验证下来最舒服的架构是两层上层用脚本引擎管流程下层用表达式引擎管计算。具体说工艺流程、状态机、设备联动这类逻辑用 AScript 写因为需要分支、状态和调用而每一个步骤里的参数计算公式比如根据环境温度对读数做补偿用 Flee 写放在配置表里数量可能有几百条。这么分的好处很实在。公式数量多、变化频繁用表达式引擎加载快、编译快、占用小改一条公式只影响那一行流程逻辑数量少、变化慢、体量大用脚本引擎写每次改完重新编译一次脚本就行。两层各自的失控风险都被限制在合理范围内公式写不出死循环流程脚本数量可控、人工审核过。代价是两套东西要维护。但在实际项目里这个代价远比把所有东西塞进一层要小。我见过一个项目把几百条公式全部塞进一个大脚本文件里结果每次改一条公式都要重新编译整个脚本一个逗号打错整个系统停摆维护的人天天骂娘。6. 踩坑实录与排查速查表6.1 那些报错信息背后真正的问题这几年在两个引擎上踩的坑我挑几个最典型的说说都是不看文档就想不到的。Flee 报找不到变量但变量明明赋值了。这是最经典的坑前面提过本质是编译顺序。解决方式是把变量注入提前到Compile之前或者改用绑定宿主对象的方式让属性直接可见。还有一种隐藏情况变量名里有数字开头、或者跟内置函数重名比如你起了个变量叫if那肯定解析不了。表达式里的除法结果不对。如果两个操作数都是整型Flee 会做整数除法5/2得到2而不是2.5。这在计算速率、比例的时候特别容易出错而且不会抛异常只是结果偏了。我的做法是所有变量契约里统一用double声明从源头避免整数除法。小数点在不同区域设置下解析失败。有些地方的区域设置用逗号做小数点3.14会被解析成314。所以一定要固定解析文化前面代码里的ParseCulture就是干这个的千万别省。AScript 报类型未定义但其实引用了。这种问题多半是脚本编译时引用的程序集列表里没有加进去需要在引擎配置里显式添加需要暴露给脚本的程序集和命名空间。我习惯把暴露给脚本的类型收敛到一个专门的脚本契约程序集里只暴露必要的类型既能减少编译开销也顺便做了安全隔离。脚本里改了宿主对象的字段但宿主看不到。检查一下注入的是对象引用还是值拷贝。如果是值类型或者传的是struct脚本里改了宿主的原对象不会变。解决办法是注入引用类型或者干脆只允许脚本读取、不允许写入让数据流单向。热更新之后旧脚本还在跑。这类问题通常是引用替换不彻底某个地方缓存了旧的Script对象或者旧对象被某个事件注册了回调替换引用之后回调还指着旧的。解决方式是给脚本对象一个显式的Dispose约定替换前先解绑所有事件订阅。6.2 资源限制与稳定性红线如果脚本是给客户或者现场工程师用的下面这几条我建议当成红线一条都别省。第一条一定要有超时。不管引擎支不支持协作式取消宿主这侧必须做超时保护。我的做法是包一层调度器所有脚本调用都带超时参数超时就记录日志、标记脚本异常、返回默认值并且把该脚本临时禁用避免持续触发。第二条注入对象要收窄。前面说过别把整个窗体或者整个数据访问层注入进去。界定标准是脚本需要什么能力就给它什么接口多一个方法都不给。尤其是涉及文件写入、进程启动、网络请求这类操作能不给就不给。第三条限制脚本能访问的类型。如果引擎支持配置可见类型列表一定要配置。不要把System.IO.File、System.Diagnostics.Process、System.Reflection这类类型暴露给脚本。我见过一个项目把反射暴露出去之后脚本里能操作任意内部字段这已经不只是技术问题而是工程管理上的隐患。第四条脚本异常要吞得住。脚本抛异常是很正常的事业务规则写错了、数据格式不对了都会抛。宿主这一侧必须 try-catch 包住所有脚本调用把异常转成业务层面的规则执行失败然后走降级逻辑绝不能让它冒泡到主循环里把采集线程干掉。第五条脚本要有版本记录。脚本内容存在哪里谁改的什么时候改的我的做法是脚本存数据库或者带版本号的目录每次加载记录哈希值出了问题能快速回滚到上一个版本。这个习惯在一次现场事故里救过我——工程师改了脚本导致误报警五分钟内回滚客户都没发现。6.3 常见问题速查表最后把这几年积累的问题整理成一张表方便排查时对着看。现象大概率原因处理方式编译报变量不存在变量未在编译前声明先注入变量契约再编译计算结果偏差整型除法或浮点精度统一用 double/decimal避免整型混算数值小数点错位解析文化未固定固定为不变文化首次调用卡顿明显编译开销集中在首次启动时预热编译运行期命中缓存批量求值时内存上涨装箱、字符串拼接、动态程序集累积用强类型求值控制表达式新增频率界面失去响应脚本死循环阻塞了宿主线程加执行超时脚本调用放独立线程热更新后状态丢失脚本类内部字段随对象重建状态外移到宿主或实现状态导入导出脚本里访问不到某类型暴露的程序集与命名空间未配置显式添加脚本可见类型清单多线程下结果串号共享上下文并发写变量每线程一个上下文或加锁/单线程调度换版本后代码跑不通API 命名随版本变化对照当前版本自带示例别照抄旧文章我把这张表贴在自己项目的文档目录里新同事接手的时候省了大量沟通成本。尤其是最后一行脚本引擎这类库更新比较快网上流传的示例代码很多都是好几年前的版本直接抄十有八九报错遇到问题先怀疑版本再看自己的代码。再补充一个我个人的小技巧给每个脚本加一个自检入口。约定脚本里实现一个SelfTest()方法返回一个字符串内容是它对当前配置的判断。加载脚本时自动调一次把结果打到日志里。这样脚本一加载上来你就能看到这条规则已就绪阈值 80 摄氏度这样的输出配置错了能立刻发现比等到半夜报警刷屏再去查要好得多。从热处理车间那套上位机到现在我经手的项目里Flee 和 AScript 都用过不止一次最后形成的偏好是能用表达式解决的坚决不上脚本必须上脚本的时候把宿主这一侧的防护做足做好。脚本引擎真正的价值不在它多能算而在它能在不发版的前提下改变系统的行为这份灵活性值钱但也意味着责任要落在架构设计者身上——你要替那些写脚本的人把边界画清楚把兜底做扎实。