Java平台的一个经常被忽略的特性是在JVM的解释器或即时(JIT)编译器执行程序之前修改程序字节码的能力。虽然此功能由工具使用,例如进行对象关系映射的探查器和库,但应用程序开发人员很少使用它。这代表了未开发的潜力,因为在运行时生成代码允许轻松实现跨领域问题,如日志记录或安全性,有时以模拟或编写性能数据收集代理的形式更改第三方库的行为。

目前有三个主要的库用于生成字节码:

  • ASM
  • cglib
  • Javassist

这些库都是为编写和修改Java代码中的特定字节码指令而设计的。但是为了能够使用它们,您需要理解字节码是如何工作的,这与理解Java源代码是完全不同的。此外,这些库比Java代码更难使用和测试,因为Java编译器无法验证方法调用的参数顺序是否与其签名匹配,或者是否违反Java语言规范。最后,由于它们的年龄,这些库并不都支持新的Java特性,例如注释、泛型、默认方法和lambda。

以下示例说明了如何使用ASM库实现一个使用单个字符串参数调用另一个静态方法的方法:

methodVisitor.visitVarInsn(Opcodes.ALOAD, 0); 
methodVisitor.visitMethodInsn( 
   Opcodes.INVOKESTATIC 
   "com/instana/agent/Agent" 
   "record" 
   "(Ljava/lang/String;)V" 
)

cglib和Javassist差别不大。它们都需要使用字节码和签名的字符串表示,正如您所看到的,它们更像是汇编语言,而不是Java。

Byte Buddy是一个新的库,它采用不同的方法来解决这个问题。Byte Buddy的任务是让对Java指令知之甚少甚至一无所知的开发人员能够访问运行时代码生成。该库还旨在支持所有Java特性,并且不限于为接口生成动态实现,这是JDK内置代理实用程序中使用的方法。Byte Buddy API将所有字节码操作符抽象为普通的旧Java方法调用。但是,它保留了ASM库的后门,在其上实现了Byte Buddy。

注意:本文中的所有示例都使用Byte Buddy的0.6 API。

Hello World, Byte Buddy

下面来自Byte Buddy文档的HelloWorld示例,以简洁的方式展示了在运行时创建新类所需的一切。

清单1:

Class<? extends Object> clazz = new ByteBuddy()
   .subclass(Object.class) 
   .method(ElementMatchers.named("toString")) 
   .intercept(FixedValue.value("Hello World!")) 
   .make() 
   .load(getClass().getClassLoader(), 
      ClassLoadingStrategy.Default.WRAPPER) 
   .getLoaded(); 
assertThat(clazz.newInstance().toString(),
      is("Hello World!"));

Byte Buddy的所有API都是构建器风格的fluent API,支持函数式。首先,您要告诉Byte Buddy您想要子类化哪个类。在本例中,您只需对Object进行子类化,就可以对任何非final类进行子类化,Byte Buddy将确保泛型返回类型为Class<? extends SuperClass>。现在您有了子类的生成器,您可以告诉Byte Buddy拦截对名为toString的方法的调用,并返回一个固定值,而不是调用java.lang.Object已经定义的方法。

你可能想知道这里的截距这个词。通常,当您对某个对象进行子类化时,当您在子类中更改超类方法的实现时,通常会使用“重写”一词。Intercept是一个来自面向方面编程(AOP)的术语,它描述了一个更强大的概念,即在调用方法时“做什么”。

在声明完子类的行为之后,调用make来获得类的所谓卸载表示。此表示的行为类似于.class文件,事实上,它甚至支持存储类文件的函数。

最后,如清单1所示,使用类加载器加载该类,并获得对加载类的引用。在开始使用Byte Buddy时,用于执行此操作的ClassLoadingStrategy通常并不重要。但是,在某些情况下,您需要一个特定的类加载器来加载新类,以实现可见性或强制执行特定的加载顺序。

请注意,Byte Buddy生成的类与常规类无法区分。与其他库或代理不同,没有留下任何痕迹。生成的代码完全类似于Java编译器为实现这样一个子类而创建的代码。

元素匹配器和实现

使用Byte Buddy添加或更改类的行为时,最常见的任务是查找字段、构造函数和方法。为了简化这些任务,Byte Buddy附带了大量有用的预定义元素匹配器,如hasParameter()isAnnotatedWith(),它们检查方法签名。它还有一些方便的别名,如isEquals()isSetter(),它们使用常见的Java命名模式来匹配方法名。使用预定义的匹配器可以简洁地描述要拦截的方法,否则编写起来会非常冗长。此外,还可以实现自定义ElementMatcher以覆盖任何更复杂的用例。

此外,intercept()中还存在许多预定义的替换实现。两个示例是MethodCall,它可以使用参数调用不同的方法;Forwarding,它使用相同的参数在另一个对象上调用相同的方法。

MethodDelegation代表了一种更强大的拦截机制:当委托给方法时,您可以首先执行自定义代码,然后将调用委托给原始实现。此外,还可以使用@Origin注释动态访问原始调用站点的信息,如清单2所示。当委托给其他方法时,您还可以动态访问原始调用站点的信息,如下所示。

清单2:

public static class Agent {
   public static String record(@Origin Method m) {
      System.out.println(m + " called"); 
   } 
}

Class<?> clazz = new ByteBuddy()
   .subclass(Object.class) 
   .method(ElementMatchers.isConstructor()) 
   .intercept(MethodDelegation
      .to(Agent.class)
      .andThen(SuperMethodCall.INSTANCE)) 
   // & make instance;

MethodDelegation在多个拦截目标可用的情况下自动查找方法签名的最佳匹配。虽然查找功能强大并且可以自定义,但我建议保持查找简单易懂。调用该方法后,原始调用将继续,这要感谢第二个(SuperMethodCall.INSTANCE)。

目标方法可以接受几个带注释的参数。要访问原始方法的参数,可以使用@Argument(position)@AllParameters。要获取有关原始方法本身的信息,可以使用@Origin。该参数的类型可以是java.lang.reflect.Methodjava.lang.Class,甚至可以是java.lang.invoke.MethodHandle(后者,如果与java 7或更高版本一起使用)。这些参数提供了有关从何处调用该方法的信息,这可能有助于调试,甚至在同一方法是多个方法的拦截目标的情况下,也可能有助于采用不同的代码路径。

要从目标方法调用发起方法或其超级方法,Byte Buddy提供@DefaultCall@SuperCall参数。

Mocking

有时,您希望为可能在运行时发生的场景编写单元测试,但您无法为测试目的可靠地激发该场景(如果有的话)。例如,在清单3中,随机数生成器需要为您生成一个特定的结果来测试控制流。

清单3:

public class Lottery { 
   public boolean win() { 
      return random.nextInt(100) == 0; 
   } 
}

Random mockRandom = new ByteBuddy() 
   .subclass(Random.class) 
   .method(named("nextInt")) 
   .intercept(value(0)) 
   // & make instance;

Lottery lottery = new Lottery(mockRandom); 
assertTrue(lottery.win());

Byte Buddy提供了各种拦截器,因此编写模拟或间谍很容易。但是,对于一些以上的模拟,我建议切换到专用的模拟库。事实上,流行的mocking库Mockito的第2版目前正在重写为基于Byte-Buddy。

到目前为止,我已经使用subclass()创建了一个本质上是类固醇的子类。Byte Buddy还有另外两种操作模式:重基和重定义。这两个选项都会更改指定类的实现;在rebase维护现有代码的同时,“redefine”将覆盖它。然而,这些修改有一个限制:要更改已经加载的类,Byte Buddy需要作为Java Agent(稍后将对此进行详细介绍)。

对于单元测试或其他特殊情况下的使用,您可以确保Byte Buddy第一次加载类,您可以在加载期间更改实现。为此,Byte Buddy支持一个称为TypeDescription的概念,它表示处于卸载状态的Java类。您可以从(尚未加载的)类路径填充它们的池,并在加载它们之前修改类。例如,我可以修改清单3中的Lottery类,如清单4所示。

清单4:

TypePool pool = TypePool.Default.ofClassPath(); 
new ByteBuddy()
   .redefine(pool.describe("Lottery")
   .resolve(), ClassFileLocator.ForClassLoader.ofClassPath())
   .method(ElementMatchers.named("win")) 
   .intercept(FixedValue.value(true)) 
   // & make and load;

assertTrue(new Lottery().win());

注意:您不能使用lotket.class来描述这里的调用,因为这样会在Byte Buddy重写类之前加载该类。加载Java类后,通常不可能卸载该类。

基于Byte Buddy的AOP Agent

在下面的示例中,我创建了一个性能监视和日志代理。它将拦截对JAX-WS端点的调用,并打印调用所用的时间。这样的代理需要遵循Javadoc for java.lang.instrument中解释的约定。它使用-javaagent命令行参数启动,并在实际的main方法(因此,名称为premain)之前执行。通常代理为自己安装一个钩子,该钩子在常规程序加载类之前触发。这绕过了无法更改加载类的限制。代理是可堆叠的,您可以使用任意数量的代理。清单5显示了代理的代码。

清单5:

public class Agent {
   public static void premain(String args, Instrumentation inst) {
      new AgentBuilder.Default() 
         .rebase(isAnnotatedWith(Path.class)) 
         .transform((b, td) -> 
            b.method( 
               isAnnotatedWith(GET.class) 
               .or(isAnnotatedWith(POST.class))) 
            .intercept(to(Agent.class))) 
         .installOn(inst);
}

@RuntimeType public static Object profile(@Origin Method m,
   @SuperCall Callable<?> c) 
   throws Exception { 
      long start = System.nanoTime(); 
      try {
         return c.call(); 
      } finally {
         long end = System.nanoTime();
         System.out.println("Call to " + m + " took "
            + (end - start) +" ns"); 
      }
   }
}

在获得一个默认的AgentBuilder之后,我告诉它应该重新设置哪些类的基础。此示例将仅修改具有javax.ws.rs.Path注释的类。接下来,我告诉构建器如何转换这些类。在本例中,代理将拦截对GET或POST注释方法的调用,并委托给profile方法。要使其工作,需要使用installOn()将代理连接到检测中。

profile方法本身使用三个注释:RuntimeType,告诉Byte Buddy返回类型对象需要调整为它截获的方法使用的实际返回类型;Origin,获取对截获的实际方法的引用,用于打印其名称;和超级调用,以实际执行原始方法调用。与上一个示例不同,我需要自己执行超级调用,因为我希望能够在方法调用之前和之后执行代码,以便执行计时。

将Byte Buddy实现方法拦截的方式与默认Java InvocationHandler进行比较,您可以看到Byte Buddy方法更加优化,因为拦截将只传入所需的参数,而InvocationHandler必须实现以下接口:

Object invoke(Object proxy, Method method, Object[] args)

对于需要自动装箱的基本参数或返回类型,这一好处尤其明显。额外的RuntimeType注释使Byte Buddy将任何装箱减少到最小。尽管JVM主要优化简单的装箱,但对于复杂接口(如调用处理程序的接口)来说,情况并非总是如此。

使用不带-javaagent的Agent

使用Agent在运行时生成和修改代码是一种强大的技术;然而,强制使用-javaagent参数使其工作有时是不方便的。Byte Buddy附带了一个方便的特性,它使用Java Attach API,该API最初设计用于在运行时加载诊断工具。它将代理附加到当前运行的JVM。您需要额外的byte-buddy-agent.jar文件,其中包含实用程序类ByteBuddyAgent。这样,您就可以调用ByteBuddyAgent.installOnOpenJDK(),这与使用-javaagent启动JVM所做的相同。这种方法唯一的另一个区别是,您不调用installOn(inst),而是调用installOnByteBuddyAgent()

结论

尽管JDK和三个流行的第三方字节码操作库中存在动态代理,但Byte Buddy填补了一个重要的空白。它的fluentapi使用泛型,因此您不会忘记正在修改的实际类型,而使用其他方法很容易发生这种情况。Byte Buddy还提供了一组丰富的匹配器、转换器和实现,并通过lambdas实现了它们的使用,从而生成相对简洁易读的代码。

因此,不习惯于读取字节码和在低级别工作的开发人员完全可以理解Byte Buddy。在即将发布的版本0.7中,Byte Buddy将支持泛型类型周围的所有基础结构。这样,即使在运行时,Byte Buddy也可以轻松地与泛型类型和类型注释进行交互。作为一个编写大量字节码处理代码的人,我推荐并使用这个库。[Byte Buddy在2015年的JavaOne会议上获得了杜克选择奖。

原文地址:https://blogs.oracle.com/javamagazine/java-bytebuddy-bytecode