14.4 使用Java模块系统开发应用
本节会介绍如何从零开始构建一个简单的模块化应用,从而让你对Java 9模块系统有一个全局的认识。你会学到如何构架、打包以及发布一个小型模块化应用。本节不会深入到模块化每一方面的细节,不过一旦有了全局的视图,需要的时候你可以在此基础上做进一步的研究。
14.4.1 从头开始搭建一个应用
为了开始使用Java模块系统,你需要一个示例项目才能着手编写代码。我们假设你爱旅行,爱去超市购物,也爱跟朋友一起去咖啡店闲谈聊天,为此你需要处理大量的发票。大家都不喜欢管理开支。为了解决这个问题,你决定编写一个程序来管理自己的开支。这个应用需要有能力完成下面这些任务:
- 从一个文件或者URL中读取开支列表;
- 解析出代表开支的字符串;
- 计算统计数据;
- 展示一个有价值的汇总信息;
- 提供一个总控方法,统一协调这些任务的启动或者停止。
你需要定义各种类和接口来对应用中的概念进行建模。首先,你需要定义一个Reader接口以序列化的方式读取来自源头的数据。依据数据源的不同,你需要定义不同的实现,比如HttpReader或者FileReader。你还需要定义一个Parser接口,用于反序列化JSON对象,将它们转换为领域对象Expense,你的应用会对这些转换后的Expense对象进行相应的处理。最后,你还需要一个SummaryCalculator类负责数据的统计工作,它接受一个Expense对象的列表,返回一个SummaryStatistics对象。
至此,你已经有了一个项目需求,那么怎样利用Java模块系统对这些需求进行模块化呢?很明显,这个项目有几个关注点,你需要对它们分别进行处理:
- 从不同的数据源读取数据(
Reader、HttpReader、FileReader); - 从不同的格式中解析数据(
Parser、JSONParser、ExpenseJSONParser); - 表示领域对象(
Expense); - 计算并返回统计数据(
SummaryCalculator、SummaryStatistics); - 协调各个任务的处理(
ExpensesApplication)。
出于教学的目的,这里会采用细粒度的方法。你可以将这些关注点切分到不同的模块中,如下所示(后续会深入讨论模块命名方案):
expenses.readersexpenses.readers.httpexpenses.readers.fileexpenses.parsersexpenses.parsers.jsonexpenses.modelexpenses.statisticsexpenses.application
在这个简单的例子中,我们采用的模块分解粒度很细,主要目的在于介绍模块系统的各个部分。在实际操作中,对于这种简单的项目如果也采用这么细粒度的划分,会导致前期的成本过高,付出这么高的代价却只对项目少部分的内容进行了恰当的封装。随着项目的不断演进,更多的内部实现被加入进来,这时封装和划分的价值就变得越来越明显。你可以将前述的列表想象成一个由包组成的列表,它的长度取决于你的应用边界。模块对一系列的包进行组织。有可能应用的每个模块都包含一些依赖特定实现的包,你不希望将这些包泄露给其他的模块使用。譬如,在expenses.statistics模块中,针对不同的实验统计方法可能就采用了不同实现的包。稍后,你可以决定将这些包中的哪些发布给用户。
14.4.2 细粒度和粗粒度的模块化
当你开始模块化一个系统的时候,可以选择以怎样的粒度进行模块化。最细粒度的方法是让每个包都独立拥有一个模块(就像上一节介绍的那样);最粗粒度的方法是把所有的包都归属到一个单一模块中。前一节已经介绍过,第一种策略极大地增加了设计的开销,并且获得的收益有限;第二种策略则完全牺牲了模块化能带来的好处。最好的选择是根据实际需求将系统分解到各个模块中并定期进行评审,从而确保随着软件项目的不断演进,代码的模块化还能保持其效果,你可以很清晰地厘清其脉络并进行修改。
简而言之,模块化是对抗软件腐臭的利器。
14.4.3 Java模块系统基础
我们从一个基础的模块应用开始介绍,这个应用只有一个模块供main应用调用。项目的目录结构如下所示,每一层目录以递归的方式嵌套:
|─ expenses.application
|─ module-info.java|─ com|─ example|─ expenses|─ application|─ ExpensesApplication.java
大概你已经注意到了,项目结构中也包含了那个神秘的module-info.java文件。本章前面介绍过,这个文件是一个模块描述符,它必须位于模块源码文件目录结构的根目录,通过它你可以指定你的模块依赖以及希望导出哪些包给别的模块使用。对你的开支管理应用而言,module-info.java文件的顶层模块描述部分只有一个名字,其他都是空的,因为它既不依赖于其他的模块,也不需要导出它的功能给别的模块使用。14.5节会进一步学习模块更复杂的特性。本例中module-info.java的内容如下:
module expenses.application {}
如何运行一个模块化的应用呢?让我们查看几个命令来理解一下底层的机制。这部分的代码都是由你的集成开发环境和编译系统完成的,不过了解一下到底发生了什么还是非常有价值的。进入项目的模块源码目录后,你可以执行下面的命令:
javac module-info.javacom/example/expenses/application/ExpensesApplication.java -d targetjar cvfe expenses-application.jarcom.example.expenses.application.ExpensesApplication -C target
执行这些命令的输出就类似下面这样,其显示了哪些目录和类文件会被打包进入生成的JAR(expenses-application.jar)文件中:
added manifestadded module-info: module-info.classadding: com/(in = 0) (out= 0)(stored 0%)adding: com/example/(in = 0) (out= 0)(stored 0%)adding: com/example/expenses/(in = 0) (out= 0)(stored 0%)adding: com/example/expenses/application/(in = 0) (out= 0)(stored 0%)adding: com/example/expenses/application/ExpensesApplication.class(in = 456)(out= 306)(deflated 32%)
终于,你可以以模块应用的方式执行生成的JAR文件了:
java --module-path expenses-application.jar \--module expenses/com.example.expenses.application.ExpensesApplication
刚才的这个过程,前两步你应该非常熟悉,它们是将Java应用打包到一个JAR文件中的标准方式。唯一不同的是,module-info.java文件成了编译过程的一部分。
现在Java程序执行Java的.class文件时,增加了两个新的选项。
--module-path——用于指定哪些模块可以加载。它与--classpath参数又不尽相同,--classpath仅是使类文件可以访问。--module——指定运行的主模块和类。
模块的声明不包含版本信息。解决版本选择问题并不是Java 9模块系统设计的出发点,所以它不支持版本。做这个决定的理由是这个问题应该由编译工具和应用容器来解决。
