构建Java Agent,而不是使用框架

Java
annotations自从被引入到Java之后,一直扮演着整合各种API的作用,尤其是对大型应用框架而言。在这方面,Spring和Hibernate都是Java
annotation应用的好例子——仅仅需要增加几行简单的Java
annotation代码,就可以实现非常复杂的程序逻辑。尽管对这些API(的写法)存在一些争论,但是大多数程序员认为,只要使用得当,这种声明式编程在形式上还是很有表达能力的。不过,只有少量程序员基于Java
annotation来编写框架API,或者应用程序中间件。之所以造成这种现象很主要的一个原因是,程序员们认为Java
annotation会降低代码的可读性。在本文中,我就想告诉大家,实现这些基于annotation的API其实并不是完全无用的,只要使用恰当的工具,其实你也并不需要了解太多Java内部函数的知识。

通过使用Byte Buddy,便捷地创建Java Agent,bytebuddy

Java
agent是在另外一个Java应用(“目标”应用)启动之前要执行的Java程序,这样agent就有机会修改目标应用或者应用所运行的环境。在本文中,我们将会从基础内容开始,逐渐增强其功能,借助字节码操作工具Byte
Buddy,使其成为高级的agent实现。

在最基本的用例中,Java
agent会用来设置应用属性或者配置特定的环境状态,agent能够作为可重用和可插入的组件。如下的样例描述了这样的一个agent,它设置了一个系统属性,在实际的程序中就可以使用该属性了:

public class Agent {
  public static void premain(String arg) {
    System.setProperty("my-property", “foo”);
  }
}

如上面的代码所述,Java
agent的定义与其他的Java程序类似,只不过它使用premain方法替代main方法作为入口点。顾名思义,这个方法能够在目标应用的main方法之前执行。相对于其他的Java程序,编写agent并没有特定的规则。有一个很小的区别在于,Java
agent接受一个可选的参数,而不是包含零个或更多参数的数组。

如果要使用这个agent,必须要将agent类和资源打包到jar中,并且在jar的manifest中要将Agent-Class属性设置为包含premain方法的agent类。(agent必须要打包到jar文件中,它不能通过拆解的格式进行指定。)接下来,我们需要启动应用程序,并且在命令行中通过javaagent参数来引用jar文件的位置:

java -javaagent:myAgent.jar -jar myProgram.jar

我们还可以在位置路径上设置可选的agent参数。在下面的命令中会启动一个Java程序并且添加给定的agent,将值myOptions作为参数提供给premain方法:

java -javaagent:myAgent.jar=myOptions -jar myProgram.jar

通过重复使用javaagent命令,能够添加多个agent。

但是,Java agent的功能并不局限于修改应用程序环境的状态,Java
agent能够访问Java instrumentation
API,这样的话,agent就能修改目标应用程序的代码。Java虚拟机中这个鲜为人知的特性提供了一个强大的工具,有助于实现面向切面的编程。

如果要对Java程序进行这种修改,我们需要在agent的premain方法上添加类型为Instrumentation的第二个参数。Instrumentation参数可以用来执行一系列的任务,比如确定对象以字节为单位的精确大小以及通过注册ClassFileTransformers实际修改类的实现。ClassFileTransformers注册之后,当类加载器(class
loader)加载类的时候都会调用它。当它被调用时,在类文件所代表的类加载之前,类文件transformer有机会改变或完全替换这个类文件。按照这种方式,在类使用之前,我们能够增强或修改类的行为,如下面的样例所示:

public class Agent {
 public static void premain(String argument, Instrumentation inst) {
   inst.addTransformer(new ClassFileTransformer() {
     @Override
     public byte[] transform(
       ClassLoader loader,
       String className,
       Class<?> classBeingRedefined, // 如果类之前没有加载的话,值为null
       ProtectionDomain protectionDomain,
       byte[] classFileBuffer) {
       // 返回改变后的类文件。
     }
   });
 }
}

通过使用Instrumentation实例注册上述的ClassFileTransformer之后,每个类加载的时候,都会调用这个transformer。为了实现这一点,transformer会接受一个二进制和类加载器的引用,分别代表了类文件以及试图加载类的类加载器。

Java
agent也可以在Java应用的运行期注册,如果是在这种场景下,instrumentation
API允许重新定义已加载的类,这个特性被称之为“HotSwap”。不过,重新定义类仅限于替换方法体。在重新定义类的时候,不能新增或移除类成员,并且类型和签名也不能进行修改。当类第一次加载的时候,并没有这种限制,如果是在这样的场景下,那classBeingRedefined会被设置为null。


实现基于annotation的API时,很明显的一个问题就是:这些API在Java运行时是不会被JVM处理的。这样造成的结果就是,你没法给一个用
户annotation赋予一个具体的含义。例如:如果我们定义了一个@Log
annotation,然后我们期望在标注了@Log的地方,每调用一次就形成一条日志记录。

Java字节码与类文件格式

类文件代表了Java类编译之后的状态。类文件中会包含字节码,这些字节码代表了Java源码中最初的程序指令。Java字节码可以视为Java虚拟机的语言。实际上,JVM并不会将Java视为编程语言,它只能处理字节码。因为它采用二进制的表现形式,所以相对于程序的源码,它占用的空间更少。除此之外,将程序以字节码的形式进行表现能够更容易地编译Java以外的其他语言,如Scala或Clojure,从而让它们运行在JVM上。如果没有字节码作为中间语言的话,那么其他的程序在运行之前,可能还需要将其转换为Java源码。

但是,在代码处理的时候,这种抽象却带来了一定的成本。如果要将ClassFileTransformer应用到某个类上,那我们不能将该类按照Java源码的形式进行处理,甚至不能假设被转换的代码最初是由Java编写而成的。更糟糕的是,探查类成员或注解的反射API也是禁止使用的,这是因为类加载之前,我们无法访问这些API,而在转换进程完成之前,是无法进行加载的。

所幸的是,Java字节码相对来讲是一个比较简单的抽象形式,它包含了很少量的操作,稍微花点功夫我们就能大致将其掌握起来。Java虚拟机执行程序的时候,会以基于栈的方式来处理值。字节码指令一般会告知虚拟机,需要从操作数栈(operand
stack)上弹出值,执行一些操作,然后再将结果压到栈中。

让我们考虑一个简单的样例:将数字1和2进行相加操作。JVM首先会将这两个数字压到栈中,这是通过 iconst_1和iconst_2这两个字节指令实现的。iconst_1是个单字节的便捷运算符(operator),它会将数字1压到栈中。与之类似,iconst_2会将数字2压到栈中。然后,会执行iadd指令,它会将栈中最新的两个值弹出,将它们求和计算的结果重新压到栈中。在类文件中,每个指令并不是以其易于记忆的名称进行存储的,而是以一个字节的形式进行存储,这个字节能够唯一地标记特定的指令,这也是bytecode这个术语的来历。上文所述的字节码指令及其对操作数栈的影响,通过下面的图片进行了可视化。

澳门新葡亰3522平台游戏 1

对于人类用户来讲,会更喜欢源码而不是字节码,不过幸运的是Java社区创建了多个库,能够解析类文件并将紧凑的字节码暴露为具有名称的指令流。例如,流行的ASM库提供了一个简单的visitor
API,它能够将类文件剖析为成员和方法指令,其操作方式类似于阅读XML文件时的SAX解析器。如果使用ASM的话,那上述样例中的字节码可以按照如下的代码来进行实现(在这里,ASM方式的指令是visitIns,能够提供修正的方法实现):

MethodVisitor methodVisitor = ...
methodVisitor.visitIns(Opcodes.ICONST_1);
methodVisitor.visitIns(Opcodes.ICONST_2);
methodVisitor.visitIns(Opcodes.IADD);

澳门新葡亰3522平台游戏,需要注意的是,字节码规范只不过是一种比喻的说法(metaphor),因为Java虚拟机允许将程序转换为优化后的机器码(machine
code),只要程序的输出能够保证是正确的即可。因为字节码的简洁性,所以在已有的类中取代和修改指令是很简单直接的。因此,使用ASM及其底层的Java字节码基础就足以实现类转换的Java
agent,这需要注册一个ClassFileTransformer,它会使用这个库来处理其参数。

class Service {
  @Log
  void doSomething() { 
    // do something ...
  }
}

克服字节码的不足

对于实际的应用来讲,解析原始的类文件依然意味着有很多的手动工作。Java程序员通常感兴趣的是类型层级结构中的类。例如,某个Java
agent可能需要修改所有实现给定接口的类。如果要确定某个类的超类,那只靠解析ClassFileTransformer所给定的类文件就不够了,类文件中只包含了直接超类和接口的名字。为了解析可能的超类型关联关系,程序员依然需要定位这些类型的类文件。

在项目中直接使用ASM的另外一个困难在于,团队中需要有开发人员学习Java字节码的基础知识。在实践中,这往往会导致很多的开发人员不敢再去修改字节码操作相关的代码。如果这样的话,实现Java
agent很容易为项目的长期维护带来风险。

为了克服这些问题,我们最好使用较高层级的抽象来实现Java
agent,而不是直接操作Java字节码。Byte Buddy是开源的、基于Apache
2.0许可证的库,它致力于解决字节码操作和instrumentation API的复杂性。Byte
Buddy所声称的目标是将显式的字节码操作隐藏在一个类型安全的领域特定语言背后。通过使用Byte
Buddy,任何熟悉Java编程语言的人都有望非常容易地进行字节码操作。

单靠@Log
标注本身写在哪里,是不可能完成执行程序逻辑的任务的,这就需要标注的使用者去发起生成日志的任务。明显,这种工作原理让annotation看上去毫无
意义,因为再调用doSomething方法的时候,我们根本无法去观察生成的log里面相应的状态。因此,annotation仅仅是作为一个标记而存
在,对程序逻辑来说毫无贡献可言。

Byte Buddy简介

Byte Buddy的目的并不仅仅是为了生成Java
agent。它提供了一个API用于生成任意的Java类,基于这个生成类的API,Byte
Buddy提供了额外的API来生成Java agent。

作为Byte
Buddy的简介,如下的样例展现了如何生成一个简单的类,这个类是Object的子类,并且重写了toString方法,用来返回“Hello
World!”。与原始的ASM类似,“intercept”会告诉Byte
Buddy为拦截到的指令提供方法实现:

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

从上面的代码中,我们可以看到Byte
Buddy要实现一个方法分为两步。首先,编程人员需要指定一个ElementMatcher,它负责识别一个或多个需要实现的方法。Byte
Buddy提供了功能丰富的预定义拦截器(interceptor),它们暴露在ElementMatchers类中。在上述的例子中,toString方法完全精确匹配了名称,但是,我们也可以匹配更为复杂的代码结构,如类型或注解。

当Byte
Buddy生成类的时候,它会分析所生成类型的类层级结构。在上述的例子中,Byte
Buddy能够确定所生成的类要继承其超类Object的名为toString的方法,指定的匹配器会要求Byte
Buddy重写该方法,这是通过随后的Implementation实例实现的,在我们的样例中,这个实例也就是FixedValue

当创建子类的时候,Byte
Buddy始终会拦截(intercept)一个匹配的方法,在生成的类中重写该方法。但是,我们在本文稍后将会看到Byte
Buddy还能够重新定义已有的类,而不必通过子类的方式来实现。在这种情况下,Byte
Buddy会将已有的代码替换为生成的代码,而将原有的代码复制到另外一个合成的(synthetic)方法中。

在我们上面的代码样例中,匹配的方法进行了重写,在实现里面,返回了固定的值“Hello
World!”。intercept方法接受Implementation类型的参数,Byte
Buddy自带了多个预先定义的实现,如上文所使用的FixedValue类。但是,如果需要的话,可以使用前文所述的ASM
API将某个方法实现为自定义的字节码,Byte Buddy本身也是基于ASM API实现的。

定义完类的属性之后,就能通过make方法来进行生成。在样例应用中,因为用户没有指定类名,所以生成的类会给定一个任意的名称。最终,生成的类将会使用ClassLoadingStrategy来进行加载。通过使用上述的默认WRAPPER策略,类将会使用一个新的类加载器进行加载,这个类加载器会使用环境类加载器作为父加载器。

类加载之后,使用Java反射API就可以访问它了。如果没有指定其他构造器的话,Byte
Buddy将会生成类似于父类的构造器,因此生成的类可以使用默认的构造器。这样,我们就可以检验生成的类重写了toString方法,如下面的代码所示:

assertThat(dynamicType.newInstance().toString(), 
           is("Hello World!"));

当然,这个生成的类并没有太大的用处。对于实际的应用来讲,大多数方法的返回值是在运行时计算的,这个计算过程要依赖于方法的参数和对象的状态。

填坑

为了克服上述的功能性局限,很多基于标注的框架都采用了子类覆盖类方法的模式,来赋予特定标注相关的程序逻辑功能。这种方法普遍使用了面向对象的集成机制。对于我们上面提到的@Log标注来说,子类实现机制会产生一个类似于下面的类LoggingService:

class LoggingService extends Service {
  @Override
  void doSomething() { 
    Logger.log("doSomething() was called");
    super.doSomething();
  }
}

当然,上面定义这些类的代码通常是不需要程序员手写的,而是在Java运行时,通过诸如
cglib或Javassst这样的库来自动生成。上面提到的两个库都提供了简易的API,可以用于生成增强型的子类程序。这种把类定义的过程放到运行时
的做法,其比较好的一个副作用是,在不特别规定程序规范,也不用修改已有的用户代码的前提下,能够有效实现
logging框架的功能。这样就可以避免“显式创建风格”,也就不用新建一个Java源文件去手写代码了。

通过委托实现Instrumentation

要实现某个方法,有一种更为灵活的方式,那就是使用Byte
Buddy的MethodDelegation。通过使用方法委托,在生成重写的实现时,我们就有可能调用给定类和实例的其他方法。按照这种方式,我们可以使用如下的委托器(delegator)重新编写上述的样例:

class ToStringInterceptor {
  static String intercept() {
    return “Hello World!”;
  }
}

借助上面的POJO拦截器,我们就可以将之前的FixedValue实现替换为MethodDelegation.to(ToStringInterceptor.class):

Class<?> dynamicType = new ByteBuddy()
  .subclass(Object.class)
  .method(ElementMatchers.named("toString"))
  .intercept(MethodDelegation.to(ToStringInterceptor.class))
  .make()
  .load(getClass().getClassLoader(),          
        ClassLoadingStrategy.Default.WRAPPER)
  .getLoaded();

使用上述的委托器,Byte
Buddy会在to方法所给定的拦截目标中,确定最优的调用方法。就ToStringInterceptor.class来讲,选择过程只是非常简单地解析这个类型的唯一静态方法而已。在本例中,只会考虑一个静态方法,因为委托的目标中指定的是一个类。与之不同的是,我们还可以将其委托给某个类的实例,如果是这样的话,Byte
Buddy将会考虑所有的虚方法(virtual
method)。如果类或实例上有多个这样的方法,那么Byte
Buddy首先会排除掉所有与指定instrumentation不兼容的方法。在剩余的方法中,库将会选择最佳的匹配者,通常来讲这会是参数最多的方法。我们还可以显式地指定目标方法,这需要缩小合法方法的范围,将ElementMatcher传递到MethodDelegation中,就会进行方法的过滤。例如,通过添加如下的filter,Byte
Buddy只会将名为“intercept”的方法视为委托目标:

MethodDelegation.to(ToStringInterceptor.class)
                .filter(ElementMatchers.named(“intercept”))

执行上面的拦截之后,被拦截到的方法依然会打印出“Hello
World!”,但是这次的结果是动态计算的,这样的话,我们就可以在拦截器方法上设置断点,所生成的类每次调用toString时,都会触发拦截器的方法。

当我们为拦截器方法设置参数时,就能释放出MethodDelegation的全部威力。这里的参数通常是带有注解的,用来要求Byte
Buddy在调用拦截器方法时,注入某个特定的值。例如,通过使用@Origin注解,Byte
Buddy提供了添加instrument功能的方法的实例,将其作为Java反射API中类的实例:

class ContextualToStringInterceptor {
  static String intercept(@Origin Method m) {
    return “Hello World from ” + m.getName() + “!”;
  }
}

当拦截toString方法时,对instrument方法的调用将会返回“Hello world from
toString!”。

除了@Origin注解以外,Byte
Buddy提供了一组功能丰富的注解。例如,通过在类型为Callable的参数上使用@Super注解,Byte
Buddy会创建并注入一个代理实例,它能够调用被instrument方法的原始代码。如果对于特定的用户场景,所提供的注解不能满足需求或者不太适合的话,我们甚至能够注册自定义的注解,让这些注解注入用户特定的值。