ByteBuddy代码生成技术

简介

如官网所说Byte Buddy 是一个代码生成和操作库,用于在Java应用程序运行时创建和修改Java类,而无需编译器的帮助。除了Java类库附带的代码生成实用程序外,Byte Buddy还允许创建任意类,并且不限于实现用于创建运行时代理的接口。此外,Byte Buddy提供了一种方便的API,可以使用Java代理或在构建过程中手动更改类。Byte Buddy 相比其他字节码操作库有如下优势:

  • 无需理解字节码格式,即可操作,简单易行的 API 能很容易操作字节码。
  • 支持 Java 任何版本,库轻量,仅取决于Java字节代码解析器库ASM的访问者API,它本身不需要任何其他依赖项。
  • 比起JDK动态代理、cglib、Javassist,Byte Buddy在性能上具有优势,具体的性能测试数据可以查看官网

创建类

public static void main(String[] args) throws IllegalAccessException, InstantiationException {
    Class<?> dynamicType = new ByteBuddy()
            .subclass(Object.class)
            .method(ElementMatchers.named("toString"))
            .intercept(FixedValue.value("Hello World"))
            .make()
            .load(HelloByteBuddy.class.getClassLoader())
            .getLoaded();

    Object instance = dynamicType.newInstance();
    String toString = instance.toString();
    System.out.println(toString);
    System.out.println(instance.getClass().getCanonicalName());
}

Hello World
net.bytebuddy.renamed.java.lang.Object$ByteBuddy$4oGQtGr3

上面的例子中创建了一个新的类型(在输出中可以看到相应的类名),继承自Object类型,并覆写了它的toString方法,返回一个固定值,api的可读性很高

  • subclass指定了新创建的类的父类
  • method 指定了需要拦截的方法
  • intercept拦截了toString方法并返回固定的value,最后make方法产生字节码,由类加载器加载到java虚拟机中

方法拦截

上面的例子是拦截了toString方法到一个FixedValue实现,实际使用中可能会实现一些更复杂的场景。Byte Buddy提供了MethodDelegation方法,可以将源方法的调用委托给任意一个POJO对象

假设target对象的实现

public class GreetingInterceptor {
  public Object greet(Object argument) {
    return "Hello from " + argument;
  }
}
public static void main(String[] args) throws IllegalAccessException, InstantiationException {
        Class<? extends java.util.function.Function> dynamicType = new ByteBuddy()
                .subclass(java.util.function.Function.class)
                .method(ElementMatchers.named("apply"))
                .intercept(MethodDelegation.to(new GreetingInterceptor()))
                .make()
                .load(MethodDelegationTest.class.getClassLoader())
                .getLoaded();

        System.out.println((String) dynamicType.newInstance().apply("Byte Buddy"));
    }

    public static class GreetingInterceptor {
        public Object greet(Object argument) {
            return "Hello from " + argument;
        }
    }
Hello from Byte Buddy

将java.util.function.Function的apply方法代理到了GreetingInterceptor的greet方法上,这里代理的时候查找的greet方法是通过返回值和参数来确认的,并不依赖方法名一致,如果有两个返回值和参数一致的方法就会产生歧义,无法正确的代理。

拦截器还可以通过注解定义接收更多的参数,以下拦截方法会在拦截到一个Funcition:apply方法后,将原方法的参数以及原方法的Method对象传入intercept方法,在intercept中实现一些自定义的逻辑。在方法上@RuntimeType注解的作用是会通知ByteBuddy在最终会将返回值cast成被拦截的方法的返回值类型。

public class GeneralInterceptor {
  @RuntimeType
  public Object intercept(@AllArguments Object[] allArguments,
                          @Origin Method method) {
    // intercept any method of any signature
  }
}

其他注解:
@SuperCall 传入的是一个Callable类型,可以在被代理类之外调用原方法
@Argument(0) 方法调用的第一个参数,可以使用0-n标记
@This 表示调用方法的原始对象
@AllArguments 被AllArguments标注的参数需要是一个数组类型,并且原参数的类型都要能和数组的类型兼容,

原生支持的注解还有很多,ByteBuddy会根据注解给我们注入相应的参数,可以参阅官方文档了解更多可以使用的注解,同时还能支持自定义的注解形式。
并且这里值得注意的是,虽然在GeneralInterceptor类中使用了bytebuddy中的注解,但是在生成新的子类的时候这些注解都会被忽略,保持生成的代码并不依赖bytebuddy框架。
关于拦截方法的选择上,ByteBuddy不要求Source(被委托的类)和target类的方法名一致,而是通过最接近原则去选取最合适的方法,主要是针对方法参数类型,方法返回值类型,如果存在歧义会报错,也可以通过注解定义优先级。

Java agent

Bytebuddy不仅能通过api创建新的类,还能够修改现有类,在不修改源代码的情况下,做一些侵入,实现一些特定功能。通过java agent可以在main函数之前修改已经存在的类定义,以下的例子是对所有的以Timed结尾的方法实现打印方法执行耗时

代理方法

public class TimingInterceptor {
  @RuntimeType
  public static Object intercept(@Origin Method method, 
                                 @SuperCall Callable<?> callable) {
    long start = System.currentTimeMillis();
    try {
      return callable.call();
    } finally {
      System.out.println(method + " took " + (System.currentTimeMillis() - start));
    }
  }
}

定义premain方法

public class TimerAgent {
  public static void premain(String arguments, 
                             Instrumentation instrumentation) {
    new AgentBuilder.Default()
      .type(ElementMatchers.nameEndsWith("Timed"))
      .transform((builder, type, classLoader, module) -> 
          builder.method(ElementMatchers.any())
                 .intercept(MethodDelegation.to(TimingInterceptor.class))
      ).installOn(instrumentation);
  }
}

通过maven插件,指定premain的mainfest属性

<plugin>
	<groupId>org.apache.maven.plugins</groupId>
	<artifactId>maven-jar-plugin</artifactId>
	<configuration>
		<archive>
			<manifestEntries>
				<Premain-Class>com.aitozi.bytebuddy.TimerAgent</Premain-Class>
			</manifestEntries>
		</archive>
	</configuration>
</plugin>

在启动java进程时通过加上以下参数:-javaagent:timingagent.jar,这样在启动后所有的以Timed结尾的方法都被注入会打印相应的执行耗时。

重新加载类

除了通过agent实现启动前redefine class。利用jvm hotswap的特性,已经加载的类也可以被重新定义,通常这样可以很方便的编写测试,直接修改类的行为来模拟拦截情况

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

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"));    

使用场景

通过这种非常方便的字节码生成技术,可以做一些有意思的功能,比如以上例子中,不修改源码计算某些方法的耗时。我注意到这个框架主要是因为在blink中也使用了这个lib。
在blink中目前支持sql,datastream,和tableapi作业,作业的资源都是在平台上在执行计划上设置每个节点的资源。
对于sql作业的执行计划的生成其实是引擎代码的逻辑,可以直接拿到用户在平台设置的内存和cpu参数设置到每一个sql节点上,但是对于datastream作业由于streamgraph的生成过程是在用户代码的main函数中,需要侵入用户代码,这就有了byte buddy的用武之地。通过字节码修改技术可以在用户的main函数执行之前,拦截transformation以及StreamNode构造方法,在创建这些方法的地方注入用户在平台上设置的每个计算节点的资源值,达到通过平台设置用户作业资源的目的

参考

官方文档
官网的翻译
深入理解instrument
JVM源码分析之javaagent原理完全解读
https://juejin.im/post/5da2fd6a6fb9a04e23576dd4

posted @ 2021-12-19 17:42  Aitozi  阅读(964)  评论(0编辑  收藏  举报