quasar-agent

前言

项目在开发过程中, 需要不断调试并且更换生产或测试环境的包, 非常麻烦, 因此需要寻求一种不停机更新代码的方式, 了解到javaagent模式可以实现.

javaAgent是一种特殊的Java程序,是Instrumentation的客户端。它与普通Java程序通过main方法启动不同,JavaAgent并不是一个可以单独启动的程序,它必须依附在一个Java应用程序(JVM)上,与主程序运行在同一个进程中,通过Instrumentation API与虚拟机交互。

javaagent有2种形式, 分别对应2个方法:

  • premain(args: String?, instrumentation: Instrumentation)

    运行在main方法前,使用方法:将agent.jar在应用运行前通过java -javaagent:<jar 路径>[=args] -jar server.jar,绑定JVM,实现class文件的修改。

  • agentmain(args: String?, instrumentation: Instrumentation)

    使用方法:希望修改的类正在运行状态,我们主动运行attach模块(这里先不要纠结什么是attach模块, 后面小节有介绍。),获取目前所有运行的JVM,通过过滤条件,找到希望修改的jvm-id,然后通过如下方式, 将agent注入,实现class文件的修改。

    val attach = VirtualMachine.attach(${jvm-id});
    attach.loadAgent("${agent_jar_path}", "${args}")
    

对比

  1. 作用时机不同: 需要根据实际的需求来进行取舍, 比如我们要进行热部署操作, 此时premain就不能满足要求, 因为在main方法前执行,此时业务JVM还没有加载我们的目标类, 自然无法很顺畅的进行重定义。
  2. 运行方式不同:agentmain不仅可以使用-javaagent:agent.jar[=args]启动, 还可以通过VirtualMachine.attach()进行附着
  3. 运行次数不同:由于运行方式的限制, premain只能运行一次, 而agentmain可以多次运行

模块

为方便演示, 共设计3个maven模块:

server: 用来模拟真实业务, 使用springboot框架

agent:用来定义新的类的加载规则, 然后通过根模块将本模块附着(attach)到业务模块中

attach: 用于附着到业务模块上, 并且将新的类重新定义在业务模块中, 以达到运行时替换class的目的.

需求

假设server模块只有一个服务HelloWorldService, 它只负责返回Hello World字符串, 当我们访问localhost:8080/hi时, 可以得到如上响应, 现要求不停机前提下, 改变它的输出为Hello JavaAgent

替换规则为:约定使用/tmp/classes/作为搜索的根目录, 如果子文件夹classes下有类文件, 则使用新类替换

/tmp/classes/并不是死的, 而是通过jvm参数传进来,比如启动时指定java -javaagent:<jar 路径>[=/tmp/agent/] -jar server.jar,或者使用VirtualMachine方式, 在loadAgent方法中指定。

设计

agent是我们的核心模块, 一般做如下设计, 我们仅需补充过滤规则和类转换规则(步骤③):

fun agentmain(args: String?, instrumentation: Instrumentation) {
    agent(args, instrumentation)
}

fun premain(args: String?, instrumentation: Instrumentation) {
    agent(args, instrumentation)
}

fun agent(args: String?, instrumentation: Instrumentation) {
    // ①当前已加载的所有类
    val allLoadedClasses = instrumentation.allLoadedClasses
    // ②通过自定义的过滤规则得到的需要替换的类
    val targetClasses = findTargetClasses(args, allLoadedClasses)
    if (targetClasses.isEmpty()) {
        println("no class found")
        return
    }
    // ③添加类转换规则的具体实现
    instrumentation.addTransformer({ loader, className, classBeingRedefined, protectionDomain, classfileBuffer ->
        TODO("补充类加载或类定义的具体实现")
    }, true) 
    // ④更新目标类到目标JVM
    instrumentation.retransformClasses(*targetClasses)
}

// 定义了哪些类时需要替换的, 一般需要从已加载的类中进行过滤
// 方式有很多, 比如扫描/tmp/classes路径下的新类, 能找到则表示要替换, 或者通过args来指定也行
fun findTargetClasses(args: String?, allLoadedClasses: Array<Class<Any>>): Array<Class<*>> {
    TODO("这里补充过滤规则")
}

上面的代码使用的是kotlin, 步骤③中的匿名内部类不是很明显, 它实际上ClassFileTransformer接口的实现, 要进行实现必须先熟悉甚至Instrumentation API.后面小节会进行简单介绍。

MANIFEST.MF

仅做代码实现是不够的, 和main方法一样, 当实际使用时, 需要在MANIFEST.MF中指定启动的类(全类名),不同的是他们对应的KEY不一样:

  • main: Main-Class
  • premain: Premain-Class
  • agentmain: Agent-Class

不过这并不是javaagent的全部, 还有常用的配套的定义:

  • Can-Redefine-Classes: 是否可进行类定义, true或false​,​ ​一般​都​写​true​,​ ​不然​我们​要​它​干啥🐶
  • Can-Retransform-Classes: 是否可进行类转换, true或false, 一般都写true, 不然我们要它干啥🐶

Instrumentation API 介绍

InstrumentationJava提供的 JVM 接口,该接口提供了一系列查看和操作Java类定义的方法,例如修改类的字节码、向 classLoaderclasspath 下加入 jar 文件等。使得开发者可以通过 Java 语言来操作和监控 JVM 内部的一些状态,进而实现 Java 程序的监控分析,甚至实现一些特殊功能(如 AOP、热部署)。

Instrumentation 的一些主要方法如下:

public interface Instrumentation {
    /**
     * 注册一个Transformer,从此之后的类加载都会被Transformer拦截。
     * Transformer可以直接对类的字节码byte[]进行修改
     */
    void addTransformer(ClassFileTransformer transformer);

    /**
     * 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
     * retransformClasses可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
     */
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    /**
     * 获取一个对象的大小
     */
    long getObjectSize(Object objectToSize);

    /**
     * 将一个jar加入到bootstrap classloader的 classpath里
     */
    void appendToBootstrapClassLoaderSearch(JarFile jarfile);

    /**
     * 获取当前被JVM加载的所有类对象
     */
    Class[] getAllLoadedClasses();
}

其中最常用的方法是addTransformer(ClassFileTransformer transformer),这个方法可以在类加载时做拦截,对输入的类的字节码进行修改,其参数是一个ClassFileTransformer接口,定义如下:

public interface ClassFileTransformer {
    /**
     * 类文件转换方法,重写transform方法可获取到待加载的类相关信息
     *
     * @param loader              定义要转换的类加载器;如果是引导加载器,则为 null
     * @param className           类名,如:java/lang/Runtime
     * @param classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
     * @param protectionDomain    要定义或重定义的类的保护域
     * @param classfileBuffer     类文件格式的输入字节缓冲区(不得修改)
     * @return 字节码byte数组。
     */
    byte[]
    transform(  ClassLoader         loader,
                String              className,
                Class<?>            classBeingRedefined,
                ProtectionDomain    protectionDomain,
                byte[]              classfileBuffer)
        throws IllegalClassFormatException;
}

重写transform方法需要注意以下事项:

  1. ClassLoader如果是被Bootstrap ClassLoader(引导类加载器)所加载那么loader参数的值是空。
  2. 修改类字节码时需要特别注意插入的代码在对应的ClassLoader中可以正确的获取到,否则会报ClassNotFoundException,比如修改java.io.FileInputStream(该类由Bootstrap ClassLoader加载)时插入了我们检测代码,那么我们将必须保证FileInputStream能够获取到我们的检测代码类。
  3. JVM类名的书写方式路径方式:java/lang/String而不是我们常用的类名方式:java.lang.String
  4. 类字节必须符合JVM校验要求,如果无法验证类字节码会导致JVM崩溃或者VerifyError(类验证错误)。
  5. 如果修改的是retransform类(修改已被JVM加载的类),修改后的类字节码不得新增方法、修改方法参数、类成员变量。
  6. addTransformer时如果没有传入retransform参数(默认是false)就算MANIFEST.MF中配置了Can-Redefine-Classes: true而且手动调用了retransformClasses方法也一样无法retransform。
  7. 卸载transform时需要使用创建时的Instrumentation实例。

局限性

在运行时,我们可以通过InstrumentationredefineClasses方法进行类重定义,在redefineClasses方法上有一段注释需要特别注意:

     * The redefinition may change method bodies, the constant pool and attributes.
     * The redefinition must not add, remove or rename fields or methods, change the
     * signatures of methods, or change inheritance.  These restrictions maybe be
     * lifted in future versions.  The class file bytes are not checked, verified and installed
     * until after the transformations have been applied, if the resultant bytes are in
     * error this method will throw an exception.1.2.3.4.5.6.

这里面提到,我们不可以增加、删除或者重命名字段和方法,改变方法的签名或者类的继承关系。认识到这一点很重要,当我们通过 ASM 等字节码增强工具获取到增强的字节码之后,如果增强后的字节码没有遵守这些规则,那么调用redefineClasses方法来进行类的重定义就会失败。

实现

agent

到这里我们对javaagent有了一个初步的认识了, 可以着手实现上面的TODO项了。

package com.yan

import java.lang.instrument.Instrumentation
import java.nio.file.Files
import java.nio.file.Paths
import java.util.logging.Logger


val logger: Logger = Logger.getLogger("agent")
fun agentmain(args: String, instrumentation: Instrumentation) {
    agent(args, instrumentation)
}

fun premain(args: String, instrumentation: Instrumentation) {
    agent(args, instrumentation)
}

fun agent(args: String, instrumentation: Instrumentation) {
    // ①当前已加载的所有类
    val allLoadedClasses = instrumentation.allLoadedClasses
    // ②通过自定义的过滤规则得到的需要替换的类
    val targetClasses = findTargetClasses(args, allLoadedClasses)
    if (targetClasses.isEmpty()) {
        println("no class found")
        return
    }
    // ③添加类转换规则的具体实现
    instrumentation.addTransformer({ loader, className, classBeingRedefined, protectionDomain, classfileBuffer ->
        if (!classFilter(args, classBeingRedefined)) {
            return@addTransformer classfileBuffer
        }
        val agentClassPath = Paths.get(args, "$className.class")
        logger.info("start redefine class: $agentClassPath")
        Files.newInputStream(agentClassPath).use {
            return@addTransformer it.readBytes()
        }
    }, true)
    // ④更新目标类到目标JVM
    println(targetClasses.map { it.name })
    instrumentation.retransformClasses(*targetClasses)
}

// 定义了哪些类时需要替换的, 一般需要从已加载的类中进行过滤
// 方式有很多, 比如扫描/tmp/classes路径下的新类, 能找到则表示要替换, 或者通过args来指定也行
fun findTargetClasses(args: String, allLoadedClasses: Array<Class<Any>>): Array<Class<Any>> {
   return allLoadedClasses.filter { classFilter(args, it) }.toTypedArray()
}

fun classFilter(args: String, target: Class<*>): Boolean {
    val name = target.name
    val path = Paths.get(args, "${name.replace('.', '/')}.class")
    return (name.startsWith("com.yan") // 只针对业务类, 一般使用包名的前缀进行过滤
            && Files.exists(path)) // 查看指定路径下有没有相关的类文件
}

attach

fun main() {
    VirtualMachine.list().forEach {
        val displayName = it.displayName()
        if (!displayName.startsWith("com.yan")) {
            return@forEach
        }
        val attach = VirtualMachine.attach(it.id())
        attach.loadAgent("/home/yanwei/IdeaProjects/java-agent/out/artifacts/agent_jar/agent.jar", "/tmp/classes")
    }
}

server

MANIFEST.MF

由于我们使用的是maven, 为了便于打包, 在pom.xml中指定内容:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.3.0</version>
    <configuration>
        <archive>
            <manifestEntries>
                <Agent-Class>com.yan.AgentMainKt</Agent-Class>
                <Premain-Class>com.yan.AgentMainKt</Premain-Class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

测试

  1. 启动server,此时访问localhost:8080/hi会输出Hello World

  2. 我们修改server代码, 将打印内容变更为Hello JavaAgent, 重新编译mvn compile -pl server

  3. 将新的classes拷贝到/tmp/下:cp ./server/target/classes/com/yan/service/impl/HelloServiceImpl.class /tmp/classes/com/yan/service/impl/

  4. 执行attach

  5. 此时访问localhost:8080/hi会输出Hello JavaAgent

源码

java-agent

参考

  1. Java agent超详细知识梳理
  2. 初探Java安全之JavaAgent
  3. maven插件maven-shade-plugin打包jar文件使用详解
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值