Byte Buddy官方教程(五) —自定义方法实现

此翻译已经合并到Byte Buddy官网,请去官网阅读最新版的文档。
https://bytebuddy.net/#/tutorial-cn

在前面的部分,我们描述了Byte Buddy 的标准 API。目前为止前面描述的特性都不需要Java字节码的知识和显式表示。然而,如果你需要创建自定义的字节码,你可以通过直接访问ASM的API来实现,它是一个底层的字节码类库,Byte Buddy是在它上面构建的。但是,注意ASM不同版本之间是不兼容的,这样当你发布代码时,你需要将Byte Buddy重新打包到你的命名空间。否则,当另一个依赖要求基于不同版本ASM构建的Byte Buddy时,你的应用可能导致不兼容Byte Buddy的其它用途。你可以在首页找到维护Byte Buddy依赖的详情。

ASM类库带有一份极好的关于Java字节码和类库使用的文档。因此,如果你想深入了解Java字节码和ASM的API,我们希望你参考此文档。相反,我们只是简单介绍JVM的执行模型和Byte Buddy对ASM的API的适配。

任何Java类都由下面几部分组成。核心的部分大致可以分为如下几类:

  • 基础数据:一个类文件引用类名及超类名和它实现的接口。另外类文件引用不同的元数据,比如类的Java版本号、注解或者编译器为创建类文件而处理的原文件名。
  • 常量池:类常量池是由一些值组成的集合,这些值被类的成员或注解所引用。在这些值中,常量池存储如基本类型的值和类的源代码中由一些字面量表达式创建的字符串。此外,常量池中保存着类中所用到的所有类型和方法的名称。
  • 字段列表:Java类文件中包含了在这个类中声明的所有字段的一个列表。除了字段的类型、名称和修饰符,类文件还保存了每个字段的注解。
  • 方法列表:类似于字段列表,Java类文件包含一个所有声明方法的列表。除了字段之外,非抽象方法还由描述主体方法的字节编码指令数组描述。这些指令代表所谓的 Java 字节码。

幸运的是,当创建类时,ASM类库完全负责创建一个合适的常量池。有了这个,唯一重要的元素仍然是方法的描述,它由一个执行指令的数组表示,每个指令被编码为一个字节。这些指令在方法调用上由虚拟stack machine(栈机)处理。举一个简单的示例,让我们考虑一个计算并返回两个基本类型整数1050之和的方法。

LDC      10  // 栈包含 10  
LDC      50  // 栈包含 10, 50  
IADD         // 栈包含 60
IRETURN      // 栈为空

上面的Java字节码数组助记符是通过使用LDC指令将两个数压入栈开始的。注意,这个执行顺序与Java源码中表示的顺序有何不同,在源码中加法被写为中缀表示法的10+50。但是后一种顺序不能被栈机处理,其中任何像+一样的指令都只能访问栈中最上面的值。这个加法用IADD表示,它消费栈中两个最上面的值,并且这两个值都要是基本的整型。最后,IRETURN表达式会消费这个计算结果并且从方法返回,给我们留下一个空栈。

我们已经提到过,任何方法中引用的基本类型的值都保存在类的常量池中。上述方法中用到的数字5010也是如此。常量池中的值都被分配了一个两字节长度的索引。让我们假定数字105存储在索引12的位置。和上述助记符对应的字节值一起,0x12对应对应LDC0x60对应IADD0xAC对应IRETURN,我们现在知道如何将上述方法表示为原始字节指令:

12 00 01
12 00 02
60
AC

对于已经编译的类,这个精确的字节序列可以在类文件中找到。然而,这个描述还不足以定义一个方法的实现。为了加速Java应用的运行时执行,每个方法都需要通知JVM其需要的执行栈大小。对于上面的没有分支的代码,这很容易确定,因为我们已经看到栈里最多有两个值。但是,对于更复杂的方法,提供这个信息很容易成为一个复杂的任务。更糟糕的是,栈的值可以有不同的大小。longdouble值消耗两个插槽(slot),而其它的值消耗一个。好像这还不够,JVM也需要有关方法体里本地变量的数量信息。方法里所有此类变量都存储在一个数组里,该数组中还包含任何方法的参数和非静态方法的this引用。同样,在这个数组中,longdouble会消耗两个插槽。

显然,保持跟踪所有这些信息会使手动组装Java字节码变得啰嗦且容易出错,这就是Byte Buddy提供简化抽象的原因。用Byte Buddy,任何栈指令都包含在StackManipulation接口的实现中。任何栈操作的实现都结合了更改给定栈的指令和该指令的大小影响的信息。然后可以轻松地将任意数量的此类指令合并为一个通用指令。为了演示这个,让我们先为IADD指令实现一个StackManipulation

enum IntegerSum implements StackManipulation {
 
  INSTANCE; // singleton
 
  @Override
  public boolean isValid() {
    return true;
  }
 
  @Override
  public Size apply(MethodVisitor methodVisitor,
                    Implementation.Context implementationContext) {
    methodVisitor.visitInsn(Opcodes.IADD);
    return new Size(-1, 0);
  }
}

从上面的apply方法,我们了解到这个栈操作通过在ASM的方法访问者上调用相应方法来执行IADD指令。此外,这个方法表示这条指令将当前的栈Size减少一个槽。创建的Size实例的第二个参数是0,这个表示该指令不需要确定的最小栈大小来计算中间结果。此外,任何StackManipulation可以表示为无效。这个行为可以用于更复杂的栈操作,比如用在可能破坏类型约束的对象分配上。我们将在本节的后面看一个无效的栈操作示例。最后,注意我们描述的栈操作是一个singleton enumeration(单例枚举)。对于Byte Buddy的内部实现,使用这样的不可变的、功能性的描述被证明是一个很好的实践,我们只能建议你遵循同样地方法。

通过组合上面的IntegerSum与预定义的IntegerConstantMethodReturn栈操作,我们现在可以实现一个方法。在Byte Buddy中,方法的实现包含在ByteCodeAppender中,我们的实现如下:

enum SumMethod implements ByteCodeAppender {
 
  INSTANCE; // singleton
 
  @Override
  public Size apply(MethodVisitor methodVisitor,
                    Implementation.Context implementationContext,
                    MethodDescription instrumentedMethod) {
    if (!instrumentedMethod.getReturnType().asErasure().represents(int.class)) {
      throw new IllegalArgumentException(instrumentedMethod + " must return int");
    }
    StackManipulation.Size operandStackSize = new StackManipulation.Compound(
      IntegerConstant.forValue(10),
      IntegerConstant.forValue(50),
      IntegerSum.INSTANCE,
      MethodReturn.INTEGER
    ).apply(methodVisitor, implementationContext);
    return new Size(operandStackSize.getMaximalSize(),
                    instrumentedMethod.getStackSize());
  }
}

同样,自定义的ByteCodeAppender也是作为一个单例枚举实现的。

在实现期望的方法之前,我们首先校验检测的方法是否真的返回一个基本整形类型。否则,这个创建的类将会被JVM的验证器拒绝。接着我们将1050装载到执行栈中,应用这些值的和,然后返回计算结果。通过用一个混合的栈操作包装这些指令,我们可以最终找到需要执行栈操作链的聚合栈的大小。最终,我们返回该方法需要的总的(容量)大小。返回的ByteCodeAppender.Size中第一个参数反应了我们刚刚提到的包含在StackManipulation.Size中的执行栈需要的大小。此外,第二个参数反应了本地变量数组所需要的大小,数组简单地类似于方法的参数和可能的this引用需要的大小,因为我们没有定义任何自己的本地变量。

用我们的求和(summation)方法的实现,我们现在为这个方法提供一个自定义Implementation(实现),我们可以提供给Byte Buddy的领域特定语言:

enum SumImplementation implements Implementation {
 
  INSTANCE; // singleton
 
  @Override
  public InstrumentedType prepare(InstrumentedType instrumentedType) {
    return instrumentedType;
  }
 
  @Override
  public ByteCodeAppender appender(Target implementationTarget) {
    return SumMethod.INSTANCE;
  }
}

任何Implementation需要分两阶段查询。首先,一个实现有机会在prepare方法中通过添加额外的字段或方法去改变创建的类。此外,准备工作允许实现注册一个我们上节中了解到的TypeInitializer。如果不需要这样的准备,返回未更改的InstrumentedType作为参数就足够了。注意,一个Implementation通常不应该返回检测类型的单个实例,而应该调用检测类型中以with为前缀的附加方法。在任何Implementation为特定类的创建准备好后,appender(附加)方法会被调用以检索ByteCodeAppender。然后查询这个追加器以获取为给定实现的拦截选择的任何方法,以及在实现调用prepare方法期间,注册的任何方法。

注意,Byte Buddy在任何类的创建过程中,每个Implementationprepareappender方法只调用一次,无论一个实现为类创建的使用注册了多少次都可以保证。这样,Implementation可以避免验证字段和方法是否已经定义。在这个过程中,Byte Buddy通过它们的hashCodeequals方法来比较Implementation实例。通常,Byte Buddy用到的任何类都应该提供这些方法的有意义的实现。枚举每个定义的此类实现是使用它们的另一个好理由。

有了这一切,让我们实际看一下SumImplementation

abstract class SumExample {
  public abstract int calculate();
}
 
new ByteBuddy()
  .subclass(SumExample.class)
    .method(named("calculate"))
    .intercept(SumImplementation.INSTANCE)
  .make()

恭喜!你刚刚扩展了Byte Buddy实现了一个自定义方法,该方法计算并返回1015的和。当然,这个实现实例没有啥实际用途。然而,可以在这个架构之上轻松地实现更复杂的实现。毕竟,如果你觉得你创造了一些有用的东西,请考虑贡献你的实现。我们期待你的来信!

在我们继续定制一些Byte Buddy的组件之前,我们应该简要地讨论一下跳转指令的使用和所谓的Java栈帧。从Java6开始,为了加速JVM的验证过程,任何跳转指令都需要一些额外的信息,这些指令用于实现比如ifwhile这类语句。这些额外信息称为 stack map frame(栈映射帧)。一个栈映射帧包含有关在跳转指令的目标处的执行栈上找到的所有值信息。通过提供这个信息,JVM的验证器减少了一些需要我们来做的工作。对于更复杂的跳转指令,提供正确的栈映射帧是一项相当困难的任务,许多代码生成框架创建正确的栈映射帧时会遇到相当多的困难。那么我们该如何解决这个问题呢?事实上,我们没有处理。Byte Buddy的思想是:代码生成应该只用作编译时未知的类型继承结构和需要注入到这些类型中的自定义代码之间的粘合剂。因此,生成的真正代码尽可能地受限制。只要有可能,条件语句应该用你选择的JVM语言实现和编译,然后用简约的实现绑定到给定的方法。这种方法的一个很好的副作用是Byte Buddy的用户可以使用标准的Java代码并使用他们习惯的工具,像调试或者集成开发工具代码导航器。对于没有源码表示的生成的代码,这一切都是不可能的。但是,如果你真地需要用跳转指令创建字节码,请确保使用ASM添加正确的栈映射帧,因为Byte Buddy不会自动包含它们。

创建自定义分配器

在上一节中,我们讨论了Byte Buddy内置的Implementation依赖Assigner(分配器)为变量分配值。在这个过程中,分配器能够通过发出合适的StackManipulation(栈操作)来将一个值转换为另一个值。这样做,Byte Buddy的内置分配器提供了比如基本类型和包装类型的自动装箱功能。在最简单的情况下,一个值可以按原来的样子(as is)分配给一个变量。然而,在某些场景下,分配或许根本不可能通过从分配至返回一个无效的StackManipulation来表示。Byte Buddy的IllegalStackManipulation类提供了无效分配的规范实现。

为了演示自定义分配器的使用,我们现在实现一个分配器,它通过在它接收到的任何值上调用toString方法来仅仅将值分配给String类型的变量:

enum ToStringAssigner implements Assigner {
 
  INSTANCE; // singleton
 
  @Override
  public StackManipulation assign(TypeDescription.Generic source,
                                  TypeDescription.Generic target,
                                  Assigner.Typing typing) {
    if (!source.isPrimitive() && target.represents(String.class)) {
      MethodDescription toStringMethod = new TypeDescription.ForLoadedType(Object.class)
        .getDeclaredMethods()
        .filter(named("toString"))
        .getOnly();
      return MethodInvocation.invoke(toStringMethod).virtual(sourceType);
    } else {
      return StackManipulation.Illegal.INSTANCE;
    }
  }
}

上面的实现首先验证输入的值不是基本类型,并且目标变量类型是String类型。如果这些条件没有满足,Assigner将发出一个IllegalStackManipulation使尝试的分配无效。否则,我们通过它的名称来识别Object类型的toString方法。然后,我们用Byte Buddy的MethodInvocation创建一个StackManipulation,它在源类型上虚拟调用此方法。最后,我们可以将这个自定义的Assigner和比如Byte Buddy的FixedValue实现集成,如下所示:

new ByteBuddy()
  .subclass(Object.class)
  .method(named("toString"))
    .intercept(FixedValue.value(42)
      .withAssigner(new PrimitiveTypeAwareAssigner(ToStringAssigner.INSTANCE),
                    Assigner.Typing.STATIC))
  .make()

toString方法在上面的类型的实例上被调用时,它将返回字符串42。这只能通过我们自定义的分配器来实现,它通过调用toString方法将Integer类型转换为String。注意,我们还用内置的PrimitiveTypeAwareAssigner包装了自定义的分配器。这个内置的分配器在将这个包装的原始值的分配委托给其内部分配器之前将提供的基本类型int自动装箱为它的包装类型。其它的内置分配器是VoidAwareAssignerReferenceTypeAwareAssigner。永远记住为你自定义的分配器实现有意义的hashCodeequals方法,因为这些方法通常是从它们在Implementation中的对应部分调用的,这个实现使用给定的分配器。同样,通过将分配器实现为单例枚举,我们避免手动执行。

创建自定义参数绑定器

我们上一节已经提到过,可以继承MethodDelegation实现以处理用户定义的注解。为此,我们需要提供一个自定义的ParameterBinder(参数绑定器),它知道如何处理给定的注解。例如,我们想定义一个注解,目的是简单地向注解的参数中注入一个固定的字符串。首先,我们定义一个StringValue注解:

@Retention(RetentionPolicy.RUNTIME)
@interface StringValue {
  String value();
}

通过设置合适的RuntimePolicy,我们需要确保注解是可见的。否则,注解不会在运行时保留,Byte Buddy就没有机会发现它。这样做,上述的value属性包含作为值分配给注解参数的字符串。

用我们自定义的注解,需要创建一个对应的ParameterBinder,它能够创建一个表示此参数绑定的StackManipulation。每次调用这个参数绑定器,它的对应注解在参数上通过MethodDelegation会被发现。为我们的实例注解实现一个自定义参数绑定器很简单:

enum StringValueBinder
    implements TargetMethodAnnotationDrivenBinder.ParameterBinder<StringValue> {
 
  INSTANCE; // singleton
 
  @Override
  public Class<StringValue> getHandledType() {
    return StringValue.class;
  }
 
  @Override
  public MethodDelegationBinder.ParameterBinding<?> bind(AnnotationDescription.Loaded<StringValue> annotation,
                                                         MethodDescription source,
                                                         ParameterDescription target,
                                                         Implementation.Target implementationTarget,
                                                         Assigner assigner,
                                                         Assigner.Typing typing) {
    if (!target.getType().asErasure().represents(String.class)) {
      throw new IllegalStateException(target + " makes illegal use of @StringValue");
    }
    StackManipulation constant = new TextConstant(annotation.loadSilent().value());
    return new MethodDelegationBinder.ParameterBinding.Anonymous(constant);
  }
}

最初,参数绑定器确保target(目标)参数实际上是一个字符串类型。如果不是这样,我们将会抛出一个异常通知注解的用户非法放置了这个注解。否则,我们仅创建一个TextConstant,它表示将常量池字符串加载到执行栈上。然后,StackManipulation被包装为匿名的ParameterBinding(参数绑定),它最终会从该方法返回。或者,你可以提供一个Unique(唯一的)Illegal(非法的)参数绑定。唯一的绑定通过允许从AmbiguityResolver(不明确解析器)中检索这个绑定的任意对象标识。在后面的步骤中,这样的解析器能够查找参数绑定是否用某个唯一标识符注册,然后可以决定这个绑定是否优于另一个成功绑定的方法。使用非法绑定,可以指示Byte Buddy一对特定的sourcetarget方法是不兼容的且不能绑定在一起。

这已经是和MethodDelegation一起使用自定义注解所需要的所有信息。收到ParameterBinding后,它会确保它的值绑定到正确的参数上,不然它将丢弃当前的sourcetarget方法对,因为它们不可绑定,此外,它将允许AmbiguityResolver查找唯一的绑定。最终让我们把这个自定义注解付诸实践:

class ToStringInterceptor {
  public static String makeString(@StringValue("Hello!") String value) {
    return value;
  }
}
 
new ByteBuddy()
  .subclass(Object.class)
  .method(named("toString"))
    .intercept(MethodDelegation.withDefaultConfiguration()
      .withBinders(StringValueBinder.INSTANCE)
      .to(ToStringInterceptor.class))
  .make()

注意,通过指定StringValueBinder为唯一的参数绑定器,我们将替换所有默认值。或者,我们可以将参数绑定器追加到那些已经注册的绑定器上。在ToStringInterceptor中只有一个可能的目标方法,被动态类拦截的toString方法被绑定到后一个方法的调用。当目标方法被调用,Byte Buddy将注解的字符串值分配为目标方法的唯一参数。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值