Java 字节码插桩探秘 与 Byte-Buddy 实战

1,861 阅读11分钟

1. Java 字节码

JVM 针对各种操作系统和平台都进行了定制,无论在什么平台,都可以通过 javac 命令将一个 (.java 文件)编译成固定格式的字节码(.class文件)供 JVM 使用。之所以被称为字节码,是因为  .class 文件是由十六进制值组成的,JVM 以两个十六进制值为一组,就是以字节为单位进行读取:

006c8c0db3a25ee05c2faad9438622fd.png

JVM 对字节码的规范是有要求的,要求每一个字节码文件都要有十部分固定的顺序组成,具体细节请读者自行阅读专项文章进行学习:

9f640652378b8dee00beb1155c29dbc9.png

2. 字节码插桩

字节码插桩,实质就是在编译期或运行期进行字节码增强,以便在运行期影响程序的执行行为。按照增强时机,可以分为编译时增强 (Pluggable Annotation Processing),运行时增强 (Dynamic ProxyJava Agent)。

Java Agent 是 Java1.5 后引入的新特性,其主要作用是在 class 被加载前对其拦截,插入我们监听的[字节码],也叫字节码插桩。作为 JVM 的 AOP,就需要有 AOP 的功能,Java Agent 提供了两个类似 AOP 的功能。

  • premain: 可以在main运行之前进行一些操作(Java 的入口是 main 方法)
  • agentmain: 控制类运行时的行为(Arthas 使用的就是这种)

在 JVM 中,只会调用其中一个。
要构建一个 agent 程序,大体可以分为以下步骤:

  1. 使用字节码增加工具,编写增强代码
  2. 在 manifest 中指定 Premain-Class / Agent-Class 属性
  3. 使用参数加载或者使用 attach 方式改变 app 项目中的内容。

为什么需要在运行时生成代码?

Java 是一个强类型语言系统,要求变量和对象都有一个确定的类型,不兼容类型赋值都会造成转换异常,通常情况下这种错误都会被编译器检查出来,如此严格的类型在大多数情况下是比较令人满意的,这对构建具有非常强可读性和稳定性的应用有很大的帮助,这也是 Java 能在企业编程中的普及的一个原因之一。

然而,因为起强类型的检查,限制了其他领域语言应用范围。比如在编写一个框架时,通常我们并不知道应用程序定义的类型,因为当这个库被编译时,我们还不知道这些类型,为了能在这种情况下能调用或者访问应用程序的方法或者变量,Java 类库提供了一套反射 API。使用这套反射 API,我们就可以反省为知类型,进而调用方法或者访问属性。但是,Java 反射有如下缺点:

  • 需要执行一个相当昂贵的方法查找来获取描述特定方法的对象,因此,相比硬编码的方法调用,使用 反射 API 非常慢。
  • 反射 API 能绕过类型安全检查,可能会因为使用不当照成意想不到的问题,这样就错失了 Java 编程语言的一大特性。

下边例举一些常见的字节码工具的特性:

1650339740627-dd9b5858-0d19-48d9-8db9-571f673dccdd.png

字节码工具java-proxyasmJavassistcglibbytebuddy
类创建支持支持支持支持支持
实现接口支持支持支持支持支持
方法调用支持支持支持支持支持
类扩展不支持支持支持支持支持
父类方法调用不支持支持支持支持支持
优点容易上手,简单动态代理首选任意字节码插入,几乎不受限制java原始语法,字符串形式插入,写入直观bytebuddy看起来差不多支持任意维度的拦截,可以获取原始类、方法,以及代理类和全部参数
缺点功能有限,不支持扩展学习难度大,编写代码量大不支持jdk1.5以上的语法,如泛型,增强for正在被bytebuddy淘汰不太直观,学习理解有些成本,API非常多
常见应用spring-aop,MyBatiscglib,bytebuddyFastjson,MyBatisspring-aop,EasyMock,jackson-databindSkyWalking,Mockito,Hibernate,powermock
学习成本一星五星二星三星三星

3. Byte-Buddy 简介

Byte-Buddy 是一种[字节码]技术框架,其广泛用于中间件开发,用于字节码增强,变更字节码的形式来拦截,用途如:链路追踪,系统 JVM 状态监控,耗时分析 等。目前市面上常见的链路追踪框架为:skywalking、美团 cat 等。

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

Byte-Buddy 动态增强代码总共有三种方式:

  • subclass: 对应 ByteBuddy.subclass() 方法。这种方式比较好理解,就是为目标类(即被增强的类)生成一个子类,在子类方法中插入动态代码。

  • rebasing: 对应 ByteBuddy.rebasing() 方法。当使用 rebasing 方式增强一个类时,Byte-Buddy 保存目标类中所有方法的实现,也就是说,当 Byte-Buddy 遇到冲突的字段或方法时,会将原来的字段或方法实现复制到具有兼容签名的重新命名的私有方法中,而不会抛弃这些字段和方法实现。从而达到不丢失实现的目的。这些重命名的方法可以继续通过重命名后的名称进行调用。

  • redefinition: 对应 ByteBuddy.redefine() 方法。当重定义一个类时,Byte Buddy 可以对一个已有的类添加属性和方法,或者删除已经存在的方法实现。如果使用其他的方法实现替换已经存在的方法实现,则原来存在的方法实现就会消失。

通过上述三种方式完成类的增强之后,我们得到的是 DynamicType.Unloaded 对象,表示的是一个未加载的类型,我们可以使用 ClassLoadingStrategy 加载此类型。Byte Buddy 提供了几种类加载策略,这些加载策略定义在 ClassLoadingStrategy.Default 中,其中:

  • WRAPPER 策略:创建一个新的 ClassLoader 来加载动态生成的类型。
  • CHILD_FIRST 策略:创建一个子类优先加载的 ClassLoader,即打破了双亲委派模型。
  • INJECTION 策略:使用反射将动态生成的类型直接注入到当前 ClassLoader 中。

Byte-Buddy 相比其他字节码操作库有如下优势:

  • 无需理解字节码格式,即可操作,简单易行的 API 能很容易操作字节码。
  • 支持 Java 任何版本,库轻量,仅取决于 Java 字节代码解析器库 ASM 的访问者 API,它本身不需要任何其他依赖项。
  • 比起 JDK 动态代理、cglib、Javassist,Byte Buddy 在性能上具有优势。

Byte-Buddy 几个核心 API 的介绍:

Builder (用于创建 DynamicType)

  • MethodDefinition (方法的定义器)
  • FieldDefinition (字段的定义器)
  • AbstractBase (基础操作方法类)

DynamicType (动态类型, 所有字节码操作的起始)

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

ElementMatchers (ElementMatcher)

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

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

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

4. Byte-Buddy 实战

我们分享的案例是: 创建一个代码零入侵的 Java Agent,对我们常规的 Spring Boot 微服务进行接口监控,能得到 Http-Request 级别和 方法反射级别 的信息

  1. 新建名为 JavaAgent 的总项目,项目的模块结构如图

1658199309865.jpg

附上项目的 parent POM.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <packaging>pom</packaging>

    <groupId>com.hupun</groupId>
    <artifactId>JavaAgent</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>JavaAgent</name>
    <description>Demo project for Spring Boot</description>

    <modules>
        <module>app-service</module>
        <module>test-premain</module>
    </modules>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <java.version>1.8</java.version>
    </properties>

</project>
  1. 创建 Spring Boot 的微服务项目 app-service

1658199591012.jpg

附上项目的 POM.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>JavaAgent</artifactId>
        <groupId>com.hupun</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>app-service</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

Main 启动类:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ServiceApplication.class, args);
    }

}

随意弄一个简单的接口:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: Jackey 2022/6/28
 */
@RestController
public class HelloController {

    @GetMapping("/service/hello")
    public String hello(String name) {
        return "Hello " + name + "! This is a test.";
    }

}
  1. 创建 Java Agent 的监控项目 test-premain

1658199902676.jpg

附上项目的 POM.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>JavaAgent</artifactId>
        <groupId>com.hupun</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>test-premain</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>net.bytebuddy</groupId>
            <artifactId>byte-buddy</artifactId>
            <version>1.12.12</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
                    </archive>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

这里值得注意的是:

  1. Maven 依赖 spring-web 和 tomcat-embed-core,是为了监控代码能获取到 request;
  2. Maven 打包插件配置特殊,是为了把依赖一起打进去,并且指定 MANIFEST.MF 文件,Java Agent 类似寄生项目植入主应用启动,除非你寄生体用到的所有类库,完全包含在主应用的依赖类库中,否则必须把依赖打包进 Agent 的 jar 中,程序方可正常运行。

MANIFEST.MF 文件内容:

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.hupun.agent.AgentByPremain
Created-By: Jackey

Premain 入口 AgentByPremain 启动类:

import lombok.extern.slf4j.Slf4j;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.implementation.MethodDelegation;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.instrument.Instrumentation;

/**
 * @author: Jackey 2022/6/28
 */
@Slf4j
public class AgentByPremain {

    private static final String ANNOTATION_PREFIX = "org.springframework.web.bind.annotation";
    private static final String ANNOTATION_CONTROLLER = "org.springframework.stereotype.Controller";
    private static final String ANNOTATION_REST_CONTROLLER = "org.springframework.web.bind.annotation.RestController";

    public static void premain(String agentArgs, Instrumentation inst) {

        log.info("Loading " + AgentByPremain.class.getName() + " ...");

        try {
            // 拦截spring controller
            AgentBuilder.Identified.Extendable agentBuilder = new AgentBuilder.Default()
                    // 拦截 @Controller 和 @RestController
                    .type(ElementMatchers.isAnnotatedWith(
                            ElementMatchers.named(ANNOTATION_CONTROLLER)
                                    .or(ElementMatchers.named(ANNOTATION_REST_CONTROLLER))))
                    .transform((builder, typeDescription, classLoader, javaModule) ->
                            // 拦截 @RestMapping 或者 @Get/Post/Put/DeleteMapping
                            builder.method(ElementMatchers.isPublic().and(ElementMatchers.isAnnotatedWith(
                                    ElementMatchers.nameStartsWith(ANNOTATION_PREFIX)
                                            .and(ElementMatchers.nameEndsWith("Mapping")))))
                                    // 拦截后交给 SpringControllerInterceptor 处理
                                    .intercept(MethodDelegation.to(ControllerInterceptor.class)));
            // 装载到 instrumentation 上
            agentBuilder.installOn(inst);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

监控主拦截实现类 ControllerInterceptor:

import lombok.extern.slf4j.Slf4j;
import net.bytebuddy.implementation.bind.annotation.AllArguments;
import net.bytebuddy.implementation.bind.annotation.Origin;
import net.bytebuddy.implementation.bind.annotation.RuntimeType;
import net.bytebuddy.implementation.bind.annotation.SuperCall;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Enumeration;
import java.util.concurrent.Callable;

/**
 * @author: Jackey 2022/6/29
 */
@Slf4j
public class ControllerInterceptor {

    @RuntimeType
    public static Object intercept(@Origin Method method,
                                   @AllArguments Object[] args,
                                   @SuperCall Callable<?> callable) throws Exception {

        Object resObj = null;
        HttpServletRequest request = null;
        long start = System.currentTimeMillis();

        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes != null) {
            request = ((ServletRequestAttributes) requestAttributes).getRequest();
        }

        try {

            resObj = callable.call();
            return resObj;

        } finally {

            StringBuilder logStr = new StringBuilder("\n");

            if (request != null) {
                StringBuilder requestHeader = new StringBuilder();
                Enumeration<String> headerNames = request.getHeaderNames();
                while (headerNames.hasMoreElements()) {
                    String name = headerNames.nextElement();
                    String value = request.getHeader(name);
                    requestHeader.append("[").append(name).append("]: ").append(value).append("; ");
                }
                logStr.append("请求URI: ").append(request.getRequestURI()).append("\n");
                logStr.append("请求Header: ").append(requestHeader).append("\n");
            }

            logStr.append("方法名称: ").append(method.getName()).append("\n");
            StringBuilder paramName = new StringBuilder();
            StringBuilder paramValue = new StringBuilder();
            for (int i = 0; i < args.length; i++) {
                paramName.append("Param[").append(i).append("]: ").append(method.getParameterTypes()[i].getName()).append("; ");
                paramValue.append("Param[").append(i).append("]: ").append(args[i]).append("; ");
            }
            logStr.append("入参名称: ").append(paramName).append("\n");
            logStr.append("入参内容: ").append(paramValue).append("\n");
            logStr.append("出参类型: ").append(method.getReturnType().getName()).append("\n");
            logStr.append("出参结果: ").append(resObj).append("\n");
            logStr.append("方法耗时: ").append(System.currentTimeMillis() - start).append(" ms");

            log.info(logStr.toString());

        }
    }

}

这里补充下 Instrumentation 相关的知识:

Java Instrumentation 指的是可以用独立于应用程序之外的代理(agent)程序来监测和协助运行在 JVM 上的应用程序。这种监测和协助包括但不限于获取 JVM 运行时状态,替换和修改类定义等。 Java SE5 中使用 JVM TI 替代了 JVM PI 和 JVM DI。提供一套代理机制,支持独立于 JVM 应用程序之外的程序以代理的方式连接和访问 JVM。java.lang.instrument 包是在 JVM TI 的基础上提供的 Java 版本的实现。 Instrumentation 提供的主要功能是修改 JVM 中类的行为

我们先打包,把 test-premain 这个小项目打包:

1658212387379.jpg

如图所示,下边这个打出来的包就是带有依赖的包:

1658212461181.jpg

去硬盘里找到他的 绝对路径,在我们 app-service 项目启动参数中,添加启动参数:

1658212618297.jpg

我来启动 app-service 项目,并且访问下接口,看看效果:

1658212714507.jpg

日志第一行输出表示我们的 agent 的 jar注入成功了,然后我们访问接口:

http://localhost:8080/service/hello?name=Jackey

Hello Jackey! This is a test.

日志输出表明我们的监控代码起作用了,然后 ControllerInterceptor 你可以随意编写想监控的内容:

1658212965397.jpg

最后我知道你们想要什么,我把 intercept() 方法中可用的一些标签给大家列一下

注解说明
@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注入被拦截对象的一个字段的值

Byte-Buddy 能做的事情还很多,大家可以去官网参考下官方教程深入了解和使用。