Skip to content

Latest commit

 

History

History
157 lines (107 loc) · 7.46 KB

java-modular-system-design.md

File metadata and controls

157 lines (107 loc) · 7.46 KB

java 模块化系统设计

2014-07-05

什么是模块化系统

模块化系统的意义

  1. 更好的模块化, 拆分 classpath.
  2. 更彻底的面向接口编程, 模块间松耦合, 透明替换.
  3. 模块间依赖隔离, 避免依赖冲突.

模块化系统的定位

理想情况下, 应用所有组件都应该被模块化, 运行在模块化系统之上.

模块化系统分为两个部分:

  1. 提供给模块使用的 模块化层 基础服务 API.
  2. 负责安装, 管理, 启动, 停止各个模块的 模块化框架. 类似于传统概念上的容器, .

模块化系统具有如下特点:

  1. 模块化层是仅次于 jvm 的基础设施.
  2. 模块化框架对模块不可见, 模块只能看见模块化层 API.

一个基于模块化系统构建的 webapp 整体结构示意图如下.

[[java-modular-system-design/webapp-sketch.png]]

图中实线表示模块间的编译时依赖, 虚线表示运行时依赖.

在 eclipse OSGi 实现中, OSGi 框架本身也是一个模块, 这样框架本身的实现会受到很多限制, 所以我们不采用这种方式. 模块化框架本身是一个普通的非模块化程序, 但它与模块也是隔离的. 模块化层 API 仍然作为一个特殊的模块存在, 由框架提供.

什么是模块

导入依赖与内部依赖

简单来说, 一个模块就是一个 maven 项目和它的所有 内部依赖. 内部依赖就是普通的 maven 依赖. 一个模块运行时有一个 classloader, 加载模块项目及其所有内部依赖 jar 包.

我们定义一个模块还可以另一种特殊的方式依赖另一个模块, 叫做 导入依赖. 运行时导入依赖的 jar 包由当前模块的 classloader 委托被导入模块的 classloader 加载.

导入依赖具有以下特点:

  1. 导入依赖没有传递性. A 依赖 B, B 依赖 C, A 不会依赖 C. 这个特性在 maven 中可用 <optional>true</optional> 表示.

    这个特性有两个意义:

    1. 这是实现依赖隔离的根本. A 依赖 B, C 时, B, C 的传递依赖互相隔离.
    2. 加载导入类时最多进行一次 classloader 委托.
  2. 打包模块时不需要打包导入依赖, 对应 <scope>provided</scope>.

综上所述, 在 maven 项目中, 我们可以通过判断一个依赖同时满足 <scope>provided</scope><optional>true</optional> 来判定它是一个导入依赖.

应用可以通过某种方式设置模块间的运行时依赖, 运行时依赖只影响模块的启动顺序, 不影响 classloader. 模块依赖一定不能出现环. 模块间导入依赖和运行时依赖合并的拓扑图决定模块的启动顺序. 一种简单实现: 启动一个模块时深度优先递归启动它的所有依赖即可.

导入依赖专指模块间的依赖, 也可直接叫做 模块依赖 .

库模块与纯模块

一个普通的 maven 库也是一个合法的模块, 这个模块只有内部依赖, 没有导入依赖, 叫做 库模块. 在模块静态关系上, 库模块一定处于模块依赖关系的叶子节点, 如上文示意图中的 servlet-api.

与之对应, 包含模块依赖的模块我们叫做 纯模块 (暂时没想到其他好名字).

一个模块内部依赖一个纯模块时会怎样呢? 间接导入依赖怎么处理? 为了简化系统模型我们约定纯模块不能作为其他模块的内部依赖, 即只能被导入依赖. 因此我们需要有一个简单有效的方法来识别一个模块是库模块还是纯模块. 我们约定所有普通 maven 库都是库模块, 而纯模块必须设置 "classifier" 为 "mod" 来识别.

库模块 可以被内部依赖或导入依赖, 纯模块 只能被导入依赖.

导入依赖与内部依赖问题

关于导入类还有个有争议的问题: 应该导入类优先还是自身类优先?

  • 如果导入类优先, 0. 导入模块的所有内部依赖都应该从当前模块裁剪掉, 现有的 maven 依赖解析模型不能满足这种情况 (?). 0. 导入类处理有一些难题. 如何保证从模块导入的类 (1) 是模块内部依赖的, (2) 同时也是模块实际使用的? 假如 A 导入 B, B 导入 C, C 覆盖了 B 中的类 X, A 从 B 导入类 X 时该如何处理? 如果允许 B 递归委托类加载, 如何确认递归加载的类是存在于 B 的内部依赖的? 递归层次太深是否有问题?

  • 如果自身类优先, 希望用导入类覆盖自身类时, 无法单方面强制覆盖. A 导入 B 时, A 必须排除 B 的所有内部依赖, 即保证一个 maven 依赖, 在导入依赖与内部依赖中只存在一份. 但类名相同 maven 坐标不同时, 还是无法实现强制覆盖.

如果导入依赖的内部依赖需要在本模块中排除, maven 依赖解析似乎变成一个深度优先 + 宽度优先的混合模式? 依赖解析变复杂, 并且当前 maven 依赖解析机制没法支持这种模式.

为了解决这个问题, 一个办法就是被导入的模块和当前模块不能同时有内部依赖. 这样在一个纯模块的依赖关系中, 只会出现一棵可预测的静态 maven 依赖树. 内部依赖与导入依赖的冲突应该被调解排除, 不会有问题. 但是这个约束难以被自动化检查, 难以保证.

或者要求纯模块不允许有内部依赖, 但纯模块导入两个库模块时还是有问题, 编译时 maven 依赖解析与运行时依赖不一致. 编译时是整体宽度优先, 运行时是两个宽度优先 (深度优先 + 宽度优先).

自身优先实现和逻辑更加简单清晰, 但强制覆盖类的需求不好实现. 是否可以默认自身优先, 但允许调控某些导入依赖优先? 或者本身不应该支持导入类强制覆盖?

轻模块

解决导入依赖与内部依赖冲突问题的一个办法是约定只能只能导入没有内部依赖的模块. 没有内部依赖的模块我们叫做 轻模块.

轻模块可以是库模块 (通常是 API 包) 也可以是纯模块.

如果导入模块都是轻模块, 那么加载类时选择导入类优先, 并直接用 findClass() 加载导入类是相对安全的, 即不太可能出现 A 导入 B, B 导入 C, C 又覆盖了 B 中的类这种情况. 这时可以选择导入类优先, 并且只执行一次委托.

模块化最佳实践

良好的组件设计应该实现接口, 实现分离. 即一个组件分为一个 API 模块和一个实现模块, 其中 API 模块是轻模块, 通常也是库模块. 其他组件只允许依赖 API 模块, 不允许依赖实现模块. API 模块通常定义了一些接口类. 为了方便其他模块简单创建得到这些接口类的实例, 可以在实现模块的基础上包装一个 factory 模块, 提供创建这些接口实例的 factory 类. factory 模块也是一个轻模块, 并且只依赖 API 模块和实现模块. 其他组件可以依赖 factory 模块, 但不会导入实现模块的传递依赖.

即一个组件拆分成一个 API 模块, 一个实现模块, 和一个可选的 factory 模块.

实现模块只能被对应的 factory 模块依赖, 但实现模块可能不是轻模块, factory 模块打破了只能依赖轻模块的约定, 这是一种安全的例外情况. 在这个场景下, 只能依赖轻模块不能作为强制要求.

不使用 factory 模块时, 实现模块可以将接口实现实例注入到模块化系统的服务层, 客户端再通过服务层拿到接口实现.

可使用一个简单的空 maven 项目将一个库模块作为内部依赖, 并将其依赖的 api 设置为导入依赖, 从而将其变成一个纯模块类型的实现模块.