一、背景
最近因为增加了一个在 agent 中上报异常的功能,agent 为了在 http 请求时方便把对象转换为 json 格式,增加了一个 fastjson 的依赖,结果搞出来各种问题。
环境:
- JDK 1.8
- SpringBoot 2.0.0.RELEASE
- skywalking agent 8.14.0
二、初现问题
2.1 初步定位
有同事反馈应用在本地能启动,但是到了测试环境(带 agent 启动)就起不来,报错如下:
图片
首先还是要确认下是不是应用的依赖冲突问题,GenericHttpMessageConverter这个类是在 spring-web 这个包下面的, 因为本地打包环境和测试环境有可能不一致,需要确认最终部署到测试环境的包里是否包含了 spring-web 包。经确认包里有 spring-web,排除这个可能。
然后怀疑是 agent 和应用的依赖冲突,临时让这个应用的 agent 下线后重新部署,发现能正常启动,基本确认是 agent 带来的问题。
2.2 进一步排查
为了方便定位问题,我把发现问题时应用部署的包下载到本地,并在本地挂载 agent 启动,问题重现,报错和测试环境一致。至此我就可以在本地 debug 了。
顺便说一下,我一开始用 idea 启动应用(挂载 agent)是没问题的,至于为什么没问题下面会说到。
本地我在java.net.URLClassLoader#findClass方法的入口处打了一个条件断点(类名为GenericHttpMessageConverter的才会进来),启动应用后一会儿进入断点。
idea 这个工具就是好用,从 debug 界面一下子就能看出来,这个 findClass 是调用了 3 次,并且能看到每一次 findClass 是加载的哪个类:
图片
从上面的图的最后一行也能看出来,这个类加载最开始的触发是在内部的一个二方库的类WebAutoConfig中触发的。
这 3 次 findClass 的顺序可以看出, 类的加载顺序为:
BootMessageConverter (二方包)
-> FastJsonHttpMessageConverter (fastjson)
-> GenericHttpMessageConverter (spring-web)
再来看看WebAutoConfig触发类加载的那段代码:
@Configuration
public class WebAutoConfig implements WebMvcConfigurer {
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters httpMessageConverter() {
BootMessageConverter converter = new BootMessageConverter(); //这一行触发了类加载
...
}
}
public class BootMessageConverter extends FastJsonHttpMessageConverter {
...
}
public class FastJsonHttpMessageConverter extends AbstractHttpMessageConverter<Object>
implements GenericHttpMessageConverter<Object> {
...
}
从上面的代码能看出最开始是因为BootMessageConverter的实例化进行了类加载, 而BootMessageConverter因为继承了FastJsonHttpMessageConverter, 又接着触发了FastJsonHttpMessageConverter的类加载, 然后FastJsonHttpMessageConverter因为实现了GenericHttpMessageConverter接口, 又进一步触发了GenericHttpMessageConverter的类加载, 这样来看源码和上面 debug 得出的结论是一致的。
分析到这一步,如果你对类加载机制以及 agent 的运行方式非常熟悉的话,基本已经能得出“为什么会报GenericHttpMessageConverter类找不到的错误”结论了。
那么接下来,我会基于类加载的机制来详细分析一下,为什么会找不到GenericHttpMessageConverter
三、类加载机制
3.1 双亲委派机制
图片
上一层类加载器是下一层类加载器的父加载器,除了 Bootstrap ClassLoader 之外,所有的加载器都是有父加载器的。
所谓的双亲委派机制,指的就是:当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。
开个玩笑:这样说来,双亲委派这种说法似乎并不准确,因为有父无母,准确来说应该是“单亲委派”...
3.1.1 类中依赖的其他类是怎么加载的
----------------接下来是重点----------------
我们定义的类一般还会依赖其他类,因此在被类加载器加载时,类加载机制中除了双亲委派机制之外,还有一个重要的机制是:
假设类 A 依赖类 B,那么哪个 ClassLoader 找到了类 A,这个 ClassLoader 也会尝试去加载类 B(当然类 B 的加载过程也遵循双亲委派)。
3.2 springboot 的类加载机制
springboot 项目打包之后的 jar 目录结构如下:
├─BOOT-INF
│ ├─classes
│ │ ├─应用代码
│ └─lib
│ ├─应用依赖的jar包
├─META-INF
│ ├─MANIFEST.MF
└─org
└─springframework
└─boot
└─loader
│ JarLauncher.class
│ LaunchedURLClassLoader.class
│ Launcher.class
│ ...
其中/META-INF/MANIFEST.MF 是 jar 包运行的关键, 来看一下里面的内容:
...
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.xxxxxx.DemoApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
...
首先 jar 包运行都有一个入口类定义了 main 方法,可以看到 springboot 项目打包出来的 jar 定义的入口运行类并不是应用代码中的XxxApplication,而是 springboot 中的一个类JarLauncher,那么应用代码中的XxxApplication是怎么运行的呢?
当你运行 java -jar 命令的时候,JarLauncher会加载 /BOOT-INF/classes 下的类和 /BOOT-INF/lib 下的 jar 包。最后调用 MANIFEST.MF 文件的 Start-Class 属性指定的类的 main 方法来完成应用程序的启动。
问题是 /BOOT-INF/ 并不是标准的 classpath 路径,系统内置的 ClassLoader 是加载不到这些目录的类的,那么这些类是谁来加载的呢?答案就是 springboot 自定义的类加载器:LaunchedURLClassLoader
图片
也就是说应用代码中的类以及应用依赖的 jar 都是LaunchedURLClassLoader负责加载的。
3.3 fastjson 的类到底是怎么找到的
再说回来在第 2.2 节中说到的类加载顺序:
BootMessageConverter (二方包)
-> FastJsonHttpMessageConverter (fastjson)
-> GenericHttpMessageConverter (spring-web)
这里我们重点来分析一下中间那个FastJsonHttpMessageConverter到底是怎么被加载的。
已知应用依赖了 fastjson 和 spring-web,agent 也依赖了 fastjson 但不依赖 spring-web。
从 Oracle 官方的文档看到,Java 8 的 agent 的 jar 包里的类会添加到 classpath 中,因此会用AppClassLoader来加载。
图片
而二方包的BootMessageConverter是应用依赖的 jar, 放在/BOOT-INF/lib 下, 因此是被LaunchedURLClassLoader加载的。整体类加载流程如下图:
图片
上图说明:
当BootMessageConverter被LaunchedURLClassLoader加载时, 发现依赖了FastJsonHttpMessageConverter, 因此LaunchedURLClassLoader会继续尝试去加载FastJsonHttpMessageConverter。由于类加载的双亲委派机制,LaunchedURLClassLoader会委派它的父加载器AppClassLoader来尝试加载,当然AppClassLoader会继续往上找父加载器,一直到Bootstrap ClassLoader。
很显然,Bootstrap ClassLoader和ExtClassLoader都无法找到FastJsonHttpMessageConverter,但是AppClassLoader可以找到(因为 agent 包中存在 fastjson 的类)。然后,这一步是关键,AppClassLoader找到了FastJsonHttpMessageConverter之后发现它依赖了GenericHttpMessageConverter,因此由找到了FastJsonHttpMessageConverter的AppClassLoader继续尝试加载GenericHttpMessageConverter,但是GenericHttpMessageConverter只有应用依赖的 spring-web.jar 中才有,而这个 jar 在/BOOT-INF/lib 下,只能被LaunchedURLClassLoader加载。双亲委派机制只能由子加载器往父加载器委托而反过来是不行的,而GenericHttpMessageConverter没办法被AppClassLoader以及它的父加载器加载到,因此AppClassLoader抛出了找不到GenericHttpMessageConverter的错误。
这里的关键就在于LaunchedURLClassLoader本身是能找到 fastjson 类的(在/BOOT-INF/lib), 但是因为双亲委派机制, 在加载 fastjson 类的时候, 被AppClassLoader截胡了,以至于丧失了后面依赖的类加载主动权。
说到这里,就可以回答之前的那个问题了:为什么用 idea 启动应用(挂载 agent)是没问题的?因为 idea 是直接运行应用的 XxxApplication 类的 main 方法,不是通过 springboot 的JarLauncher启动的,而在运行时所有的依赖都是通过指定 classpath 来做的,因此 idea 运行过程中,所有的类都能通过AppClassLoader加载到,也就不存在上面这种冲突问题了。
四、解决方案一:maven-shade-plugin
知道问题的根因了,那么思路就是怎么样可以让 fastjson 类被LaunchedURLClassLoader找到而不要被AppClassLoader找到。这里的思路是把 agent 中依赖的 fastjson 的 package 给重命名一下。
maven-shade-plugin在 maven 官方网站中提供的一个插件,官方文档中定义其功能如下:
This plugin provides the capability to package the artifact in an uber-jar, including its dependencies and to shade - i.e. rename - the packages of some of the dependencies.
简单来说就是将依赖的包在 package 阶段一起打入 jar 包中,以及对依赖的 jar 包进行重命名从而达到隔离的作用。接下来就把这个 maven 插件引入 agent 中。
maven 配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<shadedArtifactAttached>false</shadedArtifactAttached>
<createDependencyReducedPom>true</createDependencyReducedPom>
<createSourcesJar>true</createSourcesJar>
<shadeSourcesContent>true</shadeSourcesContent>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Premain-Class>xxxxxx.AgentStarter</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</transformer>
</transformers>
<!-- 这段是package重命名的关键配置 -->
<relocations>
<relocation>
<pattern>com.alibaba.fastjson</pattern>
<shadedPattern>shade.com.alibaba.fastjson</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
package 之后的效果:
图片
可以看到在 agent 包中,fastjson 类的 package 都已经加上了一个前缀shade.,这样的话,应用中加载正常的 fastjson 类的时候,肯定不会找到 agent 里面来了,以此避免了类加载被AppClassLoader截胡的情况。
用重新 package 的 agent 包启动之前应用, 应用正常启动, 至此问题解决。
五、再现问题
本以为问题已经解决,没想到几天后另一个应用又报了类找不到的错误:
图片
有了上次的经验, 这次还算顺利, 排查过程跟上次的差不多。
最后发现是应用依赖的 jersey 这个三方库,而 jersey 通过 SPI 的方式会去找所有 classpath 中\META-INF\services\目录下的javax.ws.rs.ext.MessageBodyReader这个文件,由于 agent 依赖了 fastjson,而 fastjson 也实现了这个 SPI 的扩展,结果 jersey 就找到了 agent 包的\META-INF\services\目录下的javax.ws.rs.ext.MessageBodyReader文件,而javax.ws.rs.ext.MessageBodyReader文件中的内容如下:
图片
可以看到 maven-shade-plugin 把这里的类 package 也改掉了。然后 jersey 读取到这个文件后,根据类名去加载了shade.com.alibaba.fastjson.support.jaxrs.FastJsonProvider这个类,结果肯定是找到了 agent 包里的这个类,而这个类依赖的MessageBodyReader类是在 jsr311-api.jar 里的, 这个 jar 包只在应用中依赖, agent 并不依赖这个 jar 包, 因此就抛出了找不到类的错误。
依赖冲突真是让人防不胜防~
六、决定:干掉 fastjson
本来我查了下 maven-shade-plugin 似乎是可以在 agent 打包时把\META-INF\services\这个目录排除掉的,这样的话上面的问题也能解决掉,但是连续两次踩了这个坑还是让我静下来好好思考了一下。
这两次的依赖冲突从根本上来看,都是因为 fastjson 做的太重,第一次是因为 fastjson 依赖了 spring,第二次是因为 fastjson 实现了 jsr311-api,而在 agent 中去依赖 fastjson 并没有那么多的需求,只是为了做一个纯粹的转换工作:Java 对象和 Json 串之间的互相转换。所以找一个纯粹的轻量级的 Json 转换库是我的本质需求。否则 fastjson 下次可能又遇到其他的依赖冲突问题,我还得改。
如何考量是否轻量级呢?我主要从两方面着手:
- 看这个三方库的 pom.xml 中有没有依赖其他三方库
- 看这个三方库的\META-INF\services\目录有没有多余的 SPI 实现
最终我选择了 Google 的 gson 作为 agent 依赖的 Json 转换库。