文章

Java基础框架的透明升级:从FatSDK到Java Agent

Java基础框架的透明升级:从FatSDK到Java Agent

1. 背景

1.1 什么是基础框架

基础框架(业界常称 Microservice Chassis,即“微服务底盘”)是业务应用的通用底座,日常业务迭代过程中,业务逻辑代码可能会伴随着产品需求日新月异,但是为实现业务逻辑需要的公共代码逻辑、基础能力往往是稳定的。这部分稳定的代码最后一般都会沉淀到一个独立的代码模块中由专门的团队负责。从而为各个业务应用提供如下核心能力:

  1. 开箱即用的基础能力: 中间件团队结合实际的需求,对常用能力、常用组件进行统一封装,提供了开箱即用的使用体验。避免了各个业务服务重复建设相同功能,同时让各个应用代码中提供一致的使用API,降低业务开发者在跨应用开发时的心智负担,无需重复建设、重复学习。

  2. 依赖管理: 通过公共POM统一收口常用二方依赖 (公司内部组件)和三方依赖 (外部开源或商业组件)版本,日常使用中,避免大部分依赖冲突导致的ClassNotFoundException、NoSuchMethodException问题。

这类基础框架通常由中间件团队维护,以胖客户端 (FatSDK) 的形式向业务应用提供缓存、消息、日志、压测等能力。

1.2 这种交付方式的问题

以FatSDK模式 收集、暴露能力是架构演进过程中自然而然的、符合直觉的选择。在组织发展早期,这种方式无论对于框架开发者和框架使用者来说都是便捷、高效的。

但是,随着组织的逐渐扩大,组织内的微服务数量随之爆发式增长。在这种场景下,FatSDK模式交付的基础框架同时给框架开发者和框架使用者带来了棘手的问题:

  1. 框架版本碎片化:由于各个应用接入的框架版本不统一,框架版本碎片化严重,导致各个业务应用的框架能力不对齐。同时框架接口和实现没有隔离,框架迭代过程中遗留的不合理的内部实现可能已经被业务应用代码耦合。导致基础框架在架构演进的过程中存在很大的兼容性包袱,在稳定性、开发效率方面存在阻碍。

  2. 框架升级成本高:FatSDK中包含了大而全的组件,接入的应用数量庞大且各个应用具体的使用情况、依赖情况差异很大,升级可能会有预期外的兼容性问题,导致框架升级成本高。尤其在业务版本迭代比较敏捷的情况下,业务应用开发者往往能不升级就不升级,以避免升级带来的进度、稳定性相关的风险。

  3. 基础框架低效迭代:基础框架的迭代有泾渭分明的两个周期:开发周期和使用周期。开发周期由框架维护团队把控,比较可控。但是使用周期是不可预期的,很有可能基础框架某一个新版本在很长一段时间内只有零星的业务应用有接入、使用。中间件团队在迭代的开发周期中输出的新能力、新优化、新改造,不能实际的在业务中产生作用,不能覆盖到所有业务应用。或者说,由于升级问题, 基础框架迭代的敏捷度和业务应用迭代的敏捷度是不匹配的;基础框架的一些迭代版本在实际意义上来说近乎于无效迭代。

1.3 既有的处理措施

针对上述问题,笔者所在的团队曾推动发布平台增加可配置的依赖检查,在发布过程中检查应用使用的框架版本,一定程度上推动了版本分布的收敛。但是依然存在如下问题:

  1. 发布时版本卡控是被动的、触发式的检测。如果业务应用迭代活跃度低,发布频次少,就会被遗漏。

  2. 发布时版本卡控对业务迭代的影响较大,在推进某个涉及多个组件的新功能时,由于牵涉的改造面较大,交付一个版本后会因为各类Case需要重新交付新版本,这个时候新版本的发布周期会比较短。如果每个版本都卡控,会影响发布效率;同时由于卡控后升级需要业务开发者手动修改代码或配置,频繁的卡控版本确实会影响开发的体感。

2. 思考

2.1 线索

回顾当时基础框架的版本迭代历史,笔者发现 某个新功能相关的版本发布比较频繁;梳理相关版本的代码变动,又发现了一个很关键的点: 这些版本涉及的代码变更基本都是集中在业务应用无需感知的框架内部实现逻辑上。

换句话说:尽管框架为了这个功能发布了很多新版本,但是这个版本变更在业务代码层面是感知不到的; 新版本前后需要侵入到业务代码中的Interface、Exception、Entity、Constant等核心的接口层、契约层面代码基本是没有变化的。

既然基础框架暴露的能力契约没有做变更,那在理论上我们是可以想办法做到让业务开发者无感、透明的将基础框架的版本升级,甚至 在不必要的情况下都不需要业务开发者去修改pom.xml中的基础框架版本号。

如果我们在技术上做到业务应用不修改任何代码、配置就能升级新版框架,除了可以减少框架升级引发的业务开发者的额外工作量,还可以减少因业务代码和配置调整而产生的回归测试工作,节约测试资源。

2.2 分析

综上可知,想要降低基础框架升级的成本、难度,关键点在于将基础框架整体拆分成 相互隔离的【接口】+【实现】 两个部分。业务应用代码和基础框架的代码边界是【接口】部分,业务应用代码不感知、不耦合框架的【实现】,让【实现】部分的代码可以在保持接口契约兼容的前提下独立迭代。

虽然以FatSDK交付的基础框架代码在设计上会通过 分包、分模块等措施来隐藏【实现】部分的细节,但是由于隔离程度低,总是不可避免的被上层业务代码耦合到内部实现的细节代码,从而导致上述的种种问题。

2.3 业界方案

梳理业界既有的解决类似问题的技术思路,主要核心点都是让基础框架的【实现】部分的代码以 黑盒Sidecar的形式为业务代码服务,根据 隔离程度来细分的话大致可以分为两类:多进程Sidecar和单进程Sidecar。

2.3.1 多进程Sidecar

多进程 Sidecar 架构:每个服务旁边运行独立的 Proxy 容器

如图示,将基础框架的内部实现以独立进程的形式运行在业务进程同一个节点上。业务应用内集成 稳定的轻量的FacadeSDK, 保证业务应用代码绝对无法耦合基础框架的实现细节。业务代码使用基础框架提供的能力时, FacadeSDK透明的将请求通过localhost本地网络转发到基础框架Proxy进程。这种进程粒度的隔离,业务应用的代码逻辑和基础框架的代码逻辑 边界分明,可以独立迭代。

2.3.2 单进程Sidecar

编码时的静态依赖关系:业务代码只依赖基础框架接口

运行时 JVM 进程:业务代码、基础框架接口与启动时动态装载的实现

如图示,基础框架【接口】和【实现】部分运行在同一个进程上。但是项目代码中只集成了【接口】部分的代码,业务开发者在编码阶段是无法看到【实现】部分的代码,因而 在编码阶段就杜绝了业务代码耦合框架细节的可能性。

同时【实现】部分在应用启动时 动态装载到Java进程,这种动态性为中间件团队提供了 版本监控、治理、运维、运营的操作空间,中间件团队可以因地制宜的通过各种策略来下发【实现】模块的版本。

2.4 总结

对比上述两个方案的几个主要的方面,可以得出如下表格:

 多进程Sidecar单进程Sidecar
隔离性✅进程级隔离,运行时边界清晰无法隔离业务代码运行时反射、扫描等产生动态的依赖、耦合
性能开销相对FatSDK模式多一次网络请求,代码性能会有一定程度的下降✅没有额外的跨进程通信开销
推广、落地难度复杂度高,落地门槛高,落地需要多部门的支持✅复杂度较低,中间件团队可以自行落地

结合两个方案的优缺点,以及前面提到的升级成本和落地难度,笔者认为: 单进程Sidecar模式更匹配这里的需求。

3. 基础框架Sidecar

3.1 具体思路

基础框架的 Facade、Impl、Agent 与 Admin 的关系

  1. 梳理基础框架代码,根据代码性质分类到 【基础框架Facade】、【基础框架Impl】 两个模块

  2. 【基础框架Facade】 模块发布到私服,供业务应用在项目中静态集成

  3. 业务应用利用Spring 依赖注入机制,在业务代码中使用 【基础框架Facade】 模块的API

  4. 在发布平台统一添加基础框架的JVM agent 【基础框架Agent】;本地调试启动时,需要手动添加启动参数,也可以由框架维护团队提供配套的IDEA插件

  5. 业务应用启动时,JVM agent代码先于业务代码执行

  6. 【基础框架Agent】 和 【基础框架Admin】 做C/S通讯, 协商下载合适的 【基础框架Impl】 模块Jar包

  7. 将 【基础框架Impl】 模块Jar包添加到业务应用的Classpath中, 由于SpringBoot启动时使用了自定义类加载器,这里需要特殊处理

  8. 【基础框架Impl】 通过SpringBoot自动配置机制,将 【基础框架Facade】 中定义的Interfaces的实现注册为Spring Bean

  9. 业务代码通过@Autowired等IOC注解 透明的获取接口实现

实施要点如下:

3.2 框架代码拆分

基础框架各模块的依赖关系

基础框架代码主要划分为如下几个部分:

3.2.1 功能Facade模块

用于暴露框架的能力契约,包括各类 公开的,很少会有变更的、会被业务代码引用耦合的 Interface、Exception、Entity、Constant等。会显式的被业务应用通过Maven等集成到项目代码中,编译期可见。

如CacheManager、MqPublisher等。

3.2.2 功能Impl模块

是Facade模块的具体实现,不会被业务应用手动引入到项目代码中。运行时由Agent动态下载、装载。

包含具体的功能逻辑代码,如CacheManagerImpl,MqPublisherImpl等。

这个模块是基础框架在功能迭代过程中改动最频繁的热点部分

3.2.3 Instrumentation插件模块

以 Byte Buddy字节码库为底座实现的动态字节码增强插件,同样也是在运行时动态加载。

字节码增强插件除了可以在既有的方法上插桩,还可以替换方法实现。比如我们在Facade模块中暴露一个工具方法,但是是空实现,将具体的实现隐藏在运行时替换。这对一些必须要暴露在Facade模块,但是代码逻辑改动较频繁的一些工具类的更新非常有用。

被字节码增强的 DemoUtils 空方法

3.2.4 框架引导模块

引导模块是一个JVM agent实现,提供了premain方法供JVM在启动时调用

引导模块的逻辑应尽可能轻量,因为可能会需要打到基础镜像中,所以需要避免频繁修改。

它的核心功能主要是日志初始化+模块装载。

【Impl】模块需要装载到业务应用代码所在的类加载器Classpath中。

【Plugins】模块尽可能使用独立的、隔离的类加载器,避免类冲突。

装载模块时,需要和后端的控制服务交互,采集当前运行的JVM进程、业务应用的具体信息供控制服务决策,下发符合需求的Jar文件供装载。

3.3 内部实现暴露到Spring

SpringBoot默认扫描@SpringBootApplication所在的包及其子包,Impl的包名一般不在这个范围内。

可以通过 SpringBoot自动配置机制,将Impl模块的Bean注册到容器中。在SpringBoot 2.6等版本中,通过META-INF/spring.factories声明自动配置类,再由配置类组织Bean注册。

3.4 自适应模块装载

引导模块向控制服务请求下载模块Jar包时,需要提供当前运行的业务应用的一些状态元信息。包括静态的环境信息和动态的运行时依赖信息。尤其是业务应用静态依赖的Facade模块的版本。

控制服务根据元信息结合 灰度规则等各维度要素下发匹配的Jar文件和其他相关配置文件。

3.5 类加载器&Classpath

模块装载的过程本质上就是动态修改类加载器可以扫描的Jar文件列表。

引导模块动态的将从服务端下载的Jar包添加到适合的类加载器中。

下面以JDK8和SpringBoot 2.x中的加载结构为例。对于图中的URLClassLoader,可以通过追加Jar的URL来扩展类和资源的搜索路径。

它通过ucp字段持有的URLClassPath维护搜索路径和已打开的Jar等状态。这里需要的是 在应用启动时追加实现模块的Jar。

URLClassLoader 的源码:ucp 与 acc 字段

但是SpringBoot在打包后启动和本地IDEA中启动时,类加载器关系存在较大差异。在Java类加载双亲委派模型的加持下,情况变得会比较复杂;

本地启动与 SpringBoot FatJar 启动的类加载器层级对比

不同场景下的类加载器关系如上图示。引导模块在装载业务代码运行时可见的代码模块(如Spring Bean)时,需要将代码模块的Jar包添加到业务应用代码所在的类加载器。 因此不同的启动场景,需要使用不同的方式来装载。

3.5.1 在IDE中本地启动,加载器是AppClassLoader

装载时可以通过java.lang.instrument.Instrumentation类的appendToSystemClassLoaderSearch方法直接将Jar添加到Classpath中,相对比较简单。

Instrumentation.appendToSystemClassLoaderSearch 的方法说明

3.5.2 SpringBoot FatJar启动器,加载器是LaunchedURLClassLoader

SpringBoot FatJar模式启动时,需要设法在 SpringApplication类实例化之前,捕获到由SpringBoot创建的LaunchedURLClassLoader。

SpringApplication 构造器源码

如上图,SpringApplication类构造方法中即扫描、装载、缓存了SpringFactories文件。

可以通过字节码插桩的方式,在SpringApplication类的构造方法中添加前置切面,在切面中捕获LaunchedURLClassLoader。

4. 示例项目

以下是一个可实际运行的示例项目。

示例项目 agent-demo 的模块结构

4.1 项目结构

项目结构完全参考上述代码拆分的结构

4.1.1 biz-application

业务应用,模拟实际使用基础框架的业务应用。

pom文件中的框架依赖仅引入infra-sidecar-facade模块

biz-application 模块的 pom.xml

4.1.2 infra-sidecar-boot

引导模块,在应用main方法之前执行的JVM agent。核心功能是将Jar包下载、装载到ClassLoader。

appendBizJarToClasspath 方法的源码

图中的反射调用URLClassLoader.addURL()在JDK8可用;JDK17默认的强封装会限制这种访问,需按运行版本适配。

4.1.3 infra-sidecar-facade

门面模块,主要负责向业务应用暴露基础框架提供的能力。包含众多Interface、Exception、Constant和一些静态工具方法。

infra-sidecar-facade 模块的目录结构

4.1.4 infra-sidecar-impl

基础框架内部逻辑实现模块,具体的代码实现,通过Spring IOC容器暴露给业务应用使用。

infra-sidecar-impl 模块的目录结构

4.1.5 infra-sidecar-plugins

一个聚合模块,用于聚合各类功能插件。插件在运行时利用SPI机制发现并动态加载生效。

4.2 业务代码使用

业务代码 TestController 通过 Facade 接口使用基础框架能力

如图示,业务代码中直接使用Spring IOC注解获取实现,和之前使用的FatSDK模式并无差别。

5. 总结

基础框架的迭代,代码写完只是走了一半。新版本能不能真正用起来,还取决于各个业务应用什么时候愿意升级。框架团队忙了半天,新能力却迟迟落不到业务里,这才是版本碎片化让人头疼的地方。

把相对稳定的Facade留在业务项目里,把频繁变化的Impl交给Java Agent在启动时装载,就有机会把框架实现的交付从业务项目的静态依赖中拆出来。在接口契约保持兼容的前提下,业务开发者继续使用原来的API,框架维护者则可以独立管理实现版本的选择和灰度下发。

对笔者来说,这个思路最有吸引力的地方,就是减少框架升级对业务迭代的依赖,让基础框架开发出来的能力,能够更快地真正被用起来。

本文由作者按照 CC BY 4.0 进行授权