龙空技术网

Java9中的头等概念,模块中的第一块拼图,你都了解多少?

程序员xysam 21

前言:

而今小伙伴们对“模块化使程序设计比较方便但比较难以维护”大概比较关心,我们都需要知道一些“模块化使程序设计比较方便但比较难以维护”的相关内容。那么小编在网摘上网罗了一些关于“模块化使程序设计比较方便但比较难以维护””的相关内容,希望各位老铁们能喜欢,朋友们快快来学习一下吧!

在Java 9中,模块化是头等概念(first-class concept)。但什么是模块?它解决了哪些问题?人们如何从中获益?头等概念是什么意思?

本次将回答所有这些问题,且不止于此。我将教你如何定义、构建和运行模块,介绍模块对现有项目的影响以及它们带来的好处。

这一切都会在适当的时候逐一介绍。本部分首先解释了模块化(modularity)的含义、迫切需要它的原因。

第一块拼图

本章内容

模块化及其塑造系统的原理

Java实施模块化的障碍

新模块系统解决这些问题的方法

我们都经历过部署的软件不按预期工作的情况。导致这种情况的原因很多,但是其中一类问题很讨厌,以致获得了一个如雷贯耳的名字:JAR地狱(JAR hell)。

经典的JAR地狱问题是依赖错误:某个依赖包缺失,或是某个依赖包被多次包含,并且很可能以不同版本的形式被多次包含。这类问题一定会造成程序崩溃,更糟糕的是,会悄无声息地破坏运行中的程序。

造成JAR地狱的根本原因是,我们把JAR视为具有标识和相互关系的工件,而Java认为JAR是没有任何有意义属性的类文件容器。这种差异导致了问题。

一个例证就是缺乏有意义的JAR封装:同一应用程序的所有代码都可以自由访问所有公有类型。这容易导致依赖于某个库中的类型,但是该库的维护者认为这些类型只是内部实现细节,而没有将它们打磨到可公开使用。这些类型通常被隐藏在名为internal或impl的包中,但这并不能阻止它们被引用。

于是,当类库维护者修改这些内部实现时,我们的代码也将受到影响。

或者,如果我们在社区中拥有足够的影响力,那么维护者可能会被迫保留内部实现细节的代码,进而妨碍代码的重构和演变。缺少封装会同时降低类库和应用程序的可维护性。

缺少封装对日常开发影响较小,但由于很难控制对安全相关代码的访问,这个问题给整个Java生态系统带来了糟糕的影响。这在Java开发工具包(Java Development Kit,JDK)中造成了一系列安全隐患,其中一些问题直接导致了Oracle收购Sun之后Java 8的延迟发布。

这些问题已经困扰Java开发人员20多年了,而对解决方案的讨论持续了同样长的时间。Java 9是第一个在语言层面提供解决方案的版本:自2008年以来,Jigsaw项目一直在开发Java平台模块系统(Java PlatformModule System,JPMS)。

它使开发人员可以通过将元信息附加到JAR来创建模块,从而使JAR不再仅仅是容器。从Java 9开始,编译器和运行时开始理解模块的标识和模块之间的关系,从而解决了缺少依赖、重复依赖和缺少封装等问题。

JPMS不仅仅是权宜之计,它同时拥有很多优秀特性,能够帮助我们开发更加精巧、更易维护的软件。或许,它最大的好处是让每个开发人员和社区直面模块化的基本概念。这有利于培养知识更丰富的开发人员、构建更多的模块化类库,以及提供更好的工具支持——在模块化是一等公民的Java世界中,这些收益值得期待。

我意识到许多开发人员在进行Java升级时会跳过多个版本,例如从Java8直接升级到Java 11。我会提醒大家注意Java 9、Java 10和Java 11之间的差异。本书中的大部分内容适用于Java 9及以上版本。

一节将开始探讨什么是模块化,以及通常如何看待软件系统的结构。关键在于,在特定的抽象级别(JAR)上,JVM看到的与我们所看到的不同(二节)。

一 什么是模块化

你是如何看待软件的?它们是一些代码行?一堆位(bit)和字节?一些UML图?抑或Maven POM?

本书试图寻找的是对软件的直观认识,而非定义。花点时间想想你最喜欢的项目(或者你受雇参与的项目)。感觉如何?你有办法将它可视化吗

1 用图将软件可视化

我将自己工作的代码库视作由交互部件构成的系统(对,就是这么正式)。每个部件有3个基本属性:名称、对其他部件的依赖和提供给其他部件的功能。

每个抽象级别都是如此。在非常低的级别上,部件对应单个方法:名称是方法名,依赖是调用的方法,功能是方法返回值或状态改变。在非常高的级别上,部件对应服务(有人认为是微服务吗?)或者整个应用程序。

想象一下某个结账服务。作为电子商店的一部分,该服务使用户可以购买所挑选的商品。为此,需要调用登录和购物车服务。它同样拥有所有的3个属性:名称、依赖和功能。使用这些信息,可以轻松绘制图1-1。

图1-1

如果记录结账服务及其依赖,将自然形成包含名称、依赖和功能的简单图。

我们可以感受不同抽象级别上的部件。从方法到整个应用程序,可以将部件映射到类、包和JAR。它们都具有名称、依赖和功能等属性。

这个观点的有趣之处在于,如何使用它对系统进行可视化和分析。如果我们为每个想到的部件绘制一个节点,然后根据依赖关系用边连接这些节点,将得到一幅图(graph)。

这种映射非常自然,以至于电子商店的例子已经实现了它,而你可能没有注意到。图1-2展示了将软件系统可视化的其他常用方法,图随处可见。

图1-2

在软件开发中,图无处不在。它们以各种形状和形式出现,例如,UML图(左)、Maven依赖树(中)和微服务连接图(右)类图(class diagram)也是图。

构建工具输出的类似于树状结构的依赖关系(如果使用Gradle或Maven,要执行gradle dependencies或mvndependency:tree)是一种特殊的图。你看过那些疯狂的、难以理解的微服务图吗?它们也是图。

根据是在谈论编译时依赖还是运行时依赖,是只看一个抽象级别还是将不同的抽象级别混合,是检查系统的整个生存期还是仅仅检查某个时刻,以及许多其他区别,这些图看起来会有很大不同。一些区别以后会比较重要,但目前无须深入了解细节。现在,无数的图都满足需求,所以请想象一下你觉得最舒服的那一种。

2 设计原则的影响

将系统可视化为图,是分析系统架构的常用方法。许多软件设计原则可以直接影响图的外观。

以分离关注点这一设计原则为例。采用此原则开发软件时,每个部件只关注一个任务(比如“用户登录”或“绘制地图”),而任务通常由更小的子任务构成(比如“加载用户”和“验证密码”),实现任务的部件也遵照此原则划分。实现不同功能的多个小部件相互聚合,最终形成图。

相反,如果关注点分离得较差,图就没有清晰的结构,各个节点相互连接,看起来一团乱麻。如图1-3所示,这两种情况很容易区分。

图1-3

两个用图描述的系统架构。节点可以是JAR或者类,边是节点间的依赖。然而细节并不重要:快速浏览一下,就能分辨关注点分离得如何依赖反转是另一条会影响图的样式的设计原则。

在运行时,上层代码始终调用底层代码,但设计良好的系统在编译时会反转依赖关系:上层代码依赖于接口,底层代码实现接口,从而将依赖向上反转到接口。请看图1-4,这些反转很容易发现。

图1-4

上层代码依赖底层代码的系统创建的图(左),与采用接口向上反转依赖的系统创建的图(右)不同。依赖反转有利于识别和理解系统中有意义的组件。

将图理顺是诸如关注点分离和依赖反转等原则的目标。如果忽略这些原则,那么系统会变得一团糟,任何变更都会破坏看似不相关的部分。如果遵循这些原则,则可以很好地组织系统。

3 什么是模块化

软件设计原则指导我们如何理顺系统。有趣的是,尽管目标是提高系统的可维护性,大多数以此为目标的原则却将注意力集中到单个部件上。

重点之所以不在于整个代码库而在于单个部件,是因为所有部件的特性决定了所构成的系统的特性。

我们学习了关注点分离和依赖反转所具有的两个良好特性:专注于单个任务,以及依赖于接口而不是实现。系统部件的良好特性总结如下。

要点 每个模块(也就是前文提到的部件)都有清晰的责任和要实现的明确协议。模块是自包含的并且对客户端不透明,可以被实现了相同协议的模块替换。它依赖少量的API而不是实现。

基于这些模块构建的系统能更加从容地应对变化,并且只要依赖模块实现得合理,它们在启动甚至运行时也会更加灵活。这就是模块化:通过设计良好的模块实现可维护性和灵活性。

二 Java 9之前的模块擦除

前文已经展示了由交互部件构成的图带来的良好特性,这些特性通常被概括为模块化。但它们毕竟只是想法,即谈论软件的方法。图只是一行行代码,(以Java为例)这些代码最终被编译成字节码指令并由JVM执行。如果语言、编译器和JVM(粗略地归结为Java)能够拥有人的视角,那将会很棒。

通常情况下,确实如此。如果你设计类或接口,那么名称就是Java使用的标识。你定义为API的方法正是其他代码调用的接口——具有相同的方法名和参数类型。它的依赖非常清晰,无论是导入语句还是完全限定的类名都是如此,编译器和JVM将使用这些名称所对应的类来满足其所需依赖。

以Future接口为例,它表示可能完成或尚未完成的计算结果。它的功能并不重要,因为我们只对依赖感兴趣。

通过Future接口的方法声明,很容易枚举它的依赖。

将相同的分析应用于这些类型,可以得到如图1-5所示的依赖关系。图的具体形式不重要。重要的是,当我们谈论一个类型时,所想到的依赖关系图和Java隐式创建的依赖关系图是一致的。

图1-5

对于任何给定类型,Java操作的依赖关系图与我们对类型依赖关系的感知一致。图中显示Future接口依赖java.util.concurrent包和java.lang

由于Java是强静态类型的语言,因此一旦有错误它会立即报告。类的名称非法?依赖丢失?方法的可见性发生改变,使得调用方看不见它?

Java编译器和JVM将分别报告编译期间和执行期间的问题。

反射(参见附录B)会绕过编译时检查。因此它被认为是潜在的危险工具,仅适用于特殊场合。后面的章节中会讨论反射,本章暂时忽略它。

人们对依赖以及依赖关系的理解与Java存在分歧,我们以服务或应用程序级别为例。以下内容不在Java的职责范围内:知道应用程序的名称;告诉你没有“GitHab”服务或“Oracel”数据库(有拼写错误的名称);知道你更改了服务的API并影响了客户端。Java缺少映射到应用程序或服务的结构。这没什么影响,因为Java运行在单个应用程序的级别上。

但有一个抽象级别显然属于Java的范围,尽管在Java 9之前,对它的支持非常差——差到模块化工作失去了意义,从而导致了所谓的模块擦除(module erasure)。该抽象级别称为工件(artifact),或Java术语中的JAR。

如果应用程序在JAR级别上模块化,那么它就由多个JAR组成。即便不是如此,它的依赖库也会有自己的依赖关系。记下这些,最终会获得已经熟悉的图,但图中的节点是JAR,而不是类。

例如,考虑一个名为ServiceMonitor的应用程序。忽略细节,它的行为大致如下:通过网络检查其他服务的可用性并聚合统计信息。这些统计信息被写入数据库,并通过REST API对外提供服务。

该应用程序创建了4个JAR。

observer——观察其他服务并检查可用性。

statistics——把可用性数据聚合成统计信息。

persistence——通过hibernate把统计信息读写到数据库。

monitor——触发数据收集,并将数据从statistics一路发送

persistence,采用spark实现REST API。

每个JAR都有自己的依赖,如图1-6所示。

图1-6

对于任何应用程序都可以绘制依赖关系图。此处ServiceMonitor应用程序被拆分为4JAR,它们相互依赖,另外还依赖第三方类库

此图包括了前面讨论的一切:JAR有名称,彼此依赖,每个JAR的功能通过公有类和方法的形式供其他JAR调用。

启动应用程序时,必须在类路径上列出所有要使用的JAR。

要点 此处会出问题,至少在Java 9之前会出现问题。JVM启动时缺少所需类的信息。从命令行的main开始,每次引用未知的类时,它都会遍历类路径中的所有JAR,并查找具有完全限定名称的类。如果找到一个,就加载到一个巨大的类集合中。如上所述,在JVM中没有与JAR相对应的运行时概念。

由于缺少标识,运行时丢失了JAR的信息(虽然JAR有文件名,但JVM并不关心)。如果异常信息可以指出发生问题的JAR,或者JVM可以命名缺失的依赖项,这难道不是更好吗?

与此同时,依赖也变得不可见。在类的级别操作时,JVM没有JAR之间依赖关系的概念。同时,忽略包含类的工件意味着不能封装这些工件。

而且事实上,每个公有类对所有其他类都是可见的。

名称、显式依赖和明确定义的API等模块信息虽然重要,但编译器和JVM并不关心它们。这会擦除模块化结构,并将精心设计的图变成一团乱麻(如图1-7所示),还将导致一系列后果。

图1-7

Java编译器和虚拟机没有JAR以及JAR之间依赖关系的概念。

相反,JAR仅被视为简单的类容器,其中的类被加载到单个命名空间。最终这些类处于一种混沌状态,任何公有类型都可以相互访问

标签: #模块化使程序设计比较方便但比较难以维护