使用javaagent redefine tomcat中运行的类

背景

最近在优化自己的工作流程,希望能够借此提高工作效率。工作中的一个痛点是本地代码编译打包到启动服务过程冗长,因为项目庞杂,通常需要10多分钟才能完成整个过程。由于没有申请正版的intellij idea所以不能使用Tomcat plugin的方式运行程序,也不能使用debug的hot swap功能,因此在开发环境中选择了raw tomcat的使用方式,打包完将war包copy到tomcat的webapps目录下并使用bin目录下的startup.sh脚本启动服务。在本地调试新功能的时候,会因为一些逻辑bug导致无法完成整个feature的代码调试,这时通常需要修复bug并重新打包重启服务,花费时间较长。

解决方案调研

如果能够只修改有问题的文件并重新编译,直接替换掉tomcat中正在运行的对应文件,那么就可以完美解决问题。JRebel是一个很好的选项,但是由于我司要求使用定制的jdk,尝试发现JRebel无法搭配我司jdk正常运行,因此放弃该选项。接下来最有吸引力的一个选项就是javaagent+javaassist了,可以在tomcat运行时attach,可以通过instrumentation redefine class。选定该方案!

POC以及遇到的坑

在github上发现了一个开源代码repo: https://github.com/turn/Redef..., 看起来符合我的需要。用sprintboot initializer初始化了一个简单的springboot mvc项目模拟运行时tomcat,直接把上面repo中的唯一一个类copy到本地并新建一个agent项目,添加maven框架支持。这里遇到了第一个坑点,因为tools.jar默认不包含在classpath中,因此代码编译出了问题,解决方案很简单,直接将tools.jar添加到pom依赖中解决:


            com.sun
            tools
            1.8.0
            system
            ${YOUR_JAVA_HOME}/lib/tools.jar
        

接下来是打包,因为agent项目需要有MANIFEST.MF文件来描述agent类,所以我们要在pom文件中做一些配置。有两种配置方案,一是手写MANIFEST.MF文件并在pom中指定路径,二是可以在pom中添加configuration在打包时plugin自动生成该文件。plugin使用maven-jar-plugin,

    org.apache.maven.plugins
    maven-jar-plugin
    2.3.1

下面是两种示例:

  1. 手写文件

    Manifest-Version: 1.0
    Can-Redefine-Classes: true
    Can-Retransform-Classes: true
    Agent-Class: YOUR_AGENT_CLASS_QUALIFIED_NAME

    在pom中指定手写的文件路径

    
       
           src/main/resources/META-INF/MANIFEST.MF
       
    
  2. 在pom中添加配置自动生成MANIFEST.MF

    
        
            true
        
        
            YOUR_AGENT_CLASS_QUALIFIED_NAME
            true
            true
        
    

    Agent打包基本结束,接下来就是把jar包attach到运行时服务了。我们需要一个简单的main方法:

    public static void main(String[] args) throws Exception {
         String pid = "${pid}";
         VirtualMachine vm = VirtualMachine.attach(pid);
         vm.loadAgent("/path/to/agent",
                 "class_full_name,/path/to/absolute/class/file");
         vm.detach();
    }

    注意这里loadAgent方法的两个参数,第一个是agent.jar的绝对路径,第二个是逗号分割的字符串,我这里传入了两个参数1. 类的全限名,2.修改后的类绝对路径。这两个参数会被agentmain(String agentArgs, Instrumentation inst)中的第一个参数接收。
    这里遇到了第二个坑,在loadAgent这一步一直报:

    Exception in thread "Attach Listener" java.lang.NoClassDefFoundError: javassist/CannotCompileException
     at java.lang.Class.getDeclaredMethods0(Native Method)
     at java.lang.Class.privateGetDeclaredMethods(Class.java:2701)
     at java.lang.Class.getDeclaredMethod(Class.java:2128)
     at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:327)
     at sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain(InstrumentationImpl.java:411)
    Caused by: java.lang.ClassNotFoundException: javassist.CannotCompileException
     at java.net.URLClassLoader.findClass(URLClassLoader.java:387)
     at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
     at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
     at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
     ... 5 more

    我们发现这里有一个javassist.CannotCompileException的NoClassDefFoundError异常但是没有更详细的信息了,怀疑是代码中用到了javaassist的CannotCompileException但是在classpath里面没有,所以导致agent代码不能正常编译,可以看到Agent类中使用到javaassist的地方是:https://github.com/turn/Redef...。尝试注释掉跟javaassist相关的代码,发现agent成功加载!但是注释掉这段代码基本上等于这个项目可用性基本为0了。从头开始吧。

写一个简单的HelloWorldAgent来尝试,

public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println("agentArgs : " + agentArgs);
        instrumentation = inst;
        System.out.println("entered agentmain method")
}

尝试发现这个简单的HelloWorldAgent可以成功加载。
我们直接在agentmain中添加内容,尝试让我们的redefine work,想要redefine class,拿到Class对象是必不可少的一步,所以第一步就是我们要想办法拿到我们的target class,在HelloWorldAgent的agentmain方法中添加:

Class clazz = Class.forName(className);

打印clazz内容发现clazz对象一直是null。这是遇到的第三个坑,原因是什么呢?我们先把我们能拿到的对象全部打印出来:

Class[] allClasses = instrumentation.getAllLoadedClasses();

发现我们的target对象明明是在里面的!那难道是classloader的问题?

ClassLoader classLoader = null;
for (Class clz : allClasses) {
            if (clz.getName().contains(className)) {
                classLoader = clz.getClassLoader();
            }
clazz = classLoader.loadClass(className);

成功加载到对象了!为什么会这样,我们把所有的类名和对应的classloader全部打印出来发现,我们的springboot mvc里面自己实现的类都是通过:org.springframework.boot.loader.LaunchedURLClassLoader加载的,而spring的一些框架类以及HelloWorldAgent类本身是通过sun.misc.Launcher$AppClassLoader加载的!直接使用Class clazz = Class.forName(className);时由于运行环境的classloader是后者,所以找不到我们的target类。

接下来就是尝试把我们新编译好的类的byte transform到运行时的Class中:

URL url = new File(newClassFile).toURI().toURL();
InputStream classStream = url.openStream();
byte[] bytecode = IOUtils.toByteArray(classStream);
ClassDefinition definition = new ClassDefinition(clazz, bytecode);
HelloWorldAgent.redefineClasses(definition);

这里我们遇到了第四个坑:

Exception in thread "Attach Listener" java.lang.reflect.InvocationTargetException
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:386)
    at sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain(InstrumentationImpl.java:411)
Caused by: java.lang.NoClassDefFoundError: org/apache/commons/io/IOUtils
    at com.zaniu.learn.MyRedefineClassAgent.agentmain(MyRedefineClassAgent.java:110)
    ... 6 more
Caused by: java.lang.ClassNotFoundException: org.apache.commons.io.IOUtils
    at java.net.URLClassLoader.findClass(URLClassLoader.java:387)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
    ... 7 more
Agent failed to start!

apache的IOUtils not found!其实跟我们第二个坑很像,看来是绕不过了,必须解决这个问题。继续怀疑是因为运行时apache的jar没有包含在classpath下,怎么样能够让第三方的包包含在classpath下呢?可以通过maven plugin打一个fat jar,具体可以参考这个Stack Overflow上的这个回答https://stackoverflow.com/que...
然后我们再重新attach到进程,成功!target class被成功redefine!

总结

使用javaagent过程中坑还是挺多的,比如还有一个小坑是:运行中的进程如果attach了一次之后,即使你修改了agent类的代码打包重新attach,javaagent运行的还是旧的agent代码,需要重启服务重新attach才行。
虽然有很多的坑,但是大胆假设小心求证,我们总能找到解决方案。

你可能感兴趣的:(javatomcat)