当几个软件包对相同的共享包或库有依赖性,但它们依赖于不同的、不兼容的共享包版本时,就会出现依赖性问题。如果共享包或库只能安装一个版本,用户可能需要通过获得较新或较旧版本的依赖包来解决这个问题。反过来,这可能会破坏其他的依赖关系。
【依赖冲突】问题是软件工程广泛存在的问题,换句话说,各语言生态如Python、Golang、Nodejs、Java等都存在类似问题。但是由于Java语言的特殊机制,【依赖冲突】问题在Java中似乎有完美的解决方案,那就是【类隔离容器】。
从2000年的开源规范OSGI,到阿里巴巴自研Pandora容器,再到蚂蚁金服开源sofa-ark,业界在【类隔离容器】这个领域的实践方兴未艾。那到底什么是类隔离容器?怎么实现类隔离容器?为什么它听起来很完美但是却没有成为主流实践?
本文代码均为示意的伪代码。
二、类隔离容器
当项目依赖树变得复杂时,不可避免的会出现不同的组件依赖同一个组件的不同版本的问题。如下图,3个组件分别依赖了 maven-settings 组件的2个版本:3.0、3.3.9;plexus-interpolation组件同理。
图片
图片
当项目中只有一个依赖空间时,项目需求的多个版本的组件最终只会有一个版本进入项目依赖空间,极易因为上层组件对版本需求的众口难调而出现ClassNotFoundException、NoSuchMethodException等版本兼容性问题。
为解决这个问题,业界开始考虑通过Java类加载隔离来在项目运行时创建多个隔离的依赖空间。每个依赖空间中可以各自使用相同组件的不同版本,这种隔离的依赖空间即为:类隔离容器。
如下图,项目中存在3个类隔离容器,maven-settings组件在两个容器中分别存在3个版本。
图片
这里的maven-*只是Jar包名称,和mvn工具无关,只是笔者手上恰好有这个案例。
类隔离容器劫持、干预了Java类加载流程,让同一个组件的多个版本可以在同一个项目中并存。
三、类加载API
Java是一种强类型的动态语言,其代码符号(类名、方法名、字段名)都在运行时动态链接,通过【类加载器】来实现运行时的类搜索和代码装载。这种动态特性赋予了框架开发者极大的便利性,支撑了大量企业级开发框架的实现,提高了上层业务代码的迭代效率。这也是Java语言二十几年如一日占据编程语言排行榜前列的一个重要原因。
图片
TIOBE编程社区指数-2024(https://www.tiobe.com/tiobe-index/)
为支撑上述类加载能力,同时赋予开发者自定义类加载流程的能力,Java Runtime定义了ClassLoader这一API。抽象的API如下:
图片
ClassLoader的实现者负责根据【位置无关】的类标识,定位、装载类。所谓【位置无关】说的是,JVM不关心这个类文件的物理位置是在网络上、磁盘里、内存里。
由于Classs类型的返回值无法由开发者自行构造,涉及JVM内部的状态联动,因此JVM会暴露一个构造Class对象的工具API。抽象的API如下:
图片
该parseAndLinkClass方法由JVM实现,JVM内部会进行我们八股文都背过的类验证、类解析、类初始化等标准动作。
因此,开发者自定义类加载流程的样板代码如下:
图片
Java提供了类似上述样板代码的具体实现,即:java.lang.ClassLoader,其实就是大家都熟悉的【模板方法设计模式】
上述通俗的、抽象的API能力,映射到Java的具体实现分别为:
装载类
图片
java.lang.ClassLoader#loadClass(java.lang.String)
定义类
java.lang.ClassLoader#defineClass0
JNI方法实现
图片
jdk/src/share/native/java/lang/ClassLoader.c
四、类的相等性:
ClassCastException
尽管在源代码层面,我们用【类的全限定名】作为编码时定位类的标识,但是在JVM内部,类的标识是一个联合索引。
JVM内部使用
通俗的伪代码来表达的话,上述ClassLoadUtil#parseAndLinkClass方法的实现如下:
图片
defineClass时,创建的Class对象上会关联Loader。
具体到Java中的java.lang.Class类,我们可以看到如下字段:
图片
java.lang.Class#classLoader
上述类加载特性,在复杂的类加载逻辑下如果没有处理好的话极易产生类型转换异常:ClassCastException。如下示例:
图片
图片
图片
如果Type类同时被两个类加载器加载在JVM内部产生了Type_1、Type_2两个版本的类型(Class对象)。
LoadTest类中的Type符号链接到了Type_1。
TypeUtil类中的Type符号链接到了Type_2。
那么,当LoadTest.main方法执行时即会产生ClassCastException异常。
因为TypeUtil.newType方法返回的Type_2类型的对象,和LoadTest.mian方法中声明的Type_1类型的typeVar变量的类型不兼容,无法进行隐式的类型转换。
类隔离容器的需求天然需要同名类存在多个版本,因此类隔离容器的实现和使用时需要极小心的设计、处理该问题。这种问题排查起来非常费劲。
五、类加载编排、委托
综上分析,我们发现在Java层面实现load一个类并不复杂,只需要根据类名拿到二进制的字节码,然后调用JVM提供的工具方法就行了。
到这里,事情已经回到我们最熟悉不过的CRUD主场,我们可以用各种我们熟悉的设计模式来实现特定的类加载业务需求,其中最重要的设计模式即为:委托模式。
第一个业务需求是类加载的安全性。Java标准库自带了大量易用的工具和数据结构,这部分代码的物理位置和业务代码不在一起。为避免项目中的恶意代码使用标准库同名的类来干坏事,我们需要实现类加载优先级,即加载一个类时优先从JRE目录加载,JRE目录中加载不到时再从项目中加载。
第二个业务需求是类的复用。如Tomcat场景,一个Tomcat进程可以托管多个Web服务(war包)。每个Web服务自身的业务代码和依赖是不同的,但是各个Web服务依赖的Servlet API、Tomcat API是相同的,因为这是Tomcat容器提供的公共的Runtime。考虑到上述【类的相等性】,我们希望这些Runtime类只有一个版本,以避免访问Runtime API时出现ClassCastException。
那么我们重新实现上述AbstractClassLoader如下:
图片
继而我们可以基于上述模板类,构造、编排我们的自定义类加载逻辑:
图片
上述代码通过编排类加载器,实现了如下项目依赖空间拓扑:
图片
综上,我们在Java Runtime的基础ClassLoader机制上,通过非常熟悉的业务编排实现了类加载的安全性需求、共享复用需求,最终呈现了一个树形的类加载器拓扑。
不同的类加载需求需要编排出不同的类加载器拓扑,比如我们讨论的【类隔离容器】需求,需要编排出更复杂的类加载器拓扑。但是其核心的编排思路都是相似的~
六、类加载劫持
到这里,我们已经有足够的技术储备来根据业务需求编排类加载器拓扑以达成目的。但是遗留了一个关键的问题:
怎么样才能让Java Runtime在加载、链接代码符号时,使用我们构造出来的自定义类加载器呢?
因为如果我们构造出来的类加载器不能参与到类加载流程,那其实就是一个普通的Java对象,没啥用。
要解决这个问题,我们需要参考JVM规范明确类加载器会被如何获取和使用,因为类加载器本质上是供Java Runtime使用的SPI。
JVM规范对这一块的阐述是严谨但抽象的,但通俗来说就一个原则:如果一个类C1是由CL加载器加载(defineClass)的,那么,C1触发的的其他类如Cn的加载和链接,也会委托给CL。示例如下:
图片
图片
因为app1.Main类是由app1Loader加载,那么app1.Main依赖的App1Service类也会隐式的交给app1Loader加载。这个过程是JVM在解析、链接app1.Main类的时候自动进行的。
也就是说,当我们指定某个类加载器CL加载项目的EntryPoint并执行后,后续触发的类加载动作都会交给指定的类加载器CL或者CL委托的其他类加载器。Java项目中的EntryPoint往往是项目中的main方法。
这里有点绕。换句话说,某个Class类对象C1依赖的其他类的加载都会交给C1.classLoader来进行。注意,上面【类的相似性】一节说过,每个Class对象上都持有加载它的ClassLoader的引用。
那么,想让3个WebApp在各自类空间中运行的方式就很简单了:
图片
上述流程还遗留一个问题,那就是ServiceLoader场景。
为打破呆板的双亲委派机制实现某种意义上的IOC,Java提供了contextClassLoader机制。contextClassLoader关联在Thread对象上,并且会在父子线程中复制、传播。
java.lang.Thread#getContextClassLoader
为了让上述3个WebApp中正常使用ServiceLoaderAPI或类似的SPI框架,我们需要做如下特别处理:
至此,我们就实现了Tomcat场景下的类加载劫持、类隔离、类共享。
到这里,我们已经掌握了实现类隔离容器的核心基础。
总结来说,只要我们能在应用的EntryPoint(main方法)中合理的介入、干预,就能实现灵活的类加载业务。
七、类隔离模块:Bundle
回到最开始的需求,我们希望可以在项目中达成如下依赖结构:
为了实现版本隔离、共存,上述mave-core、maven-compat、maven-xxx组件会将其依赖的maven-settings Jar文件按特定布局打包到自身的jar包中,形成各自独立的依赖空间,供运行时提取、加载。
OSGI中将上述隔离的依赖空间或者类隔离容器称为bundle。需要进行类隔离的组件按bundle文件布局来交付自己的代码和依赖。
图片
每个bundle是一个FatJar,通俗来说是一个包含自身依赖的Jar文件的Jar文件。类似如下Jar文件:
图片
可以把上述dubbo-demo jar文件想象成我们熟悉的mybatis框架。该模块把mybaits框架自身的代码和它依赖的三方包按设计的布局打包到同一个Jar中。
我们知道,Java自带的URLClassLoader天然支持从Jar文件中搜索、读取class文件,但是不支持上述嵌套Jar。
解决这个问题有两个方案:
解压FatJar
在进程启动时,类隔离容器底座识别出ClassPatch中存在上述类型的bundle Jar后,提前将上述FatJar解压到本地磁盘。后续就简单了,无非就是在指定目录搜索类和Jar。
文件切片
我们知道,Jar文件本质上就是ZIP格式的文件,而ZIP文件的逻辑结构是一个Map。
图片
如上图,test.jar中有两个文件,一个是a.b.C.class文件,另一个是dep.jar。该文件在磁盘上的抽象布局如下:
图片
ZIP文件除文件元数据外,整体分为两部分。
- 数据区:存放文件的内容。
- 索引区:存放文件名称和文件内容的偏移量和长度。
因此,在技术上我们可以对FatJar文件做切片。即在不解压FatJar的前提下,将其中一个区间当成jar文件读取。如上图,我们解析test.jar索引区得到dep.jar文件的长度为4000字节,在外层jar文件的1000偏移处,那么我们读取它内部嵌套的Jar文件的伪代码如下:
图片
这一块说来话长,全是花活。spring-boot就是使用类似方式来拍平嵌套的Jar文件。
可以参考相关资料:
【SpringBoot】服务 Jar 包的启动过程原理(https://www.cnblogs.com/kukuxjx/p/18207068)
八、类导入/导出:Bundle元信息
到这里,我们可以初步勾勒类隔离容器的代码蓝图了。
图片
- 【底座】需要在执行流进入业务main方法前,提前执行。
- 【底座】扫描项目中的依赖,区分Jar依赖和Bundle依赖。
- 【底座】为每个Bundle依赖创建独立的Bundle类加载器(N个)。
- 【底座】为Bundle以外的业务代码和普通Jar创建类加载器(1个)。
- 【底座】将上述N+1个类加载器状态编排到一起,拼凑成完整的依赖视图。
- 【底座】初始化当前线程contextClassLoader。
- 【底座】使用业务类加载器搜索、加载main方法所在类(EntryPoint)。
- 【底座】调用业务代码的main方法。
这里存在两个问题:
- 【底座】怎么区分ClassPath下的Jar文件是普通Jar还是Bundle Jar?这个一般是通过在打包时向Bundle Jar中注入特征文件来实现。比如sofa-ark在打包Bundle Jar时,会在Jar中注入如下路径固定的标记文件:com/alipay/sofa/ark/plugin/mark
图片
com.alipay.sofa.ark.spi.constant.Constants#ARK_PLUGIN_MARK_ENTRY
图片
- 项目中有那么多类加载器(N+1),当我们加载一个类时,到底应该由哪个类加载加载呢?这部分信息是控制bundle正常工作的元信息,需要每个bundle的维护者提供给【底座】读取、使用。即,每个bundle中必须要提供这个Bundle导出的类、导入的类。这类元信息一般会在bundle jar的Manifest文件提供,下图是一个OSGI规范下,bundle jar中Manifest文件提供的类导入/导出信息。
图片
上述信息表明:
- 该bundle jar向外暴露com.sample.myservice.api,即该包下的类由这个bundle类加载来加载。
- 该bundle jar依赖了org.apache.commons.logging,需要由其他类加载器来加载、提供。
是不是有点像 JDK9 的新特性:模块化?
除此之外,一般还会提供优先级等其他用于控制类加载过程的元信息,毕竟可能有多个bundle暴露相同的类。相关的细节信息很多,在各个具体的实现(开源的OSGI、阿里巴巴的Pandora、蚂蚁金服的sofa-ark)上可能有差异,但是大同小异。
九、Bundle依赖隔离
到这里,我们终于可以看下怎么实现一个类隔离容器了。
业务类加载器
图片
Bundle类加载器
图片
类加载器管理器
图片
类隔离容器底座
图片
图片
使用姿势
图片
上述代码仅为理论示意,并不能直接运行,读者会意即可。
以上,我们就实现了一个简单的类隔离容器,最终形成的类加载器拓扑如下:
图片
最终实现了每个Bundle优先使用自身内部嵌入的Jar依赖,从而实现每个Bundle Jar有一个独立的依赖空间,避免了依赖冲突。
十、没有银弹
当bundle jar中的嵌套依赖不向外逃逸时,一切都工作的很好。但是如果嵌套依赖中的API被跨bundle耦合、交互,那事情就变得棘手起来。
考虑如下的场景:
图片
- Bundle BBB中导出了如下Service:
图片
- Bundle AAA中导出了如下Service:
图片
- AAAService依赖了lang3-1.0中的Pair类,BBBService依赖了lang3-2.0中的Pair类。那么请问,AAAService这个类,应该使用哪个版本的lang3?1.0还是2.0?
使用1.0版本:action2方法可以正常工作,因为action2方法就是在1.0版本下编写、编译的。但是这个这样action1方法又无法正常工作了。因为action1方法中调用的BBBService的action方法预期的参数类型是2.0版本的Pair类。
使用2.0版本:那还是上面同样的道理。
如果AAAService使用1.0版本的Pair类,BBBService使用2.0版本的Pair类,那么又会出现我们上面着重强调过的【类的相等性】问题,一定会产生ClassCastException。
十一
总结
刚进入一个陌生领域就陷入代码细节并不是一个高效的方式,所以本文中笔者尽可能的使用伪代码、示意代码来进行论述。
一路梳理下来,我们最终通过类加载器编排,实现了一个理论上的类隔离容器。尽管没有具体的代码实现,但是相信看到这里,读者们已经对类隔离机制有了一个较为系统的认识。
总的来说,Java类隔离容器的思路是在Java语言既有特性的基础上,利用类加载劫持、类加载器编排实现了一套多版本类并存的机制,确实可以减少某些场景下的类版本冲突的问题。但是它解决了一些问题,但是同样的也带来了新的问题。
- 排障心智:类隔离机制构造了一个复杂的类加载器拓扑,当因为cornor case出现了类加载异常时,bundle组件的使用者是一脸懵逼的。本来遇到类似ClassNotFoundException、NoSuchMethodException问题时,组件使用者可以根据项目依赖树所见即所得的按沉淀的经验排查、处置。但是当你用【嵌套Jar+编排加载机制】交付组件后,之前沉淀的相关排障心智都没用了。
- 迁移成本:在组织从0到1起步阶段介入进行上述改造是合适的、成本极低的,但是没人能顾得上这个。在组织从80到100的阶段发现类隔离机制能解决一些问题,但是这个时期各个业务项目的代码结构、组件版本、组件使用姿势百花齐放。想要技改、收敛到bundle jar模式,成本比较大且客观上存在一个研发效率、业务稳定性的阵痛期。
- 元信息维护:如上梳理,bundle jar交付时,bundle维护者需要梳理其导出的类、导入的类。这个只能人肉梳理,可能会漏、可能会错;且因为多个bundle在运行期的化学反应,漏、错的异常表现很不直观,难以诊断、排查。如果各个bundle维护者都在一个部门下那沟通、处理起来还好,如果是跨部门的多个bundle互相打架,事情就比较麻烦。
笔者以为,类隔离机制的高价值场景应该是特定领域内部使用的JVM租户。由于JVM比较吃资源,某些轻量逻辑(FAAS)如果单独启动一个进程来执行,有点类似于用集装箱运一只篮球,性价比很低,那干脆大家一起众筹拼集装箱得了。
又回到了十年前用Tomcat托管多个WebApp的模式...
如上图,在JVM进程上构建一个应用引擎,可以根据JVM资源情况动态的将包含代码和依赖的bundle jar调度到JVM上运行。JVM租户的主要问题是资源隔离性不够,比如CPU、MEM和IO。但是如果这个平台只是内部特定场景下、特定开发人员使用问题倒也不大。
以上均为笔者一家之言,欢迎指正~
参考
- 微服务的灾难-依赖地狱(https://xargin.com/disaster-of-microservice-dephell/)
- 如何打包 Ark Plugin(https://www.sofastack.tech/projects/sofa-boot/sofa-ark-ark-plugin-demo/)
- OSGi 捆绑软件清单文件(https://www.ibm.com/docs/zh/was-zos/9.0.5?topic=files-example-osgi-bundle-manifest-file)