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.Method
、java.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