通过使用Byte Buddy便捷创建Java Agent
来源:脚本之家    时间:2022-03-05 12:14:26
目录
Java字节码与类文件格式克服字节码的不足ByteBuddy简介通过委托实现Instrumentation实现方法级别的安全性实现安全功能的Javaagent关于作者

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。

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_ 这个术语的来历。上文所述的字节码指令及其对操作数栈的影响,通过下面的图片进行了可视化。

对于人类用户来讲,会更喜欢源码而不是字节码,不过幸运的是 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);

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

克服字节码的不足

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

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

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

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

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

通过委托实现 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 方法的原始代码。如果对于特定的用户场景,所提供的注解不能满足需求或者不太适合的话,我们甚至能够注册自定义的注解,让这些注解注入用户特定的值。

实现方法级别的安全性

可以看到,我们在运行时可以借助简单的 Java 代码,使用 MethodDelegation 来动态重写某个方法。这只是一个简单的样例,但是这项技术可以用到更加实际的应用之中。在本文剩余的内容中,我们将会开发一个样例,它会使用代码生成技术实现一个注解驱动的库,用来限制方法级别的安全性。在我们的第一个迭代中,这个库会通过生成子类的方式来限制安全性。然后,我们将会采取相同的方式来实现 Java agent,完成相同的功能。

样例库会使用如下的注解,允许用户指定某个方法需要考虑安全因素:

@interface Secured {
  String user();
}

例如,假设应用需要使用如下的Service类来执行敏感操作,并且只有用户被认证为管理员才能执行该方法。这是通过为执行这个操作的方法声明 Secured 注解来指定的:

class Service {
  @Secured(user = “ADMIN”)
  void doSensitiveAction() {
    // 运行敏感代码...
  }
}

我们当然可以将安全检查直接编写到方法中。在实际中,硬编码横切关注点往往会导致复制 - 粘贴的逻辑,使其难以维护。另外,一旦应用需要涉及额外的需求时,如日志、收集调用指标或结果缓存,直接添加这样的代码扩展性不会很好。通过将这样的功能抽取到 agent 中,方法就能很纯粹地关注其业务逻辑,使得代码库能够更易于阅读、测试和维护。

为了让我们规划的库保持尽可能得简单,按照注解的协议声明,如果当前用户不具备注解的用户属性时,将会抛出IllegalStateException异常。通过使用 Byte Buddy,这种行为可以用一个简单的拦截器来实现,如下面样例中的SecurityInterceptor所示,它会通过其静态的 user 域,跟踪当前用户已经进行了登录:

class SecurityInterceptor {

  static String user = “ANONYMOUS”

  static void intercept(@Origin Method method) {
    if (!method.getAnnotation(Secured.class).user().equals(user)) {
      throw new IllegalStateException(“Wrong user”);
    }
  }
}

通过上面的代码,我们可以看到,即便给定用户授予了访问权限,拦截器也没有调用原始的方法。为了解决这个问题,Byte Buddy 有很多预定义的方法可以实现功能的链接。借助MethodDelegation类的andThen方法,上述的安全检查可以放到原始方法的调用之前,如下面的代码所示。如果用户没有进行认证的话,安全检查将会抛出异常并阻止后续的执行,因此原始方法将不会执行。

将这些功能集合在一起,我们就能生成Service的一个子类,所有带有注解方法的都能恰当地进行安全保护。因为所生成的类是 Service 的子类,所以它能够替代所有类型为Service的变量,并不需要任何的类型转换,如果没有恰当认证的话,调用doSensitiveAction方法就会抛出异常:

new ByteBuddy()
  .subclass(Service.class)
  .method(ElementMatchers.isAnnotatedBy(Secured.class))
  .intercept(MethodDelegation.to(SecurityInterceptor.class)
                             .andThen(SuperMethodCall.INSTANCE)))
  .make()
  .load(getClass().getClassLoader(),   
        ClassLoadingStrategy.Default.WRAPPER)
  .getLoaded()
  .newInstance()
  .doSensitiveAction();

不过坏消息是,因为实现 instrumentation 功能的子类是在运行时创建的,所以除了使用 Java 反射以外,没有其他办法创建这样的实例。因此,所有 instrumentation 类的实例都应该通过一个工厂来创建,这个工厂会封装创建 instrumentation 子类的复杂性。这样造成的结果就是,子类 instrumentation 通常会用于框架之中,这些框架本身就需要通过工厂来创建实例,例如,像依赖管理的框架 Spring 或对象 - 关系映射的框架 Hibernate,而对于其他类型的应用来讲,子类 instrumentation 实现起来通常过于复杂。

实现安全功能的 Java agent

通过使用 Java agent,上述安全框架的一个替代实现将会修改Service类的原始字节码,而不是重写它。这样做的话,我们就没有必要创建托管的实例了,只需简单地调用

new Service().doSensitiveAction()即可,如果对应的用户没有进行认证的话,就会抛出异常。为了支持这种方式,Byte Buddy 提供一种称之为 _rebase 某个类 _ 的理念。当 rebase 某个类的时候,不会创建子类,所采用的策略是实现 instrumentation 功能的代码将会合并到被 instrument 的类中,从而改变其行为。在添加 instrumentation 功能之后,在被 instrument 的类中,其所有方法的原始代码均可进行访问,因此像SuperMethodCall这样的 instrumentation,工作方式与创建子类是完全一样的。

创建子类与 rebase 的行为是非常类似的,所以两种操作的 API 执行方式是一致的,都会使用相同的DynamicType.Builder接口来描述某个类型。两种形式的 instrumentation 都可以通过ByteBuddy类来进行访问。为了使 Java agent 的定义更加便利,Byte Buddy 还提供了AgentBuilder类,它希望能够以一种简洁的方式应对一些通用的用户场景。为了定义 Java agent 实现方法级别的安全性,将如下的类定义为 agent 的入口点就足以完成该功能了:

class SecurityAgent {
  public static void premain(String arg, Instrumentation inst) {
    new AgentBuilder.Default()
    .type(ElementMatchers.any())
    .transform((builder, type) -> builder
    .method(ElementMatchers.isAnnotatedBy(Secured.class)
    .intercept(MethodDelegation.to(SecurityInterceptor.class)
               .andThen(SuperMethodCall.INSTANCE))))
    .installOn(inst);
  }
}

如果将这个 agent 打包为 jar 文件并在命令行中进行指定,那么所有带有Secured注解的方法将会进行“转换”或重定义,从而实现安全保护。如果不激活这个 Java agent 的话,应用在运行时就不包含额外的安全检查。当然,这意味着如果对带有注解的代码进行单元测试的话,这些方法的调用并不需要特殊的搭建过程来模拟安全上下文。Java 运行时会忽略掉无法在 classpath 中找到的注解类型,因此在运行带有注解的方法时,我们甚至完全可以在应用中移除掉安全库。

另外一项优势在于,Java agent 能够很容易地进行叠加。如果在命令行中指定多个 Java agent 的话,每个 agent 都有机会对类进行修改,其顺序就是在命令行中所指定的顺序。例如,我们可以采取这种方式将安全、日志以及监控框架联合在一起,而不需要在这些应用间增添任何形式的集成层。因此,使用 Java agent 实现横切的关注点提供了一种更为模块化的代码编写方式,而不必针对某个管理实例的中心框架来集成所有的代码。

_Byte Buddy 的源码可以免费地在GitHub上获取到。入门手册可以在http://bytebuddy.net上找到。Byte Buddy 当前的可用版本是 0.7.4,所有样例均是基于该版本的。因为其革新性以及对 Java 生态系统的贡献,该库曾经在 2015 年获得过 Oracle 的 Duke’s Choice 奖项。

关于作者

Rafael Winterhalter是一位软件咨询师,在挪威的奥斯陆工作。他是静态类型的支持者,对 JVM 有极大的热情,尤其关注于代码 instrumentation、并发和函数式编程。Rafael 日常会撰写关于软件开发的博客,经常出席相关的会议,并被认定为 JavaOne Rock Star。在工作以外的编码过程中,他为多个开源项目做出过贡献,经常会花精力在 Byte Buddy 上,这是一个为 Java 虚拟机简化运行时代码生成的库。因为他的贡献,Rafael 得到过 Duke’s Choice 奖项。

查看英文原文:Easily Create Java Agents with Byte Buddy

以上就是通过使用Byte Buddy便捷创建Java Agent的详细内容,更多关于Byte Buddy创建Java Agent的资料请关注脚本之家其它相关文章!

关键词: 安全检查 重新定义 抛出异常 另外一个

X 关闭

X 关闭