动态接口需要同时回答调用与查询

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

代理对象能执行 open?,却声称自己不响应这个方法,依赖反射的调用者可能直接拒绝使用它。另一个代理把未知方法都返回 nil,拼写错误又会变成正常结果。动态接口除了生成行为,还需要维护可发现性与失败语义。

本篇承接方法查找与委托,在 Ruby 3.4 中比较普通方法、define_method 和 method_missing。完整实验位于 examples/ruby/labs/16/run.rb,动态版本与普通版本使用同一任务输入对照,不通过代码行数判断设计是否更好。

1
2
3
4
5
6
7
8
9
class StaticRules
def open?(task)
task.fetch(:status) == :open
end

def done?(task)
task.fetch(:status) == :done
end
end

只有两个状态时,这个实现已经清楚。元编程版用于解释机制;如果状态集合没有显著重复或动态来源,不需要为了“Ruby 风格”替换它。

define_method 从可调用体建立实例方法

1
2
3
4
5
6
7
class DynamicRules
[:open, :done].each do |status|
define_method("#{status}?") do |task|
task.fetch(:status) == status
end
end
end

循环中的 block 参数 status 被生成方法的闭包捕获,因此每个方法使用对应的状态。调用时的 self 则是收到方法调用的 DynamicRules 实例。局部环境与接收者分别提供不同的信息来源。

完整实验遍历 open? 与 done?,对普通和动态版本调用 public_send,断言结果相同。这样的对照避免只看到动态方法能运行,却没有确认它与原接口的所有状态一致。

捕获外部可变配置会使方法行为随配置变化。若生成过程本意是建立固定规则,应在生成前固定输入;若本意是动态读取当前配置,应通过显式对象依赖表达。闭包的便利不应该让配置生命周期变得不可见。

方法数量也影响诊断。生成两个名称与生成几万项一次性名称不同;后者的内存、加载时间和文档发现性需要另行测量。本篇没有做性能比较,因此不把 define_method 宣称为普通方法的性能替代。

owner 与 source_location 保留来源线索

1
2
3
4
5
rule = DynamicRules.new
method = rule.method(:open?)

raise unless method.owner == DynamicRules
p method.source_location

由 Ruby block 生成的方法仍可报告定义文件与行号。多个方法可能共享生成代码的位置,所以位置能指出生成器,却不一定直接告诉目标名称来自哪条配置。调试日志可以补充名称与生成输入,但不要把完整敏感配置写入日志。

owner 说明方法属于哪个类或模块,与当前接收者身份不同。source_location 可能在原生实现上为空。诊断逻辑应允许这个结果,不把 nil 当成反射失败,更不能因为没有 Ruby 文件位置就认定方法不存在。

动态定义还会改变同名方法的解析结果。若生成器覆盖了已有公开方法,代码可能仍能运行,却破坏原契约。有限名称集合应在生成前检查冲突;真正需要覆盖时,应把覆盖目的与顺序作为明确行为测试。

method_missing 处理确实不存在的调用

动态代理可以只接受两个允许的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class RuleProxy
def initialize(target)
@target = target
end

private

def method_missing(name, *args, **kwargs, &block)
if [:open?, :done?].include?(name)
return @target.public_send(name, *args, **kwargs, &block)
end
super
end

def respond_to_missing?(name, include_private = false)
[:open?, :done?].include?(name) || super
end
end

允许集合决定哪些名称属于这个接口。未知名称交给 super,保留祖先处理机会与默认的错误报告。若把所有名称都转给目标,对外接口就不再是两个规则方法,而会随目标类变化而扩大。

位置参数、关键字与 block 分别转发。这里只验证有限规则接口,但实现仍保留 Ruby 的不同参数通道,避免日后添加关键字时被当成普通 Hash。参数个数错误应由真实目标方法报告,代理不应悄悄丢弃多余参数。

public_send 保持目标公开方法边界。send 能触达非公开方法,不能因为写起来相近就替换。在用户输入决定方法名的场景,公开性仍不是足够的授权条件;允许名称集合必须先于调用建立。

respond_to_missing? 让反射与行为一致

对象响应动态方法时,respond_to_missing? 为 respond_to? 等反射提供补充信息。完整实验验证三项结果:proxy.respond_to?(:open?) 为真,直接调用成功,通过 proxy.method(:open?).call(task) 也获得相同结果。

未知 typo? 不应被声明为支持,实际调用应抛出带正确方法名的 NoMethodError。实验检查异常对象的 name,而不匹配完整错误字符串,因为错误展示可能包含对象地址与实现细节。

这个配合并不保证所有文档与静态工具都能枚举出动态方法。respond_to? 需要给定名称才能询问;任意模式生成的无限名称空间本身难以完整列举。因此当公开方法集合有限且固定时,直接定义方法常常比永久依赖缺失处理更容易发现。

代理也可能处于部分初始化状态,目标尚未建立。若此时日志或异常格式化触发动态方法,宽泛的缺失处理可能递归。把允许集合控制得很小,不在错误路径中依赖同一代理的复杂格式化,有助于保持诊断路径可靠。

不吞掉目标内部的失败

一个危险写法是在整个代理调用外层捕获 NoMethodError 或所有 StandardError,然后返回 nil。目标内部拼错方法名也会被吞掉,调用者无法区分“代理不支持方法”与“目标执行坏了”。

本例通过名称判断决定是否转发,转发后不截断目标异常。任务缺少 :status 时,fetch 抛出的 KeyError 直接传播,实验明确验证。接口支持这个方法,与这次输入能被成功处理,是两个不同结论。

错误分类也影响重试。未知方法是程序使用问题,缺少字段是输入问题,外部服务暂时失败可能是运行问题。把它们统一降为 false 或 nil,会让上层把系统故障解释成业务条件不满足。

对于任务规则,普通方法的 fetch 能直接暴露字段缺失。动态版必须保留这一契约,否则同名接口在不同实现之间无法替换。

钩子观察定义过程

method_added 在新增实例方法时得到方法名。实验先初始化类对象上的记录数组,再定义钩子,然后运行 define_method,断言记录为 [:open?, :done?]。

钩子在定义阶段执行,不是每次调用方法时执行。把调用统计写进 method_added 只能统计定义事件。若钩子内部再次定义方法,还可能重新触发钩子,形成递归;简单记录足够时,不要在观察钩子里加入自动改写。

类方法属于类对象的单例方法,相关事件有不同钩子。阅读框架代码时,应先确认被定义的究竟是实例方法还是单例方法,再定位对应回调。名称里带“类”并不意味着全部事件都走同一入口。

需求 优先方式
少量固定方法 普通 def
有限规则生成 define_method 与冲突检查
名称按协议动态决定 缺失处理与反射配合
定位实际实现 owner、source_location
观察方法定义 范围有限的钩子

动态生成需要限制名称空间

将配置字符串直接当成方法名,会使配置错误升级为接口冲突。例如生成名碰到已有的初始化、反射或格式化方法,后续错误可能出现在完全不同的调用位置。生成器应只接受业务允许的状态名称,再派生有限方法名,并在加载时暴露冲突。

如果名称集合固定,加载阶段定义一次通常比每次调用时再定义更容易诊断。后者会让实例第一次调用前后的方法表不同,测试顺序也可能影响反射结果。真正需要延迟生成时,应记录生成条件,并验证重复调用不会重复改变定义。

动态代理的能力声明应和转发判断共享同一语义。两个方法各维护一份不同规则,最容易出现能调用却查不到或声明支持却无法调用。本例集合很小,直接重复便于展示;真实接口扩大后可以提取一个有限判断方法,但不需要建立通用规则引擎。

错误消息应该保留失败点。捕获异常后重新抛出一个没有原始信息的普通错误,会丢掉目标方法名和调用栈。只在跨公开边界确实需要转换错误类型时做转换,并保留原因关系;内部代理通常可以原样传播。

元编程代码也应能被静态阅读。生成器的输入、生成的名称、参数和返回值需要在同一小段代码里可见。如果这些信息分散在多个钩子和模块回调里,添加一项业务规则会变成追踪加载顺序的问题。此时普通方法的重复往往比隐藏控制流更便宜。

本例没有使用字符串 eval,因此方法体直接由 Ruby block 形成。这样避免了拼接源码的引号与转义问题,但并不免除验证生成输入。代码生成方式较安全,和输入本身符合业务要求,是两项独立结论。

实验与练习

运行 ruby examples/ruby/labs/16/run.rb,应通过普通与动态对照、钩子记录、来源、反射一致性、未知调用及目标异常传播,末行输出 PASS lab 16。

练习一:新增 cancelled?,分别扩展普通实现、生成器和代理允许集合。故意遗漏其中一处,用契约测试定位不一致。

练习二:在目标方法内部制造一个 NoMethodError,确认代理保留真实异常。再写一个捕获所有错误返回 false 的错误版本,证明测试能够区分业务否定和实现失败。

参考资料

系列导航

导读 · 上一篇:15:Struct、Data 与模式匹配:数据如何被拆解 · 下一篇:17:DSL、instance_eval 与 class_eval:配置怎样获得执行上下文 · 完整源码包