写点什么

拜托,别在 agent 中依赖 fastjson 了

作者:夏奇
  • 2023-08-01
    浙江
  • 本文字数:5605 字

    阅读完需:约 18 分钟

拜托,别在agent中依赖fastjson了

一、背景

最近因为增加了一个在 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触发类加载的那段代码:

@Configurationpublic 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加载的。整体类加载流程如下图:


上图说明:

BootMessageConverterLaunchedURLClassLoader加载时, 发现依赖了FastJsonHttpMessageConverter, 因此LaunchedURLClassLoader会继续尝试去加载FastJsonHttpMessageConverter。由于类加载的双亲委派机制,LaunchedURLClassLoader会委派它的父加载器AppClassLoader来尝试加载,当然AppClassLoader会继续往上找父加载器,一直到Bootstrap ClassLoader


很显然,Bootstrap ClassLoaderExtClassLoader都无法找到FastJsonHttpMessageConverter,但是AppClassLoader可以找到(因为 agent 包中存在 fastjson 的类)。然后,这一步是关键,AppClassLoader找到了FastJsonHttpMessageConverter之后发现它依赖了GenericHttpMessageConverter,因此由找到了FastJsonHttpMessageConverterAppClassLoader继续尝试加载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 下次可能又遇到其他的依赖冲突问题,我还得改。


如何考量是否轻量级呢?我主要从两方面着手:

  1. 看这个三方库的 pom.xml 中有没有依赖其他三方库

  2. 看这个三方库的\META-INF\services\目录有没有多余的 SPI 实现


最终我选择了 Google 的 gson 作为 agent 依赖的 Json 转换库。

可以看到 fastjson 的“罪行”可谓罄竹难书,而 gson 除了 junit 之外没有任何依赖,且 gson 不存在\META-INF\services\目录,完全满足我的需求。


顺便给 fastjson 也提个建议:目前的包耦合这么严重,是不是可以考虑拆成多个,比如 fastjson-core,fastjson-spring 等,让使用者按需依赖是不是更好呢。

七、总结

  1. 在 agent 研发中尽量用 JDK 内置的类去做功能,减少第三方库的依赖。

  2. 如果依赖了第三方库,可以用 maven-shade-plugin 来进行 package 重命名,以此达到和应用依赖类的隔离效果。

  3. 小心 SPI 机制,agent 依赖第三方库后,需要确认\META-INF\services\目录下的内容,如有必要可以进行排除或换成其他干净的依赖。


发布于: 2023-08-01阅读数: 318
用户头像

夏奇

关注

13年程序员老兵~ 2018-08-30 加入

喜欢钻研开源技术, 架构设计, 热爱分享~

评论 (2 条评论)

发布
用户头像
顶! 最近刚好遇到了类似的问题;话说回来 fastjson为啥要依赖Spring呢……
2023-08-02 11:27 · 北京
回复
不知道, 大概是为了更好的和spring结合起来用吧-_-||
2023-08-02 11:34 · 浙江
回复
没有更多了
拜托,别在agent中依赖fastjson了_Java Agent_夏奇_InfoQ写作社区