日志模块混入之后为何没有执行

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

类中已经定义了 run,再 include 一个同名日志模块,调用仍然直接进入类的方法。改成 prepend 后,日志又会先执行。这一差异来自查找位置,不是 Ruby 猜测哪个方法更像装饰器。

本篇承接单例类与 super,在 Ruby 3.4 中比较组合、混入与词法扩展。独立实验位于 examples/ruby/labs/13/run.rb,用事件数组记录完整顺序。这里只构造本地日志标记,不把输出日志当成外部投递成功的证明。

1
2
3
4
5
6
7
8
module Logging
def run(events)
events << :before
result = super
events << :after
result
end
end

这个模块没有提供底层工作实现。它依赖查找链后面还有一个兼容的 run,并依赖后续返回值与异常语义符合预期。能成功 include 并不意味着这个协议已经满足。

include 插入复用实现

实验中的 Work#run 记录 :work 并返回 :result。Included < Work 混入日志模块而不自己定义 run,调用顺序为 before → work → after。

1
2
3
4
5
6
7
8
9
10
class Work
def run(events)
events << :work
:result
end
end

class Included < Work
include Logging
end

Included 自身没有命中方法,查找经过 Logging,再通过 super 到达 Work。如果在 Included 中新增一个 run 且不调用 super,日志模块就被遮住了。模块没有失效,只是该调用没有走到它。

因此 include 常用于为类补充协议实现,而不是保证拦截类里已有的同名方法。像 Enumerable 这样基于宿主的 each 提供一组方法的模块,接口依赖清楚;混入一个假定许多实例变量存在的模块,则会增加隐式耦合。

混入不会复制一份完全独立的组件实例。模块中的实例方法运行时,self 是宿主实例,实例变量也落在宿主上。两个模块共享同名实例变量可能互相干扰。独立资源或独立配置更适合由组件对象管理。

prepend 将实现放在类前面

1
2
3
4
5
6
7
8
class Prepended < Work
prepend Logging

def run(events)
events << :own
super
end
end

本例的祖先前缀为 [Logging, Prepended, Work],事件顺序为 [:before, :own, :work, :after]。日志模块先命中,super 进入宿主自己的方法,宿主再次 super 后到达基类。

这个结构适合围绕已有方法添加行为,但顺序本身成为接口的一部分。若加入缓存与鉴权两个模块,缓存位于鉴权前面可能跳过某些检查。交换模块顺序不仅影响日志排列,还可能改变语义,因此应以具体场景验证,不把“都调用 super”当作可任意组合的证明。

多个包装层还必须遵守返回值与参数契约。某一层忘记返回 super 的结果,最后一条日志语句可能成为业务返回值。某一层只转发位置参数,则关键字与 block 可能丢失。包装器测试至少应观察输出、调用次数、参数和异常传播。

Ruby 官方Module 文档提供祖先相关接口,但最终执行顺序由真实方法体决定。一个 ancestors 数组只能说明候选位置,不能替代事件实验。

组合使被包装对象显式出现

相同日志行为也可以通过普通对象完成:

1
2
3
4
5
6
7
8
9
10
11
12
class LoggedWorker
def initialize(target)
@target = target
end

def run(events)
events << :before
result = @target.run(events)
events << :after
result
end
end

此时日志对象与工作对象是两个实例。日志状态可以由包装器拥有,底层对象的类也不必改变。实验比较组合与 include 版本的事件和返回值,确认在限定契约内二者相同。

组合需要显式转发使用的接口。若底层对象方法很多,把所有未知调用都动态转发会再次引入隐藏依赖。只委托当前需要的少量方法,往往更容易说明:包装器支持的是这个接口,而不是底层对象全部能力的自动镜像。

选择组合还是混入,可以从状态所有权开始。共享宿主状态并扩充宿主协议时,模块自然;持有独立目标、需要多个可替换实例、包装顺序需要显式构造时,组合更清楚。这里不需要为两种方式建立统一抽象层,真实接口很小时保留直接实现即可。

refinement 的生效位置是词法作用域

重新打开核心类会影响进程里使用该类的代码。refinement 允许在明确的词法范围启用扩展,但它不会因为调用栈上某一层执行了 using,就自动修改所有后续方法的世界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module LabelRefinement
refine String do
def task_label
"task:#{self}"
end
end
end

def outside_refinement(value)
value.task_label
end

module LabelClient
using LabelRefinement

def self.label(value)
value.task_label
end

def self.outside(value)
outside_refinement(value)
end
end

LabelClient.label('a') 返回 task:a,而 LabelClient.outside('a') 抛出 NoMethodError。第二次调用进入 outside_refinement。该方法定义于未启用 refinement 的作用域,其 task_label 调用不会继承调用者的启用状态。

官方refinement 文档明确其词法规则。using 可以在顶层或类、模块定义中使用,不能在普通方法作用域中随意打开。启用之后的定义与文件边界都需要检查;重开类也不能假定上一次的启用状态自动恢复。

这个机制适合受控文件中有限的语义扩展。例如为格式化代码增加短方法,而不要求全进程的字符串都获得它。但如果接口将对象传到其他库再期待库里识别扩展,词法边界就可能让设计失败。显式的格式化函数或包装对象通常更容易跨库传递。

不把局部扩展当作沙箱

refinement 控制方法查找的可见范围,不限制代码读取文件、修改全局变量或执行其他 Ruby 操作的权限。它改善猴子补丁的作用范围,但不提供执行不可信脚本的安全隔离。

同样,prepend 到自己拥有的类与全局修改第三方核心类,影响范围不同。依赖外部库内部方法名进行包装时,升级可能改变调用路径,即使公开 API 没变,补丁也可能失效。优先使用库提供的扩展接口;没有接口时,把依赖点和版本约束写进测试。

日志包装失败时,要先确认方法是否被命中,再确认后续链是否存在,最后确认异常路径。直接加更多日志模块可能改变顺序,使原问题更难观察。最小实验只需要一个接收者、三个事件和一个返回值。

机制 本例可观察路径
include,类未重写 类 → 模块 → 父类
include,类直接返回 类自身结束调用
prepend 模块 → 类 → 父类
组合 包装器显式调用目标
refinement 定义处启用范围决定调用

包装接口要分别验证成功与失败

日志扩展最常见的测试只有正常顺序,但生产问题往往发生在异常路径。假设底层工作在写入 :work 后抛出错误,当前模块将留下 before 与 work,而不会留下 after。这符合“成功后记录”的含义。如果 after 表达资源清理,就必须使用 ensure 并补上失败实验。

缓存包装还有另一种路径:命中时不执行 super。此时后面的统计或鉴权包装是否运行,完全受顺序影响。所谓“透明包装”需要列明哪些结果保持一致,不能从模块名推导。返回值相同但调用次数不同,对具有副作用的处理器仍是重大差异。

组合版的优势之一是可把目标替换成受控测试对象,直接记录参数与调用次数。混入版也能通过小型宿主类完成同样验证。测试替身应保持真实接口的失败行为;一个永远返回成功的替身只能证明包装器走到了某条路径,不能证明错误传播正确。

模块依赖还应避免通过零散实例变量暗示。如果日志模块要求宿主提供 logger,最好把它写成明确的方法协议;如果它自行创建 logger,就承担该资源的生命周期。宿主恰好有同名变量并不是可靠契约,未来重构很容易把偶然配合打破。

refinement 也需要在真正的调用位置验证。把断言写在启用范围里只证明本地调用有效;若应用实际把值交给另一个文件的方法,必须通过那个方法测试。仅打印对象的方法列表或在控制台调用成功,不能替代跨文件的实际路径。

库维护者采用 refinement 时,应在文档中明确调用端需要启用,并避免让公开返回值依赖外部无法看到的扩展方法。如果每个调用者都必须理解复杂的启用位置才能使用一个对象,普通包装类型通常更适合作为公开 API。

模块重用也不能跳过加载边界。扩展代码尚未执行时,祖先链不会包含预期模块;同一模块被其他代码重新定义方法时,后续调用又可能改变。复现脚本应明确加载顺序并使用独立命名空间,避免控制台历史污染实验。

在不需要修改宿主类型关系时,先用普通包装对象完成日志任务,可以把目标与顺序直接留在构造代码中。只有确实需要把行为加入宿主协议时,再引入混入机制。这个选择能减少隐含查找,同时保留用模块共享行为的空间。

实验与练习

运行 ruby examples/ruby/labs/13/run.rb,应出现全部事件顺序断言及 PASS lab 13。refinement 外部调用失败是预期反例,脚本会确认异常类型。

练习一:再加一个记录 enter 与 leave 的模块,交换两个 prepend 的顺序。对每种顺序写完整事件断言,说明返回值与异常路径有没有变化。

练习二:将日志实现从 prepend 改成组合,保持成功输出与失败异常一致。给底层对象增加一个包装器未声明的方法,确认包装器没有无意中扩大公开接口。

参考资料

系列导航

导读 · 上一篇:12:单例类、方法查找与 super:调用命中了哪个方法 · 下一篇:14:鸭子类型与委托:不继承也能满足什么契约 · 完整源码包