这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

产品文档

Koupleless 产品文档

1 - 1. 产品简介

1.1 - 1.1 简介与适用场景

Koupleless 简介与适用场景

什么是 Koupleless

Koupleless 是一种模块化的 Serverless 技术解决方案,它能让普通应用以比较低的代价演进为 Serverless 研发模式,让代码与资源解耦,轻松独立维护,与此同时支持秒级构建部署、合并部署、动态伸缩等能力为用户提供极致的研发运维体验,最终帮助企业实现降本增效。
随着各行各业的信息化数字化转型,企业面临越来越多的研发效率、协作效率、资源成本和服务治理痛点,接下来带领大家逐一体验这些痛点,以及它们在 Koupleless 中是如何被解决的。

适用场景

痛点 1:应用构建发布慢或者 SDK 升级繁琐

传统应用镜像化构建一般要 3 - 5 分钟,单机代码发布到启动完成也要 3 - 5 分钟,开发者每次验证代码修改或上线代码修改,都需要经历数次 6 - 10 分钟的构建发布等待,严重影响开发效率。此外,每次 SDK 升级(比如中间件框架、rpc、logging、json 等),都需要修改所有应用代码并重新构建发布,对开发者也造成了不必要的打扰。
通过使用 Koupleless 通用基座与配套工具,您可以低成本的将应用切分为 “基座” 与 “模块”,其中基座沉淀了公司或者某个业务部门的公共 SDK,基座升级可以由专人负责,对业务开发者无感,业务开发者只需要编写模块。在我们目前支持的 Java 技术栈中,模块就是一个 SpringBoot 应用代码包(FatJar),只不过 SpringBoot 框架本身和其他的企业公共依赖在运行时会让基座提前加载预热,模块每次发布都会找一台预热 SpringBoot 的基座进行热部署,整个过程类似 AppEngine,能够帮用户实现应用 10 秒级构建发布公共 SDK 升级无感

应用构建发布速度

痛点 2:长尾应用资源与维护成本高

在企业中,80% 的应用只服务了不到 20% 的流量,同时伴随着业务的变化,企业存在大量的长尾应用,这些长尾应用 CPU 使用率长期不到 10%,造成了极大的资源浪费
通过使用 Koupleless 合并部署与配套工具,您可以低成本的实现多个应用的合并部署,从而解决企业应用过度拆分和低流量业务带来的资源浪费节约成本

应用机器成本

这里 “业务A 应用1” 在 Koupleless 术语中叫 “模块”。多个模块应用可以使用 SOFAArk 技术合并到同一个基座。基座可以是完全空的 SpringBoot应用(Java 技术栈),也可以下沉一些公共 SDK 到基座,模块应用每次发布会重启基座机器。在这种方式下,模块应用最大程度复用了基座的内存(Metaspace 和 Heap),构建产物大小也能从数百 MB 瘦身到几十 MB 甚至更激进,CPU 使用率也得到了有效提升。

痛点 3:企业研发协作效率低

在企业中,一些应用需要多人开发协作。在传统研发模式下,每一个人的代码变更都需要发布整个应用,这就导致应用需要以赶火车式的方式进行研发迭代,大家需要统一一个时间窗口做迭代开发,统一的时间点做发布上线,因此存在大量的需求上线相互等待、环境机器抢占、迭代冲突等情况。
通过使用 Koupleless,您可以方便的将应用拆分为一个基座与多个功能模块,一个功能模块就是一组代码文件。不同的功能模块可以同时进行迭代开发和发布运维,模块间互不感知互不影响,这样就消除了传统应用迭代赶火车式的相互等待,每个模块拥有自己的独立迭代,需求交付效率因此得到了极大提升。如果您对模块额外启用了热部署方式(也可以每次发布模块重启整个基座),那么模块的单次构建+发布也会从普通应用的 6 - 10 分钟减少到十秒级。

协作效率低

痛点 4:难以沉淀业务资产提高中台效率

在一些中大型企业中,会沉淀各种业务中台应用。中台一般封装了业务的公共 API 实现,和 SPI 定义。其中 SPI 定义允许中台上的插件去实现各自的业务逻辑,流量进入中台应用后,会调用对应的 SPI 实现组件去完成相应的业务逻辑。中台应用内的组件,业务逻辑一般不复杂,如果拆出去部署为独立应用会带来高昂的资源成本和运维成本,而且构建发布速度很慢,严重加剧研发负担影响研发效率。
通过使用 Koupleless,您可以方便的将中台应用拆分一个基座和多个功能模块。基座可以沉淀比较厚的业务依赖、公共逻辑、API 实现、SPI 定义等(即所谓的业务资产),提供给上面的模块使用。模块使用基座的能力可以是对象之间或 Bean 之间的直接调用,代码几乎不用改造。而且多个模块间可以同时进行迭代开发和发布运维,互不感知互不影响协作交付效率得到了极大提升。此外对于比较简单的模块还可以开启热部署,单次构建+发布也会从普通应用的 6 - 10 分钟减少到 30 秒内。

提高中台效率

痛点 5:微服务演进成本高

企业里不同业务有不同的发展阶段,因此应用也拥有自己的生命周期。

初创期:一个初创的应用一般会先采用单体架构

增长期:随着业务增长,应用开发者也随之增加。此时您可能不确定业务的未来前景,也不希望过早把业务拆分成多个应用以避免不必要的维护、治理和资源成本,那么您可以用 Koupleless 低成本地将应用拆分为一个基座和多个功能模块,不同功能模块之间可以并行研发运维独立迭代,从而提高应用在此阶段的研发协作和需求交付效率。

成熟期:随着业务进一步增长,您可以使用 Koupleless 低成本地将部分或全部功能模块拆分成独立应用去研发运维。

长尾期:部分业务在经历增长期或者成熟期后,也可能慢慢步入到低活状态或者长尾状态,此时您可以用 Koupleless 低成本地将这些应用一键改回模块合并部署到一起实现降本增效

可以看到 Koupleless 支持企业应用低成本地在初创期、增长期、成熟期、长尾期之间平滑过渡甚至来回切换,从而轻松让应用架构与业务发展保持同步。
应用生命周期演进

微服务演进成本


1.2 - 1.2 行业背景

Koupleless 背景

应用研发与微服务存在的问题

应用架构从单体应用发展到微服务,结合软件工程从瀑布模式到当前的 DevOps 模式的发展,解决了可扩展、分布式、分工协作等问题,为企业提供较好的敏捷性与执行效率,带来了明显的价值。但该模式发展至今,虽然解决了一些问题,也有微服务的一些问题慢慢暴露出来,在当前已经得到持续关注:

基础设施复杂

认知负载高

当前业务要完成一个需求,背后实际上有非常多的依赖、组件和平台在提供各种各样的能力,只要这些业务以下的某一个组件出现异常被业务感知到,都会对业务研发人员带来较大认知负担和对应恢复的时间成本。


基础设施复杂、异常种类繁多

运维负担重

业务包含的各个依赖也会不断迭代升级,例如框架、中间件、各种 sdk 等,在遇到

  1. 重要功能版本发布
  2. 修复紧急 bug
  3. 遇到重大安全漏洞

等情况时,这些依赖的新版本就需要业务尽可能快的完成升级,这造成了两方面的问题:

对于业务研发人员

这些依赖的升级如果只是一次两次那么就不算是问题,但是一个业务应用背后依赖的框架、中间件与各类 sdk 是很多的,每一个依赖发布这些升级都需要业务同学来操作,这么多个依赖的话长期上就会对业务研发同学来说是不小的运维负担。另外这里也需要注意到业务公共层对业务开发者来说也是不小的负担。

对于基础设施人员

类似的对于各个依赖的开发人员自身,每发布一个这样的新版本,需要尽可能快的让使用的业务应用完成升级。但是业务研发人员更关注业务需求交付,想要推动业务研发人员快速完成升级是不太现实的,特别是在研发人员较多的企业里。

启动慢

每个业务应用启动过程都需要涉及较多过程,造成一个功能验证需要花费较长等待时间。

发布效率低

由于上面提到的启动慢、异常多的问题,在发布上线过程中需要较长时间,出现异常导致卡单需要恢复处理。发布过程中除了平台异常外,机器异常发生的概率会随着机器数量的增多而增多,假如一台机器正常完成发布(不发生异常)的概率是 99.9%,也就是一次性成功率为 99.9%,那么100台则是 90%,1000台则降低到了只有 36.7%,所以对于机器较多的应用发布上线会经常遇到卡单的问题,这些都需要研发人员介入处理,导致效率低。

协作与资源成本高

单体应用/大应用过大

多人协作阻塞

业务不断发展,应用会不断变大,这主要体现在开发人员不断增多,出现多人协作阻塞问题。

变更影响面大,风险高

业务不断发展,线上流量不断增大,机器数量也不断增多,但当前一个变更可能影响全部代码和机器流量,变更影响面大风险高。

小应用过多


在微服务发展过程中,随着时间的推移,例如部分应用拆分过多、某些业务萎缩、组织架构调整等,都会出现线上小应用或者长尾应用不断积累,数量越来越多,像蚂蚁过去3年应用数量增长了 3倍。

资源成本高

这些应用每个机房都需要几台机器,但其实流量也不大,cpu 使用率很低,造成资源浪费。

长期维护成本

这些应用同样需要人员来维度,例如升级 SDK,修复安全漏洞等,长期维护成本高。

问题必然性

微服务系统是个生态,在一个公司内发展演进几年后,参考28定律,少数的大应用占有大量的流量,不可避免的会出现大应用过大和小应用过多的问题。
然而大应用多大算大,小应用多少算多,这没有定义的标准,所以这类问题造成的研发人员的痛点是隐匿的,没有痛到一定程度是较难引起公司管理层面的关注和行动。

如何合理拆分微服务

微服务如何合理拆分始终是个老大难的问题,合理拆分始终没有清晰的标准,这也是为何会存在上述的大应用过大、小应用过多问题的原因。而这些问题背后的根因是业务与组织灵活,与微服务拆分的成本高,两者的敏捷度不一致


业务发展灵活,组织架构也在不断调整,而微服务拆分需要机器与长期维护的成本,两者的敏捷度不一致,导致容易出现未拆或过度拆分问题,从而出现大应用过大和小应用过多问题。这类问题不从根本上解决,会导致微服务应用治理过一波之后还会再次出现问题,导致研发同学始终处于低效的痛苦与治理的痛苦循环中。

不同体量企业面对的问题

行业尝试的解法

当前行业里也有很多不错的思路和项目在尝试解决这些问题,例如服务网格、应用运行时、平台工程,Spring Modulith、Google ServiceWeaver,有一定的效果,但也存在一定的局限性:

  1. 从业务研发人员角度看,只屏蔽部分基础设施,未屏蔽业务公共部分
  2. 只解决其中部分问题
  3. 存量应用接入改造成本高

Koupleless 的目的是为了解决这些问题而不断演进出来的一套研发框架与平台能力。



1.3 - 1.3 产品架构

1.3.1 - 1.3.1 架构原理

Koupleless 架构

模块化应用架构

为了解决这些问题,我们对应用同时做了横向和纵向的拆分。首先第一步纵向拆分:把应用拆分成基座业务两层,这两层分别对应两层的组织分工。基座小组与传统应用一样,负责机器维护、通用逻辑沉淀、业务架构治理,并为业务提供运行资源和环境。通过关注点分离的方式为业务屏蔽业务以下所有基础设施,聚焦在业务自身上。第二部我们将业务进行横向切分出多个模块,多个模块之间独立并行迭代互不影响,同时模块由于不包含基座部分,构建产物非常轻量,启动逻辑也只包含业务本身,所以启动快,具备秒级的验证能力,让模块开发得到极致的提效。


拆分之前,每个开发者可能感知从框架到中间件到业务公共部分到业务自身所有代码和逻辑,拆分后,团队的协作分工发生相应改变,研发人员分工出两种角色,基座和模块开发者,模块开发者不关心资源与容量,享受秒级部署验证能力,聚焦在业务逻辑自身上。

这里要重点看下我们是如何做这些纵向和横向切分的,切分是为了隔离,隔离是为了能够独立迭代、剥离不必要的依赖,然而如果只是隔离是没有共享相当于只是换了个部署的位置而已,很难有好的效果。所以我们除了隔离还有共享能力,所以这里需要聚焦在隔离与共享上来理解模块化架构背后的原理。

模块的定义

在这之前先看下这里的模块是什么?模块是通过原来应用减去基座部分得到的,这里的减法是通过设置模块里依赖的 scope 为 provided 实现的,



一个模块可以由这三点定义:

  1. SpringBoot 打包生成的 jar 包
  2. 一个模块: 一个 SpringContext + 一个 ClassLoader
  3. 热部署(升级的时候不需要启动进程)

模块的隔离与共享

模块通过 ClassLoader 隔离配置和代码,SpringContext 隔离 Bean 和服务,可以通过调用 Spring ApplicationContext 的start close 方法来动态启动和关闭服务。通过 SOFAArk 来共享模块和基座的配置和代码 Class,通过 SpringContext Manager 来共享多模块间的 Bean 和服务。


并且在 JVM 内通过

  1. Ark Container 提供多 ClassLoader 运行环境
  2. Arklet 来管理模块生命周期
  3. Framework Adapter 将 SpringBoot 生命周期与模块生命周期关联起来
  4. SOFAArk 默认委托加载机制,打通模块与基座类委托加载
  5. SpringContext Manager 提供 Bean 与服务发现调用机制
  6. 基座本质也是模块,拥有独立的 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 与服务发现调用机制提供基础能力,
!

模块的可演进

回顾背景里提到的几大问题,可以看到通过模块化架构的隔离与共享能力,可以解决掉基础设施复杂、多人协作阻塞、资源与长期维护成本高的问题,但还有微服务拆分与业务敏捷度不一致的问题未解决。


在这里我们通过降低微服务拆分的成本来解决,那么怎么降低微服务拆分成本呢?这里主要是在单体架构和微服务架构之间增加模块化架构

  1. 模块不占资源所以拆分没有资源成本
  2. 模块不包含业务公共部分和框架、中间件部分,所以模块没有长期的 sdk 升级维护成本
  3. 模块自身也是 SpringBoot,我们提供工具辅助单体应用低成本拆分成模块应用
  4. 模块具备灵活部署能力,可以合并部署在一个 JVM 内,也可拆除独立部署,这样模块可以按需低成本演进成微服务或回退会单体应用模式


图中的箭头是双向的,如果当前微服务拆分过多,也可以将多个微服务低成本改造成模块合并部署在一个 JVM 内。所以这里的本质是通过在单体架构和微服务架构之间增加一个可以双向过渡的模块化架构,降低改造成本的同时,也让开发者可以根据业务发展按需演进或回退。这样可以把微服务的这几个问题解决掉

模块化架构的优势

模块化架构的优势主要集中在这四点:快、省、灵活部署、可演进,

与传统应用对比数据如下,可以看到在研发阶段、部署阶段、运行阶段都得到了10倍以上的提升效果。

平台架构

只有应用架构还不够,需要从研发阶段到运维阶段到运行阶段都提供完整的配套能力,才能让模块化应用架构的优势真正触达到研发人员。


在研发阶段,需要提供基座接入能力,模块创建能力,更重要的是模块的本地快速构建与联调能力;在运维阶段,提供快速的模块发布能力,在模块发布基础上提供 A/B 测试和秒级扩缩容能力;在运行阶段,提供模块的可靠性能力,模块可观测、流量精细化控制、调度和伸缩能力。


组件视图

在整个平台里,需要四个组件:

  1. 研发工具 Arkctl, 提供模块创建、快速联调测试等能力
  2. 运行组件 SOFAArk, Arklet,提供模块运维、模块生命周期管理,多模块运行环境
  3. 控制面组件 ModuleController
    1. ModuleDeployment 提供模块发布与运维能力
    2. ModuleScheduler 提供模块调度能力
    3. ModuleScaler 提供模块伸缩能力

2 - 2. 快速开始

Koupleless 快速开始

2.1 - 2.1 模块开发

Koupleless 快速开始

本上手指南主要介绍动态合并部署模式,用于省资源与提高研发效率。如果你只是想节省资源,可以使用静态合并部署。本上手指南使用官网的 tomacat sample 进行演示: 1.

预先准备

研发工具

  • jdk 8
  • maven v3.9.0+
  • git
  • arkctl v0.2.1+, 安装方式请查看这里

代码下载

git clone git@github.com:koupleless/samples.git

这个仓库包含了多个框架的 samples,基座和模块都在同一个代码仓库里,如图所示

导入 springboot-samples 工程到编译器

  1. 导入 springboot-samples,有两种方式导入工程到编译器
  • 方式一:导入 samples 到编译器,然后选择 springboot-samples 子目录的 pom 为 maven 工程
  • 方式二:直接导入 springboot-samples 到编译器,此时自动将 springboot-samples 导入为 maven 工程
  1. 执行如下命令构建 springboot-samples/web/tomcat 基座与两个模块
mvn -pl web/tomcat/biz1-web-single-host,web/tomcat/biz2-web-single-host -am clean package -DskipTests

构建完之后可以看到模块打出的模块 jar 包

本地环境启动验证

  1. 启动基座,按照普通应用启动即可
  2. 安装模块1
arkctl deploy /xxx/path/to/biz1-web-single-host/target/biz1-web-single-host-0.0.1-SNAPSHOT.jar
  1. 安装模块2
arkctl deploy /xxx/path/to/biz2-web-single-host/target/biz2-web-single-host-0.0.1-SNAPSHOT.jar
  1. 测试验证
curl http://localhost:8080/biz1/

执行 curl 命令返回 hello to /biz1 deploy

curl http://localhost:8080/biz2/

执行 curl 命令返回 hello to /biz2 deploy

更多实验请查看 samples 用例

点击此处

2.2 - 2.2 模块运维

Koupleless 快速开始

本上手指南主要介绍基于 Module Controller V2 的模块运维。本上手指南包含:

  1. 环境准备
  2. Module Controller V2 部署
  3. 测试基座准备
  4. 模块发布与状态查看

环境准备

K8S 环境部署

Module Controller V2 基于 K8S 构建模块运维能力,因此,首先需要准备一个基础 K8S 环境。

注意: 目前 Module Controller 只支持 arm64 / amd64 的运行环境。

如果已经有可用的 K8S 集群,请跳过本节。

本地测试推荐使用 Minikube 快速在本地搭建 K8S。Minikube 是一个开源的本地 Kubernetes 搭建工具,能够帮助我们快速完成 K8S 各依赖组件的部署。

为了安装 Minikube,首先需要安装 Docker 运行环境:Docker官方网站

完成 Docker 安装并完成 Docker daemon 启动之后,我们就完成了所有 Minikube 的安装准备工作。

Minikube 的安装可以参考官方文档

MQTT 环境准备

Module Controller V2 包含一个基于 MQTT 的运维管道,依赖 MQTT 进行运维指令的下发与数据同步,因此需要准备一个 MQTT 服务。

如果已经有可用的 MQTT 服务,请跳过本节。

这里推荐直接使用 NanoMQ 的 MQTT 服务镜像进行测试, 使用 yaml 在 K8S 中部署 MQTT 服务:

apiVersion: v1
kind: Pod
metadata:
  name: mqtt
  labels:
    app: mqtt
spec:
  containers:
    - name: mqtt
      image: serverless-registry.cn-shanghai.cr.aliyuncs.com/opensource/base/emqx/nanomq:latest
      resources:
        limits:
          cpu: "200m"
          memory: "100Mi"
      ports:
        - name: mqtt
          containerPort: 1883

发布之后通过 kubectl get pods -o wide 来查看部署情况,容器状态转为 Running 之后保存下来查看到的 Pod IP 信息,用于后续操作。

Module Controller V2 部署

Module Controller V2 有两种部署方式:

  1. 本地运行(需要配置 go 语言环境,不建议)
  2. 镜像部署(建议)

接下来我们以镜像部署为例。

首先我们需要为 Module Controller V2 准备必要的 RBAC 配置。

  1. 下载 Service Account YAML
  2. 下载 Cluster Role YAML
  3. 下载 Cluster Role Binding YAML

接下来,依次 apply 上面的三个 yaml 文件,完成 service account 的权限设置与绑定。

接下来我们需要准备 Module Controller 部署的 Pod Yaml

apiVersion: v1
kind: Pod
metadata:
  name: module-controller
  labels:
    app: module-controller
spec:
  serviceAccountName: virtual-kubelet # 上一步中配置好的 Service Account
  containers:
    - name: module-controller
      image: serverless-registry.cn-shanghai.cr.aliyuncs.com/opensource/release/module_controller:2.0.0 # 已经打包好的镜像
      imagePullPolicy: Always
      resources:
        limits:
          cpu: "1000m"
          memory: "400Mi"
      env:
        - name: MQTT_BROKER # mqtt broker url
          value: YOUR_MQTT_BROKER
        - name: MQTT_PORT # mqtt port
          value: "1883"
        - name: MQTT_USERNAME # mqtt username
          value: koupleless
        - name: MQTT_PASSWORD # mqtt password
          value: public
        - name: MQTT_CLIENT_PREFIX # mqtt client prefix
          value: koupleless

注意,请将上面 Yaml 中 env 下的 YOUR_MQTT_BROKER 替换成为实际 MQTT 服务的端点,如果按照教程部署了 NanoMQ 服务,将此处替换为 MQTT 环境准备中获得的 mqtt Pod 的 IP。

apply 上述 Module Controller 的 yaml 到 K8S 集群,等待 Module Controller Pod 变成 Running 状态。

接下来,模块运维能力已经搭建完成了,接下来将准备测试基座和测试模块。

测试基座部署

为了方便上手,我们这里也准备好了测试基座的 Docker 镜像,首先下载基座 Yaml

apiVersion: v1
kind: Pod
metadata:
  name: base
  labels:
    app: base
spec:
  serviceAccountName: virtual-kubelet # 上一步中配置好的 Service Account
  containers:
    - name: base
      image: serverless-registry.cn-shanghai.cr.aliyuncs.com/opensource/test/base # 已经打包好的镜像
      imagePullPolicy: Always
      resources:
        limits:
          cpu: "1000m"
          memory: "400Mi"
      env:
        - name: KUPLELESS_ARKLET_MQTT_BROKER
          value: YOUR_MQTT_BROKER
        - name: KUPLELESS_ARKLET_MQTT_PORT
          value: "1883"
        - name: KUPLELESS_ARKLET_MQTT_USERNAME
          value: koupleless_base
        - name: KUPLELESS_ARKLET_MQTT_PASSWORD
          value: public
        - name: KUPLELESS_ARKLET_MQTT_CLIENT_PREFIX
          value: koupleless
        - name: KUPLELESS_ARKLET_CUSTOM_TUNNEL_CLASSNAME
          value: com.alipay.sofa.koupleless.arklet.tunnel.mqtt.MqttTunnel
        - name: KUPLELESS_ARKLET_CUSTOM_BASE_METADATA_CLASSNAME
          value: com.alipay.sofa.web.base.metadata.MetadataHook

同上一步,将yaml中的 YOUR_MQTT_BROKER 替换为实际 MQTT 服务的端点,如果按照教程部署了 NanoMQ 服务,将此处替换为 MQTT 环境准备中获得的 mqtt Pod 的 IP。

apply 更改后的 yaml 到 K8S 集群,等待 Base Pod 变成 Running 状态。

基座启动完成之后,我们可以通过以下方式验证基座已经成功映射成为 VNode:

kubectl get nodes

看到存在名为 vnode.{uuid} 的 node,并且状态为 Ready,则说明基座已经成功启动并完成映射。

上述 uuid 是在基座启动时生成的,每一次重新启动都会不同

接下来为了方便验证访问,我们使用 port-forward 将基座容器的服务暴露出来,使用如下命令:

kubectl port-forward base 8080:8080

接下来访问链接 如果能够正常访问,说明已完成映射。

测试模块发布与状态查看

模块发布

为了对比,我们首先看一下模块还没有安装的时候的情况,访问基座服务:模块测试

此时应当返回错误页,表明模块还未安装。

接下来我们将使用 Deployment 对模块进行发布,将下面的模块 yaml apply 到 K8S ,即可进行模块发布。这里以单个模块发布为例:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: biz1
  labels:
    virtual-kubelet.koupleless.io/component: module-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      module: biz1
  template:
    metadata:
      labels:
        module: biz1
        virtual-kubelet.koupleless.io/component: module
    spec:
      containers:
        - name: biz1
          image: https://serverless-opensource.oss-cn-shanghai.aliyuncs.com/module-packages/stable/biz1-web-single-host-0.0.1-SNAPSHOT-ark-biz.jar
          env:
            - name: BIZ_VERSION
              value: 0.0.1-SNAPSHOT
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: base.koupleless.io/stack
                    operator: In
                    values:
                      - java
                  - key: vnode.koupleless.io/version
                    operator: In
                    values:
                      - 1.0.0
                  - key: vnode.koupleless.io/name
                    operator: In
                    values:
                      - koupleless-sample
      tolerations:
        - key: "schedule.koupleless.io/virtual-node"
          operator: "Equal"
          value: "True"
          effect: "NoExecute"
        - key: "schedule.koupleless.io/node-env"
          operator: "Equal"
          value: "dev"
          effect: "NoExecute"

发布完成之后,可以通过 kubectl get pods 来查看所有模块 pod 的状态。

当 deployment 创建出的 pod 状态变为 Running 之后,表示当前模块已经安装完成了,接下来我们再次访问基座服务: 模块测试 来验证模块安装情况。

可以看到,页面内容已经发生了变化,展示:hello to /biz1 deploy ,表明模块已经安装完成。

模块删除

模块删除可以直接通过删除模块的 Deployment 来实现,使用命令:

kubectl delete deployment biz1

可以通过 kubectl get pods 来查看 pod 是否已经删除成功。

删除成功之后,再次访问基座服务 模块测试 来验证模块卸载情况。

可以看到,页面内容又恢复回了模块未安装的状态,表明模块卸载完成。

2.3 - 2.3 模块完整生命周期过程

Koupleless 快速开始

本上手指南主要介绍动态合并部署模式,用于省资源与提高研发效率。如果你只是想节省资源,可以使用静态合并部署。本上手指南包含:

  1. 基座接入
  2. 模块接入
  3. 模块开发验证
  4. 模块部署上线(暂不可用,待更新)

这里也提供了视频教程,可点击此处查看

预先准备

研发工具

  • jdk 8, jdk 17, jdk21+
  • maven v3.9.0+
  • arkctl v0.2.1+, 安装方式请查看这里

运维工具 (静态合并部署可不需要)

  • docker
  • kubectl
  • k8s 集群如 minikube v1.10+

基座接入

可参考此处

模块接入

可参考此处

本地环境开发验证

可查看这里

模块部署上线, 以 minikube 集群为例 (暂不可使用,待更新)

第一步:部署运维组件 ModuleController

kubectl apply -f xxx/xxx.yaml

第二步:使用样例基座发布

  1. 基座部署到 k8s 集群中,创建基座的 service,暴露端口, 可参考这里
  2. 执行 minikube service base-web-single-host-service, 访问基座的服务
微服务演进成本

第三步:发布模块

有两种方式发布模块,

  1. 直接部署本地模块 jar 包到 k8s 集群中
arkctl deploy ${模块构建出的 jar 包路径} --pod ${namespace}/${podname}
  1. 通过 k8s 模块 deployment 部署上线 创建模块 deployment,直接使用 kubectl apply 进行发布
kubectl apply -f xxx/xxxxx/xx.yaml

第四步:测试验证

更多实验请查看 samples 用例

点击此处

3 - 3. 视频教程

Koupleless 视频教程

Koupleless (SOFAServerless) 模块本地开发与上线视频教程

小贴士: 仅需两分钟时间



该视频的内容请查看此处实验一查看。

Koupleless (SOFAServerless) 平台和研发框架完整视频教程

本套学习课程为交互式课程,配置了完备的沙箱环境,可以边看边学,欢迎体验!

步骤 1:点击此处,直接通过手机号验证码的模式进行登录(新用户通过登录会直接完成注册操作),也可以通过其他快捷方式进行登录。 image

步骤 2:完成登录后,不变更页面的话会直接跳转到【SofaServerless交互式实验室】首页,然后选择如下课程

image

可按需进行交互式体验学习。

4 - 4. 用户手册

Koupleless 用户手册

4.1 - 4.1 基座接入

4.1.1 - 4.1.1 SpringBoot 或 SOFABoot 升级为基座

SpringBoot 或 SOFABoot 升级为 Koupleless 基座

前提条件

  1. SpringBoot 版本 >= 2.1.9.RELEASE(针对 SpringBoot 用户)
  2. SOFABoot 版本 >= 3.9.0 或 SOFABoot >= 4.0.0(针对 SOFABoot 用户)
  3. 本地安装 maven,且 maven 版本大于 3.9.0

注意:SpringBoot 版本 == 2.1.9.RELEASE,见 SpringBoot 2.1.9 升级为基座

接入步骤

代码与配置修改

修改 application.properties

# 需要定义应用名
spring.application.name = ${替换为实际基座应用名}

修改主 pom.xml

<properties>
    <sofa.ark.version>2.2.14</sofa.ark.version>
    <!-- 不同jdk版本,使用不同koupleless版本,参考:https://koupleless.io/docs/tutorials/module-development/runtime-compatibility-list/#%E6%A1%86%E6%9E%B6%E8%87%AA%E8%BA%AB%E5%90%84%E7%89%88%E6%9C%AC%E5%85%BC%E5%AE%B9%E6%80%A7%E5%85%B3%E7%B3%BB -->
    <koupleless.runtime.version>1.3.1</koupleless.runtime.version>
</properties>
<dependency>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-base-starter</artifactId>
    <version>${koupleless.runtime.version}</version>
</dependency>

<!-- 如果使用了 springboot web,则加上这个依赖,详细查看https://www.sofastack.tech/projects/sofa-boot/sofa-ark-multi-web-component-deploy/ -->
<dependency>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>web-ark-plugin</artifactId>
    <version>${sofa.ark.version}</version>
</dependency>

<!-- 为了让三方依赖和 koupleless 模式适配,需要引入以下构建插件 -->
<build>
    <plugins>
        <plugin>
            <groupId>com.alipay.sofa.koupleless</groupId>
            <artifactId>koupleless-base-build-plugin</artifactId>
            <version>${koupleless.runtime.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>add-patch</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

其它版本接入

SpringBoot 2.1.9 升级为基座

在修改以上配置后,还需额外修改以下配置:

主 pom 引入 sdk
<!-- 注意放在构建 pom 的第一个依赖位置 -->
<dependency>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-base-starter</artifactId>
    <version>${koupleless.runtime.version}</version>
</dependency>
<!-- 如果使用了 springboot web,则加上这个依赖,详细查看https://www.sofastack.tech/projects/sofa-boot/sofa-ark-multi-web-component-deploy/ -->
<dependency>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>web-ark-plugin</artifactId>
    <version>${sofa.ark.version}</version>
</dependency>
<dependency>
    <groupId>com.github.oshi</groupId>
    <artifactId>oshi-core</artifactId>
    <version>3.9.1</version>
</dependency>

<!-- 为了让三方依赖和 koupleless 模式适配,需要引入以下构建插件 -->
<build>
    <plugins>
        <plugin>
            <groupId>com.alipay.sofa.koupleless</groupId>
            <artifactId>koupleless-base-build-plugin</artifactId>
            <version>${koupleless.runtime.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>add-patch</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>
修改基座启动类

在 koupleless 版本 1.1.0 及以上,无需改动。

在 koupleless 版本 1.1.0 以下,在基座 Springboot 启动类的注解 @SpringBootApplication 中,排除 HealthAutoConfiguration 类,如下:

import com.alipay.sofa.koupleless.arklet.springboot.starter.health.HealthAutoConfiguration;
@SpringBootApplication(exclude = { HealthAutoConfiguration.class })
public class BaseApplication {
    public static void main(String[] args) {
        SpringApplication.run(BaseApplication.class, args);
    }
}

启动验证

基座应用能正常启动即表示验证成功!



4.2 - 4.2 模块接入

Koupleless 模块接入

4.2.1 - 4.2.1 存量 SpringBoot 或 SOFABoot 升级为模块

存量 SpringBoot 或 SOFABoot 升级为 Koupleless 模块

模块的创建有四种方式,本文介绍第二种方式:

  1. 大应用拆出多个模块
  2. 存量应用改造成一个模块
  3. 直接脚手架创建模块
  4. 普通代码片段改造成一个模块

本文介绍存量 SpringBoot 或 SOFABoot 如何低成本升级为模块的操作和验证步骤,仅需加一个 ark 打包插件 + 配置模块瘦身 即可实现普通应用一键升级为模块应用,并且能做到同一套代码分支,既能像原来 SpringBoot 一样独立启动,也能作为模块与其它应用合并部署在一起启动。

前提条件

  1. SpringBoot 版本 >= 2.1.9.RELEASE(针对 SpringBoot 用户)
  2. SOFABoot >= 3.9.0 或 SOFABoot >= 4.0.0(针对 SOFABoot 用户)

接入步骤

步骤 1:修改 application.properties

# 需要定义应用名
spring.application.name = ${替换为实际模块应用名}

步骤 2:添加模块需要的依赖和打包插件

特别注意: sofa ark 插件定义顺序必须在 springboot 打包插件前;

<properties>
    <sofa.ark.version>2.2.14</sofa.ark.version>
    <!-- 不同jdk版本,使用不同koupleless版本,参考:https://koupleless.io/docs/tutorials/module-development/runtime-compatibility-list/#%E6%A1%86%E6%9E%B6%E8%87%AA%E8%BA%AB%E5%90%84%E7%89%88%E6%9C%AC%E5%85%BC%E5%AE%B9%E6%80%A7%E5%85%B3%E7%B3%BB -->
    <koupleless.runtime.version>1.2.3</koupleless.runtime.version>
</properties>
<!-- 模块需要引入的依赖,主要用户跨模块间通信 --> 
<dependencies>
    <dependency>
        <groupId>com.alipay.sofa.koupleless</groupId>
        <artifactId>koupleless-app-starter</artifactId>
        <version>${koupleless.runtime.version}</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

<plugins>
    <!--这里添加ark 打包插件-->
    <plugin>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>sofa-ark-maven-plugin</artifactId>
        <version>{sofa.ark.version}</version>
        <executions>
            <execution>
                <id>default-cli</id>
                <goals>
                    <goal>repackage</goal>
                </goals>
            </execution>
        </executions>
        <configuration>
            <skipArkExecutable>true</skipArkExecutable>
            <outputDirectory>./target</outputDirectory>
            <bizName>${替换为模块名}</bizName>
            <webContextPath>${模块自定义的 web context path}</webContextPath>
            <declaredMode>true</declaredMode>
        </configuration>
    </plugin>
    <!--  构建出普通 SpringBoot fatjar,支持独立部署时使用,如果不需要可以删除  -->
    <plugin>
        <!--原来 spring-boot 打包插件 -->
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
</plugins>

步骤 3:自动化瘦身模块

您可以使用 ark 打包插件的自动化瘦身能力,自动瘦身模块里的 maven 依赖。这一步是必选的,否则构建出的模块 jar 包会非常大,而且启动会报错。 扩展阅读:如果模块不做依赖瘦身独立引入 SpringBoot 框架会怎样?

步骤 4:构建成模块 jar 包

执行 mvn clean package -DskipTest, 可以在 target 目录下找到打包生成的 ark biz jar 包,也可以在 target/boot 目录下找到打包生成的普通的 springboot jar 包。

小贴士模块中支持的完整中间件清单

实验:验证模块既能独立启动,也能被合并部署

增加模块打包插件(sofa-ark-maven-plugin)进行打包后,只会新增 ark-biz.jar 构建产物,与原生 spring-boot-maven-plugin 打包的可执行Jar 互相不冲突、不影响。 当服务器部署时,期望独立启动,就使用原生 spring-boot-maven-plugin 构建出的可执行 Jar 作为构建产物;期望作为 ark 模块部署到基座中时,就使用 sofa-ark-maven-plugin 构建出的 xxx-ark-biz.jar 作为构建产物

验证能合并部署到基座上

  1. 启动上一步(验证能独立启动步骤)的基座
  2. 发起模块部署
curl --location --request POST 'localhost:1238/installBiz' \
--header 'Content-Type: application/json' \
--data '{
    "bizName": "${模块名}",
    "bizVersion": "${模块版本}",
    "bizUrl": "file:///path/to/ark/biz/jar/target/xx-xxxx-ark-biz.jar"
}'

返回如下信息表示模块安装成功

  1. 查看当前模块信息,除了基座 base 以外,还存在一个模块 dynamic-provider
  1. 卸载模块
curl --location --request POST 'localhost:1238/uninstallBiz' \
--header 'Content-Type: application/json' \
--data '{
    "bizName": "dynamic-provider",
    "bizVersion": "0.0.1-SNAPSHOT"
}'

返回如下,表示卸载成功

{
    "code": "SUCCESS",
    "data": {
        "code": "SUCCESS",
        "message": "Uninstall biz: dynamic-provider:0.0.1-SNAPSHOT success."
    }
}

验证能独立启动

普通应用改造成模块之后,还是可以独立启动,可以验证一些基本的启动逻辑,只需要在启动配置里勾选自动添加 providedscope 到 classPath 即可,后启动方式与普通应用方式一致。通过自动瘦身改造的模块,也可以在 target/boot 目录下直接通过 springboot jar 包启动,点击此处查看详情。

4.2.2 - 4.2.2 使用 maven archtype 脚手架自动生成

模块的创建有四种方式,本文介绍第三种方式:

  1. 大应用拆出多个模块
  2. 存量应用改造成一个模块
  3. 直接脚手架创建模块
  4. 普通代码片段改造成一个模块

从脚手架里创建模块的方式比较简单,只需要在 idea 里创建工程里传入脚手架的 maven 坐标即可。

<dependency>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-common-module-archetype</artifactId>
    <version>{koupleless.runtime.version}</version>
</dependency>

该脚手架创建出来的模块,已经集成模块打包插件和自动瘦身配置,可以直接打包成模块安装在基座上,或者本地直接独立启动。

4.2.3 - 4.2.3 Java 代码片段作为模块

Java 代码片段作为模块

模块的创建有四种方式,本文介绍第四种方式:

  1. 大应用拆出多个模块
  2. 存量应用改造成一个模块
  3. 直接脚手架创建模块
  4. 普通代码片段改造成一个模块

本文介绍 Java 代码片段升级为模块的操作和验证步骤,仅需加一个 ark 打包插件 + 配置模块瘦身 即可实现 Java 代码片段一键升级为模块应用,并且能做到同一套代码分支,既能像原来 Java 代码片段一样独立启动,也能作为模块与其它应用合并部署在一起启动。

前提条件

  • jdk8
    • sofa.ark.version >= 2.2.14-SNAPSHOT
    • koupleless.runtime.version >= 1.3.1-SNAPSHOT
  • jdk17/jdk21
    • sofa.ark.version >= 3.1.7-SNAPSHOT
    • koupleless.runtime.version >= 2.1.6-SNAPSHOT

接入步骤

步骤 1:添加模块需要的依赖和打包插件

<properties>
    <sofa.ark.version>${见上述前提条件}</sofa.ark.version>
    <!-- 不同jdk版本,使用不同koupleless版本,参考:https://koupleless.io/docs/tutorials/module-development/runtime-compatibility-list/#%E6%A1%86%E6%9E%B6%E8%87%AA%E8%BA%AB%E5%90%84%E7%89%88%E6%9C%AC%E5%85%BC%E5%AE%B9%E6%80%A7%E5%85%B3%E7%B3%BB -->
    <koupleless.runtime.version>${见上述前提条件}</koupleless.runtime.version>
</properties>
<!-- 模块需要引入的依赖,主要用户跨模块间通信 --> 
<dependencies>
    <dependency>
        <groupId>com.alipay.sofa.koupleless</groupId>
        <artifactId>koupleless-app-starter</artifactId>
        <version>${koupleless.runtime.version}</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

<plugins>
    <!--这里添加ark 打包插件-->
    <plugin>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>sofa-ark-maven-plugin</artifactId>
        <version>{sofa.ark.version}</version>
        <executions>
            <execution>
                <id>default-cli</id>
                <goals>
                    <goal>repackage</goal>
                </goals>
            </execution>
        </executions>
        <configuration>
            <skipArkExecutable>true</skipArkExecutable>
            <outputDirectory>./target</outputDirectory>
            <bizName>${替换为模块名}</bizName>
            <declaredMode>true</declaredMode>
        </configuration>
    </plugin>
    
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.2.0</version>
        <executions>
            <execution>
                <goals>
                    <goal>jar</goal>
                </goals>
                <phase>package</phase>
                <configuration>
                    <classifier>lib</classifier>
                    <!-- Ensure other necessary configuration here -->
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>

步骤 2: 增加初始化逻辑

在代码片段添加:MainApplication.init() 来初始化容器。

public static void main(String[] args) {
        // 初始化模块的实例容器
        MainApplication.init();

        // ...
    }

在模块和基座的通信上,模块将实例注册在容器中,基座通过SpringServiceFinder获取模块实例,我们以biz3 为例:

  1. biz3 实现了以 AppService 为接口的两个实例:Biz3AppServiceImplBiz3OtherAppServiceImpl
public class Biz3OtherAppServiceImpl implements AppService {
    // 获取基座的bean
    private AppService baseAppService = SpringServiceFinder.getBaseService(AppService.class);

    @Override
    public String getAppName() {
        return "biz3OtherAppServiceImpl in the base: " + baseAppService.getAppName();
    }
}

public class Biz3AppServiceImpl implements AppService {
  // 获取基座的bean
  private AppService baseAppService = SpringServiceFinder.getBaseService(AppService.class);

  public String getAppName() {
    return "biz3AppServiceImpl in the base: " + baseAppService.getAppName();
  }
}

其中,模块获取基座的 bean 方式为:SpringServiceFinder.getBaseService(XXX.class),详细可见:模块和基座通信模块调用基座的方式二:编程API SpringServiceFinder

  1. biz3 将这两个类的实例注册到容器中:
public static void main(String[] args) {
        // 初始化模块的实例容器
        MainApplication.init();

        // 注册实例到模块容器中
        MainApplication.register("biz3AppServiceImpl", new Biz3AppServiceImpl());
        MainApplication.register("biz3OtherAppServiceImpl", new Biz3OtherAppServiceImpl());

        }
  1. 基座中获取 biz3 中的实例:
@RestController
public class SampleController {

    // 通过注解获取 biz3 中的指定实例
    @AutowiredFromBiz(bizName = "biz3", bizVersion = "0.0.1-SNAPSHOT", name = "biz3AppServiceImpl")
    private AppService biz3AppServiceImpl;

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String hello() {
        System.out.println(biz3AppServiceImpl.getAppName());

        // 通过 api 获取 biz3 中的指定实例
        AppService biz3OtherAppServiceImpl = SpringServiceFinder.getModuleService("biz3", "0.0.1-SNAPSHOT",
                "biz3OtherAppServiceImpl", AppService.class);
        System.out.println(biz3OtherAppServiceImpl.getAppName());

        // 通过 api 获取 biz3 中 AppService.class 的所有实例
        Map<String, AppService> appServiceMap = SpringServiceFinder.listModuleServices("biz3",
                "0.0.1-SNAPSHOT", AppService.class);
        for (AppService appService : appServiceMap.values()) {
            System.out.println(appService.getAppName());
        }
        return "hello to ark master biz";
    }
}

其中,SpringBoot / SOFABoot 基座可以通过 @AutowiredFromBiz 注解或 SpringServiceFinder.getModuleService() 编程API 获取模块中的实例,详细可见:模块和基座通信基座调用模块

步骤 3:自动化瘦身模块

一般来说,代码片段式的模块依赖比较简单,您可以自行将模块中与基座一致的依赖的 scope 设置成 provided,或使用 ark 打包插件的自动化瘦身能力,自动瘦身模块里的 maven 依赖。这一步是必选的,否则构建出的模块 jar 包会非常大,而且启动会报错。

步骤 4:构建成模块 jar 包

执行 mvn clean package -DskipTest, 可以在 target 目录下找到打包生成的 ark biz jar 包。

实验:验证模块能合并部署

  1. 启动上一步(验证能独立启动步骤)的基座
  2. 发起模块部署

可以参考样例中 biz3 的模块部署:https://github.com/koupleless/samples/blob/main/springboot-samples/service/README-zh_CN.md

4.3 - 4.4 模块研发

Koupleless 模块研发

4.3.1 - 4.3.1 编码规范

Koupleless 编码规范

基础规范

  1. Koupleless 模块中官方验证并兼容的中间件客户端列表详见此处。基座中可以使用任意中间件客户端。

  2. 模块里要独立使用 System.setProperties() 与 System.getProperties(),请在基座 main 方法里增加 MultiBizProperties.initSystem(),详细可参考 samples
  3. 如果使用了模块热卸载能力,您可以使用如下 API 装饰模块代码中声明的 ExecutorService(典型如各种线程池)、Timer、Thread 对象,在模块卸载时, Koupleless Arklet 客户端会尝试自动清理被装饰器装饰过的 ExecutorService、Timer、Thread:
    • 在模块代码中,装饰需要自动清理的 ExecutorService,底层会调用 ExecutorService 对象的 shutdownNow 和 awaitTermination 接口,会尽可能优雅释放线程(不保证 100% 释放,比如线程一直在等待),具体用法:
      ShutdownExecutorServicesOnUninstallEventHandler.manageExecutorService(myExecutorService);
      
      其中,myExecutorService 需要是 ExecutorService 的子类型。 您也可以在模块 SpringBoot 或 SOFABoot properties 文件中配置 com.alipay.koupleless.executor.cleanup.timeout.seconds 指定线程池 awaitTermination 的优雅等待时间。

    • 在模块代码中,装饰需要自动清理的 Timer,底层会调用 Timer 对象的 cancel,具体用法:
      CancelTimersOnUninstallEventHandler.manageTimer(myTimer);
      
    • 在模块代码中,装饰需要自动清理的 Thread,底层会强行调用 Thread 对象的 stop,具体用法:
      ForceStopThreadsOnUninstallEventHandler.manageThread(myThread);
      
      注意:JDK 并不推荐强行 stop 线程,会导致线程非预期的强行释放锁,可能引发非预期问题。除非您确定线程被暴力关闭不会引发相关问题,否则慎用。

  4. 如果使用了模块热卸载能力,并且还有其他资源、对象需要清理,您可以监听 Spring 的 ContextClosedEvent 事件,在事件处理函数中清理必要的资源和对象, 也可以在 Spring XML 定义 Bean 的地方指定它们的 destroy-method,在模块卸载时,Spring 会自动执行 destroy-method

  5. 基座启动时会部署所有模块,所以基座编码时,一定要向所有模块兼容,否则基座会发布失败。如果遇到无法绕过的不兼容变更(一般是在模块拆分过程中会有比较多的基座与模块不兼容变更), 请参见基座与模块不兼容发布

知识点

模块瘦身 (重要)
模块与模块、模块与基座通信 (重要)
模块测试 (重要)
模块复用基座拦截器
模块复用基座数据源
基座与模块间类委托加载原理介绍 模块多application.properties配置



4.3.2 - 4.3.2 模块瘦身

Koupleless 模块瘦身

为什么要瘦身

Koupleless 底层借助 SOFAArk 框架,实现了模块之间、模块和基座之间的类隔离。模块启动时会初始化各种对象,会优先使用模块的类加载器去加载构建产物 FatJar 中的 class、resource 和 Jar 包,找不到的类会委托基座的类加载器去查找。

基于这套类委托的加载机制,让基座和模块共用的 class、resource 和 Jar 包通通下沉到基座中,可以让模块构建产物非常小,从而模块消耗的内存非常少,启动也能非常快

其次,模块启动后 Spring 上下文中会创建很多对象,如果启用了模块热卸载,可能无法完全回收,安装次数过多会造成 Old 区、Metaspace 区开销大,触发频繁 FullGC,所以需要控制单模块包大小 < 5MB。这样不替换或重启基座也能热部署热卸载数百次。

所谓模块瘦身,就是让基座已经有的 Jar 依赖不参与模块打包构建,从而实现上述两个好处:

  • 提高模块安装的速度,减少模块包大小,减少启动依赖,控制模块安装耗时 < 30秒,甚至 < 5秒
  • 在热部署热卸载场景下,不替换或重启基座也能热部署热卸载数百次

瘦身原则

构建 ark-biz jar 包的原则是,在保证模块功能的前提下,将框架、中间件等通用的包尽量放置到基座中,模块中复用基座的包,这样打出的 ark-biz jar 会更加轻量。

在不同场景下,复杂应用可以选择不同的方式瘦身。

场景及相应的瘦身方式

场景一:基座和模块协作紧密,如中台模式 / 共库模式

在基座和模块协作紧密的情况下,模块应该在开发时就感知基座的部分facade类和基座正使用的依赖版本,并按需引入需要的依赖。 模块打包时,仅打包两种依赖:基座没有的依赖,模块和基座版本不一致的依赖。

因此,需要让基座:

  1. 统一管控模块依赖版本,让模块开发时就知道基座有哪些依赖,风险前置,而且模块开发者按需引入部分依赖,无需指定版本。

需要让模块:

  1. 打包时,仅打包基座没有的依赖、和基座版本不一致的依赖,降低模块瘦身成本

步骤一 打包“基座-dependencies-starter”

目标

该步骤将打出 “基座依赖-starter”,用于统一管控模块依赖版本。

基座bootstrap pom增加配置

注意:以下配置中的 dependencyArtifactId 需要修改,一般为${baseAppName}-dependencies-starter

<build>
<plugins>
    <plugin>
        <groupId>com.alipay.sofa.koupleless</groupId>
        <artifactId>koupleless-base-build-plugin</artifactId>
        <!--        koupleless.runtime.version >= 1.3.0 -->
        <version>${koupleless.runtime.version}</version>
        <configuration>
            <!--生成 starter 的 artifactId(groupId和基座一致),这里需要修改!!-->
            <dependencyArtifactId>${baseAppName}-dependencies-starter</dependencyArtifactId>
            <!--生成jar的版本号-->
            <dependencyVersion>0.0.1-SNAPSHOT</dependencyVersion>
            <!-- 调试用,改成 true 即可看到打包中间产物 -->
            <cleanAfterPackageDependencies>false</cleanAfterPackageDependencies>
        </configuration>
    </plugin>
  </plugins>
</build>

本地测试

  1. 打包基座 dependency-starter jar:在基座根目录执行命令:
mvn com.alipay.sofa.koupleless:koupleless-base-build-plugin::packageDependency -f ${基座 bootstrap pom 对于基座根目录的相对路径} 

构建出来的 pom 在 outputs 目录下,也会自动安装至本地的 maven 仓库。

注意,该步骤不会将 “基座依赖-starter” 上传至 maven 仓库。欢迎后续讨论补充 “上传至 maven 仓库” 的方案。

步骤二 模块修改打包插件和 parent

目标

  1. 模块开发时,将步骤一中的 “基座-dependencies-starter” 作为模块项目的 parent,统一管理依赖版本;
  2. 修改模块打包插件,模块打包时只将“基座没有的依赖”、“与基座版本不一致的依赖”打包进模块,而不用手动配置“provided”,自动实现模块瘦身

模块根目录的 pom 中配置 parent:

<parent>
   <groupId>com.alipay</groupId>
   <artifactId>${baseAppName}-dependencies-starter</artifactId>
   <version>0.0.1-SNAPSHOT</version>
</parent>

模块打包的 pom 中配置 plugin:

<build>
   <plugins>
       <plugin>
           <groupId>com.alipay.sofa</groupId>
           <artifactId>sofa-ark-maven-plugin</artifactId>
           <!--           since ${sofa.ark.version} >= 2.2.13    -->
           <version>${sofa.ark.version}</version>
           <executions>
               <execution>
                   <id>default-cli</id>
                   <goals>
                       <goal>repackage</goal>
                   </goals>
               </execution>
           </executions>
           <configuration>
               <!-- 配置 “基座-dependencies-starter” 的标识,规范为:'${groupId}:${artifactId}':'version' -->
               <baseDependencyParentIdentity>com.alipay:${baseAppName}-dependencies-starter:0.0.1-SNAPSHOT</baseDependencyParentIdentity>
           </configuration>
       </plugin>
   </plugins>
</build>

步骤三 配置模块依赖白名单

对于部分依赖,即使模块和基座使用的依赖版本一致,但模块打包时也需要保留该依赖,即需要配置模块瘦身依赖白名单。这部分功能我们将在 7 月底推出。

步骤四 打包构建

打包构建出模块 ark-biz jar 包即可,您可以明显看出瘦身后的 ark-biz jar 包大小差异。

场景二:基座和模块协作松散,如多应用合并部署节省资源

在基座和模块协作松散的情况下,模块不应该在开发时感知基座正使用的依赖版本,因此模块更需要注重模块瘦身的低成本接入,可以配置模块打包需要排除的依赖。

方式一:SOFAArk 配置文件排包

步骤一

SOFAArk 模块瘦身会读取两处配置文件:

  • “模块项目根目录/conf/ark/bootstrap.properties”,比如:my-module/conf/ark/bootstrap.properties
  • “模块项目根目录/conf/ark/bootstrap.yml”,比如:my-module/conf/ark/bootstrap.yml

配置方式

bootstrap.properties (推荐)

在「模块项目根目录/conf/ark/bootstrap.properties」中按照如下格式配置需要下沉到基座的框架和中间件常用包,比如:

# excludes config ${groupId}:{artifactId}:{version}, split by ','
excludes=org.apache.commons:commons-lang3,commons-beanutils:commons-beanutils
# excludeGroupIds config ${groupId}, split by ','
excludeGroupIds=org.springframework
# excludeArtifactIds config ${artifactId}, split by ','
excludeArtifactIds=sofa-ark-spi
bootstrap.yml (推荐)

在「模块项目根目录/conf/ark/bootstrap.yml」中按照如下格式配置需要下沉到基座的框架和中间件常用包,比如:

# excludes 中配置 ${groupId}:{artifactId}:{version}, 不同依赖以 - 隔开
# excludeGroupIds 中配置 ${groupId}, 不同依赖以 - 隔开
# excludeArtifactIds 中配置 ${artifactId}, 不同依赖以 - 隔开
excludes:
  - org.apache.commons:commons-lang3
  - commons-beanutils:commons-beanutils
excludeGroupIds:
  - org.springframework
excludeArtifactIds:
  - sofa-ark-spi

步骤二

升级模块打包插件 sofa-ark-maven-plugin 版本 >= 2.2.12

    <!-- 插件1:打包插件为 sofa-ark biz 打包插件,打包成 ark biz jar -->
    <plugin>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>sofa-ark-maven-plugin</artifactId>
        <version>${sofa.ark.version}</version>
        <executions>
            <execution>
                <id>default-cli</id>
                <goals>
                    <goal>repackage</goal>
                </goals>
            </execution>
        </executions>
        <configuration>
            <skipArkExecutable>true</skipArkExecutable>
            <outputDirectory>./target</outputDirectory>
            <bizName>biz1</bizName>
            <webContextPath>biz1</webContextPath>
            <declaredMode>true</declaredMode>
        </configuration>
    </plugin>

步骤三

打包构建出模块 ark-biz jar 包即可,您可以明显看出瘦身后的 ark-biz jar 包大小差异。

您可点击此处查看完整模块瘦身样例工程。

4.3.3 - 4.3.3 模块启动

模块启动

模块启动参数

模块有两种部署方式:静态合并部署和热部署。

静态合并部署模块不支持配置启动参数。模块大部分的启动参数可以放在模块配置(application.properties)中,如配置 profile 时:将启动参数中的 –spring.profiles.active=dev,配置为 application.properties 中的 spring.profiles.active=true。

热部署模块支持配置启动参数。如:使用 arklet 通过 web 请求安装模块时,可以配置启动参数和环境变量:

curl --location --request POST 'localhost:1238/installBiz' \
--header 'Content-Type: application/json' \
--data '{
    "bizName": "${Module Name}",
    "bizVersion": "${Module Version}",
    "bizUrl": "file:///path/to/ark/biz/jar/target/xx-xxxx-ark-biz.jar",
    "args": ["--spring.profiles.active=dev"],
    "env": {
        "XXX": "YYY"
    }
}'

模块启动加速

模块启动加速的设计思路

模块启动加速的总体思路是:

  1. 基座提前启动好服务,这个只需要基座提前引入依赖即可
  2. 模块通过各种方式复用基座的服务,可以通过如下的方式复用基座服务包括,具体使用哪种方式需要根据实际情况分析,有疑问可以社区群里交流:
    1. 通过类 static 变量的共享达到复用
    2. 通过基座封装一些服务的接口 api,模块直接调用这些 api 来复用基座的服务。
    3. 通过注解的方式获取基座对象的代理,Koupleless 提供的 @AutowiredFromBase 、@AutowiredFromBiz、SpringServiceFinder 工具类 ,dubbo 或者 SOFARpc 的一些支持 jvm service 调用的注解。
    4. 通过跨模块查找对象的方式,直接获取基座对象,如 Koupleless 提供的 SpringBeanFinder 工具类

这里隐含了一个问题,那就是模块为了能顺利调用基座服务,需要使用一些模型类,所以模块一般都需要将该服务对应的依赖引入进来,这导致模块启动的时候会扫描到这些服务的配置,从而再次初始化这些服务,这会导致模块启动一些不需要的服务,并且启动变慢,内存消耗增加。所以要让模块启动加速实际上要完成三件事情:

  1. 基座提前启动好服务
  2. 模块禁止启动这些服务,这是本文要详细介绍的
  3. 模块复用基座服务

模块如何禁止启动部分服务

Koupleless 1.1.0 版本开始,提供了如下的配置能力:

koupleless.module.autoconfigure.exclude # 模块启动时不需要启动的服务 AutoConfiguration
koupleless.module.autoconfigure.include # 模块启动时需要启动的服务 AutoConfiguration,如果某个服务同时配置了 include 和 exclude,则会启动该服务

该配置可以在基座里配置,也可以在模块里配置。如果在基座里配置,则所有模块都会生效,如果在模块里配置,则只有该模块生效,并且模块里的配置会覆盖基座的配置。

benchmark

详细 benchmark 还待补充

4.3.4 - 4.3.4 模块与模块、模块与基座通信

Koupleless 模块与模块、模块与基座通信

基座与模块之间、模块与模块之间存在 spring 上下文隔离,互相的 bean 不会冲突、不可见。然而很多应用场景比如中台模式、独立模块模式等存在基座调用模块、模块调用基座、模块与模块互相调用的场景。 当前支持3种方式调用,@AutowiredFromBiz, @AutowiredFromBase, SpringServiceFinder 方法调用,注意三个方式使用的情况不同。

Spring 环境

模块里引入依赖

<dependency>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-app-starter</artifactId>
    <version>${koupleless.runtime.version}</version>
    <scope>provided</scope>
</dependency>

基座调用模块

只能使用 SpringServiceFinder

@RestController
public class SampleController {

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String hello() {

        Provider studentProvider = SpringServiceFinder.getModuleService("biz", "0.0.1-SNAPSHOT",
                "studentProvider", Provider.class);
        Result result = studentProvider.provide(new Param());

        Provider teacherProvider = SpringServiceFinder.getModuleService("biz", "0.0.1-SNAPSHOT",
                "teacherProvider", Provider.class);
        Result result1 = teacherProvider.provide(new Param());
        
        Map<String, Provider> providerMap = SpringServiceFinder.listModuleServices("biz", "0.0.1-SNAPSHOT",
                Provider.class);
        for (String beanName : providerMap.keySet()) {
            Result result2 = providerMap.get(beanName).provide(new Param());
        }

        return "hello to ark master biz";
    }
}

模块调用基座

方式一:注解 @AutowiredFromBase

@RestController
public class SampleController {

    @AutowiredFromBase(name = "sampleServiceImplNew")
    private SampleService sampleServiceImplNew;

    @AutowiredFromBase(name = "sampleServiceImpl")
    private SampleService sampleServiceImpl;

    @AutowiredFromBase
    private List<SampleService> sampleServiceList;

    @AutowiredFromBase
    private Map<String, SampleService> sampleServiceMap;

    @AutowiredFromBase
    private AppService appService;

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String hello() {

        sampleServiceImplNew.service();

        sampleServiceImpl.service();

        for (SampleService sampleService : sampleServiceList) {
            sampleService.service();
        }

        for (String beanName : sampleServiceMap.keySet()) {
            sampleServiceMap.get(beanName).service();
        }

        appService.getAppName();

        return "hello to ark2 dynamic deploy";
    }
}

方式二:编程API SpringServiceFinder

@RestController
public class SampleController {

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String hello() {

        SampleService sampleServiceImplFromFinder = SpringServiceFinder.getBaseService("sampleServiceImpl", SampleService.class);
        String result = sampleServiceImplFromFinder.service();
        System.out.println(result);

        Map<String, SampleService> sampleServiceMapFromFinder = SpringServiceFinder.listBaseServices(SampleService.class);
        for (String beanName : sampleServiceMapFromFinder.keySet()) {
            String result1 = sampleServiceMapFromFinder.get(beanName).service();
            System.out.println(result1);
        }

        return "hello to ark2 dynamic deploy";
    }
}

模块调用模块

参考模块调用基座,注解使用 @AutowiredFromBiz 和 编程API支持 SpringServiceFinder。

方式一:注解 @AutowiredFromBiz

@RestController
public class SampleController {

    @AutowiredFromBiz(bizName = "biz", bizVersion = "0.0.1-SNAPSHOT", name = "studentProvider")
    private Provider studentProvider;

    @AutowiredFromBiz(bizName = "biz", name = "teacherProvider")
    private Provider teacherProvider;

    @AutowiredFromBiz(bizName = "biz", bizVersion = "0.0.1-SNAPSHOT")
    private List<Provider> providers;

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String hello() {

        Result provide = studentProvider.provide(new Param());

        Result provide1 = teacherProvider.provide(new Param());

        for (Provider provider : providers) {
            Result provide2 = provider.provide(new Param());
        }

        return "hello to ark2 dynamic deploy";
    }
}

方式二:编程API SpringServiceFinder

@RestController
public class SampleController {

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String hello() {

        Provider teacherProvider1 = SpringServiceFinder.getModuleService("biz", "0.0.1-SNAPSHOT", "teacherProvider", Provider.class);
        Result result1 = teacherProvider1.provide(new Param());

        Map<String, Provider> providerMap = SpringServiceFinder.listModuleServices("biz", "0.0.1-SNAPSHOT", Provider.class);
        for (String beanName : providerMap.keySet()) {
            Result result2 = providerMap.get(beanName).provide(new Param());
        }

        return "hello to ark2 dynamic deploy";
    }
}

完整样例

SOFABoot 环境

请参考该文档



4.3.5 - 4.3.5 模块本地开发与调试

Koupleless 模块本地开发与调试

Arkctl 工具安装

Arkctl 模块安装主要提供自动打包和部署能力,包括调用 mvn 命令自动构建模块为 jar 包,调用 arklet 提供的 api 接口进行完成部署。ArkCtl 安装方式可以参照文档:arkctl 安装本地环境开发验证小节。

安装方法一: 使用 golang 工具链

  1. golang 官网 下载对应的 golang 版本,版本需要在 1.21 以上。
  2. 执行 go install github.com/koupleless/arkctl@v0.2.1 命令,安装 arkctl 工具。

安装方式二: 下载二进制文件

  1. 根据实际运行操作系统,下载 arkctl
  2. 将对应的二进制解压并放到合适的系统变量 PATH 所在的目录里。
  3. 在基座和模块已经改造完成后,启动好基座后,可以使用 arkctl 快速完成构建与部署,将模块部署到基座中。

Linux/Mac 如何找到 PATH 的值?

终端执行

echo $PATH
# 选择一个目录,将 arkctl 放到该目录下

Windows 下如何找到 PATH 的值?

按 Windows + R 键,输入 cmd,然后按 Enter 打开命令提示符。在命令提示符窗口中,输入以下命令并按 Enter:

echo %PATH%

注意,在 Windows 环境下,如果开启 Windows Defender,浏览器下载二进制时可能会误报,提示如下:


报错原因可参考[go 官方文档](https://go.dev/doc/faq#virus)。此报错可以忽略,放心下载。

由于 Arkctl 部署其实是调用 API 的方式来完成的,如果不想使用命令行工具,也可以直接使用 Arklet API 接口 完成部署操作。当然我们也提供了 telnet 的方式来部署模块,详细可查看这里

本地快速部署

你可以使用 arkctl 工具快速地进行模块的构建和部署,提高本地调试和研发效率。

场景 1:模块 jar 包构建 + 部署到本地运行的基座中。

准备:

  1. 在本地启动一个基座。
  2. 打开一个模块项目仓库。

执行命令:

# 需要在仓库的根目录下执行。
# 比如,如果是 maven 项目,需要在根 pom.xml 所在的目录下执行。
arkctl deploy

命令执行完成后即部署成功,用户可以进行相关的模块功能调试验证。

场景 2:部署一个本地构建好的 jar 包到本地运行的基座中。

准备:

  1. 在本地启动一个基座。
  2. 准备一个构建好的 jar 包。

执行命令:

arkctl deploy /path/to/your/pre/built/bundle-biz.jar

命令执行完成后即部署成功,用户可以进行相关的模块功能调试验证。

场景 3: 部署一个本地还未构建的 jar 包到本地运行的基座中。

准备:

  1. 在本地启动一个基座

执行命令:

arkctl deploy ./path/to/your/biz/

注意该命令适用于模块可以独立构建的(可以在biz目录里成功执行 mvn package 等命令),则该命令会自动构建该模块,并部署到基座中。

场景 4: 在多模块的 Maven 项目中,在 Root 构建并部署子模块的 jar 包。

准备:

  1. 在本地启动一个基座。
  2. 打开一个多模块 Maven 项目仓库。

执行命令:

# 需要在仓库的根目录下执行。
# 比如,如果是 maven 项目,需要在根 pom.xml 所在的目录下执行。
arkctl deploy --sub ./path/to/your/sub/module

命令执行完成后即部署成功,用户可以进行相关的模块功能调试验证。

场景 5: 模块 jar 包构建 + 部署到远程运行的 k8s 基座中。

准备:

  1. 在远程已经运行起来的基座 pod。
  2. 打开一个模块项目仓库。
  3. 本地需要有具备 exec 权限的 k8s 证书以及 kubectl 命令行工具。

执行命令:

# 需要在仓库的根目录下执行。
# 比如,如果是 maven 项目,需要在根 pom.xml 所在的目录下执行。
arkctl deploy --pod {namespace}/{podName}

命令执行完成后即部署成功,用户可以进行相关的模块功能调试验证。

场景 6: 如何更快的使用该命令

可以在 IDEA 里新建一个 Shell Script,配置好运行的目录,然后输入 arkctl 相应的命令,如下图即可。

模块本地调试

模块与基座出于同一个 IDEA 工程中

因为 IDEA 工程里能看到模块代码,模块调试与普通调试没有区别。直接在模块代码里打断点,基座通过 debug 方式启动即可。

模块与基座在不同 IDEA 工程中

  1. 基座启动参数里增加 debug 配置 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000,然后启动基座
  2. 模块添加 remote jvm debug, 设置 host 为 localhost -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000
  3. 模块里打断点
  4. 这时候安装模块后就可以调试了

查看部署状态

场景 1: 查询当前基座中已经部署的模块。

准备:

  1. 在本地启动一个基座。

执行命令:

arkctl status

场景 2: 查询远程 k8s 环境基座中已经部署的模块。

准备:

  1. 在远程 k8s 环境启动一个基座。
  2. 确保本地有 kube 证书以及有关权限。

执行命令:

arkctl status --pod {namespace}/{name}

通过 arthas 查看运行时模块状态与信息

获取所有 Biz 信息

vmtool -x 1 --action getInstances --className com.alipay.sofa.ark.container.model.BizModel --limit 100

如:
image.png

获取特定 Biz 信息

# 请替换 ${bizName}
vmtool -x 1 --action getInstances --className com.alipay.sofa.ark.container.model.BizModel --limit 100 | grep ${bizName}  -A 4

如:
image.png

获取特定 BizClassLoader 对应的 Biz 信息

# 请替换 ${BizClassLoaderHashCode}
vmtool -x 1 --action getInstances --className com.alipay.sofa.ark.container.model.BizModel --limit 100 | grep ${BizClassLoaderHashCode}  -B 1 -A 3

如:
image.png

4.3.6 - 4.3.6 复用基座数据源

Koupleless 模块复用基座数据源

使用场景

建议数据源下沉到基座,模块中尽可能复用基座数据源,避免模块在安装卸载过程中反复创建、销毁数据源连接,导致模块发布运维会变慢,同时也会额外消耗内存。 如果对模块的启动时间和内存占用不太关心的,可以不用复用基座数据源,直接在模块中创建数据源,那么模块按照普通应用方式定义数据源即可无需参考本文。

SpringBoot 解法

在模块的代码中写个 MybatisConfig 类即可,这样事务模板都是复用基座的,只有 Mybatis 的 SqlSessionFactoryBean 需要新创建。
参考 demo:/koupleless/samples/springboot-samples/db/mybatis/biz1

通过SpringBeanFinder.getBaseBean获取到基座的 Bean 对象,然后注册成模块的 Bean:


@Configuration
@MapperScan(basePackages = "com.alipay.sofa.biz1.mapper", sqlSessionFactoryRef = "mysqlSqlFactory")
@EnableTransactionManagement
public class MybatisConfig {

    //tips:不要初始化一个基座的DataSource,当模块被卸载的是,基座数据源会被销毁,transactionManager,transactionTemplate,mysqlSqlFactory被销毁没有问题

    @Bean(name = "transactionManager")
    public PlatformTransactionManager platformTransactionManager() {
        return (PlatformTransactionManager) getBaseBean("transactionManager");
    }

    @Bean(name = "transactionTemplate")
    public TransactionTemplate transactionTemplate() {
        return (TransactionTemplate) getBaseBean("transactionTemplate");
    }

    @Bean(name = "mysqlSqlFactory")
    public SqlSessionFactoryBean mysqlSqlFactory() throws IOException {
        //数据源不能申明成模块spring上下文中的bean,因为模块卸载时会触发close方法

        DataSource dataSource = (DataSource) getBaseBean("dataSource");
        SqlSessionFactoryBean mysqlSqlFactory = new SqlSessionFactoryBean();
        mysqlSqlFactory.setDataSource(dataSource);
        mysqlSqlFactory.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:mappers/*.xml"));
        return mysqlSqlFactory;
    }
}

SOFABoot 解法

如果 SOFABoot 基座没有开启多 bundle(Package 里没有 MANIFEST.MF 文件),则解法和上文 SpringBoot 完全一致。
如果有 MANIFEST.MF 文件,需要调用BaseAppUtils.getBeanOfBundle获取基座的 Bean,其中BASE_DAL_BUNDLE_NAME 为 MANIFEST.MF 里面的Module-Name


@Configuration
@MapperScan(basePackages = "com.alipay.koupleless.dal.dao", sqlSessionFactoryRef = "mysqlSqlFactory")
@EnableTransactionManagement
public class MybatisConfig {

    // 注意:不要初始化一个基座的 DataSource,会导致模块被热卸载的时候,基座的数据源被销毁,不符合预期。
    // 但是 transactionManager,transactionTemplate,mysqlSqlFactory 这些资源被销毁没有问题
    
    private static final String BASE_DAL_BUNDLE_NAME = "com.alipay.koupleless.dal"

    @Bean(name = "transactionManager")
    public PlatformTransactionManager platformTransactionManager() {
        return (PlatformTransactionManager) BaseAppUtils.getBeanOfBundle("transactionManager",BASE_DAL_BUNDLE_NAME);
    }

    @Bean(name = "transactionTemplate")
    public TransactionTemplate transactionTemplate() {
        return (TransactionTemplate) BaseAppUtils.getBeanOfBundle("transactionTemplate",BASE_DAL_BUNDLE_NAME);
    }

    @Bean(name = "mysqlSqlFactory")
    public SqlSessionFactoryBean mysqlSqlFactory() throws IOException {
        //数据源不能申明成模块spring上下文中的bean,因为模块卸载时会触发close方法
        ZdalDataSource dataSource = (ZdalDataSource) BaseAppUtils.getBeanOfBundle("dataSource",BASE_DAL_BUNDLE_NAME);
        SqlSessionFactoryBean mysqlSqlFactory = new SqlSessionFactoryBean();
        mysqlSqlFactory.setDataSource(dataSource);
        mysqlSqlFactory.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:mapper/*.xml"));
        return mysqlSqlFactory;
    }
}


4.3.7 - 4.3.7 复用基座拦截器

Koupleless 模块复用基座拦截器

诉求

基座中会定义很多 Aspect 切面(Spring 拦截器),你可能希望复用到模块中,但是模块和基座的 Spring 上下文是隔离的,就导致 Aspect 切面不会在模块中生效。

解法

为原有的切面类创建一个代理对象,让模块能调用到这个代理对象,然后模块通过 AutoConfiguration 注解初始化出这个代理对象。完整步骤和示例代码如下:

步骤 1:

基座代码定义一个接口,定义切面的执行方法。这个接口需要对模块可见(在模块里引用相关依赖):

public interface AnnotionService {
    Object doAround(ProceedingJoinPoint joinPoint) throws Throwable;
}

步骤 2:

在基座中编写切面的具体实现,这个实现类需要加上 @SofaService 注解(SOFABoot)或者 @SpringService 注解(SpringBoot,建设中):

@Service
@SofaService(uniqueId = "facadeAroundHandler")
public class FacadeAroundHandler implements AnnotionService {

    private final static Logger LOG = LoggerConst.MY_LOGGER;

    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("开始执行")
        joinPoint.proceed();
        log.info("执行完成")
    }
}

步骤 3:

在模块里使用 @Aspect 注解实现一个 Aspect,SOFABoot 通过 @SofaReference 注入基座上的 FacadeAroundHandler。如果是 SpringBoot,则使用 @AutowiredFromBase 注入基座上的 FacadeAroundHandler

注意:这里不要声明成一个 Bean,不要加 @Component 或者 @Service 注解,主需要 @Aspect 注解。

//注意,这里不必申明成一个bean,不要加@Component或者@Service
@Aspect
public class FacadeAroundAspect {

    // 如果是 SOFABoot,则使用 @SofaReference,如果是 SpringBoot,则使用 @AutowiredFromBase
    @SofaReference(uniqueId = "facadeAroundHandler")
    //@AutowiredFromBase
    private AnnotionService facadeAroundHandler;

    @Pointcut("@annotation(com.alipay.linglongmng.presentation.mvc.interceptor.FacadeAround)")
    public void facadeAroundPointcut() {
    }

    @Around("facadeAroundPointcut()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        return facadeAroundHandler.doAround(joinPoint);
    }
}

步骤 4:

使用 @Configuration 注解写个 Configuration 配置类,把模块需要的 aspectj 对象都声明成 Spring Bean。

注意:这个 Configuration 类需要对模块可见,相关 Spring Jar 依赖需要以 provided 方式引进来。

@Configuration
public class MngAspectConfiguration {
    @Bean
    public FacadeAroundAspect facadeAroundAspect() {
        return new FacadeAroundAspect();
    }
    @Bean
    public EnvRouteAspect envRouteAspect() {
        return new EnvRouteAspect();
    }
    @Bean
    public FacadeAroundAspect facadeAroundAspect() {
        return new FacadeAroundAspect();
    }
    
}

步骤 5:

模块代码中显示的依赖步骤 4 创建的 Configuration 配置类 MngAspectConfiguration。

@SpringBootApplication
@ImportResource("classpath*:META-INF/spring/*.xml")
@ImportAutoConfiguration(value = {MngAspectConfiguration.class})
public class ModuleBootstrapApplication {
    public static void main(String[] args) {
        SpringApplicationBuilder builder = new SpringApplicationBuilder(ModuleBootstrapApplication.class)
        	.web(WebApplicationType.NONE);
        builder.build().run(args);
    }
}


4.3.8 - 4.3.8 线程池使用

Koupleless 线程池使用

背景

多个模块模块和基座共用一个线程池时,由于线程池执行任务使用的线程 Classloader 可能和创建该任务时使用的 Classloader 不一致,从而导致线程池执行任务时出现 ClassNotFound 异常。

因此,当多个模块或模块和基座共用一个线程池时,为了保持线程池执行任务时使用的 Classloader 和创建该任务时使用的 Classloader 一致,我们需要对线程池做一些修改。

⚠️注意:各模块使用各自的线程池,不会有此问题。

java常用的线程池使用方式有4种:

  1. 直接创建线程任务,提交到线程池中,如:Runnable, Callable, ForkJoinTask
  2. 自定义ThreadPoolExecutor,并提交到 ThreadPoolExecutor
  3. 通过 Executors 创建线程池,并提交到 ExecutorService, ScheduledExecutorService, ForkJoinPool
  4. SpringBoot 用户提交到 ThreadPoolTaskExecutor, SchedulerThreadPoolTaskExecutor

本文将介绍每一种方式在 Koupleless 上的使用方式。

使用方式

1. 直接创建线程任务,提交到线程池中

原来的方式:


threadPool.execute(new Runnable(){
    public void run() {
        //do something
    }
});


threadPool.execute(new Callable<String>(){
public String call() {
        //do something
        return "mock";
 }
});

如果保持 threadPool 不变,则需要将 Runnable 包装成 KouplelessRunnable,将 Callable 包装成 KouplelessCallable,如下:

// Runnable
// wrap方法
threadPool.execute(KouplelessRunnable.wrap(new Runnable(){
    public void run() {
        //do something
    }
});

// 或者直接new KouplelessRunnable
threadPool.execute(new KouplelessRunnable(){
    public void run() {
        //do something
    }
});

// Runnable
// wrap方法
threadPool.execute(KouplelessCallable.wrap(new Callable<String>(){
    public String call() {
        //do something
        return "mock";
    }
});

// 或者直接new KouplelessRunnable
threadPool.execute(new KouplelessCallable<String>(){
    public String call() {
        //do something
        return "mock";
    }
});

2. 自定义ThreadPoolExecutor

原来的方式:

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

threadPool.execute(new Runnable(){
    public void run() {
        //do something
    }
});


threadPool.execute(new Callable<String>(){
public String call() {
        //do something
        return "mock";
 }
});

如果要保持 Runnable 和 Callable 不变,则有两种改造方式:

  1. 将 threadPool 修改为 KouplelessThreadPoolExecutor
  2. 或者使用 kouplelessExecutorService。

首先,举例第一种改造方式:将 threadPool 修改为 KouplelessThreadPoolExecutor。如下:

// 将 ThreadPoolExecutor 修改为 KouplelessThreadPoolExecutor
ThreadPoolExecutor threadPool = new KouplelessThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

threadPool.execute(new Runnable(){
    public void run() {
        //do something
    }
});


threadPool.execute(new Callable<String>(){
public String call() {
        //do something
        return "mock";
 }
});

然后,举例第二种改造方式:使用 kouplelessExecutorService。如下:

// 使用 KouplelessExecutorService
ExecutorService executor        = new KouplelessExecutorService(new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()));

// 用 executor 执行任务
executor.execute(new Runnable(){
    public void run() {
        //do something
    }
});
executor.execute(new Callable<String>(){
public String call() {
        //do something
        return "mock";
 }
});

3. 使用二方包中的 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor

原来的方式:

ThreadPoolExecutorA executorService = new ThreadPoolExecutorA();

executorService.execute(new Runnable(){
    public void run() {
        //do something
    }
});


executorService.execute(new Callable<String>(){
public String call() {
        //do something
        return "mock";
 }
});

ScheduledThreadPoolExecutorA scheduledExecutorService = new ScheduledThreadPoolExecutorA();

scheduledExecutorService.execute(new Runnable(){
    public void run() {
        //do something
    }
});

scheduledExecutorService.execute(new Callable<String>(){
    public String call() {
        //do something
        return "mock";
    }
});

如果要保持 Runnable 和 Callable 不变,则需要使用 kouplelessExecutorService, kouplelessScheduledExecutorService,如下:

// 使用 KouplelessExecutorService
ExecutorService executor        = new KouplelessExecutorService(new ThreadPoolExecutorA());

// 用 executor 执行任务
executor.execute(new Runnable(){
    public void run() {
        //do something
    }
});
executor.execute(new Callable<String>(){
    public String call() {
        //do something
    return "mock";
    }
});


// 使用 KouplelessScheduledExecutorService
ScheduledExecutorService scheduledExecutor = new KouplelessScheduledExecutorService(new ScheduledThreadPoolExecutorA());

// 用 scheduledExecutor 执行任务
scheduledExecutor.execute(new Runnable(){
    public void run() {
        //do something
    }
});
scheduledExecutor.execute(new Callable<String>(){
    public String call() {
        //do something
        return "mock";
    }
});

4. 通过 Executors 创建线程池

原来的方式:

ExecutorService executorService = Executors.newFixedThreadPool(6);

executorService.execute(new Runnable(){
    public void run() {
        //do something
    }
});


executorService.execute(new Callable<String>(){
public String call() {
        //do something
        return "mock";
 }
});

ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();

scheduledExecutorService.execute(new Runnable(){
    public void run() {
        //do something
    }
});

scheduledExecutorService.execute(new Callable<String>(){
    public String call() {
        //do something
        return "mock";
    }
});

如果要保持 Runnable 和 Callable 不变,则需要使用 kouplelessExecutorService, kouplelessScheduledExecutorService,如下:

// 使用 KouplelessExecutorService
ExecutorService executor        = new KouplelessExecutorService(Executors.newFixedThreadPool(6));

// 用 executor 执行任务
executor.execute(new Runnable(){
    public void run() {
        //do something
    }
});
executor.execute(new Callable<String>(){
    public String call() {
        //do something
    return "mock";
    }
});

// 使用 KouplelessScheduledExecutorService
ScheduledExecutorService scheduledExecutor = new KouplelessScheduledExecutorService(Executors.newSingleThreadScheduledExecutor());

// 用 scheduledExecutor 执行任务
scheduledExecutor.execute(new Runnable(){
    public void run() {
        //do something
    }
});
scheduledExecutor.execute(new Callable<String>(){
    public String call() {
        //do something
        return "mock";
    }
});

5. SpringBoot 用户提交到 ThreadPoolTaskExecutor, SchedulerThreadPoolTaskExecutor

由于 koupeless 已经对 springboot(2.3.0-2.7.x) 的 ThreadPoolTaskExecutor 和 SchedulerThreadPoolTaskExecutor 做了适配,所以可以直接使用。

@Autowired
private ThreadPoolTaskExecutor threadPoolTaskExecutor;

@Autowired
private SchedulerThreadPoolTaskExecutor schedulerThreadPoolTaskExecutor;

threadPoolTaskExecutor.execute(new Runnable(){
    public void run() {
        //do something
    }
});

schedulerThreadPoolTaskExecutor.execute(new Runnable(){
    public void run() {
        //do something
    }
});

4.3.9 - 4.3.9 模块多配置

为什么要多配置

在不同场合下,一份模块代码会部署到不同的应用中,但需要使用不同的配置。

怎么使用

步骤一:在不同场合下,给一份模块代码打包时,配置不同的 bizName,如:biz1, biz2

<plugin>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>sofa-ark-maven-plugin</artifactId>
    <version>${sofa.ark.version}</version>
    <executions>
        <execution>
            <id>default-cli</id>
            <goals>
                <goal>repackage</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <!-- 不同场合配置不同的bizName,如:biz1, biz2 -->
        <bizName>biz1</bizName>
        <!-- ... 其它属性 -->
    </configuration>
</plugin>

步骤二:在模块的 resources 目录下,新增文件。其中 config, biz1 和 biz2 为文件夹:

  • config/biz1/application.properties

  • config/biz2/application.properties

步骤三:用不同的 bizName(biz1,biz2),打包出两个不同的 ark-biz 文件:

  • biz1-0.0.1-SNAPSHOT-ark-biz.jar

  • biz2-0.0.1-SNAPSHOT-ark-biz.jar

步骤四:在不同场合下,安装不同的 ark-biz 模块。模块启动时,将根据不同的 bizName 读取不同的配置文件:

  • config/biz1/application.properties

  • config/biz2/application.properties

原理

模块启动时,根据模块名称与 spring.profiles.active 字段,读取以下文件为属性源:

  • config/${bizName}/application-${profile}.properties
  • config/${bizName}/application.properties

如果未设置 spring.profiles.active,则读取以下文件为属性源:

  • config/${bizName}/application.properties

4.3.10 - 4.3.10 多模块集成测试

English | 简体中文

为什么我们需要集成测试框架?

如果没有集成测试框架,在验证 koupleless 模块逻辑时,开发者的验证步骤是繁琐的,需要做如下步骤:

  1. 启动一个基座进程。
  2. 构建模块 jar 包。
  3. 安装模块。
  4. 调用模块的 http 接口(或其他方法)验证逻辑。

如果逻辑不符合预期,开发者需要重复上述步骤, 这样的验证流程是非常低效的。 为了提高开发者的验证效率,我们决定提供 koupleless 集成测试框架,让开发者能够在一个进程内同时启动基座和模块。

集成测试框架

原理

集成测试框架通过增强基座的类加载器和模块的类加载行为,来模拟多模块部署的场景。 具体的源代码可以参照 koupleless-test-suite

如何使用

以 webflux-samples 为例子。webflux-samples 的项目结构如下:

我们新建一个 maven module:

首先该 module 需要添加集成测试框架依赖:


<dependency>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-test-suite</artifactId>
    <version>${koupleless.runtime.version}</version>
</dependency>

然后我们需要添加基座和模块的依赖:

<!-- 基座依赖 -->
<dependency>
    <groupId>com.alipay.sofa.web.webflux</groupId>
    <artifactId>demowebflux</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <classifier>lib</classifier>
</dependency>
        <!-- 模块依赖 -->
<dependency>
<groupId>com.alipay.sofa.web.webflux</groupId>
<artifactId>bizwebflux</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>

接着,我们需要编写集成测试用例:

    public static void setUp() {
    TestMultiSpringApplication multiApp = new TestMultiSpringApplication(
            MultiSpringTestConfig
                    .builder()
                    .baseConfig(
                            BaseSpringTestConfig
                                    .builder()
                                    // 传入基座的启动类。
                                    .mainClass(DemoWebfluxApplication.class)
                                    .build()
                    )
                    .bizConfigs(
                            Lists.newArrayList(
                                    BizSpringTestConfig
                                            .builder()
                                            .bizName("biz")
                                            // 传入模块的启动类。
                                            .mainClass(BizWebfluxApplication.class)
                                            .build()))
                    .build());
    multiApp.run();
}

最后,在 IDEA 里启动测试,我们会发现基座和模块的 Spring 容器都启动了。这样我们就可以在一个进程内验证多模块的逻辑。
如此,我们就完成了一个集成测试用例。

总结

通过上面的实验,我们验证了可以通过 koupleless 集成测试框架,来快速验证多模块的逻辑,提高开发者的验证效率。

4.3.11 - 4.3.11 静态合并部署

Koupleless 模块静态合并部署

介绍

SOFAArk 提供了静态合并部署能力,Base 包(基座应用) 在启动时,可以启动已经构建完成的 Biz 包(模块应用),默认获取模块的方式为:本地目录、本地文件URL、远程URL。

此外,SOFAArk 还提供了静态合并部署的扩展接口,开发者可以自定义获取 Biz 包(模块应用) 的方式。

使用方式

步骤一:模块应用打包成 Ark Biz

如果开发者希望自己应用的 Ark Biz 包能够被其他应用直接当成 Jar 包依赖,进而运行在同一个 SOFAArk 容器之上,那么就需要打包发布 Ark Biz 包,详见 Ark Biz 介绍。 Ark Biz 包使用 Maven 插件 sofa-ark-maven-plugin 打包生成。


<build>
    <plugin>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>sofa-ark-maven-plugin</artifactId>
        <version>${sofa.ark.version}</version>
        <executions>
            <execution>
                <id>default-cli</id>
                <goals>
                    <goal>repackage</goal>
                </goals>
            </execution>
        </executions>
        <configuration>
            <!--                默认100,数值越大越后面安装,koupleless runtime 版本大于等于 1.2.2             -->
            <priority>200</priority>
        </configuration>
    </plugin>
</build>

步骤二:基座获取需要合并部署的 Ark Biz

要求:

  • jdk8
    • sofa.ark.version >= 2.2.12
    • koupleless.runtime.version >= 1.2.3
  • jdk17/jdk21
    • sofa.ark.version >= 3.1.5
    • koupleless.runtime.version >= 2.1.4

方式一:使用官方默认获取方式,支持本地目录、本地文件URL、远程URL

1. 基座配置本地目录、本地文件URL、远程URL

开发者需要在基座的 ark 配置文件中(conf/ark/ark.propertiesconf/ark/ark.yml)指定需要合并部署的 Ark Biz 包,支持:

  • 本地目录
  • 本地文件URL(windows 系统为 file:\\, linux 系统为 file://)
  • 远程URL(支持 http://,https://

其中,本地文件URL、远程URL 配置在 integrateBizURLs 字段中,本地目录配置在 integrateLocalDirs 字段中。

配置方式如下:

integrateBizURLs=file://${xxx}/koupleless_samples/springboot-samples/service/biz1/biz1-bootstrap/target/biz1-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar,\
  file://${xxx}/koupleless_samples/springboot-samples/service/biz2/biz2-bootstrap/target/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar,\
  https://oss.xxxxx/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar
integrateLocalDirs=/home/${xxx}/sofa-ark/biz,\
  /home/${xxx}/sofa-ark/biz2

integrateBizURLs:
  - file://${xxx}/springboot-samples/service/biz2/biz2-bootstrap/target/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar
  - file://${xxx}/koupleless_samples/springboot-samples/service/biz2/biz2-bootstrap/target/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar
integrateLocalDirs:
  - /home/${xxx}/sofa-ark/biz
  - /home/${xxx}/sofa-ark/biz2
2. 基座配置打包插件目标 integrate-biz

基座 bootstrap 的 pom 中给 koupleless-base-build-plugin 添加 integrate-biz,如下:

<plugin>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-base-build-plugin</artifactId>
    <version>${koupleless.runtime.version}</version>
    <executions>
        <execution>
            <goals>
                <goal>add-patch</goal>
<!--                用于静态合并部署-->
                <goal>integrate-biz</goal>
            </goals>
        </execution>
    </executions>
</plugin>

执行打包后,如果自行解压打包的 jar 文件,可以在 classPath/SOFA-ARK/biz 中看到指定的模块 ark-biz 包。

方式二:使用自定义获取方式

1. Ark 扩展机制原理

Ark 扩展机制原理介绍

2. 实现 AddBizToStaticDeployHook 接口

基座/基座二方包中,实现 AddBizToStaticDeployHook 接口,以 AddBizInResourcesHook 为例,如下:

@Extension("add-biz-in-resources-to-deploy")
public class AddBizInResourcesHook implements AddBizToStaticDeployHook {
    @Override
    public List<BizArchive> getStaticBizToAdd() throws Exception {
        List<BizArchive> archives = new ArrayList<>();
        // ...
        archives.addAll(getBizArchiveFromResources());
        return archives;
    }

    protected List<BizArchive> getBizArchiveFromResources() throws Exception {
        // ... 读取资源中的Ark Biz包
        return archives;
    }
}
3. 配置 spi

在 resources 目录下添加 /META-INF/services/sofa-ark/ 目录,再在 /META-INF/services/sofa-ark/ 添加一个 名为 com.alipay.sofa.ark.spi.service.biz.AddBizToStaticDeployHook 的文件,文件里面内容为 hook 类的全限定名:

com.alipay.sofa.ark.support.common.AddBizInResourcesHook

重新打包基座。

步骤三:启动基座

JVM 添加参数,配置: -Dsofa.ark.embed.static.biz.enable=true

4.3.12 - 4.3.12 各版本兼容性关系表与适配验证的组件列表

Koupleless 各版本兼容性关系表与适配验证的组件列表

框架自身各版本兼容性关系

用户可根据实际 jdk 和 springboot 版本按需引入 Koupleless 版本

JDKSpringBootSOFA-ARKKoupleless
1.82.x2.x.x1.x.x
173.0.x, 3.1.x3.0.7(no update anymore)2.0.4(no update anymore)
17 & 213.2.x and above3.1.x2.1.x

koupleless 的 sdk 最新版本请查看 https://github.com/koupleless/runtime/releases


各组件兼容性报告

在 Koupleless 模块中,官方目前支持并兼容常见的中间件客户端。
注意,这里 “已经支持” 需要在基座 POM 中引入相关客户端依赖(强烈建议使用 SpringBoot Starter 方式引入相关依赖),同时在模块 POM 中也引入相关依赖并设置 * provided* 将依赖委托给基座加载。

中间件客户端版本号备注
JDK8.x
17.x
已经支持
SpringBoot>= 2.3.0 或 3.x已经支持
JDK17 + SpringBoot3.x 基座和模块完整使用样例可参见此处
SpringBoot Cloud>= 2.7.x已经支持
基座和模块完整使用样例可参见此处
SOFABoot>= 3.9.0 或 4.x已经支持
JMXN/A已经支持
需要给基座加 -Dspring.jmx.default-domain=${spring.application.name} 启动参数
log4j2任意已经支持。在基座和模块引入 log4j2,并额外引入依赖:
<dependency>
  <groupId>com.alipay.sofa.koupleless</groupId>
  <artifactId>koupleless-adapter-log4j2</artifactId>
  <version>${最新版 Koupleless 版本}</version>
  <scope>provided</scope> <!– 模块需要 provided –>
  </dependency>
基座和模块完整使用样例参见此处
slf4j-api1.x 且 >= 1.7已经支持
tomcat7.x、8.x、9.x、10.x
及以上均可
已经支持
基座和模块完整使用样例可参见此处
netty4.x已经支持
基座和模块完整使用样例可参见此处
sofarpc>= 5.8.6已经支持
dubbo3.x已经支持
基座和模块完整使用样例及注意事项可参见此处
grpc1.x 且 >= 1.42已经支持
基座和模块完整使用样例及注意事项可参见此处
protobuf-java3.x 且 >= 3.17已经支持
基座和模块完整使用样例及注意事项可参见此处
apollo1.x 且 >= 1.6.0已经支持
基座和模块完整使用样例及注意事项可参见此处
nacos2.1.x已经支持
基座和模块完整使用样例及注意事项可参见此处
kafka-client>= 2.8.0 或
>= 3.4.0
已经支持
基座和模块完整使用样例可参见此处
rocketmq4.x 且 >= 4.3.0已经支持
基座和模块完整使用样例可参见此处
jedis3.x已经支持
基座和模块完整使用样例可参见此处
xxl-job2.x 且 >= 2.1.0已经支持
需要在模块里声明为 compile 依赖独立使用
mybatis>= 2.2.2 或
>= 3.5.12
已经支持
基座和模块完整使用样例可参见此处
druid1.x已经支持
基座和模块完整使用样例可参见此处
mysql-connector-java8.x已经支持
基座和模块完整使用样例可参见此处
postgresql42.x 且 >= 42.3.8已经支持
mongodb4.6.1已经支持
基座和模块完整使用样例可参见此处
hibernate5.x 且 >= 5.6.15已经支持
j2cache任意已经支持
需要在模块里声明为 compile 依赖独立使用
opentracing0.x 且 >= 0.32.0已经支持
elasticsearch7.x 且 >= 7.6.2已经支持
jaspyt1.x 且 >= 1.9.3已经支持
OKHttp-已经支持
需要放在基座里,请使用模块自动瘦身能力
io.kubernetes:client10.x 且 >= 10.0.0已经支持
net.java.dev.jna5.x 且 >= 5.12.1已经支持
prometheus-待验证支持
skywalking-官方不支持一个进程多个 service_name,通过每个模块打印日志到独立目录才实现 tracing 的隔离,可参考 logging 的 samples

4.3.13 - 4.3.13 Koupleless 配置

Koupleless 各项配置

打包构建阶段

基座打包插件配置

插件参数配置

完整的 koupleless-base-build-plugin 插件配置模板如下:

<plugin>
  <groupId>com.alipay.sofa.koupleless</groupId>
  <artifactId>koupleless-base-build-plugin</artifactId>
  <version>${koupleless.runtime.version}</version>
  <executions>
    <execution>
      <goals>
        <goal>add-patch</goal>
        <!-- 用于静态合并部署-->
        <goal>integrate-biz</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
      <!--基座打包存放目录,默认为工程 build 目录-->
      <outputDirectory>./target</outputDirectory>
      
      <!--打包 starter 的 groupId,默认为工程的 groupId-->
      <dependencyGroupId>${groupId}</dependencyGroupId>
      
      <!--打包 starter 的 artifactId-->
      <dependencyArtifactId>${baseAppName}-dependencies-starter</dependencyArtifactId>
      
      <!--打包 starter 的版本号-->
      <dependencyVersion>0.0.1-SNAPSHOT</dependencyVersion>
      
      <!-- 调试用,改成 true 即可看到打包 starter 的中间产物 -->
      <cleanAfterPackageDependencies>false</cleanAfterPackageDependencies>
  </configuration>
</plugin>

静态合并部署的配置

开发者需要在基座的 ark 配置文件中(conf/ark/bootstrap.propertiesconf/ark/bootstrap.yml)指定需要合并部署的 Ark Biz 包,支持:

  • 本地目录
  • 本地文件URL(windows 系统为 file:\\, linux 系统为 file://)
  • 远程URL(支持 http://,https://

其中,本地文件URL、远程URL 配置在 integrateBizURLs 字段中,本地目录配置在 integrateLocalDirs 字段中。

配置方式如下:

integrateBizURLs=file://${xxx}/koupleless_samples/springboot-samples/service/biz1/biz1-bootstrap/target/biz1-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar,\
  file://${xxx}/koupleless_samples/springboot-samples/service/biz2/biz2-bootstrap/target/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar,\
  https://oss.xxxxx/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar
integrateLocalDirs=/home/${xxx}/sofa-ark/biz,\
  /home/${xxx}/sofa-ark/biz2

integrateBizURLs:
  - file://${xxx}/springboot-samples/service/biz2/biz2-bootstrap/target/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar
  - file://${xxx}/koupleless_samples/springboot-samples/service/biz2/biz2-bootstrap/target/biz2-bootstrap-0.0.1-SNAPSHOT-ark-biz.jar
integrateLocalDirs:
  - /home/${xxx}/sofa-ark/biz
  - /home/${xxx}/sofa-ark/biz2

模块打包插件配置

插件参数配置

完整的 sofa-ark-maven-plguin 插件配置模板如下:

<plugins>
    <plugin>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>sofa-ark-maven-plugin</artifactId>
        <version>${sofa.ark.version}</version>
        <executions>
            <execution>
                <id>default-cli</id>
                <goals>
                    <goal>repackage</goal>
                </goals>
                <configuration>
                    <!--ark 包和 ark biz 的打包存放目录,默认为工程 build 目录-->
                    <outputDirectory>./target</outputDirectory>

                    <!--设置应用的根目录,用于读取 ${base.dir}/conf/ark/bootstrap.application 配置文件,默认为 ${project.basedir}-->
                    <baseDir>./</baseDir>

                    <!--生成 ark 包文件名称,默认为 ${artifactId}-->
                    <finalName>demo-ark</finalName>

                    <!--是否跳过执行 goal:repackage,默认为false-->
                    <skip>false</skip>

                    <!--是否打包、安装和发布 ark biz,详细参考 Ark Biz 文档,默认为false-->
                    <attach>true</attach>

                    <!--设置 ark 包的 classifier,默认为空-->
                    <arkClassifier>ark</arkClassifier>

                    <!--设置 ark biz 的 classifier,默认为 ark-biz-->
                    <bizClassifier>ark-biz</bizClassifier>

                    <!--设置 ark biz 的 biz name,默认为 ${artifactId}-->
                    <bizName>demo-ark</bizName>

                    <!--设置 ark biz 的 biz version,默认为 ${artifactId}-->
                    <bizVersion>0.0.1</bizVersion>

                    <!--设置 ark biz 的 启动优先级,值越小优先级越高,${artifactId}-->
                    <priority>100</priority>

                    <!--设置 ark biz 的启动入口,默认会搜索被打 org.springframework.boot.autoconfigure.SpringBootApplication 注解且含有 main 方法的入口类-->
                    <mainClass>com.alipay.sofa.xx.xx.MainEntry</mainClass>

                    <!--设置是否将 scope=provided 的依赖打包,默认为 false-->
                    <packageProvided>false</packageProvided>

                    <!--设置是否生成 Biz 包,默认为true-->
                    <keepArkBizJar>true</keepArkBizJar>

                    <!--针对 Web 应用,设置 context path,默认为 /,模块应该配置自己的 webContextPath,如:biz1 -->
                    <webContextPath>/</webContextPath>

                    <!--打包 ark biz 时,排除指定的包依赖;格式为: ${groupId:artifactId} 或者 ${groupId:artifactId:classifier}-->
                    <excludes>
                        <exclude>org.apache.commons:commons-lang3</exclude>
                    </excludes>

                    <!--打包 ark biz 时,排除和指定 groupId 相同的包依赖-->
                    <excludeGroupIds>
                        <excludeGroupId>org.springframework</excludeGroupId>
                    </excludeGroupIds>

                    <!--打包 ark biz 时,排除和指定 artifactId 相同的包依赖-->
                    <excludeArtifactIds>
                        <excludeArtifactId>sofa-ark-spi</excludeArtifactId>
                    </excludeArtifactIds>

                    <!--打包 ark biz 时,配置不从 ark plugin 索引的类;默认情况下,ark biz 会优先索引所有 ark plugin 的导出类,
                    添加该配置后,ark biz 将只在ark biz内部加载该类,不再优先委托 ark plugin 加载-->
                    <denyImportClasses>
                        <class>com.alipay.sofa.SampleClass1</class>
                        <class>com.alipay.sofa.SampleClass2</class>
                    </denyImportClasses>

                    <!--对应 denyImportClasses 配置,可以配置包级别-->
                    <denyImportPackages>
                        <package>com.alipay.sofa</package>
                        <package>org.springframework.*</package>
                    </denyImportPackages>

                    <!--打包 ark biz 时,配置不从 ark plugin 索引的资源;默认情况下,ark biz 会优先索引所有 ark plugin 的导出资源,
                    添加该配置后,ark biz 将只在ark biz内部寻找该资源,不在从 ark plugin 查找-->
                    <denyImportResources>
                        <resource>META-INF/spring/test1.xml</resource>
                        <resource>META-INF/spring/test2.xml</resource>
                    </denyImportResources>
                  
                     <!--ark biz 仅能找到自己在pom 中声明过的依赖,默认为 false-->
                    <declaredMode>true</declaredMode>

                    <!--打包 ark biz 时,仅打包基座没有的依赖、模块与基座不同版本的依赖。该参数用于指定“基座的依赖管理”标识,“基座的依赖管理”需要作为模块 pom 的 parent ,以 ${groupId}:${artifactId}:${version} 标识 -->
                    <baseDependencyParentIdentity>${groupId}:${artifactId}:${version}</baseDependencyParentIdentity>
                </configuration>
            </execution>
        </executions>
    </plugin>
</plugins>

模块瘦身配置

SOFAArk 模块瘦身会读取两处配置文件:

  • “模块项目根目录/conf/ark/bootstrap.properties”,比如:my-module/conf/ark/bootstrap.properties
  • “模块项目根目录/conf/ark/bootstrap.yml”,比如:my-module/conf/ark/bootstrap.yml

bootstrap.properties

在「模块项目根目录/conf/ark/bootstrap.properties」中按照如下格式配置需要下沉到基座的框架和中间件常用包,比如:

# excludes config ${groupId}:{artifactId}:{version}, split by ','
excludes=org.apache.commons:commons-lang3,commons-beanutils:commons-beanutils
# excludeGroupIds config ${groupId}, split by ','
excludeGroupIds=org.springframework
# excludeArtifactIds config ${artifactId}, split by ','
excludeArtifactIds=sofa-ark-spi

bootstrap.yml

在「模块项目根目录/conf/ark/bootstrap.yml」中按照如下格式配置需要下沉到基座的框架和中间件常用包,比如:

# excludes 中配置 ${groupId}:{artifactId}:{version}, 不同依赖以 - 隔开
# excludeGroupIds 中配置 ${groupId}, 不同依赖以 - 隔开
# excludeArtifactIds 中配置 ${artifactId}, 不同依赖以 - 隔开
excludes:
  - org.apache.commons:commons-lang3
  - commons-beanutils:commons-beanutils
excludeGroupIds:
  - org.springframework
excludeArtifactIds:
  - sofa-ark-spi

开发阶段

Arklet 配置

端口配置

基座启动时,在JVM参数中配置端口,默认为 1238

-Dkoupleless.arklet.http.port=XXXX

模块运行时配置

健康检查的配置

基座的 application.properties 配置:

# 或者不配置 management.endpoints.web.exposure.include
management.endpoints.web.exposure.include=health
# 如果需要展示所有信息,则配置以下内容
management.endpoint.health.show-components=always
management.endpoint.health.show-details=always
# 不忽略模块启动状态
koupleless.healthcheck.base.readiness.withAllBizReadiness=true

Web Gateway 配置

在传统应用拆出模块时,由于每个模块都有自己的 webContextPath,上游调用方需要修改请求路径。为了避免修改,可以在 application.properties 或 application.yaml 中配置 Web Gateway 转发规则,让上游调用方无需修改。

在配置上,可以配置三种策略:

  • 域名匹配:指定 符合HostA的请求 转发到 模块A
  • 路径匹配:指定 符合PathA的请求 转发到 模块A的特定PathB
  • 域名和路径同时匹配:指定 符合HostA且PathA的请求 转发到 模块A的特定PathB

application.yaml 配置样例如下:

koupleless:
  web:
    gateway:
      forwards:
# host in [a.xxx,b.xxx,c.xxx] path /${anyPath} --forward to--> biz1/${anyPath}
        - contextPath: biz1
        - hosts:
            - a
            - b
            - c
# /idx2/** -> /biz2/**, /t2/** -> /biz2/timestamp/**
        - contextPath: biz2
        - paths:
            - from: /idx2
            - to: /
            - from: /t2
            - to: /timestamp
# /idx1/** -> /biz1/**, /t1/** -> /biz1/timestamp/**
        - contextPath: biz1
        - paths:
            - from: /idx1
            - to: /
            - from: /t1
            - to: /timestamp

application.properties 配置样例如下:

# host in [a.xxx,b.xxx,c.xxx] path /${anyPath} --forward to--> biz1/${anyPath}
koupleless.web.gateway.forwards[0].contextPath=biz1
koupleless.web.gateway.forwards[0].hosts[0]=a
koupleless.web.gateway.forwards[0].hosts[1]=b
koupleless.web.gateway.forwards[0].hosts[2]=c
# /idx2/** -> /biz2/**, /t2/** -> /biz2/timestamp/**
koupleless.web.gateway.forwards[1].contextPath=biz2
koupleless.web.gateway.forwards[1].paths[0].from=/idx2
koupleless.web.gateway.forwards[1].paths[0].to=/
koupleless.web.gateway.forwards[1].paths[1].from=/t2
koupleless.web.gateway.forwards[1].paths[1].to=/timestamp
# /idx1/** -> /biz1/**, /t1/** -> /biz1/timestamp/**
koupleless.web.gateway.forwards[2].contextPath=biz1
koupleless.web.gateway.forwards[2].paths[0].from=/idx1
koupleless.web.gateway.forwards[2].paths[0].to=/
koupleless.web.gateway.forwards[2].paths[1].from=/t1
koupleless.web.gateway.forwards[2].paths[1].to=/timestamp

4.3.14 - 4.3.14 SOFAArk 关键用户文档

模块生命周期

Ark 事件机制

Ark 自身日志



4.4 - 5. Module Controller V2 模块运维

Koupleless Module Controller V2架构下的模块运维

4.4.1 - 5.1 模块发布

Koupleless 模块上线与下线

注意:当前 ModuleController v2 仅在 K8S 1.24 版本测试过,没有在其它版本测试,ModuleController V2依赖了部分K8S特性,K8S的版本不能低于V1.10。

模块上线

ModuleController V2支持以任意Pod的发布方式进行模块发布上线,包含但不仅限于裸pod发布、Deployment、DaemonSet、StatefulSet。下面以Deployment为例演示模块的发布流程,其他方式可以参考Deployment中template的配置:

kubectl apply -f samples/module-deployment.yaml --namespace yournamespace

完整内容如下:

apiVersion: apps/v1  # 指定api版本,此值必须在kubectl api-versions中
kind: Deployment  # 指定创建资源的角色/类型
metadata:  # 资源的元数据/属性
  name: test-module-deployment  # 资源的名字,在同一个namespace中必须唯一
  namespace: default # 部署在哪个namespace中
spec: # 资源规范字段
  replicas: 1
  revisionHistoryLimit: 3 # 保留历史版本
  selector: # 选择器
    matchLabels: # 匹配标签
      app: test-module-deployment
  strategy: # 策略
    rollingUpdate: # 滚动更新
      maxSurge: 30% # 最大额外可以存在的副本数,可以为百分比,也可以为整数
      maxUnavailable: 30% # 示在更新过程中能够进入不可用状态的 Pod 的最大值,可以为百分比,也可以为整数
    type: RollingUpdate # 滚动更新策略
  template: # 模版
    metadata: # 资源的元数据/属性
      labels: # 设定资源的标签
        module-controller.koupleless.io/component: module # 必要,声明pod的类型,用于module controller管理
        # deployment unique id
        app: test-module-deployment-non-peer
    spec: # 资源规范字段
      containers:
        - name: biz1 # 必要,声明module的bizName,需与pom中声明的artifactId保持一致
          image: https://serverless-opensource.oss-cn-shanghai.aliyuncs.com/module-packages/stable/biz1-web-single-host-0.0.1-SNAPSHOT-ark-biz.jar
          env:
            - name: BIZ_VERSION # 必要,声明module的biz_version,value需与pom中声明的version保持一致
              value: 0.0.1-SNAPSHOT
      affinity:
        nodeAffinity: # 必要,声明基座选择器,保证模块被调度到指定的基座上
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: base.koupleless.io/stack
                    operator: In
                    values:
                      - java # 多语言环境下可能有其他技术栈,必填
                  - key: base.koupleless.io/version
                    operator: In
                    values:
                      - 1.1.1 # 指定的基座版本,必填,至少需要一个
                  - key: base.koupleless.io/name
                    operator: In
                    values:
                      - base  # 指定的基座bizName,必填,至少需要一个
      tolerations: # 必要,允许pod被调度到基座node上
        - key: "schedule.koupleless.io/virtual-node"
          operator: "Equal"
          value: "True"
          effect: "NoExecute"

其中所有的配置与普通Deployment一致,除必填项外,可添加其他Deployment的配置实现自定义能力。

查看模块状态

这一需求可以通过查看nodeName为基座对应node的Pod来实现。首先需要了解基座服务与node的对应关系。

在Module Controller V2的设计中,每一个基座会在启动时随机生成一个全局唯一的UUID作为基座服务的标识,对应的node的Name则将包含这一ID。

除此之外,基座服务的IP与node的IP是一一对应的,也可以通过IP来筛选对应的基座Node。

因此,可以通过以下命令查看某个基座上安装的所有Pod(模块),和对应的状态。

kubectl get pod -n <namespace> --field-selector status.podIP=<baseIP>

kubectl get pod -n <namespace> --field-selector spec.nodeName=virtual-node-<baseUUID>

模块下线

在 K8S 集群中删除模块的Pod或其他控制资源即可完成模块下线,例如,在Deployment部署的场景下,可以直接删除对应的Deployment实现模块的下线:

kubectl delete yourmoduledeployment --namespace yournamespace

其中 yourmoduledeployment 替换成您的 ModuleDeployment 名字,yournamespace 替换成您的 namespace。

如果要自定义模块发布运维策略(比如分组、Beta、暂停等),可参考模块发布运维策略

样例演示的是使用 kubectl 方式,直接调用 K8S APIServer 删除Deployment一样能实现模块分组下线。

模块扩缩容

由于ModuleController V2完全复用了K8S的Pod编排方案,扩缩容只发生在ReplicaSet、Deployment、StatefulSet等部署方式上,扩缩容可以按照各自对应的扩缩容方式实现,下面以Deployment为例:

kubectl scale deployments/yourdeploymentname --namespace=yournamespace --replicas=3

其中 yourdeploymentname 替换成您的 Deployment name,yournamespace 替换成您的 namespace,replicas参数设置为希望扩/缩容到的数量。

也可以通过API调用实现扩缩容策略。

模块替换

在ModuleController v2中,模块与Container是强绑定的关系,如果想实现模块的替换,需要执行更新逻辑,更新模块所在Pod上的模块对应Image地址。

具体的替换方式随模块部署的方式不同而略有区别,例如,直接更新Pod信息会在原地进行模块的替换,Deployment会执行配置的更新策略(例如滚动更新,先创建新版本的Pod,再删除旧版本的Pod),DaemonSet也会执行配置的更新策略,与Deployment不同,DaemonSet是先删除后创建的逻辑,可能会造成流量损失。

模块回滚

由于与原生的Deployment兼容,因此可以采用Deployment的回滚方式实现模块回滚。

查看deployment历史。

kubectl rollout history deployment yourdeploymentname

回滚到指定版本

kubectl rollout undo deployment yourdeploymentname --to-revision=<TARGET_REVISION>

其他运维问题

模块流量 Service 实现方案

可以通过创建原生Service的方式创建模块的Service,仅当基座与ModuleController部署在同一VPC中时才能够正常提供服务。

由于目前基座与ModuleController并不一定部署在同一个VPC下,两者之间通过MQTT消息队列实现交互。基座node会集成基座所在Pod的IP,模块所在Pod会集成基座node的IP,因此,当基座本身与ModuleController不属于同一个VPC的时候,这里模块的IP实际上是无效的,因此无法对外提供服务。

可能的解决方案是在Service上的LB层做转发,将对应Service的流量转发到基座所在K8S的对应IP的基座服务上。后续将根据实际使用情况对这一问题进行评估与优化。

基座和模块不兼容发布

  1. 首先部署一个module的Deployment,其中Container指定为最新版本的模块代码包地址,nodeAffinity指定新版本基座的名称和版本信息。 此时,这一Deployment会创建出对应的Pod,但是由于还没有新版本的基座创建,因此不会被调度。

  2. 更新基座Deployment,发布新版本镜像,此时会触发基座的替换和重启,基座启动时会告知ModuleController V2控制器,会创建对应版本的node。

  3. 对应版本的基座node创建之后,K8S调度器会自动触发调度,将步骤1中创建的模块Pod调度到基座node上,进行新版本的模块安装,从而实现同时发布。



4.4.2 - 5.2 模块发布运维策略

Koupleless 模块发布运维策略

运维策略

为了实现生产环境的无损变更,模块发布运维基于K8S的原生调度能力提供了安全可靠的变更能力。用户可以通过业务需要使用合适的模块Pod部署方式。

调度策略

打散调度:通过Deployment的原生控制方式实现,可以通过PodAffinity配置实现打散调度。

对等和非对等

可以通过选择不同的部署方式实现对等和非对等部署策略。

对等部署

下面提供两种实现方式:

  1. 可以通过将模块部署成为DaemonSet实现,这样每当一个基座node上线时,DaemonSet控制器就会自动为其创建模块Pod,实现对等部署。

    这里需要注意,DaemonSet的滚动更新是先卸后装,请结合业务实际需求进行选择。

  2. 通过Deployment实现,相比DaemonSet,需要额外增加一个组件用于控制模块副本数与基座数量一致(正在建设中,预计下一个版本发布)。支持先装后卸,不会造成中台模式下基座流量损失。

    注意,Deployment虽然会尽量选择打散部署,但是并不能完全保证打散调度,可能会出现统一模块多次部署到同一个基座上,如果要实现强打散调度,需要在部署模块Deployment中添加Pod反亲和配置,示例如下:

apiVersion: apps/v1  # 指定api版本,此值必须在kubectl api-versions中
kind: Deployment  # 指定创建资源的角色/类型
metadata:  # 资源的元数据/属性
    name: test-module-deployment  # 资源的名字,在同一个namespace中必须唯一
    namespace: default # 部署在哪个namespace中
    labels:  # 设定资源的标签
        module-controller.koupleless.io/component: module-deployment # 资源类型标记, 用于module controller管理
spec: # 资源规范字段
    replicas: 1
    revisionHistoryLimit: 3 # 保留历史版本
    selector: # 选择器
        matchLabels: # 匹配标签
            module.koupleless.io/name: biz1
            module.koupleless.io/version: 0.0.1
    strategy: # 策略
        rollingUpdate: # 滚动更新
            maxSurge: 30% # 最大额外可以存在的副本数,可以为百分比,也可以为整数
            maxUnavailable: 30% # 示在更新过程中能够进入不可用状态的 Pod 的最大值,可以为百分比,也可以为整数
        type: RollingUpdate # 滚动更新策略
    template: # 模版
        metadata: # 资源的元数据/属性
            labels: # 设定资源的标签
                module-controller.koupleless.io/component: module # 必要,声明pod的类型,用于module controller管理
                module.koupleless.io/name: biz1
                module.koupleless.io/version: 0.0.1
        spec: # 资源规范字段
            containers:
            - name: biz1
              image: https://serverless-opensource.oss-cn-shanghai.aliyuncs.com/module-packages/test_modules/biz1-0.0.1-ark-biz.jar
              env:
              - name: BIZ_VERSION
                value: 0.0.1
            affinity:
              nodeAffinity:
                requiredDuringSchedulingIgnoredDuringExecution:
                  nodeSelectorTerms: # 基座node选择
                      - matchExpressions:
                        - key: base.koupleless.io/stack
                          operator: In
                          values:
                              - java
                        - key: base.koupleless.io/version
                          operator: In
                          values:
                              - 1.0.0 # 模块可能只能被调度到一些特殊版本的 node 上,如有这种限制,则必须有这个字段。
                        - key: base.koupleless.io/name
                          operator: In
                          values:
                          - base  # 模块可能只能被调度到一些特殊版本的 node 上,如有这种限制,则必须有这个字段。
              podAntiAffinity: # 打散调度核心配置
                  requiredDuringSchedulingIgnoredDuringExecution:
                  - labelSelector:
                      matchLabels:
                          module.koupleless.io/name: biz1 # 与template中的label配置保持一致
                          module.koupleless.io/version: 0.0.1 # 与template中的label配置保持一致
                    topologyKey: topology.kubernetes.io/zone
            tolerations:
              - key: "schedule.koupleless.io/virtual-node" # 确保模块能够调度到基座node上
                operator: "Equal"
                value: "True"
                effect: "NoExecute"

非对等部署:可以通过将模块部署成为Deployment/ReplicaSet实现,此时将根据replica设置进行模块的部署。

分批更新

分批更新策略需要自行实现相关控制逻辑,ModuleController V2能够提供的能力是,当某个基座上先后安装同名不同版本的模块之后,安装时间较早的模块对应Pod会进入BizDeactivate状态,并进入Failed Phase。可结合这一逻辑实现分批更新逻辑。



4.4.3 - 5.3 健康检查

背景

健康检查的目的是获取应用在生命周期中的状态,包括:运维阶段和运行阶段的状态,以便用户根据该状态做决策。例如:如果发现应用状态为 DOWN,则表示应用存在故障,用户可以重启或替换机器。

在单应用情况下,健康检查比较简单:

  • 运维阶段状态:
    • 如果正在启动,则为 UNKNOWN;
    • 如果启动失败,则为 DOWN;
    • 如果启动成功,则为 UP。
  • 运行阶段状态:
    • 如果应用各健康检查点健康,则为 UP;
    • 如果应用各健康检查点不健康,则为 DOWN。

在多应用场景下,情况会复杂得多。 我们需要考虑多应用在运维阶段运行阶段的状态对整体应用健康状态的影响。在设计健康检查时,我们需要考虑以下2个问题:

  • 模块运维阶段,模块启动状态是否应该影响整体应用健康状态?

    在不同运维场景下,用户的期望是不同的。 koupleless 中模块运维有三种场景:

场景模块对整体应用健康状态的影响
模块热部署提供配置,让用户自行决定模块热部署结果是否影响应用整体健康状态(默认配置为:不影响整体应用原本的健康状态)
静态合并部署模块部署发生在基座启动时,模块启动状态应该直接影响整体应用的健康状态
模块回放模块回放发生在基座启动时,模块启动状态应该直接影响整体应用的健康状态
  • 模块运行阶段,模块运行状态是否应该影响整体应用健康状态?

    模块运行阶段的状态应该直接影响应用整体健康状态。

在此背景下,我们设计了多应用下的健康检查方案。

使用

前置条件

  • koupleless 版本 >= 1.2.1
  • sofa-ark 版本 >= 2.2.9

获取应用整体健康状态

基座的健康状态有 3 类:

状态含义
UP健康,表示已就绪(readiness)
UNKNOWN正在启动中
DOWN不健康(可能是启动失败,也可能是运行状态不健康)

由于 Koupleless 支持热部署模块,因此用户在获取应用整体健康状态时,可能希望模块部署是否成功影响整体应用健康状态,或不影响。

模块启动是否成功不影响整体应用健康状态(默认)

  • 特点:对于健康的基座,如果模块安装失败,不会影响整体应用健康状态。
  • 使用:和普通 Spring Boot 应用的配置一致,在基座的 application.properties 中配置:
# 或者不配置 management.endpoints.web.exposure.include
management.endpoints.web.exposure.include=health
# 如果需要展示所有信息,则配置以下内容
management.endpoint.health.show-components=always
management.endpoint.health.show-details=always
  • 访问:{baseIp:port}/actuator/health
  • 结果:
{
    // 应用整体健康状态
    "status": "UP",
    "components": {
        // 模块聚合健康状态
        "arkBizAggregate": {
            "status": "UP",
            "details": {
                "biz1:0.0.1-SNAPSHOT": {
                    "status": "UP",
                    // 可以看到模块中所有生效的 HealthIndicator 的健康状态
                    "details": {
                        "diskSpace": {
                          "status": "UP",
                          "details": {
                            "total": 494384795648,
                            "free": 272435396608,
                            "threshold": 10485760,
                            "exists": true
                            }
                        },
                        "pingHe": {
                          "status": "UP",
                          "details": {}
                        }
                    }
                }
            }
        },
        // 启动健康状态
        "masterBizStartUp": {
            "status": "UP",
            // 包括每一个模块的启动状态
            "details": {
                "base:1.0.0": {
                    "status": "UP"
                },
                "biz1:0.0.1-SNAPSHOT": {
                    "status": "UP"
                },
                "biz2:0.0.1-SNAPSHOT": {
                    "status": "DOWN"
                }
            }
        }
    }
}

不同场景下的整体健康状态

场景1:无模块基座启动

状态含义
UP基座健康
UNKNOWN基座正在启动中
DOWN基座不健康

场景2:基座启动时,静态合并部署

状态含义
UP基座和模块都健康
UNKNOWN基座正在启动中/模块正在启动中
DOWN基座启动失败/基座不健康/模块启动失败/模块不健康

场景3:基座启动后,热部署

提供配置,让用户自行决定模块热部署结果是否影响应用整体健康状态(默认配置为:不影响整体应用原本的健康状态)

默认配置:热部署场景下,模块是否安装成功不影响应用整体健康状态,如下:

状态含义
UP基座和模块都健康
UNKNOWN基座正在启动中
DOWN基座启动失败/基座不健康/模块不健康

场景4:基座运行中

状态含义
UP基座和模块都健康
UNKNOWN-
DOWN基座不健康或模块不健康

场景5:基座启动后,模块回放

模块回放是指在基座启动后,自动拉取模块基线,并安装模块。

目前未支持模块回放。

模块启动是否成功影响整体应用健康状态

  • 特点:对于健康的基座,如果模块安装失败,整体应用健康状态也会为失败。
  • 使用:在上述配置之外,需要配置 koupleless.healthcheck.base.readiness.withAllBizReadiness=true,即在基座的 application.properties 中配置:
# 或者不配置 management.endpoints.web.exposure.include
management.endpoints.web.exposure.include=health
# 如果需要展示所有信息,则配置以下内容
management.endpoint.health.show-components=always
management.endpoint.health.show-details=always
# 不忽略模块启动状态
koupleless.healthcheck.base.readiness.withAllBizReadiness=true
  • 访问:{baseIp:port}/actuator/health
  • 结果:
{
    // 应用整体健康状态
    "status": "UP",
    "components": {
        // 模块聚合健康状态
        "arkBizAggregate": {
            "status": "UP",
            "details": {
                "biz1:0.0.1-SNAPSHOT": {
                    "status": "UP",
                    // 可以看到模块中所有生效的 HealthIndicator 的健康状态
                    "details": {
                        "diskSpace": {
                          "status": "UP",
                          "details": {
                            "total": 494384795648,
                            "free": 272435396608,
                            "threshold": 10485760,
                            "exists": true
                            }
                        },
                        "pingHe": {
                          "status": "UP",
                          "details": {}
                        }
                    }
                }
            }
        },
        // 启动健康状态
        "masterBizStartUp": {
            "status": "UP",
            // 包括每一个模块的启动状态
            "details": {
                "base:1.0.0": {
                    "status": "UP"
                },
                "biz1:0.0.1-SNAPSHOT": {
                    "status": "UP"
                }
            }
        }
    }
}

不同场景下的整体健康状态

场景1:无模块基座启动

状态含义
UP基座健康
UNKNOWN基座正在启动中
DOWN基座不健康

场景2:基座启动时,静态合并部署

状态含义
UP基座和模块都健康
UNKNOWN基座正在启动中/模块正在启动中
DOWN基座启动失败/基座不健康/模块启动失败/模块不健康

场景3:基座启动后,热部署

提供配置,让用户自行决定模块热部署结果是否影响应用整体健康状态(默认配置为:不影响整体应用原本的健康状态)

当设置为 koupleless.healthcheck.base.readiness.withAllBizReadiness=true

状态含义
UP基座和模块都健康
UNKNOWN基座正在启动中/模块正在启动中
DOWN基座启动失败/模块启动失败/基座不健康/模块不健康

场景4:基座运行中

状态含义
UP基座和模块都健康
UNKNOWN-
DOWN基座不健康或模块不健康

场景5:基座启动后,模块回放

模块回放是指在基座启动后,自动拉取模块基线,并安装模块。

目前未支持模块回放。

获取单个模块的健康状态

  • 使用:和普通 springboot 的健康检查配置一致,开启 health 节点,即:在模块的 application.properties 中配置:
# 或者不配置 management.endpoints.web.exposure.include
management.endpoints.web.exposure.include=health
  • 访问:{baseIp:port}/{bizWebContextPath}/actuator/info
  • 结果:
{
    "status": "UP",
    "components": {
        "diskSpace": {
            "status": "UP",
            "details": {
                "total": 494384795648,
                "free": 270828220416,
                "threshold": 10485760,
                "exists": true
            }
        },
        "ping": {
            "status": "UP"
        }
    }
}

获取基座、模块和插件信息

  • 使用:和普通 springboot 的健康检查配置一致,开启 info 节点,即:在基座的 application.properties 中配置:
# 注意:如果用户自行配置了 management.endpoints.web.exposure.include,则需要将 health 节点配置上,否则无法访问 health 节点
management.endpoints.web.exposure.include=health,info
  • 访问:{baseIp:port}/actuator/info
  • 结果:
{
    "arkBizInfo": [
      {
        "bizName": "biz1",
        "bizVersion": "0.0.1-SNAPSHOT",
        "bizState": "ACTIVATED",
        "webContextPath": "biz1"
      },
      {
        "bizName": "base",
        "bizVersion": "1.0.0",
        "bizState": "ACTIVATED",
        "webContextPath": "/"
      }
    ],
    "arkPluginInfo": [
        {
            "pluginName": "koupleless-adapter-log4j2",
            "groupId": "com.alipay.sofa.koupleless",
            "artifactId": "koupleless-adapter-log4j2",
            "pluginVersion": "1.0.1-SNAPSHOT",
            "pluginUrl": "file:/Users/lipeng/.m2/repository/com/alipay/sofa/koupleless/koupleless-adapter-log4j2/1.0.1-SNAPSHOT/koupleless-adapter-log4j2-1.0.1-SNAPSHOT.jar!/",
            "pluginActivator": "com.alipay.sofa.koupleless.adapter.Log4j2AdapterActivator"
        },
        {
            "pluginName": "web-ark-plugin",
            "groupId": "com.alipay.sofa",
            "artifactId": "web-ark-plugin",
            "pluginVersion": "2.2.9-SNAPSHOT",
            "pluginUrl": "file:/Users/lipeng/.m2/repository/com/alipay/sofa/web-ark-plugin/2.2.9-SNAPSHOT/web-ark-plugin-2.2.9-SNAPSHOT.jar!/",
            "pluginActivator": "com.alipay.sofa.ark.web.embed.WebPluginActivator"
        },
        {
            "pluginName": "koupleless-base-plugin",
            "groupId": "com.alipay.sofa.koupleless",
            "artifactId": "koupleless-base-plugin",
            "pluginVersion": "1.0.1-SNAPSHOT",
            "pluginUrl": "file:/Users/lipeng/.m2/repository/com/alipay/sofa/koupleless/koupleless-base-plugin/1.0.1-SNAPSHOT/koupleless-base-plugin-1.0.1-SNAPSHOT.jar!/",
            "pluginActivator": "com.alipay.sofa.koupleless.plugin.ServerlessRuntimeActivator"
        }
    ]
}

4.4.4 - 5.4 Module Controller 部署

Koupleless Module Controller V2的部署方式

注意:当前 ModuleController v2 仅在 K8S 1.24 版本测试过,没有在其它版本测试,ModuleController V2依赖了部分K8S特性,K8S的版本不能低于V1.10。

资源文件位置

  1. Role 定义
  2. RBAC 定义
  3. ServiceAccount 定义
  4. ModuleControllerV2 部署定义

部署方式

使用 kubectl apply 命令,依次 apply 上述 4 个资源文件,即可完成 ModuleController 部署。

文档参考

具体的结构和实现介绍请参考文档



4.4.5 - 5.5 模块信息查看

Koupleless 模块信息查看

查看某个基座上所有安装的模块名称和状态

kubectl get module -n <namespace> -l koupleless.alipay.com/base-instance-ip=<pod-ip> -o custom-columns=NAME:.metadata.name,STATUS:.status.status

kubectl get module -n <namespace> -l koupleless.alipay.com/base-instance-name=<pod-name> -o custom-columns=NAME:.metadata.name,STATUS:.status.status

查看某个基座上所有安装的模块详细信息

kubectl describe module -n <namespace> -l koupleless.alipay.com/base-instance-ip=<pod-ip>

kubectl describe module -n <namespace> -l koupleless.alipay.com/base-instance-name=<pod-name>

替换<pod-ip>为需要查看的基座ip,<pod-name>为需要查看的基座名称,<namespace>为需要查看资源的namespace

4.4.6 - 5.6 错误码

本文主要介绍 Arklet, ModuleController, KouplelessBoard 的错误码。

ErrorCode 规则

两级错误码,支持动态组合,采用大驼峰方式,不同级别错误码之间只能用 “.” 分隔。
如 Arklet.InstallModuleFailed
一级:错误来源
二级:错误类型

Suggestion

简要说明解决方案,供上游操作参考。

Arklet 错误码

一级 错误来源

编码含义
User客户导致的错误
ArkletArklet 自身异常
ModuleController具体上游组件导致的异常
OtherUpstream未知上游导致的异常

二级 错误类型

业务类型错误来源错误类型含义解决方案
通用ArkletUnknownError未知错误(默认)请排查

ModuleControllerInvalidParameter参数校验失败请检查参数
ModuleControllerInvalidRequest操作类型非法请检查请求
OtherUpstreamDecodeURLFailedurl 解析失败请检查 url 是否合法
查询相关ArkletNoMatchedBiz模块查询失败,没有目标 biz 存在-
ArkletInvalidBizName模块查询失败,查询参数 bizName 不能为空请添加查询参数 bizName
安装相关ArkletInstallationRequirementNotMet模块安装条件不满足请检查模块安装的必要参数
ArkletPullBizError拉包失败请重试
ArkletPullBizTimeOut拉包超时请重试
UserDiskFull拉包时,磁盘已满请替换基座
UserMachineMalfunction机器故障请重启基座
UserMetaspaceFullMetaspace超过阈值请重启基座
ArkletInstallBizExecuting模块安装时,当前模块正在安装请重试

ArkletInstallBizTimedOut模块安装时,卸载老模块失败请排查
ArkletInstallBizFailed模块安装时,新模块安装失败请排查
UserInstallBizUserError模块安装失败,业务异常请检查业务代码
卸载相关ArkletUninstallBizFailed卸载失败,当前 biz 还存在在 容器中请排查
ArkletUnInstallationRequirementNotMet模块卸载条件不满足当前模块存在多版本,且卸载的版本是激活状态的,不允许卸载

ModuleController 错误码

一级 错误来源

编码含义
User客户导致的错误
ModuleControllerModuleController 自身异常
KouplelessBoard具体上游组件导致的异常
Arklet具体下游组件导致的异常
OtherUpstream未知上游导致的异常
OtherDownstream未知下游导致的异常

二级 错误类型

业务类型错误来源错误类型含义解决方案
通用ModuleControllerUnknownError未知错误(默认)请排查

OtherUpstreamInvalidParameter参数校验失败请检查参数
ArkletArkletServiceNotFound找不到基座服务请确保基座有Koupleless依赖
ArkletNetworkError网络调用异常请重试
OtherUpstreamSecretAKError签名异常请确认有操作权限
ModuleControllerDBAccessError读写数据库失败请重试
OtherUpstreamDecodeURLFailedurl 解析失败请检查 url 是否合法
ModuleControllerRetryTimesExceeded重试多次失败请排查
ModuleControllerProcessNodeMissed缺少可用的工作节点请稍后重试
ModuleControllerServiceMissed服务缺失请检查ModuleController版本是否含有该模版类型
ModuleControllerResourceConstraned资源受限(线程池、队列等满)请稍后重试
安装相关ArkletInstallModuleTimedOut模块安装超时请重试
Arklet / UserInstallModuleFailed模块安装失败请检查失败原因
ArkletInstallModuleExecuting模块安装中相同模块在安装,请稍后重试
UserDiskFull磁盘已满请替换
卸载相关OtherUpstreamEmptyIPListip 列表为空请输入要卸载的ip

ArkletUninstallBizTimedOut模块卸载超时请重试

ArkletUninstallBizFailed模块卸载失败请排查
基座相关ModuleControllerBaseInstanceNotFound基座实例不存在请确保基座实例存在

KubeAPIServerGetBaseInstanceFailed查询不到基座信息请确保基座实例存在
ModuleControllerBaseInstanceInOperation基座正在运维中请稍后重试
ModuleControllerBaseInstanceNotReady暂未读到基座数据或基座不可用请确保基座可用
ModuleControllerBaseInstanceHasBeenReplaced基座已被替换后续会新增基座实例,请等候
ModuleControllerInsufficientHealthyBaseInstance健康基座不足请扩容
扩缩容ModuleControllerRescaleRequirementNotMet扩缩容条件不满足请检查扩容机器是否足够/请检查缩容比例

⚠️注意:基座运行在不同基座实例上,如:pod。因此 BaseInstanceInOperation, BaseInstanceNotReady, BaseInstanceHasBeenReplaced, InsufficientHealthyBaseInstance 错误码可能指包括基座应用状态或基座实例的状态。

DashBoard 错误码

一级 错误来源

编码含义
KouplelessBoardKouplelessBoard 自身异常
ModuleController具体下游组件导致的异常
OtherUpstream未知上游导致的异常
OtherDownstream未知下游导致的异常

二级 错误类型

业务类型错误来源错误类型含义解决方案
通用KouplelessBoardUnknownError未知错误(默认)

OtherUpstreamInvalidParameter参数校验失败请检查参数
工单KouplelessBoardOperationPlanNotFound工单不存在请排查
KouplelessBoardOperationPlanMutualExclusion工单互斥请重试
内部错误KouplelessBoardInternalError系统内部错误请排查
KouplelessBoardThreadPoolError线程池调用异常请排查
运维ModuleControllerBaseInstanceOperationFailed运维失败请排查
ModuleControllerBaseInstanceUnderOperation运维中请重试
ModuleControllerBaseInstanceOperationTimeOut运维超时请重试
ModuleControllerOverFiftyPercentBaseInstancesUnavaliable超过50% 机器流量不可达请检查基座实例
KouplelessBoardBaselineInconsistency一致性校验失败(基线不一致)请排查
外部服务调用错误OtherDownstreamExternalError外部服务调用错误请排查
KouplelessBoardNetworkError外部服务调用超时请重试

5 - 6. 参与社区

5.1 - 6.1 开放包容理念

Koupleless 使命愿景

核心价值观

Koupleless 社区的核心价值观是 “开放” 和 “包容”。社区里所有的用户、开发者完全平等,体现在如下几个方面:

  1. 社区参考了 Apache 开源项目的运作方式,对社区做出任意贡献的同学,尤其是非代码贡献的同学(文档、官网、Issue 回复、运营布道、发展建议等),都是我们的 Contributor,都有机会成为社区的 Committer 甚至是 PMC(Project Management Committee)成员。

  2. 社区所有的 OKR、RoadMap、讨论、会议、技术方案等都是完全开放的,所有人都可以看见,并且都可以参与其中,社区会认证倾听、考虑大家的所有建议和意见,一旦采纳就会确保执行落地。希望大家带着无所顾虑、求同尊异的心态参与 Koupleless 社区。

  3. 社区不限地域国籍,所有源代码必须是英文注释确保大家都能理解,官网也是中英文双语。所有微信群、钉钉群、GitHub Issues 讨论都可以是中英双语。但由于当前我们主要聚焦在国内用户,因此大部分文档暂时只有中文版,未来会提供英文版。

2023 年 OKR

O1 打造社区健康、有行业影响力的 Serverless 开源产品

KR1 新增 10 个 Contributors,年底 OpenRank 指数 > 15(当前 5)、活跃度 > 50(当前 44)

KR1.1 完成 5 次布道和 5 次文章分享,触达 200 家企业,深度交流 30+ 企业。
KR1.2 形成完善的社区共建机制(包括 Issue 管理、文档、问题响应、培养与晋升机制),发布 2+ 培训课程与产品手册,共建开发者可在一周内上手,开发总吞吐率达到 20+ Issues/周。

KR2 新增 5 家企业在生产环境使用或完成试点接入(当前新增 1),3 家企业参与社区

KR2.1 产出初步行业分析报告,帮助定位适用不同场景的重点企业对象。
KR2.2 5 家企业生产真实使用或完成试点接入,3 家企业参与社区,覆盖 3 个场景并沉淀 3+ 用户案例。

O2 打造技术先进、效果显著的降本增效解决方案

KR1 落地模块化技术实现机器减少 30%、部署验证耗时降低至 30 秒、需求交付效率提升 50%

KR1.1 搭建 1 分钟快速试用平台,完善的文档、官网与配套支持,用户可在 10 分钟完成一个模块拆分。
KR1.2 完成 20 种中间件和三方包治理,同时形成多应用与热卸载评测和自动检测标准。
KR1.3 模块具备热部署启动耗时降低至 10 秒级,多模块具备合并部署资源减少 30%,同时让用户需求交付效率提升 50%。
KR1.4 落地开源版 Arklet,支持 SOFABoot 和 SpringBoot。提供运维管道、指标采集、模块生命周期管理、多模块运行环境、Bean 与服务发现及调用能力。
KR1.5 落地研发工具 ArkCtl,具备快速开发验证、灵活部署(合并与独立部署)、模块低成本拆分改造能力。

KR2 运维调度 1.0 版本上线。全链路高频端到端测试用例成功率 99.9%,自身端到端耗时 P90 < 500ms

KR2.1 上线基于 K8S Operator 的开源版运维调度能力,至少具备发布、回滚、下线、扩缩容、替换、副本保持、2+ 调度策略、模块流控、部署策略、对等和非对等运维能力。
KR2.2 建设开源版 CI 和 25+ 高频端到端测试用例,不断打磨并推动端到端 P90 耗时 < 500ms、所有预演成功率> 99.9%、单测覆盖率达到行 > 80% 分支 > 60%(通过率 100%)。

KR3 开源版自动伸缩初步上线,模块具备人工画像和分时伸缩能力

RoadMap

  • 2023.08 完成 SOFABoot 完整的部署功能验证,产出兼容性 Benchmark 基线。
  • 2023.09 发布基础运维和调度系统 ModuleController 0.5 版本。
  • 2023.09 发布研发运维工具 Arkctl 与 Arklet 0.5 版本。
  • 2023.09 官网和完整用户手册上线。
  • 2023.10 新增 2+ 公司使用。
  • 2023.11 支持 SpringBoot 完整能力和 5+ 社区常用中间件。
  • 2023.11 Koupleless 0.8 版本上线(ModuleController、Arkctl、Arklet、SpringBoot 兼容)。
  • 2023.12 Koupleless 0.9 版本上线(包括基础自动伸缩、模块基础拆分工具、20+ 中间件与三方包兼容)。
  • 2023.12 新增 5+ 家公司真实使用,10+ Contributors 参与。


5.2 - 6.2 交流渠道

Koupleless 交流渠道

Koupleless 提供如下沟通交流渠道,欢迎加入我们一起分享、一起使用、一起收获:

Koupleless 社区交流与协作钉钉群:24970018417

如果您对 Koupleless 感兴趣、或者有初步意向使用 Koupleless、或者已经是 Koupleless / SOFAArk 的用户、或者有兴趣成为社区 Contributor,都可以加入该钉钉群和我们随时随地一起交流讨论、一起贡献代码。
Koupleless 用户钉钉群二维码

Koupleless 用户微信群

Koupleless 用户微信群二维码
如果您对 Koupleless 感兴趣、或者有初步意向使用 Koupleless、或者已经是 Koupleless / SOFAArk 的用户,都可以加入该微信群随时随地一起交流讨论。

社区双周会

每两周周二晚 19:30 - 20:30 会举办社区会议,欢迎大家积极参与旁听或讨论。社区钉钉会议入会方式:
入会链接:https://meeting.dingtalk.com/dialin/?corpId=dingd8e1123006514592
钉钉会议号:90957500367
电话呼入:057128095818 (中国大陆)、02162681677 (中国大陆)
具体会议时间也可关注社区钉钉协作群(群号:24970018417)

双周会记录(有视频回放)

查看这里

每个月底的周一会召开社区各组件 PMC 成员迭代规划会议,讨论并敲定下一个月需求规划。



5.3 - 6.3 贡献社区

5.3.1 - 6.3.1 本地开发测试

Koupleless 本地开发测试

SOFAArk 和 Arklet

SOFAArk 是一个普通 Java SDK 项目,使用 Maven 作为依赖管理和构建工具,只需要本地安装 Maven 3.6 及以上版本即可正常开发代码和单元测试,无需其它的环境准备工作。
关于代码提交细节请参考:完成第一次 PR 提交

ModuleController

ModuleController 是一个标准的 K8S Golang Operator 组件,里面包含了 ModuleDeployment Operator、ModuleReplicaSet Operator、Module Operator,在本地可以使用 minikube 做开发测试,具体请参考本地快速开始
编译构建请在 module-controller 项目里执行:

go mod download   # if compile module-controller first time
go build -a -o manager cmd/main.go  

单元测试执行请在 module-controller 项目里执行:

make test

您也可以使用 IDE 进行编译构建、开发调试和单元测试执行。
module-controller 开发方式和标准 K8S Operator 开发方式完全一样,您可以参考 K8S Operator 开发官方文档

Arkctl

Arkctl 是一个普通 Golang 项目,他是一个命令行工具集,包含了用户在本地开发和运维模块过程中的常用工具。 可参考此处


5.3.2 - 6.3.2 完成第一次 PR 提交

完成第一次 Koupleless PR 提交

认领或提交 Issue

不论您是修复 bug、新增功能或者改进现有功能,在您提交代码之前,请在 KouplelessSOFAArk GitHub 上认领一个 Issue 并将 Assignee 指定为自己(新人建议认领 good-first-issue 标签的新手任务)。或者提交一个新的 Issue,描述您要修复的问题或者要增加、改进的功能。这样做的好处是能避免与其他人的工作重复

获取源码

要修改或新增功能,在提 Issue 或者领取现有 Issue 后,点击左上角的fork按钮,复制一份 Koupleless 或 SOFAArk 主干代码到您的代码仓库。

拉分支

Koupleless 和 SOFAArk 所有修改都在个人分支上进行,修改完后提交 pull request,当前在跑通 PR 流水线之后,会由相应组件的 PMC 或 Maintainer 负责 Review 与合并代码到主干(master)。因此,在 fork 源码后,您需要:

  • 下载代码到本地,这一步您可以选择 git/https 方式:
git clone https://github.com/您的账号名/koupleless.git
git clone https://github.com/您的账号名/sofa-ark.git
  • 拉分支准备修改代码:
git branch add_xxx_feature


执行完上述命令后,您的代码仓库就切换到相应分支了。执行如下命令可以看到您当前分支:

  git branch -a

如果您想切换回主干,执行下面命令:

  git checkout -b master

如果您想切换回分支,执行下面命令:

  git checkout -b "branchName"

修改代码提交到本地

拉完分支后,就可以修改代码了。

修改代码注意事项

  • 代码风格保持一致。Koupleless arklet 和 sofa-ark 通过 Maven 插件来保持代码格式一致,在提交代码前,务必先本地执行:
mvn clean compile
  • 补充单元测试代码。
  • 确保新修改通过所有单元测试。
  • 如果是 bug 修复,应该提供新的单元测试来证明以前的代码存在 bug,而新的代码已经解决了这些 bug。对于 arklet 和 sofa-ark 您可以用如下命令运行所有测试:
mvn clean test

对于 module-controller 和 arkctl 项目,您可以用如下命令运行所有测试:

make test

也可以通过 IDE 来辅助运行。

其它注意事项

  • 请保持您编辑的代码使用原有风格,尤其是空格换行等。
  • 对于无用的注释,请直接删除。注释必须使用英文。
  • 对逻辑和功能不容易被理解的地方添加注释。
  • 务必第一时间更新 docs/content/zh-cn/ 目录中的 “docs”、“contribution-guidelines” 目录中的相关文档。

修改完代码后,执行如下命令提交所有修改到本地:

git commit -am '添加xx功能'

提交代码到远程仓库

在代码提交到本地后,就是与远程仓库同步代码了。执行如下命令提交本地修改到 github 上:

git push origin "branchname"

如果前面您是通过 fork 来做的,那么这里的 origin 是 push 到您的代码仓库,而不是 Koupleless 的代码仓库。

提交合并代码到主干的请求

在的代码提交到 GitHub 后,您就可以发送请求来把您改好的代码合入 Koupleless 或 SOFAArk 主干代码了。此时您需要进入您的 GitHub 上的对应仓库,按右上角的 pull request按钮。选择目标分支,一般就是 master,当前需要选择组件的 MaintainerPMC 作为 Code Reviewer,如果 PR 流水线校验和 Code Review 都通过,您的代码就会合入主干成为 Koupleless 的一部分。

PR 流水线校验

PR 流水线校验包括:

  1. CLA 签署。第一次提交 PR 必须完成 CLA 协议的签署,如果打不开 CLA 签署页面请尝试使用代理。
  2. 自动为每个文件追加 Apache 2.0 License 声明和作者。
  3. 执行全部单元测试且必须全部通过。
  4. 检测覆盖率是否达到行覆盖 >= 80%,分支覆盖 >= 60%。
  5. 检测提交的代码是否存在安全漏洞。
  6. 检测提交的代码是否符合基本代码规范。

以上校验必须全部通过,PR 流水线才会通过并进入到 Code Review 环节。

Code Review

当您选择对应组件的 MaintainerPMC 作为 Code Reviewer 数天后,仍然没有人对您的提交给予任何回复,可以在 PR 下面留言并 at 相关人员,或者在社区钉钉协作群中(钉钉群号:24970018417)直接 at 相关人员 Review 代码。对于 Code Review 的意见,Code Reviewer 会直接备注到到对应的 PR 或者 Issue 中,如果您觉得建议是合理的,也请您把这些建议更新到您的代码中并重新提交 PR。

合并代码到主干

在 PR 流水线校验和 Code Review 都通过后,就由 Koupleless 维护人员操作合入主干了,代码合并之后您会收到合并成功的提示。


5.3.3 - 6.3.3 文档、Issue、流程贡献

Koupleless 文档、Issue、流程贡献

文档贡献

使用文档、技术文档、官网内容需要社区每一位 Contributor 共同维护,对任意文档和官网内容做出贡献的同学都是我们的 Contributor,并且根据活跃度有机会成为 Koupleless 组件的 Committer 甚至 PMC 成员,共同主导 Koupleless 的技术演进。

Issue 提交与回复贡献

任何使用过程中的问题、Bug、新功能、改进优化请创建 GitHub Issue,社区每天会有值班同学负责跟进 Issue。任何人提出或者回复 Issue 都是 Koupleless 的 Contributor,对回复 Issue 活跃的 Contributor 可以晋升为 Committer,如果特别活跃甚至可以晋升为 PMC 成员,共同主导 Koupleless 的技术演进。

Issue 模板

Koupleless(含 SOFAArk)Issue 有两种模板,一种是 “Question or Bug Report”,一种是 “Feature Request”。
image.png

Question or Bug Report

所有使用过程中遇到的问题或者疑似 Bug,请选择 “Question or Bug Report”,并提供详细的复现信息如下:

### Describe the question or bug

A clear and concise description of what the question or bug is.

### Expected behavior

A clear and concise description of what you expected to happen.

### Actual behavior

A clear and concise description of what actually happened.

### Steps to reproduce

Steps to reproduce the problem:

1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

### Screenshots

If applicable, add screenshots to help explain your problem.

### Minimal yet complete reproducer code (or GitHub URL to code)

### Environment

- SOFAArk version:
- JVM version (e.g. `java -version`):
- OS version (e.g. `uname -a`):
- Maven version:
- IDE version:

Feature Request

新功能、已有功能改进优化或者其它讨论,请选择 “Feature Request”。

流程贡献

Koupleless 当前制定了代码规约、PR 流程、CI 流水线、迭代管理、周会、交流渠道等各种协作规范,您可以对我们的协作规范和流程在 GitHub 上提出建议,即可成为我们的 Contributor。



5.3.4 - 6.3.4 组织会议和运营布道

Koupleless 组织会议和运营布道

我们鼓励大家宣传、布道 Koupleless,通过运营成为 Koupleless 的 Contributor、Committer 甚至 PMC,每一次 Contributor 的晋升,我们也会发放纪念品奖励。运营方式包括但不限于:

  1. 在线上或线下技术会议、Meetup 中发表 Koupleless 的使用或者技术实现相关演讲。
  2. 与其他企业分享交流 Koupleless 的使用场景等。
  3. 在各种渠道发表关于 Koupleless 的使用或者技术实现相关文章或视频。
  4. 其它运营方式。

5.4 - 6.4 社区角色与晋升

Koupleless 社区角色与晋升

角色职责与晋升机制

Koupleless 社区角色参考了 Apache 开源产品组织方式,SOFAArk、Arklet、ModuleController、ArkCtl 每个组件都有各自的角色。每个组件的角色职责从低到高分别是:Contributor、Committer、PMC (Project Management Committee)、Maintainer

角色责任与权限晋升到更高角色机制
Contributor所有在社区提 Issue、回答 Issue、对外运营、提交文档内容或者提交任意代码的同学,都是相应组件的 Contributor。Contributor 拥有 Issue 提交、Issue 回复、官网或文档内容提交、代码提交(不包括代码评审)和对外发表文章权限。当 Contributor 完成合并的代码或者文档内容足够多,就可以由该组件的 PMC 成员投票晋升为 Committer。当 Contributor 回答的 Issue 或者参与的运营活动足够多,也可以被 PMC 成员投票晋升为 Committer。
Committer所有在社区积极回答 Issue、对外运营、提交文档内容或者提交代码的同学,按积极度都有可能被 PMC 成员投票晋升为 Committer。Committer 额外拥有代码评审、技术方案评审、Contributor 培养的责任与权限。对长期积极投入或持续有突出贡献的 Committer,经 PMC 成员投票可以晋升为相应组件的 PMC 成员。
PMC对相应组件持续贡献特别活跃的同学有机会晋升为 PMC 成员。PMC 成员额外拥有组件的 RoadMap 制定、技术方案和代码评审、Issue 和迭代管理、Contributor 和 Committer 培养等责任与权限。
MaintainerMaintainer 额外拥有密钥管理和仓库管理等管理员权限,除此之外在其他方面和 PMC 成员的责任与权限是完全对等的。

社区角色成员名单

SOFAArk

Maintainer

yuanyuancin
lvjing2

PMC (Project Management Comittee)

glmapper

Committer

zjulbj5
gaosaroma
QilongZhang133
straybirdzls13
caojie0911

Contributor

lylingzhen10
khotyn
FlyAbner (260+ 行提交,提名 Comitter?)
alaneuler
sususama
ujjboy
JoeKerouac
Lunarscave
HzjNeverStop
AiWu4Damon
vchangpengfei
HuangDayu
shenchao45
DalianRollingKing
nobodyiam
lanicc
azhsmesos
wuqian0808
KangZhiDong
suntao4019
huangyunbin
jiangyunpeng
michalyao
rootsongjc
Zwl0113
tofdragon
lishiguang4
hionwi
343585776
g-stream
zkitcast
davidzj
zyclove
WindSearcher
lovejin52022
smalljunHw
vchangpengfei
sq1015
xwh1108
yuanChina
blysin
yuwenkai666
hadoop835
gitYupan
thirdparty-core
Estom
jijuanwang
DCLe-DA
linkoog
springcoco
zhaowwwjian
xingcici
ixufeng
jnan806
lizhi12q
kongqq
wangxiaotao00
由于篇幅有限,23 年之前提交 Issue 的 Contributor 不在此一一列举,也同样感谢大家对 SOFAArk 的使用和咨询

Arklet

Maintainer

yuanyuancin
lvjing2

PMC (Project Management Committee)

TomorJM

Committer

暂无

Contributor

glmapper
Lunarscave
lylingzhen

ModuleController

Maintainer

CodeNoobKing

PMC (Project Management Committee)

暂无

Committer

暂无

Contributor

liu-657667
Charlie17Li
lylingzhen

Arkctl

Maintainer

yuanyuancin
lvjing2

PMC (Project Management Committee)

暂无

Committer

暂无

Contributor

暂无


5.5 - 6.5 技术原理

5.5.2 - 6.5.2 Arklet 技术文档

Overview

概述

Arklet 提供了 SOFAArk 基座和模块的运维接口,通过 Arklet 可以轻松灵活的进行 Ark Biz 的发布和运维。

Arklet 内部由 ArkletComponent 构成

image

  • ApiClient: The core components responsible for interacting with the outside world
  • ApiClient: 负责与外界交互的核心组件
  • CommandService: Arklet 暴露能力指令定义和扩展
  • OperationService: Ark Biz 与 SOFAArk 交互,增删改查,封装基础能力
  • HealthService: 基于基座、模块系统等指标统计健康和稳定性

这些组件之间的关联关系如下图 overview

当然,您也可以通过实现 ArkletComponent 接口来扩展 Arklet 的组件功能。

Command Extension

指令扩展

Arklet 通过外部暴露指令 API,通过每个 API 映射的 CommandHandler 内部处理指令。

CommandHandler 相关扩展属于 CommandService 组件统一管理

你可以通过继承 AbstractCommandHandler 来自定义扩展指令

内置指令 API

下面所有的指令 API 都是通过 POST(application/json) 请求格式访问 arklet

使用的是 http 协议,1238 端口

你可以通过设置 koupleless.arklet.http.port JVM 启动参数覆盖默认端口

查询支持的指令

  • URL: 127.0.0.1:1238/help
  • input sample:
{}
  • output sample:
{
    "code":"SUCCESS",
    "data":[
        {
            "desc":"query all ark biz(including master biz)",
            "id":"queryAllBiz"
        },
        {
            "desc":"list all supported commands",
            "id":"help"
        },
        {
            "desc":"uninstall one ark biz",
            "id":"uninstallBiz"
        },
        {
            "desc":"switch one ark biz",
            "id":"switchBiz"
        },
        {
            "desc":"install one ark biz",
            "id":"installBiz"
        }
    ]
}

安装一个模块

  • URL: 127.0.0.1:1238/installBiz
  • 输入例子:
{
    "bizName": "test",
    "bizVersion": "1.0.0",
    // local path should start with file://, alse support remote url which can be downloaded
    "bizUrl": "file:///Users/jaimezhang/workspace/github/sofa-ark-dynamic-guides/dynamic-provider/target/dynamic-provider-1.0.0-ark-biz.jar"
}
  • 输出例子(success):
{
  "code":"SUCCESS",
  "data":{
    "bizInfos":[
      {
        "bizName":"dynamic-provider",
        "bizState":"ACTIVATED",
        "bizVersion":"1.0.0",
        "declaredMode":true,
        "identity":"dynamic-provider:1.0.0",
        "mainClass":"io.sofastack.dynamic.provider.ProviderApplication",
        "priority":100,
        "webContextPath":"provider"
      }
    ],
    "code":"SUCCESS",
    "message":"Install Biz: dynamic-provider:1.0.0 success, cost: 1092 ms, started at: 16:07:47,769"
  }
}
  • 输出例子(failed):
{
  "code":"FAILED",
  "data":{
    "code":"REPEAT_BIZ",
    "message":"Biz: dynamic-provider:1.0.0 has been installed or registered."
  }
}

卸载一个模块

  • URL: 127.0.0.1:1238/uninstallBiz
  • 输入例子:
{
    "bizName":"dynamic-provider",
    "bizVersion":"1.0.0"
}
  • 输出例子(success):
{
  "code":"SUCCESS"
}
  • 输出例子(failed):
{
  "code":"FAILED",
  "data":{
    "code":"NOT_FOUND_BIZ",
    "message":"Uninstall biz: test:1.0.0 not found."
  }
}

切换一个模块

  • URL: 127.0.0.1:1238/switchBiz
  • 输入例子:
{
    "bizName":"dynamic-provider",
    "bizVersion":"1.0.0"
}
  • 输出例子:
{
  "code":"SUCCESS"
}

查询所有模块

  • URL: 127.0.0.1:1238/queryAllBiz
  • 输入例子:
{}
  • 输出例子:
{
  "code":"SUCCESS",
  "data":[
    {
      "bizName":"dynamic-provider",
      "bizState":"ACTIVATED",
      "bizVersion":"1.0.0",
      "mainClass":"io.sofastack.dynamic.provider.ProviderApplication",
      "webContextPath":"provider"
    },
    {
      "bizName":"stock-mng",
      "bizState":"ACTIVATED",
      "bizVersion":"1.0.0",
      "mainClass":"embed main",
      "webContextPath":"/"
    }
  ]
}

查询健康与状态信息

  • URL: 127.0.0.1:1238/health

查询所有健康与状态信息

  • 输入信息:
{}
  • 输出信息:
{
  "code": "SUCCESS",
  "data": {
    "healthData": {
      "jvm": {
        "max non heap memory(M)": -9.5367431640625E-7,
        "java version": "1.8.0_331",
        "max memory(M)": 885.5,
        "max heap memory(M)": 885.5,
        "used heap memory(M)": 137.14127349853516,
        "used non heap memory(M)": 62.54662322998047,
        "loaded class count": 10063,
        "init non heap memory(M)": 2.4375,
        "total memory(M)": 174.5,
        "free memory(M)": 37.358726501464844,
        "unload class count": 0,
        "total class count": 10063,
        "committed heap memory(M)": 174.5,
        "java home": "****\\jre",
        "init heap memory(M)": 64.0,
        "committed non heap memory(M)": 66.203125,
        "run time(s)": 34.432
      },
      "cpu": {
        "count": 4,
        "total used (%)": 131749.0,
        "type": "****",
        "user used (%)": 9.926451054656962,
        "free (%)": 81.46475495070172,
        "system used (%)": 6.249762806548817
      },
      "masterBizInfo": {
        "webContextPath": "/",
        "bizName": "bookstore-manager",
        "bizState": "ACTIVATED",
        "bizVersion": "1.0.0"
      },
      "pluginListInfo": [
        {
          "artifactId": "web-ark-plugin",
          "groupId": "com.alipay.sofa",
          "pluginActivator": "com.alipay.sofa.ark.web.embed.WebPluginActivator",
          "pluginName": "web-ark-plugin",
          "pluginUrl": "file:/****/2.2.3-SNAPSHOT/web-ark-plugin-2.2.3-20230901.090402-2.jar!/",
          "pluginVersion": "2.2.3-SNAPSHOT"
        },
        {
          "artifactId": "runtime-sofa-boot-plugin",
          "groupId": "com.alipay.sofa",
          "pluginActivator": "com.alipay.sofa.runtime.ark.plugin.SofaRuntimeActivator",
          "pluginName": "runtime-sofa-boot-plugin",
          "pluginUrl": "file:/****/runtime-sofa-boot-plugin-3.11.0.jar!/",
          "pluginVersion": "3.11.0"
        }
      ],
      "masterBizHealth": {
        "readinessState": "ACCEPTING_TRAFFIC"
      },
      "bizListInfo": [
        {
          "bizName": "bookstore-manager",
          "bizState": "ACTIVATED",
          "bizVersion": "1.0.0",
          "webContextPath": "/"
        }
      ]
    }
  }
}

查询系统健康与状态信息

  • 输入例子:
{
  "type": "system",
  // [OPTIONAL] if metrics is null -> query all system health info
  "metrics": ["cpu", "jvm"]
}
  • 输出例子:
{
  "code": "SUCCESS",
  "data": {
    "healthData": {
      "jvm": {...},
      "cpu": {...},
//      "masterBizHealth": {...}
    }
  }
}

查询模块健康与状态信息

  • 输入例子:
{
  "type": "biz",
  // [OPTIONAL] if moduleName is null and moduleVersion is null -> query all biz
  "moduleName": "bookstore-manager",
  // [OPTIONAL] if moduleVersion is null -> query all biz named moduleName
  "moduleVersion": "1.0.0"
}
  • 输出例子:
{
  "code": "SUCCESS",
  "data": {
    "healthData": {
      "bizInfo": {
        "bizName": "bookstore-manager",
        "bizState": "ACTIVATED",
        "bizVersion": "1.0.0",
        "webContextPath": "/"
      }
//      "bizListInfo": [
//        {
//          "bizName": "bookstore-manager",
//          "bizState": "ACTIVATED",
//          "bizVersion": "1.0.0",
//          "webContextPath": "/"
//        }
//      ]
    }
  }
}

查询插件健康与状态信息

  • 输入例子:
{
  "type": "plugin",
  // [OPTIONAL] if moduleName is null -> query all biz
  "moduleName": "web-ark-plugin"
}
  • 输出例子:
{
  "code": "SUCCESS",
  "data": {
    "healthData": {
      "pluginListInfo": [
        {
          "artifactId": "web-ark-plugin",
          "groupId": "com.alipay.sofa",
          "pluginActivator": "com.alipay.sofa.ark.web.embed.WebPluginActivator",
          "pluginName": "web-ark-plugin",
          "pluginUrl": "file:/****/web-ark-plugin-2.2.3-20230901.090402-2.jar!/",
          "pluginVersion": "2.2.3-SNAPSHOT"
        }
      ]
    }
  }
}

使用 Endpoint 来查询健康信息

使用 endpoint 来查询 k8s 模块的健康信息

** 默认配置 **

  • endpoints path: /
  • endpoints 服务端口: 8080

** http 结果码 **

  • HEALTHY(200): 所有健康指标都健康
  • UNHEALTHY(400): 至少有一个健康指标已经不健康
  • ENDPOINT_NOT_FOUND(404): 路径或参数不存在
  • ENDPOINT_PROCESS_INTERNAL_ERROR(500): 遇到异常

查询所有健康信息

  • url: 127.0.0.1:8080/arkletHealth
  • method: GET
  • 输出例子
{   
    "healthy": true,
    "code": 200,    
    "codeType": "HEALTHY",    
    "data": {        
        "jvm": {...},        
        "masterBizHealth": {...},        
        "cpu": {...},        
        "masterBizInfo": {...},        
        "bizListInfo": [...],        
        "pluginListInfo": [...]    
    }
}  

查询所有模块的健康信息

  • url: 127.0.0.1:8080/arkletHealth/{moduleType} (moduleType must in [‘biz’, ‘plugin’])
  • method: GET
  • 输出例子
{   
   "healthy": true,
   "code": 200,    
   "codeType": "HEALTHY",    
   "data": {        
       "bizListInfo": [...],  
       // "pluginListInfo": [...]      
   }
}  

查询单个模块的健康信息

  • url: 127.0.0.1:8080/arkletHealth/{moduleType}/moduleName/moduleVersion (moduleType must in [‘biz’, ‘plugin’])
  • method: GET
  • 输出例子
{   
   "healthy": true,
   "code": 200,    
   "codeType": "HEALTHY",    
   "data": {        
       "bizInfo": {...},  
       // "pluginInfo": {...}      
   }
}  

5.5.3 - 6.5.3 多模块运行时适配或最佳实践

5.5.3.1 - 6.5.3.1 Koupleless 多应用治理补丁治理

Koupleless 多应用治理补丁治理

Koupleless 为什么需要多应用治理补丁?

Koupleless 是一种多应用的架构,而传统的中间件可能只考虑了一个应用的场景,故在一些行为上无法兼容多应用共存的行为,会发生共享变量污染、classLoader 加载异常、class 判断不符合预期等问题。 由此,在使用 Koupleless 中间件时,我们需要对一些潜在的问题做补丁,覆盖掉原有中间件的实现,使开源的中间件也能兼容多应用的模式。

Koupleless 多应用治理补丁方案调研

在多应用兼容性治理中,我们不仅仅只考虑生产部署,还要考虑用户本地开发的兼容性(IDEA 点击 Debug),单测编写的兼容性(如 @SpringbootTest)等等。


下面是不同方案的对比表格。

方案对比

方案名接入成本可维护性部署兼容性IDE 兼容性单测兼容性
A:将补丁包的依赖放在 maven dependency 的首部,以此保证补丁类能优先被 classLoader 加载。低。
用户只需要控制 maven 家在的顺序。

用户需要严格保证相关依赖在最前面,且启动的时候不手动传入 classpath。
兼容✅兼容✅兼容✅
B:通过 maven 插件修改 springboot 构建产物的索引文件的顺序。低。
只需要新增一个 package 周期的 maven 插件即可,用户感知低。

用户需要保证启动的时候不手动传入 classpath。
兼容✅不兼容❌
jetbrains 无法兼容,jetbrains 会自己构建 cli 命令行把 classpath 按照 maven 依赖的顺序传进去,这会导致 adapter 的顺序加载不一定是最优先的。
不兼容❌
单测不走 repackage 周期,不依赖 classpath.idx 文件。
C:新增自定义的 springboot 的 jarlaunch 启动器,通过启动器控制 classLoader 加载的行为。高。
需要用户修改自己的基座启动逻辑,使用 Koupleless 自定义的 jarlaunch。

自定义的 jarlaunch 可以通过钩子控制代码的加载顺序。
兼容✅兼容✅
但需要配置 IDE 使用自定义的 jarlaunch。
不兼容❌
因为单测不会走 jarlaunch 逻辑。
D:增强基座的 classloader, 保证优先搜索和加载补丁类。高。
用户需要初始化增强的代码,且该模式对 sofa-ark 识别 master biz 的逻辑也有侵入,需要改造支持。

基座的 classloader 可以编程化地控制依赖加载的顺序。
兼容✅兼容✅兼容✅
E:通过 maven 插件配置配置拷贝补丁类代码到当前项目中, 当前项目的文件会被优先加载。高。
maven 目前的拷贝插件无法用通配符,所以接入一个 adapter 就得多一个配置。

用户只要配置了,就可以保证依赖有限被加载(因为本地项目的类最优先被加载)。
兼容✅兼容✅不兼容❌
因为单测不会走到 package 周期,而 maven 的拷贝插件是在 package 周期生效的。

结论

综合地来看,没有办法完全做到用户 0 感知接入,每个方法都需要微小程度的业务改造。 在诸多方案中,A 和 D 能做到完全兼容,不过 A 方案不需要业务改代码,也不会侵入运行时逻辑,仅需要用户在 maven dependency 的第一行中加入如下依赖:

<dependency>
  <groupId>com.alipay.sofa.koupleless</groupId>
  <artifactId>koupleless-base-starter</artifactId>
  <version>${koupleless.runtime.version}</version>
  <type>pom</type>
</dependency>

故我们将采取方案 A。
如果你有更多的想法,或输入,欢迎开源社区讨论!

5.5.3.2 - 6.5.3.2 多模块集成测试框架介绍

本文着重介绍多模块继承测试框架的设计思路、实现细节、使用方式。

为什么需要多模块集成测试框架?

假设没有集成测试框架,当开发者想要验证多模块部署的进程是否行为正确时,开发者需要进行如下步骤:

  1. 构建基座和所有模块的 jar 包。
  2. 启动基座进程。
  3. 安装模块 jar 包到基座中。
  4. 进行 HTTP / RPC 接口的掉用。
  5. 验证返回结果是否正确。

上述工作流看起来简单,但是开发者面临如下困扰:

  1. 反复在命令行和代码中来回切换。
  2. 如果验证结果不正确,还需要反复修改代码和重新构建 + 远程 debug。
  3. 如果 APP 本来只提供内部方法,为了验证多模块部署的行为,还需要修改代码通过 HTTP / RPC 暴露接口。

上述困扰导致开发者的效率低下,体验不友好。
因此,我们需要一个集成测试框架来提供一站式的验证体验。

集成测试框架需要解决哪些问题?

集成测试框架需要能在同一个进程中,通过一次启动,模拟多模块部署的行为。 同时也允许开发者直接对模块 / 基座进行直接的代码调用,验证模块的行为是否正确。 这需要解决如下几个技术问题:

  1. 模拟基座 springboot 的启动。
  2. 模拟模块 springboot 的启动,同时支持直接从 dependency 中而非 jar 包中加载模块。
  3. 模拟 ark-plugin 的加载。
  4. 和 maven 的测试命令集成兼容。

由于默认的 sofa-ark 是通过 jar 包的方式加载模块的 executable-jar 包和 ark-plugin。 而显然,这会需要开发者在每次验证时都需要重新构建 jar 包 / 发布到仓库,降低验证效率。 所以,框架需要能够拦截掉对应的加载行为,直接从 maven 依赖中加载模块,模拟多模块部署的行为。
完成相关工作的代码有:

  1. TestBizClassLoader: 完成模拟 biz 模块的加载工作,是原来 BizClassLoader 的派生类, 解决了在同一个 jar 包下按需加载类到不同的 ClassLoader 的问题。
  2. TestBiz: 完成模拟 biz 模块的启动工作,是原来 Biz 的派生类,封装了初始化 TestBizClassLoader 的逻辑。
  3. TestBootstrap: 完成 ArkContainer 的初始化,并完成 ark-plugin 的加载等。
  4. TestClassLoaderHook: 通过 Hook 机制控制 resource 的加载顺序,例如 biz jar 包中的 application.properties 会被优先加载。
  5. BaseClassLoader: 模拟正常的基座 ClassLoader 行为,会和 surefire 等测试框架进行适配。
  6. TestMultiSpringApplication: 模拟多模块的 springboot 启动行为。

如何使用集成测试框架?

在同一个进程中同时启动基座和模块 springboot

样例代码如下:

public void demo() {
    new TestMultiSpringApplication(MultiSpringTestConfig
            .builder()
            .baseConfig(BaseSpringTestConfig
                    .builder()
                    .mainClass(BaseApplication.class) // 基座的启动类
                    .build())
            .bizConfigs(Lists.newArrayList(
                    BizSpringTestConfig
                            .builder()
                            .bizName("biz1") // 模块1的名称
                            .mainClass(Biz1Application.class) // 模块1的启动类
                            .build(),
                    BizSpringTestConfig
                            .builder()
                            .bizName("biz2") // 模块2的名称
                            .mainClass(Biz2Application.class) // 模块2的启动类
                            .build()
            ))
            .build()
    ).run();
}

进行 Assert 逻辑的编写

可以通过如下方式获取模块的服务:

public void getService() {
    StrategyService strategyService = SpringServiceFinder.
            getModuleService(
                    "biz1-web-single-host",
                    "0.0.1-SNAPSHOT",
                    "strategyServiceImpl",
                    StrategyService.class
            );
}

获取到服务后,可以进行断言逻辑的编写。

用例参考

更完整的用例可以参考 tomcat 多模块集成测试用例

5.5.3.3 - 6.5.3.3 dubbo2.7 的多模块化适配

为什么需要做适配

原生 dubbo2.7 在多模块场景下,无法支持模块发布自己的dubbo服务,调用时存在序列化、类加载异常等一系列问题。

多模块适配方案

dubbo2.7多模块适配SDK 在基座构建时 koupleless-base-build-plugin 会自动将 patch 代码打包到基座代码里,该适配逻辑主要从类加载、服务发布、服务卸载、服务隔离、模块维度服务管理、配置管理、序列化等方面进行适配。

1. AnnotatedBeanDefinitionRegistryUtils使用基座classloader无法加载模块类

com.alibaba.spring.util.AnnotatedBeanDefinitionRegistryUtils#isPresentBean

public static boolean isPresentBean(BeanDefinitionRegistry registry, Class<?> annotatedClass) {
    ...

    //        ClassLoader classLoader = annotatedClass.getClassLoader(); // 原生逻辑
    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();   // 改为使用tccl加载类

    for (String beanName : beanNames) {
        BeanDefinition beanDefinition = registry.getBeanDefinition(beanName);
        if (beanDefinition instanceof AnnotatedBeanDefinition) {
            ...
            String className = annotationMetadata.getClassName();
            Class<?> targetClass = resolveClassName(className, classLoader);
            ...
        }
    }

    return present;
}

2. 模块维度的服务、配置资源管理

  1. com.alipay.sofa.koupleless.support.dubbo.ServerlessServiceRepository 替代原生 org.apache.dubbo.rpc.model.ServiceRepository

原生service采用interfaceName作为缓存,在基座、模块发布同样interface,不同group服务时,无法区分,替代原生service缓存模型,采用Interface Class类型作为key,同时采用包含有group的path作为key,支持基座、模块发布同interface不同group的场景

private static ConcurrentMap<Class<?>, ServiceDescriptor> globalClassServices = new ConcurrentHashMap<>();

private static ConcurrentMap<String, ServiceDescriptor>   globalPathServices  = new ConcurrentHashMap<>();
  1. com.alipay.sofa.koupleless.support.dubbo.ServerlessConfigManager 替代原生 org.apache.dubbo.config.context.ConfigManager

    为原生config添加classloader维度的key,不同模块根据classloader隔离不同的配置

final Map<ClassLoader, Map<String, Map<String, AbstractConfig>>> globalConfigsCache = new HashMap<>();

public void addConfig(AbstractConfig config, boolean unique) {
    ...
    write(() -> {
        Map<String, AbstractConfig> configsMap = getCurrentConfigsCache().computeIfAbsent(getTagName(config.getClass()), type -> newMap());
        addIfAbsent(config, configsMap, unique);
    });
}
private Map<String, Map<String, AbstractConfig>> getCurrentConfigsCache() {
    ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();   // 根据当前线程classloader隔离不同配置缓存
    globalConfigsCache.computeIfAbsent(contextClassLoader, k -> new HashMap<>());
    return globalConfigsCache.get(contextClassLoader);
}

ServerlessServiceRepository 和 ServerlessConfigManager 都依赖 dubbo ExtensionLoader 的扩展机制,从而替代原生逻辑,具体原理可参考 org.apache.dubbo.common.extension.ExtensionLoader.createExtension

3. 模块维度服务发布、服务卸载

override DubboBootstrapApplicationListener 禁止原生dubbo模块启动、卸载时发布、卸载服务

  • com.alipay.sofa.koupleless.support.dubbo.BizDubboBootstrapListener

原生dubbo2.7只在基座启动完成后发布dubbo服务,在多模块时,无法支持模块的服务发布,Ark采用监听器监听模块启动事件,并手动调用dubbo进行模块维度的服务发布

private void onContextRefreshedEvent(ContextRefreshedEvent event) {
  try {
      ReflectionUtils.getMethod(DubboBootstrap.class, "exportServices")
          .invoke(dubboBootstrap);
      ReflectionUtils.getMethod(DubboBootstrap.class, "referServices").invoke(dubboBootstrap);
  } catch (Exception e) {
      
  }
}

原生dubbo2.7在模块卸载时会调用DubboShutdownHook,将JVM中所有dubbo service unexport,导致模块卸载后基座、其余模块服务均被卸载,Ark采用监听器监听模块spring上下文关闭事件,手动卸载当前模块的dubbo服务,保留基座、其余模块的dubbo服务

private void onContextClosedEvent(ContextClosedEvent event) {
        // DubboBootstrap.unexportServices 会 unexport 所有服务,只需要 unexport 当前 biz 的服务即可
        Map<String, ServiceConfigBase<?>> exportedServices = ReflectionUtils.getField(dubboBootstrap, DubboBootstrap.class, "exportedServices");

        Set<String> bizUnexportServices = new HashSet<>();
        for (Map.Entry<String, ServiceConfigBase<?>> entry : exportedServices.entrySet()) {
            String serviceKey = entry.getKey();
            ServiceConfigBase<?> sc = entry.getValue();
            if (sc.getRef().getClass().getClassLoader() == Thread.currentThread().getContextClassLoader()) {   // 根据ref服务实现的类加载器区分模块服务
                bizUnexportServices.add(serviceKey);
                configManager.removeConfig(sc);   // 从configManager配置管理中移除服务配置
                sc.unexport();   // 进行服务unexport
                serviceRepository.unregisterService(sc.getUniqueServiceName());   // 从serviceRepository服务管理中移除配置
            }
        }
        for (String service : bizUnexportServices) {
            exportedServices.remove(service);    // 从DubboBootstrap中移除该service
        }
    }

4. 服务路由

  • com.alipay.sofa.koupleless.support.dubbo.ConsumerRedefinePathFilter

dubbo服务调用时通过path从ServiceRepository中获取正确的服务端服务模型(包括interface、param、return类型等)进行服务调用、参数、返回值的序列化,原生dubbo2.7采用interfaceName作为path查找service model,无法支持多模块下基座模块发布同interface的场景,Ark自定义consumer端filter添加group信息到path中,以便provider端进行正确的服务路由

public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
  if (invocation instanceof RpcInvocation) {
      RpcInvocation rpcInvocation = (RpcInvocation) invocation;
      // 原生path为interfaceName,如com.alipay.sofa.rpc.dubbo27.model.DemoService
      // 修改后path为serviceUniqueName,如masterBiz/com.alipay.sofa.rpc.dubbo27.model.DemoService
      rpcInvocation.setAttachment("interface", rpcInvocation.getTargetServiceUniqueName());   // 原生path为interfaceName,如
  }
  return invoker.invoke(invocation);
}

5. 序列化

  • org.apache.dubbo.common.serialize.java.JavaSerialization
  • org.apache.dubbo.common.serialize.java.ClassLoaderJavaObjectInput
  • org.apache.dubbo.common.serialize.java.ClassLoaderObjectInputStream

在获取序列化工具JavaSerialization时,使用ClassLoaderJavaObjectInput替代原生JavaObjectInput,传递provider端service classloader信息

// org.apache.dubbo.common.serialize.java.JavaSerialization
public ObjectInput deserialize(URL url, InputStream is) throws IOException {
    return new ClassLoaderJavaObjectInput(new ClassLoaderObjectInputStream(null, is));   // 使用ClassLoaderJavaObjectInput替代原生JavaObjectInput,传递provider端service classloader信息
}

// org.apache.dubbo.common.serialize.java.ClassLoaderObjectInputStream
private ClassLoader classLoader;

public ClassLoaderObjectInputStream(final ClassLoader classLoader, final InputStream inputStream) {
  super(inputStream);
  this.classLoader = classLoader;
}
  • org.apache.dubbo.rpc.protocol.dubbo.DecodeableRpcInvocation 服务端反序列化参数
// patch begin
if (in instanceof ClassLoaderJavaObjectInput) {
   InputStream is = ((ClassLoaderJavaObjectInput) in).getInputStream();
   if (is instanceof ClassLoaderObjectInputStream) {
      ClassLoader cl = serviceDescriptor.getServiceInterfaceClass().getClassLoader();  // 设置provider端service classloader信息到ClassLoaderObjectInputStream中
      ((ClassLoaderObjectInputStream) is).setClassLoader(cl);
   }
}
// patch end
  • org.apache.dubbo.rpc.protocol.dubbo.DecodeableRpcResult 客户端反序列化返回值
// patch begin
if (in instanceof ClassLoaderJavaObjectInput) {
   InputStream is = ((ClassLoaderJavaObjectInput) in).getInputStream();
   if (is instanceof ClassLoaderObjectInputStream) {
       ClassLoader cl = invocation.getInvoker().getInterface().getClassLoader(); // 设置consumer端service classloader信息到ClassLoaderObjectInputStream中
       ((ClassLoaderObjectInputStream) is).setClassLoader(cl);
   }
}
// patch end

多模块 dubbo2.7 使用样例

多模块 dubbo2.7 使用样例

dubbo2.7多模块适配sdk源码

5.5.3.4 - 6.5.3.4 ehcache 的多模块化最佳实践

Koupleless ehcache 的多模块化最佳实践

为什么需要最佳实践

CacheManager 初始化的时候存在共用 static 变量,多应用使用相同的 ehcache name,导致缓存互相覆盖。

最佳实践的几个要求

  1. 基座里必须引入 ehcache,模块里复用基座

在 springboot 里 ehcache 的初始化需要通过 Spring 里定义的 EhCacheCacheConfiguration 来创建,由于 EhCacheCacheConfiguration 是属于 Spring, Spring 统一放在基座里。

这里在初始化的时候,在做 Bean 初始化的条件判断时会走到类的检验,

如果 net.sf.ehcache.CacheManager 是。这里会走到 java native 方法上做判断,从当前类所在的 ClassLoader 里查找 net.sf.ehcache.CacheManager 类,所以基座里必须引入这个依赖,否则会报 ClassNotFound 的错误。

  1. 模块里将引入的 ehcache 排包掉(scope设置成 provide,或者使用自动瘦身能力)

模块使用自己 引入的 ehcache,照理可以避免共用基座 CacheManager 类里的 static 变量,而导致报错的问题。但是实际测试发现,模块安装的时候,在初始化 enCacheCacheManager 时,

这里在 new 对象时,需要先获得对象所属类的 CacheManager 是基座的 CacheManager。这里也不能讲 CacheManager 由模块 compile 引入,否则会出现一个类由多个不同 ClassLoader 引入导致的问题。

所以结论是,这里需要全部委托给基座加载。

最佳实践的方式

  1. 模块 ehcache 排包瘦身委托给基座加载
  2. 如果多个模块里有多个相同的 cacheName,需要修改 cacheName 为不同值。
  3. 如果不想改代码的方式修改 cache name,可以通过打包插件的方式动态替换 cacheName
 <plugin>
    <groupId>com.google.code.maven-replacer-plugin</groupId>
    <artifactId>replacer</artifactId>
    <version>1.5.3</version>
    <executions>
        <!-- 打包前进行替换 -->
        <execution>
            <phase>prepare-package</phase>
            <goals>
                <goal>replace</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <!-- 自动识别到项目target文件夹 -->
        <basedir>${build.directory}</basedir>
        <!-- 替换的文件所在目录规则 -->
        <includes>
            <include>classes/j2cache/*.properties</include>
        </includes>
        <replacements>
            <replacement>
                <token>ehcache.ehcache.name=f6-cache</token>
                <value>ehcache.ehcache.name=f6-${parent.artifactId}-cache</value>
            </replacement>

        </replacements>
    </configuration>
</plugin>
  1. 需要把 FactoryBean 的 shared 设置成 false
@Bean
    public EhCacheManagerFactoryBean ehCacheManagerFactoryBean() {
        EhCacheManagerFactoryBean factoryBean = new EhCacheManagerFactoryBean();

        // 需要把 factoryBean 的 share 属性设置成 false
        factoryBean.setShared(true);
//        factoryBean.setShared(false);
        factoryBean.setCacheManagerName("biz1EhcacheCacheManager");
        factoryBean.setConfigLocation(new ClassPathResource("ehcache.xml"));
        return factoryBean;
    }

否则会进入这段逻辑,初始化 CacheManager 的static 变量 instance. 该变量如果有值,且如果模块里 shared 也是ture 的化,就会重新复用 CacheManager 的 instance,从而拿到基座的 CacheManager, 从而报错。

最佳实践的样例

样例工程请参考这里

5.5.3.5 - 6.5.3.5 logback 的多模块化适配

为什么需要做适配

原生 logback 只有默认日志上下文,各个模块间日志配置无法隔离,无法支持独立的模块日志配置,最终导致在合并部署多模块场景下,模块只能使用基座的日志配置,对模块日志打印带来不便。

多模块适配方案

Logback 支持原生扩展 ch.qos.logback.classic.selector.ContextSelector,该接口支持自定义上下文选择器,Ark 默认实现了 ContextSelector 对多个模块的 LoggerContext 进行隔离 (参考 com.alipay.sofa.ark.common.adapter.ArkLogbackContextSelector),不同模块使用各自独立的 LoggerContext,确保日志配置隔离

启动期,经由 spring 日志系统 LogbackLoggingSystem 对模块日志配置以及日志上下文进行初始化

指定上下文选择器为 com.alipay.sofa.ark.common.adapter.ArkLogbackContextSelector,添加JVM启动参数

-Dlogback.ContextSelector=com.alipay.sofa.ark.common.adapter.ArkLogbackContextSelector

当使用 slf4j 作为日志门面,logback 作为日志实现框架时,在基座启动时,首次进行 slf4j 静态绑定时,将初始化具体的 ContextSelector,当没有自定义上下文选择器时,将使用 DefaultContextSelector, 当我们指定上下文选择器时,将会初始化 ArkLogbackContextSelector 作为上下文选择器

ch.qos.logback.classic.util.ContextSelectorStaticBinder.init

public void init(LoggerContext defaultLoggerContext, Object key) {
  ...

  String contextSelectorStr = OptionHelper.getSystemProperty(ClassicConstants.LOGBACK_CONTEXT_SELECTOR);
  if (contextSelectorStr == null) {
  contextSelector = new DefaultContextSelector(defaultLoggerContext);
  } else if (contextSelectorStr.equals("JNDI")) {
  // if jndi is specified, let's use the appropriate class
  contextSelector = new ContextJNDISelector(defaultLoggerContext);
  } else {
  contextSelector = dynamicalContextSelector(defaultLoggerContext, contextSelectorStr);
  }
}

static ContextSelector dynamicalContextSelector(LoggerContext defaultLoggerContext, String contextSelectorStr) {
  Class<?> contextSelectorClass = Loader.loadClass(contextSelectorStr);
  Constructor cons = contextSelectorClass.getConstructor(new Class[] { LoggerContext.class });
  return (ContextSelector) cons.newInstance(defaultLoggerContext);
}

在 ArkLogbackContextSelector 中,我们使用 ClassLoader 区分不同模块,将模块 LoggerContext 根据 ClassLoader 缓存

根据 classloader 获取不同的 LoggerContext,在 Spring 环境启动时,根据 spring 日志系统初始化日志上下文,通过 org.springframework.boot.logging.logback.LogbackLoggingSystem.getLoggerContext 获取日志上下文,此时将会使用 Ark 实现的自定义上下文选择器 com.alipay.sofa.ark.common.adapter.ArkLogbackContextSelector.getLoggerContext() 返回不同模块各自的 LoggerContext

public LoggerContext getLoggerContext() {
  ClassLoader classLoader = this.findClassLoader();
  if (classLoader == null) {
      return defaultLoggerContext;
  }
  return getContext(classLoader);
}

获取 classloader 时,首先获取线程上下文 classloader,当发现是模块的classloader时,直接返回,若tccl不是模块classloader,则从ClassContext中获取调用Class堆栈,遍历堆栈,当发现模块classloader时直接返回,这样做的目的是为了兼容tccl没有保证为模块classloader时的场景, 比如在模块代码中使用logger打印日志时,当前类由模块classloader自己加载,通过ClassContext遍历可以最终获得当前类,获取到模块classloader,以便确保使用模块对应的 LoggerContext

private ClassLoader findClassLoader() {
  ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
  if (classLoader != null && CONTAINER_CLASS_LOADER.equals(classLoader.getClass().getName())) {
      return null;
  }
  if (classLoader != null && BIZ_CLASS_LOADER.equals(classLoader.getClass().getName())) {
      return classLoader;
  }

  Class<?>[] context = new SecurityManager() {
      @Override
      public Class<?>[] getClassContext() {
          return super.getClassContext();
      }
  }.getClassContext();
  if (context == null || context.length == 0) {
      return null;
  }
  for (Class<?> cls : context) {
      if (cls.getClassLoader() != null
          && BIZ_CLASS_LOADER.equals(cls.getClassLoader().getClass().getName())) {
          return cls.getClassLoader();
      }
  }

  return null;
}

获取到合适 classloader 后,为不同 classloader选择不同的 LoggerContext 实例,所有模块上下文缓存在 com.alipay.sofa.ark.common.adapter.ArkLogbackContextSelector.CLASS_LOADER_LOGGER_CONTEXT 中,以 classloader 为 key

private LoggerContext getContext(ClassLoader cls) {
  LoggerContext loggerContext = CLASS_LOADER_LOGGER_CONTEXT.get(cls);
  if (null == loggerContext) {
      synchronized (ArkLogbackContextSelector.class) {
          loggerContext = CLASS_LOADER_LOGGER_CONTEXT.get(cls);
          if (null == loggerContext) {
              loggerContext = new LoggerContext();
              loggerContext.setName(Integer.toHexString(System.identityHashCode(cls)));
              CLASS_LOADER_LOGGER_CONTEXT.put(cls, loggerContext);
          }
      }
  }
  return loggerContext;
}

多模块 logback 使用样例

多模块 logback 使用样例

详细查看ArkLogbackContextSelector源码

5.5.3.6 - 6.5.3.6 log4j2 的多模块化适配

Koupleless log4j2 的多模块化适配

为什么需要做适配

原生 log4j2 在多模块下,模块没有独立打印的日志目录,统一打印到基座目录里,导致日志和对应的监控无法隔离。这里做适配的目的就是要让模块能有独立的日志目录。

普通应用 log4j2 的初始化

在 Spring 启动前,log4j2 会使用默认值初始化一次各种 logContext 和 Configuration,然后在 Spring 启动过程中,监听 Spring 事件进行初始化 org.springframework.boot.context.logging.LoggingApplicationListener,这里会调用到 Log4j2LoggingSystem.initialize 方法

该方法会根据 loggerContext 来判断是否已经初始化过了

这里在多模块下会存在问题一

这里的 getLoggerContext 是根据 org.apache.logging.log4j.LogManager 所在 classLoader 来获取 LoggerContext。根据某个类所在 ClassLoader 来提取 LoggerContext 在多模块化里会存在不稳定,因为模块一些类可以设置为委托给基座加载,所以模块里启动的时候,可能拿到的 LoggerContext 是基座的,导致这里 isAlreadyInitialized 直接返回,导致模块的 log4j2 日志无法进一步根据用户配置文件配置。

如果没初始化过,则会进入 super.initialize, 这里需要做两部分事情:

  1. 获取到日志配置文件
  2. 解析日志配置文件里的变量值 这两部分在多模块里都可能存在问题,先看下普通应用过程是如何完成这两步的

获取日志配置文件

可以看到是通过 ResourceUtils.getURL 获取的 location 对应日志配置文件的 url,这里通过获取到当前线程上下文 ClassLoader 来获取 URL,这在多模块下没有问题(因为每个模块启动时线程上下文已经是 模块自身的 ClassLoader )。

解析日志配置值

配置文件里有一些变量,例如这些变量

这些变量的解析逻辑在 org.apache.logging.log4j.core.lookup.AbstractLookup 的具体实现里,包括

变量写法代码逻辑地址
${bundle:application:logging.file.path}org.apache.logging.log4j.core.lookup.ResourceBundleLookup根据 ResourceBundleLookup 所在 ClassLoader 提前到 application.properties, 读取里面的值
${ctx:logging.file.path}org.apache.logging.log4j.core.lookup.ContextMapLookup根据 LoggerContext 上下文 ThreadContex 存储的值来提起,这里需要提前把 applicaiton.properties 的值设置到 ThreadContext 中

根据上面判断通过 bundle 的方式配置在多模块里不可行,因为 ResourceBundleLookup 可能只存在于基座中,导致始终只能拿到基座的 application.properties,导致模块的日志配置路径与基座相同,模块日志都打到基座中。所以需要改造成使用 ContextMapLookup。

static final修饰的Logger导致三方组件下沉基座后日志打印不能正常隔离

如:

private static final Logger LOG = LoggerFactory.getLogger(CacheManager.class);
  1. static final修饰的变量只会在类加载的时候初始化话一次
  2. 组件依赖下沉基座后,类加载器使用的为基座的类加载器,初始化log实例时使用的是基座的log配置,所以会打印到基座文件中,不能正常隔离

具体获取log源码如下:

//org.apache.logging.log4j.spi.AbstractLoggerAdapter
@Override
public L getLogger(final String name) {
    //关键是LoggerContext获取是否正确,往下追
    final LoggerContext context = getContext();
    final ConcurrentMap<String, L> loggers = getLoggersInContext(context);
    final L logger = loggers.get(name);
    if (logger != null) {
        return logger;
    }
    loggers.putIfAbsent(name, newLogger(name, context));
    return loggers.get(name);
}

//获取LoggerContext
protected LoggerContext getContext() {
    Class<?> anchor = LogManager.getFactory().isClassLoaderDependent() ? StackLocatorUtil.getCallerClass(Log4jLoggerFactory.class, CALLER_PREDICATE) : null;
    LOGGER.trace("Log4jLoggerFactory.getContext() found anchor {}", anchor);
    return anchor == null ? LogManager.getContext(false) : this.getContext(anchor);
}

//获取LoggerContext,关键在这里
protected LoggerContext getContext(final Class<?> callerClass) {
    ClassLoader cl = null;
    if (callerClass != null) {
        //会优先使用当前类相关的类加载器,这里肯定是基座的类加载,所以返回的是基座的LoggerContext
        cl = callerClass.getClassLoader();
    }
    if (cl == null) {
        cl = LoaderUtil.getThreadContextClassLoader();
    }
    return LogManager.getContext(cl, false);
}

预期多模块合并下的日志

基座与模块都能使用独立的日志配置、配置值,完全独立。但由于上述分析中,存在两处可能导致模块无法正常初始化的逻辑,故这里需要多 log4j2 进行适配。 static修饰的log在三方组件下沉基座后也会导致相关日志不能正常隔离打印,所以这里也需要做 log4j2 进行适配。

多模块适配点

  1. getLoggerContext() 能拿到模块自身的 LoggerContext
  1. 需要调整成使用 ContextMapLookup,从而模块日志能获取到模块应用名,日志能打印到模块目录里

    a. 模块启动时将 application.properties 的值设置到 ThreadContext 中 b. 日志配置时,只能使用 ctx:xxx:xxx 的配置方式

  2. LoggerFactory.getLogger()获取的是org.apache.logging.slf4j.Log4jLogger实例,他是一个包装类,所以有一定操作空间, 针对Log4jLogger进行复写改造,根据当前线程上下文类加载器动态获取底层ExtendedLogger对象

public class Log4JLogger implements LocationAwareLogger, Serializable {
   private transient final Map<ClassLoader, ExtendedLogger> loggerMap = new ConcurrentHashMap<>();
   private static final Map<ClassLoader, LoggerContext> LOGGER_CONTEXT_MAP = new ConcurrentHashMap<>();

   pubblic void info(final String format, final Object o) {
       //每次调用都获取对应的ExtendedLogger
       getLogger().logIfEnabled(FQCN, Level.INFO, null, format, o);
   }
   
   //根据当前线程类加载器动态获取ExtendedLogger
   private ExtendedLogger getLogger() {
       ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
       ExtendedLogger extendedLogger = loggerMap.get(classLoader);
       if (extendedLogger == null) {
           LoggerContext loggerContext = LOGGER_CONTEXT_MAP.get(classLoader);
           if (loggerContext == null) {
               loggerContext = LogManager.getContext(classLoader, false);
               LOGGER_CONTEXT_MAP.put(classLoader, loggerContext);
           }
           extendedLogger = loggerContext.getLogger(this.name);
           loggerMap.put(classLoader, extendedLogger);
       }
       return extendedLogger;
  }
  } 

模块改造方式

详细查看源码

5.5.3.7 - 6.5.3.7 模块使用宝蓝德 web 服务器

koupleless-adapter-bes

koupleless-adapter-bes 是为了适配宝蓝德(BES)容器,仓库地址为 koupleless-adapter-bes(感谢社区同学陈坚贡献)。

项目目前仅在BES 9.5.5.004 版本中验证过,其他版本需要自行验证,必要的话需要根据相同的思路进行调整。

如果多个BIZ模块不需要使用同一端口来发布服务,只需要关注下文安装依赖章节提到的注意事项即可,不需要引入本项目相关的依赖。

快速开始

1. 安装依赖

首先需要确保已经在maven仓库中导入了BES相关的依赖。 (这里有个关键点,由于koupleless 2.2.9项目对于依赖包的识别机制与BES的包结构冲突,需要用户先自行将BES的依赖包加上sofa-ark-的前缀,具体的识别机制可参考koupleless的com.alipay.sofa.ark.container.model.BizModel类)

参考导入脚本如下:

mv XXX/BES-EMBED/bes-lite-spring-boot-2.x-starter-9.5.5.004.jar XXX/BES-EMBED/sofa-ark-bes-lite-spring-boot-2.x-starter-9.5.5.004.jar

mvn install:install-file -Dfile=XXX/BES-EMBED/sofa-ark-bes-lite-spring-boot-2.x-starter-9.5.5.004.jar -DgroupId=com.bes.besstarter -DartifactId=sofa-ark-bes-lite-spring-boot-2.x-starter -Dversion=9.5.5.004 -Dpackaging=jar

mvn install:install-file -Dfile=XXX/BES-EMBED/bes-gmssl-9.5.5.004.jar -DgroupId=com.bes.besstarter -DartifactId=bes-gmssl -Dversion=9.5.5.004 -Dpackaging=jar

mvn install:install-file -Dfile=XXX/BES-EMBED/bes-jdbcra-9.5.5.004.jar -DgroupId=com.bes.besstarter -DartifactId=bes-jdbcra -Dversion=9.5.5.004 -Dpackaging=jar

mvn install:install-file -Dfile=XXX/BES-EMBED/bes-websocket-9.5.5.004.jar -DgroupId=com.bes.besstarter -DartifactId=bes-websocket -Dversion=9.5.5.004 -Dpackaging=jar

2. 编译安装本项目插件

进入本项目的bes9-web-adapter 目录执行mvn install命令即可。 项目将会安装“bes-web-ark-plugin” 和 “bes-sofa-ark-springboot-starter” 两个模块。

3. 使用本项目组件

首先需要根据koupleless的文档,将项目升级为Koupleless基座

然后将依赖中提到的

<dependency>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>web-ark-plugin</artifactId>
    <version>${sofa.ark.version></version>
</dependency>

替换为本项目的坐标

<dependency>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>bes-web-ark-plugin</artifactId>
    <version>2.2.9</version>
</dependency>
<dependency>
   <groupId>com.alipay.sofa</groupId>
   <artifactId>bes-sofa-ark-springboot-starter</artifactId>
   <version>2.2.9</version>
</dependency>

引入BES相关依赖(同时需要exclude tomcat的依赖)。参考依赖如下:

       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>com.bes.besstarter</groupId>
            <artifactId>sofa-ark-bes-lite-spring-boot-starter</artifactId>
            <version>9.5.5.004</version>
        </dependency>

        <dependency>
            <groupId>com.bes.besstarter</groupId>
            <artifactId>bes-gmssl</artifactId>
            <version>9.5.5.004</version>
        </dependency>

        <dependency>
            <groupId>com.bes.besstarter</groupId>
            <artifactId>bes-jdbcra</artifactId>
            <version>9.5.5.004</version>
        </dependency>

        <dependency>
            <groupId>com.bes.besstarter</groupId>
            <artifactId>bes-websocket</artifactId>
            <version>9.5.5.004</version>
        </dependency>

4. 完成

完成上述步骤后,即可在Koupleless中使用BES启动项目。

5.5.3.8 - 6.5.3.8 模块使用东方通 web 服务器

koupleless-adapter-tongweb

koupleless-adapter-tongweb 是为了适配东方通(TongWEB)容器,仓库地址为:koupleless-adapter-tongweb(感谢社区同学陈坚贡献)。

项目目前仅在tongweb-embed-7.0.E.6_P7 版本中验证过,其他版本需要自行验证,必要的话需要根据相同的思路进行调整。

如果多个BIZ模块不需要使用同一端口来发布服务,只需要关注下文安装依赖章节提到的注意事项即可,不需要引入本项目相关的依赖。

快速开始

1. 安装依赖

首先需要确保已经在maven仓库中导入了TongWEB相关的依赖。 (这里有个关键点,由于koupleless 2.2.9项目对于依赖包的识别机制与TongWEB的包结构冲突,需要将TongWEB的依赖包加上sofa-ark-的前缀,具体的识别机制可参考koupleless的com.alipay.sofa.ark.container.model.BizModel类)

参考导入脚本如下:

mv XXX/tongweb-spring-boot-starter-7.0.E.6_P7.jar XXX/sofa-ark-tongweb-spring-boot-starter-7.0.E.6_P7.jar
mv XXX/tongweb-tongweb-embed-core-7.0.E.6_P7.jar XXX/sofa-ark-tongweb-embed-core-7.0.E.6_P7.jar
mv XXX/tongweb-lic-sdk-4.5.0.0.jar XXX/sofa-ark-tongweb-lic-sdk-4.5.0.0.jar
mvn install:install-file -DgroupId=com.tongweb.springboot -DartifactId=sofa-ark-tongweb-spring-boot-starter -Dversion=7.0.E.6_P7 -Dfile="XXX/sofa-ark-tongweb-spring-boot-starter-7.0.E.6_P7.jar" -Dpackaging=jar
mvn install:install-file -DgroupId=com.tongweb -DartifactId=sofa-ark-tongweb-embed-core -Dversion=7.0.E.6_P7 -Dfile="XXX/sofa-ark-tongweb-embed-core-7.0.E.6_P7.jar" -Dpackaging=jar
mvn install:install-file -DgroupId=com.tongweb -DartifactId=sofa-ark-tongweb-lic-sdk -Dversion=4.5.0.0 -Dfile="XXX/sofa-ark-tongweb-lic-sdk-4.5.0.0.jar" -Dpackaging=jar

2. 编译安装本项目插件

进入本项目的tongweb7-web-adapter 目录执行mvn install命令即可。 项目将会安装“tongweb7-web-ark-plugin” 和 “tongweb7-sofa-ark-springboot-starter” 两个模块。

3. 使用本项目组件

首先需要根据koupleless的文档,将项目升级为Koupleless基座

然后将依赖中提到的

    <dependency>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>web-ark-plugin</artifactId>
        <version>${sofa.ark.version></version>
    </dependency>

替换为本项目的坐标

    <dependency>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>tongweb7-web-ark-plugin</artifactId>
        <version>2.2.9</version>
    </dependency>
    
    <dependency>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>tongweb7-sofa-ark-springboot-starter</artifactId>
        <version>2.2.9</version>
    </dependency>

引入TongWEB相关依赖(同时需要exclude tomcat的依赖)。参考依赖如下:

       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>com.tongweb.springboot</groupId>
            <artifactId>sofa-ark-tongweb-spring-boot-starter</artifactId>
            <version>7.0.E.6_P7</version>
        </dependency>
        <dependency>
            <groupId>com.tongweb</groupId>
            <artifactId>sofa-ark-tongweb-embed-core</artifactId>
            <version>7.0.E.6_P7</version>
        </dependency>
        <dependency>
            <groupId>com.tongweb</groupId>
            <artifactId>sofa-ark-tongweb-lic-sdk</artifactId>
            <version>4.5.0.0</version>
        </dependency>

4. 完成

完成上述步骤后,即可在Koupleless中使用TongWEB启动项目。

5.5.3.9 - 6.5.3.9 模块使用 Dubbo

模块拦截器(Filter)

模块可以使用本模块上定义的拦截器,也可以使用基座上定义的拦截器。

⚠️注意:避免模块拦截器的名称与基座拦截器名称一致。如果名称一致,则将使用基座拦截器。

5.5.3.10 - 6.5.3.10 基座与模块间类委托加载原理介绍

Koupleless 基座与模块间类委托加载原理介绍

多模块间类委托加载

SOFAArk 框架是基于多 ClassLoader 的通用类隔离方案,提供类隔离和应用的合并部署能力。本文档并不打算介绍 SOFAArk 类隔离的原理与机制,这里主要介绍多 ClassLoader 当前的最佳实践。
当前基座与模块部署在 JVM 上的 ClassLoader 模型如图:

当前类委托加载机制

当前一个模块在启动与运行时查找的类,有两个来源:当前模块本身,基座。这两个来源的理想优先级顺序是,优先从模块中查找,如果模块找不到再从基座中查找,但当前存在一些特例:

  1. 当前定义了一份白名单,白名单范围内的依赖会强制使用基座里的依赖。
  2. 模块可以扫描到基座里的所有类:
    • 优势:模块可以引入较少依赖
    • 劣势:模块会扫描到模块代码里不存在的类,例如会扫描到一些 AutoConfiguration,初始化时由于第四点扫描不到对应资源,所以会报错。
  3. 模块不能扫描到基座里的任何资源:
    • 优势:不会与基座重复初始化相同的 Bean
    • 劣势:模块启动如果需要基座的资源,会因为查找不到资源而报错,除非模块里显示引入(Maven 依赖 scope 不设置成 provided)
  4. 模块调用基座时,部分内部处理传入模块里的类名到基座,基座如果存在直接从基座 ClassLoader 查找模块传入的类,会查找不到。因为委托只允许模块委托给基座,从基座发起的类查找不会再次查找模块里的。

使用时需要注意事项

模块要升级委托给基座的依赖时,需要让基座先升级,升级之后模块再升级。

类委托的最佳实践

类委托加载的准则是中间件相关的依赖需要放在同一个的 ClassLoader 里进行加载执行,达到这种方式的最佳实践有两种:

强制委托加载

由于中间件相关的依赖一般需要在同一个 ClassLoader 里加载运行,所以我们会制定一个中间件依赖的白名单,强制这些依赖委托给基座加载。

使用方法

application.properties 里增加配置 sofa.ark.plugin.export.class.enable=true

优点

模块开发者不需要感知哪些依赖属于需要强制加载由同一个 ClassLoader 加载的依赖。

缺点

白名单里要强制加载的依赖列表需要维护,列表的缺失需要更新基座,较为重要的升级需要推所有的基座升级。

自定义委托加载

模块里 pom 通过设置依赖的 scope 为 provided主动指定哪些要委托给基座加载。通过模块瘦身把与基座重复的依赖委托给基座加载,并在基座里预置中间件的依赖(可选,虽然模块暂时不会用到,但可以提前引入,以备后续模块需要引入的时候不需再发布基座即可引入)。这里:

  1. 基座尽可能的沉淀通用的逻辑和依赖,特别是中间件相关以 xxx-alipay-sofa-boot-starter 命名的依赖。
  2. 基座里预置一些公共依赖(可选)。
  3. 模块里的依赖如果基座里面已经有定义,则模块里的依赖尽可能的委托给基座,这样模块会更轻(提供自动模块瘦身的工具)。模块里有两种途径设置为委托给基座:
    1. 依赖里的 scope 设置为 provided,注意通过 mvn dependency:tree 查看是否还有其他依赖设置成了 compile,需要所有的依赖引用的地方都设置为 provided。
    2. biz 打包插件sofa-ark-maven-plugin里设置 excludeGroupIdsexcludeArtifactIds
            <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。

  1. 只有模块声明过的依赖才可以委托给基座加载。

模块启动的时候,Spring 框架会有一些扫描逻辑,这些扫描如果不做限制会查找到模块和基座的所有资源,导致一些模块明明不需要的功能尝试去初始化,从而报错。SOFAArk 2.0.3 之后新增了模块的 declaredMode, 来限制只有模块里声明过的依赖才可以委托给基座加载。只需在模块的打包插件的 Configurations 里增加 <declaredMode>true</declaredMode>即可。

优点

不需要维护 plugin 的强制加载列表,当部分需要由同一 ClassLoader 加载的依赖没有设置为统一加载时,可以修改模块就可以修复,不需要发布基座(除非基座确实依赖)。

缺点

对模块瘦身的依赖较强。

对比与总结

依赖缺失排查成本修复成本模块改造成本维护成本
强制加载类转换失败或类查找失败,成本中更新 plugin,发布基座,高
自定义委托加载类转换失败或类查找失败,成本中更新模块依赖,如果基座依赖不足,需要更新基座并发布,中
自定义委托加载 + 基座预置依赖 + 模块瘦身类转换失败或类查找失败,成本中更新模块依赖,设置为 provided,低

结论:推荐自定义委托加载方式

  1. 模块自定义委托加载 + 模块瘦身。
  2. 模块开启 declaredMode。
  3. 基座预置依赖。

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

开启方式

模块打包插件里增加如下配置:

开启后的副作用

如果模块委托给基座的依赖里有发布服务,那么基座和模块会同时发布两份。


5.5.3.11 - 6.3.5.11 如果模块独立引入 SpringBoot 框架部分会怎样?

Koupleless 模块独立引入 SpringBoot 框架部分会怎样?

由于多模块运行时的逻辑在基座引入和加载,例如一些 Spring 的 Listener。如果模块启动使用完全自己的 SpringBoot,则会出现一些类的转换或赋值判断失败,例如:

CreateSpringFactoriesInstances

name = ‘com.alipay.sofa.ark.springboot.listener.ArkApplicationStartListener’, ClassUtils.forName 获取到的是从基座 ClassLoader 的类


而 type 是模块启动时加载的,也就是使用模块 BizClassLoader 加载。


此时这里做 isAssignable 判断,则会报错。

com.alipay.sofa.koupleless.plugin.spring.ServerlessApplicationListener is not assignable to interface org.springframework.context.ApplicationListener

所以模块框架这部分需要委托给基座加载。



5.5.4 - 6.5.4 模块拆分工具

5.5.4.1 - 6.5.4.1 半自动化拆分工具使用指南

背景

从大单体 SpringBoot 应用中拆出 Koupleless 模块时,用户拆分的学习和试错成本较高。用户需要先从服务入口分析要拆出哪些类至模块,然后根据 Koupleless 模块编码方式改造模块。

为了降低学习和试错成本,KouplelessIDE 插件提供半自动化拆分能力:分析依赖,并自动化修改。

快速开始

1. 安装插件

从 IDEA 插件市场安装插件 KouplelessIDE:

2. 配置 IDEA

确保 IDEA -> Preferences -> Builder -> Compiler 的 “User-local build process heap size” 至少为 4096

3. 选择模块

步骤一:用 IDEA 打开需要拆分的 SpringBoot 应用,在面板右侧打开 ServerlessSplit

步骤二:按需选择拆分方式,点击“确认并收起”

4. 依赖分析

在拆分时,需要分析类和Bean之间的依赖。可以通过插件可视化依赖关系,由业务方决定某个类是否要拆分到模块中。

步骤一:点击激活

步骤二:拖拽服务入口至模块,支持跨层级拖拽

拖拽结果:

步骤三:拖拽“待分析文件”,点击分析依赖,查看类/Bean的依赖关系,如下图:

其中,各图标表示:

图标含义用户需要的操作
in-module-icon.jpg已在模块无需操作
can-move-to-module.jpg可移入模块拖拽至模块 can-move-to-module-action.jpg
recommend-to-analyse-icon.jpg建议分析被依赖关系拖拽至分析 recommend-to-analyse-action.jpg
warning-icon.jpg不应该移入模块鼠标悬停,查看该类被依赖情况 warning-icon.jpg

步骤四:根据提示,通过拖拽,一步步分析,导入需要的模块文件

5. 检测

点击初步检测,将提示用户此次拆分可能的问题,以及哪些中间件需要人工。

打开下侧面板中 KouplelessIDE,查看提示。

6. 拆分

点击开始拆分。

打开下侧面板中 KouplelessIDE,查看提示。

5.5.4.2 - 6.5.4.2 单体应用协作开发太难?Koupleless 带来拆分插件,帮你抽丝剥茧提高协作开发效率!

背景

你的企业应用协作效率低吗?

明明只改一行,但代码编译加部署要十分钟;

多人开发一套代码库,调试却频频遇到资源抢占和相互覆盖,需求上线也因此相互等待……

当项目代码逐渐膨胀,业务逐渐发展,代码耦合、发布耦合以及资源耦合的问题日益加重,开发效率一降再降

如何解决?来试试把单个 Springboot 应用拆分为多个 Springboot 应用吧!拆出后,多个 Springboot 应用并行开发,互不干扰。在 Koupleless 模式下,业务可以将 Springboot 应用拆分成一个基座和多个 Koupleless 模块(Koupleless 模块也是 Springboot 应用)。

🙌拉到「Koupleless 拆分插件解决方案」部分,可直接查看单体应用拆分的插件演示视频!

关键挑战

从单个 Springboot 应用拆出 多个 Springboot 应用有三大关键挑战:

一是拆子应用前,复杂单体应用中代码耦合高、依赖关系复杂、项目结构复杂,难以分析各文件间的耦合性,更难从中拆出子应用,因此需要解决拆分前复杂单体应用中的文件依赖分析问题

二是拆子应用时,拆出操作繁琐、耗时长、用户需要一边分析依赖关系一边拆出,对用户要求极高,因此需要降低拆分时用户交互成本。

三是拆子应用后,单体应用演进为多应用共存,其编码模式会发生改变。Bean 调用方式由单应用调用演进为跨应用调用,特殊的多应用编码模式也需根据框架文档调整,比如在 koupleless 中,为了减少模块的数据源连接,模块会按照某种方式使用基座的数据源,其学习成本与调整成本极高,因此需要解决拆分后多应用编码模式演进问题。

Koupleless 拆分插件解决方案

针对以上三大关键挑战,Koupleless IntelliJ IDEA 插件将解决方案分为 3 个部分:分析、交互和自动化拆出,提供依赖分析、友好交互和自动化拆出能力,如下图:

  • 在分析中,分析项目中的依赖关系,包括类依赖和Bean依赖分析,解决拆分前复杂单体应用中的文件依赖分析问题;
  • 在交互中,可视化类文件之间的依赖关系,帮助用户梳理关系。同时,可视化模块目录结构,让用户以拖拽的方式决定要拆分的模块文件,降低拆分时的用户交互成本;
  • 在自动化拆出中,插件将构建模块,并根据特殊的多应用编码修改代码,解决拆分后多应用编码模式演进问题。

🙌 此处有 Koupleless 半自动拆分演示视频,带你更直观了解插件如何在分析、交互、自动化拆出中提供帮助。

一个示例 秒懂 Koupleless 解决方案优势

假设某业务需要将与 system 相关的代码都拆出到模块,让通用能力保留在基座。这里我们以 system 的入口服务 QuartzJobController 为例。

步骤一:分析项目文件依赖关系

首先,我们会分析 QuartzJobController 依赖了哪些类和 Bean。

方式一:使用 Idea 专业版,对 Controller 做 Bean 分析和类分析,得到以下 Bean 依赖图和类依赖关系图

  • 优势:借助 IDEA 专业版,分析全面
  • 劣势:需要对每个类文件都做一次分析,Bean 依赖图可读性不强。

方式二:凭借脑力分析

当 A 类依赖了B、C、D、…、N类,在拆出时需要分析每一个类有没有被其它类依赖,能不能够拆出到模块应用。

  • 优势:直观
  • 劣势:当 A 的依赖类众多,需要层层脑力递归。

方式三:使用 Koupleless 辅助工具,轻松分析!

选择你想要分析的任意类文件,点击“分析依赖”,插件帮你分析~ 不仅帮你分析类文件依赖的类和 Bean,还提示你哪些类可以拆出,哪些类不能拆出。 以 QuartzJobController 为例,当选定的模块中有 QuartzJobController, QuartzJobService 和 QuartzJobServiceImpl 时,QuartzJobController 依赖的类和Bean关系如下图所示:

QuartzJobController 的依赖类/Bean 分为四类:已在模块、可移入模块、建议分析被依赖关系和不建议移入模块:

  • 如果在模块中,则被标记为绿色“已在模块”,如: QuartzJobService 和 QuartzJobServiceImpl;
  • 如果只被模块类依赖,那么被标记为蓝色“可移入模块”,如 JobQueryCriteria;
  • 如果只被一个非模块类依赖,那么被标记为黄色“建议分析被依赖关系”,如 QuartLog;
  • 如果被大量非模块类依赖,那么被标记为红色“不建议移入模块”,如 BadRequestException。

当使用插件对 QuartzJobController 和 JobQueryCriteria 进行分析时,依赖树和被依赖树如下,与上述分析对应:

  • 优势:直观、操作便捷、提示友好
  • 劣势:插件只支持分析常见的 Bean 定义和类引用

步骤二:拆出到模块&修改单应用编码为多应用编码模式

选择相关的类文件拆出到模块。

方式一:复制粘贴每个文件、脑力分析所有模块基座之间的Bean调用、根据多应用编码模式修改代码。

在拆出时容易有灵魂发问:刚拆到哪儿了?这文件在没在模块里?我要不要重构这些包名?Bean调用跨应用吗?多应用编码的文档在哪?

  • 优势:可以处理插件无法处理的多应用编码模式
  • 劣势:用户不仅需要分析跨应用Bean依赖关系,还需要学习多应用编码方式,人工成本较高。

方式二:使用 Koupleless 辅助工具,轻松拆出!

根据你想要的模块目录结构,拖拽需要拆出的文件至面板。点击“拆出”,插件帮你分析,帮你根据 Koupleless 多应用编码模式修改~

  • 优势:直观、交互方便、插件自动修改跨应用 Bean 调用方式和部分特殊的多应用编码模式
  • 劣势:插件只能根据部分多应用编码模式修改代码,因此用户需要了解插件能力的范围

技术方案

插件将整体流程分为 3 个阶段:分析阶段交互阶段自动化拆出阶段,整体流程如下图所示:

  • 在分析阶段中,分析项目中的依赖关系,包括类依赖、Bean 依赖和特殊的多应用编码分析,如:MyBatis 配置依赖;

  • 在交互阶段,可视化类文件之间的依赖关系和模块目录结构;

  • 在自动化拆出阶段,插件首先将构建模块并整合配置,然后根据用户需要重构包名,接着修改模块基座 Bean 调用方式,并根据特殊的多应用编码修改代码,如:自动复用基座数据源。

接下来,我们将简单介绍分析阶段、交互阶段和自动化拆出阶段的主要技术。

分析阶段

插件分别使用 JavaParser 和 commons-configuration2 扫描项目中的 Java 文件和配置文件。

类依赖分析

为了准确地分析出项目的类依赖关系,插件需要完整分析一个类文件代码中所有使用到的项目类,即:分析代码中每个涉及类型的语句。

插件首先扫描所有类信息,然后用 JavaParser 扫描每一个类的代码代码,分析它依赖的项目类文件所有涉及类型的语句,并解析涉及到的类型,最后记录其关联关系。涉及类型的语句如下:

  1. 类定义分析: 解析父类类型和实现接口类型,作为引用的类型;
  2. 注解分析:解析注解类型,作为引用的类型;
  3. 字段定义分析:解析字段类型,作为引用的类型;
  4. 变量定义分析:解析变量类型,作为引用的类型;
  5. 方法定义分析:解析方法的返回类型、入参类型、注解以及抛出类型,作为引用的类型;
  6. 类对象创建分析:解析类对象创建语句的对象类型,作为引用的类型;
  7. catch分析:解析 catch 的对象类型,作为引用的类型;
  8. foreach分析:解析 foreach 的对象类型,作为引用的类型;
  9. for分析:解析 for 的对象类型,作为引用的类型;

为了快速解析对象类型,由于直接使用 JavaParser 解析较慢,因此先通过 imports 解析是否有匹配的类型,如果匹配失败,则使用 JavaParser 解析。

Bean 依赖分析

为了准确地分析出项目的Bean依赖关系,插件需要扫描项目中所有的 Bean 定义和依赖注入方式,然后通过静态代码分析的方式解析类文件依赖的所有项目 Bean。

Bean 定义主要有三种方式:类名注解、方法名注解和 xml。不同方式的 Bean 定义对应着不同的 Bean 依赖注入分析方式,最终依赖的 Bean 又由依赖注入类型决定,整体流程如下:

在扫描 Bean 时,解析并记录 bean 信息、依赖注入类型和依赖的Bean信息。

  1. 对于以类注解定义的类,将以字段注解,解析该字段的依赖注入类型和依赖的Bean信息。
  2. 对于以方法定义的类,将以入参信息,解析该入参数的依赖注入类型和依赖的Bean信息。
  3. 对于以 xml 定义的类,将通过解析 xml 和类方法的方式解析依赖注入:
    1. 解析 byName类型的依赖Bean信息
    2. 以字段解析依赖注入类型和依赖Bean信息。
    3. 如果 xml 的依赖注入方式不为 no,那么解析依赖注入类型和 set方法对应依赖Bean信息。

最后按照依赖注入类型,在项目记录的Bean定义中查找依赖的Bean信息,以实现Bean依赖关系的分析。

特殊的多应用编码分析

这里我们以 MyBatis 配置依赖分析为例。

在拆出 Mapper 至模块时,模块需要复用基座数据源,因此插件需要分析 Mapper 关联的所有 MyBatis 配置类。MyBatis 的各项配置类和 Mapper 文件之间通过 MapperScanner 配置连接,整体关系如下图:

因此,插件记录所有的 Mapper 类文件和 XML 文件,解析与之关联的 MapperScanner,并解析与 MapperScanner 配置关联的所有 Mybatis 配置 Bean 信息。

交互阶段

这里简述依赖关系可视化和跨层级导入的实现。

  • 可视化依赖关系:插件通过递归的方式解析所有类文件之间的依赖关系(包括类依赖关系和 Bean 依赖关系)。由于类文件间可能有循环依赖,因此使用缓存记录所有类文件节点。在递归时优先从缓存中取依赖节点,避免导致构建树节点时出现栈溢出问题。
  • 跨层级导入:记录所有标记的选中文件,如果需选中了文件夹及文件夹中文件,导入时只导入标记过的文件。

自动化拆出阶段

这里简述包重命名、配置整合、Bean调用和特殊的多应用编码修改(以“复用基座数据源”为例)的实现。

  • 包重命名:当用户自定义包名时,插件将修改类包名,并根据类依赖关系,将其 import 字段修改为新包的名称。
  • 配置整合:针对子应用的每一个模块,读取所有拆出文件所在的原模块配置,并整合到新模块中;自动抽出子应用相关的 xml 的 bean 节点。
  • Bean 调用:由上文分析的 Bean 依赖关系,插件过滤出模块和基座之间的 Bean 调用,并将字段注解(@Autowired @Resource @Qualifier) 修改为 @AutowiredFromBase 或 @AutowiredFromBiz
  • 基座数据源复用:根据用户选择的 Mapper 文件及 MyBatis 配置依赖关系,抽取该 Mapper 相关的MyBatis 配置信息。然后把配置信息填充至数据源复用模板文件,保存在模块中。

未来展望

当前在内部已经完成上述功能开发,但还未正式开源,预计 2024年上半年将开源,敬请期待。

此外,在功能上,未来还需解决更多挑战:如何解决单测的拆分,如何验证拆出的多应用能力与单应用能力一致性。

欢迎更多感兴趣的同学关注 Koupleless 社区共同建设 Koupleless 生态。

5.6 - 6.6 ModuleController 技术文档

5.6.1 - 6.6.1 ModuleControllerV2 架构设计

Koupleless ModuleControllerV2 架构设计

简要介绍

ModuleControllerV2 是一个 K8S 控制面组件,基于Virtual Kubelet能力,将基座伪装成K8S体系中的node,将模块映射为K8S体系中的Container,从而将模块运维映射成为Pod的运维,基于K8S包含的Pod生命周期管、调取,以及基于Pod的Deployment、DaemonSet、Service等现有控制器,实现了 Serverless 模块的秒级运维调度,以及与基座的联动运维能力。

背景

原有的Module Controller(以下称之为MC)基于K8S Operator技术设计。 在这种模式下,原有的MC从逻辑上是定义了另外一套与基座隔离的,专用的模块控制面板,从逻辑上将模块与基座分成了两个相对独立的类别进行分别的运维,即基座的运维通过K8S的原生能力进行运维,模块运维通过Operator封装的运维逻辑进行运维。

这样的构建方法在逻辑上很清晰,对模块和基座的概念进行了区分,但是也带来了一定的局限性:

  1. 首先由于逻辑上模块被抽象成了不同于基座模型的另一类代码,因此原来的MC除了要实现在基座上进行模块的加载、卸载以外,还需要:

    1. 感知当前所有的基座
    2. 维护基座状态(基座在线状态、模块加载情况、模块负载等)
    3. 维护模块状态(模块在线状态等)
    4. 根据业务需要实现相应的模块调度逻辑

    这将为带来巨大的开发与维护成本。(单场景的Operator开发成本高)

  2. 模块能力与角色横向扩展困难。这种实现方式从逻辑上是与原先比较常见的微服务架构是相互不兼容的,微服务架构不同微服务之间的角色是相同的,然而在Operator的实现中,模块与基座的抽象层级是不同的,两者不能互通。 以Koupleless所提出的:“模块既可以以模块的形式attach到基座上,也可以以服务的形式单独运行”为例,在Operator架构下,如果想实现后者的能力,就需要单独为这种需求定制一套全新的调度逻辑,至少需要定制维护特定的依赖资源,才能够实现,这样未来每需要一种新的能力/角色,都需要定制开发大量的代码,开发维护成本很高。(横向扩展的开发成本高)

  3. 在这种架构下,模块成为了一种全新的概念,从产品的角度,会增大使用者的学习成本。

架构

ModuleControllerV2 目前包含Virtual Kubelet Manager控制面组件和Virtual Kubelet组件。Virtuakl Kubelet组件是Module Controller V2的核心,负责将基座服务映射成一个node,并对其上的Pod状态进行维护,Manager维护基座相关的信息,监听基座上下线消息,关注基座的存活状态,并维护Virtual Kubelet组件的基本运行环境。

Virtual Kubelet

Virtual Kubelet参考了官方文档 的实现

一句话做一个总结就是:VK是一个可编程的Kubelet。

就像在编程语言中的概念,VK是一个Kubelet接口,定义了一组Kubelet的标准,通过对VK这一接口进行实现,我们就可以实现属于我们自己的Kubelet。

K8S中原有的运行在Node上的Kubelet就是对VK的一种实现,通过实现VK中抽象的方法从而使得K8S控制面能够对Node上物理资源的使用与监控。

因此VK具有伪装成为Node的能力,为了区分传统意义上的Node和VK伪装的Node,下面我们将VK伪装的Node称为VNode。

逻辑结构

在Koupleless的架构中,基座服务运行在Pod中,这些Pod由K8S进行调度与维护,分配到实际的Node上运行。

对模块调度的需求实际上和基座的调度是一致的,因此在MC V2的设计中,使用VK将基座服务伪装成传统K8S中的Node,变成基座VNode,将模块伪装成Pod, 变成模块VPod,从逻辑上抽象出第二层K8S用于管理VNode和VPod。

综上,在整体架构中会将包含两个逻辑K8S:

  1. 基座K8S:维护真实Node(虚拟机/物理机),负责将基座Pod调度到真实Node上
  2. 模块K8S:维护虚拟VNode(基座Pod), 负责将模块VPod调度到虚拟VNode上

之所以称之为逻辑K8S是因为两个K8S不一定需要是真实的两个分离的K8S,在隔离做好的情况下,可以由同一个K8S完成两部分任务。

通过这种抽象,我们就可以在不对框架做额外开发的前提下,复用K8S原生的调度和管理的能力实现:

  1. 基座VNode的管理(非核心能力,因为其本身就是底层K8S中的一个Pod,可以由底层K8S来维护状态,但作为Node也将包含更多的信息)
  2. VPod的管理(核心能力:包含模块运维,模块调度、模块生命周期状态维护等)

多租户VK架构(VK-Manager)

原生的VK基于K8S的Informer机制和ListWatch实现当前vode上pod事件的监听。但是这样也就意味着每一个vnode都需要启动一套监听逻辑,这样,随着基座数量的增加,APIServer的压力会增长的非常快,不利于规模的横向扩展。

为了解决这一问题,Module Controller V2基于Virtual Kubelet,将其中的ListWatch部分抽取出来,通过监听某一类Pod(具体实现中为包含特定label的Pod)的事件,再通过进程内通信的方式将事件转发到逻辑VNode上进行处理的方式实现Informer资源的复用,从而只需要在各VNode中维护本地上下文,而不需要启动单独的监听,降低APIServer的压力。

多租户架构下,Module Controller V2中将包含两个核心模块:

  1. 基座注册中心:这一模块通过特定的运维管道进行基座服务的发现,以及VK的上下文维护与数据传递。
  2. VK:VK保存着某个具体基座和node,pod之间的映射,实现对node和pod状态的维护以及将pod操作翻译成为对应的模块操作下发至基座。

分片多租户VK架构(WIP)

单点的Module Controller很显然缺乏容灾能力,并且单点的上限也很明显,因此Module Controller V2需要一套更加稳定,具备容灾和水平扩展能力的架构。

这一架构正在设计与完善过程中。


5.6.2 - 6.6.2 ModuleControllerV2 原理

Koupleless ModuleControllerV2 架构设计

模块运维架构

简要介绍

Module Controller V2 基于 Virtual Kubelet 能力,实现将基座映射为 K8S 中的 Node,进而通过将 Module 定义为 Pod 实现对 K8S 调度器以及各类控制器的复用,快速搭建模块运维调度能力。

基座 <-> VNode 映射

Module Controller V2 通过 Tunnel 实现基座发现,基座发现后将会通过 Virtual Kubelet 将其伪装成 Node,以下称此类伪装的 Node 为 VNode。

基座发现时将读取基座中所配置的 Metadata 和 Network 信息,其中 Metadata 包含 Name 和 Version,Network 包含 IP 和 Hostname 信息。

Metadata 信息将变成 VNode 上的 Label 信息,用于标识基座信息, Network 信息将成为 VNode 的网络配置,未来调度到基座上的 module pod 将继承 VNode 的 IP, 用于配置 Service 等。

一个 VNode 还将包含以下关键信息:

apiVersion: v1
kind: Node
metadata:
  labels:
    base.koupleless.io/stack: java # 目前为默认值,未来可能支持更多类型的编程语言
    virtual-kubelet.koupleless.io/component: vnode # vnode标记
    virtual-kubelet.koupleless.io/env: dev # vnode环境标记
    vnode.koupleless.io/name: base # 基座 Metadata 中的 Name 配置
    vnode.koupleless.io/tunnel: mqtt_tunnel_provider # 基座当前归属 tunnel
    vnode.koupleless.io/version: 1.0.0 # 基座版本号
  name: vnode.2ce92dca-032e-4956-bc91-27b43406dad2 # vnode name, 后半部分为基座运维管道所生成的 uuid
spec:
  taints:
  - effect: NoExecute
    key: schedule.koupleless.io/virtual-node # vnode 污点,防止普通 pod 调度
    value: "True"
  - effect: NoExecute
    key: schedule.koupleless.io/node-env # node env 污点,防止非当前环境 pod 调度
    value: dev
status:
  addresses:
  - address: 127.0.0.1
    type: InternalIP
  - address: local
    type: Hostname

模块 <-> vPod 映射

Module Controller V2 将模块定义为 K8S 体系中的一个 Pod(为了区分,后续称为 vPod ),通过配置 Pod Yaml 实现丰富的调度能力。

一个模块 vPod 的 Yaml 配置如下:

apiVersion: v1
kind: Pod
metadata:
  name: test-single-module-biz1
  labels:
    virtual-kubelet.koupleless.io/component: module # 必要,声明pod的类型,用于module controller管理
spec:
  containers:
    - name: biz1 # 模块名,需与模块 pom 中 artifactId 的配置严格对应
      image: https://serverless-opensource.oss-cn-shanghai.aliyuncs.com/module-packages/stable/biz1-web-single-host-0.0.1-SNAPSHOT-ark-biz.jar # jar包地址,支持本地 file,http/https 链接
      env:
        - name: BIZ_VERSION # 模块版本配置
          value: 0.0.1-SNAPSHOT # 需与 pom 中的 version 配置严格对应
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms: # 基座node选择
          - matchExpressions:
              - key: base.koupleless.io/stack # 未来多语言支持
                operator: In
                values:
                  - java
              - key: vnode.koupleless.io/version # 基座版本筛选
                operator: In
                values:
                  - 1.0.0 # 模块可能只能被调度到一些特殊版本的基座上,如有这种限制,则必须有这个字段。
              - key: vnode.koupleless.io/name # 基座名筛选
                operator: In
                values:
                  - base  # 模块可能只能被调度到一些特定基座上,如有这种限制,则必须有这个字段。
  tolerations:
    - key: "schedule.koupleless.io/virtual-node" # 确保模块能够调度到基座 vnode 上
      operator: "Equal"
      value: "True"
      effect: "NoExecute"
    - key: "schedule.koupleless.io/node-env" # 确保模块能够调度到特定环境的基座node上
      operator: "Equal"
      value: "test"
      effect: "NoExecute"

上面的样例只展示了最基本的配置,另外还可以添加任意配置以实现丰富的调度能力,例如在 Module Deployment 发布场景中,可另外添加 Pod AntiAffinity 以防止模块的重复安装。

运维流程

基于上述结构与映射关系,我们就可以复用 K8S 原生的控制面组件,实现复杂多样的模块运维需求。

下面以模块 Deployment 为例展示整个模块运维流程,此时基座已完成启动与映射:

  1. 创建模块 Deployment (原生 K8S Deployment,其中 Template 中的 PodSpec 对模块信息进行了定义),K8S ControllerManager 中的 Deployment Controller 会根据 Deployment 配置创建模块vPod,此时 vPod 还未调度,状态为 Pending
  1. K8S Scheduler 扫描未调度的 vPod,然后根据 selector、affinity、taint/toleration 配置将其调度到合适的 vNode 上
  1. Module Controller 监听到 vPod 完成调度,获取到 vPod 中定义的模块信息,将模块安装指令发送到基座上
  1. 基座完成模块安装后,将模块安装状态与 Module Controller 进行同步,Module Controller 再将模块状态转换为 Container Status 同步到 K8S

  2. 同时,基座也会持续上报健康状态,Module Controller 会将 Metaspace 容量以及使用量映射为 Node Memory,更新到 K8S


5.6.3 - 6.6.3 核心流程时序

Koupleless ModuleController V2 核心流程时序

基座生命周期

模块发布运维


6 - 6.7 常见问题

6.1 - 6.7.1 常见问题列表(请搜索查询)

使用问题

arklet 安装问题

现象

通过 go install 无法安装 arkctl,执行命令如:

go install koupleless.alipay.com/koupleless/v1/arkctl@latest

报错信息如下:

go: koupleless.alipay.com/koupleless/v1/arkctl@latest: module koupleless.alipay.com/koupleless/v1/arkctl: Get "https://proxy.golang.org/koupleless.alipay.com/koupleless/v1/arkctl/@v/list": dial tcp 142.251.42.241:443: i/o timeout

解决方式

arkctl 是作为 koupleless 子目录的方式存在的,所以没法直接 go get,可以从这下面下载执行文件, 请参考安装 arkctl

模块构建问题

maven 版本太低

现象

构建时,

  • 报错 Unable to parse configuration of mojo com.alipay.sofa:sofa-ark-maven-plugin:.*:repackage for parameter excludeArtifactIds
  • 报错 com.google.inject.ProvisionException: Unable to provision, see the following errors:
  • 报错 Error injecting: private org.eclipse.aether.spi.log.Logger org.apache.maven.repository.internal.DefaultVersionRangeResolver.logger
  • 报错 Caused by: java.lang.IllegalArgumentException: Can not set org.eclipse.aether.spi.log.Logger field org.apache.maven.repository.internal.DefaultVersionRangeResolver.logger to org.eclipse.aether.internal.impl.slf4j.Slf4jLoggerFactory

原因

maven 版本太低

解决方式

需升级到 3.6.1 及以上

配置问题

application.properties 配置

现象

spring.application.name must be configured

原因

application.properties 里未配置 spring.application.name

解决方式

application.properties 里配置 spring.application.name

SOFABoot 基座或模块启动因 AutoConfiguration 失败

现象

报错 The following classes could not be excluded because they are not auto-configuration classes: org.springframework.boot.actuate.autoconfigure.startup.StartupEndpointAutoConfiguration

原因

SOFABoot 正确引入需要同时引入 spring-boot-actuator-autoconfiguration,因为 sofa-boot 里通过代码定义了 spring.exclude.autoconfiguration = org.springframework.boot.actuate.autoconfigure.startup.StartupEndpointAutoConfiguration, 当启动时找不到该类时就会报错。

解决方式

基座或模块里引入 sprign-boot-actuator-autoconfiguration

模块发rest服务webContextPath冲突

现象

报错 org.springframework.context.ApplicationContextException: Unable to start web server; nested exception is java.lang.IllegalArgumentException: Child name xxx is not unique

原因

webContextPath 冲突

解决方式

检查其他模块是否设置相同的webContextPath

jvm参数配置错误

现象

报错 Error occurred during initialization of VM

原因

报错 Error occurred during initialization of VM,一般是jvm参数配置有问题

解决方式

用户侧检查 jvm 参数配置

运行时问题

koupleless 依赖缺失

现象

  • 模块安装时,报错 com.alipay.sofa.ark.exception.ArkLoaderException: [ArkBiz Loader] module1:1.0-SNAPSHOT : can not load class: com.alipay.sofa.koupleless.common.spring.KouplelessApplicationListener

原因

koupleless 依赖缺失

解决方式

请在模块里面添加如下依赖:

<dependency>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-app-starter</artifactId>
    <version>${koupleless.runtime.version}</version>
</dependency>

或者升级 koupleless 版本到最新版本

koupleless 版本较低

现象

  • 模块安装报错 Master biz environment is null
  • 模块静态合并部署无法从指定的目录里找到模块包

解决方式

升级 koupleless 版本到最新版本

<dependency>
    <groupId>com.alipay.sofa.koupleless</groupId>
    <artifactId>koupleless-app-starter</artifactId>
    <version>${最新版本号}</version>
</dependency>

类缺失

现象

  • 报错 java.lang.ClassNotFoundException
  • 报错 java.lang.NoClassDefFoundError

原因

模块/基座 找不到类

解决方式

请根据 模块类缺失 和 基座类缺失 进行排查。

模块类缺失

现象

ArkBiz Loader.*can not load class

原因

模块里缺失对应类的依赖!

解决方式

检查模块里是否包含该类的依赖,如果没有,请增加相应的依赖。

基座类缺失

现象

ArkLoaderException: Post find class XXX occurs an error via biz ClassLoaderHook

原因

设置委托给基座加载的类,但基座里找不到对应类的依赖,或者依赖的版本不对。

解决方式

基座添加相应依赖,或者修改依赖版本。

模块依赖的类存在多个不同的来源

现象

  • 报错 java.lang.LinkageError
  • 报错 java.lang.ClassCastException
  • 报错 previously initiated loading for a different type with name

原因

该类在基座和模块间引入了多个相同的依赖,加载到的类可能来自于不同 ClassLoader

解决方式

在模块主 pom 中,把类所在的包设置为 provided。(做好模块瘦身和基座与模块间的依赖管理。)

方法缺失

现象

java.lang.NoSuchMethodError

原因

报出java.lang.NoSuchMethodError,可能存在存在jar包冲突、依赖未加载的情况

解决方式

请检查是否存在jar包冲突或依赖未加载。

模块直接使用基座数据源

现象

No operation is allowed after dataSource is closed

原因

模块里直接使用了基座里的 dataSource,模块卸载导致基座 dataSource 关闭

解决方式

dataSource 已经关闭,确认模块里是否通过 bean 获取方式直接使用了基座里的 dataSource

Bean 配置问题

现象

  • 报错 org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: Invalid bean definition with name
  • 报错 java.lang.IllegalArgumentException: JVM Reference
  • 报错 Error creating bean with name
  • 报错 BeanDefinitionStoreException: Invalid bean definition with name
  • 报错 org.springframework.beans.FatalBeanException: Bean xx has more than one interface
  • 报错 No qualifying bean of type
  • 报错 BeanDefinitionStoreException: Invalid bean definition with name

原因

工程中 bean 配置问题

解决方式

  1. xml中是否错误配置了 bean, 或者依赖存在问题 2. bean初始化/Bean定义异常, 请检查业务逻辑

Spring Bean 重复定义

现象

There is already xxx bean

原因

业务编码问题:bean 重复定义

解决方式

检查业务侧代码

XML 配置问题

现象

Error parsing XPath XXX Cause: java.io.IOException: Could not find resource

原因

XML文件解析失败, 无法找到对应的依赖配置

解决方式

请用户排查解析失败问题

JMX 配置问题

现象

org.springframework.jmx.export.UnableToRegisterMBeanException: Unable to register MBean

原因

JMX 需要手动配置应用名称

解决方式

给基座加 -Dspring.jmx.default-domain=${spring.application.name} 启动参数

依赖配置

现象

Dependency satisfaction failed XXX java.lang.NoClassDefFoundError

原因

jar包依赖问题, class not found

解决方式

请检查 jar 包依赖, 工程中是否依赖了错误 jar 包, 进行修正

SOFA JVM Service 查找失败

现象

  • 报错 can not find corresponding jvm service
  • 报错 JVM Reference XXX can not find the corresponding JVM service

原因

JVM Reference 依赖的JVM 服务未找到

解决方式

请检查业务代码是否正确,对应的服务是否存在。

内存不足

现象

  • 报错 Insufficient space for shared memory
  • 报错 java.lang.OutOfMemoryError: Metaspace

原因

内存不足或内存溢出

解决方式

请替换或重启机器

hessian版本冲突

现象

Illegal object reference

原因

hessian版本冲突

解决方式

使用mvn dependency:tree查看依赖树,排掉冲突依赖

guice 版本较低

现象 报错 Caused by: java.Lang.ClassNotFoundException: com.google.inject.multibindings.Multibinder

guice_version_incompatibility.png

原因

用户工程与 koupleless 里 guice 版本不一致,且版本较老

解决方式

升级 guice 版本到较新版本,如

<dependency>
    <groupId>com.google.inject</groupId>
    <artifactId>guice</artifactId>
    <version>6.0.0</version>
</dependency>

SOFABoot 健康检查失败

现象

HealthCheck Failed

原因

Sofaboot工程HealthCheck失败

解决方式

请用户排查具体失败原因

需要模块瘦身

现象

  • 报错 java.lang.IllegalArgumentException: Cannot instantiate interface org.springframework.context.ApplicationListener : com.alipay.sofa.koupleless.common.spring.KouplelessApplicationListener
  • 报错 Caused by: java.lang.IllegalArgumentException: class com.alipay.sofa.koupleless.plugin.spring.BizApplicationContextInitializer is not assignable to interface org.springframework.context.ApplicationContextInitializer

原因

模块应该以provided方式引入 springboot 依赖,或者通过配置方式模块瘦身

解决方式

模块做好瘦身,参考这里:模块瘦身

模块与基座共库时,模块启动了基座的逻辑

现象

例如基座引入了 druid,但是模块里没有引入,按照设计模块应该不需要初始化 dataSource,但是如果遇到模块也初始化了 dataSource,那么该行为是不符合预期的,也可能导致报错。

解决方式

  1. 确保模块可以独立构建,也就是可以在模块的目录里执行 mvn clean package,并且不会报错
  2. 升级 koupleless 版本到最新版本 1.0.0

模块启动无法初始化 EnvironmentPostProcessor

现象

模块启动时报这类错误信息 Unable to instantiate com.baomidou.mybatisplus.autoconfigure.SafetyEncryptProcessor [org.springframework.boot.en V.EnvironmentPostProcessor]

解决方式

模块main方法里启动 springboot 时指定 ResourceLoader 的 ClassLoader

SpringApplicationBuilder builder = new SpringApplicationBuilder(Biz1Application.class);
// set biz to use resource loader.
ResourceLoader resourceLoader = new DefaultResourceLoader(
    Biz1Application.class.getClassLoader());
builder.resourceLoader(resourceLoader);
builder.build().run(args);

基座关闭的时候Tomcat报错

现象

基座关闭时,报错:Unable to stop embedded Tomcat

原因

基座关闭时,Tomcat 有自身关闭逻辑, koupleless 增加了关闭逻辑,导致基座关闭会尝试第二次关闭。该信息只是一个警告,不影响基座的正常关闭。

解决方式

无需处理

模块compile引入 tomcat启动导致报错 Caused by: java.lang.Error: factory already defined

现象

详细报错堆栈可以看这里

原因

模块 compile 引入了 tomcat,模块启动时 tomcat 会再次初始化,这时 TomcatURLStreamHandlerFactory 通过 URL::setURLStreamHandlerFactory 注册进 URL, 然后由于基座已经注册过一次,重复注册报错,详细看这里

解决方式

通过代码设置 TomcatURLStreamHandlerFactory.disable()

模块启动在 JdkDynamicAopProxy.getProxy 报错 xxx referenced from a method is not visible from class loader

原因

spring-core 是 6.0.9 的版本,内部逻辑存在bug,这里即使传入 BizClassLoader,也会因为 BizClassLoader 没有 parent 而强制切换 classLoader 成了 基座 ClassLoader

public Object getProxy(@Nullable ClassLoader classLoader) {
    if (logger.isDebugEnabled()) {
        logger.debug("Creating JDK dynamic proxy: " + this.advised.getTargetSource());
    }
    if (classLoader == null || classLoader.getParent() == null) {
        // JDK bootstrap loader or platform loader
        // use higher-level loader which can see spring infrastructure classes
        classLoader = getClass().getClassLoader();
    }
    return Proxy.newProxyInstance(classLoader, this.proxiedInterfaces, this);
}

解决方式

使用 spring-core 更新版本,例如 6.0.11

Hessian 序列化反序列化不支持多应用(classLoader)

解决方式

参考 https://github.com/koupleless/koupleless/issues/196, 可以升级 hessian 版本到 4.x 以上,或者自定义多 ClassLoader 的 SerializerFactory

基座启动时报错 IllegalArgumentException: File must exist

原因

springboot 新版本更新了文件路径格式,导致部分对文件路径的逻辑发生错误,详见 https://github.com/koupleless/koupleless/issues/161

解决方式

springboot 打包插件里增加配置 <loaderImplementation>CLASSIC</loaderImplementation>,回退会原有路径格式

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <executions>
        <execution>
          <goals>
            <goal>repackage</goal>
          </goals>
          <configuration>
            <loaderImplementation>CLASSIC</loaderImplementation>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>