Byte Buddy官方教程(二) — 类创建

此翻译已经合并到Byte Buddy官网,请去官网阅读最新版的文档。
https://bytebuddy.net/#/tutorial-cn

Byte Buddy创建的任何类都是从Byte Buddy的实例开始创建。仅仅需要通过调用new ByteBuddy()创建一个新实例,你就可以开始了。希望你正在使用的是开发环境,在开发环境中调用给定对象的方法时会有建议。这样,可以避免手动在Byte Buddy的Java文档中查找一个类的API,而是让IDE引导这个过程。正如前面所提到的,Byte Buddy提供了一种领域特定语言,旨在尽可能地便于人类阅读。因此,大多数情况下,你的IDE提示将为你指明正确的方向。话不多说,话不多说,让我们在程序运行时创建第一个类:

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

很明显,上面的代码示例创建了一个继承了Object的类。这个动态创建的类相当于一个只继承Object而没有显式的实现任何方法,字段或构造器的类。你可能已经注意到我们甚至没有给动态生成的类命名,命名在定义一个类时是必须的。当然,你可以轻松地显式命名你的类:

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

但是如果没有显式的命名会发生什么?Byte Buddy遵循约定优于配置的原则,并且提供我们发现的便利的默认值。至于类的名称,Byte Buddy的默认配置提供了一个命名策略,它可以根据动态类的超类名称随机生成一个名称。此外,定义的类名中的包和超类相同的话,直接父类的包私有方法对动态类就是可见的。例如,如果你子类化一个名为example.Foo的类,生成的类的名称就像example.Foo$$ByteBuddy$$1376491271,其中数字序列是随机的。这条规则的一个例外情况是:子类化的类型来自Object所在的包java.lang。Java的安全模型不允许自定义的类在这个命名空间。因此,在默认的命名策略中,这种类型以net.bytebuddy.renamed前缀命名。

这种默认的行为对你来说可能不方便。由于约定优于配置原则的原因,你总是可以根据需要改变这种默认行为,这就显示出Byte Buddy类的功能比较周全。通过创建一个new ByteBuddy()实例,你就创建了一个默认的配置。通过在这个配置上调用方法,你可以根据需要自定义配置。让我们试试这个:

DynamicType.Unloaded<?> dynamicType = new ByteBuddy()
  .with(new NamingStrategy.AbstractBase() {
    @Override
    public String subclass(TypeDescription superClass) {
        return "i.love.ByteBuddy." + superClass.getSimpleName();
    }
  })
  .subclass(Object.class)
  .make();

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

领域特定语言与不变性

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

由于上述不变性的影响,当你设置ByteBuddy实例时,一定要谨慎。你可能会犯如下的错误:

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

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

重新定义已存在的类和对类变基

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

类型重定义

重新定义类时,Byte Buddy允许通过添加字段和方法或者替换已存在的方法实现来修改已存在的类。但是,如果方法的实现被另一个实现所替换,之前的实现就会丢失。例如,当重新定义下面的类型时:

class Foo {
  String bar() { return "bar"; }
}

从方法 bar返回 "qux" 的话,该方法原来返回的信息 "bar"就会丢失。

类型变基

当对类型变基时,Byte Buddy会保留所有被变基类的方法实现。Byte Buddy会用兼容的签名复制所有方法的实现为一个私有的重命名过的方法,而不像重定义时丢弃覆写的方法。用这种方式的话,不存在方法实现的丢失,而且变基的方法可以通过调用这些重命名的方法,继续调用原始的方法。这样,上面的Foo类可能会变基为这样:

class Foo {
  String bar() { return "foo" + bar$original(); }
  private String bar$original() { return "bar"; }
}

其中bar方法原来返回的"bar"保存在另一个方法中,因此仍然可以访问。当对一个类变基时,Byte Buddy会处理所有方法,就像你定义了一个子类一样。例如,如果你尝试调用变基的方法的超类方法实现,你将会调用变基的方法。但相反,它最终会扁平化这个假设的超类为上面显示的变基的类。

任何变基,重定义或者子类化都是用相同的API执行的,它是由DynamicType.Builder 接口定义的。这样的话,可以将一个类定义为子类,然后修改其定义,替换为变基类。这是通过仅更改一个Byte Buddy的领域特定语言的单词来实现的。这样,应用任何一种可能的方式会在在定义处理的进一步阶段中透明的处理,这将在该教程的其余部分讲解。

new ByteBuddy().subclass(Foo.class)
new ByteBuddy().redefine(Foo.class)
new ByteBuddy().rebase(Foo.class)

因为子类定义对于Java开发者来说是一个熟悉的概念,所以下面关于Byte Byddy领域特定语言的所有解释和实例都是通过创建类来演示的。但是,记住,所有类都可以类似地通过重定义和变基来定义。

类加载

目前为止,我们只是创建了一个动态类型,但是我们并没有使用它。Byte Buddy创建的类型是通过DynamicType.Unloaded的一个实例来表示的。通过名称可以猜到,这些类不会加载到JVM。相反,Byte Buddy创建的类以Java类文件格式的二进制结构表示。这样的话,你可以决定用生成的类来做什么。例如,你或许想从构建脚本运行Byte Buddy,该脚本仅在部署前生成类以增强Java应用。对于这个目的,DynamicType.Unloaded 类允许提取动态类型的字节数组。为了方便,该类型还额外提供了 saveIn(File) 方法,该方法允许你将一个类保存到给定的文件夹。此外,它允许你通过 inject(File) 方法将类注入到已存在的jar文件。

虽然直接访问一个类的二进制结构是直截了当的,但不幸的是加载一个类更复杂。在Java里,所有的类都用 ClassLoader加载。这种类加载器的一个示例是启动类加载器,它负责加载Java类库里的类。另一方面,系统类加载器负责加载Java应用程序类路径里的类。显然,这些预先存在的类加载器都不知道我们创建的任何动态类。为了解决这个问题,我们需要找其他的可能性用于加载运行时生成的类。Byte Buddy通过开箱即用的不同方法提供解决方案:

  • 我们仅仅创建一个新的ClassLoader,它被明确地告知存在一个特定的动态创建的类。因为Java类加载器是按层级组织的,我们定义的这个类加载器是程序里已经存在的类加载器的孩子。这样,程序里的所有类对于新类加载器加载的动态类型都是可见的。
  • 通常,Java类加载器在尝试直接加载给定名称的类之前会询问他的父亲。这意味着,在父类加载器知道有相同名称的类时,子类加载器通常不会加载类。为此,Byte Buddy提供了孩子优先创建的类加载器,它在询问父亲之前会尝试自己加载类。除此之外,这种方法类似于刚才上面提及的方法。注意,这种方法不会覆盖父类加载器加载的类,而是隐藏其他类型。
  • 最后,我们可以用反射将一个类注入到已存在的类加载器。通常,类加载器会被要求通过类名称来提供一个给定的类。用反射我们可以扭转这个规则,调用受保护的方法将一个新类注入类加载器,而类加载器实际上不知道如何定位这个动态类。

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

  • 如果我们创建一个新的类加载器,这个类加载器会定义一个新的命名空间。这样可能会通过两个不同的类加载器加载两个有相同名称的类。这两个类永远不会被JVM视为相等,即时这两个类是相同的类实现。这个相等规则也适用于Java包。这意味着,如果不是用相同的类加载器加载,example.Foo 类无法访问example.Bar类的包私有方法。此外,如果example.Bar 继承example.Foo,任何被覆写的包私有方法都将变为无效,但会委托给原始实现。
  • 每当加载一个类时,一旦引用另一种类型的代码段被解析,它的类加载器将查找该类中引用的所有类型。该查找会委托给同一个类加载器。想象一下这种场景:我们动态的创建了example.Fooexample.Bar两个类,如果我们将example.Foo注入一个已经存在的类加载器,这个类加载器可能会尝试定位查找example.Bar。然而,这个查找会失败,因为后一个类是动态创建的,而且对于刚才注入Foo类的类加载器来说是不可达的。因此反射的方法不能用于在类加载期间生效的带有循环依赖的类。幸运的是,大多数JVM的实现在第一次使用时都会延迟解析引用类,这就是类注入通常在没有这些限制的时候正常工作的原因。此外,实际上,由Byte Buddy创建的类通常不会受这样的循环影响。

你可能会任务遇到循环依赖的机会是无关紧要的,因为一次只创建一个动态类。然而,动态类型的创建可能会触发辅助类型的创建。这些类型由 Byte Buddy自动创建,以提供对正在创建的动态类型的访问。我们将在下面的章节学习辅助类型,现在不要担心这些。但是,正因为如此,我们推荐你尽可能通过创建一个特定的类加载器来加载动态类,而不是将他们注入到一个已存在的类加载器。

创建一个DynamicType.Unloaded后,这个类型可以用ClassLoadingStrategy加载。如果没有提供这个策略,Byte Buddy会基于提供的类加载器推测出一种策略,并且仅为启动类加载器创建一个新的类加载器,该类加载器不能用反射的方式注入任何类。否则为默认设置。Byte Buddy提供了几种开箱即用的类加载策略,每一种都遵循上述概念中的其中一个。这些策略都在ClassLoadingStrategy.Default中定义,其中,WRAPPER策略会创建一个新的,经过包装的类加载器,CHILD_FIRST策略会创建一个类似的具有孩子优先语义的类加载器,INJECTION策略会用反射注入一个动态类型。WRAPPERCHILD_FIRST策略也可以在所谓的清单版本中使用,即使在类加载后,也会保留类的二进制格式。这些可替代的版本使类加载器加载的类的二进制表示可以通过ClassLoader::getResourceAsStream方法访问。但是,请注意,这需要这些类加载器保留一个类的完整的二进制表示的引用,这会占用JVM堆上的空间。因此,如果你打算实际访问类的二进制格式,你应该只使用清单版本。由于INJECTION策略通过反射实现,而且不可能改变方法ClassLoader::getResourceAsStream的语义,因此它自然在清单版本中不可用。
让我们看一下这样的类加载:

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

在上面的示例中,我们创建并加载了一个类。像我们之前提到的,我们用WRAPPER加载策略加载类,它适用于大多数场景。最后,getLoaded方法返回了一个现在已经加载的Java类的实例,这个实例代表着动态类。

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

重新加载类

在上一节中,我们了解到Byte Buddy可以用来重新定义或变基一个已存在的类。然而,在执行Java程序时,通常无法保证给定的类没有加载。(此外,Byte Buddy目前仅仅将加载的类作为参数,这将在未来的版本中改变,现有的API同样可以处理未加载的类)由于Java虚拟机的HotSwap功能,即使在类被加载之后,他们也可以被重新定义。这个功能可以通过Byte Buddy的ClassReloadingStrategy使用。让我们通过重新定义Foo类来演示一下这个策略:

class Foo {
  String m() { return "foo"; }
}
 
class Bar {
  String m() { return "bar"; }
}

用Byte Buddy,我们现在可以容易地将Foo重新定义为Bar。用HotSwap,这个重定义甚至可以用于预先存在的示例。

ByteBuddyAgent.install();
Foo foo = new Foo();
new ByteBuddy()
  .redefine(Bar.class)
  .name(Foo.class.getName())
  .make()
  .load(Foo.class.getClassLoader(), ClassReloadingStrategy.fromInstalledAgent());
assertThat(foo.m(), is("bar"));

HotSwap只能用所谓的Java agent访问。这样的代理可以通过在虚拟机启动时使用-javaagent 参数指定它来安装,其中javaagent的参数得是Byte Buddy的代理jar,它可以从Maven仓库下载。然而,当Java应用从虚拟机的一个JDK运行时,即使应用启动后,Byte Buddy也可以通过ByteBuddyAgent.installOnOpenJDK()加载Java代理。因为类重定义主要用在工具和测试的实现上,这可能是一个非常方便的可选方案。从Java 9开始,在没有JDK的情况下,运行时安装代理成为可能。

上述示例中,可能首先出现违反直觉的一件事是,Byte Buddy重新定义Bar类,其中Foo类最终也被重定义了。JVM通过名称和类加载器识别类,因此,通过将Bar重命名为Foo且应用这个定义,我们最终重新定义了重命名的类型Bar。当然,同样可以直接重定义Foo,而不重命名不同的类型。

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

使用未加载的类

意识到HotSwap功能的局限性后,人们可能会认为变基重定义指令的唯一有意义的应用是在构建期间。通过应用构建时的处理,人们可以断言一个已经处理过的类在它的初始类简单地加载之前没有被加载,因为这个类加载是在不同的JVM实例中完成的。
然而,Byte Buddy同样有能力处理尚未加载的类。为此,Byte Buddy抽象了Java的反射API,例如,一个Class实例在内部由一个TypeDescription表示。事实上,Byte Buddy只知道如何处理由实现了TypeDescription接口的适配器提供的Class。这种抽象的最大好处是类的信息不需要由类加载器提供,而是可以由其他的源提供。

Byte Buddy使用TypePool(类型池),提供了一种标准的方式来获取类的TypeDescription(类描述)。当然,这个池的默认实现也提供了。TypePool.Default的实现解析类的二进制格式并将其表示为需要的TypeDescription。类似于类加载器为加载好的类维护一个缓存,该缓存也是可定制的。此外,它通常从类加载器中检索类的二进制格式,但不指示它加载此类。

JVM仅在第一次使用时加载一个类。因此,我们可以安全的重定义一个类,例如:

package foo;
class Bar { }

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

class MyApplication {
  public static void main(String[] args) {
    TypePool typePool = TypePool.Default.ofSystemLoader();
    Class bar = new ByteBuddy()
      .redefine(typePool.describe("foo.Bar").resolve(), // do not use 'Bar.class'
                ClassFileLocator.ForClassLoader.ofSystemLoader())
      .defineField("qux", String.class) // we learn more about defining fields later
      .make()
      .load(ClassLoader.getSystemClassLoader());
    assertThat(bar.getDeclaredField("qux"), notNullValue());
  }
}

通过第一次在断言中使用类之前显式地重定义类,我们先于JVM内置的类加载。这样,重新定义的类foo.Bar就被加载了,并且贯穿整个应用的运行时。然而,请注意,当我们用TypePool来提供类型描述时,我们不会通过一个类的字面量(literal)来引用该类。如果我们用了foo.bar的字面量,JVM将在我们有机会重定义该类之前加载它,我们的重定义尝试将无效。此外,请注意,当处理未加载的类时,我们还需要指定一个ClassFileLocator(类文件定位器),它允许定位类的类文件。在上面的示例中,我们简单地创建了一个类文件定位器,它扫描了正在运行的应用的类路径以查找foo.Bar这样的文件。

创建Java代理

当一个应用增长得更大,且变得更模块化时,在指定的程序位置应用这样的转换当然是一个繁琐的强制约束。而且,确实有一个更好的办法来按需要应用类的重定义。用Java代理,它可以拦截Java应用中进行的任何类加载活动。Java代理被实现为一个简单的带有入口点的jar文件,其入口点在jar文件的manifest(清单)文件中指定,像链接中描述的一样。在Byte Buddy中,通过使用AgentBuilder,代理的实现是相当直接的。假定我们之前定义了一个名为ToString的注解,通过实现代理的premain方法,对所有带该注解的类实现toString方法是很容易的。如下所示:

class ToStringAgent {
  public static void premain(String arguments, Instrumentation instrumentation) {
    new AgentBuilder.Default()
        .type(isAnnotatedWith(ToString.class))
        .transform(new AgentBuilder.Transformer() {
      @Override
      public DynamicType.Builder transform(DynamicType.Builder builder,
                                              TypeDescription typeDescription,
                                              ClassLoader classloader) {
        return builder.method(named("toString"))
                      .intercept(FixedValue.value("transformed"));
      }
    }).installOn(instrumentation);
  }
}

作为应用上述AgentBuilder.Transformer的结果,添加注解的类的所有toString方法将返回transformed。我们将在接下来的章节中学习DynamicType.Builder,所以现在不要担心这个类。上面的代码当然是一个微不足道且毫无意义的应用。然而,正确使用这个概念,对于实现面向切面编程会提供一个非常强大的工具。

请注意,在使用代理时也可以检测由启动类加载器加载的类。但是,这需要一些准备。首先,启动类加载器由null值表示,这会导致无法通过反射在该类加载器加载类。然而,有时需要加载辅助类到检测类的类加载器中以支持类的实现。为了向启动类加载器中加载类,Byte Buddy可以创建jar文件并且将这些文件添加到启动类的加载路径中。然而,为了使这个成为可能,需要将这些类保存到磁盘上。使用enableBootstrapInjection命令可以指定存放这些类的文件夹,该命令也采用Instrumentation接口以附加这些类。请注意,被检测类使用的所有用户类也需要放在启动类加载器类加载路径上,这样会使使用Instrumentation接口成为可能。

在Android应用中加载类

Android使用了不同的类文件格式,使用的是不在Java类文件格式布局中的dex文件。此外,通过接替Dalvik虚拟机的ART运行时,Android应用可以安装到设备之前编译成本地机器码。因此,只要应用没有显式地与Java源码部署在一起,Byte Buddy就不能对类进行重定义或变基,否则,就没有中间代码表示可以解释。然而,Byte Buddy仍然能够使用DexClassLoader和内置的dex编译器一起定义新类。为此,Byte Buddy提供了byte-buddy-android模块,它包含了允许加载在Android应用中动态创建的类的AndroidLoadingStrategy。为了运行,它需要一个用于写入临时文件和保存编译后的类文件的文件夹。该文件夹不能在不同应用之间共享,因为这在Android的安全管理器中是禁止的。

使用泛型类型

Byte Buddy在处理Java程序语言中定义的泛型类型。Java运行时不考虑泛型,只处理泛型的擦除。然而,泛型仍然会嵌入在任何Java文件中并且被反射API暴露。因此,有时在生成的类中包含泛型信息是有意义的,因为泛型信息能影响其他类库和框架的行为。当编译器将一个类作为类库进行处理和持久化时,嵌入的泛型信息也很重要。

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

当一个类型被创建时,Byte Buddy还会透明的插入桥接方法。桥接方法被MethodGraph.Compiler处理,它是ByteBuddy实例的一个属性。默认方法图编译器行为像Java编译器一样处理任何类文件的泛型信息。但是,对于Java以外的语言,不同的方法图编译器可能是合适的。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值