对模块和元数据进行打包
我们这个模块系统需要一种方法来对模块的内容以及描述导入和导出的元数据进行打包,将其包括到一个可部署的单元中。
Java 已经有了标准的部署单元:JAR 文件。JAR 文件可能并不算一种非常成熟的模块,但对于移动大块的编译代码还是不错的,所以我们并不需要创建新的东西。那么现在的唯一问题是,将元数据(即导入和导出列表、版本等等)放在哪里?
看起来配置格式强烈地受到一时潮流的影响;如果我们是在 2000 年到 2006 年期间设计这个模块系统,我们很可能会选择将元数据放到 JAR 文件下的某个 XML 文件中这种方式能够工作,但会遇到许多问题:对于流程,XML 文件并不是特别有效率,尤其是我们必须在 JAR 文件的某个地方才能找到它,而且在进行语法分析之前还要对其进行解压。JAR 文件是一个 ZIP 压缩包,所以要找到某个特定文件,意味着必须读取末端,找到用于跟踪记录的中央目录,然后再跳转到该目录指定的分支上。换句话说,通常不得不读取整个 JAR 文件,对于需扫描大型目录的工具,如果这个目录下有很多模块,这个过程将变得非常痛苦。比如,搜索某个可用的模块,以满足某个依赖关系。
另外 XML 几乎不能人工编辑。为了正确的编辑这种文件,我们需要使用特定的编辑根据。
另一方面,如果是在 2006年之后设计这个模块系统,我们的第一个想法会是使用 Java 注释(annotation)。如果使用适当,我非常喜欢注释,将类似 @Export(version="1.0.0") 的东西放到 Java 源文件中的包声明上,很明显比在单独文件中对其进行维护要更有吸引力。不过,等一下……在包的每个源文件中,包声明都会重复一次;难道我们也必须在所有源文件中加入注释?
为了解决这个问题,Java 语言规范(JLS)建议使用一个名为“package-info.java” 特定源文件。但对于不属于任何特定包的元数据怎么处理呢?比如导入包的列表或模块本身的名称和版本。Java 语言规范建议我们需要使用另一个特定源文件,使用类似“module-info.java”名称。
到目前一切顺利,现在让我们看看如何对模块进行处理。
这些特定的源文件将在 package-info.class 和 module-info.class 中被编译为字节码,这样就不需要打开 ZIP 压缩的 JAR 文件来查看元数据了。所有模块扫描工具都必须对整个模块系统进行读取,而且也必须能够处理字节码。运行时模块系统自身也必须立即为模块常见一个类加载器,用于读取它的元数据;结果是,如果我们能够将类加载器的创建推迟到真正从模块中加载某个类那个时刻,就可以消除大量的优化工作。
已经发生的事实是,OSGi 的设计的确是在 2000 年之前,所以它的确选择了这些方案中的其中之一。回头看看 JAR 文件规范,答案自动浮现:META-INF/MANIFEST.MF 是应用程序专用元数据的标准位置。在规范中这样写道:“忽略不可理解的属性。这类属性可能包含应用程序所用的特定部署新型。”
MANIFEST.MF 专为提高流程的效率而设计,而且它至少比 XML 更快。某种长度上,它是可读的;至少与 XML 一样可读,很明显比编译的 Java 字节码更具有可读性。此外,标准的 jar 命令行工具通常将 MANIFEST.MF 放到 JAR 文件的第一项中,所以为了获取元数据,工具只需扫描文件中的前几百个字节。
令人遗憾的是 MANIFEST.MF 并不完美。其一,由于规则要求每行不超过 72 个字节,手工编写相对困难,考虑到单个 UTF-8 字符为 1-6 个字节,这种规则会导致一些问题。一个更好的方式是利用另一格式的模板来生成 MANIFEST.MF。Bnd 工具是这样的,Maven 的 Bundle Pulin 和 SpringSource 的 Bundlor 也是如此。
事实上,Bnd 甚至包括对于处理注释的实验式的支持,比如 @Exporton 源代码注释。这样我们将能够获得来自2个方面的好处:注释的便利性,以及 MANIFEST.MF 的效率和运行时可读性/工具性。
后期绑定
模块拼图的最后一块是部署到接口的后期绑定。我认为这是模块化一个至关重要的功能,虽然某些模块系统对此完全忽略,或者认为它不属于模块化这个范围。
人们都知道,Java 中的接口会破坏功能提供者和使用方之间的耦合性。定义一个接口,其作用相对于使用方和提供方的合同,如何一方都不需直接获得对方的信息,这样我们就可以将它们放到不同的模块中,而这些模块之间不存在互相的依赖关系。而是每一个模块对于接口存在依靠性,我们可以选择囧这个接口放在第三个模块中。唯一的问题是如何为使用方类提供接口实例,而最常见的答案是使用依赖注入(Dependency Injection,缩写为DI),比如 Spring 或 Guice。
因此,为了完成我们的模块系统,只需使用现有的 DI 框架即可。毕竟我们追求的简洁性,声明一个问题不属于我们处理的范围,让别人来解决,没有什么比这个还简单。但是,这种方式并不是非常令人满意,因为 DI 框架事实上也需要知道模块的边界。传统的 DI 使用方式的问题在于它会创建巨大的中心化配置,这个配置会对所有模块产生影响。Peter Kriens 将这一问题称为“全能类”(God Class)问题,在这个问题中,一个组件了解每个模块的所有内容,并要求所有模块对其进行绑定(作为一个无神论者,我认为这个不可能做到,但即便你是有神论者,我肯定你也同意除了当前已存在的上帝之外,我们不应再去制造更多神)。这些全能类(或 XML 配置)非常脆弱,难于维护,否定了将代码划分到模块中所带来的大多数好处。
我们应该寻找一种去中心化的方法。不是让全能类告诉我们去做什么,我们可以假设,每个模块可能常见对象并将它们发布到某些地方,而其他模块可以找到它们。我们将这些发布的对象成为“服务”,而它们发布的地方称为“服务寄存器”。有关服务,最重要的信息是它进行部署的接口,所以我们可以将它作为最初的注册码。现在,一个模块,如果需要找到特定接口的实例,只需查询寄存器,看看当时提供哪些服务。寄存器本身仍然是位于任何模块之外的中性化组件,但它不是全能的,而是更像一个共享黑板。
我们不需要放弃 DI,事实上它还非常有用:现有的 DI 框架可用来向其他服务中注入服务,以及将某些对象发布为服务。DI 框架不在指挥整个系统,相反它只是在单个模块中的部署的应用。我们甚至可以使用多个 DI 框架,比如在同一个应用程序中同时使用 Spring 和 Guice,当想要集成第三方组件而这个组件使用的框架不是我们所选择的那个时,这是非常有用的。最后,服务寄存器为发布和查询提供可编程的 API 接口,但只能用于低阶工作,如部署一个新的 DI 框架。
总结
希望以上的泛泛而论能够解释为什么OSGi 会是现在这个样子;从某种意义上说,这是一种技术的进化。人们将会继续抱怨OSGi 太复杂,但我认为任何存在的复杂性都是必要的,用于解决我以上描述的难题。
当然它并不是完美的。比如,版本控制还可以进行改善,尤其是对于那些版本方案非常奇怪的第三方库。为版本编号赋予一定的意义,仍然是正确的做法,但为了对版本和 API 兼容性进行管理,还需要更多的协助工具。还有传统的库,仍然在危险的假设一个扁平化系统类路径的存在。按照我的观点,任何在类名称中使用字符串或调用 Class.forName() 来获得对象的库都是错误的,因为它假设所有类对于模块都是可见的,而在任何类型的模块化系统中,这都是不正确的。很遗憾,这些问题还不能在一夜之间完全解决,所以处理这些破损的库,我们需要一些策略。不过处理这些问题需要一种不同的方式,从而对于其他人来说,不至于破坏模块化的规则。