MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java模块化系统(JPMS)的设计与使用

2023-01-125.8k 阅读

Java模块化系统(JPMS)概述

Java 9引入了Java模块化系统(Java Platform Module System,简称JPMS),这是Java平台自Java 5以来最重要的特性之一。JPMS旨在解决Java开发中的一些长期存在的问题,如类路径混乱、版本冲突以及模块间依赖关系管理等。

在JPMS出现之前,Java项目通常依赖于类路径(classpath)来管理项目中的所有类和资源。随着项目规模的增长,类路径变得越来越复杂,不同库之间的版本冲突问题也越来越难以解决。此外,开发者很难明确地定义模块之间的依赖关系,这使得大型项目的维护和演进变得困难。

JPMS通过引入模块(module)的概念,为Java开发带来了更好的封装性、可维护性和可扩展性。模块是一组相关的包和资源的集合,它可以明确地声明自己的依赖关系,并控制哪些包可以被其他模块访问。

模块的定义与声明

在Java中,模块通过一个名为module - info.java的文件来定义。这个文件位于模块的根目录下,它使用module关键字来声明一个模块。以下是一个简单的module - info.java文件示例:

module com.example.hello {
    // 声明模块依赖
    requires java.base;
    // 声明对外导出的包
    exports com.example.hello.api;
}

在上述示例中:

  • module com.example.hello声明了一个名为com.example.hello的模块。
  • requires java.base表示该模块依赖于Java标准库的java.base模块。java.base模块是所有Java模块的基础,包含了Java核心类库。
  • exports com.example.hello.api表示将com.example.hello.api包导出,使得其他模块可以访问该包中的类型。

模块声明的语法细节

  1. 模块名称:模块名称遵循Java标识符的命名规则,通常采用反向域名的形式,如com.example.module
  2. 依赖声明:使用requires关键字来声明模块的依赖。例如,requires com.example.anothermodule表示当前模块依赖于com.example.anothermodule模块。
  3. 导出包声明:通过exports关键字指定哪些包应该被导出供其他模块使用。一个模块可以导出多个包,如exports com.example.hello.api; exports com.example.hello.internal.util;

模块的依赖关系

模块之间的依赖关系是JPMS的核心特性之一。通过明确声明依赖关系,Java编译器和运行时系统可以更好地管理模块间的交互。

传递依赖

当一个模块A依赖于模块B,而模块B又依赖于模块C时,模块A间接依赖于模块C。这种依赖关系称为传递依赖。例如:

// module - info.java for com.example.moduleA
module com.example.moduleA {
    requires com.example.moduleB;
}

// module - info.java for com.example.moduleB
module com.example.moduleB {
    requires com.example.moduleC;
}

在上述例子中,com.example.moduleA通过com.example.moduleB间接依赖于com.example.moduleC。这意味着,在运行时,com.example.moduleC的相关类和资源必须可用,否则com.example.moduleA的运行可能会出现问题。

可选依赖

有时候,一个模块可能依赖于另一个模块,但这个依赖是可选的。在JPMS中,可以使用optional关键字来声明可选依赖。例如:

module com.example.moduleWithOptionalDependency {
    requires optional com.example.optionalmodule;
}

当一个模块声明了可选依赖时,在编译时,如果可选依赖的模块不存在,编译仍然可以成功。在运行时,如果可选依赖的模块不存在,Java运行时系统会发出警告,但不会导致应用程序崩溃。

模块的访问控制

JPMS提供了更细粒度的访问控制机制,通过模块声明中的exportsopens关键字来实现。

exports关键字

如前文所述,exports关键字用于将模块中的包导出,使得其他模块可以访问该包中的类型。只有被导出的包中的公有类型(public types)才能被其他模块访问。例如:

// 在com.example.hello模块的module - info.java中
exports com.example.hello.api;
// 位于com.example.hello.api包中的HelloService接口
package com.example.hello.api;

public interface HelloService {
    String sayHello();
}
// 位于另一个模块中的使用HelloService的类
module com.example.consumer {
    requires com.example.hello;
}

package com.example.consumer;

import com.example.hello.api.HelloService;

public class HelloConsumer {
    private final HelloService service;

    public HelloConsumer(HelloService service) {
        this.service = service;
    }

    public void greet() {
        System.out.println(service.sayHello());
    }
}

opens关键字

opens关键字用于在运行时开放包的反射访问,而不影响编译时的访问控制。这在某些需要使用反射来访问模块内部类型的场景中非常有用。例如:

module com.example.reflectionmodule {
    opens com.example.reflectionmodule.internal to com.example.reflectionconsumer;
}

在上述示例中,com.example.reflectionmodule模块使用opens关键字开放了com.example.reflectionmodule.internal包,并且指定只有com.example.reflectionconsumer模块可以通过反射访问该包中的类型。

模块的编译与运行

编译模块

在编译包含模块的Java项目时,需要使用javac命令,并指定模块路径(module - path)。模块路径用于指定模块的位置,类似于传统的类路径。例如:

javac -d out -p mods $(find src -name "*.java")

在上述命令中:

  • -d out指定编译输出目录为out
  • -p mods指定模块路径为mods目录,该目录包含项目中的所有模块。
  • $(find src -name "*.java")用于查找src目录下所有的Java源文件。

运行模块

运行包含模块的Java应用程序时,使用java命令,并同样指定模块路径。例如:

java -p mods -m com.example.hello/com.example.hello.Main

在上述命令中:

  • -p mods指定模块路径为mods目录。
  • -m com.example.hello/com.example.hello.Main表示运行com.example.hello模块中的com.example.hello.Main类。

模块化项目的结构

一个典型的模块化Java项目通常具有以下结构:

project/
├── mods/
│   ├── com.example.hello/
│   │   ├── module - info.class
│   │   └── com/
│   │       └── example/
│   │           └── hello/
│   │               ├── api/
│   │               │   └── HelloService.class
│   │               └── Main.class
├── src/
│   ├── com.example.hello/
│   │   ├── module - info.java
│   │   └── com/
│   │       └── example/
│   │           └── hello/
│   │               ├── api/
│   │               │   └── HelloService.java
│   │               └── Main.java
└── module - path/
    └── com.example.dependency.jar

在上述结构中:

  • mods目录用于存放编译后的模块,每个模块是一个独立的目录,包含module - info.class和相关的类文件。
  • src目录是项目的源代码目录,每个模块都有自己的子目录,包含module - info.java和Java源文件。
  • module - path目录用于存放项目依赖的其他模块,通常以JAR文件的形式存在。

使用JPMS的最佳实践

  1. 合理划分模块:根据项目的功能和职责,将相关的包和资源划分到不同的模块中。模块应该具有高内聚、低耦合的特点,使得模块之间的依赖关系清晰明了。
  2. 最小化导出包:只导出那些真正需要被其他模块访问的包,避免过度导出导致模块的封装性被破坏。
  3. 明确依赖关系:在模块声明中准确地声明依赖关系,这有助于在编译和运行时发现潜在的问题,并使得项目的依赖管理更加容易。
  4. 使用模块化的库:尽量使用已经模块化的第三方库,避免在模块化项目中引入传统的非模块化JAR包。如果必须使用非模块化JAR包,可以将其封装成一个模块。

处理模块升级与版本管理

随着项目的发展,模块可能需要升级到新的版本。在JPMS中,处理模块升级和版本管理需要注意以下几点:

模块版本声明

在JPMS中,可以在module - info.java文件中使用@since注解来声明模块的版本信息。例如:

@since 1.0
module com.example.hello {
    requires java.base;
    exports com.example.hello.api;
}

虽然@since注解主要用于文档说明,但它可以作为模块版本的一种标识。

处理版本冲突

当项目中存在多个模块依赖于同一个模块的不同版本时,可能会出现版本冲突。为了解决这个问题,可以采取以下方法:

  1. 统一版本:尽量将所有模块对同一个依赖模块的版本统一到一个兼容的版本。这可以通过项目的构建工具(如Maven或Gradle)来管理。
  2. 使用模块隔离:在某些情况下,可以使用模块隔离技术,使得不同版本的模块可以在同一个应用程序中共存。例如,使用Java的多模块加载器(Multi - Module ClassLoader)来加载不同版本的模块。

与传统Java项目的集成

在实际开发中,可能需要将模块化项目与传统的非模块化Java项目集成。JPMS提供了一些机制来支持这种集成。

自动模块

对于传统的JAR文件,如果没有module - info.java文件,JPMS会将其视为自动模块(automatic module)。自动模块会自动依赖于所有其他模块,并且会导出其所有包。例如,如果有一个传统的legacy.jar文件,可以将其放置在模块路径中,它会被当作一个自动模块。

无名模块

在Java运行时,仍然存在一个无名模块(unnamed module),它包含了所有通过类路径(-cp选项)加载的类和资源。无名模块可以访问所有的自动模块和系统模块,但其他模块无法访问无名模块中的类型。

在将模块化项目与传统项目集成时,可以将传统项目的类和资源放置在无名模块中,通过适当的配置来实现与模块化部分的交互。例如,可以通过设置类路径和模块路径,使得无名模块中的类可以调用模块化项目中的导出包。

高级模块特性

  1. 服务加载机制:JPMS增强了Java的服务加载机制(ServiceLoader)。通过模块,可以更方便地定义和使用服务提供者接口(SPI)。例如,一个模块可以定义一个SPI接口,并在module - info.java中使用provides关键字来指定该接口的实现类。其他模块可以通过ServiceLoader来加载这些实现类。
// 在com.example.spi模块的module - info.java中
module com.example.spi {
    exports com.example.spi.api;
    provides com.example.spi.api.ServiceInterface with com.example.spi.impl.ServiceImplementation;
}
// 在另一个模块中使用ServiceLoader加载服务实现
module com.example.consumer {
    requires com.example.spi;
}

package com.example.consumer;

import com.example.spi.api.ServiceInterface;

import java.util.ServiceLoader;

public class ServiceConsumer {
    public void consumeService() {
        ServiceLoader<ServiceInterface> serviceLoader = ServiceLoader.load(ServiceInterface.class);
        serviceLoader.forEach(ServiceInterface::performAction);
    }
}
  1. 模块描述符:除了module - info.java文件外,JPMS还支持使用模块描述符(module descriptor)文件,即module - info.class的二进制形式。模块描述符包含了模块的所有元数据,包括依赖关系、导出包等信息。在一些场景下,如模块的分发和部署,可以直接使用模块描述符而不是源代码形式的module - info.java

  2. 模块层:Java运行时系统使用模块层(module layer)来管理模块的加载和解析。模块层可以包含多个模块,并且不同的模块层可以具有不同的加载策略。例如,可以创建一个自定义的模块层来加载特定的一组模块,这在实现插件系统或容器化应用程序时非常有用。

示例项目:一个简单的模块化应用

下面通过一个简单的示例项目来演示JPMS的实际应用。假设我们要开发一个简单的命令行计算器应用,它由两个模块组成:一个是提供计算功能的com.example.calculator模块,另一个是负责用户交互的com.example.calculator.ui模块。

com.example.calculator模块

  1. module - info.java
module com.example.calculator {
    exports com.example.calculator.api;
}
  1. com.example.calculator.api.Calculator接口
package com.example.calculator.api;

public interface Calculator {
    double add(double a, double b);
    double subtract(double a, double b);
    double multiply(double a, double b);
    double divide(double a, double b);
}
  1. com.example.calculator.impl.DefaultCalculator实现类
package com.example.calculator.impl;

import com.example.calculator.api.Calculator;

public class DefaultCalculator implements Calculator {
    @Override
    public double add(double a, double b) {
        return a + b;
    }

    @Override
    public double subtract(double a, double b) {
        return a - b;
    }

    @Override
    public double multiply(double a, double b) {
        return a * b;
    }

    @Override
    public double divide(double a, double b) {
        if (b == 0) {
            throw new IllegalArgumentException("Cannot divide by zero");
        }
        return a / b;
    }
}

com.example.calculator.ui模块

  1. module - info.java
module com.example.calculator.ui {
    requires com.example.calculator;
    requires java.base;
    exports com.example.calculator.ui;
}
  1. com.example.calculator.ui.CalculatorApp
package com.example.calculator.ui;

import com.example.calculator.api.Calculator;
import com.example.calculator.impl.DefaultCalculator;

import java.util.Scanner;

public class CalculatorApp {
    public static void main(String[] args) {
        Calculator calculator = new DefaultCalculator();
        Scanner scanner = new Scanner(System.in);

        System.out.println("Enter first number: ");
        double num1 = scanner.nextDouble();
        System.out.println("Enter second number: ");
        double num2 = scanner.nextDouble();

        System.out.println("Addition: " + calculator.add(num1, num2));
        System.out.println("Subtraction: " + calculator.subtract(num1, num2));
        System.out.println("Multiplication: " + calculator.multiply(num1, num2));
        try {
            System.out.println("Division: " + calculator.divide(num1, num2));
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }

        scanner.close();
    }
}

编译与运行

  1. 编译
mkdir -p out/com.example.calculator
mkdir -p out/com.example.calculator.ui
javac -d out/com.example.calculator src/com.example.calculator/module - info.java src/com.example.calculator/com/example/calculator/api/Calculator.java src/com.example.calculator/com/example/calculator/impl/DefaultCalculator.java
javac -d out/com.example.calculator.ui -p out -cp src/com.example.calculator.ui src/com.example.calculator.ui/module - info.java src/com.example.calculator.ui/com/example/calculator/ui/CalculatorApp.java
  1. 运行
java -p out -m com.example.calculator.ui/com.example.calculator.ui.CalculatorApp

通过这个示例,可以看到如何使用JPMS来构建一个简单的模块化应用,通过明确的模块依赖和包导出,使得代码结构更加清晰,项目的可维护性和扩展性得到提升。