前兩年重構一個遺留項目時,遇到了典型的"類路徑地獄"——項目依賴混亂,明明在類路徑裏的類卻報ClassNotFoundException,排查了半天才發現是不同版本的依賴衝突。後來用Java 9的模塊化(JPMS)重構後,模塊間依賴清晰可見,編譯期就能發現依賴問題,維護起來輕鬆多了。

Java 9引入的JPMS(Java Platform Module System)不僅解決了依賴混亂問題,還通過模塊邊界控制實現了更好的封裝。但很多開發者覺得模塊化複雜,尤其是模塊間通信和服務加載的機制。本文通過實際案例,講解如何定義模塊、實現模塊間通信,以及利用服務加載機制實現模塊解耦。

一、模塊化基礎:模塊定義與依賴

模塊化的核心是module-info.java文件,它定義了模塊的名稱、依賴和導出的包。我們先創建三個基礎模塊,演示最基本的模塊通信。

1. 創建基礎模塊

假設我們有三個模塊:

  • com.example.api:提供接口定義(API模塊)
  • com.example.service:實現API的服務(服務模塊)
  • com.example.app:使用服務的應用(應用模塊)

步驟1:定義API模塊(com.example.api)

src/main/java下創建module-info.java

// module-info.java
module com.example.api {
    // 導出包含接口的包,允許其他模塊訪問
    exports com.example.api;
}

創建接口類:

// com/example/api/UserService.java
package com.example.api;

public interface UserService {
    String getUsername(Long id);
}

步驟2:定義服務模塊(com.example.service)

服務模塊依賴API模塊,並實現接口:

// module-info.java
module com.example.service {
    // 聲明依賴API模塊
    requires com.example.api;
    // 導出服務實現包(如果需要被直接引用)
    exports com.example.service.impl;
}

實現接口:

// com/example/service/impl/UserServiceImpl.java
package com.example.service.impl;

import com.example.api.UserService;

public class UserServiceImpl implements UserService {
    @Override
    public String getUsername(Long id) {
        return "user_" + id;
    }
}

步驟3:定義應用模塊(com.example.app)

應用模塊依賴服務模塊,使用其提供的實現:

// module-info.java
module com.example.app {
    requires com.example.service;
}

應用主類:

// com/example/app/Main.java
package com.example.app;

import com.example.service.impl.UserServiceImpl;
import com.example.api.UserService;

public class Main {
    public static void main(String[] args) {
        UserService service = new UserServiceImpl();
        System.out.println(service.getUsername(100L)); // 輸出 user_100
    }
}

這種通過requires聲明依賴、exports導出包的方式,是模塊間通信的基礎。但它有個問題:應用模塊直接依賴了服務實現,耦合度高。如果要替換服務實現,必須修改應用代碼。

二、服務加載機制:實現模塊解耦

JPMS的服務加載機制(Service Loader)可以解決模塊耦合問題:應用模塊只依賴API,不依賴具體實現,通過服務接口動態加載實現類。

1. 改進模塊定義

步驟1:修改API模塊

API模塊只需要定義服務接口,無需修改:

// module-info.java(保持不變)
module com.example.api {
    exports com.example.api;
}

步驟2:修改服務模塊

服務模塊不再導出實現類,而是通過provides...with聲明服務實現:

// module-info.java
module com.example.service {
    requires com.example.api;
    // 聲明提供UserService接口的實現(UserServiceImpl)
    provides com.example.api.UserService with com.example.service.impl.UserServiceImpl;
}

實現類可以保持包私有(不被導出),增強封裝性:

// 包名可以不變,但無需被導出
package com.example.service.impl;

import com.example.api.UserService;

// 可以是包私有(不加public),僅模塊內可見
class UserServiceImpl implements UserService {
    @Override
    public String getUsername(Long id) {
        return "user_" + id;
    }
}

步驟3:修改應用模塊

應用模塊通過uses聲明使用的服務接口,無需依賴具體實現模塊:

// module-info.java
module com.example.app {
    requires com.example.api;
    // 聲明使用UserService服務
    uses com.example.api.UserService;
}

應用通過ServiceLoader動態加載服務實現:

// com/example/app/Main.java
package com.example.app;

import com.example.api.UserService;
import java.util.ServiceLoader;

public class Main {
    public static void main(String[] args) {
        // 加載所有實現UserService的服務
        ServiceLoader<UserService> services = ServiceLoader.load(UserService.class);
        // 獲取第一個可用的服務實現
        UserService service = services.findFirst().orElseThrow();
        System.out.println(service.getUsername(100L)); // 輸出 user_100
    }
}

2. 服務加載的工作原理

  1. 服務模塊通過provides聲明"我提供某個接口的實現";
  2. 應用模塊通過uses聲明"我需要使用某個接口的服務";
  3. JVM在運行時自動查找所有提供該服務的模塊,通過ServiceLoader加載實現類。

這種方式下,應用模塊和服務模塊完全通過API接口交互,實現瞭解耦。如果要替換服務實現,只需新增一個提供相同接口的服務模塊,無需修改應用代碼。

三、實戰進階:多服務實現與動態切換

實際開發中可能有多個服務實現(如MySQL實現、Oracle實現),服務加載機制支持動態選擇。

1. 新增服務實現模塊

創建com.example.service.mysql模塊,提供另一種實現:

// module-info.java
module com.example.service.mysql {
    requires com.example.api;
    provides com.example.api.UserService with com.example.service.mysql.MySQLUserService;
}

// 實現類
package com.example.service.mysql;

import com.example.api.UserService;

class MySQLUserService implements UserService {
    @Override
    public String getUsername(Long id) {
        return "mysql_user_" + id;
    }
}

2. 應用模塊動態選擇服務

應用可以遍歷所有服務實現,根據條件選擇:

public class Main {
    public static void main(String[] args) {
        ServiceLoader<UserService> services = ServiceLoader.load(UserService.class);
        
        // 遍歷所有服務實現
        for (UserService service : services) {
            String username = service.getUsername(100L);
            System.out.println("服務實現:" + service.getClass().getName() + ",結果:" + username);
        }
        
        // 根據實現類選擇
        UserService mysqlService = services.stream()
                .map(ServiceLoader.Provider::get)
                .filter(s -> s.getClass().getName().contains("mysql"))
                .findFirst()
                .orElseThrow();
    }
}

運行時如果同時包含com.example.servicecom.example.service.mysql模塊,會輸出兩種實現的結果。這種動態發現機制非常適合插件化開發。

四、模塊邊界控制:exports與opens的區別

模塊間通信時,需要明確控制哪些類可以被訪問,exportsopens是兩種常用的訪問控制方式:

  • exports com.example.api:允許其他模塊在編譯期和運行時訪問該包的public類和成員;
  • opens com.example.internal:只允許其他模塊在運行時通過反射訪問該包的類(如JSON序列化需要反射私有字段時使用)。

例如,服務實現中的內部包需要被JSON庫反射時:

// com.example.service的module-info.java
module com.example.service {
    requires com.example.api;
    requires com.fasterxml.jackson.databind; // 依賴Jackson
    
    provides com.example.api.UserService with com.example.service.impl.UserServiceImpl;
    
    // 允許Jackson在運行時反射訪問impl包
    opens com.example.service.impl to com.fasterxml.jackson.databind;
}

五、模塊化常見問題與解決方案

  1. 找不到模塊依賴:確保module-info.java中正確聲明requires,且模塊路徑(module-path)包含依賴的模塊JAR。
  2. 類可見性問題:如果編譯時報package is not visible,檢查是否忘記exports對應的包,或依賴模塊是否正確requires當前模塊。
  3. 服務加載失敗:確保服務模塊用provides...with聲明瞭實現,應用模塊用uses聲明瞭服務,且兩個模塊都在模塊路徑中。
  4. 反射訪問被拒絕:如果框架需要反射訪問模塊內的非導出類,用opens聲明包,並指定允許訪問的模塊(如opens com.example.internal to spring.core)。

六、模塊化的適用場景

  • 大型項目:通過模塊邊界控制依賴,減少類路徑衝突;
  • 框架開發:用服務加載機制實現插件化(如日誌框架適配不同實現);
  • 需要嚴格封裝的場景:隱藏內部實現,只暴露API接口。

小型項目如果依賴簡單,可能不需要模塊化,避免增加複雜度。

總結

Java模塊化通過module-info.java定義模塊依賴和邊界,解決了傳統類路徑的混亂問題。服務加載機制(provides/uses+ServiceLoader)則實現了模塊間的解耦,讓應用可以動態發現和使用服務實現,非常適合插件化和可擴展的系統。

實際開發中,不必一開始就將所有代碼模塊化,可以從核心模塊逐步遷移。關鍵是理解模塊邊界的控制(exports/opens)和服務加載的原理,這樣才能充分發揮JPMS的優勢,構建更清晰、更易維護的系統。