14.2 为什么要设计Java模块系统
这一节里,你会了解为什么Java语言及其编译器需要一个全新的模块系统。首先,我们会介绍Java 9之前版本在模块化方面的局限性。接着,我们会聊聊JDK库的一些背景知识并解释为什么模块化如此重要。
14.2.1 模块化的局限性
不幸的是,Java 9之前内建的模块化支持或多或少都存在一些局限,无法有效地实现软件项目的模块化。从代码层次而言,Java的模块化可以分为三层,分别是:类、包以及JAR。对类而言,Java可以通过访问修饰符实现封装。不过,从包和JAR的层次看,对应的封装则相当有限。
- 有限的可见性控制
正如前文所述,Java提供了访问描述符来支持信息封装。这些描述符可以设定对象的公有访问、保护性访问、包级别访问以及私有访问。不过,如果需要控制包之间的访问,又该如何做呢?大多数Java应用程序采用包来组织和管理不同的类,然而包之间的访问控制方式乏善可陈。如果你希望一个包中的某个类或接口可以被另外一个包中的类或接口访问,那么只能将它声明为public。这样一来,任何人都可以访问这些类和接口了。这种问题的典型症状是,你能直接访问包中名字含有impl字符串的类——这些类通常用于提供某种默认实现。由于包内的这段代码被声明为公有访问,因此你无法限制其他人访问或者使用这些内部实现。这样一来,你的代码演进就受到了极大的制约,局部代码的变更可能导致无法预计业务失效,因为你原以为仅供内部使用的类或者接口,可能会被某个程序员在编码解决某个问题时突发奇想地调用,很快这种结构不良好的代码就会融入整个系统。从安全性角度而言,这种状况带来的影响更为严重,它增大了系统受攻击的可能性,因为更多的代码都暴露在了攻击面下。
- 类的路径
本章前面讨论了用容易维护和理解,也就是易于推理的方式构建软件的好处。我们也探讨了关注点分离以及模块间的模型依赖(modeling dependency)。非常不幸的是,说到应用的打包以及运行,Java一直以来在这些方面都存在着短板。实际上,每次发布时你只能把所有的类打包成一个扁平结构的JAR文件,并把这个JAR包添加到类路径(class path)1上。这之后JVM才能按照需求动态地从类的路径中定位并载入相关的类。
然而,类路径与JAR混合使用也存在几个严重的问题。
首先,对同一个类,无法指定到底使用类路径上的哪一个版本,因为根本无法通过路径指定版本。举个例子,使用来自某个解析库的JSONParser类时,你无法指定是使用1.0版本的还是2.0版本的,由此也无法预测,如果类路径上同一个库存在两个不同版本会发生什么。这种情况在大型应用中相当常见,因为应用的不同组件可能需要使用同一个库的不同版本。
其次,类路径也不支持显式的依赖。类路径上林林总总的JAR中所有的类都被一股脑地塞到了一个类组成的大包裹中。换句话说,类路径不支持显式地声明某个JAR依赖于另一个JAR中的某些类。这种设计使得我们很难对类路径进行分析并回答下面这种问题,譬如:
- 是否有某些类在路径中遗漏了?
- 路径上的类是否存在冲突?
Maven或者Gradle这样的构建工具可以帮助解决这一问题。Java 9之前,无论是Java还是JVM都不支持显式地声明依赖。这些问题碰到一起就产生了我们称之为“JAR地狱”或“类路径地狱”的问题。这些问题的直接结果就是我们不停地在类路径上添加和删除类文件,希望能通过实验找出合适的搭配,让JVM顺利地执行应用,不再抛出让人头疼的ClassNotFound Exception。理想情况下,这种问题在开发的早期阶段就应该被发现并解决。好消息是,如果你持续一致地在项目中使用Java 9的模块系统,刚才提到的所有问题都可以在编译期就被捕获。
像“类路径地狱”这样的封装问题并不是只存在于你的软件架构中,JDK自身也存在类似的问题。
1这种说法常用于Java文档,对于程序参数而言,常使用的是classpath。
14.2.2 单体型的JDK
Java开发工具集(JDK)由一系列编写并执行Java程序的工具组成。有几个重要的工具你可能已经很熟悉了,譬如,javac可以编译Java程序,而java搭配JDK提供的库可以加载并执行Java应用。JDK库提供了Java程序的运行时支持,包括输入/输出、集合以及流。第一版JDK发布于1996年。像任何其他的软件一样,随着新特性的引入,JDK也不断增大,理解这一点非常重要。许多之前加入的技术随着潮流的更迭逐渐被废弃。这其中一个著名的例子就是CORBA。无论你是否在你的应用中使用了CORBA,对CORBA的支持默认都打包在JDK之中。由于越来越多的应用运行在移动设备或者云端,它们通常不需要JDK中所有的内容,因此之前这种打包发布模式问题的影响就变得越来越严重了。
怎样从全局或者整个系统的角度来解决这一问题呢?Java 8引入了精简配置(compact profile)这一概念,这是一个很好的尝试。Java 8定义了三种配置,它们的内存开销不一样,你可以根据应用需要的到底是JDK库的哪一部分来决定使用哪一个配置。然而,精简配置只是一个短期的解决方案。JDK中存在着大量的内部API,这些内部API并不是为普通用户使用所设计的。不幸的是,由于Java语言糟糕的封装,这些API现在被大量地使用了。一个典型的例子是sun.misc.Unsafe类,这个类被好几个流行的类库(包括Spring、Netty、Mockito等)所使用,不过它设计之初并不期望被JDK之外的任何代码访问或使用。由于这些牵绊,想要改进这些API非常困难,因为结果很可能是牵一发而动全身,引起前后不兼容的问题。
这些问题为设计新的Java模块系统提供了动力,反过来也用在了JDK自身的模块化上。简而言之,新的结构让你可以更灵活地选择使用JDK的哪一部分以及如何规划类路径,同时也为Java平台的进一步发展演化提供了更强大的封装。
14.2.3 与OSGi的比较
本节会比较Java 9的模块系统与OSGi。如果你从未听说过OSGi,那建议你跳过本节的内容。
Java 9基于Jigsaw项目引入的模块系统诞生之前,Java已经有了一个比较强大的模块系统,名叫开放服务网关协议(open service gateway initiative,OGSi),不过它并非Java平台的官方组成部分。OSGi最早提出于2000年,直到Java 9诞生,一直都是实现基于JVM的模块化应用的事实标准。
实际上,OGSi与新的Java 9模块系统之间并不是完全互斥的,它们甚至可以在同一个应用之中共存。事实上,它们的特性只有小部分的重叠。OGSi所覆盖的范畴要大得多,很多的功能迄今为止在Jigsaw中还不支持。
在OGSi中,模块被称作bundle,它们运行在某个OGSi的框架之中。市面上有多个OGSi认证支持的框架,应用最广的两个是Apache Felix和Equinox(也被用于执行Eclipse的集成开发环境)。一个bundle运行于OGSi框架中时,它可以被远程安装、启动、停止、更新以及卸载,任何一个动作都无须重启应用。换句话说,OGSi为bundle定义了一个非常清晰的生命周期,其状态如表14-1所示。
表 14-1 OGSi中定义的bundle状态
| bundle状态 | 描述 |
|---|---|
| INSTALLED | bundle已经安装成功 |
| RESOLVED | 运行bundle需要的所有Java类都已齐备 |
| STARTING |
bundle正在启动,BundleActivator.start方法已经被调用,不过start方法还未返回结果
|
| ACTIVE | bundle已经成功地启动并运行 |
| STOPPING |
bundle正在停止过程中,BundleActivator.stop方法已经被调用,不过stop方法还未返回结果
|
| UNINSTALLED | bundle已经被卸载,之后它无法进入别的状态了 |
与Jigsaw相比,能够以热切换方式替换应用的各个子系统而无须重启应用是OGSi最大的优势。每一个bundle都通过文本文件声明了该bundle运行所需的外部包依赖,以及由这个bundle导出并可以被其他bundle使用的内部包。
OGSi的另一个有趣的特性是,它允许在框架中同时安装同一个bundle的不同版本。Java 9模块系统还不支持这样的版本控制,因为Jigsaw中每个应用仅使用一个类加载器,而OGSi中每一个bundle都有单独的类加载器。
