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": {...}      
   }
}  

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

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。
如果你有更多的想法,或输入,欢迎开源社区讨论!

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 多模块集成测试用例

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源码

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, 从而报错。

最佳实践的样例

样例工程请参考这里

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源码

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;
  }
  } 

模块改造方式

详细查看源码

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启动项目。

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启动项目。

3.9 - 6.5.3.9 模块使用 Dubbo

模块拦截器(Filter)

模块可以使用本模块上定义的拦截器,也可以使用基座上定义的拦截器。

⚠️注意:避免模块拦截器的名称与基座拦截器名称一致。如果名称一致,则将使用基座拦截器。

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

开启方式

模块打包插件里增加如下配置:

开启后的副作用

如果模块委托给基座的依赖里有发布服务,那么基座和模块会同时发布两份。


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

所以模块框架这部分需要委托给基座加载。



4 - 6.5.4 模块拆分工具

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,查看提示。

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 生态。