Java 模块化的设计目标是提供一种更好的项目组织和管理方式,解决依赖管理、可重用性、可扩展性和安全性等方面的问题,使得开发者能够更有效地构建和维护大型和复杂的 Java 应用程序。
其实 Java 官方发布这个特性至今为止很少有人用到 Java 原生的模块系统,取而代之的是各种包管理工具提供的类似的特性。例如 Maven、Ant 或者 Gradle 这些包管理器都提供了类似的概念,而且到 Java 17 为止这个特性确实没有很出色的表现(至少作者没有看到)。但既然作为新特性,我们仍然有学习的必要。因为很有可能在未来的某个时间点,这个原生特性将会被普及。
给 Java 这门语言添加这一重要特性需要考虑向下兼容问题,所以一般项目如果不显示开启这个特性的话,还是同之前的包开发相同,并不会涉及模块相关特点。在全部项目都转向这个特性之前,需要一些方式来过渡,这一点 Java 开发者也考虑到了, 自动模块和不具名模块 这一小节简单介绍了相关内容。这些方式用来将模块项目同非模块项目结合,直到模块特性成为 Java 业界的标准,这将是一项重大社会工程!
下表是本章节所涉及到有关 Java 模块的所有关键字(共 10 个),接下来的内容也将围绕这些关键字展开:
关键字 | 功能 |
---|---|
module | 声明一个Java模块 |
requires | 声明一个模块依赖关系 |
transitive | 传递性依赖 |
exports | 导出模块中的包 |
opens | 开放模块中的包,允许反射访问 |
open | 开放模块以及所有包,允许反射访问 |
to | 限制模块的可见性范围 |
provides | 提供服务实现 |
with | 定义服务接口的实现 |
uses | 使用服务接口 |
初识 Java 模块化
模块是包的集合。模块名不能重复,一般模块名称要按照提供的顶级包来命名。例如,SLF4J 日志记录外观有一个 org.slf4j 模块,其中包含的包为 org.slf4j、org.slf4j.spi、org.slf4j.event 和 org.slf4j.helpers。那么模块名称就最好使用顶级包命名,只要保证你使用的模块之间不重复即可,同包名不重复的解决方案相同,模块名称也最好使用域名倒置。
简单模块化例子
如果想要使用模块,就要在当前项目源文件根路径下建立一个名为 module-info.java
的 Java 文件。这个文件名和需要放在项目源文件根路径下都是不可变的,因为这些都是 Java 模块的“铁则”。
假如我们有如下一个要模块化的代码例子(结构如下):
/
└─szyilin
└─module-info.java
└─cn
└─szyilin
└─hello
└─HelloWorld.java
package cn.szyilin.hello;
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello!");
}
}
module-info.java
文件内容如下:
// cn.szyilin 就是模块的名称
module cn.szyilin { ... }
要使用模块这个神奇的功能最关键的就是在使用编译命令的时候不要指定类路径了,要使用模块相关的参数开启模块编译模式。这里不明白不要紧接着往后看:
java --module-path cn.szyilin --module cn.szyilin/cn.szyilin.hello.HelloWorld
# 指令简写
java -p cn.szyilin -m cn.szyilin/cn.szyilin.hello.HelloWorld
module
参数后要按照 模块/类名
形式指定主类
导入和导出模块
看到这里大家可能还是不明白模块到底是什么东西,我们先回忆非模块的开发模式。我们如果需要一个 Java API 只需要使用关键字 import
关键字导入指定包就能使用了。但加入了模块化之后这些模式都被改变了,我们不能随便引入别人开发包中的内容了,需要使用 requires
关键字导入指定模块才能使用其模块下的包内容,而且被导入的包也不是随便导入的,被导入的包需要在它的 module-info.java
文件中使用关键字 exports
导出对应包,这样外界才能通过 requires
关键字访问其内容。
看到这里可能有聪明的同学就会疑问了,那如果我之前没有使用模块开发的项目可以使用的 Java API,如果他没有对外使用
exports
导出,那我岂不是不能使用了?是的,但是也从来没有人保证过非公有的 API 一直保持可用,因此不必对这个感到震惊。
例如,我们有 A 和 B 两个项目,项目路径分别如下:
// A 项目结构
/
└─szyilinA
└─module-info.java
└─cn
└─szyilin
└─A
└─PrintHello.java
// PrintHello 内容
package cn.szyilin.A;
public class PrintHello {
public void printHello() {
System.out.println("Hello!");
}
}
// B 项目结构
/
└─szyilinB
└─module-info.java
└─cn
└─szyilin
└─B
└─TestHello.java
// TestHello 内容
package cn.szyilin.B;
import cn.szyilin.A
public class TestHello {
public static void main(String[] args) {
new PrintHello().printHello();
}
}
上面是两个很明显的 模块 项目,如果想要使得 TestHello
顺利运行需要两个项目的 module-info.java
文件写入如下内容:
// A 项目
module cn.szyilin.A {
exports cn.szyilin.A;
}
// B 项目
module cn.szyilin.B {
requires cn.szyilin.A;
}
注意:
requires
后面是模块名,只要这个模块内导出的包全都可以被本模块使用。exports
后面的是包名。
传递依赖
使用 requires
关键字,并不会传递依赖。例如上面的例子在添加一个项目 C,如果 B 使用了 A 项目,而 C 项目又引入了 B 项目,那么 C 能直接使用 A 项目中的内容吗,答案是不能。但是可以通过在 requires
关键字后添加 关键字 transitive
组成 requires transitive
后面跟导入内容,那么这个后面的模块就能被传递依赖了。
这个特性最有吸引力的用法就是 聚集模块。就是定义一个模块,这个模块本身没有任何包,只有传递性需求的模块。java.se 模块就是这样的模块,他被声明称下面这样子:
module java.se {
requires transitive java.compiler;
requires transitive java.datatransfer;
...
requires transitive java.desktop;
requires transitive java.sql;
}
模块的反射式访问
了解反射机制的同学应该知道,可以通过反射机制很好的访问任何访问级别的内容,那就是使用 setAccessible
方法。我们如果像上面那样导入一个模块后也能很好的使用反射机制吗?答案是不能!如果使用 exports
关键字导出的包是不能让别的包在运行时访问其内容的(也就是使用反射机制)。取而代之的需要使用 opens
关键字来导出所需要的内容,这样导出的包内容才能被其他引入的模块通过反射访问。
module cn.szyilin {
opens cn.szyilin;
}
如果想要当前模块所有包内容都可以被外界反射访问还有一种简单的方法,那就是使用 open
关键字:
open module cn.szyilin {
...
}
这样属于这个模块的所有包内容都可以被外界反射访问
限定导出和开放
使用 exports
和 opens
这两个导出关键字的时候可以指定只能被那些模块所使用,那就是使用 to
关键字:
module szyilin {
exports cn.szyilin.A to com.test.A, com.test.B;
opens cn.szyilin.B to com.test.A, com.test.B;
}
上面代表只能被导出的包内容只能被 to
后列表的模块使用
服务加载
Java 模块服务加载机制是 Java 模块化系统的一部分,它提供了一种在模块之间共享服务实现的方式。通过这种机制,模块可以声明自己提供的服务接口,并在需要的时候使用其他模块提供的服务实现。这种模块之间的松耦合方式使得应用程序的开发和维护更加灵活和可扩展。
下面是一个简单的示例代码,演示了 Java 模块服务加载机制的基本使用:
// 定义服务接口
public interface GreetingService {
void greet();
}
// 定义服务实现模块 A
module moduleA {
provides GreetingService with GreetingServiceImpl;
}
public class GreetingServiceImpl implements GreetingService {
@Override
public void greet() {
System.out.println("Hello from moduleA!");
}
}
// 定义服务使用模块 B
module moduleB {
requires moduleA;
uses GreetingService;
}
public class Main {
public static void main(String[] args) {
ServiceLoader<GreetingService> serviceLoader = ServiceLoader.load(GreetingService.class);
for (GreetingService service : serviceLoader) {
service.greet();
}
}
}
在上面的示例中,模块 A 定义了一个提供 GreetingService 接口实现的服务类GreetingServiceImpl,并通过 provides
关键字声明该服务的提供者。模块 B 则使用 requires
关键字引入了模块 A,并通过 uses
关键字声明了对 GreetingService 服务的使用。
在 Main 类中,通过 ServiceLoader
类加载 GreetingService 服务的实现,并遍历调用其中的 greet()
方法。这样,模块 B 就可以使用模块 A 提供的服务了。
通过 Java 模块服务加载机制,不同模块之间可以通过接口和实现的方式进行解耦,使得模块之间的依赖更加清晰和灵活。这样的机制可以提高代码的可维护性和扩展性,使得开发人员能够更加方便地开发和集成各种功能模块。
自动模块和不具名模块
在Java模块化系统中,除了显式定义模块的方式,还存在着自动模块和不具名模块这两个特殊概念,用于处理一些特殊的模块情况。
-
自动模块(Automatic Module):自动模块是指没有显式定义模块描述文件(module-info.java)的普通JAR文件。当将一个普通JAR文件添加到模块路径(module path)中时,Java模块系统会将其视为一个自动模块。自动模块会自动根据其包结构生成模块名,并自动导出该模块的所有包。自动模块可以访问模块路径上的所有命名模块,但无法访问具名模块中未导出的包。
-
不具名模块(Unnamed Module):不具名模块是指没有模块描述文件(module-info.java)的类路径(classpath)上的类集合。当使用传统的类路径方式运行Java应用程序时,所有的类都被视为属于不具名模块。不具名模块具有一些特殊的行为,它可以访问模块路径上的所有命名模块,但无法访问具名模块中未导出的包。
自动模块和不具名模块是为了兼容现有的非模块化的Java库和应用程序而引入的概念。它们使得我们可以在现有的类路径和模块路径混合的环境中使用模块化系统。通过自动模块和不具名模块,我们可以逐步将现有的代码库迁移到模块化系统中,而无需立即对所有的库和应用进行模块化改造。
需要注意的是,自动模块和不具名模块是一种临时的解决方案,应该尽可能将所有的库和应用程序都改造为显式的具名模块,以便更好地利用Java模块化系统的优势和特性。