1 - 架构原理
模块化应用架构
为了解决这些问题,我们对应用同时做了横向和纵向的拆分。首先第一步纵向拆分:把应用拆分成基座和业务两层,这两层分别对应两层的组织分工。基座小组与传统应用一样,负责机器维护、通用逻辑沉淀、业务架构治理,并为业务提供运行资源和环境。通过关注点分离的方式为业务屏蔽业务以下所有基础设施,聚焦在业务自身上。第二部我们将业务进行横向切分出多个模块,多个模块之间独立并行迭代互不影响,同时模块由于不包含基座部分,构建产物非常轻量,启动逻辑也只包含业务本身,所以启动快,具备秒级的验证能力,让模块开发得到极致的提效。
拆分之前,每个开发者可能感知从框架到中间件到业务公共部分到业务自身所有代码和逻辑,拆分后,团队的协作分工也从发生改变,研发人员分工出两种角色,基座和模块开发者,模块开发者不关系资源与容量,享受秒级部署验证能力,聚焦在业务逻辑自身上。
这里要重点看下我们是如何做这些纵向和横向切分的,切分是为了隔离,隔离是为了能够独立迭代、剥离不必要的依赖,然而如果只是隔离是没有共享相当于只是换了个部署的位置而已,很难有好的效果。所以我们除了隔离还有共享能力,所以这里需要聚焦在隔离与共享上来理解模块化架构背后的原理。
模块的定义
在这之前先看下这里的模块是什么?模块是通过原来应用减去基座部分得到的,这里的减法是通过设置模块里依赖的 scope 为 provided 实现的,
一个模块可以由这三点定义:
- SpringBoot 打包生成的 jar 包
- 一个模块: 一个 SpringContext + 一个 ClassLoader
- 热部署(升级的时候不需要启动进程)
模块的隔离与共享
模块通过 ClassLoader 隔离配置和代码,SpringContext 隔离 Bean 和服务,可以通过调用 Spring ApplicationContext 的start close 方法来动态启动和关闭服务。通过 SOFAArk 来共享模块和基座的配置和代码 Class,通过 SpringContext Manager 来共享多模块间的 Bean 和服务。
并且在 JVM 内通过
- Ark Container 提供多 ClassLoader 运行环境
- Arklet 来管理模块生命周期
- Framework Adapter 将 SpringBoot 生命周期与模块生命周期关联起来
- SOFAArk 默认委托加载机制,打通模块与基座类委托加载
- SpringContext Manager 提供 Bean 与服务发现调用机制
- 基座本质也是模块,拥有独立的 SpringContext 和 ClassLoader
但是在 Java 领域模块化技术已经发展了20年了,为什么这里的模块化技术能够在蚂蚁内部规模化落地,这里的核心原因是
基于 SOFAArk 和 SpringContext Manager 的多模块能力,提供了低成本的使用方式。
隔离方面
对于其他的模块化技术,从隔离角度来看,JPMS 和 Spring Modulith 的隔离是通过自定义的规则来做限制的,Spring Modulith 还需要在单元测试里执行 verify 来做校验,隔离能力比较弱且一定程度上是比较 tricky 的,对于存量应用使用来说也是有不小改造成本的,甚至说是存量应用无法改造。而 SOFAArk 和 OSGI 一样采用 ClassLoader 和 SpringContext 的方式进行配置与代码、bean与服务的隔离,对原生应用的启动模式完全保持一致。
共享方面
SOFAArk 的隔离方式和 OSGI 是一致的,但是在共享方面 OSGI 和 JPMS、Spring Modulith 一样都需要在源模块和目标模块间定义导入导出列表或其他配置,这造成业务使用模块需要强感知和理解多模块的技术,使用成本是比较高的,而 SOFAArk 则定义了默认的类委托加载机制,和跨模块的 Bean 和服务发现机制,让业务不用改造的情况下能够使用多模块的能力。
这里额外提下,为什么基于 SOFAArk 的多模块化技术能提供这些默认的能力,而做到低成本的使用呢?这里主要的原因是因为我们对模块做了角色的区分,区分出了基座与模块,在这个核心原因基础上也对低成本使用这块比较重视,做了重要的设计考量和取舍。具体有哪些设计和取舍,可以查看技术实现文章。
模块间通信
模块间通信主要依托 SpringContext Manager 的 Bean 与服务发现调用机制提供基础能力,
模块的可演进
回顾背景里提到的几大问题,可以看到通过模块化架构的隔离与共享能力,可以解决掉基础设施复杂、多人协作阻塞、资源与长期维护成本高的问题,但还有微服务拆分与业务敏捷度不一致的问题未解决。
在这里我们通过降低微服务拆分的成本来解决,那么怎么降低微服务拆分成本呢?这里主要是在单体架构和微服务架构之间增加模块化架构
- 模块不占资源所以拆分没有资源成本
- 模块不包含业务公共部分和框架、中间件部分,所以模块没有长期的 sdk 升级维护成本
- 模块自身也是 SpringBoot,我们提供工具辅助单体应用低成本拆分成模块应用
- 模块具备灵活部署能力,可以合并部署在一个 JVM 内,也可拆除独立部署,这样模块可以按需低成本演进成微服务或回退会单体应用模式
图中的箭头是双向的,如果当前微服务拆分过多,也可以将多个微服务低成本改造成模块合并部署在一个 JVM 内。所以这里的本质是通过在单体架构和微服务架构之间增加一个可以双向过渡的模块化架构,降低改造成本的同时,也让开发者可以根据业务发展按需演进或回退。这样可以把微服务的这几个问题解决掉
模块化架构的优势
模块化架构的优势主要集中在这四点:快、省、灵活部署、可演进,
与传统应用对比数据如下,可以看到在研发阶段、部署阶段、运行阶段都得到了10倍以上的提升效果。
平台架构
只有应用架构还不够,需要从研发阶段到运维阶段到运行阶段都提供完整的配套能力,才能让模块化应用架构的优势真正触达到研发人员。
在研发阶段,需要提供基座接入能力,模块创建能力,更重要的是模块的本地快速构建与联调能力;在运维阶段,提供快速的模块发布能力,在模块发布基础上提供 A/B 测试和秒级扩缩容能力;在运行阶段,提供模块的可靠性能力,模块可观测、流量精细化控制、调度和伸缩能力。
组件视图
在整个平台里,需要四个组件:
- 研发工具 Arkctl, 提供模块创建、快速联调测试等能力
- 运行组件 SOFAArk, Arklet,提供模块运维、模块生命周期管理,多模块运行环境
- 控制面组件 ModuleController
- ModuleDeployment 提供模块发布与运维能力
- ModuleScheduler 提供模块调度能力
- ModuleScaler 提供模块伸缩能力
2 - 基座与模块间类委托加载原理介绍
多模块间类委托加载
SOFAArk 框架是基于多 ClassLoader 的通用类隔离方案,提供类隔离和应用的合并部署能力。本文档并不打算介绍 SOFAArk 类隔离的原理与机制,这里主要介绍多 ClassLoader 当前的最佳实践。
当前基座与模块部署在 JVM 上的 ClassLoader 模型如图:
当前类委托加载机制
当前一个模块在启动与运行时查找的类,有两个来源:当前模块本身,基座。这两个来源的理想优先级顺序是,优先从模块中查找,如果模块找不到再从基座中查找,但当前存在一些特例:
- 当前定义了一份白名单,白名单范围内的依赖会强制使用基座里的依赖。
- 模块可以扫描到基座里的所有类:
- 优势:模块可以引入较少依赖
- 劣势:模块会扫描到模块代码里不存在的类,例如会扫描到一些 AutoConfiguration,初始化时由于第四点扫描不到对应资源,所以会报错。
- 模块不能扫描到基座里的任何资源:
- 优势:不会与基座重复初始化相同的 Bean
- 劣势:模块启动如果需要基座的资源,会因为查找不到资源而报错,除非模块里显示引入(Maven 依赖 scope 不设置成 provided)
- 模块调用基座时,部分内部处理传入模块里的类名到基座,基座如果存在直接从基座 ClassLoader 查找模块传入的类,会查找不到。因为委托只允许模块委托给基座,从基座发起的类查找不会再次查找模块里的。
使用时需要注意事项
模块要升级委托给基座的依赖时,需要让基座先升级,升级之后模块再升级。
类委托的最佳实践
类委托加载的准则是中间件相关的依赖需要放在同一个的 ClassLoader 里进行加载执行,达到这种方式的最佳实践有两种:
强制委托加载
由于中间件相关的依赖一般需要在同一个 ClassLoader 里加载运行,所以我们会制定一个中间件依赖的白名单,强制这些依赖委托给基座加载。
使用方法
application.properties 里增加配置 sofa.ark.plugin.export.class.enable=true
。
优点
模块开发者不需要感知哪些依赖属于需要强制加载由同一个 ClassLoader 加载的依赖。
缺点
白名单里要强制加载的依赖列表需要维护,列表的缺失需要更新基座,较为重要的升级需要推所有的基座升级。
自定义委托加载
模块里 pom 通过设置依赖的 scope 为 provided
主动指定哪些要委托给基座加载。通过模块瘦身把与基座重复的依赖委托给基座加载,并在基座里预置中间件的依赖(可选,虽然模块暂时不会用到,但可以提前引入,以备后续模块需要引入的时候不需再发布基座即可引入)。这里:
- 基座尽可能的沉淀通用的逻辑和依赖,特别是中间件相关以
xxx-alipay-sofa-boot-starter
命名的依赖。 - 基座里预置一些公共依赖(可选)。
- 模块里的依赖如果基座里面已经有定义,则模块里的依赖尽可能的委托给基座,这样模块会更轻(提供自动模块瘦身的工具)。模块里有两种途径设置为委托给基座:
- 依赖里的 scope 设置为 provided,注意通过 mvn dependency:tree 查看是否还有其他依赖设置成了 compile,需要所有的依赖引用的地方都设置为 provided。
- biz 打包插件
sofa-ark-maven-plugin
里设置excludeGroupIds
或excludeArtifactIds
<plugin>
<groupId>com.alipay.sofa</groupId>
<artifactId>sofa-ark-maven-plugin</artifactId>
<configuration>
<excludeGroupIds>io.netty,org.apache.commons,......</excludeGroupIds>
<excludeArtifactIds>validation-api,fastjson,hessian,slf4j-api,junit,velocity,......</excludeArtifactIds>
<declaredMode>true</declaredMode>
</configuration>
</plugin>
通过 2.a 的方法需要确保所有声明的地方 scope 都设置为provided,通过2.b的方法只要指定一次即可,建议使用方法 2.b。
- 只有模块声明过的依赖才可以委托给基座加载。
模块启动的时候,Spring 框架会有一些扫描逻辑,这些扫描如果不做限制会查找到模块和基座的所有资源,导致一些模块明明不需要的功能尝试去初始化,从而报错。SOFAArk 2.0.3 之后新增了模块的 declaredMode, 来限制只有模块里声明过的依赖才可以委托给基座加载。只需在模块的打包插件的 Configurations 里增加 <declaredMode>true</declaredMode>
即可。
优点
不需要维护 plugin 的强制加载列表,当部分需要由同一 ClassLoader 加载的依赖没有设置为统一加载时,可以修改模块就可以修复,不需要发布基座(除非基座确实依赖)。
缺点
对模块瘦身的依赖较强。
对比与总结
依赖缺失排查成本 | 修复成本 | 模块改造成本 | 维护成本 | |
---|---|---|---|---|
强制加载 | 类转换失败或类查找失败,成本中 | 更新 plugin,发布基座,高 | 低 | 高 |
自定义委托加载 | 类转换失败或类查找失败,成本中 | 更新模块依赖,如果基座依赖不足,需要更新基座并发布,中 | 高 | 低 |
自定义委托加载 + 基座预置依赖 + 模块瘦身 | 类转换失败或类查找失败,成本中 | 更新模块依赖,设置为 provided,低 | 低 | 低 |
结论:推荐自定义委托加载方式
- 模块自定义委托加载 + 模块瘦身。
- 模块开启 declaredMode。
- 基座预置依赖。
declaredMode 开启方式
开启条件
declaredMode 的本意是让模块能合并部署到基座上,所以开启前需要确保模块能本地启动成功。
如果是 SOFABoot 应用且涉及到模块调用基座服务的,本地启动因为没有基座服务,可以通过在模块 application.properties 添加这两个参数进行跳过(SpringBoot 应用无需关心):
# 如果是 SOFABoot,则:
# 配置健康检查跳过 JVM 服务检查
com.alipay.sofa.boot.skip-jvm-reference-health-check=true
# 忽略未解析的占位符
com.alipay.sofa.ignore.unresolvable.placeholders=true
开启方式
模块打包插件里增加如下配置:
开启后的副作用
如果模块委托给基座的依赖里有发布服务,那么基座和模块会同时发布两份。