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

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

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

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

模块改造方式

详细查看源码

7 - 6.5.3.7 模块使用宝蓝德 web 服务器

koupleless-adapter-bes

koupleless-adapter-bes 是为了适配宝蓝德(BES)容器,以支持基座模块复用相同的端口。仓库地址为 koupleless-adapter-bes(感谢社区同学陈坚贡献)。

项目目前仅在BES 9.5.5.004 版本中验证过,其他版本需要自行验证,必要的话需要根据相同的思路进行调整。

如果多个BIZ模块不需要使用同一端口来发布服务,只需要关注下文安装依赖章节提到的注意事项即可,不需要引入本项目相关的依赖。

快速开始

0. 前置条件

jdk8

koupleless >= 1.3.1 sofa-ark >= 2.2.14

jdk17

koupleless >= 2.1.6 sofa-ark >= 3.1.7

如果不满足改条件,需要按照该文档的老版本进行操作,可通过 github 文档源码查看该文档的老版本。

1. 安装依赖

首先需要确保已经在maven仓库中导入了BES相关的依赖,参考导入脚本如下:

mvn install:install-file -Dfile=D:/software/xc/BES-EMBED/bes-lite-spring-boot-starter-9.5.5.004.jar -DgroupId=com.bes.besstarter -DartifactId=bes-lite-spring-boot-starter -Dversion=9.5.5.004 -Dpackaging=jar

mvn install:install-file -Dfile=D:/software/xc/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=D:/software/xc/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=D:/software/xc/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>${sofa.ark.version}</version>
</dependency>
<dependency>
   <groupId>com.alipay.sofa</groupId>
   <artifactId>bes-sofa-ark-springboot-starter</artifactId>
   <version>${sofa.ark.version}</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其中 SOFAArk组件对于依赖包的识别机制与BES的包结构冲突,参考这里

需要在模块根目录 ark 配置文件中(conf/ark/bootstrap.propertiesconf/ark/bootstrap.yml)增加白名单

declared.libraries.whitelist=com.bes.besstarter:bes-sofa-ark-springboot-starter

5. 完成

完成上述步骤后,即可在 Koupleless 基座和模块中使用 BES 启动项目。

8 - 6.5.3.8 模块使用东方通 web 服务器

koupleless-adapter-tongweb

koupleless-adapter-tongweb 是为了适配东方通(TongWEB)容器,仓库地址为:koupleless-adapter-tongweb(感谢社区同学陈坚贡献)。

项目目前仅在tongweb-embed-7.0.E.6_P7 版本中验证过,其他版本需要自行验证,必要的话需要根据相同的思路进行调整。

如果多个BIZ模块不需要使用同一端口来发布服务,只需要关注下文安装依赖章节提到的注意事项即可,不需要引入本项目相关的依赖。

快速开始

0. 前置条件

jdk8

koupleless >= 1.3.1 sofa-ark >= 2.2.14

jdk17

koupleless >= 2.1.6 sofa-ark >= 3.1.7

如果不满足改条件,需要按照该文档的老版本进行操作,可通过 github 文档源码查看该文档的老版本。

1. 安装依赖

首先需要确保已经在 maven 仓库中导入了 TongWEB 相关的依赖,参考导入脚本如下:

mvn install:install-file -DgroupId=com.tongweb.springboot -DartifactId=tongweb-spring-boot-starter -Dversion=7.0.E.6_P7 -Dfile="XXX/tongweb-spring-boot-starter-7.0.E.6_P7.jar" -Dpackaging=jar
mvn install:install-file -DgroupId=com.tongweb -DartifactId=tongweb-embed-core -Dversion=7.0.E.6_P7 -Dfile="XXX/tongweb-embed-core-7.0.E.6_P7.jar" -Dpackaging=jar
mvn install:install-file -DgroupId=com.tongweb -DartifactId=tongweb-lic-sdk -Dversion=4.5.0.0 -Dfile="XXX/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>${sofa.ark.version}</version>
    </dependency>
    
    <dependency>
        <groupId>com.alipay.sofa</groupId>
        <artifactId>tongweb7-sofa-ark-springboot-starter</artifactId>
        <version>${sofa.ark.version}</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>tongweb-spring-boot-starter</artifactId>
            <version>7.0.E.6_P7</version>
        </dependency>
        <dependency>
            <groupId>com.tongweb</groupId>
            <artifactId>tongweb-embed-core</artifactId>
            <version>7.0.E.6_P7</version>
        </dependency>
        <dependency>
            <groupId>com.tongweb</groupId>
            <artifactId>tongweb-lic-sdk</artifactId>
            <version>4.5.0.0</version>
        </dependency>

4. 基座中增加宝蓝德特殊配置

为什么需要这个配置, 是因为 koupleless其中 SOFAArk组件对于依赖包的识别机制与BES的包结构冲突,参考这里

需要在基座根目录 ark 配置文件中(conf/ark/bootstrap.propertiesconf/ark/bootstrap.yml)增加白名单

declared.libraries.whitelist=com.tongweb.springboot:tongweb-spring-boot-starter,com.tongweb:tongweb-embed-core,com.tongweb:tongweb-lic-sdk

5. 完成

完成上述步骤后,即可在 Koupleless 基座和模块中使用 TongWEB 启动项目。

9 - 6.5.3.9 模块使用 Dubbo

模块拦截器(Filter)

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

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

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

开启方式

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

开启后的副作用

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


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

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