使用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);

【使用javaagent redefine tomcat中运行的类】成功加载到对象了!为什么会这样,我们把所有的类名和对应的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才行。
虽然有很多的坑,但是大胆假设小心求证,我们总能找到解决方案。

    推荐阅读