背景
最近在优化自己的工作流程,希望能够借此提高工作效率。工作中的一个痛点是本地代码编译打包到启动服务过程冗长,因为项目庞杂,通常需要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
下面是两种示例:
- 手写文件
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
- 在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了。从头开始吧。
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才行。
虽然有很多的坑,但是大胆假设小心求证,我们总能找到解决方案。