使用Byte Buddy轻松实现Java Agent

本文介绍了如何使用ByteBuddy这个库来创建Java Agent,以实现对目标应用程序的字节码级别的修改,特别是在实现方法级安全方面。ByteBuddy提供了一个友好的API,帮助开发者克服直接操作字节码的复杂性,使得动态增强或修改类的行为变得更加简单。文章通过示例展示了如何使用ByteBuddy创建代理,以在运行时动态地添加安全检查,防止未经授权的访问。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Java agent是在另一个Java应用程序(“目标”应用程序)启动之前执行的Java程序,为该agent提供修改目标应用程序或其运行环境的机会。在本文中,我们将从基础知识开始,使用字节码操作工具Byte Buddy实现高级代理。

在最基本的用例中,Java agent设置应用程序属性或配置特定的环境状态,从而使代理能够充当可重用和可插入的组件。下面的示例描述了这样一个代理agent,它设置了可供实际程序使用的系统属性:

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

正如上面的代码所演示的,Java agent的定义与任何其他Java程序一样,只是 premain 将 main 方法替换为入口点。顾名思义,此方法在目标应用程序的主方法之前执行。除了适用于任何其他Java程序的标准规则之外,没有其他特定的编写agent代理的规则。作为最小的区别,Java agent接收单个可选参数,而不是零个或多个参数的数组。

要启动agent代理,必须将agent类和资源捆绑在一个jar中,并在jar manifest中将代理类属性设置为包含 premain 方法的代理类的名称(代理必须始终绑定为jar文件,不能以分解格式指定。)接下来,必须通过命令行上的 javaagent 参数引用jar文件的位置来启动应用程序:

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

还可以将可选代理参数前置到此位置路径。以下命令启动Java程序,并将提供值 myOptions 作为参数的给定代理附加到 premain 方法:

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

通过重复 javaagent 命令可以附加多个代理。

然而,Java agent的功能远不止改变应用程序环境的状态;Java代理可以被授予访问Java instrumentation API的权限,从而允许代理修改目标应用程序的代码。Java虚拟机的这一鲜为人知的特性提供了一个强大的工具,可以促进面向方面编程的实现。

关于Java Instrumentation可以参考这篇文章: https://javakk.com/2237.html

通过向agent代理的 premain 方法添加第二个Instrumentation类型的参数,可以应用Java程序的这种修改。Instrumentation参数可用于执行一系列任务,从确定对象的确切大小(以字节为单位),到通过注册 ClassFileTransformers 来实际修改类实现。注册后,任何类装入器在装入类时都会调用 ClassFileTransformer 。调用时,类文件转换器有机会在加载所表示的类之前转换甚至完全替换任何类文件。通过这种方式,可以在类投入使用之前增强或修改类的行为,如下例所示:

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 if class was not previously loaded
       ProtectionDomain protectionDomain,
       byte[] classFileBuffer) {
       // return transformed class file.
     }
   });
 }
}

在将上述 ClassFileTransformer 注册到一个 Instrumentation 实例之后,每次加载一个类时都会调用该转换器。为此,转换器 transformer 接收类文件的二进制表示形式和对试图加载此类的类加载器的引用。

Java agent也可以在Java应用程序运行时注册。在这种情况下,instrumentation API允许重新定义已加载的类,这一特性称为“热更新”。不幸的是,重新定义加载的类仅限于替换方法体。在重新定义类时,不能添加或删除任何成员,也不能更改任何类型或签名。第一次加载类时,此限制不适用,并且在这些情况下, classBeingRedefined 参数设置为null。

Java字节码和类文件格式

类文件表示处于编译状态的Java类。类文件包含最初编码为Java源代码的程序指令的字节码表示。Java字节码可以被认为是Java虚拟机的语言。事实上,JVM没有Java作为编程语言的概念,而是专门处理字节码。作为二进制表示的结果,字节码比程序源代码占用更少的空间。此外,将程序表示为字节码可以更容易地编译Java以外的语言,例如Scala或Clojure,以便在JVM上运行。如果没有字节码作为中间语言,在运行任何程序之前都必须将其翻译成Java源代码。

然而,在代码操作的上下文中,这种抽象是有代价的。在将 ClassFileTransformer 应用于Java类时,该类不能再作为Java源代码处理,即使假定转换后的代码最初是用Java编写的。更糟糕的是,在转换类时,用于内省类的成员或注释的反射API也是禁止的,因为访问API需要已经加载类,而这在转换过程完成之前是不会发生的。

幸运的是,Java字节码是一个相对简单的抽象,操作数量相对较少,并且通常可以轻松学习。Java虚拟机通过将值作为堆栈机进行处理来执行程序。字节码指令通常向虚拟机指示它应该从操作数堆栈中弹出值,执行一些操作,并将结果推回到堆栈上。

让我们考虑一个简单的例子:添加数字一和二。JVM首先通过执行字节码指令 iconst_1 和 iconst_2 将这两个数字推送到操作数堆栈上。 iconst_1 是一个单字节方便运算符,用于将数字1推送到堆栈上。类似地, iconst_2 将数字2推到堆栈上。随后,执行 iadd 指令将从堆栈中弹出两个最新的值,并向后推这些数字的总和。在类文件中,每条指令不是按其助记符名称存储的,而是作为唯一标识特定指令的单个字节存储的,因此称为字节码。下图显示了上述字节码指令及其对操作数堆栈的影响。

但幸运的是,对于人类来说,源代码比字节码更好,Java社区已经创建了几个库来解析类文件,并将压缩的字节码作为命名指令流公开。例如,流行的ASM库提供了一个简单的访问者API,该API将类文件分解为成员和方法指令,其操作方式与用于读取XML文件的SAX解析器类似。使用ASM,上述示例的字节码可以通过以下代码实现(其中 visitIns 指令是ASM提供修改后的方法实现的方式):

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

应该注意的是,字节码规范只是一个隐喻,Java虚拟机可以将程序翻译成优化的机器代码,只要程序的结果仍然正确。由于字节码的简单性,替换或修改现有类中的指令非常简单。因此,使用ASM和理解Java字节码的基本原理已经足以通过注册 ClassFileTransformer 来实现类转换Java代理,该类转换Java代理使用该库处理其参数。

克服字节码隐喻

对于实际应用程序,解析原始类文件仍然需要大量的手工工作。Java程序员通常对类型层次结构上下文中的类感兴趣。例如,可能需要Java代理来修改实现给定接口的任何类。要确定有关类的超类型的信息,解析 ClassFileTransformer 提供的类文件就不再足够了,因为 ClassFileTransformer 只包含直接超类型和接口的名称。为了解决潜在的超级类型关系,仍然需要程序员来定位这些类型的类文件。

另一个困难是,在项目中直接使用ASM需要团队中的任何开发人员学习Java字节码的基础知识。在实践中,这常常导致许多开发人员无法更改与字节码操作有关的任何代码。在这种情况下,实现Java代理会对项目的长期可维护性造成威胁。

为了克服这些问题,最好使用比直接操作Java字节码更高级别的抽象来实现Java agent。Byte Buddy是一个开源的、Apache 2.0许可的库,它解决了字节码操作和检测API的复杂性。Byte Buddy声明的目标是将显式字节码生成隐藏在类型安全的域特定语言后面。通过使用Byte Buddy,字节码操作有望成为熟悉Java编程语言的任何人的直观操作。

介绍Byte Buddy

Byte Buddy并不是专门用于生成Java agent的。它提供了一个用于生成任意Java类的API,在这个类生成API的基础上,Byte Buddy还提供了一个用于生成Java agent的附加API。

为了简单地介绍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提供了一组丰富的预定义拦截器,这些拦截器在 ElementMatchers 类中公开。在上面的例子中, toString 方法通过其确切名称进行匹配,但我们也可以匹配更复杂的代码结构,如类型或注释。

每当Byte Buddy生成一个类时,它都会分析所生成类型的类层次结构。在上面的示例中,Byte Buddy确定生成的类从其super class对象继承了一个名为 toString 的方法,在我们的示例中,指定的匹配器指示Byte Buddy通过后续实现实例 FixedValue 重写该方法。

创建子类时,Byte Buddy总是通过重写生成的类中的方法来截取匹配的方法。不过,我们将在本文后面看到,Byte Buddy还能够在不进行子类化的情况下重新定义现有类。在这种情况下,Byte Buddy用生成的代码替换现有方法,同时将原始代码复制到另一个合成方法中。

在上面的示例代码中,匹配的方法被一个返回固定值“ helloworld! ”的实现覆盖。 intercept 方法接受一个类型为 Implementation 的参数,并与几个预定义的实现(如选定的 FixedValue 类)一起提供字节。但是,如果需要,可以使用上面讨论的ASMAPI将方法实现为自定义字节码,除此之外,还可以实现Byte Buddy。

定义类的属性后,它由 make 方法生成。在示例应用程序中,生成的类被赋予一个随机名称,因为用户没有指定任何名称。最后,使用 ClassLoadingStrategy 加载生成的类。使用上面的默认包装策略,一个类由一个新的类加载器加载,该加载器将环境的类加载器作为父类。

加载类后,可以使用Java反射API访问该类。如果没有不同的指定,Byte Buddy将生成类似于超类的构造函数,以便生成的类可以使用默认构造函数。因此,可以验证生成的类是否重写了 toString 方法,如下代码所示:

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

当然,这个生成的类没有太多实际用途。对于实际应用程序,大多数方法的返回值是在运行时计算的,并且取决于方法参数和对象状态。

Instrumentation by delegation

实现方法的一种更灵活的方法是使用Byte Buddy的 MethodDelegation 。使用方法委托,可以生成将调用给定类或实例的另一个方法的重写实现。通过这种方式,可以使用以下委托器重写前面的示例:

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

使用上述POJO拦截器,可以使用 MethodDelegation.to(ToStringInterceptor.class) 替换以前的 FixedValue 实现:

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

使用此委托器 delegator ,Byte Buddy从提供给 to 方法的拦截目标中确定最佳可调用方法。在 ToStringInterceptor.class 的情况下,选择过程通常解析为该类型声明的唯一静态方法。在这种情况下,只考虑静态方法,因为类被指定为委托的目标。相反,可以委托给类的实例,在这种情况下,Byte Buddy会考虑所有虚拟方法。如果一个类或实例上有几个这样的方法可用,Byte Buddy首先会消除所有与特定检测不兼容的方法。在其余的方法中,库然后选择一个最佳匹配,通常是参数最多的方法。也可以显式地选择目标方法,方法是通过调用 filter 方法将 ElementMatcher 交给 MethodDelegation 来缩小合格方法的范围。例如,通过添加以下筛选器,Byte Buddy仅将名为“ intercept ”的方法视为委派目标:

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

拦截后,被拦截的方法仍然打印“ Hello World! ”但这一次,结果是动态计算的,例如,可以在拦截器方法中设置断点,该断点在每次从生成的类调用 toString 时都会触发。

当为拦截器方法指定参数时, MethodDelegation 的全部功能将被释放。在调用拦截器时,通常会对参数进行注释,以指示Byte Buddy注入值。例如,使用 @Origin 注释,Byte Buddy提供了插入指令的方法的实例,作为Java反射API提供的类的实例:

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

当拦截 toString 方法时,调用现在被检测为返回“ Hello world from toString! ”。

除了 @Origin 注释外,Byte-Buddy还提供了一组丰富的注释。例如,在Callable类型的参数上使用 @Super 注释,Byte Buddy创建并注入一个代理实例,该实例允许调用插入指令的方法的原始代码。如果提供的注释对于特定用例来说不充分或不切实际,那么甚至可以注册注入用户指定值的自定义注释。

实现方法级安全

正如我们所看到的,可以使用 MethodDelegation 在运行时使用纯Java动态重写方法。这是一个简单的例子,但该技术可用于实现更实际的应用。在本文的其余部分中,我们将开发一个示例,该示例使用代码生成来实现注释驱动库,以强制实现方法级安全性。在我们的第一次迭代中,库将生成子类来加强这种安全性。然后,我们将使用相同的方法实现Java agent,以实现相同的功能。

示例库使用以下注释允许用户指定方法被认为是安全的:

@interface Secured {
  String user();
}

例如,考虑一个应用程序,它使用下面的服务类来执行一个敏感的操作,只要用户作为管理员进行身份验证,就应该执行该操作。这是通过在执行此操作的方法上声明安全注释来指定的。

class Service {
  @Secured(user = “ADMIN”)
  void doSensitiveAction() {
    // run sensitive code...
  }
}

当然,可以将安全检查直接写入方法中。在实践中,硬编码横切关注点经常导致难以维护的复制粘贴逻辑。此外,一旦应用程序显示了额外的需求(如日志记录、收集调用度量或结果缓存),直接添加此类代码的扩展性就不好。通过将这些功能提取到代理中,方法可以完全表示其业务逻辑,从而更易于阅读、测试和维护代码库。

为了使建议的库保持简单,注释的契约声明,如果当前用户不是由注释的用户属性指定的用户,则应抛出 IllegalStateException 。通过使用Byte Buddy,可以通过一个简单的拦截器实现此行为,如以下示例中的 SecurityInterceptor ,该拦截器还通过其静态用户字段跟踪当前登录的用户:

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 类的第二个方法,可以将上述安全检查放在原始方法的普通调用之前,如下所示。由于失败的安全检查将引发异常并阻止任何进一步的执行,因此如果用户未经过身份验证,则不会执行对原始方法的调用。

将这些部分放在一起,现在可以生成一个适当的服务子类,其中所有带注释的方法都得到了适当的保护。由于生成的类是服务的子类,因此生成的类可以用作服务类型的所有变量的替代品,而无需类型转换,并且在调用 DosSensitiveAction 方法时会引发异常,而无需进行适当的身份验证:

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

不幸的是,由于插入指令的子类仅在运行时创建,因此不使用Java反射就不可能创建这样的实例。因此,插装类的任何实例都应该由工厂创建,该工厂封装了为插装目的创建子类的复杂性。因此,子类插装通常用于已经需要工厂创建实例的框架,例如Spring之类的依赖注入框架或Hibernate之类的对象关系映射框架。对于其他类型的应用程序,子类检测通常过于复杂而难以实现。

用于安全的Java agent

使用Java agent,上述安全框架的另一种实现是修改类(如上述服务)的原始字节码,而不是覆盖它。这样就不再需要创建托管实例;简单的调用:

new Service().doSensitiveAction()

当相应的用户未通过身份验证时,将已经引发异常。为了支持这种修改类的方法,Byte Buddy提供了一个称为重定类基址的概念。当一个类被重新基化时,不会创建任何子类,而是将插入指令的代码合并到插入指令的类中以更改其行为。使用这种方法,插装类的任何方法的原始代码在插装后仍然可以访问,因此像 SuperMethodCall 这样的插装的工作方式与创建子类时完全相同。

由于子类化或重基化时的行为相似,这两个操作的API都以相同的方式执行,使用相同的 DynamicType.Builder 接口描述类型。这两种形式的工具都可以通过ByteBuddy类访问。为了使Java代理的定义更加方便,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);
  }
}

如果此代理捆绑在jar文件中并在命令行上指定,则任何类型都会被“转换”或重新定义以保护指定安全注释的任何方法。如果不激活Java agent,应用程序将在不进行额外安全检查的情况下运行。当然,这意味着单元测试,在单元测试中,可以调用带注释方法的代码,而不需要特定的设置来模拟安全上下文。由于Java运行时忽略在类路径上找不到的注释类型,因此甚至可以在完全从应用程序中删除安全库后运行带注释的方法。

作为另一个优势,Java agent很容易堆叠。如果在命令行上指定了几个Java agent,则每个代理都有机会按照它们在命令行上的顺序修改类。例如,这将允许安全、日志和监控框架的组合,而不需要这些应用程序之间的任何形式的集成层。因此,使用Java agent实现横切关注点提供了编写更多模块化代码的机会,而无需将所有代码集成到管理实例的中心框架中。

Buddy的源代码在GitHub上免费提供: http://bytebuddy.net

原文地址: https://www.infoq.com/articles/Easily-Create-Java-Agents-with-ByteBuddy/

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值