构建Java Agent

在应用程序中很多时候都不方便直接修改代码,java agent模式可以不用直接修改应用的代码就能够实现自己的功能。使用ByteBuddy可以让我们很容易构建自己的agent。事实上很多的开源Agent都是借助的ByteBuddy来实现的Agent。关于java agent我后续会写一些文章来进一步深入介绍相关内容,在此就不多赘述了。

处理泛型

Java的泛型会在运行时进行类型擦除。但是,由于泛型类型可能被嵌入到任何Java类文件中,并由 Java反射API对外暴露。所以将通用信息包含到生成的类中是有意义的。

由此种种,在子类化类、实现接口或声明字段或方法时,ByteBuddy接受Type的参数而不是擦除的Class。也可以使用 TypeDescription.Generic.Builder 明确定义泛型类型。

字段和方法

上述的章节讲述了类的创建与修改,接下来就要讲讲字段和方法的处理了。其实在上文中我们也已经举过了相关的例子了。我们引用了一个将类的方法替换成其他返回值的例子:

    new ByteBuddy()
            .subclass(Object.class)
            .method(ElementMatchers.named("toString"))
            .intercept(FixedValue.value("Hello World!"))
            .make()
            .saveIn(new File("result"));

现在我们仔细的审视上述的代码,在method方法中使用到了ElementMatchers.named方法,这个方法是ElementMatchers中定义的一系列方法的其中一种,这个类主要用于创建易于人类阅读的类和方法匹配机制。其中定义了大量的方法来助于定义类和方法。

例如:

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

上述代码就是描述的名称为toString,返回值为String且没有参数的方法

接下来来看一个复杂的案例:

class Foo {
  public String bar() { return null; }
  public String foo() { return null; }
  public String foo(Object o) { return null; }
}
 
Foo dynamicFoo = new ByteBuddy()
  .subclass(Foo.class)
  .method(isDeclaredBy(Foo.class)).intercept(FixedValue.value("One!"))
  .method(named("foo")).intercept(FixedValue.value("Two!"))
  .method(named("foo").and(takesArguments(1))).intercept(FixedValue.value("Three!"))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded()
  .newInstance();

在这个例子中定义了三个方法匹配,依据ByteBuddy的实现原则,上述的调用是基于堆栈的形式,因此在最后的.method(named("foo").and(takesArguments(1))).intercept(FixedValue.value("Three!"))反而会被最先匹配,当他未匹配成功时会以堆栈的顺序依次匹配。

如果不想覆盖方法,想要重新定义自己的方法,可以使用defineMethod,当然这也符合上述的堆栈的执行顺序。

深入了解FixedValue

在上文中我们已经有了一些使用FixedValue的例子了。顾名思义,FixedValue的作用是返回一个固定的提供的对象。

类会议如下两种方式记录这个对象:

  1. 固定值写入类的常量池。常量池主要记住类的属性,比如类名或者方法名。除了这些反射属性之外,常量池还有空间来存储在类的方法或字段中使用的任何字符串或原始值。除了字符串和原始值,类池还可以存储对其他类型的引用。
  2. 该值存储在类的静态字段中。所以一旦将类加载到Java虚拟机中,就必须为该字段分配给定值。

当你使用FixedValue.value(Object)时,ByteBuddy会分析参数的类型,并且存储下来(优先尝试第一种方法,不可行才会使用第二种方法)。但是请注意,如果值存储在类池中,则所选方法返回的实例可能具有不同的对象标识。这种时候就可以使用FixedValue.reference(Object)来始终将对象存储在静态字段中。

委托方法调用

在很多场景下使用FixedValue返回固定值显然是远远不够的。所以ByteBuddy提供了MethodDelegation来支持更加强大的和自由的方法定义。

看这个例子:

    class Source {
        public String hello(String name) { return null; }
    }
    
    class Target {
        public static String hello(String name) {
            return "Hello " + name + "!";
        }
    }
    
    String helloWorld = new ByteBuddy()
            .subclass(Source.class)
            .method(named("hello")).intercept(MethodDelegation.to(Target.class))
            .make()
            .load(ClassLoader.getSystemClassLoader())
            .getLoaded()
            .newInstance()
            .hello("World");

    System.out.println(helloWorld);

在这个例子里面我们把Sourcehello方法委托给了Target,因此程序输出了Hello World!而不是null

为了实现上述的效果MethodDelegation会找到Target的所有可以调用的方法并且进行最佳匹配。在上述方法中因为只有一个方法,因此匹配非常简单,那么遇到复杂的情况MethodDelegation会怎么进行匹配呢?我们看下一个例子:

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

这个例子中的Target有三个重载方法,我们用这个类来进行测试。经过测试,最后输出的结果是Hello World!,可能有人会疑惑为什么连方法名都完全不一样,这也能被委托吗?

这里就涉及到了ByteBuddy的实现了。ByteBuddy不要求目标方法和源方法同名,回看上述方法,显然最后绑定的是第一个intercept方法,这是为什么呢?首先,第二个方法入参为int显然无法匹配,但是第一和第三个方法应该如何选择,这就又涉及到了内部的实现问题。ByteBuddy模仿了java编译器绑定重载方法的实现方式,总是选择“最具体”的类型来进行绑定。而String显然比Object更为具体,因此绑定到了第一个intercept方法。

MethodDelegation可以配合注解@Argument一起使用,@Argument可以通过配置参数的位置(排在第n个)来进行参数的绑定。实际上如果你没有配置此注解,ByteBuddy也会按照注解绑定的方式来处理,例如:

void foo(Object o1, Object o2)

如果原始方法是这样的,那么ByteBuddy会进行如下的解析:

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

第一个参数和第二个参数会被分配到对应的拦截器,如果被拦截的方法少于两个参数,或者参数类型不能匹配,那么就舍弃拦截方法。

MethodDelegation还可以配合很多的注解来处理不同的场景:

  1. @AllArguments:此配置为数组类型,包含所有源方法的参数。为此,所有源方法参数都必须是可分配给数组的类型。如果不是此方法在匹配时会被舍弃。
  2. @This:这个注解可以用于获取当前实例
  3. @Origin:此注解用于获取方法的签名,例如:

    public static String intercept(@Origin String method) { return "Hello " + method + "!"; }

    这段代码会输出
    Hello public java.lang.String org.example.bytebuddy.test.Source.hello(java.lang.String)!

访问成员变量

使用FieldAccessor可以访问类成员变量,并且可以读写变量的值。

我们可以通过FieldAccessor.ofBeanProperty()来为类构建Java Bean规范的getset方法:

    new ByteBuddy()
            .subclass(Object.class, ConstructorStrategy.Default.NO_CONSTRUCTORS)
            .name("org.example.bytebuddy.FieldTest")
            .defineField("myField", String.class, Visibility.PRIVATE)
            .defineField("myTest", String.class, Visibility.PRIVATE)
            .defineMethod("getMyField", String.class)
            .intercept(FieldAccessor.ofBeanProperty())
            .make()
            .saveIn(new File("result"));

当然如果需要自行定义field的绑定名称,可以通过FieldAccessor.ofField来指定:

    new ByteBuddy()
            .subclass(Object.class, ConstructorStrategy.Default.NO_CONSTRUCTORS)
            .name("org.example.bytebuddy.FieldTest")
            .defineField("myField", String.class, Visibility.PRIVATE)
            .defineField("myTest", String.class, Visibility.PRIVATE)
            .defineMethod("getMyField", String.class)
            .intercept(FieldAccessor.ofField("myTest"))
            .make()
            .saveIn(new File("result"));

总结

ByteBuddy作为一种高性能的字节码组件有着较为广泛的使用。他的能力非常强大,此处只是介绍了他的部分能力,如果有需要的话可以前往byte-buddy了解更多信息。


骑牛上青山
1.2k 声望22 粉丝