前年冬天在热处理车间调一套上位机,工艺参数表三天一小改、五天一改,每次改完都要重新编译发布,现场停线等我们装包,那滋味真不好受。也就是从那时候起,我开始认真琢磨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; IGenericExpression<double> expr = ctx.Compile<double>( "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.Compile<bool>("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 基本上就在做语法分析,根本不干正事。正确姿势只有一条:编译一次,缓存委托,反复调用。缓存键就是表达式文本本身,用ConcurrentDictionary<string, IGenericExpression<double>>就够了。
关于速度,我先给个量级感受:一个简单的a + b * 2,手写 C# 大概是几纳秒级别,Flee 编译后的委托调用大概在几十到两百纳秒之间,具体取决于参数类型和表达式的复杂程度。也就是说它比原生慢一到两个数量级,但绝对值依然很小——在每秒几万次求值的量级上完全无感。真正拖慢你的永远不会是求值本身,而是编译、装箱、字典查找这些周边开销。
顺便提一个容易被忽略的性能陷阱:Flee 的Evaluate()返回object,会装箱。如果你的循环里是expr.Evaluate(),每次都会产生一个装箱对象,几十万次之后 GC 压力就很明显了。所以只要类型确定,一律用Evaluate<T>()或者用Compile<T>()编译成强类型接口,能省掉大量无谓的堆分配。这个小细节我在一个每秒两千次求值的项目里改过,GC 的 Gen0 回收次数直接降了差不多三分之一。
2.3 Flee 明确不打算做的事
Flee 最值得称赞的地方,是它对自己的边界非常清楚。它不是脚本引擎,所以下面这些事它做不了,也不打算做:
- 不能声明变量。表达式里没有
var,你只能用外部注入的变量。 - 不能定义方法。想复用一段逻辑,只能在宿主代码里注册成函数,
ctx.Functions["Round2"] = new Func<double, 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.GetDelegate<Func<double, 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 能力边界对照表
说得再热闹,不如拉一张表出来。下面这张是我自己整理的能力对照,基于我实际用过的版本,具体细节可能随版本更新有出入,但大方向是稳的。
| 对比维度 | Flee | AScript |
|---|---|---|
| 定位 | 表达式求值器 | 完整脚本语言 |
| 语法范围 | 单个表达式 + 三元运算符 | 类、方法、字段、属性、异常、闭包 |
| 变量声明 | 不支持,必须宿主注入 | 支持,脚本内部自由声明 |
| 定义方法 | 不支持,只能宿主注册函数 | 支持,脚本内直接定义 |
| 状态保持 | 无状态 | 支持脚本类持有字段状态 |
| 继承与接口 | 不支持 | 支持继承宿主类型、实现接口 |
| 循环与分支 | 不支持语句,只能运算组合 | 支持 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 ConcurrentDictionary<string, IGenericExpression<double>> _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.Compile<double>(def.Body); } } public double Eval(string name, IDictionary<string, 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对象,最好只在一个线程上使用。那多线程怎么办?三种方案:
第一种是每个线程一个引擎。用ThreadLocal<ScriptHost>或者按线程 ID 建字典,每个线程自己持有引擎和脚本实例。好处是完全无锁,坏处是编译开销乘以线程数,内存也乘以线程数。适合线程数量固定且不多的场景,比如采集线程、UI 线程、后台计算线程,三四个就顶天了。
第二种是引擎池。搞一个对象池,借用、使用、归还,跟数据库连接池一个思路。这个方案能控制编译次数,但要小心"借出去忘了还"导致的耗尽问题,务必配合using或者 try-finally 使用。
第三种是单线程队列。所有脚本调用都投递到一个专门的调度线程上去执行,串行处理。这个方案最简单也最安全,缺点是吞吐量受单线程限制。但对于大多数上位机场景,脚本调用频率远没到需要榨干多核的程度,我倾向于优先选这个。
public sealed class ScriptHost : IDisposable { private readonly BlockingCollection<Action> _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 Invoke<T>(Func<Script, 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 都用过不止一次,最后形成的偏好是:能用表达式解决的坚决不上脚本,必须上脚本的时候把宿主这一侧的防护做足做好。脚本引擎真正的价值不在它多能算,而在它能在不发版的前提下改变系统的行为,这份灵活性值钱,但也意味着责任要落在架构设计者身上——你要替那些写脚本的人,把边界画清楚,把兜底做扎实。