深入 Ruby E07:受限规则语言:从 block DSL 到独立解析器
同一条筛选规则,不同的执行权限
任务筛选可以写成 Ruby block,也可以由外部文本描述。两者看起来都在回答“保留哪些任务”,但执行权限不同。block 能修改捕获的数组、调用文件接口或发起网络操作;外部规则只需要比较优先级、状态与标签,没有理由得到整个 Ruby 运行时的能力。
本篇建立一个独立的小语言。示例规则为:
1 | |
输入三项任务后,结果应包含 ID 1、2。这个结果来自 and 比 or 绑定更紧:已完成任务直接入选,未完成任务必须同时满足优先级和标签。括号可以覆盖默认优先级,not 只否定它后面的一个更紧密表达式。
前置内容是对象协议、DSL、模式匹配和输入边界。实现固定在 Ruby 3.4.11,不添加 gem;完整代码为 examples/ruby/labs/E07/rules.rb 与 run.rb,附件为 本篇实验源码。
先规定语言能够表达什么
实验只接受优先级、状态和标签三类谓词。优先级使用整数与五种比较运算,状态和标签仅支持相等;标签相等的含义是任务的标签数组包含该值。文法如下:
1 | |
词法层允许空白,字符串使用双引号,内容限定为小写 ASCII 字母开头,后接小写字母、数字、下划线或短横线,最多 32 字符。优先级字面量必须在 1 到 5 之间,状态只能是 todo 或 done。本语言没有注释、转义、方法调用、变量赋值和用户正则。
这些限制是当前业务契约,不是 Ruby 语法。将来若需要中文标签,应连同编码、长度、转义和比较规范一起扩展;不能只放宽一个正则后假定所有层都会正确处理。语言能力越小,越容易说明哪些输入能产生什么结果。
整数词法允许前导零,并按十进制解析,但限制最多三位。这个选择展示了词法形状和语义范围的区别:005 可以表示 5,999 虽然是合法整数 token,却在语义检查中被拒绝。
Lexer 必须覆盖每一个字符
Lexer.call 先检查输入为有效 UTF-8 标记的 ASCII String,再检查 1024 字节上限。随后从当前位置扫描空白或 token;遇到无法识别的字符立即报错,不能跳到下一个看起来合法的位置继续解析。
实现的核心是固定正则配合 \G 和显式 offset。\G 限制匹配开始位置,使词法扫描与当前位置衔接。>=、<= 等长运算符放在单字符运算符之前,否则同一输入可能被错误拆分。Ruby Regexp
例如 priority >= 1; exit 在分号处被拒绝,而不是仅解析前半段后忽略尾部。system("echo") 即使部分字符能形成 token,随后也因字段不在允许集合而失败。词法允许某个词出现,并不表示语法授予它执行权限。
每个 token 保存种类、值和原输入 offset。报错可以指出问题位置,客户端也可以据此标出输入区间。当前输入限定 ASCII,因此字符位置与字节位置一致;扩展 Unicode 后必须重新定义错误位置的单位。
Parser 用调用结构表达优先级
递归下降解析器让 disjunction 调用 conjunction,后者调用 unary,最终进入 predicate。高优先级部分先形成子树,低优先级规则再把子树组合起来。
1 | |
同层连续运算采用循环,生成左结合的树;括号和 not 才增加解析递归深度。解析器最终必须抵达 end token,因此合法表达式后面附加 garbage 也会失败。只返回第一棵成功解析的树,会给未消费的输入留下歧义。
原示例的 AST 根节点是 or,右孩子是 and:
flowchart TD
O[or] --> S["status == done"]
O --> A[and]
A --> P["priority >= 4"]
A --> T["tag == ruby"]
lab 同时断言树形与最终筛选结果。仅检查 ID 列表可能碰巧通过,例如样本全部满足条件时,错误优先级也能得到相同结果。结构断言与区分性输入应同时存在。
节点使用 Data.define(:kind, :args)。Data 本身不会递归冻结成员,所以构造节点时额外冻结参数数组,词法字符串也被冻结。这样 parser 产出的树不需要在求值时修改。Ruby Data
本实现只允许 evaluator 使用本 parser 产生的 AST。它没有把任意外部 Ruby 对象或反序列化节点作为输入协议;若对外提供 AST 接口,还需要独立验证节点种类、结构、循环和总量。冻结一个对象不能替代这些检查。
Evaluator 只解释已定义的节点
求值器用固定 case 区分 and、or、not 和 predicate,不把字段名传给 send。标签比较对应 task[:tags].include?;其余操作只在已声明的字段和运算符中选择。
1 | |
这段是完整实现中布尔节点的分支,predicate 另有显式比较分支。语言的短路语义来自这里的控制流。priority >= 1 or tag == "ruby" 对合法任务在左侧已经为真时,不再访问右谓词;lab 给单项任务仅两次节点预算,仍能成功,验证确实发生了短路。
输入任务也先经过检查:必须是普通 Hash,优先级是范围内 Integer,状态属于有限集合,标签数组和标签长度都有上限。否则即使 AST 完全合法,底层数据仍可能让比较失败,或者把一个本应常量成本的操作变成长遍历。
求值结果保留原任务对象,当前接口是只读筛选视图,不承诺深复制隔离。调用者若要编辑结果,应在应用边界按字段复制。不能因为规则无法修改任务,就认为之后拿到引用的其他代码也无法修改它。
资源限制贯穿解析与执行
本实现拒绝超过 1024 字节的规则、超过 128 个 token 的输入和超过 12 层的括号或连续 not。AST 节点数另设 128 上限;在当前文法和 token 限制下,这是冗余保护,不能当成另一个已经独立触发过的验收点。
平铺的 and 与 or 虽然不增加解析递归计数,却会产生较深的左结合 AST。其规模仍受 token 总量限制。若提高 token 上限,应重新评估 evaluator 的递归栈,或者改成显式栈遍历,不能只把一个数字放大。
每批最多 100 项任务,每项最多 32 个标签。一次批量求值共享默认 256 次节点访问预算,进入一个节点消耗一次。预算耗尽会使整个调用抛 Rules::Error,不会把已经选中的前半批作为正常结果返回。调用者必须能区分“无匹配”与“计算未完成”。
预算是可重复测试的工作量限制,不是墙钟超时。程序自身的运行环境、对象校验和调度也有成本;若规则服务需要硬时间隔离,还需在进程与请求层建立额外边界。本篇没有把几条长度限制称作完整安全证明。
解析与求值分离后,同一棵树可以服务多批任务。词法和语法检查只处理规则,任务校验与预算则在每次求值时重新建立。不能把上一次的剩余预算保存在共享 AST 中,否则同一规则的结果会依赖此前调用次数,也会给并发调用引入共享状态。
若增加规则缓存,缓存容量与淘汰也属于资源边界。限制单条规则为 1024 字节,并不能限制无限多条不同规则的总占用。当前实验没有缓存或全局注册表,因此没有声称解决这项长期运行问题。引入缓存前,先决定键的规范化、租户隔离和总量限制,再验证重复解析是否真的构成瓶颈。
错误类型也应保持一致。语法拒绝和预算耗尽都通过本语言的 Error 报告,但客户端可以进一步增加错误代码区分原因;程序缺陷不应随意包装成“用户规则错误”。本实验只在已知边界主动抛错,没有用一个覆盖全部异常的 rescue 掩盖实现故障。
block DSL 的对照暴露真实能力
同一批任务可以交给普通 Ruby Proc:
1 | |
筛选本身返回 ID 2、3,但所有任务 ID 同时写入了外部数组。这个副作用不需要破坏 DSL 的表面形式,闭包本来就能访问定义处的变量。因此 Ruby block DSL 适合受信任程序代码;它并非把外部文本安全执行的方式。
本语言从词法、文法到 evaluator 都没有执行外部 Ruby 源码。给 eval 加禁用词,或把接收者改成一个方法较少的对象,不能自动得到同样的边界。Ruby 官方安全文档也明确反对过滤后再执行不可信文本,并提醒动态方法分派需要允许集合。Ruby 安全说明
实验、失败判定与练习
从仓库根目录执行:
1 | |
结果包含 ids=[1, 2] AST=or/and block_effects=[1, 2, 3],最后为 PASS lab E07。反例覆盖空输入、缺失值、非法状态、错误运算符、调用语法、分号、尾部垃圾、注释、转义与小数;另有字节、token、递归深度、编码、任务数、任务结构及求值预算失败。
每个拒绝案例仅捕获 Rules::Error。若输入意外被接受,测试抛出另一种错误使整个实验失败。拒绝日志不是普通打印说明,而是已经走过对应失败路径的结果。
| 阶段 | 拒绝的内容 | 保留下来的信息 |
|---|---|---|
| Lexer | 非法字符、编码与过量输入 | token 类型、值、位置 |
| Parser | 非法组合、未知字段、错误字面量 | 明确优先级的 AST |
| Evaluator | 非法任务、耗尽的工作量预算 | 成功完成的筛选结果 |
练习一:增加 priority != 3。先写一个当前会拒绝的用例,再同时修改 lexer、parser 和 evaluator,确认其他字段仍不接受 !=,并给优先级表达式补正反样本。
练习二:把默认预算设为 2,比较单项与多项任务的结果;再将 or 左侧换成假谓词,观察右侧访问怎样改变预算消耗。要求耗尽时返回明确错误,而不是不完整列表。
参考资料
系列导航
导读 · 上一篇:E06:SQL 与事务:对象状态何时成为数据库事实 · 下一篇:E08:后台任务与消息处理:重试怎样改变副作用 · 完整源码包
