11 月 19 - 20 日 Apache Pulsar 社区年度盛会来啦,立即报名! 了解详情
写点什么

Java 9,OSGi 以及模块化的未来

  • 2016-10-19
  • 本文字数:9275 字

    阅读完需:约 30 分钟

关键要点

  • Java 9 将在 2017 年发布,一个标志性的特性是新的模块化系统,命名为 Java 平台模块化系统(JPMS)。本文探讨了它与现有的 Java 标准组件如何关联起来,以及它对现有的 Java 标准组件有什么影响。
  • 自 1.0 版本以来,Java 已经增长了 20 倍,模块化平台是非常必要的。为了解决这个问题,也曾有过很多失败的尝试。而与此同时,OSGi 已经提供应用程序模块化 16 年。
  • OSGi 和 JPMS 在实现细节上有本质上的区别。如果 JPMS 被当作模块化的通用解决方案,似乎会有严重的缺陷和缺失的功能。
  • JPMS 的目标是使用起来比 OSGi 更简单、更容易。但是,让现有的非模块化产品模块化是非常复杂的,而且 JPMS 在这个目标上似乎没有成功。
  • JPMS 在 Java 平台自身模块化方面做得很好,这意味着我们可以为特定的工作构建一个小的运行时环境,它只包含 Java 平台相关的部分。在应用程序模块化方面 OSGi 有很多优势。我们已经证明了两者可以结合起来,这看起来是一个成功的方式。

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 应用程序几乎不可能有这样的一套依赖,该依赖中每个库只包含一个版本。

例如,我们来看看 JitWatch 库 1。JitWatch 依赖于 slf4j-api 1.7.7 和 logback-classic 1.1.2,但是 logback-classic 1.1.2 依赖于 slf4j-api 1.7.6,与 JitWatch 直接的依赖有冲突。JitWatch 也传递地依赖于 jansi 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 旨在实现“跨越所有阶段的保真度”,这样“模块化系统应该…在编译时、运行时以及在开发或部署的各个阶段可以以完全相同的方式工作”(来自 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 定期会在 Paremus 博客写博文,他也写了他的第二本书《Effective OSGi》,该书为开发人员展示了采用最新的技术和工具如何快速加速OSGi 的生产力。

Kai Hackbarth是博世软件创新的传播者。他曾深入参与 OSGi 联盟的技术标准化活动超过 15 年。Kai 是 OSGi 联盟董事会的成员,自 2008 年以来一直是 OSGi 住宅专家组的联合主席。他正在协调多个不同物联网领域的研究项目活动。他重点关注的领域是智能家居、汽车和物联网,并积极支持目前的发展和产品组合的战略定位。

参考文献

  1. 感谢 Alex Blewitt 的分析
  2. 使用 @Version 注解意味着导出,因为只有导出包需要版本。在接下来的 OSGi 发布中一个更明确的 @Export 注解正在计划中。
  3. 就在本篇文章发表之前,本区域在 2016 年 9 月 12 日再次发生了变化。动态导出现在换成“弱模块”的概念。我们仍在评估这一根本变化的影响,并注意到它在 Java 9 的发布时间表中已进一步造成 4 个月的延迟。

查看英文原文: Java 9, OSGi and the Future of Modularity


感谢夏雪对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2016-10-19 18:3520111
用户头像

发布了 31 篇内容, 共 90983 次阅读, 收获喜欢 0 次。

关注

评论 1 条评论

发布
用户头像
这篇文档看起来有点乱 组织得不好
2019-08-09 10:21
回复
没有更多了
发现更多内容
Java 9,OSGi以及模块化的未来_Java_Kai Hackbarth_InfoQ精选文章