Skip to content
dragonpan edited this page Oct 21, 2016 · 1 revision

java 模块化系统畅想

classpath 问题

java 应用程序的入口是一个类的静态 main 方法. 启动一个 java 程序的命令如下所示:

java -classpath a.jar:b.jar com.example.App

jvm 启动时, 自动用 classpath 上的所有路径列表, 创建一个类加载器, 叫做系统类加载器. 然后用系统类加载器加载入口类, 并执行入口类的 main 方法. classpath 由 CLASSPATH 环境变量 和 -classpath 命令行参数合并产生.

jvm 默认存在 3 个类加载器, 父委托关系如下:

"system class loader" -> "ext class loader" -> "bootstrap class loader"

扩展阅读: java 类加载器.

web 容器会为每个 webapp 创建一个类加载器, 以 WEB-INF/classes 目录和 WEB-INF/lib 目录下的所有 jar 包作为 "classpath".

通常 java 应用不会自己创建类加载器. 除了标准库以外, 应用需要的所有 jar 包都放在 classpath 下. 建立 classpath 的过程非常烦琐痛苦, 特别是依赖很多的富依赖库. 这个问题叫做 classpath 地狱 (classpath hell).

那时候没有 maven 也没有依赖管理. 应用依赖了 a, 我们添加 a.jar, a 又依赖了 b, c, 我们再添加 b.jar, c.jar, b 又依赖了 d, e, 我们再添加 d.jar, e.jar. 我们只能不断往 classpath 添加 jar 包, 直到 jvm 不再报错.

maven 依赖管理

早期解决依赖的一个土办法是先手动解决依赖, 再将结果缓存起来. 早期 spring 发行的时候都会提供一个 "-with-dependencies" 大礼包. spring 自身只有几 MB, 而包含各种东西和依赖库的大礼包有 70, 80 多 MB. 经常与 spring 一起使用的 hibernate 也提供了依赖大礼包. 用户通常需要将两个大礼包解压合并到一个 lib 目录, 如果两者依赖了同一个库的不同版本, 还要手动去重. 为了保存依赖, 一些应用甚至将依赖 jar 和应用源码一起放到 svn 上. 这种方式有很多缺点.

当时我们使用 MyEclipse 开发, MyEclipse 的一个优点就是整合了 spring, hibernate, strusts 等富依赖库, 应用选择需要的富依赖库后, MyEclipse 将所有依赖 jar 整合到 classpath. 讽刺的是, 学习了 maven 后我发现已经不需要使用 MyEclipse 了, 使用 eclipse 就够了. MyEclipse 解决了流行库的问题, maven 解决了所有问题.

maven 实现了程序库依赖管理.

  1. maven 仓库. 一个程序库, 叫做一个 "artifact", 即一个 jar 包, 对应一个 pom 文件, jar 包和 pom 文件都保存在仓库上. 一个 artifact 可以用一个 maven 仓库坐标唯一定位.

  2. pom 文件记录了程序库自身及其依赖库的坐标, 通过坐标可以在仓库上找到依赖库的 jar 包和 pom, 从而递归找到所有传递依赖.

一个应用也是一个 artifact, 包含一个 pom 文件. 应用自身及其所有依赖都通过 maven 管理. maven 还定义了依赖调解规则, 实现了自动解析依赖, 构建应用的 "classpath". maven 实现了将 jar 包从应用源码移动到 maven 仓库, 优雅而合理. maven 实现了本地仓库缓存, 本地仓库是远程仓库的一个子集, 被用到的 jar 包将被同步到本地仓库. 本地仓库有很多优点, 应用编译、运行时可以直接从本地仓库加载 jar 包, 避免下载, 拷贝.

现在几乎所有 java 库都在 maven 仓库上, 几乎所有 java 项目都使用 maven 作依赖管理. maven 还是一个构建工具, 但依赖管理无疑是其最重要最具革命性的功能. 一些其他构建工具, 也沿用了 maven 依赖管理. 迄今为止 java 世界最重要的两个工具, 一个是 eclipse, 一个就是 maven.

OSGi 模块化

为了解决 "classpath hell" 问题, maven 的思路是自动构建 classpath, OSGi 模块化的思路则是消除 classpath. OSGi 想要解决的问题很多, "classpath hell" 只是其中一角.

应用本身及每个依赖都是一个模块, 叫做一个 bundle. bundle 运行在 OSGi 模块化层上, 由 OSGi 创建的 bundle 类加载器加载. OSGi 模块化层自身可能还是非模块化的, 还是由系统类加载器加载. 但 bundle 已经跟系统类加载器无关, 应用也就不用关心 classpath 了.

bundle 使用元数据设置导入导出包, OSGi 通过导入导出关系解析 bundle 依赖, 设置类加载器之间的委托关系. OSGi 技术出现的很早, 当时 maven 还没有流行 (甚至还没出现?), 所以 OSGi 依赖管理做了部分与 maven 重叠的事, 而没有使用或者借鉴 maven.

maven 依赖是以 jar 包为粒度的, 而 OSGi 是以 package 为粒度. OSGi 要求一个 package 只能在一个 bundle 中, 而一个 bundle 可能包含多个 package. bundle 可以只导出部分 package, 而不是整个 jar 包. 这正是 OSGi 模块化的一个目标, 模块级私有.

与 maven 不同的另一个地方是 OSGi 依赖是不传递的. 假设 a 依赖 b, b 依赖 c, a 是不能看到 c 的. 当然运行时 a, b, c 都必须被安装, 但依赖 c 是 b 的内部细节, a 不用关心. 如果 a 要使用 c, a 必须设置自己依赖 c. 这样每个 bundle 只用关心自己的直接依赖.

OSGi 的主要缺点是:

  1. 模块化层是一个很基础的底层运行环境, 要完全使用 OSGi, 整套系统都要进行 OSGi 模块化改造, 改造成本可能会很大. 比如 webapp 应用, tomcat 容器本身也要改造支持 OSGi.
  2. OSGi 模块 (即 bundle) 开发, 打包方式与普通 maven 项目不一样, 开发人员需要学习成本. maven 已经是事实上的依赖管理标准, 能否复用 maven 的依赖管理机制?
  3. 开发工具支持问题, eclipse, 编译器能否识别导入导出包限定?
  4. OSGi 为了支持热替换引入了过多的复杂性.

基于 maven 的模块化系统

maven 已经是 java 依赖管理事实上的标准, 但使用 maven 进行依赖调解存在一些问题.

  1. 有些依赖是不可调解的. 比如 a, b 两个库依赖了 c 库的两个不兼容版本, 这时无论加载哪个版本都不能使 a, b 同时正常工作. 再比如由于历史原因, 导致一个库在不同的仓库上用了不同的名字, 两个名字被不同的库间接依赖到, 因为名字不一样, maven 不会进行依赖调解, 最后两个 jar 包都会出现在 classpath 上, 如果碰巧低版本的 jar 在前面, 被加载到低版本的类, 导致依赖高版本的库不能正常工作.

  2. 兼容性浅在风险. 假设 a 开发测试时一直使用的是 c1, 使用 a 的一个应用必须使用更高版本的 c2, 即使 c2 声称自己是完全兼容 c1 的, 由于 a 没有在 c2 下完全测试过, 存在潜在风险.

解决这些问题最好的办法就是借鉴 OSGi 的模块化方案, 不要将所有 jar 包都放到一个 classpath 下, 而是拆分成多个模块, 每个模块一个 class loader, 模块间通过 class loader 委托关系加载依赖模块下的类. 模块间也不需要依赖调解, 每个模块都保持自己的真实依赖.

这套系统与 OSGi 比应该具备以下特点:

  1. 复用 maven 作 jar 包管理和依赖管理, 可以直接通过 maven 查看模块间的静态依赖关系.
  2. 开发方式与普通 maven 项目尽量接近, 对开发人员入侵最小, 普通 maven 项目不加修改或者只需要很小的修改就可以成为一个 bundle.
  3. 去掉热部署相关的复杂性, 尽量简单易用.

一种简单的方式就是每个 artifact 作为一个模块, 一个 jar 对应一个 class loader, artifact 之间的依赖关系就是 class loader 之间的委托关系, 每个模块都保留自己的真实依赖. 这样做还有个好处, 每个 jar 包都只会被加载一次, 就像操作系统加载动态链接库一样.

每个模块都保留自己的真实依赖可能会有问题, 假设 a 依赖 c1, b 依赖 c2, a 将一个 c1 对象传给 b 的 c2 类型就会有问题. 如果两个模块互相通信, 他们通信的类必须由同一个 class loader 加载. 这时就要进行调解, 让 a, b 都依赖 c1, 或者都依赖 c2. 模块间要实现依赖隔离, 应该只暴露接口, 模块间只能通过接口通信, 接口应该尽量简单, 最好只包含标准库. 系统要能够运行时修改依赖关系, 或者自动调解接口依赖.

如果两个模块依赖了不兼容的接口类, 又必须互相通信, 只能采用适配器的办法, 将一个类的对象转换成另一个类的对象, 或者转换成基本类型, 如 JSON String. 转换器不能同时直接访问这两个类, 其中一个必须通过 class loader 使用反射操作. 系统要能够支持查询找到某个模块的 class loader.

maven 依赖只是表达编译时依赖, 编译时依赖不允许出现环, 如果两个 jar 编译时互相依赖就说明它们是高内聚的, 应该合并成一个 jar. 模块间的运行时依赖应该可以在编译时依赖的基础上修改和增加, 运行时互相依赖也是可能的, 比如 SPI 框架和 SPI 实现可以运行时互相依赖. 框架安装模块应该类似于 class loader 加载类, 自动从 maven 仓库安装依赖的模块.

C 语言区分可执行文件和库, 库又分为动态库和静态库两种类型. 在 C 语言中, 只使用动态库或者只使用静态库都是 OK 的. 一个程序中, 一个静态库要么直接链接到可执行文件, 要么只能链接到一个动态库, 如果混用就可能导致问题: 一个库在程序中出现多份实例.

java 库 (jar 包) 在物理结构上像动态库, 运行时链接, 可替换. 但 classpath 像静态链接, 所有符号 (类名) 聚集在一起, 容易冲突. maven 依赖就像静态库, 具有传递依赖. 而 class loader 的边界就像动态库的边界. 从这个角度看, jar 包就像静态库, class loader 就是把静态库链接成动态库. java 中必定要通过 class loader 加载类, 可看作程序必定只使用动态库. 一个 jar 包对应一个 class loader, 就好像一个静态库链接成一个动态库. jar 本身只有一种, 静态链接还是动态链接只是看怎样依赖它. 添加到 classpath 就是静态链接, 委托 class loader 就是动态链接.

每个 artifact 都使用动态链接没有问题, 但有时在开发人员明白链接关系的情况下允许静态链接是有用的, 如实现私有的 artifact 只被一个 class loader 静态链接实现模块级私有, 或者模块明确需要一个库的私有拷贝.

maven 普通依赖就是静态链接, 会依赖传递. 只使用静态链接没有问题, 但就不是模块化系统了. maven "optional provided" 就是动态链接, 不会依赖传递. 只使用动态链接, 全部依赖都不传递, 也是没有问题的. 但 "optional" 只对间接依赖有效, 假设 a 动态链接 b, b 静态链接 c, 由于 a 直接依赖 b, 现有的 maven 规则就无法避免依赖传递了. 要同时允许动态链接和静态链接, 就要重新定义 maven 依赖解析规则, 相关开发工具也要重写. 为了重用现有开发工具, 最好可以重用现有 maven 规则.

bundle 间默认应该是动态链接, 静态链接应该添加一个依赖属性显式说明, 比如可以考虑用 <scope>static</scope> 或者 <scope>internal</scope>. 解析模块依赖时, 动态依赖只需要解析直接依赖, 而静态依赖需要解析到动态依赖为止. 静态依赖与动态依赖冲突时, 应该使用静态依赖并给出警告.