多模块运行时适配或最佳实践
1 - 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 - 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, 这里需要做两部分事情:
- 获取到日志配置文件
- 解析日志配置文件里的变量值 这两部分在多模块里都可能存在问题,先看下普通应用过程是如何完成这两步的
获取日志配置文件
可以看到是通过 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);
- static final修饰的变量只会在类加载的时候初始化话一次
- 组件依赖下沉基座后,类加载器使用的为基座的类加载器,初始化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 进行适配。
多模块适配点
- getLoggerContext() 能拿到模块自身的 LoggerContext
需要调整成使用 ContextMapLookup,从而模块日志能获取到模块应用名,日志能打印到模块目录里
a. 模块启动时将 application.properties 的值设置到 ThreadContext 中 b. 日志配置时,只能使用 ctx:xxx:xxx 的配置方式
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 - 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. 模块维度的服务、配置资源管理
- 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<>();
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 使用样例
4 - 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 使用样例
5 - ehcache 的多模块化最佳实践
为什么需要最佳实践
CacheManager 初始化的时候存在共用 static 变量,多应用使用相同的 ehcache name,导致缓存互相覆盖。
最佳实践的几个要求
- 基座里必须引入 ehcache,模块里复用基座
在 springboot 里 ehcache 的初始化需要通过 Spring 里定义的 EhCacheCacheConfiguration 来创建,由于 EhCacheCacheConfiguration 是属于 Spring, Spring 统一放在基座里。
这里在初始化的时候,在做 Bean 初始化的条件判断时会走到类的检验,
如果 net.sf.ehcache.CacheManager 是。这里会走到 java native 方法上做判断,从当前类所在的 ClassLoader 里查找 net.sf.ehcache.CacheManager 类,所以基座里必须引入这个依赖,否则会报 ClassNotFound 的错误。
- 模块里将引入的 ehcache 排包掉(scope设置成 provide,或者使用自动瘦身能力)
模块使用自己 引入的 ehcache,照理可以避免共用基座 CacheManager 类里的 static 变量,而导致报错的问题。但是实际测试发现,模块安装的时候,在初始化 enCacheCacheManager 时,
这里在 new 对象时,需要先获得对象所属类的 CacheManager 是基座的 CacheManager。这里也不能讲 CacheManager 由模块 compile 引入,否则会出现一个类由多个不同 ClassLoader 引入导致的问题。所以结论是,这里需要全部委托给基座加载。
最佳实践的方式
- 模块 ehcache 排包瘦身委托给基座加载
- 如果多个模块里有多个相同的 cacheName,需要修改 cacheName 为不同值。
- 如果不想改代码的方式修改 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>
- 需要把 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, 从而报错。
最佳实践的样例
样例工程请参考这里
6 - 基座与模块间类委托加载原理介绍
多模块间类委托加载
SOFAArk 框架是基于多 ClassLoader 的通用类隔离方案,提供类隔离和应用的合并部署能力。本文档并不打算介绍 SOFAArk 类隔离的原理与机制,这里主要介绍多 ClassLoader 当前的最佳实践。
当前基座与模块部署在 JVM 上的 ClassLoader 模型如图:
当前类委托加载机制
当前一个模块在启动与运行时查找的类,有两个来源:当前模块本身,基座。这两个来源的理想优先级顺序是,优先从模块中查找,如果模块找不到再从基座中查找,但当前存在一些特例:
- 当前定义了一份白名单,白名单范围内的依赖会强制使用基座里的依赖。
- 模块可以扫描到基座里的所有类:
- 优势:模块可以引入较少依赖
- 劣势:模块会扫描到模块代码里不存在的类,例如会扫描到一些 AutoConfiguration,初始化时由于第四点扫描不到对应资源,所以会报错。
- 模块不能扫描到基座里的任何资源:
- 优势:不会与基座重复初始化相同的 Bean
- 劣势:模块启动如果需要基座的资源,会因为查找不到资源而报错,除非模块里显示引入(Maven 依赖 scope 不设置成 provided)
- 模块调用基座时,部分内部处理传入模块里的类名到基座,基座如果存在直接从基座 ClassLoader 查找模块传入的类,会查找不到。因为委托只允许模块委托给基座,从基座发起的类查找不会再次查找模块里的。
使用时需要注意事项
模块要升级委托给基座的依赖时,需要让基座先升级,升级之后模块再升级。
类委托的最佳实践
类委托加载的准则是中间件相关的依赖需要放在同一个的 ClassLoader 里进行加载执行,达到这种方式的最佳实践有两种:
强制委托加载
由于中间件相关的依赖一般需要在同一个 ClassLoader 里加载运行,所以我们会制定一个中间件依赖的白名单,强制这些依赖委托给基座加载。
使用方法
application.properties 里增加配置 sofa.ark.plugin.export.class.enable=true
。
优点
模块开发者不需要感知哪些依赖属于需要强制加载由同一个 ClassLoader 加载的依赖。
缺点
白名单里要强制加载的依赖列表需要维护,列表的缺失需要更新基座,较为重要的升级需要推所有的基座升级。
自定义委托加载
模块里 pom 通过设置依赖的 scope 为 provided
主动指定哪些要委托给基座加载。通过模块瘦身把与基座重复的依赖委托给基座加载,并在基座里预置中间件的依赖(可选,虽然模块暂时不会用到,但可以提前引入,以备后续模块需要引入的时候不需再发布基座即可引入)。这里:
- 基座尽可能的沉淀通用的逻辑和依赖,特别是中间件相关以
xxx-alipay-sofa-boot-starter
命名的依赖。 - 基座里预置一些公共依赖(可选)。
- 模块里的依赖如果基座里面已经有定义,则模块里的依赖尽可能的委托给基座,这样模块会更轻(提供自动模块瘦身的工具)。模块里有两种途径设置为委托给基座:
- 依赖里的 scope 设置为 provided,注意通过 mvn dependency:tree 查看是否还有其他依赖设置成了 compile,需要所有的依赖引用的地方都设置为 provided。
- biz 打包插件
sofa-ark-maven-plugin
里设置excludeGroupIds
或excludeArtifactIds
<plugin>
<groupId>com.alipay.sofa</groupId>
<artifactId>sofa-ark-maven-plugin</artifactId>
<configuration>
<excludeGroupIds>io.netty,org.apache.commons,......</excludeGroupIds>
<excludeArtifactIds>validation-api,fastjson,hessian,slf4j-api,junit,velocity,......</excludeArtifactIds>
<declaredMode>true</declaredMode>
</configuration>
</plugin>
通过 2.a 的方法需要确保所有声明的地方 scope 都设置为provided,通过2.b的方法只要指定一次即可,建议使用方法 2.b。
- 只有模块声明过的依赖才可以委托给基座加载。
模块启动的时候,Spring 框架会有一些扫描逻辑,这些扫描如果不做限制会查找到模块和基座的所有资源,导致一些模块明明不需要的功能尝试去初始化,从而报错。SOFAArk 2.0.3 之后新增了模块的 declaredMode, 来限制只有模块里声明过的依赖才可以委托给基座加载。只需在模块的打包插件的 Configurations 里增加 <declaredMode>true</declaredMode>
即可。
优点
不需要维护 plugin 的强制加载列表,当部分需要由同一 ClassLoader 加载的依赖没有设置为统一加载时,可以修改模块就可以修复,不需要发布基座(除非基座确实依赖)。
缺点
对模块瘦身的依赖较强。
对比与总结
依赖缺失排查成本 | 修复成本 | 模块改造成本 | 维护成本 | |
---|---|---|---|---|
强制加载 | 类转换失败或类查找失败,成本中 | 更新 plugin,发布基座,高 | 低 | 高 |
自定义委托加载 | 类转换失败或类查找失败,成本中 | 更新模块依赖,如果基座依赖不足,需要更新基座并发布,中 | 高 | 低 |
自定义委托加载 + 基座预置依赖 + 模块瘦身 | 类转换失败或类查找失败,成本中 | 更新模块依赖,设置为 provided,低 | 低 | 低 |
结论:推荐自定义委托加载方式
- 模块自定义委托加载 + 模块瘦身。
- 模块开启 declaredMode。
- 基座预置依赖。
declaredMode 开启方式
开启条件
declaredMode 的本意是让模块能合并部署到基座上,所以开启前需要确保模块能本地启动成功。
如果是 SOFABoot 应用且涉及到模块调用基座服务的,本地启动因为没有基座服务,可以通过在模块 application.properties 添加这两个参数进行跳过(SpringBoot 应用无需关心):
# 如果是 SOFABoot,则:
# 配置健康检查跳过 JVM 服务检查
com.alipay.sofa.boot.skip-jvm-reference-health-check=true
# 忽略未解析的占位符
com.alipay.sofa.ignore.unresolvable.placeholders=true
开启方式
模块打包插件里增加如下配置:
开启后的副作用
如果模块委托给基座的依赖里有发布服务,那么基座和模块会同时发布两份。
7 - 如果模块独立引入 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
所以模块框架这部分需要委托给基座加载。