【51CTO译文】我们曾不只一次的听到2010年将是Java模块化的一年的言论;也知道目前为Java提供模块化的OSGi正在受到IBM和Eclipse基金会的大力支持。但作为实现Java模块化应用的基础框架,OSGi似乎并不完美;我们经常能听到关于OSGi过于复杂的抱怨。
从个人的角度,我以开放的心态去了解OSGi。令人失望的是,我发现它的规则非常复杂而且是低阶的(low-level),对于大多数企业 Java 环境,需要对其进行许多改善/缩写的工作,才能让它更容易被人理解。对于大多数实际的企业需求,它又显得功能过于强大。比较而言,Jigsaw 感觉更“干净”,以 Java 为中心,紧凑而且易于理解。
说实话,这种抱怨让我有点困惑。我来假设一下,如果OSGi 现在并不存在,有人给我一项任务,为Java 平台设计一个新的模块系统,那么对于这个模块系统的最合理的需求集合将直接指向OSGi,因为OSGi的设计目的可能是满足我们需求的最简单的解决方案。
是不是我的想象力不够?或者这不过是爱因斯坦剃刀原理(事物应尽可能简单而不是更简单)打败奥卡姆剃刀原理(如无必要,勿增实体)的又一个实例?
另外,我认可OSGi 初看并不是那么简单这一说法,尤其是你不了解它为什么会是现在这样的时候。
在这篇文章中,我将按照上述的那个假设,从零开始设计OSGi 系统。当然许多细节问题在这里我不能一一讲述。下面切入正题,为什么OSGi 成为现在这个样子?我们一起看看OSGi不算漫长但足够复杂的进化(这种进化是积极的,因为它为解决业界实际存在的问题而生)。
51CTO编者注:关于OSGi的更多内容可以参考51CTO的专题OSGi入门与实践全攻略或参考OSGi的入门文章初探Java企业级开源框架OSGi ;关于Java模块化的内容可以参考51CTO对淘宝网架构师的专访一步一步了解Java模块化。
模块分离
我们的第一个需求是清晰地划分模块,这样一个模块中的类就不会具有我们无法控制的功能:使用或覆盖另一个模块中的类。在传统的 Java 中有一个“classpath”(类路径),这是一个巨大的类列表,当多个类碰巧使用相同的名称时,总是使用第一个类,而第二个和其他所有的同名类将被忽略。看起来这种事情不会经常发生,当事实并非如此。当存在许多库而这些库又依靠其他库时,这个问题就变得常见了。这个覆盖问题绝对是致命的,因为它会导致一些奇怪的错误,比如 LinkageError、IncompatibleClassChangeError 等。事实上能够看到这些错误,那还是比较幸运的。倒霉的是这些错误没有提示,而系统一声不响地错误地运行,哪怕在部署之前我们做了许多先行测试。
对于类的覆盖和不可能空的可见性,预防方法是为每一个模块创建一个类加载器(class loader)。类加载器能够做到仅加载它能够直接识别的类,在我们的这个系统中,就是某个模块的内容(不过,它也可以根据类对类的方式,请求其他类加载器提供类,这种方式称为委派,即 delegation)。使用类加载器之后,每个模块包括他需要处理的代码和类,而且能够保证获得按照计划应该使用的类,即使系统中的其他模块包含同名的类。
从整体上恢复可见性等功能
完成以上步骤之后,我们到达这样一个点:所有模块完全隔离,无法互相通信。为了让这个系统变得实用些,我们需要恢复一些功能,以便能够看到其他模块中的类,不过这样做时必须非常谨慎,而且必须使用严格控制的方式。这里我们又多了一个需求:模块需要能够隐藏某些部署细节。
在 Java 中,protected/默认和 public 类型之间缺少访问修饰符。假设我写了一个库,希望这个库中其他包能够使用我的一个类,我必须让这个类设置为 public。但这样这个类将对所有人是可见的,包括这个库外部的客户,这些客户将能够直接使用我的内部类。我们想要的是一个“模块”级的访问级别,但现在的问题是 javac 编译器无法区分模块边界在哪里,因此对于这样的访问修饰符它无法执行任何检测。事实上,现有的“默认”访问修饰符也是有问题的,因为它应该只对同一个“运行时包(runtime package,即由某个特定类加载器加载的包)”提供访问权。但同样 javac 无法确定运行时存在哪些加载器。对于这种情况,javac 会采取冒险的方式:即使之后会导致 IllegalAccessErrors 错误,它也会提供访问权。
在我们这个模块系统中,我们选择的解决方式是允许模块仅“导出”其内容的一部分。如果模块中某些部分是非导出的,那么对于其他模块就是不可见的。但默认导出哪些内容?除了某些明显需要隐藏的部分,我们应该导出所有内容吗?或者除了那些明显需导出的部分,我们应该隐藏所有其他内容?选择后者看起来能够到来更好的透明度:我们可以很方便查看导出列表,确定那些可见的部分,即模块的“表面部分”。
请注意,我目前还没指定具体导出什么内容,这是一个需要仔细考虑的问题。
导出的反面是什么?当然是导入。一个模块想要使用其他模块中代码可以从后者进行导入。现在我们有了另一个选择……我们应该导入另一个模块导出的所有内容吗?或者只导入我们所需的那部分?同样我们还是选择后者,因为它会带来更好的透明度:重要的是我们导入了什么而不是从哪里导入。
与购物行为进行类比
关于导入的话题非常重要,所以这里次岔开一下话题,让我们看一个有点搞笑又有点夸张的购物行为。
我妻子和我的购物方式是不同的。我认为购物是一件麻烦的琐事。每当不得不去买东西时,我就找到一家商店(或者一组商店),那里有我需要的东西,我只买我需要的商品,买到之后回家。只要能买到我需要的东西,我不关心是从哪家商店买到的。
而我妻子去了一家商店,那家商店买什么她就买什么。
很明显我觉得我的购物方式更好,因为我妻子无法控制她能买到什么东西。如果她常去的一家商店更换了货柜上的商品,那她买回来的将是另外一些东西。当然很多东西并不是她需要的,而且她真正需要的又没有买到。
更糟糕的是,有时她买回来的东西并不能独自使用,因为还需要其他东西,比如电池。所以她不得不再次去商店里买电池,同样这次她会买下电池商店里出售的各种电池。再进一步假设,从电池商店里买到的某样东西还依靠其他东西才能使用,所以她又跑去另一家商店,仅仅是为了让某些商品能够正常工作,而这些商品从最初就不是我们所需要的。这个问题被称为“扇出”(fan-out)。
通过这个购物类比,相信你对模块系统将有一个更清晰的概念。这种非理智的购物行为等同于这样一个系统:我们申明了对某个模块的依靠性,而这个系统强制我们从该模块导入所有内容。当进行导入时,应导入所有我们实际需要的内容,而不管它来自哪里,同时忽略其他所有内容,可能内容只是碰巧位于它的包内。使用 Maven 构建工具时我们遇到这个尖锐的“扇出”问题,这个工具仅提供整体模块的依赖性(即“买下整个商店”方式)。其结果是,在编译 200 个字节的源文件之前,必须下载整个互联网的内容。
导入和导出的粒度(granularity)
从模块导入和导出内容的粒度应该是怎样的?由于存在各种嵌入等级,Java 中有多种等级的粒度。方法和域嵌入到类中,类又嵌入到包中,包嵌入到模块或 JAR 文件中。
不难看出共享等级不应是方法和域。导入一个类的某些方法而排除例外一些,这种方式很明显是荒唐的。不仅仅这种方式是如此。我们可以为某个模块中类写一些方法/域,在另一个模块中再写一些方法/域,这种方式也同样是不可行的。想象一下,为在模块中的每一个共享方法写一些导入和导出列表,运行时对这些列表进行检查以及诊断为题的复杂度将是非常恐怖的,会出现许多错误,因为类并不是设计用来在运行时进行分割的。
现在看看另一个极端,共享等级也不应是整个模块,因为这样模块就不能隐藏实施细节的部分,导入方将经常性地遇到“买下整个商店”的问题。
所以唯一合理的选择是类和包。老实说,选择类也不是那么合理。虽然没有方法/域那么糟糕,但类的数量非常多,由于它太过于依赖同一个包中的其他类,无论是将类列出作为我们的导入和导出,还是将包中的一些类划分到某个模块同时将同一个包中另一些类划分到了另一个模块中,都是不合理的。
最终的结果,OSGi 选择了包。Java 包的内容通常具有某种程度的一致性,但列出导入和导出的包并不是那么麻烦,而且在某个模块加入一些包而在另一个模块在加入另一些包,并不会对如何东西造成损坏。应该属于模块内部的代码可以放到一个或多个非导出的包中。
我们的损失的无法干净地处理那些所谓的“分裂包”(split-package)。在 OSGi 中,包是进行共享的最基本单元:当导入个包时,你获得一个模块导出包的所有内容而不包括其他内容。一些传统的包,一直坚持在许多模块中共享包内容,对于这些包也存在一些方法进行处理,但这好过对每个包进行调整以便让它作为整体只能由某个模块导出。
包连线(wiring)
既然对于模块如何自我分离然后再连接有了一个模型,我们现在可以想象创建一个框架,这个框架将为这些模块构造实际的运行时实例。它将负责安装模块以及构造类加载器(这些类加载器知道相应模块的内容)。
然后它将查看新安装的模块的导入,并试图找到匹配的导出。假设模块 A 导出包 com.foo,模块 B 要导入这个包。该框架将通知 B,它可以从模块 A 获得 com.foo 的类,这个称为连线(wiring)。如果 B 的类加载器要加载类 com.foo.Bar,它将委派 A 的类加载器来做。对整个模块的导入进行连线的过程成为解析(resolution),当所有导入都成功进行连线后,那么这个组件(bundle)就被解析(resolved)了,这将令它完全可用。
一个预料之外的好处是我们可以动态地安装、更新和卸载模块。对于已经解析的模块,安装新模块对它们没有影响,虽然这可能导致某些之前不可解析的模块变得可解析。当进行卸载或更新时,该框架非常清楚那些模块受到影响,并且如果需要它将更改它们的状态。为了能够顺利地进行,还有一些额外的细节需要处理,比如,一个模块在卸载或者取消解析之前正在做非常的事情,那么需要向它发送通知,以便让它干净利落地关闭。所以,OSGi 中的动态模块并不是凭空出现的,这里并没有什么神奇的功能,但 OSGi 至少让它成为可能。
某些 OSGi 用户更喜欢避免动态加载,这样做没有问题。这不是 OSGi 最重要的功能,但由于对于 OSGi 它是独一无二的,英尺获得了过多的关注。无论如何,没有人强迫你使用它,即使从来不去利用动态性的优势,你仍然能够从 OSGi 获得许多好处。
版本控制
我们的模块系统现在看起来非常不错,但随着时间的推移,模块不可避免地会发生方便,对称我们还不能处理。所以,我们还需要支持“版本控制”。
如何进行版本控制?手洗,导出方可可进行声明,为其导出的包提供一些有用的信息:“这个是 API 版本 1.0.0”。导入方现在能够只导入与其预期匹配并且经过编译/测试的版本,并且解决接受某个版本,比如版本 3.0.0。但是如果导入方想要版本 1.0.0 而只有版本 1.0.1 可用时,应该如何处理呢?一个稍高一点的版本看起来不会保护巨大的更改,所以导入方应该可以接受版本 1.0.1。事实上,导入方应为其可接受版本指定一个范围,比如类似这样的一个范围:“版本 1.0.0 到 2.0.0 但不含 2.0.0”。对包进行连线的流程可以支持这种范围,如果导出的导出版本位于导入指定的范围内,就将导入与该导出进行连线。为了让这个机制能够正常使用,版本编号应该是有顺序的并且能够进行比较。
我们如何确定版本 1.0.1 相对于 1.0.0 没有包含巨大的更改呢?很遗憾,我们无法确认这种事情。对于版本编号,OSGi 强烈建议而不是强制使用以下语法规则:
1. 对于非向后兼容的更改,对主要(第一)部分进行递增。
2. 对于向后兼容的功能改善,对次要(中间)部分进行递增。
3. 对于未造成可见的功能更改的故障修复,对最后部分进行递增。
如果所有人都遵守这些语法规则,那么指定导入范围将是一件轻松简单的事情。但现实世界并不是这么简单,因此在试用如何外部库时,我们必须小心地处理兼容问题。
对模块和元数据进行打包
我们这个模块系统需要一种方法来对模块的内容以及描述导入和导出的元数据进行打包,将其包括到一个可部署的单元中。
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() 来获得对象的库都是错误的,因为它假设所有类对于模块都是可见的,而在任何类型的模块化系统中,这都是不正确的。很遗憾,这些问题还不能在一夜之间完全解决,所以处理这些破损的库,我们需要一些策略。不过处理这些问题需要一种不同的方式,从而对于其他人来说,不至于破坏模块化的规则。