Java 9将在明年发布,一个标志性的特性是新的模块化系统:Java平台模块化系统(JPMS)。虽然JPMS的细节还没有完全确定,我们已经了解了很多有关它方向性的内容。
Java已经有一个预先存在的模块化系统,自2000年以来一直以各种形式存在。它就是被称为OSGi的模块化系统,是一个独立于供应商的行业标准。它由OSGi联盟发布,由领先的软件供应商、电信公司和其他组织(包括Adobe、博世、华为、IBM、Liferay、NTT、Oracle、Paremus 以及Software AG)组成。它推进了几乎所有的Java EE应用服务器、最流行的IDE、Web应用程序(像eBay、Salesforce.com和Liferay),并用于政府和军队,如美国空军和联邦航空管理局。
OSGi是为物联网提供的——OSGi一开始是专为嵌入式设备设计的,那是在很多年前,当时内存和CPU资源明显受到局限。现在设备有了更多的能力。这提供了构建复杂应用程序和解决方案的机会,并催生了蓬勃发展的生态系统,在这个生态系统中组织和个人贡献的软件和硬件元素可以添加到整体解决方案中。这样的生态系统在市场上很广泛,包括互联家庭、车联网、智能城市和工业4.0(IIoT)。网关通常用于传感器和设备之间相互连接,并连接到后端系统。应用程序和服务可以在本地网关和/或云上运行。
OSGi还提供多种规范启用构建开放的物联网生态系统的基本特性。这些特性包括设备管理、软件配置以及设备抽象,即从底层通信协议归纳设备。在今天,像AT&T、博世、NTT、德国电信、美国通用电气、日立、美诺、施耐德电气等公司都受益于采用OSGi构建物联网的解决方案,并且做了很多年。目前已经有上百万的设备连接采用OSGi和物联网。
当然,OSGi的用户都很好奇Java 9中新的模块化系统在短期和长期将会如何影响OSGi。
Java生态系统中很快会出现两个模块化系统,这有技术、政治和商业的原因。本文中,我们避开政治原因,从技术的角度对两者进行比较。我们总结了JPMS和OSGi如何协同工作,思考它们各自的领域是什么以及在崭新的世界中存在什么样的机遇。
请注意,本文中,我们使用的信息在2016年8月已公开发布。在该规范确定之前一些细节可能会改变。
背景
自1990年代末诞生以来Java平台增长显著。综观下载文件的大小,JDK 1.1为10Mb,而Mac OS X下载JDK 8u77却非常大,有227Mb。安装占用的空间和内存需求也有了相应的增加。这些增加是因为增加了新的功能,而且大部分功能是受欢迎并且有用的。然而,每一个新的功能都为不需要这个功能的用户创造了膨胀——没有人会使用平台所有的功能。而且即使已经过时,所有现有的功能都会保留,因为Java管理员提供了令人钦佩的奉献精神——向后兼容性。
多年来,Java体重的增加并不是一个大问题。它是最流行的企业平台,它的主要竞争对手是微软的.NET,然而.NET也有着相似的轨迹。在当今世界,Java面临不同的挑战。物联网推动了空间占用新一轮的关注,新的、灵活的平台和语言(比如Node.js、Go)都是非常有竞争力的对手。
安全也是一个大问题:Java攻击引起了组织对安全意识的重视,把它从用户桌面完全移除。如果内部JVM和用户空间应用程序代码之间有更好的隔离,这些攻击是不可能发生的。
很早之前我们就清楚需要为模块化平台做一些事情了。在2000年中期有一系列失败的尝试,例如,JSR 294和它的“superpackages”,JSR 277的“Java模块化系统”——最终名为Jigsaw的原型项目出现了。这本来是在2011年Java 7中提交的,但被推迟到Java 8再推迟到Java 9。作为一个原型项目,Jigsaw为JPMS规范提供了参考实现。
而与此同时,OSGi用了16年时间不断发展和完善。OSGi是应用程序模块化的标准:由于它不是Java平台的一部分,它不能影响平台本身的模块化。但是,许多应用已经受益于它提供的高于JVM的模块化模型。
高层比较
JPMS和OSGi之间有很多小的差异,但是有一个很大的不同,就是隔离的实现。
隔离是模块化系统最基本的特征。每个模块必须有一些保护措施防止运行在同一应用程序中其他模块的干扰。隔离是一个连续的而不是二进制的概念:无论OSGi还是JPMS都需要做一些事情来避免那些表现不好的模块的影响,这些模块占用了JVM中所有可用的内存,运行了数千个线程或者让CPU处于繁忙的循环。如果一个模块可以作为操作系统上独立的进程运行,是可以提供这类保护的,但即使是这样,它也是不完美的;有人仍然可以使操作系统崩溃或者擦除磁盘。
OSGi和JPMS都提供了代码级隔离,这意味着一个模块不能访问另一个模块的内部类型,除非该模块有明确的许可。
OSGi通过类加载器实现隔离。每个模块(或者在OSGi术语中称为“bundle”)有一个类加载器,它知道如何在bundle中加载类型。它也可以将类加载请求委托给它所依赖的其他bundle的加载器。该系统是高度优化的,例如,OSGi不会为一个bundle创建一个类加载器直到最后一刻,而且事实上每个加载器会处理一个更小的类型,这样每个类型可以加载得更快。
这个系统最大的优势是,bundle可以包含重叠的包和类型,而且不会相互干扰。实际的结果是,可能某些包和库有多个版本同时运行在相同的JVM。在处理像Maven这样的构建工具带来的复杂的传递依赖图时,这是个福音。在许多企业,Java应用程序几乎不可能有这样的一套依赖,该依赖中每个库只包含一个版本。
例如,我们来看看库1。JitWatch依赖于 1.7.7 和 1.1.2,但是logback-classic 1.1.2依赖于slf4j-api 1.7.6,与JitWatch直接的依赖有冲突。JitWatch也传递地依赖于 1.6和1.9版本,如果包含测试范围的依赖,我们会有另一个slf4j-api的版本1.6。这种混乱是很常见的,传统的Java中没有真正的解决方案,只能逐步在依赖树中添加“excludes”直到奇迹般地得到一套可以运行的依赖库。不幸的是对于这个问题JPMS也没有答案,我们很快就会看到。
使用类加载器进行隔离确实有一个缺点:它打破了每一个类型最多可以在一个位置找到的假设。这是模块化的一个自然结果。如果一个模块可以不受其他模块的干扰使用自己的类型,那么不可避免地一个单一类型的名称可能会在多个模块中发现。遗憾的是,这造成了一个问题,因为很多保留的Java代码不是用模块化的思想编写的。特别是,调用Class.forName(String)通过名字查询类型时,在真正模块化的环境中不是总能得到正确的结果,因为有多个可能的返回类型。
正是由于这个缺点,不能使用OSGi模块化JDK本身。JDK的许多地方都有一个隐含的假设,任何JDK类型可以从JDK的任何其他部分加载,所以很多事情在OSGi下会被打破,比如模型。为了解决这个问题,也为了减少使用Class.forName代码的迁移,JPMS选择在隔离时不使用类加载器。当你在“modulepath”使用一组模块来启动应用程序时,所有这些模块将由相同的类加载器加载。相反,JPMS引入新的访问规则实现隔离。
OSGi的隔离屏障是可见的。在OSGi,我们不能加载一个模块的内部类,因为它们是不可见的。也就是说,自己模块的类加载器只能看到自己模块内部的类型以及从其他模块明确导入的类型。如果我试图从其他的模块中加载一个内部类,我的类加载器是看不到该类型的。就好像是根本不存在的类型。如果试图继续加载该类,就会得到NoClassDefFoundError或者ClassNotFoundException的异常。
在JPMS,每一个类型对于任何其他类型都是可见的,因为他们存在于同一个类加载器。但是,JPMS增加了辅助检查以确定加载类有权访问它试图加载的类型。其他模块的内部类型实际上是private的,即使它们被声明为public。如果我们试图继续加载它,那么我们会得到IllegalAccessError或者IllegalAccessException的异常。如果我们试图加载private的或者另一个包的默认访问类型也会得到相同的错误,而且在这个类型上调用setAccessible也是无用的。这改变了Java中public修饰符的语义,以前它是普遍可访问的,现在只可在一个模块和它的require对象中访问。
JPMS方法的缺点是,它不可能有重叠内容的模块。也就是说,如果两个模块都包含一个私有(非导出)的包org.example.util,这些模块不能同时在模块路径上被加载——它会导致layerinstantiationexception异常。通过应用程序实例化类加载器可能会解决此限制——但这正是OSGi已经为我们做的!
再次强调,完全是通过设计允许JPMS模块化JDK的内部。但结果是,你会有不能完全一起工作的模块,因为它们内部的实现细节有冲突。
复杂性
对于OSGi最常见的抱怨之一是,它给开发人员增加了复杂性。这有一定的道理,但是有这些抱怨的人都搞错了复杂性的原因。
模块化并不是一个在应用程序发布前洒在上面的神奇的尘埃。它是在设计和开发各个阶段必须遵循的准则。一些开发人员已经意识到了OSGi带来的巨大收益,他们在早期就开始使用OSGi并且在编写一行代码之前会运用模块化思想,他们发现OSGi实际上是非常简单的,尤其是在使用现代化OSGi工具链时,它自动生成元数据并且在运行前做了大量的一致性检查捕获异常。
而另一方面,开发人员试图把OSGi引入现有的大型代码库时遭遇了困难,因为这些代码很少能够模块化以便迁移。没有执行模块化的准则,很容易走捷径,打破封装性。BEA WebLogic的一个开发人员告诉我,在Oracle收购BEA之前:“我们以为我们是模块化的,直到我们开始使用OSGi。”
除了非模块化的应用程序,OSGi的采用也受到非模块化库的阻碍。一些流行的Java库中类加载和全局可见性的假设在模块化结构中被打破了。OSGi做了大量工作,让它可以使用这些库,这是OSGi规范明显复杂性的来源。我们需要有一定的复杂性来处理混乱的、复杂的现实世界。
我们很快就会看到,JPMS也会有同样的问题——可能更是如此。如果你的组织曾试图采用OSGi,却因为迁移工作量过大而放弃了,那么当你要迁移到JPMS时,至少应该预期会有同样多的工作量。只需要看看Oracle在模块化JDK时的经验:有很多的工作要做,导致Jigsaw从Java 7延迟到Java 8,再到Java 9,甚至Java 9已经延迟了一年(到目前为止)。
Jigsaw项目开始于一个目标就是越来越简单,但JPMS规范大大增加了复杂性:与类装载器模块的相互作用;分层结构和配置;re-exporting要求;弱模块;静态要求;qualified导出;dynamic导出;跨层继承的可读性;多模块JAR文件;自动模块;未命名的模块等等,已经非常清晰所有的这些功能都会作为需求添加进来。类似的过程也发生在OSGi,只是它有16年领先的优势。
依赖:包vs全部模块
隔离只是模块化的一个难题:模块仍然需要协同工作和通信。模块之间建立“墙”后,它们需要以一个可控的方式重新连接。一个模块化系统必须定义模块访问其他模块功能的方式。可以通过在类型级别上静态地或者动态地使用对象来实现。
静态依赖在编译时就是已知的和可控的。如果一个类型在一个模块的边界引用另一个类型,那么模块系统需要提供一个方法让该类型可见并且可访问。有两种方式:模块需要有选择性地暴露一些内部类型,模块需要指定自己使用了其他模块的哪些类型。
导出(Exports)
在OSGi和JPMS中,类型暴露在Java包级别就完成了。在OSGi使用Export-Package语句声明指定名称的包对其他bundle是可见的。它看起来像这样:
Export-Package: org.example.foo; version=1.0.1, org.example.bar; version=2.1.0
该声明在META-INF/ MANIFEST.MF文件中。OSGi初期大多数开发人员会手工指定这样的声明;但我们越来越倾向于使用构建工具生成。现在最流行的方式是在Java源代码中添加注解,Java 5中引入了package-info.java文件允许包级别的注解和文档,所以OSGi中可以如下编写:
@org.osgi.annotation.versioning.Version("1.0.1") package org.example.foo;
这是一个有用的模式,因为想要导出一个包时可以直接在该包中表示。版本也可以在这里显示,包的内容变化时在附近就可以更新2。
JPMS中包的导出在module-info.java文件中,如下:
module A { exports org.example.foo; exports org.example.bar; }
请注意,如果缺少version,JPMS中模块和包都不能被版本化;稍后我们会讨论这一点。
Imports/Requires
虽然在导出时OSGi和JPMS是类似的,但是导入或对其他模块的依赖却有显著的差异。
在OSGi,导入包是对导出包的补充。使用Import-Package声明导入包,例如:
Import-Package: org.example.foo; version='[1,2)', org.example.bar; version='[2.0,2.1)'
OSGi中bundle必须导入它所依赖的所有包,除了java.*开头的包,如java.util。例如,如果你的bundle中的代码依赖org.slf4j.Logger(并且你的bundle中实际上并不包含org.slf4j包),那么这个包必须被导入。同样,如果你依赖于org.w3c.dom.Element,那么你必须导入org.w3c.dom。但是,如果你依赖java.math.BigInteger,你不需要导入java.math,因为Java.*包是由JVM的bootstrap类加载器加载的。
OSGi对于引用所有的bundle还有一个并行机制,称为Require-Bundle,但在OSGi规范中它已经过时了,现在它的存在只是为了支持很小的边缘的案例。Import-Package最大的优势是它允许模块在不影响下游模块的前提下被重构或被重命名。如图1和2所示。
在图1中,模块A被重构为两个新的模块,A和A',但模块B不受该操作影响,因为它依赖于提供的软件包。在图2中,我们对模块A执行完全相同的重构,但现在B可能是坏的,因为它引用的包有可能不再存在于模块A(在这里我们必须说“可能”,因为我们不知道模块B使用模块A哪些包——这正是问题所在!)。
图1:通过 Imported Packages 重构模块
图2:通过 Requires 重构模块
Import-Package语句手动写是很繁琐的,所以我们不这样做。通过OSGi工具检查依赖生成该语句,并将编译类型内置到bundle中。这是非常可靠的,比开发人员自己声明运行时依赖更可靠。当然,开发人员仍然需要管理自己的编译依赖,按照Maven正常的方式去做(或者你选择的构建工具)。如果编译时把太多的依赖放在classpath下并不会有影响:可能发生的最坏情况是编译失败,这只会影响源头的开发人员并且很容易修复。另一方面,太多的运行时依赖会降低模块的可移植性,因为移植时所有这些依赖关系必须一起移植,而且可能与另一个模块的依赖发生冲突。
这导致了OSGi和JPMS之间另一个关键的理论差异。在OSGi我们始终认为,编译时依赖和运行时依赖可以并且经常会不同。例如,它的标准做法是,有一套编译时API和一套运行时API。此外,开发人员通常在我们所能兼容的最老的API版本上编译,但会选择可以找到的最新的版本来运行。甚至非OSGi开发人员也很熟悉这种方法:你通常会在准备支持的最低版本的JDK上编译,却鼓励用户在最高的版本(包含所有的安全补丁和增强性能)上运行。
另一方面JPMS采取了不同的策略。JPMS旨在实现“跨越所有阶段的保真度”,这样“模块化系统应该…在编译时、运行时以及在开发或部署的各个阶段可以以完全相同的方式工作”(来自)。因此,依赖关系是在整个模块运行时定义的,因为这就是它们在编译时定义的方式。例如:
module B { require A; }
require语句和OSGi过时的Require-Bundle有相同的效果:模块B可以访问所有模块A的导出包。因此,它也存在Require-Bundle同样的问题:从模块的声明无法确定重构模块A的内容是否是安全的,所以这样做一般是不安全的。
我们发现,依赖树使用requirements而不是imports有更高程度的扇出:每个模块携带比它真正需要的更多的依赖。这些问题是真实和重要的。尤其是Eclipse插件作者深受其害,因为历史原因Eclipse bundle 倾向于使用requires而不是imports。非常不幸地,JPMS也遵循了这条路线。
有趣的是,虽然编译/运行时的保真度是JPMS的根本目标,但最近的变化明显减弱了保真度。目前的早期试用版本允许用static修饰符声明requirement,这意味着在编译时依赖是强制性的,但在运行时是可选的。相反,可以用dynamic修饰符声明导出,这可以使导出包在编译时无法访问,但在运行时可以访问(使用反射)。有了这些新特性可能会创建出成功编译和链接,但在运行时抛出IllegalAccessError/Exception异常的模块。
反射和服务
Java生态系统是巨大的,包含了用于各种目的的各种各样的框架:从依赖注入到mocking框架、远程调用、O/R映射等。从用户提供的代码来看,许多框架使用反射来实例化和管理对象。例如,Java持久化架构(JPA),它是Java EE套件规范的一部分:作为对象关系映射,为了将domain类与从数据库加载的记录一一映射,必须从用户代码加载和实例化domain类。另一个例子是,Spring框架加载和实例化“bean”类实现接口。
这会为包括OSGi和JPMS的模块化系统带来问题。理想情况下,domain或bean类应该隐藏在一个模块内部:如果它被导出,就会成为公共API,这样会对依赖于它的消费者造成破坏,但是我们希望能够灵活地随意改变我们的内部类。另一方面,如所述的,通过反射访问非导出类型支持框架是非常有效的。
由于OSGi的类加载器是基于设计的,模块可以获得其他模块非导出包和类型的可见性——只要他们知道该类型的全限定名以及知道是哪个模块发出的请求(请记住几个模块可以包含任何给定的类型名称)。Java长期使用反射的精神有效地减少了隔离,在这里甚至所谓的私有字段都可以通过调用setAccessible方法被公开。
在OSGi中使用此功能是常见的做法,用来提供根本没有导出模块的实现!相反,它们可能包含引用内部类型的声明,这些内部类型可以通过框架加载。例如,使用JPA做持久化的模块可以引用persistence.xml文件的domain类型,并且在需要时JPA实现模块将会加载引用类型。
最大的用例是实施服务组件。OSGi规范包含一章节叫声明式服务(DS),定义了一个模块如何声明组件:类的生命周期是由框架管理的。组件可以绑定到OSGi服务注册表中的服务,并且可以自选地为自己提供服务。例如:
@Component public class CartMgrComponent implements CartManager { @Reference UserAdmin users; @Override public Cart getOrCreateCart(String user) { // ... } }
在这个例子中,CartMgrComponent是一个提供CartManager服务的组件。它引用了一个服务——UserAdmin,类的生命周期由DS框架管理。当UserAdmin服务可用,CartMgrComponent就会被创建,并且它会发布CartManager服务,该服务同样可以在其他模块的其他组件中引用。
这个框架可以工作是因为它加载了CartMgrComponent类,该类已经被@Component注解标记为组件。定义组件和服务是OSGi应用设计和编写的主要方式。
在JPMS,只有导出包的类型可以被访问,即使是反射。虽然在非导出包中类型是可见的(你可以调用Class.forName获取一个类对象),但在模块外它们是不可访问的。当一个框架试图调用newInstance实例化一个对象,会抛出IllegalAccessException异常。这似乎切断了框架的许多可能性,但是也有一些解决方法。
一种方法是提供个别类型作为服务,可以通过java.util.serviceloader加载。自Java 6开始serviceloader就是标准平台的一部分,在Java 9它已被更新支持跨模块工作。serviceloader可以访问非导出包的类型,只要提供包含provides声明的模块。不幸的是,serviceloader是古老的,不能为现代化框架,如DS或spring,提供所需的灵活性。
第二种可能是使用“qualified”导出包。这种导出,只允许指定模块访问,而不是所有模块都通用。例如,你可以导出bean包到Spring Framework模块。但是这可能无法用于其他方面像JPA,因为JPA是一个规范而不是一个指定的模块,并且它可以由不同的模块实现,如Hibernate、EclipseLink等。
第三种可能是“dynamic”导出,这种包任何人都可以访问,但只能自己使用反射,而且不是在编译时。这是JPMS一个非常新的特性,在邮件列表上它仍然是有争议的。它最接近OSGi的permissive方法,但它仍然需要模块作者为某些包明确添加dynamic导出,这些包中可能包含需要反射地加载的类型。作为一个OSGi用户感觉它是不必要的复杂性。
下回分解
以上就是我们文章的第一部分。未来两周内我们会探寻第二部分的内容,考察版本的主题、动态加载以及OSGi和JPMS潜在的未来互操作性。
关于作者
Neil Bartlett是首席工程师、顾问、培训师、Paremus的开发工程师。Neil从1998年开始从事于Java,2003年开始从事于OSGi,专注于Java、OSGi、Eclipse和Haskell。他是Eclipse插件bndtools的创始人,bndtools是OSGi领先的IDE。他经常在Twitter(@nbartlett)上推OSGi有关的内容,回答堆栈溢出的问题,他是黄金OSGi徽章唯一的持有人。Neil定期会在写博文,他也写了他的第二本书,该书为开发人员展示了采用最新的技术和工具如何快速加速OSGi的生产力。
Kai Hackbarth是博世软件创新的传播者。他曾深入参与OSGi联盟的技术标准化活动超过15年。Kai是OSGi联盟董事会的成员,自2008年以来一直是OSGi住宅专家组的联合主席。他正在协调多个不同物联网领域的研究项目活动。他重点关注的领域是智能家居、汽车和物联网,并积极支持目前的发展和产品组合的战略定位。
参考文献
- 感谢Alex Blewitt的。
- 使用@Version注解意味着导出,因为只有导出包需要版本。在接下来的OSGi发布中一个更明确的@Export注解正在计划中。
- 就在本篇文章发表之前,本区域在2016年9月12日再次发生了变化。动态导出现在换成“弱模块”的概念。我们仍在评估这一根本变化的影响,并注意到它在Java 9的发布时间表中已进一步造成4个月的延迟。
查看英文原文:
感谢对本文的审校。