Byte Buddy - Java 虚拟机的运行时代码生成

Byte Buddy - Java 虚拟机的运行时代码生成 --- Byte Buddy - runtime code generation for the Java virtual machine

为什么要生成运行时代码?

Java 语言具有比较严格的类型系统。 Java 要求所有变量和对象都属于特定类型,任何分配不兼容类型的尝试总是会导致错误。
这些错误通常是由 Java 编译器发出的,或者至少是由 Java 运行时在非法转换类型时发出的。这种严格的类型通常是可取的,例如在编写业务应用程序时。
业务领域通常可以用这样一种显式的方式来描述,其中任何领域项都代表其自己的类型。通过这种方式,我们可以使用 Java 构建非常可读且健壮的应用程序,其中的错误可以在接近其源头的地方被捕获。
其中,Java 的类型系统是 Java 在企业编程中流行的原因。

然而,通过强制执行其严格的类型系统,Java 施加了限制,限制了该语言在其他领域的范围。
例如,当编写供其他 Java 应用程序使用的通用库时,我们通常无法引用用户应用程序中定义的任何类型,因为在编译库时我们不知道这些类型。
为了调用方法或访问用户未知代码的字段,Java 类库附带了反射 API。使用反射 API,我们能够内省未知类型并调用方法或访问字段。
不幸的是,使用反射 API 有两个显着的缺点:

  • 使用反射 API 比硬编码方法调用慢:首先,需要执行相当昂贵的方法查找来获取描述特定方法的对象。
    当调用一个方法时,这需要 JVM 运行本机代码,与直接调用相比,这需要较长的运行时间。然而,现代 JVM 知道一个称为 “膨胀” 的概念,其中基于 JNI 的方法调用被生成的字节代码所取代,该字节代码被注入到动态创建的类中。
    (甚至 JVM 本身也使用代码生成!)毕竟,Java 的膨胀系统仍然存在生成非常通用的代码的缺点,例如仅适用于盒装原始类型,因此性能缺点尚未完全解决。
  • 反射 API 破坏了类型安全:尽管 JVM 能够通过反射调用代码,但反射 API 本身并不是类型安全的。
    在编写库时,只要我们不需要向库的用户公开反射 API,这就不是问题。毕竟,我们在编译期间不知道用户代码,也无法根据其类型验证我们的库代码。
    然而,有时,需要向用户公开反射 API,例如让库为我们调用我们自己的方法之一。
    这就是使用反射 API 出现问题的地方,因为 Java 编译器将拥有验证程序类型安全性的所有信息。
    例如,当实现方法级安全性的库时,该库的用户希望该库仅在强制执行安全约束后才调用方法。
    为此,库需要在用户移交该方法所需的参数后反射性地调用该方法。
    但是,这样做后,不再进行编译时类型检查这些方法参数是否与方法的反射调用匹配。方法调用仍然有效,但检查会延迟到运行时。
    这样做,我们就失去了 Java 编程语言的一项重要功能。

这就是运行时代码生成可以帮助我们的地方。它允许我们模拟一些通常只有在使用动态语言编程时才能访问的功能,而无需放弃 Java 的静态类型检查。
这样,我们就可以两全其美,并进一步提高运行时性能。为了更好地理解这个问题,让我们看一下实现上述方法级安全库的示例。

编写安全库

业务应用程序可能会变得很大,有时很难概览应用程序中的调用堆栈。当我们的应用程序中有只应在特定条件下调用的关键方法时,这可能会成为问题。
想象一个业务应用程序实现了重置功能,允许从应用程序的数据库中删除所有内容。

  1. class Service {
  2. void deleteEverything() {
  3. // delete everything ...
  4. }
  5. }

当然,此类重置只能由管理员执行,而不能由我们应用程序的普通用户执行。通过分析我们的源代码,我们当然可以确保这种情况永远不会发生。
然而,我们可以预期我们的应用程序在未来会增长和改变。因此,我们希望实现一个更严格的安全模型,其中方法调用通过对应用程序当前用户的显式检查来保护。
我们通常会使用安全框架来确保该方法不会被除管理员之外的任何人调用。

为此,假设我们使用带有公共 API 的安全框架,如下所示:

  1. @Retention(RetentionPolicy.RUNTIME)
  2. @interface Secured {
  3. String user();
  4. }
  5.  
  6. class UserHolder {
  7. static String user;
  8. }
  9.  
  10. interface Framework {
  11. <T> T secure(Class<T> type);
  12. }

在此框架中,应使用 Secured 注释来标记只能由给定用户访问的方法。 UserHolder 用于全局定义当前登录应用程序的用户。 Framework 接口允许通过调用给定类型的默认构造函数来创建安全实例。当然,这个框架过于简单,但原则上这就是安全框架(例如流行的 Spring Security)的工作原理。该安全框架的一个特点是我们保留用户的类型。根据框架接口的约定,我们承诺用户返回其接收到的任何类型 T 的实例。因此,用户能够与自己的类型进行交互,就好像安全框架不存在一样。在测试环境中,用户甚至可以创建其类型的不安全实例并使用这些实例而不是安全实例。
您会同意这真的很方便!众所周知,此类框架与 POJO(普通的旧 Java 对象)进行交互,该术语是为了描述不会将自己的类型强加给用户的非侵入式框架而创造的。

现在想象一下,我们知道传递给 Framework 的类型只能是 T = Service 并且 deleteEverything 方法用 @Secured("ADMIN") 注释。这样,我们可以通过简单地子类化该特定类型来轻松实现该特定类型的安全版本:

  1. class SecuredService extends Service {
  2. @Override
  3. void deleteEverything() {
  4. if(UserHolder.user.equals("ADMIN")) {
  5. super.deleteEverything();
  6. } else {
  7. throw new IllegalStateException("Not authorized");
  8. }
  9. }
  10. }

通过这个附加类,我们可以按如下方式实现框架:

  1. class HardcodedFrameworkImpl implements Framework {
  2. @Override
  3. public <T> T secure(Class<T> type) {
  4. if(type == Service.class) {
  5. return (T) new SecuredService();
  6. } else {
  7. throw new IllegalArgumentException("Unknown: " + type);
  8. }
  9. }
  10. }

当然,这个实现并没有多大用处。通过 secure 方法的签名,我们建议该方法可以为任何类型提供安全性,但实际上,一旦遇到除已知 Service 之外的其他内容,我们就会抛出异常。此外,这还要求我们的安全库在编译库时了解这个特定的 Service 类型。显然,这不是实现框架的可行方案。那么我们该如何解决这个问题呢?
好吧,由于这是关于代码生成库的教程,您应该已经猜到了答案:当 Service 类首次被我们的安全框架调用时,我们会按需创建一个子类,并在运行时创建一个子类。 secure 方法。通过代码生成,我们可以采用任何给定类型,在运行时对其进行子类化并覆盖我们想要保护的方法。在我们的例子中,我们重写所有用 @Secured 注释的方法,并从注释的 user 属性中读取所需的用户。许多流行的 Java 框架都是使用类似的方法实现的。

 一般信息

在我们了解有关代码生成和 Byte Buddy 的所有知识之前,请注意您应该谨慎使用代码生成。 Java 类型对于 JVM 来说相当特殊,通常不会被垃圾回收。
因此,您永远不应该过度使用代码生成,而只能在唯一的出路时使用生成的代码来解决问题。但是,如果您需要像前面的示例一样增强未知类型,那么代码生成很可能是您唯一的选择。
安全、事务管理、对象关系映射或模拟框架是代码生成库的典型用户。

当然,Byte Buddy 并不是第一个在 JVM 上生成代码的库。然而,我们相信 Byte Buddy 知道一些其他框架无法应用的技巧。
Byte Buddy 的总体目标是通过关注其领域特定语言和注释的使用来以声明方式工作。据我们所知,没有其他 JVM 代码生成库能够以这种方式工作。
尽管如此,您可能想看看其他一些代码生成框架,以找出最适合您的框架。其中,以下库在 Java 领域很流行:

  Java 代理
Java 类库附带了一个代理工具包,允许创建实现给定接口集的类。这个内置的代理供应商很方便,但也非常有限。
例如,上述安全框架不能以这种方式实现,因为我们想要扩展类而不是接口。
  程序库
代码生成库是在 Java 早期实现的,不幸的是它没有跟上 Java 平台的发展。尽管如此,cglib 仍然是一个相当强大的库,但它的积极发展变得相当模糊。
出于这个原因,它的许多用户放弃了 cglib。
  Java 助手
该库附带一个编译器,它接受包含 Java 源代码的字符串,这些字符串在应用程序运行时被转换为 Java 字节代码。
这是一个非常雄心勃勃的想法,原则上也是一个好主意,因为 Java 源代码显然是描述 Java 类的好方法。
然而,Javassist 编译器在功能上无法与 javac 编译器相比,并且在动态组合字符串以实现更复杂的逻辑时很容易出错。
此外,Javassist 还附带一个代理库,该库与 JCL 的代理实用程序类似,但允许扩展类且不限于接口。然而,Javassist 代理工具的范围在其 API 和功能上仍然受到同样的限制。

请您自行评估框架,但我们相信 Byte Buddy 提供的功能和便利性是您搜索不到的。
Byte Buddy 附带了一种富有表现力的领域特定语言,允许通过编写简单的 Java 代码并为您自己的代码使用强类型来创建非常自定义的运行时类。
同时,Byte Buddy 对定制非常开放,不会限制您使用开箱即用的功能。如果需要,您甚至可以为任何实现的方法定义自定义字节代码。
但即使不知道字节码是什么或者它是如何工作的,您也可以在不深入研究框架的情况下做很多事情。例如,您是否看过 Hello World! 示例?使用 Byte Buddy 就是这么简单。

当然,令人愉悦的 API 并不是选择代码生成库时要考虑的唯一功能。对于许多应用程序来说,生成代码的运行时特征更有可能决定最佳选择。
除了生成代码本身的运行时之外,创建动态类的运行时也可能是一个问题。声称我们是最快的!为库的速度提供有效的度量标准既简单又困难。尽管如此,我们还是想提供这样一个指标作为基本方向。
但是,请记住,这些结果不一定会转化为您更具体的用例,您应该在其中执行单独的指标。

在讨论我们的指标之前,让我们先看一下原始数据。下表显示了操作的平均运行时间(以纳秒为单位),其中标准差附在大括号中:

   基线  字节好友  程序库  Java 助手  Java 代理
 琐碎的类创建 0.003 (0.001) 142.772 (1.390) 515.174 (26.753) 193.733 (4.430) 70.712 (0.645)
接口实现 0.004 (0.001) 1'126.364 (10.328) 960.527 (11.788) 1'070.766 (59.865) 1'060.766 (12.231)
 存根方法调用 0.002 (0.001) 0.002 (0.001) 0.003 (0.001) 0.011 (0.001) 0.008 (0.001)
 类扩展 0.004 (0.001) 885.983
5'408.329
(7.901)
(52.437)
1'632.730 (52.737) 683.478 (6.735)
 超级方法调用 0.004 (0.001) 0.004
0.004
(0.001)
(0.001)
0.021 (0.001) 0.025 (0.001)

与静态编译器类似,代码生成库面临着生成快速代码和快速生成代码之间的权衡。在这些相互冲突的目标之间进行选择时,Byte Buddy 的主要关注点在于生成运行时间最少的代码。
通常,类型创建或操作不是任何程序中的常见步骤,并且不会对任何长时间运行的应用程序产生重大影响;特别是因为类加载或类检测是运行此类代码时最耗时且不可避免的步骤。

上表中的第一个基准测试在不实现或重写任何方法的情况下测量子类 Object 的库的运行时间。这让我们对库在代码生成方面的一般开销有了一个印象。
在此基准测试中,Java 代理的性能优于其他库,因为只有在假设始终扩展接口时才可能进行优化。 Byte Buddy 还检查类的泛型类型和注释,了解导致额外运行时间的原因。
这种性能开销在创建类的其他基准测试中也很明显。
基准 (2a) 显示了创建(和加载)一个实现具有 18 个方法的单个接口的类的测量运行时间,(2b) 显示了为此类生成的方法的执行时间。
类似地,(3a) 显示了使用已实现的 18 个相同方法扩展类的基准。由于始终执行 super 方法的拦截器可能进行优化,Byte Buddy 提供了两个基准。
在类创建过程中牺牲一些时间,Byte Buddy 创建的类的执行时间通常会达到基线,这意味着检测根本不会产生任何开销。
应该注意的是,如果元数据处理被禁用,Byte Buddy 在类创建期间也优于任何其他代码生成库。
然而,由于与程序的总运行时间相比,代码生成的运行时间非常短,因此这种选择退出不可用,因为它会以牺牲库代码复杂化为代价获得很少的性能。

最后,请注意,我们的指标衡量的是之前由 JVM 的即时编译器优化的 Java 代码的性能。如果您的代码只是偶尔执行,则性能将比上述指标建议的性能更差。然而,在这种情况下,您的代码一开始就不是性能关键型的。
该指标的代码与 Byte Buddy 一起分发,您可以在自己的计算机上运行这些指标,其中上述数字可能会根据您的计算机的处理能力进行缩放。
因此,不要绝对地解释上述数字,而是将它们视为比较不同库的相对度量。在进一步开发 Byte Buddy 时,我们希望监控这些指标,以避免添加新功能时的性能损失。

在接下来的教程中我们将逐步讲解 Byte Buddy 的功能。我们将从大多数用户最有可能使用的更通用的功能开始。
然后,我们将考虑越来越高级的主题,并对 Java 字节码和类文件格式进行简短介绍。如果您快进到后面的材料,请不要灰心!
通过使用 Byte Buddy 的标准 API,您几乎可以做任何事情,而无需了解任何 JVM 细节。要了解标准 API,请继续阅读。

 创建一个类

Byte Buddy 创建的任何类型均由 ByteBuddy 类的实例发出。只需通过调用 new ByteBuddy() 创建一个新实例即可。希望您使用的开发环境可以在其中获得有关可以在给定对象上调用的方法的建议。
这样,您就可以避免在 Byte Buddy 的 javadoc 中手动查找类的 API,而是让您的 IDE 引导您完成整个过程。如前所述,Byte Buddy 提供了一种领域特定语言,旨在尽可能地易于人类阅读。
因此,大多数时候 IDE 的提示都会为您指明正确的方向。说得够多了,让我们在 Java 程序的运行时创建一个第一个类:

  1. DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
  2. .subclass(Object.class)
  3. .make();

显而易见,上面的代码示例创建了一个扩展 Object 类型的新类。这种动态创建的类型相当于仅扩展 Object 而不显式实现任何方法、字段或构造函数的 Java 类。您可能已经注意到,我们甚至没有命名动态生成的类型,这在定义 Java 类时通常是必需的。
当然,您可以轻松地显式命名您的类型:

  1. DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
  2. .subclass(Object.class)
  3. .name("example.Type")
  4. .make();

但是如果没有明确的命名会发生什么呢? Byte Buddy 遵循配置惯例,并为您提供我们认为方便的默认设置。至于类型的名称,默认的 Byte Buddy 配置提供了 NamingStrategy ,它根据动态类型的超类名称随机创建一个类名称。此外,该名称被定义为与超类位于同一包中,以便直接超类的包私有方法始终对动态类型可见。
例如,如果您对名为 example.Foo 的类型进行子类化,则生成的名称将类似于 example.Foo$$ByteBuddy$$1376491271 ,其中数字序列是随机的。当从 java.lang 包(其中存在诸如 Object 的类型)中子类化类型时,会出现此规则的例外情况。 Java 的安全模型不允许自定义类型存在于该名称空间中。因此,这种类型名称按照默认命名策略以 net.bytebuddy.renamed 为前缀。

这种默认行为可能对您来说不方便。并且由于约定优于配置的原则,您始终可以根据需要更改默认行为。这就是 ByteBuddy 类发挥作用的地方。通过创建 new ByteBuddy() 实例,您可以创建默认配置。通过调用此配置上的方法,您可以根据您的个人需求对其进行自定义。让我们试试这个:

  1. DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
  2. .with(new NamingStrategy.AbstractBase() {
  3. @Override
  4. protected String name(TypeDescription superClass) {
  5. return "i.love.ByteBuddy." + superClass.getSimpleName();
  6. }
  7. })
  8. .subclass(Object.class)
  9. .make();

在上面的代码示例中,我们创建了一个新配置,其类型命名策略与默认配置不同。匿名类的实现是为了简单地连接字符串 i.love.ByteBuddy 和基类的简单名称。当子类化 Object 类型时,动态类型因此被命名为 i.love.ByteBuddy.Object 。但在创建自己的命名策略时要小心! Java 虚拟机使用名称来区分类型,这就是您要避免命名冲突的原因。如果您需要自定义命名行为,请考虑使用 Byte Buddy 的内置 NamingStrategy.SuffixingRandom ,您可以自定义它以包含比我们的默认值对您的应用程序更有意义的前缀。

领域特定语言和不变性

在看到 Byte Buddy 的领域特定语言的实际应用之后,我们需要简要了解一下该语言的实现方式。关于实现,您需要了解的一个细节是该语言是围绕不可变对象构建的。事实上,几乎所有存在于 Byte Buddy 命名空间中的类都是不可变的,并且在少数情况下我们无法使类型不可变,我们在此类的 javadoc 中明确提到了这一点。
如果您为 Byte Buddy 实现自定义功能,我们建议您坚持这一原则。

作为上述不变性的暗示,您在配置 ByteBuddy 实例时必须小心。例如,您可能会犯以下错误:

  1. ByteBuddy byteBuddy = new ByteBuddy();
  2. byteBuddy.withNamingStrategy(new NamingStrategy.SuffixingRandom("suffix"));
  3. DynamicType.Unloaded<?> dynamicType = byteBuddy.subclass(Object.class).make();

您可能期望使用(据称)定义的自定义命名策略 new NamingStrategy.SuffixingRandom("suffix") 生成动态类型。调用 withNamingStrategy 方法不会改变存储在 byteBuddy 变量中的实例,而是返回一个自定义的 ByteBuddy 实例,但该实例会丢失。因此,动态类型是使用最初创建的默认配置创建的。

重新定义现有类并重新建立基础

到目前为止,我们仅演示了如何使用 Byte Buddy 创建现有类的子类。然而,相同的 API 可以用于增强现有的类。这种增强有两种不同的形式:

 类型重定义
重新定义类时,Byte Buddy 允许通过添加字段和方法或替换现有方法实现来更改现有类。
然而,如果先前存在的方法实现被另一个实现替换,它们就会丢失。例如,当重新定义以下类型时

  1. class Foo {
  2. String bar() { return "bar"; }
  3. }
从 bar 方法返回 "qux" ,该方法最初返回 "bar" 的信息将完全丢失。
 类型变基
当对一个类进行变基时,Byte Buddy 会保留变基类的所有方法实现。 Byte Buddy 不会像执行类型重新定义时那样丢弃重写的方法,而是将所有此类方法实现复制到具有兼容签名的重命名的私有方法中。这样,就不会丢失任何实现,并且重新设置基数的方法可以通过调用这些重命名的方法来继续调用原始代码。
这样,上面的类 Foo 可以被重新设置为类似的东西

  1. class Foo {
  2. String bar() { return "foo" + bar$original(); }
  3. private String bar$original() { return "bar"; }
  4. }
其中 bar 方法最初返回 "bar" 的信息保留在另一个方法中,因此仍然可以访问。
当对类进行变基时,Byte Buddy 会像定义子类一样对待所有方法定义,即,如果您尝试调用变基方法的超级方法实现,它将调用变基方法。
但相反,它最终将这个假设的超类扁平化为上面显示的重新基化类型。

任何变基、重新定义或子类化都是使用 DynamicType.Builder 接口定义的相同 API 执行的。这样,就可以将一个类定义为子类,然后更改定义以表示重新基类。这只需更改 Byte Buddy 领域特定语言的单个单词即可实现。
这样,应用任一可能的方法

  1. new ByteBuddy().subclass(Foo.class)
  2. new ByteBuddy().redefine(Foo.class)
  3. new ByteBuddy().rebase(Foo.class)

在定义过程的后续阶段进行透明处理,本教程的其余部分将对此进行解释。
由于子类定义对于 Java 开发人员来说是一个熟悉的概念,因此以下所有 Byte Buddy 领域特定语言的解释和示例都是通过创建子类来演示的。
但是,请记住,所有类都可以通过重新定义或变基来类似地定义。

 加载一个类

到目前为止,我们只定义并创建了一个动态类型,但没有使用它。 Byte Buddy 创建的类型由 DynamicType.Unloaded 的实例表示。顾名思义,这些类型不会加载到 Java 虚拟机中。相反,Byte Buddy 创建的类以二进制形式和 Java 类文件格式表示。这样,您就可以决定如何处理生成的类型。例如,您可能希望从构建脚本运行 Byte Buddy,该脚本仅在部署之前生成用于增强 Java 应用程序的类。为此, DynamicType.Unloaded 类允许提取表示动态类型的字节数组。为了方便起见,该类型还提供了一个 saveIn(File) 方法,允许您将类存储在给定文件夹中。此外,它允许您将 inject(File) 类添加到现有的 jar 文件中。

虽然直接访问类的二进制形式很简单,但不幸的是加载类型更为复杂。在 Java 中,所有类都使用 ClassLoader 加载。这种类加载器的一个示例是引导类加载器,它负责加载 Java 类库中提供的类。
另一方面,系统类加载器负责加载 Java 应用程序的类路径上的类。显然,这些预先存在的类加载器都不知道我们创建的任何动态类。
为了克服这个问题,我们必须找到加载运行时生成的类的其他可能性。 Byte Buddy 通过不同的方法提供开箱即用的解决方案:

  • 我们简单地创建一个新的 ClassLoader ,它被明确告知特定的动态创建的类的存在。因为 Java 类加载器是按层次结构组织的,所以我们将此类加载器定义为已存在于正在运行的 Java 应用程序中的给定类加载器的子类加载器。
    这样,正在运行的 Java 程序的所有类型对于使用 new ClassLoader 加载的动态类型都是可见的。
  • 通常,Java 类加载器在尝试直接加载给定名称的类型之前会查询其父级 ClassLoader 。这意味着类加载器通常不会加载类型,以防其父类加载器知道具有相同名称的类型。
    为此,Byte Buddy 提供了一个子优先类加载器的创建,它尝试在查询其父级之前自行加载类型。除此之外,该方法与上面提到的方法类似。
    请注意,此方法不会覆盖父类加载器的类型,而是隐藏其他类型。
  • 最后,我们可以使用反射将类型注入到现有的 ClassLoader 中。通常,类加载器被要求按名称提供给定类型。
    使用反射,我们可以扭转这个原则,调用受保护的方法将新类注入到类加载器中,而类加载器实际上不知道如何找到这个动态类。

不幸的是,上述方法都有其缺点:

  • 如果我们创建一个新的 ClassLoader ,这个类加载器定义一个新的命名空间。这意味着,只要两个类是由两个不同的类加载器加载的,就可以加载两个具有相同名称的类。
    Java 虚拟机永远不会认为这两个类是相等的,即使这两个类代表相同的类实现。然而,这条平等规则也适用于 Java 包。这意味着如果两个类不是使用同一个类加载器加载的,则类 example.Foo 无法访问另一个类 example.Bar 的包私有方法。此外,如果 example.Bar 扩展 example.Foo ,任何重写的包私有方法都将变得无效,但会委托给原始实现。
  • 每当加载一个类时,一旦解析了引用另一个类型的代码段,它的类加载器就会查找该类中引用的任何类型。该查找委托给同一个类加载器。
    想象一下我们动态创建两个类 example.Foo 和 example.Bar 的场景。如果我们将 example.Foo 注入到现有的类加载器中,则该类加载器可能会尝试定位 example.Bar 。然而,此查找会失败,因为后一个类是动态创建的,并且对于我们刚刚注入 example.Foo 类的类加载器来说是无法访问的。因此,反射方法不能用于具有循环依赖关系且在类加载期间生效的类。
    幸运的是,大多数 JVM 实现在第一次主动使用时都会延迟解析引用的类,这就是类注入通常在没有这些限制的情况下工作的原因。
    而且,在实践中,由 Byte Buddy 创建的类通常不会受到这种循环的影响。

您可能会认为遇到循环依赖关系的可能性并不重要,因为您一次创建一种动态类型。但是,类型的动态创建可能会触发所谓的辅助类型的创建。
这些类型由 Byte Buddy 自动创建,以提供对您正在创建的动态类型的访问。我们将在下一节中了解有关辅助类型的更多信息,现在不用担心它们。
但是,正因为如此,我们建议您尽可能通过创建特定的 ClassLoader 来加载动态创建的类,而不是将它们注入到现有的类中。

创建 DynamicType.Unloaded 后,可以使用 ClassLoadingStrategy 加载此类型。如果没有提供这样的策略,Byte Buddy 会根据提供的类加载器推断出这样的策略,并仅为引导类加载器创建一个新的类加载器,其中不能使用反射注入任何类型,否则是默认的。
Byte Buddy 提供了几种开箱即用的类加载策略,其中每种策略都遵循上述概念之一。这些策略在 ClassLoadingStrategy.Default 中定义,其中 WRAPPER 策略创建一个新的包装 ClassLoader ,其中 CHILD_FIRST 策略创建一个类似的类加载器具有子优先语义,并且 INJECTION 策略使用反射注入动态类型。 WRAPPER 和 CHILD_FIRST 策略也可以在所谓的清单版本中使用,其中即使在加载类之后也会保留类型的二进制格式。这些替代版本使类加载器的类的二进制表示可以通过 ClassLoader::getResourceAsStream 方法访问。但是,请注意,这要求这些类加载器维护对消耗 JVM 堆空间的类的完整二进制表示形式的引用。因此,如果您计划实际访问二进制格式,则应该仅使用清单版本。
由于 INJECTION 策略通过反射工作,并且无法更改 ClassLoader::getResourceAsStream 方法的语义,因此它自然在清单版本中不可用。

让我们看看这样的类加载的实际效果:

  1. Class<?> type = new ByteBuddy()
  2. .subclass(Object.class)
  3. .make()
  4. .load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
  5. .getLoaded();

在上面的示例中,我们创建并加载了一个类。正如我们之前提到的,我们使用 WRAPPER 策略来加载适合大多数情况的类。最后, getLoaded 方法返回 Java Class 的实例,该实例表示现在加载的动态类。

注意,加载类时,预定义的类加载策略是通过应用当前执行上下文的 ProtectionDomain 来执行的。或者,所有默认策略都通过调用 withProtectionDomain 方法来提供显式保护域的规范。在使用安全管理器或使用签名 jar 中定义的类时,定义显式保护域非常重要。

 重新加载一个类

在上一节中,我们了解了如何使用 Byte Buddy 来重新定义现有类或重新建立现有类的基础。然而,在 Java 程序执行期间,通常无法保证特定类尚未加载。
(此外,Byte Buddy 目前仅将加载的类作为其参数,这将在未来的版本中发生变化,现有的 API 可以用于同等地处理卸载的类。)由于 Java 虚拟机的 HotSwap 功能,现有的类可以被重新定义即使它们已加载。
此功能可通过 Byte Buddy 的 ClassReloadingStrategy 访问。让我们通过重新定义类 Foo 来演示此策略:

  1. class Foo {
  2. String m() { return "foo"; }
  3. }
  4.  
  5. class Bar {
  6. String m() { return "bar"; }
  7. }

使用 Byte Buddy,我们现在可以轻松地将类 Foo 重新定义为 Bar 。使用 HotSwap,此重新定义甚至适用于预先存在的实例:

  1. ByteBuddyAgent.install();
  2. Foo foo = new Foo();
  3. new ByteBuddy()
  4. .redefine(Bar.class)
  5. .name(Foo.class.getName())
  6. .make()
  7. .load(Foo.class.getClassLoader(), ClassReloadingStrategy.fromInstalledAgent());
  8. assertThat(foo.m(), is("bar"));

HotSwap 只能使用所谓的 Java 代理进行访问。这样的代理可以通过在 Java 虚拟机启动时使用 -javaagent 参数指定来安装,其中参数的参数需要是 Byte Buddy 的代理 jar,可以从 Maven Central 下载。但是,当 Java 应用程序从 Java 虚拟机的 JDK 安装运行时,即使在通过 ByteBuddyAgent.installOnOpenJDK() 启动应用程序后,Byte Buddy 也可以加载 Java 代理。由于类重定义主要用于实现工具或测试,因此这可能是一个非常方便的替代方案。从 Java 9 开始,无需安装 JDK,也可以在运行时安装代理。

关于上面的示例,首先可能出现违反直觉的一件事是,Byte Buddy 被指示重新定义 Bar 类型,其中 Foo 类型最终被重新定义。 Java 虚拟机通过名称和类加载器来识别类型。因此,通过将 Bar 重命名为 Foo 并应用此定义,我们最终重新定义了重命名为 Bar 的类型。当然,同样可以直接重新定义 Foo 而无需重命名不同的类型。

然而,使用 Java 的 HotSwap 功能有一个巨大的缺点。 HotSwap 的当前实现要求重新定义的类在类重新定义之前和之后应用相同的类模式。
这意味着重新加载类时不允许添加方法或字段。我们已经讨论过,Byte Buddy 为任何变基类定义了原始方法的副本,因此类变基不适用于 ClassReloadingStrategy 。此外,类重新定义不适用于具有显式类初始值设定项方法(类中的静态块)的类,因为该初始值设定项也需要复制到额外的方法中。不幸的是,OpenJDK 已不再扩展 HotSwap 功能,因此无法使用 HotSwap 功能来解决此限制。同时,Byte Buddy 的 HotSwap 支持可用于看似有用的极端情况。
否则,当从构建脚本增强现有类时,类变基和重新定义可能是一个方便的功能。

使用卸载的类

认识到 Java 的 HotSwap 功能的局限性后,人们可能会认为 rebase 和 redefinition 指令的唯一有意义的应用是在构建期间。通过应用构建时操作,可以断言已处理的类在其初始类加载之前不会加载,因为该类加载是在 JVM 的不同实例中完成的。
然而,Byte Buddy 同样能够处理尚未加载的类。为此,Byte Buddy 对 Java 的反射 API 进行了抽象,使得 Class 实例在内部由 TypeDescription 实例表示。事实上,Byte Buddy 只知道如何通过实现 TypeDescription 接口的适配器来处理提供的 Class 。这种抽象的一大优点是类的信息不需要由 ClassLoader 提供,而是可以由任何其他源提供。

Byte Buddy 提供了一种使用 TypePool 获取类的 TypeDescription 的规范方式。当然还提供了这样一个池的默认实现。此 TypePool.Default 实现解析类的二进制格式并将其表示为所需的 TypeDescription 。与 ClassLoader 类似,它为表示的类维护一个缓存,该缓存也是可定制的。此外,它通常从 ClassLoader 检索类的二进制格式,但不指示它加载此类。

Java 虚拟机仅在第一次使用时加载类。因此,我们可以安全地重新定义一个类,例如

  1. package foo;
  2. class Bar { }

在程序启动时运行任何其他代码之前:

  1. class MyApplication {
  2. public static void main(String[] args) {
  3. TypePool typePool = TypePool.Default.ofSystemLoader();
  4. Class bar = new ByteBuddy()
  5. .redefine(typePool.describe("foo.Bar").resolve(), // do not use 'Bar.class'
  6. ClassFileLocator.ForClassLoader.ofSystemLoader())
  7. .defineField("qux", String.class) // we learn more about defining fields later
  8. .make()
  9. .load(ClassLoader.getSystemClassLoader(), ClassLoadingStrategy.Default.INJECTION)
  10. .getLoaded();
  11. assertThat(bar.getDeclaredField("qux"), notNullValue());
  12. }
  13. }

通过在断言语句中首次使用之前显式加载重新定义的类,我们可以阻止 JVM 的内置类加载。这样, foo.Bar 的重新定义定义就会在我们的应用程序运行时加载并使用。但请注意,当我们使用 TypePool 提供描述时,我们不会通过类文字引用该类。如果我们确实使用了 foo.Bar 的类文字,那么 JVM 将在我们有机会重新定义它之前加载该类,并且我们的重新定义尝试将无效。另请注意,在使用已卸载的类时,我们还需要指定一个 ClassFileLocator ,它允许定位类的类文件。在上面的示例中,我们只是创建一个类文件定位器,它扫描正在运行的应用程序的类路径以查找此类文件。

 创建 Java 代理

当应用程序变得越来越大并且变得更加模块化时,在特定程序点应用这种转换当然是一个执行起来很麻烦的约束。并且确实有更好的方法来按需应用此类重新定义。使用 Java 代理,可以直接拦截 Java 应用程序中进行的任何类加载活动。 Java 代理被实现为一个简单的 jar 文件,其入口点在此 jar 文件的清单文件中指定,如链接资源下所述。使用 Byte Buddy,可以通过使用 AgentBuilder 直接实现此类代理。假设我们之前定义了一个名为 ToString 的简单注释,那么只需实现代理的 premain 方法即可为所有带注释的类实现 toString 方法,如下所示:

  1. class ToStringAgent {
  2. public static void premain(String arguments, Instrumentation instrumentation) {
  3. new AgentBuilder.Default()
  4. .type(isAnnotatedWith(ToString.class))
  5. .transform(new AgentBuilder.Transformer() {
  6. @Override
  7. public DynamicType.Builder transform(DynamicType.Builder builder,
  8. TypeDescription typeDescription,
  9. ClassLoader classloader) {
  10. return builder.method(named("toString"))
  11. .intercept(FixedValue.value("transformed"));
  12. }
  13. }).installOn(instrumentation);
  14. }
  15. }

应用上述 AgentBuilder.Transformer 的结果是,带注释的类的所有 toString 方法现在都将返回 transformed 。我们将在接下来的部分中了解有关 Byte Buddy 的 DynamicType.Builder 的所有内容,暂时不用担心此类。上面的代码当然会产生一个微不足道且毫无意义的应用程序。然而,正确使用这个概念,可以提供一个强大的工具,可以轻松实现面向方面的编程。

请注意,在使用代理时,还可以检测引导类加载器加载的类。然而,这需要一些准备。首先,引导类加载器由 null 值表示,这使得无法使用反射在该类加载器中加载类。然而,有时需要将辅助类加载到检测类的类加载器中以支持类的实现。
为了将类加载到引导类加载器中,Byte Buddy 可以创建 jar 文件并将这些文件添加到引导类加载器的加载路径中。然而,为了实现这一点,需要将这些类保存到磁盘。
可以使用 enableBootstrapInjection 命令指定这些类的文件夹,该命令还采用 Instrumentation 接口的实例来附加类。请注意,检测类使用的所有用户类也需要放在引导搜索路径上,这可以使用 Instrumentation 接口进行。

在 Android 应用程序中加载类

Android 使用不同的类文件格式,使用 dex 文件,这些文件不在 Java 类文件格式的布局中。此外,借助继承 Dalvik 虚拟机的 ART 运行时,Android 应用程序在安装到 Android 设备上之前会被编译为本机机器代码。
因此,只要应用程序未与其 Java 源代码一起显式部署,Byte Buddy 就无法再重新定义或变基类,否则就没有中间代码表示可以解释。
然而,Byte Buddy 仍然能够使用 DexClassLoader 和内置的 dex 编译器来定义新类。为此,Byte Buddy 提供了 byte-buddy-android 模块,其中包含 AndroidClassLoadingStrategy ,允许从 Android 应用程序中加载动态创建的类。为了发挥作用,它需要一个用于写入临时文件和编译后的类文件的文件夹。
该文件夹不得在不同的应用程序之间共享,因为 Android 的安全管理器禁止这样做。

使用泛型类型

Byte Buddy 正在处理由 Java 编程语言定义的泛型类型。 Java 运行时不考虑泛型类型,它仅处理泛型类型的擦除。
但是,泛型类型仍然嵌入到任何 Java 类文件中,并由 Java 反射 API 公开。
因此,有时将通用信息包含到生成的类中是有意义的,因为通用类型信息可以影响其他库和框架的行为。
当 Java 编译器将类作为库进行持久化和处理时,嵌入泛型类型信息也很重要。

由于上述原因,在子类化类、实现接口或声明字段或方法时,Byte Buddy 接受 Java Type 而不是擦除的 Class 。还可以使用 TypeDescription.Generic.Builder 显式定义泛型类型。 Java 泛型类型与类型擦除的一个重要区别是类型变量的上下文含义。
当另一个类型以相同的名称声明相同的类型变量时,由某种类型定义的特定名称的类型变量不一定表示相同的类型。
因此,当 Type 实例传递给库时,Byte Buddy 会重新绑定在生成的类型或方法的上下文中表示类型变量的所有泛型类型。

创建类型时,Byte Buddy 还会透明地插入桥接方法。桥接方法由 MethodGraph.Compiler 解析,它是任何 ByteBuddy 实例的属性。默认方法图编译器的行为类似于 Java 编译器,并处理任何类文件的通用类型信息。然而,对于 Java 以外的其他语言,不同的方法图编译器可能是合适的。

 字段和方法

我们在上一节中创建的大多数类型没有定义任何字段或方法。但是,通过子类化 Object ,创建的类将继承其超类定义的方法。让我们验证这个 Java 琐事并在动态类型的实例上调用 toString 方法。我们可以通过反射调用创建的类的构造函数来获取实例。

  1. String toString = new ByteBuddy()
  2. .subclass(Object.class)
  3. .name("example.Type")
  4. .make()
  5. .load(getClass().getClassLoader())
  6. .getLoaded()
  7. .newInstance() // Java reflection API
  8. .toString();

Object#toString 方法的实现返回实例的完全限定类名和实例哈希码的十六进制表示形式的串联。事实上,在创建的实例上调用 toString 方法会返回类似 example.Type@340d1fa5 的内容。

当然,我们的工作还没有结束。创建动态类的主要动机是定义新逻辑的能力。为了演示这是如何完成的,让我们从一些简单的事情开始。我们想要重写 toString 方法并返回 Hello World! 而不是之前的默认值:

  1. String toString = new ByteBuddy()
  2. .subclass(Object.class)
  3. .name("example.Type")
  4. .method(named("toString")).intercept(FixedValue.value("Hello World!"))
  5. .make()
  6. .load(getClass().getClassLoader())
  7. .getLoaded()
  8. .newInstance()
  9. .toString();

我们添加到代码中的行包含 Byte Buddy 的域特定语言的两条指令。第一条指令是 method ,它允许我们选择任意数量的要覆盖的方法。通过传递 ElementMatcher 来应用此选择,该 ElementMatcher 作为谓词来决定每个可重写方法是否应该重写。 Byte Buddy 附带了许多预定义的方法匹配器,这些方法匹配器收集在 ElementMatchers 类中。通常,您会静态导入此类,以便生成的代码读起来更自然。上面的示例也假设了这样的静态导入,其中我们使用 named 方法匹配器,它通过确切的名称选择方法。请注意,预定义的方法匹配器是可组合的。这样,我们就可以更详细地描述方法选择,例如:

  1. named("toString").and(returns(String.class)).and(takesArguments(0))

后一个方法匹配器通过其完整的 Java 签名描述 toString 方法,因此仅匹配此特定方法。然而,在给定的上下文中,我们知道没有其他名为 toString 的方法具有不同的签名,因此我们的原始方法匹配器就足够了。

选择 toString 方法后,第二条指令 intercept 确定应覆盖给定选择的所有方法的实现。为了了解如何实现方法,该指令需要一个 Implementation 类型的参数。在上面的示例中,我们使用了 Byte Buddy 附带的 FixedValue 实现。正如此类的名称所示,该实现实现了一个始终返回给定值的方法。我们将在本节稍后部分更详细地了解 FixedValue 实现。现在,让我们更仔细地看看方法的选择。

到目前为止,我们只拦截了一个方法。然而,在实际应用中,事情可能会更复杂,我们可能希望应用不同的规则来覆盖不同的方法。让我们看一个这样的场景的例子:

  1. class Foo {
  2. public String bar() { return null; }
  3. public String foo() { return null; }
  4. public String foo(Object o) { return null; }
  5. }
  6.  
  7. Foo dynamicFoo = new ByteBuddy()
  8. .subclass(Foo.class)
  9. .method(isDeclaredBy(Foo.class)).intercept(FixedValue.value("One!"))
  10. .method(named("foo")).intercept(FixedValue.value("Two!"))
  11. .method(named("foo").and(takesArguments(1))).intercept(FixedValue.value("Three!"))
  12. .make()
  13. .load(getClass().getClassLoader())
  14. .getLoaded()
  15. .newInstance();

在上面的示例中,我们为重写方法定义了三种不同的规则。研究代码时,您会注意到第一条规则涉及 Foo 定义的任何方法,即示例类中的所有三个方法。第二条规则匹配名为 foo 的两个方法,这是先前选择的子集。最后一条规则只匹配 foo(Object) 方法,这是对前一个选择的进一步减少。但是考虑到这种选择重叠,Byte Buddy 如何决定哪个规则应用于哪个方法?

Byte Buddy 以堆栈形式组织重写方法的规则。
这意味着,每当您注册用于覆盖方法的新规则时,它都会被推入该堆栈的顶部,并且始终首先应用,直到添加新规则,该规则将具有更高的优先级。对于上面的例子,这意味着:

  • bar() 方法首先与 named("foo").and(takesArguments(1)) 进行匹配,然后与 named("foo") 进行匹配,两次匹配尝试的结果都是否定的。最后, isDeclaredBy(Foo.class) 匹配器批准重写 bar() 方法以返回 One! 。
  • 类似地, foo() 方法首先与 named("foo").and(takesArguments(1)) 进行匹配,其中缺少参数会导致匹配失败。此后, named("foo") 匹配器确定肯定匹配,从而重写 foo() 方法以返回 Two! 。
  • foo(Object) 立即由 named("foo").and(takesArguments(1)) 匹配器匹配,以便重写的实现返回 Three! 。

由于这种组织方式,您应该始终最后注册更具体的方法匹配器。否则,随后注册的任何不太具体的方法匹配器可能会阻止应用您之前定义的规则。请注意, ByteBuddy 配置允许定义 ignoreMethod 属性。与此方法匹配器成功匹配的方法永远不会被覆盖。默认情况下,Byte Buddy 不会覆盖任何合成方法。

在某些情况下,您可能希望定义一个不重写超类型或接口的方法的新方法。使用 Byte Buddy 也可以实现这一点。为此,您可以调用 defineMethod 来定义签名。定义方法后,系统会要求您提供 Implementation ,就像方法匹配器识别的方法一样。请注意,在方法定义之后注册的方法匹配器可能会根据我们之前讨论的堆栈原则取代此实现。

通过 defineField ,Byte Buddy 允许为给定类型定义字段。在 Java 中,字段永远不会被覆盖,而只能被隐藏。因此,无法进行字段匹配等。

有了关于如何选择方法的知识,我们就可以了解如何实现这些方法了。为此,我们现在研究 Byte Buddy 附带的预定义 Implementation 实现。定义自定义实现将在其自己的部分中讨论,并且仅适用于需要非常自定义的方法实现的用户。

仔细看看固定值

我们已经看到了 FixedValue 的实际实施。顾名思义, FixedValue 实现的方法只是返回一个提供的对象。类能够以两种不同的方式记住这样的对象:

  • 固定值被写入类的常量池中。常量池是 Java 类文件格式中的一个部分,包含许多描述任何类的属性的无状态值。
    常量池主要需要记住类的属性,例如类的名称或其方法的名称。
    除了这些反射属性之外,常量池还有空间来存储类的方法或字段中使用的任何字符串或原始值。除了字符串和原始值之外,类池还可以存储对其他类型的引用。
  • 该值存储在类的静态字段中。为了实现这一点,一旦类被加载到 Java 虚拟机中,就必须为该字段分配给定值。为此,每个动态创建的类都附带一个 TypeInitializer ,可以将其配置为执行此类显式初始化。当您指示加载 DynamicType.Unloaded 时,Byte Buddy 会自动触发其类型初始值设定项,以便该类可供使用。因此,您通常不需要担心类型初始值设定项。
    但是,如果您想要加载要在 Byte Buddy 外部加载的动态类,则在加载这些类后手动运行它们的类型初始值设定项非常重要。否则, FixedValue 实现将返回 null 而不是所需的值,因为静态字段从未分配过该值。然而,许多动态类型可能不需要显式初始化。因此,可以通过调用类的 isAlive 方法来查询类的类型初始值设定项的活跃性。如果您需要手动触发 TypeInitializer ,您会发现它是由 DynamicType 接口公开的。

当您通过 FixedValue#value(Object) 实现方法时,Byte Buddy 会分析参数的类型,并在可能的情况下将其定义为存储在动态类型的类池中,否则将值存储在静态字段中。
但请注意,如果值存储在类池中,则所选方法返回的实例可能具有不同的对象标识。因此,您可以使用 FixedValue#reference(Object) 指示 Byte Buddy 始终将对象存储在静态字段中。后一个方法被重载,以便您可以提供字段名称作为第二个参数。否则,字段名称将自动从对象的哈希码派生。此行为的一个例外是 null 值。 null 值永远不会存储在字段中,而是简单地由其文字表达式表示。

您可能想知道在这种情况下的类型安全。显然,您可以定义一个方法来返回无效值:

  1. new ByteBuddy()
  2. .subclass(Foo.class)
  3. .method(isDeclaredBy(Foo.class)).intercept(FixedValue.value(0))
  4. .make();

Java 类型系统中的编译器很难阻止这种无效实现。相反,当创建类型并且将整数非法分配给返回 String 的方法生效时,Byte Buddy 将抛出 IllegalArgumentException 。 Byte Buddy 尽力确保其创建的所有类型都是合法的 Java 类型,并在创建非法类型期间通过抛出异常来快速失败。

Byte Buddy 的分配行为是可定制的。同样,Byte Buddy 只提供了一个合理的默认值,它模仿了 Java 编译器的赋值行为。
因此,Byte Buddy 允许将类型分配给其任何超类型,并且它还会考虑对原始值进行装箱或拆箱其包装表示。
但请注意,Byte Buddy 目前不完全支持泛型类型,只会考虑类型擦除。因此,Byte Buddy 有可能造成堆污染。您始终可以实现自己的 Assigner ,而不是使用预定义的分配器,它能够进行 Java 编程语言中不隐式的类型转换。我们将在本教程的最后一部分研究此类自定义实现。现在,我们满足于提及您可以通过在任何 FixedValue 实现上调用 withAssigner 来定义此类自定义分配器。

委托方法调用

在许多情况下,从方法返回固定值当然是不够的。为了获得更大的灵活性,Byte Buddy 提供了 MethodDelegation 实现,它在响应方法调用方面提供了最大的自由度。方法委托定义动态创建类型的方法,以将任何调用转发到可能存在于动态类型之外的另一个方法。
这样,动态类的逻辑可以使用纯 Java 来表示,而仅通过代码生成来实现与另一个方法的绑定。在讨论细节之前,让我们看一个使用 MethodDelegation 的示例:

  1. class Source {
  2. public String hello(String name) { return null; }
  3. }
  4.  
  5. class Target {
  6. public static String hello(String name) {
  7. return "Hello " + name + "!";
  8. }
  9. }
  10.  
  11. String helloWorld = new ByteBuddy()
  12. .subclass(Source.class)
  13. .method(named("hello")).intercept(MethodDelegation.to(Target.class))
  14. .make()
  15. .load(getClass().getClassLoader())
  16. .getLoaded()
  17. .newInstance()
  18. .hello("World");

在示例中,我们将对 Source#hello(String) 方法的调用委托给 Target 类型,以便该方法返回 Hello World! 而不是 null 。为此, MethodDelegation 实现标识 Target 类型的任何可调用方法,并标识这些方法中的最佳匹配。在上面的示例中,这是微不足道的,因为 Target 类型仅定义一个静态方法,其中该方法的参数、返回类型和名称与 Source#name(String) 的参数、返回类型和名称相同。

实际上,授权目标方法的决策很可能会更加复杂。那么,如果有实际选择的话,Byte Buddy 如何在方法之间做出选择呢?为此,我们假设 Target 类定义如下:

  1. class Target {
  2. public static String intercept(String name) { return "Hello " + name + "!"; }
  3. public static String intercept(int i) { return Integer.toString(i); }
  4. public static String intercept(Object o) { return o.toString(); }
  5. }

您可能已经注意到,上述方法现在都称为 intercept 。 Byte Buddy 不要求目标方法与源方法具有相同的名称。我们很快就会进一步研究这个问题。更重要的是,如果您使用更改后的 Target 定义运行前一个示例,您会发现 named(String) 方法绑定到 intercept(String) 。但这是为什么呢?显然, intercept(int) 方法无法接收源方法的 String 参数,因此甚至不被视为可能的匹配。但是,对于可以绑定的 intercept(Object) 方法来说,情况并非如此。为了解决这种歧义,Byte Buddy 再次模仿 Java 编译器,选择与最具体的参数类型绑定的方法。请记住 Java 编译器如何为重载方法选择绑定!由于 String 比 Object 更具体,因此最终在三个选项中选择了 intercept(String) 类。

根据到目前为止的信息,您可能会认为方法绑定算法具有相当严格的性质。然而,我们还没有讲述完整的故事。
到目前为止,我们只观察到约定优于配置原则的另一个例子,如果默认值不符合实际要求,则该原则可以进行更改。实际上, MethodDelegation 实现与注释一起使用,其中参数的注释决定应为其分配什么值。但是,如果未找到注释,Byte Buddy 会将参数视为使用 @Argument 进行注释。后一个注释会导致 Byte Buddy 将源方法的第 n 参数分配给带注释的目标。当未显式添加注释时, n 的值将设置为注释参数的索引。根据这个规则,Byte Buddy 对待

  1. void foo(Object o1, Object o2)

就好像所有参数都注释为:

  1. void foo(@Argument(0) Object o1, @Argument(1) Object o2)

结果,检测方法的第一个和第二个参数被分配给拦截器。
如果被拦截的方法没有声明至少两个参数,或者如果注释的参数类型不能从检测方法的参数类型分配,则丢弃相关的拦截器方法。

除了 @Argument 注释之外,还有其他几个可以与 MethodDelegation 一起使用的预定义注释:

  • 带有 @AllArguments 注释的参数必须是数组类型,并分配一个包含源方法的所有参数的数组。为此,所有源方法参数都必须可分配给数组的组件类型。
    如果不是这种情况,则当前目标方法不被视为绑定到源方法的候选方法。
  • @This 注释诱导当前调用拦截方法的动态类型实例的分配。
    如果带注释的参数不可分配给动态类型的实例,则当前方法不会被视为绑定到源方法的候选方法。
    请注意,在此实例上调用任何方法都将导致调用潜在的检测方法实现。要调用重写的实现,您需要使用下面讨论的 @Super 注释。使用 @This 注释来访问实例字段的典型原因。
  • 用 @Origin 注释的参数必须用于任何类型 Method 、 Constructor 、 Executable 、 Class 、 MethodType 、 String 或 int 。根据参数的类型,为其分配对现在已检测的原始方法或构造函数的 Method 或 Constructor 引用,或对动态创建的 Class 的引用。使用 Java 8 时,还可以通过在拦截器中使用 Executable 类型来接收方法或构造函数引用。如果带注释的参数是 String ,则为该参数分配 Method 的 toString 方法返回的值。一般来说,我们建议尽可能使用这些 String 值作为方法标识符,并且不鼓励使用 Method 对象,因为它们的查找会带来大量的运行时开销。为了避免这种开销, @Origin 注释还提供了一个属性来缓存此类实例以供重用。请注意, MethodHandle 和 MethodType 存储在类的常量池中,因此使用这些常量的类必须至少是 Java 版本 7。而不是使用反射来反射调用拦截的方法对于另一个对象,我们还建议使用 @Pipe 注释,这将在本节后面讨论。当对 int 类型的参数使用 @Origin 注释时,它会被分配检测方法的修饰符。

除了使用预定义的注释之外,Byte Buddy 还允许您通过注册一个或多个 ParameterBinder 来定义自己的注释。我们将在本教程的最后一部分研究此类自定义。

除了我们到目前为止讨论的四个注释之外,还存在另外两个预定义注释,它们授予对动态类型方法的超级实现的访问权限。这样,动态类型可以向类添加方面,例如方法调用的日志记录。使用 @SuperCall 注释,甚至可以从动态类外部执行对方法的超级实现的调用,如以下示例所示:

  1. class MemoryDatabase {
  2. public List<String> load(String info) {
  3. return Arrays.asList(info + ": foo", info + ": bar");
  4. }
  5. }
  6.  
  7. class LoggerInterceptor {
  8. public static List<String> log(@SuperCall Callable<List<String>> zuper)
  9. throws Exception {
  10. System.out.println("Calling database");
  11. try {
  12. return zuper.call();
  13. } finally {
  14. System.out.println("Returned from database");
  15. }
  16. }
  17. }
  18.  
  19. MemoryDatabase loggingDatabase = new ByteBuddy()
  20. .subclass(MemoryDatabase.class)
  21. .method(named("load")).intercept(MethodDelegation.to(LoggerInterceptor.class))
  22. .make()
  23. .load(getClass().getClassLoader())
  24. .getLoaded()
  25. .newInstance();

从上面的示例中,很明显,通过将 Callable 的某个实例注入 LoggerInterceptor 来调用 super 方法,该实例调用 MemoryDatabase#load(String) 方法。在 Byte Buddy 的术语中,此帮助程序类称为 AuxiliaryType 。辅助类型由 Byte Buddy 根据需要创建,并且在创建类后可以从 DynamicType 接口直接访问。由于存在此类辅助类型,手动创建一种动态类型可能会导致创建多种其他类型,从而帮助实现原始类。最后,请注意, @SuperCall 注释也可以用在 Runnable 类型上,但原始方法的返回值会被删除。

您可能仍然想知道这个辅助类型如何能够调用另一种类型的超级方法,这在 Java 中通常是禁止的。
然而,经过仔细检查,这种行为非常常见,类似于编译以下 Java 源代码片段时生成的编译代码:

  1. class LoggingMemoryDatabase extends MemoryDatabase {
  2.  
  3. private class LoadMethodSuperCall implements Callable {
  4.  
  5. private final String info;
  6. private LoadMethodSuperCall(String info) {
  7. this.info = info;
  8. }
  9.  
  10. @Override
  11. public Object call() throws Exception {
  12. return LoggingMemoryDatabase.super.load(info);
  13. }
  14. }
  15.  
  16. @Override
  17. public List<String> load(String info) {
  18. return LoggerInterceptor.log(new LoadMethodSuperCall(info));
  19. }
  20. }

然而,有时,您可能希望使用与方法原始调用时分配的参数不同的参数来调用超级方法。在 Byte Buddy 中,通过使用 @Super 注释也可以实现这一点。此注释触发另一个 AuxiliaryType 的创建,该 AuxiliaryType 现在扩展了相关动态类型的超类或接口。与之前类似,辅助类型重写所有方法以在动态类型上调用其超级实现。
这样,可以实现上一个示例中的示例记录器拦截器来更改实际调用:

  1. class ChangingLoggerInterceptor {
  2. public static List<String> log(String info, @Super MemoryDatabase zuper) {
  3. System.out.println("Calling database");
  4. try {
  5. return zuper.load(info + " (logged access)");
  6. } finally {
  7. System.out.println("Returned from database");
  8. }
  9. }
  10. }

请注意,分配给用 @Super 注释的参数的实例与动态类型的实际实例具有不同的标识!因此,可通过参数访问的实例字段不会反映实际实例的字段。
此外,辅助实例的不可重写方法不会委托其调用,而是保留原始实现,这可能会在调用它们时导致荒谬的行为。最后,如果用 @Super 注释的参数不表示相关动态类型的超类型,则该方法不会被视为其任何方法的绑定目标。

由于 @Super 注释允许使用任何类型,因此我们可能需要提供有关如何构造此类型的信息。默认情况下,Byte Buddy 尝试使用类的默认构造函数。这始终适用于隐式扩展 Object 类型的接口。但是,当扩展动态类型的超类时,该类甚至可能不提供默认构造函数。如果是这种情况,或者如果应使用特定的构造函数来创建此类辅助类型,则 @Super 注释允许通过将其参数类型设置为注释的 constructorParameters 来识别不同的构造函数财产。然后,将通过为每个参数分配相应的默认值来调用该构造函数。或者,也可以使用 Super.Instantiation.UNSAFE 策略来创建类,该策略利用 Java 内部类来创建辅助类型,而不调用任何构造函数。但请注意,此策略不一定可移植到非 Oracle JVM,并且在未来的 JVM 版本中可能不再可用。
然而,截至目前,几乎所有 JVM 实现中都可以找到这种不安全实例化策略所使用的内部类。

此外,您可能已经注意到上面的 LoggerInterceptor 声明了一个已检查的 Exception 。另一方面,调用此方法的检测源方法不会声明任何已检查异常。通常,Java 编译器会拒绝编译这样的调用。然而,与编译器相反,Java 运行时处理已检查异常与未检查异常并允许这种调用。
出于这个原因,我们决定忽略已检查的异常并为其使用提供充分的灵活性。
但是,从动态创建的方法抛出未声明的已检查异常时要小心,因为遇到此类异常可能会使应用程序的用户感到困惑。

方法委托模型中还有另一个警告可能引起您的注意。虽然静态类型非常适合实现方法,但严格类型会限制代码的重用。要理解原因,请考虑以下示例:

  1. class Loop {
  2. public String loop(String value) { return value; }
  3. public int loop(int value) { return value; }
  4. }

由于上述类的方法描述了两个具有不兼容类型的相似签名,因此您通常无法使用单个拦截器方法来检测这两种方法。
相反,您必须提供两个具有不同签名的不同目标方法,以满足静态类型检查。为了克服这个限制,Byte Buddy 允许使用 @RuntimeType 注释方法和方法参数,这指示 Byte Buddy 暂停严格的类型检查以支持运行时类型转换:

  1. class Interceptor {
  2. @RuntimeType
  3. public static Object intercept(@RuntimeType Object value) {
  4. System.out.println("Invoked method with: " + value);
  5. return value;
  6. }
  7. }

使用上面的目标方法,我们现在能够为两个源方法提供单一的拦截方法。请注意,Byte Buddy 还能够对原始值进行装箱和拆箱。但是,请注意,使用 @RuntimeType 是以放弃类型安全为代价的,如果混合了不兼容的类型,您可能最终会得到 ClassCastException 。

作为 @SuperCall 的等价物,Byte Buddy 带有 @DefaultCall 注释,它允许调用默认方法而不是调用方法的超级方法。
仅当拦截的方法实际上由直接由检测类型实现的接口声明为默认方法时,才考虑使用具有此参数注释的方法进行绑定。同样,如果检测的方法未定义非抽象超级方法,则 @SuperCall 注释会阻止方法的绑定。但是,如果您想调用特定类型的默认方法,则可以使用特定接口指定 @DefaultCall 的 targetType 属性。根据此规范,Byte Buddy 注入一个代理实例,该实例调用给定接口类型的默认方法(如果存在这样的方法)。
否则,带参数注解的目标方法不被视为委托目标。显然,默认方法调用仅适用于在等于 Java 8 或更高版本的类文件版本中定义的类。类似地,除了 @Super 注释之外,还有一个 @Default 注释,它注入一个代理来显式调用特定的默认方法。

我们已经提到过,您可以使用任何 MethodDelegation 定义和注册自定义注释。 Byte Buddy 附带了一个可供使用的注释,但仍需要显式安装和注册。通过使用 @Pipe 注释,您可以将拦截的方法调用转发到另一个实例。 @Pipe 注解没有预先注册到 MethodDelegation 中,因为 Java 类库在定义 Function 类型的 Java 8 之前没有提供合适的接口类型。因此,您需要显式提供一个具有单个非静态方法的类型,该方法将 Object 作为其参数,并返回另一个 Object 作为结果。请注意,只要方法类型受 Object 类型绑定,您仍然可以使用泛型类型。当然,如果您使用的是 Java 8,则 Function 类型是一个可行的选择。当对参数的参数调用方法时,Byte Buddy 会将参数转换为方法的声明类型,并使用与原始方法调用相同的参数来调用拦截的方法。
在查看示例之前,让我们定义一个可以与 Java 5 及更高版本一起使用的自定义类型:

  1. interface Forwarder<T, S> {
  2. T to(S target);
  3. }

使用这种类型,我们现在可以通过将方法调用转发到现有实例来实现记录上述 MemoryDatabase 访问的新解决方案:

  1. class ForwardingLoggerInterceptor {
  2.  
  3. private final MemoryDatabase memoryDatabase; // constructor omitted
  4.  
  5. public List<String> log(@Pipe Forwarder<List<String>, MemoryDatabase> pipe) {
  6. System.out.println("Calling database");
  7. try {
  8. return pipe.to(memoryDatabase);
  9. } finally {
  10. System.out.println("Returned from database");
  11. }
  12. }
  13. }
  14.  
  15. MemoryDatabase loggingDatabase = new ByteBuddy()
  16. .subclass(MemoryDatabase.class)
  17. .method(named("load")).intercept(MethodDelegation.withDefaultConfiguration()
  18. .withBinders(Pipe.Binder.install(Forwarder.class)))
  19. .to(new ForwardingLoggerInterceptor(new MemoryDatabase()))
  20. .make()
  21. .load(getClass().getClassLoader())
  22. .getLoaded()
  23. .newInstance();

在上面的示例中,我们仅将调用转发到我们本地创建的另一个实例。然而,与通过子类化类型来拦截方法相比,这种方法的优点是可以增强已经存在的实例。
此外,您通常会在实例级别注册拦截器,而不是在类级别注册静态拦截器。

到目前为止,我们已经看到了大量的 MethodDelegation 实现。但在继续之前,我们想更详细地了解 Byte Buddy 如何选择目标方法。我们已经描述了 Byte Buddy 如何通过比较参数类型来解析最具体的方法,但还有更多内容。在 Byte Buddy 识别出符合绑定到给定源方法的候选方法后,它将解析委托给 AmbiguityResolver 链。同样,您可以自由地实现自己的歧义解析器,这些解析器可以补充甚至替换 Byte Buddy 的默认设置。
如果没有此类更改,歧义解析器链会尝试通过按以下相同顺序应用以下规则来识别唯一的目标方法:

  • 可以通过使用 @BindingPriority 注释方法来为其分配显式优先级。如果一种方法的优先级高于另一种方法,则高优先级方法始终优先于低优先级方法。此外,用 @IgnoreForBinding 注释的方法永远不会被视为目标方法。
  • 如果源方法和目标方法具有相同的名称,则该目标方法优先于具有不同名称的其他目标方法。
  • 如果两个方法使用 @Argument 绑定源方法的相同参数,则考虑参数类型最具体的方法。在这种情况下,通过不注释参数来显式或隐式地提供注释并不重要。
    解析算法的工作原理与 Java 编译器解析对重载方法的调用的算法类似。如果两种类型同样具体,则绑定更多参数的方法将被视为目标。
    如果在此解析阶段应为参数分配参数而不考虑参数类型,则可以通过将注释的 bindingMechanic 属性设置为 BindingMechanic.ANONYMOUS 来实现。此外,请注意,每个目标方法上的每个索引值的非匿名参数都必须是唯一的,解析算法才能发挥作用。
  • 如果一个目标方法比另一个目标方法具有更多参数,则前一个方法优于后者。

到目前为止,我们仅通过命名特定的类来将方法调用委托给静态方法,如 MethodDelegation.to(Target.class) 中。然而,也可以委托给实例方法或构造函数:

  • 通过调用 MethodDelegation.to(new Target()) ,可以将方法调用委托给 Target 类的任何实例方法。请注意,这包括在实例的类层次结构中任何位置定义的方法,包括在 Object 类中定义的方法。您可能希望通过在任何 MethodDelegation 上调用 filter(ElementMatcher) 来将过滤器应用于方法委托来限制候选方法的范围。 ElementMatcher 类型与之前在 Byte Buddy 的域特定语言中选择源方法时使用的类型相同。作为方法委托目标的实例存储在静态字段中。
    与固定值的定义类似,这需要定义 TypeInitializer 。您可以选择通过 MethodDelegation.toField(String) 定义任何字段的使用,而不是将委托存储在静态字段中,其中参数指定所有方法委托都转发到的字段名称。始终记住在调用此类动态类的实例上的方法之前为此字段分配一个值。否则,方法委托将导致 NullPointerException 。
  • 方法委托可用于构造给定类型的实例。通过使用 MethodDelegation.toConstructor(Class) ,任何拦截方法的调用都会返回给定目标类型的新实例。

正如您刚刚了解到的, MethodDelegation 检查注释以调整其绑定逻辑。这些注释特定于 Byte Buddy,但这并不意味着带注释的类以任何方式依赖于 Byte Buddy。
相反,Java 运行时只是忽略加载类时在类路径中找不到的注释类型。这意味着创建动态类后不再需要 Byte Buddy。
这意味着即使类路径上没有 Byte Buddy,您也可以在另一个 JVM 进程中加载​​动态类及其委托方法调用的类型。

还有几个预定义注释可以与 MethodDelegation 一起使用,我们只想简单地命名。如果您想了解有关这些注释的更多信息,可以在代码内文档中找到更多信息。这些注释是:

  • @Empty :应用此注释,Byte Buddy 注入参数类型的默认值。对于基本类型,这相当于数字零,对于引用类型,这是 null 。使用此注释的目的是使拦截器的参数无效。
  • @StubValue :通过此注解,被注解的参数将被注入被拦截方法的存根值。对于引用返回类型和 void 方法,将注入值 null 。对于返回原始值的方法,将注入等效的装箱类型 0 。在使用 @RuntimeType 注释定义返回 Object 类型的通用拦截器时,这可能会很有帮助。通过返回注入的值,该方法充当存根,同时正确考虑原始返回类型。
  • @FieldValue :此注释在检测类型的类层次结构中定位一个字段,并将该字段的值注入到带注释的参数中。如果找不到带注释的参数的兼容类型的可见字段,则不会绑定目标方法。
  • @FieldProxy :使用此注释,Byte Buddy 为给定字段注入访问器。所访问的字段可以通过其名称显式指定,也可以从 getter 或 setter 方法名称派生(如果拦截的方法表示此类方法)。
    在使用此注解之前,需要显式安装并注册它,类似于 @Pipe 注解。
  • @Morph :此注释的工作方式与 @SuperCall 注释非常相似。但是,使用此注释允许指定用于调用 super 方法的参数。
    请注意,仅当您需要调用具有与原始调用不同的参数的超级方法时,才应使用此注释,因为使用 @Morph 注释需要对所有参数进行装箱和拆箱。如果您想调用特定的超级方法,请考虑使用 @Super 注释来创建类型安全代理。在使用此注解之前,需要显式安装并注册它,类似于 @Pipe 注解。
  • @SuperMethod :此注释只能用于可从 Method 分配的参数类型。分配的方法设置为允许调用原始代码的合成访问器方法。
    请注意,使用此注释会导致为代理类创建一个公共访问器,该访问器允许在不传递安全管理器的情况下从外部调用 super 方法。
  • @DefaultMethod :与 @SuperMethod 类似,但用于默认方法调用。如果默认方法调用只有一种可能性,则在唯一类型上调用默认方法。否则,可以将类型显式指定为注释属性。

调用超级方法

顾名思义, SuperMethodCall 实现可用于调用方法的超级实现。乍一看,单独调用超级实现似乎不是很有用,因为这不会改变实现,而只会复制现有逻辑。
但是,通过重写方法,您可以更改方法及其参数的注释,我们将在下一节中讨论这一点。
然而,在 Java 中调用超方法的另一个基本原理是构造函数的定义,该构造函数必须始终调用其超类型或其自身类型的另一个构造函数。

到目前为止,我们只是假设动态类型的构造函数总是类似于其直接超类型的构造函数。举个例子,我们可以调用

  1. new ByteBuddy()
  2. .subclass(Object.class)
  3. .make()

使用单个默认构造函数创建 Object 的子类,该构造函数被定义为简单地调用其直接超级构造函数,即 Object 的默认构造函数。然而,这种行为并不是字节好友所规定的。相反,上面的代码是调用的快捷方式

  1. new ByteBuddy()
  2. .subclass(Object.class, ConstructorStrategy.Default.IMITATE_SUPER_TYPE)
  3. .make()

其中 ConstructorStrategy 负责为任何给定的类创建一组预定义的构造函数。
除了复制动态类型的直接超类的每个可见构造函数的上述策略之外,还存在其他三种预定义策略:一种根本不创建任何构造函数,一种创建调用直接超类的默认构造函数的默认构造函数,以及如果不存在这样的构造函数并且仅模仿超类型的公共构造函数,则抛出异常。

在 Java 类文件格式中,构造函数通常与方法没有什么不同,因此 Byte Buddy 允许将它们同样对待。
但是,构造函数需要包含对另一个构造函数的硬编码调用,才能被 Java 运行时接受。因此,除了 SuperMethodCall 之外的大多数预定义实现在应用于构造函数时都将无法创建有效的 Java 类。

但是,通过使用自定义实现,您可以通过实现自定义 ConstructorStrategy 或使用 defineConstructor 方法在 Byte Buddy 的域特定语言中定义单独的构造函数来定义自己的构造函数。此外,我们计划向 Byte Buddy 添加新功能,以定义更复杂的开箱即用的构造函数。

对于类变基和类重定义,构造函数当然会被简单地保留,这使得 ConstructorStrategy 的规范变得过时。相反,为了复制这些保留的构造函数(和方法)实现,需要指定一个 ClassFileLocator ,它允许查找包含这些构造函数定义的原始类文件。 Byte Buddy 尽力自行识别原始类文件的位置,例如通过查询相应的 ClassLoader 或查看应用程序的类路径。然而,在处理常规类加载器时,查找可能仍然不成功。然后,可以提供自定义 ClassFileLocator 。

调用默认方法

随着版本 8 的发布,Java 编程语言引入了接口的默认方法。在 Java 中,默认方法调用的语法与超级方法的调用类似。唯一的区别是,默认方法调用命名定义该方法的接口。
这是必要的,因为如果两个接口定义具有相同签名的方法,则默认方法调用可能不明确。因此,Byte Buddy 的 DefaultMethodCall 实现采用优先级接口列表。当拦截方法时, DefaultMethodCall 会调用第一个提到的接口上的默认方法。例如,假设我们要实现以下两个接口:

  1. interface First {
  2. default String qux() { return "FOO"; }
  3. }
  4.  
  5. interface Second {
  6. default String qux() { return "BAR"; }
  7. }

如果我们现在创建一个实现两个接口的类并实现 qux 方法来调用默认方法,则此调用可以表示调用 First 上定义的默认方法或 Second 界面。但是,通过指定 DefaultMethodCall 优先考虑 First 接口,Byte Buddy 会知道它应该调用后一个接口的方法而不是替代方法。

  1. new ByteBuddy(ClassFileVersion.JAVA_V8)
  2. .subclass(Object.class)
  3. .implement(First.class)
  4. .implement(Second.class)
  5. .method(named("qux")).intercept(DefaultMethodCall.prioritize(First.class))
  6. .make()

请注意,Java 8 之前的类文件版本中定义的任何 Java 类都不支持默认方法。
此外,您应该意识到,与 Java 编程语言相比,Byte Buddy 对默认方法的可调用性的要求较弱。
Byte Buddy 仅需要由类型层次结构中最具体的类实现默认方法的接口。
除了 Java 编程语言之外,它不要求该接口是任何超类实现的最具体的接口。最后,如果您不希望出现不明确的默认方法定义,则始终可以使用 DefaultMethodCall.unambiguousOnly() 来接收在发现不明确的默认方法调用时引发异常的实现。优先级 DefaultMethodCall 显示了相同的行为,其中默认方法调用在非优先级接口之间不明确,并且未找到优先级接口来定义具有兼容签名的方法。

调用特定方法

在某些情况下,上述 Implementation 不足以实现更多自定义行为。例如,人们可能想要实现具有显式行为的自定义类。
例如,我们可能想要使用不具有具有相同参数的超级构造函数的构造函数来实现以下 Java 类:

  1. public class SampleClass {
  2. public SampleClass(int unusedValue) {
  3. super();
  4. }
  5. }

先前的 SuperMethodCall 实现无法用于实现此类,因为 Object 类未定义采用 int 作为其参数的构造函数。相反,我们可以显式调用 Object 超级构造函数:

  1. new ByteBuddy()
  2. .subclass(Object.class, ConstructorStrategy.Default.NO_CONSTRUCTORS)
  3. .defineConstructor(Arrays.<Class<?>>asList(int.class), Visibility.PUBLIC)
  4. .intercept(MethodCall.invoke(Object.class.getDeclaredConstructor()))
  5. .make()

通过上面的代码,我们创建了 Object 的一个简单子类,它定义了一个构造函数,该构造函数采用一个未使用的 int 参数。然后,通过对 Object 超级构造函数的显式方法调用来实现后一个构造函数。

传递参数时也可以使用 MethodCall 实现。这些参数要么作为值、需要手动设置的实例字段的值或作为给定参数值显式传递。
此外,该实现还允许调用除正在检测的实例之外的其他实例上的方法。此外,它允许构造从拦截的方法返回的新实例。 MethodCall 类的文档提供了有关这些功能的详细信息。

 访问字段

使用 FieldAccessor ,可以实现读取或写入字段值的方法。为了与此实现兼容,方法必须:

  • 使用类似于 void setBar(Foo f) 的签名来定义字段设置器。 setter 通常会访问名为 bar 的字段,因为它在 Java bean 规范中是约定俗成的。在此上下文中,参数类型 Foo 必须是该字段类型的子类型。
  • 使用类似于 Foo getBar() 的签名来定义字段 getter。 setter 通常会访问名为 bar 的字段,因为它在 Java bean 规范中是约定俗成的。为此,方法的返回类型 Foo 必须是字段类型的超类型。

创建这样的实现很简单:只需调用 FieldAccessor.ofBeanProperty() 。但是,如果您不想从方法名称派生字段名称,您仍然可以使用 FieldAccessor.ofField(String) 显式指定字段名称。使用此方法,唯一的参数定义应访问的字段名称。
如果需要,这甚至允许您定义一个新字段(如果这样的字段尚不存在)。访问现有字段时,您可以通过调用 in 方法来指定定义字段的类型。在 Java 中,在层次结构的多个类中定义字段是合法的。在此过程中,类的字段被其子类中的字段定义所遮盖。如果没有字段类的显式位置,Byte Buddy 将通过从最具体的类开始遍历类层次结构来访问它遇到的第一个字段。

让我们看一下 FieldAccessor 的示例应用程序。对于这个例子,我们假设我们收到了一些我们想要在运行时子类化的 UserType 。为此,我们希望为每个由接口表示的实例注册一个 Interceptor 。这样我们就可以根据自己的实际需求提供不同的实现。然后,应该可以通过调用相应实例上的 InterceptionAccessor 接口的方法来交换后一个实现。为了创建此动态类型的实例,我们不想使用反射,而是调用充当对象工厂的 InstanceCreator 的方法。以下类型类似于此设置:

  1. class UserType {
  2. public String doSomething() { return null; }
  3. }
  4.  
  5. interface Interceptor {
  6. String doSomethingElse();
  7. }
  8.  
  9. interface InterceptionAccessor {
  10. Interceptor getInterceptor();
  11. void setInterceptor(Interceptor interceptor);
  12. }
  13.  
  14. interface InstanceCreator {
  15. Object makeInstance();
  16. }

我们已经学习了如何使用 MethodDelegation 拦截类的方法。使用后一种实现,我们可以定义对实例字段的委托,并将该字段命名为 interceptor 。此外,我们正在实现 InterceptionAccessor 接口并拦截该接口的所有方法来实现该字段的访问器。通过定义 bean 属性访问器,我们实现了 getInterceptor 的 getter 和 setInterceptor 的 setter:

  1. Class<? extends UserType> dynamicUserType = new ByteBuddy()
  2. .subclass(UserType.class)
  3. .method(not(isDeclaredBy(Object.class)))
  4. .intercept(MethodDelegation.toField("interceptor"))
  5. .defineField("interceptor", Interceptor.class, Visibility.PRIVATE)
  6. .implement(InterceptionAccessor.class).intercept(FieldAccessor.ofBeanProperty())
  7. .make()
  8. .load(getClass().getClassLoader())
  9. .getLoaded();

通过新的 dynamicUserType ,我们可以实现 InstanceCreator 接口来成为这种动态类型的工厂。同样,我们使用已知的 MethodDelegation 来调用动态类型的默认构造函数:

  1. InstanceCreator factory = new ByteBuddy()
  2. .subclass(InstanceCreator.class)
  3. .method(not(isDeclaredBy(Object.class)))
  4. .intercept(MethodDelegation.construct(dynamicUserType))
  5. .make()
  6. .load(dynamicUserType.getClassLoader())
  7. .getLoaded().newInstance();

请注意,我们需要使用 dynamicUserType 的类加载器来加载工厂。否则,该类型在加载时对工厂来说是不可见的。

有了这两种动态类型,我们最终可以创建动态增强的 UserType 的新实例,并为其实例定义自定义 Interceptor 。让我们通过将一些 HelloWorldInterceptor 应用于新创建的实例来结束此示例。请注意,由于字段访问器接口和工厂,我们现在如何能够在不使用任何反射的情况下做到这一点。

  1. class HelloWorldInterceptor implements Interceptor {
  2. @Override
  3. public String doSomethingElse() {
  4. return "Hello World!";
  5. }
  6. }
  7.  
  8. UserType userType = (UserType) factory.makeInstance();
  9. ((InterceptionAccessor) userType).setInterceptor(new HelloWorldInterceptor());

 各种各样的

除了我们到目前为止讨论的 Implementation 之外,Byte Buddy 还包括其他几个实现:

  • StubMethod 实现一个方法来简单地返回该方法返回类型的默认值,而不需要任何进一步的操作。这样,可以静默地抑制方法调用。例如,这种方法可用于实现模拟类型。
    任何基元类型的默认值分别为零或零字符。返回引用类型的方法默认返回 null 。
  • ExceptionMethod 可用于实现仅抛出异常的方法。如前所述,即使方法没有声明此异常,也可能从任何方法引发检查异常。
  • Forwarding 实现允许简单地将方法调用转发到与拦截方法的声明类型相同类型的另一个实例。使用 MethodDelegation 可以获得相同的结果。然而,通过 Forwarding 应用了更简单的委托模型,它可以覆盖不需要目标方法发现的用例。
  • InvocationHandlerAdapter 允许使用 Java 类库附带的代理类中的现有 InvocationHandler 。
  • InvokeDynamic 实现允许使用可从 Java 7 开始访问的引导方法在运行时动态绑定方法。

 注释

我们刚刚了解了 Byte Buddy 如何依赖注释来提供其某些功能。 Byte Buddy 并不是迄今为止唯一一个具有基于注释的 API 的 Java 应用程序。
为了将动态创建的类型与此类应用程序集成,Byte Buddy 允许为其创建的类型及其成员定义注释。
在研究为动态创建的类型分配注释的详细信息之前,让我们看一个注释运行时类的示例:

  1. @Retention(RetentionPolicy.RUNTIME)
  2. @interface RuntimeDefinition { }
  3.  
  4. class RuntimeDefinitionImpl implements RuntimeDefinition {
  5. @Override
  6. public Class<? extends Annotation> annotationType() {
  7. return RuntimeDefinition.class;
  8. }
  9. }
  10.  
  11. new ByteBuddy()
  12. .subclass(Object.class)
  13. .annotateType(new RuntimeDefinitionImpl())
  14. .make();

正如 Java 的 @interface 关键字所暗示的那样,注释在内部表示为接口类型。因此,注释可以像普通接口一样由 Java 类实现。与实现接口的唯一区别是注释的隐式 annotationType 方法,该方法确定类表示的注释类型。后一种方法通常返回已实现的注释类型的类文字。除此之外,任何注释属性都像接口方法一样实现。
但是,请注意,注释方法的实现需要重复注释的默认值。

当一个类应充当另一个类的子类代理时,为动态创建的类定义注释尤其重要。子类代理通常用于实现横切关注点,其中子类应尽可能透明地模仿原始类。但是,只要通过将注释定义为 @Inherited 来明确要求此行为,则不会为其子类保留类上的注释。使用 Byte Buddy,通过调用 Byte Buddy 的域特定语言的 attribute 方法,可以轻松创建保留其基类注释的子类代理。此方法需要 TypeAttributeAppender 作为其参数。类型属性附加器提供了一种灵活的方法,用于根据基类定义动态创建的类的注释。例如,通过传递 TypeAttributeAppender.ForSuperType ,类的注释将被复制到其动态创建的子类中。请注意,注释和类型属性附加程序是附加的,并且对于任何类,不得多次定义注释类型。

方法和字段注释的定义与我们刚刚讨论的类型注释类似。方法注释可以定义为用于实现方法的 Byte Buddy 领域特定语言的结论性语句。
同样,可以在定义字段后对其进行注释。再次让我们看一个例子:

  1. new ByteBuddy()
  2. .subclass(Object.class)
  3. .annotateType(new RuntimeDefinitionImpl())
  4. .method(named("toString"))
  5. .intercept(SuperMethodCall.INSTANCE)
  6. .annotateMethod(new RuntimeDefinitionImpl())
  7. .defineField("foo", Object.class)
  8. .annotateField(new RuntimeDefinitionImpl())

上面的代码示例重写了 toString 方法,并用 RuntimeDefinition 注释了重写的方法。此外,创建的类型定义了一个带有相同注释的字段 foo ,并且还在创建的类型本身上定义了后一个注释。

默认情况下, ByteBuddy 配置不会为动态创建的类型或类型成员预定义任何注释。但是,可以通过提供默认的 TypeAttributeAppender 、 MethodAttributeAppender 或 FieldAttributeAppender 来更改此行为。请注意,此类默认附加程序不是附加的,而是替换其以前的值。

有时,在定义类时不加载注释类型或其任何属性的类型是可取的。为此,可以使用 AnnotationDescription.Builder ,它提供了一个流畅的接口来定义注释,而不触发类加载,但以类型安全为代价。然而,所有注释属性都是在运行时评估的。

默认情况下,Byte Buddy 将注释的任何属性包含到类文件中,包括由 default 值隐式指定的默认属性。不过,可以通过向 ByteBuddy 实例提供 AnnotationFilter 来自定义此行为。

 类型注释

Byte Buddy 公开并编写类型注释,因为它们是作为 Java 8 的一部分引入的。任何 TypeDescription.Generic 实例都可以将类型注释作为声明的注释进行访问。如果应将类型注释添加到泛型字段或方法的类型中,则可以使用 TypeDescription.Generic.Builder 生成注释类型。

 属性附加器

Java 类文件可以包含任何自定义信息作为所谓的属性。可以使用 Byte Buddy 通过使用类型、字段或方法的 *AttributeAppender 来包含此类属性。然而,属性附加器也可用于基于拦截的类型、字段或方法提供的信息来定义方法。
例如,当重写子类中的方法时,可以复制被拦截方法的所有注释:

  1. class AnnotatedMethod {
  2. @SomeAnnotation
  3. void bar() { }
  4. }
  5. new ByteBuddy()
  6. .subclass(AnnotatedMethod.class)
  7. .method(named("bar"))
  8. .intercept(StubMethod.INSTANCE)
  9. .attribute(MethodAttributeAppender.ForInstrumentedMethod.INSTANCE)

上面的代码重写了 AnnotatedMethod 类的 bar 方法,但复制了被重写方法的所有注释,包括参数或类型的注释。

当类被重新定义或重新基化时,相同的规则可能不适用。默认情况下, ByteBuddy 配置为保留重新基址或重新定义方法的任何注释,即使该方法如上所述被拦截。但是,可以更改此行为,以便 Byte Buddy 通过将 AnnotationRetention 策略设置为 DISABLED 来丢弃任何预先存在的注释。

自定义方法实现

在前面的章节中,我们描述了 Byte Buddy 的标准 API。到目前为止所描述的功能都不需要 Java 字节码的知识或显式表达。但是,如果您需要创建自定义字节代码,则可以通过直接访问 ASM 的 API 来实现,ASM 是一个低级字节代码库,Byte Buddy 构建于其之上。但请注意,不同版本的 ASM 之间不兼容,因此您在发布代码时需要将 Byte Buddy 重新打包到您自己的命名空间中。
否则,当另一个依赖项需要基于不同版本 ASM 的不同版本的 Byte Buddy 时,您的应用程序可能会导致 Byte Buddy 的其他用途不兼容。
您可以在首页上找到有关维护对 Byte Buddy 的依赖关系的详细信息。

ASM 库附带了有关 Java 字节代码和库使用的优秀文档。因此,如果您想详细了解 Java 字节码和 ASM 的 API,我们建议您参考本文档。
相反,我们只是简单介绍一下 JVM 的执行模型以及 Byte Buddy 对 ASM API 的适配。

任何 Java 类文件都是由几个段构成的。核心部分大致可分为以下几类:

  • 基础数据:类文件引用类的名称及其超类的名称及其实现的接口。
    此外,类文件包含不同的元数据,例如类的 Java 版本号、其注释或编译器为创建类而处理的源文件的名称。
  • 常量池:类的常量池是由该类的成员或注释引用的值的集合。
    在这些值中,常量池存储例如由类源代码中的某些文字表达式创建的原始值和字符串。此外,常量池存储类中使用的所有类型和方法的名称。
  • 字段列表:Java 类文件包含该类中声明的所有字段的列表。除了字段的类型、名称和修饰符之外,类文件还存储每个字段的注释。
  • 方法列表:与字段列表类似,Java 类文件包含所有声明的方法的列表。除了字段之外,非抽象方法还通过描述方法主体的字节编码指令数组进行描述。
    这些指令代表所谓的 Java 字节码。

幸运的是,ASM 库在创建类时完全负责建立适用的常量池。
这样,唯一重要的元素仍然是方法实现的描述,它由执行指令数组表示,每个指令都编码为单个字节。这些指令由虚拟堆栈机在方法调用时处理。举一个简单的例子,让我们考虑一个计算并返回两个原始整数 10 和 50 之和的方法。该方法的 Java 字节代码如下所示:

  1. LDC 10 // stack contains 10
  2. LDC 50 // stack contains 10, 50
  3. IADD // stack contains 60
  4. IRETURN // stack is empty

上述 Java 字节码数组的助记符首先使用 LDC 指令将两个数字压入堆栈。请注意此执行顺序与 Java 源代码中表示的顺序有何不同,在 Java 源代码中,添加将被编写为中缀符号 10 + 50 。然而,后一个顺序不能由堆栈机处理,其中像 + 这样的任何指令只能访问当前在堆栈上找到的最高值。此加法由 IADD 表示,它消耗两个最高的堆栈值,它们都期望是原始整数。在此过程中,它将这两个值相加并将结果推回堆栈顶部。最后, IRETURN 语句消耗这个计算结果并从方法中返回它,留下一个空堆栈。

我们已经提到过,方法中引用的任何原始值都存储在类的常量池中。对于上述方法中引用的数字 50 和 10 也是如此。常量池中的任何值都会被分配一个长度为两个字节的索引。让我们假设数字 10 和 50 存储在索引 1 和 2 处。连同上述助记符的字节值,其中 LDC 为 0x12 , IADD 为 0x60 和 0xAC 对于 IRETURN ,我们现在知道如何将上述方法表达为原始字节指令:

  1. 12 00 01
  2. 12 00 02
  3. 60
  4. AC

对于已编译的类,可以在类文件中找到这个确切的字节序列。但是,此描述还不足以完全定义方法的实现。
为了加速 Java 应用程序的运行时执行,每个方法都需要通知 Java 虚拟机其执行堆栈所需的大小。
对于上面没有分支的方法,这很容易确定,因为我们已经看到堆栈上最多有两个值。然而,对于更复杂的方法,提供此信息很容易成为一项复杂的任务。
更糟糕的是,堆栈值的大小可能不同。 long 和 double 值都消耗两个槽,而任何其他值消耗一个槽。似乎这还不够,Java 虚拟机还需要有关方法体内所有局部变量大小的信息。
方法中的所有此类变量都存储在一个数组中,该数组还包括任何方法参数和非静态方法的 this 引用。同样, long 和 double 值占用此数组中的两个槽。

显然,跟踪所有这些信息会使 Java 字节代码的手动组装变得乏味且容易出错,这就是 Byte Buddy 提供简化抽象的原因。在 Byte Buddy 中,任何堆栈指令都包含在 StackManipulation 接口的实现中。堆栈操作的任何实现都结合了更改给定堆栈的指令和有关该指令的大小影响的信息。然后可以轻松地将任意数量的此类指令合并为通用指令。
为了演示这一点,让我们首先为 IADD 指令实现一个 StackManipulation :

  1. enum IntegerSum implements StackManipulation {
  2.  
  3. INSTANCE; // singleton
  4.  
  5. @Override
  6. public boolean isValid() {
  7. return true;
  8. }
  9.  
  10. @Override
  11. public Size apply(MethodVisitor methodVisitor,
  12. Implementation.Context implementationContext) {
  13. methodVisitor.visitInsn(Opcodes.IADD);
  14. return new Size(-1, 0);
  15. }
  16. }

从上面的 apply 方法中,我们了解到这个堆栈操作是通过调用 ASM 的方法访问器上的相关方法来执行 IADD 指令的。进一步地,该方法表示该指令将当前堆栈 Size 减少一个槽。创建的 Size 实例的第二个参数是 0 ,表示该指令不需要特定的最小堆栈大小来计算中间结果。此外,任何 StackManipulation 都可以表示无效。此行为可用于更复杂的堆栈操作,例如可能会破坏类型约束的对象分配。我们将在本节后面查看无效堆栈操作的示例。
最后,请注意,我们将堆栈操作描述为单例枚举。事实证明,使用这种不可变的堆栈操作功能描述对于 Byte Buddy 的内部实现来说是一种很好的做法,我们只能建议您遵循相同的方法。

通过将上面的 IntegerSum 与预定义的 IntegerConstant 和 MethodReturn 堆栈操作相结合,我们现在可以实现一个方法。在 Byte Buddy 中,方法实现包含在 ByteCodeAppender 中,我们实现如下:

  1. enum SumMethod implements ByteCodeAppender {
  2.  
  3. INSTANCE; // singleton
  4.  
  5. @Override
  6. public Size apply(MethodVisitor methodVisitor,
  7. Implementation.Context implementationContext,
  8. MethodDescription instrumentedMethod) {
  9. if (!instrumentedMethod.getReturnType().asErasure().represents(int.class)) {
  10. throw new IllegalArgumentException(instrumentedMethod + " must return int");
  11. }
  12. StackManipulation.Size operandStackSize = new StackManipulation.Compound(
  13. IntegerConstant.forValue(10),
  14. IntegerConstant.forValue(50),
  15. IntegerSum.INSTANCE,
  16. MethodReturn.INTEGER
  17. ).apply(methodVisitor, implementationContext);
  18. return new Size(operandStackSize.getMaximalSize(),
  19. instrumentedMethod.getStackSize());
  20. }
  21. }

同样,自定义 ByteCodeAppender 是作为单例枚举实现的。

在实现所需的方法之前,我们首先验证检测的方法是否确实返回一个原始整数。否则,创建的类将被 JVM 的验证器拒绝。然后我们将两个数字 10 和 50 加载到执行堆栈上,对这些值进行求和并返回计算结果。
通过使用复合堆栈操作包装所有这些指令,我们可以最终检索执行此堆栈操作链所需的聚合堆栈大小。最后,我们返回该方法的总体大小要求。
返回的 ByteCodeAppender.Size 的第一个参数反映了我们刚刚提到的 StackManipulation.Size 包含的执行堆栈所需的大小。此外,第二个参数反映了局部变量数组所需的大小,这里简单地类似于方法参数所需的大小和可能的 this 引用,因为我们没有定义自己的任何局部变量。

通过求和方法的实现,我们现在准备为此方法提供自定义 Implementation ,我们可以将其提供给 Byte Buddy 的域特定语言:

  1. enum SumImplementation implements Implementation {
  2.  
  3. INSTANCE; // singleton
  4.  
  5. @Override
  6. public InstrumentedType prepare(InstrumentedType instrumentedType) {
  7. return instrumentedType;
  8. }
  9.  
  10. @Override
  11. public ByteCodeAppender appender(Target implementationTarget) {
  12. return SumMethod.INSTANCE;
  13. }
  14. }

任何 Implementation 都会分两个阶段进行查询。首先,实现有机会通过在 prepare 方法中添加其他字段或方法来更改创建的类。此外,准备工作允许实现注册我们在上一节中了解的 TypeInitializer 。如果不需要此类准备,则只需返回作为参数提供的未更改的 InstrumentedType 即可。请注意, Implementation 通常不应返回检测类型的单个实例,而应调用检测类型的附加方法,这些方法均以 with 为前缀。为特定类创建准备好任何 Implementation 后,将调用 appender 方法来检索 ByteCodeAppender 。然后,查询此附加程序以查找给定实现选择拦截的任何方法,以及在实现调用 prepare 方法期间注册的任何方法。

请注意,Byte Buddy 在任何类的创建过程中仅调用每个 Implementation 的 prepare 和 appender 方法一次。无论一个实现被注册多少次以用于类的创建,这都是可以保证的。这样, Implementation 可以避免验证字段或方法是否已定义。在此过程中,Byte Buddy 通过 hashCode 和 equals 方法比较 Implementation 的实例。一般来说,Byte Buddy 使用的任何类都应该提供这些方法的有意义的实现。事实上,枚举根据定义附带了这样的实现,这是使用它们的另一个很好的理由。

有了这一切,让我们看看 SumImplementation 的实际效果:

  1. abstract class SumExample {
  2. public abstract int calculate();
  3. }
  4.  
  5. new ByteBuddy()
  6. .subclass(SumExample.class)
  7. .method(named("calculate"))
  8. .intercept(SumImplementation.INSTANCE)
  9. .make()

恭喜!您刚刚扩展了 Byte Buddy 来实现计算并返回 10 和 50 之和的自定义方法。当然,这个示例实现并没有多大实际用处。然而,可以在此基础设施之上轻松实现更复杂的实现。毕竟,如果您觉得自己创建了一些方便的东西,请考虑贡献您的实现。我们期待您的来信!

在我们继续定制 Byte Buddy 的其他一些组件之前,我们应该简要讨论一下跳转指令的使用以及所谓的 Java 堆栈帧的问题。从 Java 6 开始,任何用于实现例如 if 或 while 语句的跳转指令都需要一些附加信息,以加速 JVM 的验证过程。该附加信息称为堆栈映射帧。堆栈映射帧包含有关在跳转指令的任何目标处的执行堆栈上找到的所有值的信息。通过提供此信息,JVM 验证程序节省了一些工作,但现在这些工作留给了我们。
对于更复杂的跳转指令,提供正确的堆栈映射帧是一项相当困难的任务,并且许多代码生成框架在始终创建正确的堆栈映射帧方面存在相当大的麻烦。那么我们该如何处理这个问题呢?
事实上,我们根本不知道。 Byte Buddy 的理念是,代码生成只能用作编译时未知的类型层次结构与需要注入这些类型的自定义代码之间的粘合剂。
因此,生成的实际代码应尽可能受到限制。
只要有可能,条件语句就应该用您选择的 JVM 语言来实现和编译,然后通过使用简约的实现将其绑定到给定的方法。
这种方法的一个很好的副作用是 Byte Buddy 的用户可以使用普通的 Java 代码并使用他们习惯的工具,例如调试器或 IDE 代码导航器。对于没有源代码表示的生成代码,这一切都是不可能的。
但是,如果您确实需要使用跳转指令创建字节代码,请确保使用 ASM 添加正确的堆栈映射帧,因为 Byte Buddy 不会自动为您包含它们。

创建自定义分配者

在上一节中,我们讨论了 Byte Buddy 的内置 Implementation 依赖 Assigner 来为变量赋值。在此过程中, Assigner 能够通过发出适当的 StackManipulation 将一个值转换为另一个值。为此,Byte Buddy 的内置分配器提供了原始值及其包装类型的自动装箱等功能。在最简单的情况下,可以将值按原样分配给变量。然而,在某些情况下,分配可能根本不可能通过从分配者返回无效的 StackManipulation 来表达。 Byte Buddy 的 IllegalStackManipulation 类提供了无效赋值的规范实现。

为了演示自定义分配器的使用,我们现在将实现一个 Assigner ,它仅通过调用 toString 方法将值分配给 String 类型的变量它收到的任何值:

  1. enum ToStringAssigner implements Assigner {
  2.  
  3. INSTANCE; // singleton
  4.  
  5. @Override
  6. public StackManipulation assign(TypeDescription.Generic source,
  7. TypeDescription.Generic target,
  8. Assigner.Typing typing) {
  9. if (!source.isPrimitive() && target.represents(String.class)) {
  10. MethodDescription toStringMethod = new TypeDescription.ForLoadedType(Object.class)
  11. .getDeclaredMethods()
  12. .filter(named("toString"))
  13. .getOnly();
  14. return MethodInvocation.invoke(toStringMethod).virtual(sourceType);
  15. } else {
  16. return StackManipulation.Illegal.INSTANCE;
  17. }
  18. }
  19. }

上面的实现首先验证输入值不是原始类型并且目标变量类型是 String 类型。如果不满足这些条件, Assigner 会发出 IllegalStackManipulation 以使尝试的分配无效。否则,我们通过名称来识别 Object 类型的 toString 方法。然后,我们使用 Byte Buddy 的 MethodInvocation 创建一个 StackManipulation ,它在源类型上虚拟地调用此方法。最后,我们可以将此自定义 Assigner 与 Byte Buddy 的 FixedValue 实现集成,如下所示:

  1. new ByteBuddy()
  2. .subclass(Object.class)
  3. .method(named("toString"))
  4. .intercept(FixedValue.value(42)
  5. .withAssigner(new PrimitiveTypeAwareAssigner(ToStringAssigner.INSTANCE),
  6. Assigner.Typing.STATIC))
  7. .make()

当对上述类型的实例调用 toString 方法时,它将返回字符串值 42 。这只能通过使用我们的自定义分配器来实现,该分配器通过调用 toString 方法将 Integer 类型转换为 String 类型。请注意,我们还使用内置的 PrimitiveTypeAwareAssigner 包装了自定义分配器,该分配器在委托此包装的基元值的分配之前,将所提供的基元 int 自动装箱为其包装器类型给它的内部分配者。其他内置分配器是 VoidAwareAssigner 和 ReferenceTypeAwareAssigner 。始终记住为自定义分配器实现有意义的 hashCode 和 equals 方法,因为这些方法通常是从使用给定的 Implementation 中的对应方法调用的转让人。同样,通过将分配器实现为单例枚举,我们可以避免手动执行此操作。

创建自定义参数绑定器

我们在上一节中已经提到,可以扩展 MethodDelegation 实现来处理用户定义的注释。为此,我们需要提供一个自定义 ParameterBinder ,它知道如何处理给定的注释。举个例子,我们想要定义一个注释,目的是简单地将固定字符串注入到注释参数中。首先,我们定义这样一个 StringValue 注解:

  1. @Retention(RetentionPolicy.RUNTIME)
  2. @interface StringValue {
  3. String value();
  4. }

我们需要通过设置适当的 RuntimePolicy 来确保注释在运行时可见。否则,注释不会在运行时保留,并且 Byte Buddy 没有机会发现它。这样做,上面的 value 属性包含作为值分配给带注释的参数的字符串。

使用我们的自定义注释,我们需要创建一个相应的 ParameterBinder ,它能够创建一个 StackManipulation 来表达此参数的绑定。每次调用此参数绑定器时, MethodDelegation 在参数上发现其相应的注释。为我们的示例注释实现自定义参数绑定器非常简单:

  1. enum StringValueBinder
  2. implements TargetMethodAnnotationDrivenBinder.ParameterBinder<StringValue> {
  3.  
  4. INSTANCE; // singleton
  5.  
  6. @Override
  7. public Class<StringValue> getHandledType() {
  8. return StringValue.class;
  9. }
  10.  
  11. @Override
  12. public MethodDelegationBinder.ParameterBinding<?> bind(AnnotationDescription.Loaded<StringValue> annotation,
  13. MethodDescription source,
  14. ParameterDescription target,
  15. Implementation.Target implementationTarget,
  16. Assigner assigner,
  17. Assigner.Typing typing) {
  18. if (!target.getType().asErasure().represents(String.class)) {
  19. throw new IllegalStateException(target + " makes illegal use of @StringValue");
  20. }
  21. StackManipulation constant = new TextConstant(annotation.loadSilent().value());
  22. return new MethodDelegationBinder.ParameterBinding.Anonymous(constant);
  23. }
  24. }

最初,参数绑定器确保 target 参数实际上是 String 类型。如果不是这种情况,我们将抛出异常来通知注释的用户他非法放置了该注释。否则,我们只需创建一个 TextConstant 来表示将常量池字符串加载到执行堆栈上。然后将此 StackManipulation 包装为匿名 ParameterBinding ,最终从该方法返回。或者,您可以提供 Unique 或 Illegal 参数绑定。唯一的绑定由任何允许从 AmbiguityResolver 检索此绑定的对象来标识。在后面的步骤中,这样的解析器能够查找参数绑定是否使用某个唯一标识符注册,然后可以确定该绑定是否优于另一个成功绑定的方法。
通过非法绑定,可以指示 Byte Buddy 一对特定的 source 和 target 方法不兼容并且无法绑定在一起。

这已经是使用带有 MethodDelegation 实现的自定义注释所需的所有信息。收到 ParameterBinding 后,它会确保其值绑定到正确的参数,否则它将丢弃当前的 source 和 target 方法对,因为它们不可绑定。此外,它将允许 AmbiguityResolver 查找唯一的绑定。最后,让我们将这个自定义注释付诸实践:

  1. class ToStringInterceptor {
  2. public static String makeString(@StringValue("Hello!") String value) {
  3. return value;
  4. }
  5. }
  6.  
  7. new ByteBuddy()
  8. .subclass(Object.class)
  9. .method(named("toString"))
  10. .intercept(MethodDelegation.withDefaultConfiguration()
  11. .withBinders(StringValueBinder.INSTANCE)
  12. .to(ToStringInterceptor.class))
  13. .make()

请注意,通过将 StringValueBinder 指定为唯一的参数绑定器,我们替换了所有默认值。或者,我们可以将参数绑定器附加到那些已经注册的参数绑定器中。由于 ToStringInterceptor 中只有一个可能的目标方法,动态类的拦截 toString 方法将绑定到后一个方法的调用。当调用目标方法时,Byte Buddy 会将注释的字符串值分配为目标方法的唯一参数。

posted @ 2024-06-27 14:49  CharyGao  阅读(47)  评论(0编辑  收藏  举报