搞了一个更完善的javaagent项目结构
Pre:书接上文
在之前的文章中已经写过一个javaagent,但是后来发现不是很好用,这次我们再搞一个!
一、啥是javaagent?
在日常的开发实践中,javaagent的应用场景可谓是非常广泛了,无论是在链路监控的APM中,还是在诊断工具的Arthas中,亦或是在处理log4j2漏洞的”疫苗“中,都能看到javaagent的身影,并发挥着重要的作用。
限于篇幅原因,这里就不详细介绍了,有兴趣的同学可以自行了解!
ps:javaagent还可以做很多很多很强大且有趣的事情!
二、遇到了啥问题需要用javaagnent来解决呢?
pre.多服务、多环境的部署的现状
- 多环境不是指
dev
、fat
、uat
、pro
这样的多环境,而是fat
中包含了fat1
、fat2
、fat3
等多套环境 - 不同的业务中,如
study
、play
,它们所部署的fat
环境数量是不确定的 - 同一个业务中,每套
fat
环境部署的服务也不一定是一致的,如study
业务中的fat-3
中仅部署了appA
,没有部署appB
。
ps:
fat
环境共用一个注册中心,每个服务(如appA
)数据库也是只有一个,apollo配置亦是如此
1.如此部署服务会导致什么问题?
一些基于或类似于注册中心的调用可能会出现不可控的情况,示意如下:
当
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:本次的这个项目结构就是之前想法和实现方案的一些升级和完善,有其它想法意见的话欢迎交流呀!
转载自:https://juejin.cn/post/7135734853869404167