利用java代理实现对代码的运行时修改

本文介绍如何利用Java代理技术实现对已有系统的无侵入式改进,通过修改字节码实现在运行时添加功能,如接口调用监控。文章详细讲解了背景知识,包括Java代理的工作原理,以及如何使用javassist库实现对HttpClient的代码修改,记录调用参数、耗时等信息。

问题

最近工作上遇到一个需求:

  1. 有一个老系统,由许多模块组成,这些模块之间采用http的接口互相调用
  2. 现在需要了解各接口的调用情况,如调用时间、耗时、参数、返回值等
  3. 要求对原系统的改动越少越好

思考

面对这个需求,应该如何解决:

  1. 修改原系统的各模块,在调用接口的地方加代码
    • 优点:简单直接,想怎么加就怎么加
    • 缺点:需要对原系统的每个模块都进行改动,与需求3有很大的冲突
  2. 使用Spring AOP,通过配置的方式动态加代码
    1. 优点:实现比较简单,对原系统的改动也比较少
    2. 缺点:对没有采用Spring技术的模块就没办法了

有没有其他办法?

这时,想起了以前曾经了解过一些的java代理技术。这种技术能在运行时修改java代码,对原有的代码几乎不需要任何改造。

背景知识

  • java代理能通过修改JVM中运行的Java应用的字节码,修改这些应用的行为
  • java代理本身是一个特殊的java类
    • java代理必须实现一个premain方法,作为代理的入口点
    • java代理还可以实现一个agentmain方法,用于代理后启动(晚于应用启动)的场合
    • java代理还必须在包里提供manifest,提供一些元数据(比如代理类的名字、是否可以修改类)
    • 虚拟机通过-javaagent参数指定java代理
  • 开发java代理需要对字节码进行操作,java标准库没有提供相应的API,好在社区开发了一些相关的库,如javassit和ASM

实操

了解过java代理的背景知识后,是时候开始实际操作了。这次我们将:

  • 编写一个java代理
  • 使用javassit库来修改HttpClient的代码,具体来说,是org.apache.http.impl.client.InternalHttpClient的doExecute方法,这可通过调查方法调用栈获得
  • 实现在调用http接口时,输出参数、耗时等信息

确定依赖

  • 项目需要对HttpClient进行修改,因此需依赖HttpClient,而HttpClient是目标模块本身就有的依赖,所以这里将scope设置为provided
  • 项目需使用javassist进行字节码操作,因此需依赖javassist
<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.25.0-GA</version>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.6</version>
    <scope>provided</scope>
</dependency>

定义java代理类

首先需要定义java代理类。

public class Agent {

    public static void premain(String args, Instrumentation instrumentation) {
        modifyClass("org.apache.http.impl.client.InternalHttpClient");
    }

    private static void modifyClass(String className) {
        try {
            ClassPool classPool = ClassPool.getDefault();
            CtClass ctClass = classPool.get(className);

            // 找到目标方法
            CtMethod ctMethod = ctClass.getDeclaredMethod("doExecute");

            // 添加本地变量
            ctMethod.addLocalVariable("beginTime", CtClass.longType);
            ctMethod.addLocalVariable("endTime", CtClass.longType);
            ctMethod.addLocalVariable("timeCost", CtClass.longType);                       
                
            // 在方法最前面,添加代码(初始化beginTime)
            StringBuilder stringBuilder = new StringBuilder();
            stringBuilder.append("beginTime = System.currentTimeMillis();");
            ctMethod.insertBefore(stringBuilder.toString());

            // 在方法最后面,添加代码(计算耗时,调用记录方法)
            stringBuilder = new StringBuilder();
            stringBuilder.append("endTime = System.currentTimeMillis();");
            stringBuilder.append("timeCost = endTime - beginTime;");
            stringBuilder.append("cn.alfredzhang.instrument.agent.HttpClientRecorder.record($1, $2, $3, $_, beginTime, timeCost);");
            ctMethod.insertAfter(stringBuilder.toString());
            
            ctClass.toClass();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
  • premain方法:这个方法在上文的背景知识中也提到过,是代理的入口
  • 这个类里通过javassist的api,对类进行了修改

 

一些知识点:

  • javassist修改代码的方式是用字符串(不用直接操作字节码,已经是比较方便了)
  • 本地变量必须通过addLocalVariable声明
  • insertBefore是在方法体开始的地方添加代码
  • insertAfter是在方法体最后添加代码
  • 可以通过一些特殊变量引用方法的参数和返回值($n代表第n个参数,$_代表返回值)
  • 最后的toClass()非常重要,表示将修改后的CtClass对象编译为java的Class对象,并装载,如果不调用toClass,则前面的修改不会生效

定义辅助类HttpClientRecorder

上面的代理类里,最后我们是让InternalHttpClient的doExecute方法调用了一个名叫HttpClientRecorder的类。这个类的任务就是接收各参数,并进行处理,代码如下:

public class HttpClientRecorder {
    public static void record(HttpHost target, HttpRequest request, HttpContext context, CloseableHttpResponse response, long beginTime, long timeCost) {
        try {
            System.out.println("agent:target=" + target + ",request=" + request + ",context=" + context + ",beginTime=" + beginTime + ",timeCost=" + timeCost);

            // 对各参数进行各种解析和记录

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

定义manifest文件

还记得背景知识里提到的,代理需要提供一个manifest文件吗?

在resources目录下创建文件META-INF/MANIFEST.MF,内容如下:

Premain-Class: cn.alfredzhang.instrument.agent.Agent
Can-Redefine-Classes: true
Can-Retransform-Classes: true

打包设置

我们使用maven-assembly-plugin,生成一个大jar(把依赖也打进去),否则运行时会找不到javassist的类。

同时别忘了在configuration下配置archive,把manifest文件也打进去。

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

完整的pom文件:

<?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">
    <modelVersion>4.0.0</modelVersion>

    <groupId>cn.alfredzhang.instrument</groupId>
    <artifactId>instrument-agent</artifactId>
    <version>1.0-SNAPSHOT</version>

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

    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.25.0-GA</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.6</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

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

</project>

编写应用类

为了检查代理是否能正常运行,编写一个简单的应用类,模拟我们系统中的某个模块。

public void test() throws IOException {

    CloseableHttpClient httpClient = HttpClients.createDefault();

    HttpGet httpGet = new HttpGet("http://localhost:10400/hello?x=dog");

    try (CloseableHttpResponse response = httpClient.execute(httpGet)) {

        System.out.println("statusLine=" + response.getStatusLine());   
        // 下略
    }

}

 

测试

两个项目分别编译打包后,运行:

java -javaagent:instrument-agent-1.0-SNAPSHOT-jar-with-dependencies.jar -jar app.jar

其中,-javaagent参数指定代理的路径和jar包名。

我们得到了类似以下的输出:

agent:target=http://localhost:10400,request=GET http://localhost:10400/hello?x=dog HTTP/1.1,context=null,beginTime=1561342757914,timeCost=160

表示HttpClient的类确实已经被修改,并输出了以上信息,成功!

总结

当我们需要对已有的系统添加一些功能特性,但又不希望对原有的代码进行修改时,可以:

  • 使用java代理技术,在运行时对原有的代码类进行修改
  • 在此过程中,可以使用javassist提供的API,较方便地创建类、创建方法、在方法体中添加代码、替换方法体
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值