Java字节码增强技术Bytebuddy探路篇

图片来自pixabay.com的Alexas_Fotos会员

最近为了实现Java应用RPC调用的录制和Mock回放,需要以无侵入方式获取到RPC方法的出入参数和返回响应消息等数据,于是踏上了Java字节码增强技术的道路摸索,这个非常类似Trace所使用的相关技术,不过需要深入到RPC方法级别,对指定方法进行无侵入方式切面处理。先后对ASM/Javaassist/Bytebuddy等技术进行了调研等,本文是对所做摸索探路工作的总结,若读者有类似Trace场景需求,可以进行借鉴参考。

在众多比较之后最后选择的是Bytebuddy技术。

1. Java字节码简介

Java字节码是众多字节码增强技术的知识基础。Java语言写出的源代码首先需要编译成class文件,即字节码文件,然后被JVM加载并运行,每个class文件具有如下固定的数据格式,

ClassFile {
    u4             magic;           // 魔数,固定为0xCAFEBABE
    u2             minor_version;   // 次版本
    u2             major_version;   // 主版本,常见版本:52对应1.8,51对应1.7,其他依次类推
    u2             constant_pool_count;                     // 常量池个数
    cp_info        constant_pool[constant_pool_count-1];    // 常量池定义
    u2             access_flags;    // 访问标志:ACC_PUBLIC, ACC_INTERFACE, ACC_ABSTRACT等
    u2             this_class;      // 类索引
    u2             super_class;     // 父类索引
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

可以看到,class文件总是一个魔数开头,后面跟着版本号,然后就是常量定义、访问标志、类索引、父类索引、接口个数和索引表、字段个数和索引表、方法个数和索引表、属性个数和索引表。

class文件本质上是一个字节码流,每个字节码所处的位置代表着一定的指令和含义。如何对class文件中定义的指令和字节码进行解读、增强定义、编排,这是字节码增强技术所要完成的事情。

了解Java字节码有助于字节码增强的开发,但并不是实现字节码增强开发的必要条件,最新主流的众多字节码增强工具框架类库都将字节码的编排进行了不同程度封装,在可读性、易编排性、排错性上提供开发便利性,学习曲线和开发难度得到了较好的改善。

2. Java字节码增强支持

对于字节码增强的开发来说,JVMTI是一个在实践中应该被熟悉的工具技术。JVM从1.5版本开始提供JVM Tool Interface,这是JVM对外的、用于Java应用监控和调试的一系列工具接口,是JVM平台调试架构的重要组成部分。

下图是JVM平台调试架构图

The Java™ Platform Debugger Architecture is structured as follows:
           Components                          Debugger Interfaces

                /    |--------------|
               /     |     VM       |
 debuggee ----(      |--------------|  <------- JVM TI - Java VM Tool Interface(Jvm服务端调试接口)
               \     |   back-end   |
                \    |--------------|
                /           |
 comm channel -(            |  <--------------- JDWP - Java Debug Wire Protocol (Java调试通信协议)
                \           |
                     |--------------|
                     | front-end    |
                     |--------------|  <------- JDI - Java Debug Interface (客户端调试接口和调试应用)
                     |      UI      |
                     |--------------|

JVM启动支持加载agent代理,而agent代理本身就是一个JVM TI的客户端,其通过监听事件的方式获取Java应用运行状态,调用JVM TI提供的接口对应用进行控制。

我们可以看下Java agent代理的两个入口函数定义,

// 用于JVM刚启动时调用,其执行时应用类文件还未加载到JVM
public static void premain(String agentArgs, Instrumentation inst);

// 用于JVM启动后,在运行时刻加载
public static void agentmain(String agentArgs, Instrumentation inst);

这两个入口函数定义分别对应于JVM TI专门提供了执行字节码增强(bytecode instrumentation)的两个接口。

  • 加载时刻增强,类字节码文件在JVM加载的时候进行增强,。
  • 动态增强,已经被JVM加载的class字节码文件,当被修改或更新时进行增强,从JDK 1.6开始支持。

这两个接口都是从JDK 1.6开始支持。

我们无需对上面JVM TI提供的两个接口规范了解太多,Java Agent和Java Instrument类包封装好了字节码增强的上述接口通信。我们需要了解的是,上述入口函数传入的第二个参数Instrumentation实例,即Java Instrument类java.lang.instrument.Instrumentation,查看其类定义,可以看到其提供的核心方法只有一个addTransformer,用于添加多个ClassFileTransformer,

// 说明:添加ClassFileTransformer
// 第一个参数:transformer,类转换器
// 第二个参数:canRetransform,经过transformer转换过的类是否允许再次转换
void Instrumentation.addTransformer(ClassFileTransformer transformer, boolean canRetransform)

ClassFileTransformer则提供了tranform()方法,用于对加载的类进行增强重定义,返回新的类字节码流。需要特别注意的是,若不进行任何增强,当前方法返回null即可,若需要增强转换,则需要先拷贝一份classfileBuffer,在拷贝上进行增强转换,然后返回拷贝。

// 说明:对类字节码进行增强,返回新的类字节码定义
// 第一个参数:loader,类加载器
// 第二个参数:className,内部定义的类全路径
// 第三个参数:classBeingRedefined,待重定义/转换的类
// 第四个参数:protectionDomain,保护域
// 第五个参数:classfileBuffer,待重定义/转换的类字节码(不要直接在这个classfileBuffer对象上修改,需拷贝后进行)
// 注:若不进行任何增强,当前方法返回null即可,若需要增强转换,则需要先拷贝一份classfileBuffer,在拷贝上进行增强转换,然后返回拷贝。
byte[] ClassFileTransformer.transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte classfileBuffer)

下面简单的演示样例,通过Java agent打印出JVM加载的类列表,

  1. 下载演示代码,见这里
  2. 编译项目 mvn clean package
  3. 运行命令
java -javaagent:./demo-agent/target/agent-agent.jar -jar ./demo-app/target/demo-app.jar
  1. 可以通过控制台查看所有JVM加载的类列表。
loading agent..
agent has been loaded.
transforming class = java/lang/invoke/MethodHandleImpl
transforming class = java/lang/invoke/MethodHandleImpl$1
transforming class = java/lang/invoke/MethodHandleImpl$2
transforming class = java/util/function/Function
transforming class = java/lang/invoke/MethodHandleImpl$3
transforming class = java/lang/invoke/MethodHandleImpl$4
transforming class = java/lang/ClassValue

3. 演示类

为了方便展示不同字节码增强技术,下面将以Greeting类为例,对sayHello()方法进行加强。

public class Greeting {

    public String sayHello() {
        String hello = "hello,world";
        System.out.println(hello);
        return hello;
    }

}

分别在sayHello函数执行前后添加打印语句,输出如下类似信息。

begin of sayhello().
hello,world
end of sayhello().

为了能够检查增强后的类,建议使用arthas调试工具,

jad  com.pphh.demo.api.Greeting

通过jad命令,对Greeting类进行反编译,以了解字节码增强后的类定义。

4. Java字节码增强类库 - ASM

ASM是一个Java字节码解析和操作框架,整个类包非常小,还不到120KB,但其非常注重对类字节码的操作速度,这种高性能来自于它的设计模式 - 访问者模式,即通
过Reader、Visitor和Writer模式。

ASM是直接操作类字节码数据,因此其读写的是字节码指令,比如,

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("begin of sayhello().");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

这种指令比较晦涩难懂,在实际操作过程中,会先将期望的类源码文件写好,编译后查看字节码文件,然后复制相关字节码指令。

演示代码
1. 下载演示代码,见这里
2. 编译项目 mvn clean package
3. 运行命令

java -javaagent:./demo-asm/target/agent-asm.jar -jar ./demo-app/target/demo-app.jar
  1. 可以通过控制台查看日志。

5. Java字节码增强类库 - Javassist

Javassist是一个非常早的字节码操作类库,开始于1999年,它能够支持两种编辑方式:源码级别和字节码指令级别,相比于晦涩的字节码级别,源码级别更加人性化,代码编写起来更加易懂。

以上面的ASM字节码指令编辑为例,换成对应的Javassist源码级别编辑方式,如下所示,

CtMethod m = cc.getDeclaredMethod("sayHello");
m.insertBefore("{ System.out.println(\"begin of sayhello()\"); }");

相信大多数程序员更愿意接受源码级别编辑方式,翻译成直接码指令的工作就交给Javassist完成,目前源码级别方式Javassist只支持Java语言语法。

演示代码
1. 下载演示代码,见这里
2. 编译项目 mvn clean package
3. 运行命令

java -javaagent:./demo-javaassist/target/agent-jassist.jar -jar ./demo-app/target/demo-app.jar
  1. 可以通过控制台查看日志。

6. Java字节码增强工具/类库 - ByteBuddy

ByteBuddy是一个基于ASM的字节码增强框架,开始于2014年,相比其它字节码操作类库而言,其诞生的时间则年轻得多,所以ButeBuddy API在设计之初上就吸收了业界其它字节码工具类库的优点,提供了丰富灵活的API接口,可以快速创建新类、继承已有类、动态重构类。

如下是几个简单代码样例如下,

// 创建一个com.pphh.demo.Sample类,继承于Object.class
DynamicType.Unloaded dynamicType = new ByteBuddy()
  .subclass(Object.class)
  .name("com.pphh.demo.Sample")
  .make();

// 创建一个新类,继承于Foo.class
new ByteBuddy().subclass(Foo.class)

// 重构类Foo.class
new ByteBuddy().redefine(Foo.class)

// 重设定Foo.class(保留原先类的方法定义,基于原先定义进行扩展)
new ByteBuddy().rebase(Foo.class)

和Javassist比起来,它不再是一个Java源码的文本填充工具,而是让开发能够以代码开发的方式增强类,能够有编译级别的纠错。在下面的例子中,

    public Object intercept(@This Object obj,
                            @AllArguments Object[] allArguments,
                            @SuperCall Callable zuper,
                            @Origin Method method) throws Throwable {
        System.out.println("begin of sayHello()");
        Object result = zuper.call();
        System.out.println("end of sayHello()");
        return result;
    }

同样地写System.out.println,若不小心写错了,如上方式是可以在编译时刻得到提醒的,这个和Javassist比起来好很多。

演示代码
1. 下载演示代码,见这里
2. 编译项目 mvn clean package
3. 运行命令

java -javaagent:./demo-bytebuddy/target/agent-buddy.jar -jar ./demo-app/target/demo-app.jar
  1. 可以通过控制台查看日志。

7. Java字节码增强工具对比和关系图

7.1 Java字节码增强工具对比

对比 ASM Javassist JDK Proxy Cglib ByteBuddy
起源时间 2002 1999 2000 2011 2014
包大小 130KB
(版本9.3)
788KB
(版本3.28.0-GA)
3.7MB
(版本1.10.19)
增强方式 字节码指令 字节码指令和源码(注:源码文本) 源码 源码 源码
源码编译 NA 不支持 支持 支持 支持
agent支持 支持 支持 不支持,依赖框架 不支持,依赖框架 支持
性能
维护状态 停止升级 停止维护 活跃
优点 超高性能,应用场景广泛 同时支持字节码指令和源码两种增强方式 JDK原生类库支持 零侵入,提供良好的API扩展编程
缺点 字节码指令对应用开发者不友好 场景非常局限,只适用于Java接口 已经不再维护,对于新版JDK17+支持不好,官网建议切换到ByteBuddy
应用场景 小,高性能,广泛用于语言级别 广泛用于框架场景 广泛用于Trace场景

注:相关性能数据来自这里

综合了上述的字节码增强工具对比,比较了开发便利性和需求目标,我们最后选择了ByteBuddy来实现Trace跟踪技术。

7.2 Java字节码增强工具关系图

Java字节码增强工具关系图

需要提一下,JDK Proxy和Cglib也是以代码方式进行类方法的切面增强,但它们都是以框架的方式实现了Java类的动态扩展,主要应用在框架级别的字节码增强,在某种程度上JDK Proxy和Cglib技术对应用是有代码侵入的,这里的侵入不仅仅是框架代码侵入,而且包括增强的类中依赖JDK Proxy和Cglib类。与此相比,ButeBuddy API是以无侵入方式加强类代码,设计理念更优。

8. 参考资料

  1. JVM - The class File Format
  2. The Java Platform Debugger Architecture
  3. JVMTI - Bytecode Instrumentation
  4. java.lang.instrument类定义
  5. ASM
  6. Javassist
  7. Bytebuddy
  8. cglib

杳杳寒山道

杳杳寒山道

唐代:寒山

杳杳寒山道,落落冷涧滨。
啾啾常有鸟,寂寂更无人。
淅淅风吹面,纷纷雪积身。
朝朝不见日,岁岁不知春。

一条幽暗寂静的寒山道上,旁边流淌着飘飘落落的山水小溪。 驻足而立,可以闻见山谷中鸟儿啾啾地啼鸣,四周远望,山中空无一人。 风淅淅沥沥地吹向脸庞,突然空中飘起了雪花,纷纷扬扬地落在了我身上。在这里,一年四季难得见一束温暖的阳关,终年也不知春天的花香鸟语,却是一片静心之地。

架构艺术之应用分层设计

图片来自pixabay.com的ROverhate会员

1. 为什么需要应用分层架构设计

高内聚、低耦合、职责单一,是一个应用的基本设计要求。但是起初设计很好的应用边界,随着业务的扩张,待开发的业务功能越来越多,不断拆分出新应用
,经常出现的问题是,应用之间的相互依赖关系越来越模糊,相互调用,进而出现循环依赖和长链条依赖问题。

举一个简单的业务功能为例,用户下单支付,其需要调用支付中心进行支付、调用会员中心确认可用积分、调用营销中心发放优惠券、调用消息中心发送用户短信等步骤,这里涉及的应用有4个,

  • A 支付中心
  • B 会员中心
  • C 营销中心
  • D 消息中心

一个常见的技术实现是,在应用A中提供一个下单支付的接口,在A中先后调用执行应用B、C、D的接口,串联实现下单支付的功能。这不是理想的技术方案,最大的问题是,支付中心A承担了整个下单支付的流程,不仅需要负责处理各个接口的返回消息,还要处理在接口调用失败下的重试和异常告警,其功能职责不再单一。随着下单支付的业务功能越来越复杂,需要串联的业务流程步骤越多,今天加个促销积分,明天发不同优惠券,支付中心变得臃肿不堪,负责支付的开发工程师苦不堪言。

在调用关系上,到底是A调用B或C,还是从B调用到C,在没有沟通清楚的情况下,各种调用方法实现都有,很容易导致A->B->C->D的长链条调用,或A->B->A的循环调用,应用之间的调用变得复杂。管控的不好,业务架构的可扩展性无从谈起,开发团队之间经常扯皮,一个功能代码到底如何串联?在哪里实现?

这里其实涉及到的关键问题是,应用的逻辑架构和相互依赖关系,这正是应用分层架构设计所要解决的问题。

2. 一个通用的架构分层设计

下面将介绍一个通用的架构分层方案,

应用架构分层设计

如上图所示,这个架构分层设计的要点如下,

  1. 应用根据分层架构划分为四层,从上到下分别为,
    • API网关层:对外提供HTTP接口服务,实现统一的鉴权、流控和降级。
    • 业务聚合层:依赖业务中心服务,调用中心服务所提供的原子业务接口,串联起业务流程,实现基于业务场景的功能接口。其负责流程的异常处理和重试,跟进流程状态。
    • 业务中心服务层:实现单一、独立的原子业务功能,高内聚、低耦合。原子业务的含义是指业务不可拆分,有确定的输入和输出,在输入正常的情况下,必须确保业务的正常完成。
    • 业务数据服务层:提供业务数据的只读查询,不提供写操作。
  2. 应用调用关系必须从上往下调用,不允许同层调用,以免形成互相依赖和循环依赖。
  3. 应用必须按规范格式xxx-gateway/business/center/data命名,以便快速识别其工作的逻辑层次。
  4. 业务中心服务和数据服务应用将共享同一个DAO类库,其数据服务提供的是只读接口。
  5. 业务中心服务层不能相互调用,若有需要,允许通过消息中心进行异步通信调用。
  6. 业务数据服务层不是一定需要,若没有数据查询的需求,可以省略这一层的数据服务应用部署。

这个架构分层顺利执行的关键点主要在于两方面,

1. 应用调用关系严格按照从上往下调用,禁止同层调用,特别是业务中心服务层,各个中心服务不允许相互调用。
2. 应用命名必须严格遵从格式规范,规范的命名将方便团队之间沟通,特别是在架构评审时,大家可以快速识别其所在的逻辑层次,从而判断其调用关系是否合理。

3. 项目代码结构

根据上面的架构分层设计,一个相应的项目代码结构如下,

pphh-demo
  + demo-async        业务异步调用:包括消息或定时任务
  + demo-gateway      业务网关
  + demo-business     业务聚合层应用
  + demo-business-api 业务聚合层接口定义
  + demo-center       业务中心服务应用
  + demo-center-api   业务中心服务接口定义
  + demo-data         业务数据服务应用
  + demo-data-api     业务数据服务接口定义
  + demo-dao          数据库访问类库

4. 带来的好处

作为一个顶层设计,分层架构定义了整体的逻辑应用架构和上下依赖关系,确认各个层次的应用大边界。以此为基础,进行新应用的设计和拆分、定义功能边界,将会更加容易,整体架构的可扩展性也可以得到保证。

从调用链的角度,应用的调用层次深度保持为一个常量,按照上图的分层架构设计,深度最多为5,并且不会出现循环调用和长链条调用的问题。

在团队规模很小时,分层架构设计带来的好处可能会比较小,但是一旦团队规模成长到50人以上、应用个数上升到30个以上时,分层架构设计将发挥越来越大的作用,一旦整个团队对分层架构都达成统一思想,大家对自己开发的功能边界和应用交互都有清晰的认知,随着应用数增多,团队的沟通成本几乎为零。换句话说,任何一个开发人员通过应用名都可以清晰地知道应用之间合理的调用关系。

以上述的用户下单支付为例,在各个中心服务提供相应的原子业务实现,

  • A 支付中心:完成支付功能
  • B 会员中心:确认可用积分
  • C 营销中心:发放优惠券
  • D 消息中心:发送用户短信

然后整个流程的串联放在聚合层(即business层)实现,不再放在支付中心或其它任何中心服务,在聚合层中确保流程的异常处理和重试。任何流程的串联变化,都只需修改聚合层的代码。流程的状态处理完全从中心服务的业务实现中隔离出来,让中心服务专注于业务的原子粒度实现。

当前分层架构也带来一些开销,在团队规模小的时候,需要拆分的应用个数也是可观的,带来部署的机器成本也是存在的。但总体而言,利大于弊。

5. 结束语

分层架构设计提供了一个自顶向下的设计思路,一旦理顺了架构分层,在团队内掌握好架构分层的要点,则应用的边界定义、架构的可扩展性、团队的沟通协作,都是水到渠成的事情,这也正是架构的艺术魅力所在。