搞了一个更完善的javaagent项目结构

搞了一个更完善的javaagent项目结构
SE

Pre:书接上文

在之前的文章中已经写过一个javaagent,但是后来发现不是很好用,这次我们再搞一个!

Springboot上运行javaagent时出现NoClassDefFoundError错误的分析和解决

一、啥是javaagent?

在日常的开发实践中,javaagent的应用场景可谓是非常广泛了,无论是在链路监控的APM中,还是在诊断工具的Arthas中,亦或是在处理log4j2漏洞的”疫苗“中,都能看到javaagent的身影,并发挥着重要的作用。

限于篇幅原因,这里就不详细介绍了,有兴趣的同学可以自行了解!

ps:javaagent还可以做很多很多很强大且有趣的事情!

二、遇到了啥问题需要用javaagnent来解决呢?

pre.多服务、多环境的部署的现状

未命名作品 22.png

  • 多环境不是指devfatuatpro这样的多环境,而是 fat 中包含了 fat1fat2fat3 等多套环境
  • 不同的业务中,如studyplay,它们所部署的fat环境数量是不确定的
  • 同一个业务中,每套fat环境部署的服务也不一定是一致的,如study业务中的fat-3中仅部署了appA,没有部署appB

ps:fat环境共用一个注册中心,每个服务(如appA)数据库也是只有一个,apollo配置亦是如此

1.如此部署服务会导致什么问题?

一些基于或类似于注册中心的调用可能会出现不可控的情况,示意如下: 未命名作品 23.pngappC去调用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隔离

先给个环境隔离的示意图: 未命名作品 17.png

  • Topic_internal_A_to_Bstudy业务内(appA生产消息,appB消费消息)的主题
  • Topic_external_x 是外部业务作为生产者,study业务需要进行消费的主题

i.处理当前业务内部的topic隔离:

未命名作品 16.png

  • appA发送消息时,重命名发送的topic,如Topic_internal_A_to_B_fat1
  • appB消费消息时,订阅相应的topic,如Topic_internal_A_to_B_fat1

ii.处理消费外部的topic隔离:

未命名作品 15.png

  • appA向注册中心注册时,group带上环境标识,如fat-1
  • 将不期待消费的topic进行禁用,不订阅,从而避免重复消费,如fat-1中不订阅Topic_external_2Topic_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来启动的线程,目前的场景是竞争处理一个队列中的任务: 未命名作品 24.png

隔离处理方式示意图: 未命名作品 25.png

总结:

  • 禁用部分服务的runner线程,不给启动

d.xxl-job隔离

xxl-job注册示意如下: 未命名作品 26.png

当有任务需要调度时,也是会按某种规则从3个appA中选一个来进行执行,某些情况下也是不可控的,解决方法也很简单,覆盖注册的名称即可: 未命名作品 27.png

总结:

  • 需要修改某些环境服务注册使用的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吧!

结构图: javaagent-解压.png

咋一看,这可一点都不优雅了呀,不急,且听我娓娓道来!

1.复杂逻辑的封装插桩运行

为了封装相关逻辑,我们将javaagent分成两部分:

  • 一部分是封装复杂业务逻辑,也就结构图中的 helper 模块
  • 一部分则是具体插桩的操作,也就是结构图中的 transformer 模块

因此在具体操作时,一般只会往字节码中插入方法的调用,如下: image.png

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即可。 image.png

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配置即可: image.png

五、那么,要怎样才能生成这样的javaagnent呢?

1.先看结果

未命名作品 19.png 项目最终是产生了4个子模块:

  • helper:封装复杂的操作逻辑,对应了上文的helper模块
  • transformer:入口、同时也是插桩操作的实现,对应了上文的transformer模块
  • package:没有代码,仅做打包用,为的是能同时将helper模块解压到顶层目录和放到 /BOOT-INF.lib/中。
  • maven-shade-transformer:合并spring.factories需要用到的plugin配置。

2.演进过程

该项目结构不是一蹴而就的,而是随着需求丰富逐步增加的: 未命名作品 18.png

  • 分离业务逻辑与插桩操作时拆分了helper子项目transformer子项目
  • 修改打包方式,兼容两种形式的启动方式时新增了package子项目
  • 支持自定义bean,合并spring.factories时新增了maven-shade-transformer子项目

3.依赖关系

未命名作品 20.png

  • package依赖了helpertransformer,负责生成最终javaagent的jar
  • helper依赖了transformer,因为helper中需要访问transformer的配置等
  • maven-shade-transformer只是打包支持用的

4.打包过程

未命名作品 21.png

  • a.先打包transformer,仅打包类,没有特殊处理 image.png
  • b.再打包helper,此时会对helper中的一些依赖进行shadow操作,如slf4j image.png
  • c.package阶段:
    • package第一阶段:对dependencies进行shadow操作,并解压到顶层目录,此时helper模块非jar in jar依赖会在此时生成。
    • package第二阶段:复制一份helper/BOOT-INF/lib下,也就是jar in jarhelper模块

六、代码

github链接(代码中仍有很多可以改善优化的地方,但是我已经迫不及待地分享啦!)

ps:本次的这个项目结构就是之前想法和实现方案的一些升级和完善,有其它想法意见的话欢迎交流呀!

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改