省略接收者之后,规则仍是 Ruby 程序

系列导读 · 下载本篇完整实验

Rules.build { priority_at_least(2) } 看起来像专门的配置语言,实际仍执行普通 Ruby block。方法能够省略接收者,是因为执行上下文改变了;外部局部变量仍然可以被捕获。这种表达方式没有自动获得安全隔离。

本篇承接作用域与动态方法,在 Ruby 3.4 中比较普通 API、block DSL 和字符串求值。完整实验位于 examples/ruby/labs/17/run.rb。字符串形式只使用源码内固定的合成代码,以说明常量查找差异;外部输入不进入 eval。

1
2
3
threshold = 2
plain = Rules.new.priority_at_least(threshold)
dsl = Rules.build { priority_at_least(threshold) }

两种写法应产生相同的筛选行为。DSL 只是调用形式,字段校验与业务语义仍由普通方法负责。若两种入口各写一套验证逻辑,配置语法越简短,后续维护反而越容易分叉。

先建立普通 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Rules
attr_reader :minimum

def priority_at_least(value)
unless value.is_a?(Integer) && (1..3).cover?(value)
raise ArgumentError, 'priority'
end
@minimum = value
self
end

def match?(task)
task.fetch(:priority) >= minimum
end
end

这里限定优先级为一到三的整数。priority_at_least 返回自身,允许普通链式调用;match? 根据已配置阈值判断任务。这段行为不依赖 DSL,能够单独测试,也能被命令行参数解析器调用。

普通对象尚未配置阈值就调用 match?,不属于本例公开的正常使用路径。DSL 构建入口会验证完整性。若希望普通 API 也保证创建后立即可用,可以把阈值放进构造器,或提供明确的 finalize 操作;不能把一个半初始化对象当成完全合法的值。

规则配置是否允许重复设定也要定义。本例后一次赋值覆盖前一次,属于普通方法的行为。若产品需要拒绝重复设置,检查也应位于 priority_at_least 内,保证普通入口和 DSL 一起遵守。

instance_eval 改变 self

1
2
3
4
5
6
7
8
class Rules
def self.build(&block)
rules = new
rules.instance_eval(&block)
raise ArgumentError, 'missing priority' unless rules.minimum
rules
end
end

block 执行期间的 self 变成 rules,因此裸方法调用 priority_at_least 发给规则对象。外层局部变量 threshold 仍由 block 的定义环境提供。两个来源同时存在,不能把“切换上下文”理解成把整个词法环境替换。

实验返回 [self, threshold, outer_self],分别检查新接收者、捕获的阈值和显式保存的原接收者。结果显示改变的是当前接收者,闭包仍可使用原环境里的引用。DSL 代码若需要外层对象的方法,可以事先保存引用或显式传参,而不是假定裸调用仍指向原对象。

instance_eval 还能够接触接收者的实例变量与私有方法。这使 DSL 简短,也扩大了 block 对构建器内部状态的访问。把 DSL 交给受信任的项目代码使用,与把任意用户提交的 Ruby 执行在同一进程中,权限边界完全不同。

instance_exec 传入显式参数

1
2
3
4
5
6
7
8
threshold = 2
target = Object.new

result = target.instance_exec(3) do |value|
value + threshold
end

raise unless result == 5

instance_exec 同样改变 self,并把调用时给出的参数交给 block。它适合规则求值时既需要一个上下文对象,又需要显式输入的场景。输入可以来自受控结构,避免规则实现到处读取隐藏全局状态。

不过接收者切换仍有阅读成本。一个 block 中裸调用可能属于规则对象,局部变量来自外层,常量又使用定义处语境。若规则数量少且调用方主要是 Ruby 开发者,rules.priority_at_least(2) 通常已经够清楚。

还可以采用不切换 self 的 block API:创建对象后执行 yield rules,调用方写 Rules.build { |rules| rules.priority_at_least(2) }。这种方式多了一个接收者,却保留普通闭包习惯,编辑器也更容易追踪方法来源。是否值得省略这几个字符,应由配置的真实使用方式决定。

class_eval 的 block 保留定义处常量语境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module DefinitionContext
TOKEN = :lexical

def self.install(klass)
klass.class_eval do
define_method(:token) { TOKEN }
end
end
end

class EvalTarget
TOKEN = :target
end

DefinitionContext.install(EvalTarget)
raise unless EvalTarget.new.token == :lexical

class_eval 把执行接收者设为目标类,因而 define_method 将方法加入目标类。block 内的 TOKEN 却仍受 block 定义位置约束,得到 :lexical。方法最终属于哪个类,与常量名从哪个词法环境解析,是两项独立信息。

这个差异在类生成器里很重要。生成器位于库自己的命名空间,目标类位于应用命名空间;如果生成方法需要应用的配置,应显式传入或限定访问,不要依靠目标类恰好有同名常量。

把 class_eval 与 instance_eval 当成完全相同的接口也会误导。前者针对模块或类的定义上下文,常用于安装实例方法;后者针对单个接收者的上下文。方法添加到哪里,应结合调用接口与具体定义形式验证。

字符串求值建立另一种代码定义环境

完整实验对目标类执行固定字符串:

1
2
3
4
5
6
7
EvalTarget.class_eval(
'def string_token; TOKEN; end',
__FILE__,
__LINE__
)

raise unless EvalTarget.new.string_token == :target

字符串被重新解析为 Ruby 源码,其常量查找在这里以目标类的求值上下文解释,因此返回 :target。这与前面的 block 结果不同。不能为了减少闭包问题,把 block 简单转成字符串而期待语义不变。

文件名与行号参数有助于错误定位,但它们不验证源代码安全。字符串拼接还会引入引号、转义、名称冲突和注入问题。能够用普通方法、define_method 或显式参数表达的行为,不需要重新生成 Ruby 源码。

外部配置应优先作为数据读取。若用户只能选择阈值与标签,解析一个有限字段集合,再调用普通 API 即可。即使方法名称经过允许集合检查,直接 eval 整段输入仍允许任意 Ruby 表达式,完全绕开字段限制。

BasicObject 或私有方法也不能把 Ruby block 变成沙箱。代码仍可能通过常量或持有的引用获得更大能力。真正的不可信规则语言需要独立文法、有限求值器与资源约束,这属于选修主题,而不是给 eval 增加几个黑名单即可完成。

DSL 的完整性与错误归属

Rules.build {} 必须失败,因为没有配置阈值。阈值为九也必须失败,且错误来自与普通 API 相同的校验。完整 lab 对两项负例分别断言,并对合法的两种入口使用同一任务数组比较筛选结果。

构建器应返回完成配置的规则对象,而不是随意返回 block 的最后一个表达式。否则调用方在最后一行添加日志,构建结果可能变成 nil。显式返回对象让 DSL 的外部契约稳定。

若配置过程会产生外部副作用,构建失败不能自动撤销它们。一个纯内存规则构建器把风险范围控制在对象内,容易废弃失败实例;边配置边写文件或发请求,就需要额外的提交与补偿设计。Taskbook 的规则配置阶段因此只形成内存规则,真正处理任务在后续显式调用中进行。

调试 DSL 时,先检查普通 API 是否正确,再检查接收者与词法环境,最后检查构建完整性。若一开始就同时调试语法糖、业务条件和动态生成,错误来源很难分离。

场景 较小的实现
少量配置项 普通关键字参数或方法
需要分组但保留 self yield 配置对象
受信任内部 DSL instance_eval 与普通校验方法
显式输入的上下文执行 instance_exec
不可信配置 数据解析与有限规则

配置代码的拥有者决定信任边界

内部开发者在仓库里编写的 Ruby 配置,可以接受普通代码审查并与应用一起发布。用户上传的规则文本则来自另一个信任边界,不能因为文件后缀相同就使用同一种执行方式。DSL 的表面语法没有改变代码拥有的能力。

若配置只包含阈值、标签和状态,数据格式已经能够完整表达需求。解析后先检查字段集合,再检查类型与范围,最后调用普通 API。这个流程可以逐项给出错误,也能限制输入体积;执行任意 block 则很难只允许其中几个看起来像配置的方法。

测试 DSL 等价时应比较业务结果,而不只比较内部实例变量。两个规则对象保存的阈值相同,但其中一个错误地使用大于而非大于等于,内部状态检查仍会通过。因此实验输入同时包含低于、等于和高于阈值的三条任务,边界值不可省略。

构建期间的异常也应明确处理。创建新对象后执行配置,失败就让异常传播,并且不返回半成品;如果构建器把对象提前放入全局注册表,失败对象可能已经可见。注册动作应在完整验证后进行,避免让“构建失败”留下隐式状态。

DSL 方法名还可能与继承来的方法碰撞。一个裸调用究竟调用配置动作还是普通对象方法,要由方法查找决定。设计名称时应检查已有接口,不能指望语境自动区分。名称较少时,显式参数对象能够直接避免这类模糊性。

字符串求值实验只回答两种定义环境的差异,不证明 eval 对任何外部输入安全。测试能够在合成样本上运行,意味着语言机制可观察;权限、资源上限和恶意输入处理需要另一套验收。对当前任务筛选需求,有限数据配置已经足够,无需扩大执行能力。

实验与练习

运行 ruby examples/ruby/labs/17/run.rb。验收包括普通 API 与 DSL 等价、self 切换与闭包保留、显式参数、block 与字符串常量语境,以及非法和不完整配置。全部通过时输出 PASS lab 17。

练习一:实现 yield 对象的构建入口,保留原 block 的 self。用同一输入验证三种入口结果一致,并比较错误信息是否指向同一个校验方法。

练习二:为规则增加标签过滤,只接受一个 Symbol 或非空字符串。使用数据 Hash 构造规则,拒绝未知字段;证明整个外部输入路径不需要 eval,也能表达已有筛选需求。

参考资料

系列导航

导读 · 上一篇:16:动态方法、反射与钩子:元编程怎样保持可诊断 · 下一篇:18:异常、ensure 与 throw/catch · 完整源码包