拜托,别在 agent 中依赖 fastjson 了

共 13488字,需浏览 27分钟

 ·

2023-08-10 08:52

依旧是夏老师的文章,原文发表在 infoQ:https://xie.infoq.cn/article/99092a1da96f7d17989db7217,点击文章底部阅读原文直达。

一、背景

最近因为增加了一个在 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加载的。整体类加载流程如下图:

上图说明:

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\目录下的内容,如有必要可以进行排除或换成其他干净的依赖。

最后感谢能抽空看到这里,如果你能点赞在看分享,我会更加感激不尽~


  • 搜索关注微信公众号"捉虫大师",后端技术分享,架构设计、性能优化、源码阅读、问题排查、踩坑实践
  • 进技术交流群加微信 MrRoshi

浏览 2966
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报