Pré : le livre continue au-dessus
J'ai écrit un javaagent dans l'article précédent, mais j'ai trouvé plus tard qu'il n'est pas très utile, cette fois nous allons en faire un autre !
Analyse et solution de l'erreur NoClassDefFoundError lors de l'exécution de javaagent sur Springboot
1. Qu'est-ce que javaagent ?
Dans la pratique quotidienne du développement, les scénarios d'application de javaagent sont très larges, que ce soit dans APM pour la surveillance des liens, Arthas pour les outils de diagnostic, ou "vaccin" pour traiter les vulnérabilités log4j2.Voir la figure de javaagent et jouer un rôle important.
Faute d'espace, je ne le présenterai pas en détail ici, et les étudiants intéressés pourront l'apprendre par eux-mêmes !
- Le principe et la pratique de la technologie de débogage dynamique Java
- instrumentation java
- de java
ps : javaagent peut aussi faire beaucoup, beaucoup de choses puissantes et intéressantes !
Deuxièmement, quels problèmes avez-vous besoin d'utiliser javagne pour résoudre ?
pré. État actuel des déploiements multiservices et multienvironnements
- Plusieurs environnements ne font pas référence à plusieurs environnements tels que
dev
,fat
,uat
,pro
mais incluent plusieurs environnements telsfat
quefat1
, ,fat2
,fat3
etc. - Dans différentes entreprises, telles que
study
,play
, le nombre d'environnements déployésfat
est incertain - Dans une même entreprise,
fat
les services déployés dans chaque environnement ne sont pas forcément cohérents, par exemplestudy
, dans l'entreprise,fat-3
seuls les services sont déployésappA
, mais pas déployésappB
.
ps:
fat
L'environnement partage un registre, et il n'y a qu'une seule base de données pour chaque service (par exempleappA
), et il en va de même pour la configuration apollo
1. Quels problèmes peuvent être causés par le déploiement d'un tel service ?
一些基于或类似于注册中心的调用可能会出现不可控的情况,示意如下: 当appC
去调用appA
的时候,可能会调用到3套环境中的其中一个,是不可控。 为了解决这个问题,就需要对各个环境的调用进行隔离。
ps:更详细的可背景以参考之前的文章:一种多业务下多环境的dubbo隔离方案
2.需要隔离的“调用”有哪些?
常用的基于或者类似于注册中心的调用有以下这些:
- mq
- dubbo
- xxl-job
- runner线程
- 其它自研的框架调用
3.能“抓老鼠”就是好猫?
能实现隔离功能就行了嘛?不一定!除了解决基本的隔离的基本问题外,还期待:
a.可配置
通过配置文件进行配置,支持统一管理,不需要跨越多个平台来配置。
b.不影响宿主应用的正常功能使用
这个属于底线要求了,不能影响正常的业务功能逻辑。
c.不侵入代码提交
这并不是一个业务需求,并且是不需要上线的,因此不宜提交。
d.兼容多种运行场景
目前存在的运行方式有:
- 使用springboot的打包插件,打包成一个fatjar来启动(下称
jar in jar
形式) - 指定class文件启动,不打包成jar包(下称
非jar in jar
形式)
ps:若有一种运行环境不支持,或一个服务不配合,都无法达到完整的隔离
三、javaagent是怎么实现环境隔离的?
1.首先来分析下能不能实现隔离
a.mq隔离
先给个环境隔离的示意图:
Topic_internal_A_to_B
是study
业务内(appA
生产消息,appB
消费消息)的主题Topic_external_x
是外部业务作为生产者,study
业务需要进行消费的主题
i.处理当前业务内部的topic隔离:
appA
发送消息时,重命名发送的topic,如Topic_internal_A_to_B_fat1
appB
消费消息时,订阅相应的topic,如Topic_internal_A_to_B_fat1
ii.处理消费外部的topic隔离:
appA
向注册中心注册时,group带上环境标识,如fat-1
- 将不期待消费的topic进行禁用,不订阅,从而避免重复消费,如
fat-1
中不订阅Topic_external_2
、Topic_external_3
这两个topic
总结:
- 需要拦截修改发送消息的topic
- 需要拦截修改subscribe的topic
- 需要拦截修改subscribe的group
- 需要禁用某些topic的订阅
b.dubbo隔离
与mq的处理类似,这里就不重复了。
总结:
- 需要拦截修改指定provider-api的group
- 需要拦截修改指定consumer-api的group
- 需要禁用provider注册
ps:如果仍有疑问还是可以参考之前的文章:一种多业务下多环境的dubbo隔离方案,处理方案是一样的,不同的是之前是在项目内处理,现在换成javaagnet实现
c.runner线程控制
这边的runner线程指的是springboot中继承了CommandLineRunner
来启动的线程,目前的场景是竞争处理一个队列中的任务:
隔离处理方式示意图:
总结:
- 禁用部分服务的runner线程,不给启动
d.xxl-job隔离
xxl-job注册示意如下:
当有任务需要调度时,也是会按某种规则从3个appA
中选一个来进行执行,某些情况下也是不可控的,解决方法也很简单,覆盖注册的名称即可:
总结:
- 需要修改某些环境服务注册使用的appName
ps:看起来实现并不难,归结为拦截属性、禁用bean两种操作,真的有这么简单?
2.还要优雅地实现!!
那么,怎么样才算是优雅呢?
a.不能与宿主应用的类产生冲突
举例:在javaagent中使用了1.0版本的StringUtils
类,而宿主服务中使用了2.0版本的StringUtils
类,那么当jvm在执行javaagent里相关逻辑过程中加载了1.0版本的StringUtils
类时,就不会再尝试加载2.0版本的StringUtils
类(同一个类加载器下),这可能导致宿主服务出现异常。
b.能使用宿主应用的类
因为要基于宿主内使用的组件来做一些处理,所以编写和运行时候都需要能访问相关的类,甚至是需要调用宿主应用中的bean。
c.兼容两种运行方式
应用运行的环境是硬性条件,很难为了隔离而强制要求开发小伙伴更换应用的运行方式。
d.复杂逻辑的封装再插桩
当处理过程中需要进行集合操作等较为复杂的流程时,如果以字符串形式插入一堆复杂的代码,会导致:
- 第一可阅读性不佳
- 第二非常容易出现编译错误
- 第三调试起来可谓是地狱难度
所以更稳妥的方法是将相关的处理逻辑封装到方法,在插桩时仅插入这个方法的调用即可。
e.日志统一
这里的统一指的是在javaagent中打出的日志应该是一致的,更甚者可能要求跟宿主的日志保持一致。 如果你使用了System.out
来进行日志输出,那你大概率会被锤的了。
f.能够注入自定义的bean
基于此能够实现一些有趣的东西,参考之前的文章:
说了这么多,你一定很好奇这样的javaagent到底长什么样吧!
四、那么,我们来解剖一个优雅的javaagent吧!
结构图:
咋一看,这可一点都不优雅了呀,不急,且听我娓娓道来!
1.复杂逻辑的封装插桩运行
为了封装相关逻辑,我们将javaagent分成两部分:
- 一部分是封装复杂业务逻辑,也就结构图中的
helper 模块
- 一部分则是具体插桩的操作,也就是结构图中的
transformer 模块
因此在具体操作时,一般只会往字节码中插入方法的调用,如下:
ps:由于运行环境和类加载的不确定
transformer模块
不一定能调用helper模块
!
2.不与宿主应用的类产生冲突
回应上文的举例:我们可以使用shade插件的relocation特性,修改javaagent中的StringUtils
类的全限定名,如从org.apache.commons.lang3.StringUtils
改为 shaded.org.apache.commons.lang3.StringUtils
类,这样就不会冲突了。
如结构图所示:对相关的工具类进行了更改包名的操作(javaassist、jsoup、slf4等),都在其原有包名基础上加入了shaded
前缀,这样就能确保不会与宿主应用的依赖产生冲突,因此也不会出现类覆盖的情况。
3.能使用宿主应用的类
这里说白了就是要求javaagent内在书写、编译、运行时都能访问到宿主应用的类,但是运行时相关的类在宿主应用的依赖中已经有了,因此javaagent中不能重复出现。
因此结构图中可见压jar中并没有宿主应用的类,在maven引入这些依赖时scope使用provided即可。
4.兼容两种运行方式
方向:处理的重点是
helper模块
,因为该模块依赖了宿主应用的类。
几点必要的说明:
- 第一点:
helper模块
中的类是会被宿主应用执行过程中被调用的,而helper模块
本身又依赖了宿主应用的类,因此,helper模块
与 应用的类 必须是被同一个类加载器加载。 - 第二点:javaagent的jar包会被添加到
AppClassLoader
的加载路径中。 - 第三点:使用
jar in jar
形式启动时,宿主应用会被以jar in jar
形式加载,其类加载器是AppClassLoader
的子类加载器LanuchedURLClassLoader
。
基于此,要想兼容运行jar in jar
形式启动的服务,需要做到:
- 一是
helper模块
对AppClassLoader
不可见,否则会直接被AppClassLoader
提前加载(双亲委派) - 二是
helper模块
能被LanuchedURLClassLoader
加载。
具体措施是:
- 首先,将
helper模块
放进jar in jar
中,这对AppClassLoader
不可见。 - 其次,将
helper模块
的jar in jar
路径添加到LanuchedURLClassLoader
的类加载路径中,使其能够被搜索加载。
结果是:
- 结构图可见,
helper模块
同时存在于顶层目录
和/BOOT-INF.lib/
中,简单来说是因为jar in jar
形式下,访问的是/BOOT-INF.lib/
中的jar包依赖的,而非jar in jar
形式下运,访问的是顶层目录
中的helper模块
。 - 如果你足够细心,还能发现
/BOOT-INF.lib/
与顶层目录
中的的helper模块
的包名是不一致的,并且在具体插桩的时候包了一层TransformerHelper.unShadeIfNecessary
,为的就是控制不用运行环境下访问不同位置的helper模块
。
参考:
5.日志统一
使用日志组件即可,目前使用的是slf4j。
6.能够注入自定义的bean
以依赖形式来注入bean的常用方式是增加 /META-INF/spring.factories
配置,因此结构图中可见,helper模块
中是有 /META-INF/spring.factories
文件的。
ps:细心的你一定发现在
jar in jar
中的是没有shaded开头的,而顶层目录里是有的,这也是为了兼容两个环境做的处理
7.可配置
直接用http请求访问一个统一的apollo配置即可:
五、那么,要怎样才能生成这样的javaagnent呢?
1.先看结果
项目最终是产生了4个子模块:
helper
:封装复杂的操作逻辑,对应了上文的helper模块
。transformer
:入口、同时也是插桩操作的实现,对应了上文的transformer模块
。package
:没有代码,仅做打包用,为的是能同时将helper模块
解压到顶层目录
和放到/BOOT-INF.lib/
中。maven-shade-transformer
:合并spring.factories
需要用到的plugin配置。
2.演进过程
该项目结构不是一蹴而就的,而是随着需求丰富逐步增加的:
- 分离业务逻辑与插桩操作时拆分了
helper子项目
与transformer子项目
。 - 修改打包方式,兼容两种形式的启动方式时新增了
package子项目
- 支持自定义bean,合并
spring.factories
时新增了maven-shade-transformer子项目
3.依赖关系
package
依赖了helper
与transformer
,负责生成最终javaagent的jarhelper
依赖了transformer
,因为helper
中需要访问transformer
的配置等maven-shade-transformer
只是打包支持用的
4.打包过程
- a.先打包
transformer
,仅打包类,没有特殊处理 - b.再打包
helper
,此时会对helper
中的一些依赖进行shadow操作,如slf4j - c.
package
阶段: -
- package第一阶段:对dependencies进行shadow操作,并解压到
顶层目录
,此时helper模块
的非jar in jar
依赖会在此时生成。
- package第一阶段:对dependencies进行shadow操作,并解压到
-
- package第二阶段:复制一份
helper
到/BOOT-INF/lib
下,也就是jar in jar
的helper模块
。
- package第二阶段:复制一份
六、代码
github链接(代码中仍有很多可以改善优化的地方,但是我已经迫不及待地分享啦!)
ps : la structure de ce projet est constituée de quelques mises à jour et améliorations des idées précédentes et des plans de mise en œuvre. Si vous avez d'autres idées et opinions, bienvenue pour échanger !