头图

调研字节码插桩技术,用于互联网分布式系统监控设计和实现!

小傅哥
English

作者:小傅哥
博客:https://bugstack.cn

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、来自深夜的电话!

咋滴,你那上线的系统是裸奔呢?

周末熟睡的深夜,突然接到老板电话☎的催促。“赶紧看微信、看微信,咋系统出问题了,我们都不知道,还得用户反馈才知道的!!!”深夜爬起来,打开电脑连上 VPN ,打着哈欠、睁开朦胧的眼睛,查查系统日志,原来是系统挂了,赶紧重启恢复!

虽然重启恢复了系统,也重置了老板扭曲的表情。但系统是怎么挂的呢,因为没有一个监控系统,也不知道是流量太大导致,还是因为程序问题引起,通过一片片的日志,也仅能粗略估计出一些打着好像的标签给老板汇报。不过老板也不傻,聊来聊去,让把所有的系统运行状况都监控出来。

双手拖着困倦的脑袋,一时半会也想不出什么好方法,难道在每个方法上都硬编码上执行耗时计算。之后把信息在统一收集起来,展示到一个监控页面呢,监控页面使用阿帕奇的 echarts,别说要是这样显示了,还真能挺好看还好用。

  • 但这么硬编码也不叫玩意呀,这不把我们部门搬砖的码农累岔气呀!再说了,这么干他们肯定瞧不起我。啥架构师,要监控系统,还得硬编码,傻了不是!!!
  • 这么一想整的没法睡觉,得找找资料,明天给老板汇报!

其实一套线上系统是否稳定运行,取决于它的运行健康度,而这包括;调用量、可用率、影响时长以及服务器性能等各项指标的一个综合值。并且在系统出现异常问题时,可以抓取整个业务方法执行链路并输出;当时的入参、出参、异常信息等等。当然还包括一些JVM、Redis、Mysql的各项性能指标,以用于快速定位并解决问题。

那么要做到这样的事情有什么处理方案呢,其实做法还是比较多的,比如;

  1. 最简单粗暴的就是硬编码在方法中,收取执行耗时以及出入参和异常信息。但这样的编码成本实在太大,而且硬编码完还需要大量回归测试,可能给系统带来一定的风险。万一谁手抖给复制粘贴错了呢!
  2. 可以选择切面方式做一套统一监控的组件,相对来说还是好一些的。但也需要硬编码,比如写入注解,同时维护成本也不低。
  3. 其实市面上对于这样的监控其实是有整套的非入侵监控方案的,比如;Google Dapper、Zipkin等都可以实现监控系统需求,他们都是基于探针技术非入侵的采用字节码增强的方式采集系统运行信息进行分析和监控运行状态。

好,那么本文就来带着大家来尝试下几种不同方式,监控系统运行状态的实现思路。

二、准备工作

本文会基于 AOP、字节码框架(ASMJavassistByte-Buddy),分别实现不同的监控实现代码。整个工程结构如下:

MonitorDesign
├── cn-bugstack-middleware-aop
├── cn-bugstack-middleware-asm
├── cn-bugstack-middleware-bytebuddy
├── cn-bugstack-middleware-javassist
├── cn-bugstack-middleware-test
└──    pom.xml
  • 源码地址:https://github.com/fuzhengwei/MonitorDesign
  • 简单介绍:aop、asm、bytebuddy、javassist,分别是四种不同的实现方案。test 是一个基于 SpringBoot 的简单测试工程。
  • 技术使用:SpringBoot、asm、byte-buddy、javassist

cn-bugstack-middleware-test

@RestController
public class UserController {

    private Logger logger = LoggerFactory.getLogger(UserController.class);

    /**
     * 测试:http://localhost:8081/api/queryUserInfo?userId=aaa
     */
    @RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
    public UserInfo queryUserInfo(@RequestParam String userId) {
        logger.info("查询用户信息,userId:{}", userId);
        return new UserInfo("虫虫:" + userId, 19, "天津市东丽区万科赏溪苑14-0000");
    }

}
  • 接下来的各类监控代码实现,都会以监控 UserController#queryUserInfo 的方法执行信息为主,看看各类技术都是怎么操作的。

三、使用 AOP 做个切面监控

1. 工程结构

cn-bugstack-middleware-aop
└── src
    ├── main
    │   └── java
    │       ├── cn.bugstack.middleware.monitor
    │       │   ├── annotation
    │       │   │   └── DoMonitor.java
    │       │   ├── config
    │       │   │   └── MonitorAutoConfigure.java
    │       │   └── DoJoinPoint.java
    │       └── resources
    │           └── META-INF 
    │               └── spring.factories
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java

基于 AOP 实现的监控系统,核心逻辑的以上工程并不复杂,其核心点在于对切面的理解和运用,以及一些配置项需要按照 SpringBoot 中的实现方式进行开发。

  • DoMonitor,是一个自定义注解。它作用就是在需要使用到的方法监控接口上,添加此注解并配置必要的信息。
  • MonitorAutoConfigure,配置下是可以对 SpringBoot yml 文件的使用,可以处理一些 Bean 的初始化操作。
  • DoJoinPoint,是整个中间件的核心部分,它负责对所有添加自定义注解的方法进行拦截和逻辑处理。

2. 定义监控注解

cn.bugstack.middleware.monitor.annotation.DoMonitor

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DoMonitor {

   String key() default "";
   String desc() default "";

}
  • @Retention(RetentionPolicy.RUNTIME),Annotations are to be recorded in the class file by the compiler and retained by the VM at run time, so they may be read reflectively.
  • @Retention 是注解的注解,也称作元注解。这个注解里面有一个入参信息 RetentionPolicy.RUNTIME 在它的注释中有这样一段描述:Annotations are to be recorded in the class file by the compiler and retained by the VM at run time, so they may be read reflectively. 其实说的就是加了这个注解,它的信息会被带到JVM运行时,当你在调用方法时可以通过反射拿到注解信息。除此之外,RetentionPolicy 还有两个属性 SOURCECLASS,其实这三个枚举正式对应了Java代码的加载和运行顺序,Java源码文件 -> .class文件 -> 内存字节码。并且后者范围大于前者,所以一般情况下只需要使用 RetentionPolicy.RUNTIME 即可。
  • @Target 也是元注解起到标记作用,它的注解名称就是它的含义,目标,也就是我们这个自定义注解 DoWhiteList 要放在类、接口还是方法上。在 JDK1.8 中 ElementType 一共提供了10中目标枚举,TYPE、FIELD、METHOD、PARAMETER、CONSTRUCTOR、LOCAL_VARIABLE、ANNOTATION_TYPE、PACKAGE、TYPE_PARAMETER、TYPE_USE,可以参考自己的自定义注解作用域进行设置
  • 自定义注解 @DoMonitor 提供了监控的 key 和 desc描述,这个主要记录你监控方法的为唯一值配置和对监控方法的文字描述。

3. 定义切面拦截

cn.bugstack.middleware.monitor.DoJoinPoint

@Aspect
public class DoJoinPoint {

    @Pointcut("@annotation(cn.bugstack.middleware.monitor.annotation.DoMonitor)")
    public void aopPoint() {
    }

    @Around("aopPoint() && @annotation(doMonitor)")
    public Object doRouter(ProceedingJoinPoint jp, DoMonitor doMonitor) throws Throwable {
        long start = System.currentTimeMillis();
        Method method = getMethod(jp);
        try {
            return jp.proceed();
        } finally {
            System.out.println("监控 - Begin By AOP");
            System.out.println("监控索引:" + doMonitor.key());
            System.out.println("监控描述:" + doMonitor.desc());
            System.out.println("方法名称:" + method.getName());
            System.out.println("方法耗时:" + (System.currentTimeMillis() - start) + "ms");
            System.out.println("监控 - End\r\n");
        }
    }

    private Method getMethod(JoinPoint jp) throws NoSuchMethodException {
        Signature sig = jp.getSignature();
        MethodSignature methodSignature = (MethodSignature) sig;
        return jp.getTarget().getClass().getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
    }

}
  • 使用注解 @Aspect,定义切面类。这是一个非常常用的切面定义方式。
  • @Pointcut("@annotation(cn.bugstack.middleware.monitor.annotation.DoMonitor)"),定义切点。在 Pointcut 中提供了很多的切点寻找方式,有指定方法名称的、有范围筛选表达式的,也有我们现在通过自定义注解方式的。一般在中间件开发中,自定义注解方式使用的比较多,因为它可以更加灵活的运用到各个业务系统中。
  • @Around("aopPoint() && @annotation(doMonitor)"),可以理解为是对方法增强的织入动作,有了这个注解的效果就是在你调用已经加了自定义注解 @DoMonitor 的方法时,会先进入到此切点增强的方法。那么这个时候就你可以做一些对方法的操作动作了,比如我们要做一些方法监控和日志打印等。
  • 最后在 doRouter 方法体中获取把方法执行 jp.proceed(); 使用 try finally 包装起来,并打印相关的监控信息。这些监控信息的获取最后都是可以通过异步消息的方式发送给服务端,再由服务器进行处理监控数据和处理展示到监控页面。

4. 初始化切面类

cn.bugstack.middleware.monitor.config.MonitorAutoConfigure

@Configuration
public class MonitorAutoConfigure {

    @Bean
    @ConditionalOnMissingBean
    public DoJoinPoint point(){
        return new DoJoinPoint();
    }

}
  • @Configuration,可以算作是一个组件注解,在 SpringBoot 启动时可以进行加载创建出 Bean 文件。因为 @Configuration 注解有一个 @Component 注解
  • MonitorAutoConfigure 可以处理自定义在 yml 中的配置信息,也可以用于初始化 Bean 对象,比如在这里我们实例化了 DoJoinPoint 切面对象。

5. 运行测试

5.1 引入 POM 配置

<!-- 监控方式:AOP -->
<dependency>
    <groupId>cn.bugstack.middleware</groupId>
    <artifactId>cn-bugstack-middleware-aop</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

5.2 方法上配置监控注册

@DoMonitor(key = "cn.bugstack.middleware.UserController.queryUserInfo", desc = "查询用户信息")
@RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
public UserInfo queryUserInfo(@RequestParam String userId) {
    logger.info("查询用户信息,userId:{}", userId);
    return new UserInfo("虫虫:" + userId, 19, "天津市东丽区万科赏溪苑14-0000");
}
  • 在通过 POM 引入自己的开发的组件后,就可以通过自定义的注解,拦截方法获取监控信息。

5.3 测试结果

2021-07-04 23:21:10.710  INFO 19376 --- [nio-8081-exec-1] c.b.m.test.interfaces.UserController     : 查询用户信息,userId:aaa
监控 - Begin By AOP
监控索引:cn.bugstack.middleware.UserController.queryUserInfo
监控描述:查询用户信息
方法名称:queryUserInfo
方法耗时:6ms
监控 - End
  • 通过启动 SpringBoot 程序,在网页中打开 URL 地址:http://localhost:8081/api/queryUserInfo?userId=aaa,可以看到已经可以把监控信息打印到控制台了。
  • 此种通过自定义注解的配置方式,能解决一定的硬编码工作,但如果在方法上大量的添加注解,也是需要一定的开发工作的。

接下来我们开始介绍关于使用字节码插桩非入侵的方式进行系统监控,关于字节码插桩常用的有三个组件,包括:ASM、Javassit、Byte-Buddy,接下来我们分别介绍它们是如何使用的。

四、ASM

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

1. 先来个测试

cn.bugstack.middleware.monitor.test.ApiTest

private static byte[] generate() {
    ClassWriter classWriter = new ClassWriter(0);
    // 定义对象头;版本号、修饰符、全类名、签名、父类、实现的接口
    classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "cn/bugstack/demo/asm/AsmHelloWorld", null, "java/lang/Object", null);
    // 添加方法;修饰符、方法名、描述符、签名、异常
    MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
    // 执行指令;获取静态属性
    methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
    // 加载常量 load constant
    methodVisitor.visitLdcInsn("Hello World ASM!");
    // 调用方法
    methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    // 返回
    methodVisitor.visitInsn(Opcodes.RETURN);
    // 设置操作数栈的深度和局部变量的大小
    methodVisitor.visitMaxs(2, 1);
    // 方法结束
    methodVisitor.visitEnd();
    // 类完成
    classWriter.visitEnd();
    // 生成字节数组
    return classWriter.toByteArray();
}
  • 以上这段代码就是基于 ASM 编写的 HelloWorld,整个过程包括:定义一个类的生成 ClassWriter、设定版本、修饰符、全类名、签名、父类、实现的接口,其实也就是那句;public class HelloWorld
  • 类型描述符:

    | Java 类型 | 类型描述符 |
    | :--------- | :------------------- |
    | boolean | Z |
    | char | C |
    | byte | B |
    | short | S |
    | int | I |
    | float | F |
    | long | J |
    | double | D |
    | Object | Ljava/lang/Object; |
    | int[] | [I |
    | Object[][] | [[Ljava/lang/Object; |

  • 方法描述符:

    | 源文件中的方法声明 | 方法描述符 |
    | :----------------------- | :---------------------- |
    | void m(int i, float f) | (IF)V |
    | int m(Object o) | (Ljava/lang/Object;)I |
    | int[] m(int i, String s) | (ILjava/lang/String;)[I |
    | Object m(int[] i) | ([I)Ljava/lang/Object; |

  • 执行指令;获取静态属性。主要是获得 System.out
  • 加载常量 load constant,输出我们的HelloWorld methodVisitor.visitLdcInsn("Hello World");
  • 最后是调用输出方法并设置空返回,同时在结尾要设置操作数栈的深度和局部变量的大小。
  • 这样输出一个 HelloWorld 是不还是蛮有意思的,虽然你可能觉得这编码起来实在太难了吧,也非常难理解。不过你可以安装一个 ASM 在 IDEA 中的插件 ASM Bytecode Outline,能更加方便的查看一个普通的代码在使用 ASM 的方式该如何处理。
  • 另外以上这段代码的测试结果,主要是生成一个 class 文件和输出 Hello World ASM! 结果。

2. 监控设计工程结构

cn-bugstack-middleware-asm
└── src
    ├── main
    │   ├── java
    │   │   └── cn.bugstack.middleware.monitor
    │   │       ├── config
    │   │       │   ├── MethodInfo.java
    │   │       │   └── ProfilingFilter.java
    │   │       ├── probe
    │   │       │   ├── ProfilingAspect.java
    │   │       │   ├── ProfilingClassAdapter.java
    │   │       │   ├── ProfilingMethodVisitor.java
    │   │       │   └── ProfilingTransformer.java
    │   │       └── PreMain.java
    │   └── resources    
    │       └── META_INF
    │           └── MANIFEST.MF
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java

以上工程结构是使用 ASM 框架给系统方法做增强操作,也就是相当于通过框架完成硬编码写入方法前后的监控信息。不过这个过程转移到了 Java 程序启动时在 Javaagent#premain 进行处理。

  • MethodInfo 是方法的定义,主要是描述类名、方法名、描述、入参、出参信息。
  • ProfilingFilter 是监控的配置信息,主要是过滤一些不需要字节码增强操作的方法,比如main、hashCode、javax/等
  • ProfilingAspect、ProfilingClassAdapter、ProfilingMethodVisitor、ProfilingTransformer,这四个类主要是完成字节码插装操作和输出监控结果的类。
  • PreMain 提供了 Javaagent 的入口,JVM 首先尝试在代理类上调用 premain 方法。
  • MANIFEST.MF 是配置信息,主要是找到 Premain-Class Premain-Class: cn.bugstack.middleware.monitor.PreMain

3. 监控类入口

cn.bugstack.middleware.monitor.PreMain

public class PreMain {

    //JVM 首先尝试在代理类上调用以下方法
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new ProfilingTransformer());
    }

    //如果代理类没有实现上面的方法,那么 JVM 将尝试调用该方法
    public static void premain(String agentArgs) {
    }

}
  • 这个是 Javaagent 技术的固定入口方法类,同时还需要把这个类的路径配置到 MANIFEST.MF 中。

4. 字节码方法处理

cn.bugstack.middleware.monitor.probe.ProfilingTransformer

public class ProfilingTransformer implements ClassFileTransformer {

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            if (ProfilingFilter.isNotNeedInject(className)) {
                return classfileBuffer;
            }
            return getBytes(loader, className, classfileBuffer);
        } catch (Throwable e) {
            System.out.println(e.getMessage());
        }
        return classfileBuffer;
    }

    private byte[] getBytes(ClassLoader loader, String className, byte[] classfileBuffer) {
        ClassReader cr = new ClassReader(classfileBuffer);
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
        ClassVisitor cv = new ProfilingClassAdapter(cw, className);
        cr.accept(cv, ClassReader.EXPAND_FRAMES);
        return cw.toByteArray();
    }

}
  • 使用 ASM 核心类 ClassReader、ClassWriter、ClassVisitor,处理传入进行的类加载器、类名、字节码等,负责字节码的增强操作。
  • 此处主要是关于 ASM 的操作类,ClassReader、ClassWriter、ClassVisitor,关于字节码编程的文章:ASM、Javassist、Byte-bu 系列文章

5.字节码方法解析

cn.bugstack.middleware.monitor.probe.ProfilingMethodVisitor

public class ProfilingMethodVisitor extends AdviceAdapter {

    private List<String> parameterTypeList = new ArrayList<>();
    private int parameterTypeCount = 0;     // 参数个数
    private int startTimeIdentifier;        // 启动时间标记
    private int parameterIdentifier;        // 入参内容标记
    private int methodId = -1;              // 方法全局唯一标记
    private int currentLocal = 0;           // 当前局部变量值
    private final boolean isStaticMethod;   // true;静态方法,false;非静态方法
    private final String className;

    protected ProfilingMethodVisitor(int access, String methodName, String desc, MethodVisitor mv, String className, String fullClassName, String simpleClassName) {
        super(ASM5, mv, access, methodName, desc);
        this.className = className;
        // 判断是否为静态方法,非静态方法中局部变量第一个值是this,静态方法是第一个入参参数
        isStaticMethod = 0 != (access & ACC_STATIC);
        //(String var1,Object var2,String var3,int var4,long var5,int[] var6,Object[][] var7,Req var8)=="(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;IJ[I[[Ljava/lang/Object;Lorg/itstack/test/Req;)V"
        Matcher matcher = Pattern.compile("(L.*?;|\\[{0,2}L.*?;|[ZCBSIFJD]|\\[{0,2}[ZCBSIFJD]{1})").matcher(desc.substring(0, desc.lastIndexOf(')') + 1));
        while (matcher.find()) {
            parameterTypeList.add(matcher.group(1));
        }
        parameterTypeCount = parameterTypeList.size();
        methodId = ProfilingAspect.generateMethodId(new MethodInfo(fullClassName, simpleClassName, methodName, desc, parameterTypeList, desc.substring(desc.lastIndexOf(')') + 1)));
    }     

    //... 一些字节码插桩操作 
}
  • 当程序启动加载的时候,每个类的每一个方法都会被监控到。类的名称、方法的名称、方法入参出参的描述等,都可以在这里获取。
  • 为了可以在后续监控处理不至于每一次都去传参(方法信息)浪费消耗性能,一般这里都会给每个方法生产一个全局防重的 id ,通过这个 id 就可以查询到对应的方法。
  • 另外从这里可以看到的方法的入参和出参被描述成一段指定的码,(II)Ljava/lang/String; ,为了我们后续对参数进行解析,那么需要将这段字符串进行拆解。

6. 运行测试

6.1 配置 VM 参数 Javaagent

-javaagent:E:\itstack\git\github.com\MonitorDesign\cn-bugstack-middleware-asm\target\cn-bugstack-middleware-asm.jar
  • IDEA 运行时候配置到 VM options 中,jar包地址按照自己的路径进行配置。

6.2 测试结果

监控 - Begin By ASM
方法:cn.bugstack.middleware.test.interfaces.UserController$$EnhancerBySpringCGLIB$$8f5a18ca.queryUserInfo
入参:null 入参类型:["Ljava/lang/String;"] 入数[值]:["aaa"]
出参:Lcn/bugstack/middleware/test/interfaces/dto/UserInfo; 出参[值]:{"address":"天津市东丽区万科赏溪苑14-0000","age":19,"code":"0000","info":"success","name":"虫虫:aaa"}
耗时:54(s)
监控 - End
  • 从运行测试结果可以看到,在使用 ASM 监控后,就不需要硬编码也不需要 AOP 的方式在代码中操作了。同时还可以监控到更完整的方法执行信息,包括入参类型、入参值和出参信息、出参值。
  • 但可能大家会发现 ASM 操作起来还是挺麻烦的,尤其是一些很复杂的编码逻辑中,可能会遇到各种各样问题,因此接下来我们还会介绍一些基于 ASM 开发的组件,这些组件也可以实现同样的功能。

五、Javassist

Javassist是一个开源的分析、编辑和创建Java字节码的类库。是由东京工业大学的数学和计算机科学系的 Shigeru Chiba (千叶 滋)所创建的。它已加入了开放源代码JBoss 应用服务器项目,通过使用Javassist对字节码操作为JBoss实现动态"AOP"框架。

1. 先来个测试

cn.bugstack.middleware.monitor.test.ApiTest

public class ApiTest {

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();

        CtClass ctClass = pool.makeClass("cn.bugstack.middleware.javassist.MathUtil");

        // 属性字段
        CtField ctField = new CtField(CtClass.doubleType, "π", ctClass);
        ctField.setModifiers(Modifier.PRIVATE + Modifier.STATIC + Modifier.FINAL);
        ctClass.addField(ctField, "3.14");

        // 方法:求圆面积
        CtMethod calculateCircularArea = new CtMethod(CtClass.doubleType, "calculateCircularArea", new CtClass[]{CtClass.doubleType}, ctClass);
        calculateCircularArea.setModifiers(Modifier.PUBLIC);
        calculateCircularArea.setBody("{return π * $1 * $1;}");
        ctClass.addMethod(calculateCircularArea);

        // 方法;两数之和
        CtMethod sumOfTwoNumbers = new CtMethod(pool.get(Double.class.getName()), "sumOfTwoNumbers", new CtClass[]{CtClass.doubleType, CtClass.doubleType}, ctClass);
        sumOfTwoNumbers.setModifiers(Modifier.PUBLIC);
        sumOfTwoNumbers.setBody("{return Double.valueOf($1 + $2);}");
        ctClass.addMethod(sumOfTwoNumbers);
        // 输出类的内容
        ctClass.writeFile();

        // 测试调用
        Class clazz = ctClass.toClass();
        Object obj = clazz.newInstance();

        Method method_calculateCircularArea = clazz.getDeclaredMethod("calculateCircularArea", double.class);
        Object obj_01 = method_calculateCircularArea.invoke(obj, 1.23);
        System.out.println("圆面积:" + obj_01);

        Method method_sumOfTwoNumbers = clazz.getDeclaredMethod("sumOfTwoNumbers", double.class, double.class);
        Object obj_02 = method_sumOfTwoNumbers.invoke(obj, 1, 2);
        System.out.println("两数和:" + obj_02);
    }

}
  • 这是一个使用 Javassist 生成的求圆面积和抽象的类和方法并运行结果的过程,可以看到 Javassist 主要是 ClassPool、CtClass、CtField、CtMethod 等方法的使用。
  • 测试结果主要包括会生成一个指定路径下的类 cn.bugstack.middleware.javassist.MathUtil,同时还会在控制台输出结果。

生成的类

public class MathUtil {
  private static final double π = 3.14D;

  public double calculateCircularArea(double var1) {
      return 3.14D * var1 * var1;
  }

  public Double sumOfTwoNumbers(double var1, double var3) {
      return var1 + var3;
  }

  public MathUtil() {
  }
}

测试结果

圆面积:4.750506
两数和:3.0

Process finished with exit code 0

2. 监控设计工程结构

cn-bugstack-middleware-javassist
└── src
    ├── main
    │   ├── java
    │   │   └── cn.bugstack.middleware.monitor
    │   │       ├── config
    │   │       │   └── MethodDescription.java
    │   │       ├── probe
    │   │       │   ├── Monitor.java
    │   │       │   └── MyMonitorTransformer.java
    │   │       └── PreMain.java
    │   └── resources
    │       └── META_INF
    │           └── MANIFEST.MF
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java
  • 整个使用 javassist 实现的监控框架来看,与 ASM 的结构非常相似,但大部分操作字节码的工作都交给了 javassist 框架来处理,所以整个代码结构看上去更简单了。

3. 监控方法插桩

cn.bugstack.middleware.monitor.probe.MyMonitorTransformer

public class MyMonitorTransformer implements ClassFileTransformer {

    private static final Set<String> classNameSet = new HashSet<>();

    static {
        classNameSet.add("cn.bugstack.middleware.test.interfaces.UserController");
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        try {
            String currentClassName = className.replaceAll("/", ".");
            if (!classNameSet.contains(currentClassName)) { // 提升classNameSet中含有的类
                return null;
            }

            // 获取类
            CtClass ctClass = ClassPool.getDefault().get(currentClassName);
            String clazzName = ctClass.getName();

            // 获取方法
            CtMethod ctMethod = ctClass.getDeclaredMethod("queryUserInfo");
            String methodName = ctMethod.getName();

            // 方法信息:methodInfo.getDescriptor();
            MethodInfo methodInfo = ctMethod.getMethodInfo();

            // 方法:入参信息
            CodeAttribute codeAttribute = methodInfo.getCodeAttribute();
            LocalVariableAttribute attr = (LocalVariableAttribute) codeAttribute.getAttribute(LocalVariableAttribute.tag);
            CtClass[] parameterTypes = ctMethod.getParameterTypes();

            boolean isStatic = (methodInfo.getAccessFlags() & AccessFlag.STATIC) != 0;  // 判断是否为静态方法
            int parameterSize = isStatic ? attr.tableLength() : attr.tableLength() - 1; // 静态类型取值
            List<String> parameterNameList = new ArrayList<>(parameterSize);            // 入参名称
            List<String> parameterTypeList = new ArrayList<>(parameterSize);            // 入参类型
            StringBuilder parameters = new StringBuilder();                             // 参数组装;$1、$2...,$$可以获取全部,但是不能放到数组初始化

            for (int i = 0; i < parameterSize; i++) {
                parameterNameList.add(attr.variableName(i + (isStatic ? 0 : 1))); // 静态类型去掉第一个this参数
                parameterTypeList.add(parameterTypes[i].getName());
                if (i + 1 == parameterSize) {
                    parameters.append("$").append(i + 1);
                } else {
                    parameters.append("$").append(i + 1).append(",");
                }
            }

            // 方法:出参信息
            CtClass returnType = ctMethod.getReturnType();
            String returnTypeName = returnType.getName();

            // 方法:生成方法唯一标识ID
            int idx = Monitor.generateMethodId(clazzName, methodName, parameterNameList, parameterTypeList, returnTypeName);

            // 定义属性
            ctMethod.addLocalVariable("startNanos", CtClass.longType);
            ctMethod.addLocalVariable("parameterValues", ClassPool.getDefault().get(Object[].class.getName()));

            // 方法前加强
            ctMethod.insertBefore("{ startNanos = System.nanoTime(); parameterValues = new Object[]{" + parameters.toString() + "}; }");

            // 方法后加强
            ctMethod.insertAfter("{ cn.bugstack.middleware.monitor.probe.Monitor.point(" + idx + ", startNanos, parameterValues, $_);}", false); // 如果返回类型非对象类型,$_ 需要进行类型转换

            // 方法;添加TryCatch
            ctMethod.addCatch("{ cn.bugstack.middleware.monitor.probe.Monitor.point(" + idx + ", $e); throw $e; }", ClassPool.getDefault().get("java.lang.Exception"));   // 添加异常捕获

            return ctClass.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

}
  • 与 ASM 实现相比,整体的监控方法都是类似的,所以这里只展示下不同的地方。
  • 通过 Javassist 的操作,主要是实现一个 ClassFileTransformer 接口的 transform 方法,在这个方法中获取字节码并进行相应的处理。
  • 处理过程包括:获取类、获取方法、获取入参信息、获取出参信息、给方法生成唯一ID、之后开始进行方法的前后增强操作,这个增强也就是在方法块中添加监控代码。
  • 最后返回字节码信息 return ctClass.toBytecode(); 现在你新加入的字节码就已经可以被程序加载处理了。

4. 运行测试

4.1 配置 VM 参数 Javaagent

-javaagent:E:\itstack\git\github.com\MonitorDesign\cn-bugstack-middleware-javassist\target\cn-bugstack-middleware-javassist.jar
  • IDEA 运行时候配置到 VM options 中,jar包地址按照自己的路径进行配置。

4.2 测试结果

监控 -  Begin By Javassist
方法:cn.bugstack.middleware.test.interfaces.UserController$$EnhancerBySpringCGLIB$$8f5a18ca.queryUserInfo
入参:null 入参类型:["Ljava/lang/String;"] 入数[值]:["aaa"]
出参:Lcn/bugstack/middleware/test/interfaces/dto/UserInfo; 出参[值]:{"address":"天津市东丽区万科赏溪苑14-0000","age":19,"code":"0000","info":"success","name":"虫虫:aaa"}
耗时:46(s)
监控 - End
  • 从测试结果来看与 ASM 做字节码插桩的效果是一样,都可以做到监控系统执行信息。但是这样的框架会使开发流程更简单,也更容易控制。

六、Byte-Buddy

2015年10月,Byte Buddy被 Oracle 授予了 Duke's Choice大奖。该奖项对Byte Buddy的“ Java技术方面的巨大创新 ”表示赞赏。我们为获得此奖项感到非常荣幸,并感谢所有帮助Byte Buddy取得成功的用户以及其他所有人。我们真的很感激!

Byte Buddy 是一个代码生成和操作库,用于在 Java 应用程序运行时创建和修改 Java 类,而无需编译器的帮助。除了 Java 类库附带的代码生成实用程序外,Byte Buddy 还允许创建任意类,并且不限于实现用于创建运行时代理的接口。此外,Byte Buddy 提供了一种方便的 API,可以使用 Java 代理或在构建过程中手动更改类。

  • 无需理解字节码指令,即可使用简单的 API 就能很容易操作字节码,控制类和方法。
  • 已支持Java 11,库轻量,仅取决于Java字节代码解析器库ASM的访问者API,它本身不需要任何其他依赖项。
  • 比起JDK动态代理、cglib、Javassist,Byte Buddy在性能上具有一定的优势。

1. 先来个测试

cn.bugstack.middleware.monitor.test.ApiTest

public class ApiTest {

    public static void main(String[] args) throws IllegalAccessException, InstantiationException {
        String helloWorld = new ByteBuddy()
                .subclass(Object.class)
                .method(named("toString"))
                .intercept(FixedValue.value("Hello World!"))
                .make()
                .load(ApiTest.class.getClassLoader())
                .getLoaded()
                .newInstance()
                .toString();

        System.out.println(helloWorld);
    }

}
  • 这是一个使用 ByteBuddy 语法生成的 "Hello World!" 案例,他的运行结果就是一行,Hello World!,整个代码块核心功能就是通过 method(named("toString")),找到 toString 方法,再通过拦截 intercept,设定此方法的返回值。FixedValue.value("Hello World!")。到这里其实一个基本的方法就通过 Byte-buddy ,最后加载、初始化和调用输出。

测试结果

Hello World!

Process finished with exit code 0

2. 监控设计工程结构

cn-bugstack-middleware-bytebuddy
└── src
    ├── main
    │   ├── java
    │   │   └── cn.bugstack.middleware.monitor
    │   │       ├── MonitorMethod
    │   │       └── PreMain.java
    │   └── resources
    │       └── META_INF
    │           └── MANIFEST.MF
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java
  • 这是我个人最喜欢的一个框架,因为它操作的方便性,可以像使用普通的业务代码一样使用字节码增强的操作。从现在的工程结构你能看得出来,代码类数量越来越少了。

3. 监控方法插桩

cn.bugstack.middleware.monitor.MonitorMethod

public class MonitorMethod {

    @RuntimeType
    public static Object intercept(@Origin Method method, @SuperCall Callable<?> callable, @AllArguments Object[] args) throws Exception {
        long start = System.currentTimeMillis();
        Object resObj = null;
        try {
            resObj = callable.call();
            return resObj;
        } finally {
            System.out.println("监控 - Begin By Byte-buddy");
            System.out.println("方法名称:" + method.getName());
            System.out.println("入参个数:" + method.getParameterCount());
            for (int i = 0; i < method.getParameterCount(); i++) {
                System.out.println("入参 Idx:" + (i + 1) + " 类型:" + method.getParameterTypes()[i].getTypeName() + " 内容:" + args[i]);
            }
            System.out.println("出参类型:" + method.getReturnType().getName());
            System.out.println("出参结果:" + resObj);
            System.out.println("方法耗时:" + (System.currentTimeMillis() - start) + "ms");
            System.out.println("监控 - End\r\n");
        }
    }

}
  • @Origin,用于拦截原有方法,这样就可以获取到方法中的相关信息。
  • 这一部分的信息相对来说比较全,尤其也获取到了参数的个数和类型,这样就可以在后续的处理参数时进行循环输出。

常用注解说明

除了以上为了获取方法的执行信息使用到的注解外,Byte Buddy 还提供了很多其他的注解。如下;

注解说明
@Argument绑定单个参数
@AllArguments绑定所有参数的数组
@This当前被拦截的、动态生成的那个对象
@Super当前被拦截的、动态生成的那个对象的父类对象
@Origin可以绑定到以下类型的参数:Method 被调用的原始方法 Constructor 被调用的原始构造器 Class 当前动态创建的类 MethodHandle MethodType String 动态类的toString()的返回值 int 动态方法的修饰符
@DefaultCall调用默认方法而非super的方法
@SuperCall用于调用父类版本的方法
@Super注入父类型对象,可以是接口,从而调用它的任何方法
@RuntimeType可以用在返回值、参数上,提示ByteBuddy禁用严格的类型检查
@Empty注入参数的类型的默认值
@StubValue注入一个存根值。对于返回引用、void的方法,注入null;对于返回原始类型的方法,注入0
@FieldValue注入被拦截对象的一个字段的值
@Morph类似于@SuperCall,但是允许指定调用参数

常用核心API

  1. ByteBuddy

    • 流式API方式的入口类
    • 提供Subclassing/Redefining/Rebasing方式改写字节码
    • 所有的操作依赖DynamicType.Builder进行,创建不可变的对象
  2. ElementMatchers(ElementMatcher)

    • 提供一系列的元素匹配的工具类(named/any/nameEndsWith等等)
    • ElementMatcher(提供对类型、方法、字段、注解进行matches的方式,类似于Predicate)
    • Junction对多个ElementMatcher进行了and/or操作
  3. DynamicType

    (动态类型,所有字节码操作的开始,非常值得关注)

    • Unloaded(动态创建的字节码还未加载进入到虚拟机,需要类加载器进行加载)
    • Loaded(已加载到jvm中后,解析出Class表示)
    • Default(DynamicType的默认实现,完成相关实际操作)
  4. `Implementation

    (用于提供动态方法的实现)

    • FixedValue(方法调用返回固定值)
    • MethodDelegation(方法调用委托,支持两种方式: Class的static方法调用、object的instance method方法调用)
  5. Builder

    (用于创建DynamicType,相关接口以及实现后续待详解)

    • MethodDefinition
    • FieldDefinition
    • AbstractBase

4. 配置入口方法

cn.bugstack.middleware.monitor.PreMain

public class PreMain {

    //JVM 首先尝试在代理类上调用以下方法
    public static void premain(String agentArgs, Instrumentation inst) {
        AgentBuilder.Transformer transformer = (builder, typeDescription, classLoader, javaModule) -> {
            return builder
                    .method(ElementMatchers.named("queryUserInfo")) // 拦截任意方法
                    .intercept(MethodDelegation.to(MonitorMethod.class)); // 委托
        };

        new AgentBuilder
                .Default()
                .type(ElementMatchers.nameStartsWith(agentArgs))  // 指定需要拦截的类 "cn.bugstack.demo.test"
                .transform(transformer)
                .installOn(inst);
    }

    //如果代理类没有实现上面的方法,那么 JVM 将尝试调用该方法
    public static void premain(String agentArgs) {
    }

}
  • premain 方法中主要是对实现的 MonitorMethod 进行委托使用,同时还在 method 设置了拦截的方法,这个拦截方法还可以到类路径等。

5. 运行测试

5.1 配置 VM 参数 Javaagent

-javaagent:E:\itstack\git\github.com\MonitorDesign\cn-bugstack-middleware-bytebuddy\target\cn-bugstack-middleware-bytebuddy.jar
  • IDEA 运行时候配置到 VM options 中,jar包地址按照自己的路径进行配置。

5.2 测试结果

监控 - Begin By Byte-buddy
方法名称:queryUserInfo
入参个数:1
入参 Idx:1 类型:java.lang.String 内容:aaa
出参类型:cn.bugstack.middleware.test.interfaces.dto.UserInfo
出参结果:cn.bugstack.middleware.test.interfaces.dto.@214b199c
方法耗时:1ms
监控 - End
  • Byte-buddy 是我们整个测试过程的几个字节码框架中,操作起来最简单,最方便的,也非常容易扩容信息。整个过程就像最初使用 AOP 一样简单,但却满足了非入侵的监控需求。
  • 所以在使用字节码框架的时候,可以考虑选择使用 Byte-buddy 这个非常好用的字节码框架。

七、总结

  • ASM 这种字节码编程的应用是非常广的,但可能确实平时看不到的,因为他都是与其他框架结合一起作为支撑服务使用。像这样的技术还有很多,比如 javassit、Cglib、jacoco等等。
  • 在一些全链路监控中的组件中 Javassist 的使用非常多,它即可使用编码的方式操作字节码增强,也可以像 ASM 那样进行处理。
  • Byte-buddy 是一个非常方便的框架,目前使用也越来越广泛,并且上手使用的学习难度也是几个框架中最低的。除了本章节的案例使用介绍外,还可以通过官网:https://bytebuddy.net,去了解更多关于 Byte Buddy 的内容。
  • 本章节所有的源码已经上传到GitHub:https://github.com/fuzhengwei/MonitorDesign

八、系列推荐

阅读 288

CodeGuide | 程序员编码指南
公众号:bugstack虫洞栈,回复:设计模式,可以下载《重学Java设计模式》PDF,全网下载量17万+ | 这是一...

CodeGuide | 程序员编码指南 - 原创文章、案例源码、资料书籍、简历模版等下载。

3.7k 声望
21.6k 粉丝
0 条评论
你知道吗?

CodeGuide | 程序员编码指南 - 原创文章、案例源码、资料书籍、简历模版等下载。

3.7k 声望
21.6k 粉丝
宣传栏