Byteman 扩展
另一个值得注意的特性是,Byteman 规则的内置操作集并非固定不变。规则引擎通过将规则中使用到的内置操作映射到与之关联的帮助类的公共实例方法来实现这一功能。默认情况下,帮助类为 org.jboss.byteman.rule.helper.Helper
,它提供了一系列标准的内置操作,旨在简化多线程应用程序中的线程管理。例如,在前文提到的示例规则中,内置操作 createCountDown()
和 countDown()
实际上就是 Helper
类的公共方法。通过为规则指定一个替代的帮助类,可以灵活地扩展或修改规则中可用的内置操作集。
任何非抽象且非最终的类都可以被指定为帮助类。该类的公共实例方法将自动成为规则中事件、条件和动作部分的内置操作。例如,通过指定一个扩展了默认 Helper
类的自定义帮助类,规则既可以继续使用现有的内置操作,也可以引入特定于规则或应用程序的自定义操作。因此,尽管 Byteman 的默认规则语言主要面向多线程测试中独立线程的行为编排,但其灵活的架构使其能够轻松适应更广泛的应用程序需求。
代理转换
Byteman 的字节码修改功能是通过 Java 代理程序 实现的。JVM 的类加载机制为代理提供了在字节码编译之前修改加载的类的能力(有关 Java 代理的工作原理,可参考 java.lang.Instrumentation
包)。Byteman 代理在 JVM 启动时读取规则脚本,随后监控方法代码的加载过程,寻找与规则事件中指定的位置相匹配的 触发点。
代理会在与规则事件匹配的代码点插入 触发调用。触发调用是对规则执行引擎的调用,它会识别以下内容:
- 触发方法:即包含触发点的方法。
- 匹配的规则:与触发点匹配的规则。
- 触发方法的参数:传递给触发方法的参数。
如果多个规则匹配同一个触发点,则会生成一系列触发调用,每个匹配的规则对应一个触发调用。通常情况下,规则会按照它们在脚本中出现的顺序依次触发。唯一的例外是那些指定了 AFTER
位置的规则(例如 AFTER READ myField
或 AFTER INVOKE someMethod
),它们会按照相反的顺序执行。
当触发调用发生时,规则执行引擎会找到相关规则并执行它。引擎会为规则事件中提到的变量建立绑定,然后评估规则条件。如果条件评估为 true
,则会触发规则,并按顺序执行每个规则动作。
触发调用会将方法的接收者(this
)和参数传递给规则引擎。这些值可以在条件和动作中通过标准命名约定(如 $0
、$1
等)引用。事件规范还可以为额外的变量引入绑定。这些变量的绑定可以通过字面数据、调用方法或操作参数和/或静态数据来初始化。在事件中绑定的变量可以通过名称直接在条件或动作中引用。绑定机制允许在触发上下文中使用任意数据进行条件测试,以决定是否触发规则,并作为规则动作的目标或参数。需要注意的是,当触发代码使用相关调试选项编译时,代理能够将触发点范围内的局部变量作为参数传递给触发调用,使它们作为默认绑定可用。规则可以通过在变量名前加上 $
字符来引用这些局部变量(例如 $this
、$arg1
、$i
等)。
代理还会在触发调用周围编译异常处理代码,以处理规则执行过程中可能抛出的异常。这里的异常处理并不是为了捕获规则执行引擎内部的错误(这些错误应被引擎内部捕获并处理),而是为了改变触发方法的控制流。通常情况下,触发线程在触发调用返回后会继续执行原始方法代码。然而,规则可以使用 return
或 throw
等内置动作来指定从触发方法中提前返回或抛出异常。规则语言通过在触发调用下方抛出其私有的内部异常来实现这一点。编译到触发方法中的异常处理代码会捕获这些内部异常,然后返回给调用者或递归抛出运行时异常或应用程序特定的异常。这样可以避免触发方法主体中剩余代码的正常执行。如果触发点还有其他待处理的触发调用,这些调用也会被跳过。
如果当前加载的类与上传的规则匹配,代理会重新转换这些类,修改相关目标方法以包含必要的触发调用。如果上传的规则替换了现有规则,则在删除旧规则时,与之相关的所有触发调用也会从受影响的目标方法中移除。需要注意的是,重新转换类并不会将新的类对象与现有类的实例关联起来,它只是为这些类的方法安装了不同的实现。
在代理引导期间,重新转换可能会自动发生,而无需显式上传规则。这一点非常重要,因为 JVM 需要先加载其自身的引导类,然后才能启动代理并允许其注册转换器。一旦代理处理了初始规则集并注册了转换器,它会扫描所有当前加载的类,并识别那些与规则集中的规则匹配的类。代理会自动重新转换这些类,从而使得后续对引导代码的调用能够触发规则处理。
Agent Retransformation
Byteman 代理还允许在应用程序运行时动态上传规则。这一功能可用于重新定义已加载的规则或动态引入新规则。如果当前加载的类与上传的规则不匹配,代理仅会将新规则添加到当前规则集中。如果新规则与现有规则同名,则会替换旧规则。当后续加载与规则匹配的类时,代理会使用最新版本的规则对其进行转换。
如果已加载的类与上传的规则匹配,代理会重新转换这些类,修改相关目标方法以包含必要的触发调用。如果上传的规则替换了现有规则,则在删除旧规则时,与之关联的所有触发调用也会从受影响的目标方法中移除。需要注意的是,重新转换类并不会将新的类对象与现有实例关联,它只是为这些类的方法安装了不同的实现。
在代理引导期间,重新转换可能会自动发生,而无需显式上传规则。这一点非常重要,因为 JVM 需要先加载其自身的引导类,然后才能启动代理并允许其注册转换器。一旦代理处理了初始规则集并注册了转换器,它会扫描所有当前加载的类,并识别那些与规则集中的规则匹配的类。代理会自动重新转换这些类,从而使得后续对引导代码的调用能够触发规则处理。
ECA 规则引擎
Byteman 规则执行引擎由规则解析器、类型检查器和解释器/编译器组成。在代理引导期间,解析器会被调用,以提供足够的信息供代理识别潜在的触发点。
规则的类型检查和编译不会在触发注入时立即进行,而是延迟到它们引用的类和方法字节码被加载时才会执行。类型检查需要识别触发类的属性,有时还需要通过反射识别相关类的信息。为了确保在类型检查器访问这些类之前,触发类及其所有依赖类已被加载,规则会在首次触发时进行类型检查和编译。这种延迟处理机制还避免了检查和编译那些实际未被调用的规则所带来的额外开销。
如果类型检查或编译操作失败,规则引擎会打印错误信息并禁用相关触发调用的执行。需要注意的是,在事件规范不明确的情况下,规则可能对某些触发点成功通过类型检查,但对其他触发点则无法通过。只有在类型检查失败时,规则执行才会被禁用。
解释/编译执行
在基本操作模式下,触发调用通过解释规则解析树来执行规则。此外,规则还可以将其绑定、条件和动作翻译成字节码,然后由 JIT 编译器执行。尽管默认行为是使用解释器,但可以通过在代理安装时设置系统属性来更改此默认值。
无论选择哪种模式,规则的执行都由 Byteman 代理在运行时生成的辅助类(称为 帮助适配器)完成。这个类是与规则关联的帮助类的子类(这也是为什么用户定义的帮助类不能是 final 的原因)。它继承自帮助类,以便能够执行帮助类定义的内置操作。使用子类的目的是为了添加规则系统所需的额外功能,其中最显著的是 execute0
方法,该方法在触发点被调用,以及一个局部绑定字段,用于存储将方法参数和事件变量映射到其绑定值的哈希表。
当规则被触发时,规则引擎会创建规则的帮助适配器类的实例,为触发调用提供上下文(这也是为什么用户定义的帮助类不能是 abstract 的原因)。引擎使用 Byteman 代理生成的 setter 方法初始化规则和绑定字段,然后调用适配器实例的 execute
方法。由于每个规则触发都由其自己的适配器实例处理,这确保了来自不同线程的相同规则的并发触发不会相互干扰(同时也确保递归触发的相同规则保留它们自己的上下文)。
在解释模式下,execute0
方法会定位触发的规则,并从规则中获取事件、条件和动作的解析树。它递归遍历这三个组件的解析树,评估每个表达式。绑定在规则执行期间被查找或分配,当它们在规则事件、条件或动作中被引用时。当 execute
方法遇到对内置操作的调用时,它会使用反射调用其帮助超类的继承方法来执行该操作。
当启用规则编译时,Byteman 代理会生成一个包含从规则事件、条件和动作派生的内联字节码的 execute
方法。这段代码直接编码了规则中定义的所有操作和方法调用。它以与解释代码相同的方式访问绑定和执行内置操作,只不过对内置操作的调用被编译为直接方法调用,而不是依赖于反射调用。
FunTester 原创精华