博客 / 詳情

返回

從頭學Java17-Modules模塊

模塊Modules

瞭解module系統如何塑造 JDK,如何使用,使項目更易於維護。

燒哥注

從頭講JDK17的文章比較少,英文為主,老外雖能講清原理,但寫的比較繞,所以決定翻譯一下,也有個別細節完善。

原文關注點主要在java生態,以及類庫的維護者如何過渡到module,對新用户也同樣適用。

logo.jpg

module簡介

瞭解module系統基礎知識,如何創建和構建module,如何提高可維護性和封裝性。

Java API 的作用範圍分為methods、classes、packages和modules(最高)。 module包含許多基本信息:

  • 名字
  • 對其他module的依賴關係
  • 開放的API(其他都是module內部的,無法訪問)
  • 使用和提供的service

不僅 Java API ,也可以是你自己項目,提供這些信息都能生成module。 項目部署為module,可以提高可靠性和可維護性,防止內部 API的意外使用,並且可以更輕鬆地創建運行映像,裏面僅包含必需的JDK 代碼。甚至可以把應用程序打包為獨立映像。

在討論這些優點之前,我們將探討如何定義module及其屬性,如何將其轉換為可交付 JAR,以及module系統如何處理它們。 為了簡化討論,我們將假設所有內容(JDK 中的代碼、庫、框架、應用程序)都是一個module。

module聲明

module聲明是每個module的核心,一個module-info.java,定義module所有屬性。 下面是java.sql的module聲明,定義了JDBC API:

module java.sql {
    requires transitive java.logging;
    requires transitive java.transaction.xa;
    requires transitive java.xml;

    exports java.sql;
    exports javax.sql;

    uses java.sql.Driver;
}

包含了module的名稱(java.sql),對其他module(java.loggingjava.transaction.xajava.xml)的依賴關係,開放的API包(java.sqljavax.sql),以及它使用的service(java.sql.Driver)。 這裏還有個transitive,可以先跳過。 一般來説,module聲明具有以下基本形式:

module $NAME {
    // for each dependency:
    requires $MODULE;

    // for each API package:
    exports $PACKAGE

    // for each package intended for reflection:
    opens $PACKAGE;

    // for each used service:
    uses $TYPE;

    // for each provided service:
    provides $TYPE with $CLASS;
}

可以為自己項目創建module聲明,module-info.java通常放在源碼根目錄,比如src/main/java。 對於庫,lib module聲明可能如下:

module com.example.lib {
    requires java.sql;
    requires com.sample.other;

    exports com.example.lib;
    exports com.example.lib.db;

    uses com.example.lib.Service;
}

對於應用程序,app module可能是這樣:

module com.example.app {
    requires com.example.lib;

    opens com.example.app.entities;

    provides com.example.lib.Service
        with com.example.app.MyService;
}

讓我們快速瀏覽一下細節。 本節重點為module聲明的內容:

  • module名稱
  • 依賴
  • 導出的包
  • 使用和提供的service

module名稱

module名稱的要求和準則跟包一樣:

  • 合法字符包括 A-Z, a-z, 0-9, _,和 $,用 .分隔
  • 按照慣例,都用小寫,$一般不用
  • 應全局唯一

一個 JAR的module信息一般項目文檔裏都會描述,也可以查看module-info.class,使用jar --describe-module --file $FILE

關於module的名稱唯一,建議類似包名,可以採用翻轉域名。

依賴關係

requires指令。 看看上面三個module中的那些:

// from java.sql, but without `transitive`
requires java.logging;
requires java.transaction.xa;
requires java.xml;

// from com.example.lib
requires java.sql;
requires com.sample.other;

// from com.example.app
requires com.example.lib;

我們可以看到,app module com.example.app 依賴於lib module com.example.lib,而lib module接着依賴了不相關的 com.sample.other 和平台module java.sql。 雖然不懂com.sample.other,但我們知道java.sql依賴於java.loggingjava.transaction.xajava.xml。 繼續的話並沒有其他依賴。 (確切地説,沒有顯式依賴)

後面章節可以瞭解到可選依賴和隱式依賴。

外部依賴列表跟構建配置(如Maven)列出的依賴項非常相似,不過並不多餘,因為module名稱不包含Maven獲取 JAR 所需的版本或任何其他信息(如group ID 和artifact ID),而Maven列出了這些信息,卻不需要module的名稱。屬於兩種不同角度。

導出和open Packages

默認,所有類型(甚至是public)只能在module內部訪問。 外部要想訪問,需要exportsopens包含該類型的包。 要點是:

  • exports包的public類型和成員在編譯和運行時可用
  • opens包的所有類型和成員都可以在運行時通過反射訪問

以下是三個示例module中的指令:

// from module java.sql
exports java.sql;
exports javax.sql;

// from com.example.lib
exports com.example.lib;
exports com.example.lib.db;

// from com.example.app
opens com.example.app.entities;

這表明java.sql導出了同名的包,以及javax.sql - 該module當然包含更多的包,但它們不是其API的一部分,與我們無關。 lib module導出兩個包供其他module使用 - 同樣,所有其他(潛在)包都被安全地鎖定。 app module不導出任何包,這種情況並不少見,因為啓動應用程序的module很少是其他module的依賴項,沒有人調用它。 不過,com.example.app.entities確實可以進行反射 - 從名稱來看可能是因為它包含其他module通過反射與之交互的實體(想想 JPA)。

根據經驗,exports的包要儘可能少 - 就像保持字段private,需要時才讓方法package可見或public可見。讓類默認package可見,在另一個包需要時才public可見。 這減少了隨處可見的代碼量,降低了複雜性。

使用和提供Services

可以將service API 的使用者與其實現分離,從而在啓動應用程序之前更容易的替換它。 如果module使用某種類型(接口或類)作為service,需要uses指令,後跟完全限定的類型名稱。 提供service的module也要表名自己的類型(通常通過實現或擴展)。

lib和app示例module顯示了兩個方面:

// in com.example.lib
uses com.example.lib.Service;

// in module com.example.app
provides com.example.lib.Service
    with com.example.app.MyService;

lib module使用Service ,它自己的類型之一,作為service,而具體實現由app module使用MyService提供(依賴於 lib module) 。在運行時,lib module將使用ServiceLoader的 API ServiceLoader.load(Service.class)來訪問所有實現/擴展類。 這意味着lib module的具體執行,是在app module中定義,但並不依賴它 - 這有利於解除依賴關係,使module更加專注自己的業務。

構建和啓動modules

module聲明module-info.java也是一個源文件,因此在 JVM 中運行之前需要幾個步驟。 幸運的是,這些步驟與普通源代碼所執行的步驟完全相同,大多數構建工具和 IDE 都非常瞭解這些步驟,足以適應它的存在。 很可能,您無需任何手動操作即可構建和啓動模塊化項目。 當然,瞭解細節還是有價值的。

這裏,我們將站在更高的抽象層面,討論一些在構建和運行模塊化代碼中發揮重要作用的概念:

  • 模塊化 JARs
  • module path
  • module解析和module圖
  • base module

模塊化 JARs

module-info.java文件(又名module聲明)被編譯為module-info.class(稱為module描述符),放入 JAR 的根目錄。 包含module描述符的 JAR 稱為模塊化 JAR,可以用作module ,而普通的沒有描述符的 JARs則是純 JARs。 如果module JAR 放置在module path上(見下文),它在運行時會成為module,不過也可以在class path上,成為unamed module的一部分,就像class path上的普通 JAR 。

module path

module path是一個與class path平行的新概念: 包含製品(JAR 或字節碼文件夾)和製品目錄。 module系統在程序運行中找不到所需module時,用它來查找,通常是應用、庫和框架module。 它將module path上的所有制品,甚至是普通 JARs,轉換成自動module,實現漸進模塊化。 javacjava以及其他與module相關的命令都能理解和正確處理module path。

旁註:JAR是否模塊化並不能決定它是否被視為module! class path上的 JAR 都被視為unamed module,module path上的 JAR 都轉換為module。 這意味着項目的負責人可以決定哪些依賴項最終成為單獨的module(與依賴項的維護者相反)。

module解析和module圖

要啓動模塊化應用程序,使用java命令,並提供module path和所謂的初始module(包含main方法的module):

# modules are in `app-jars` | initial module is `com.example.app`
java --module-path app-jars --module com.example.app

這將啓動一個稱為module解析的過程: 從初始module開始,module系統將在module path中搜索。 如果找到,將檢查requires指令以查看它需要哪些module,然後重複該過程。 如果找不到module,拋出錯誤,讓你知道缺少依賴項。 您可以通過添加--show-module-resolution來觀察此過程。

此過程的產出是module圖。 節點是module,根據每個requires指令在兩個module之間生成一個可讀邊,表示向量方向。

想象一個普通的Java程序,例如Web應用程序的後端,我們可以畫出它的module圖: 在頂部,我們將找到初始module,再往下找到其他應用程序module以及它們使用的框架和庫。 然後是它們的依賴關係,可能是JDK module,最底部是java.base

base module

有一個module支配了這一切:java.base,即所謂的base module。 它包含像ClassClassLoader的類,像java.langjava.util的包,以及整個module系統。 沒有它,JVM上的程序將無法運行,因此它獲得了特殊狀態:

  • module系統特別瞭解它
  • module聲明中不需要requires java.base - 對base module的依賴是免費的

因此,前面討論的各種module的依賴關係,並不完全完整。 它們都隱式依賴於base module - 它們必須這樣做。 上一節説module系統從module解析開始時,也不是 100% 正確的。 首先發生的是,module系統解析了base module並自行引導。

module系統優勢

那麼,為項目創建module聲明那麼麻煩,能得到什麼呢? 以下是三個最突出的好處:

  • 強封裝
  • 可靠的配置
  • 可擴展的平台

強封裝

如果沒有module,每個public類或成員都可以自由地被其他類使用——無法控制某些內容只在 JAR 中可見而不越界。 甚至非public可見性也不是真正的威懾力,因為總能用反射來訪問私有 API。歸根於 JAR 本身並沒有界限,它們只是類加載器從中加載類的容器。

module是不同的,它們確實具有編譯和運行時能夠識別的界限。 只有以下情況才能使用module中的類型:

  • 該類型是public的(和以前一樣)
  • exports這個包
  • 調用的module 聲明瞭requires此module

這意味着module的創建者可以更好地控制哪些類型將構成public API。 不再是所有,現在是導出包中的所有public類型

這對於 JDK API 本身顯然至關重要,其開發人員不必再懇求我們不要使用類似 sun.*com.sun.* 的包。 JDK 也不必再依賴安全管理器的手動方法來防止訪問安全敏感的類型和方法,從而消除了一整大類潛在的安全隱患。定義好清晰的外部交互,並強制説明哪些API是public的和(大概的)穩定的,庫和框架也能從中受益。

應用程序項目可以確保不會意外使用依賴項中那些可能在新版本更改的內部 API。 較大的項目可以進一步受益於創建具有強界限的多個module。 這樣,實現功能的開發人員可以清楚地與同事溝通,哪些添加的代碼用於哪個部分,而哪些只是內部腳手架 - 不再有API的意外使用。

可靠的配置

在module解析期間,module系統會檢查是否存在所有必需的依賴項(直接依賴項和傳遞依賴項),並在缺少某些依賴項時報告錯誤。 但它不僅僅是檢查存在。

不能有歧義,即沒有兩個製品可以聲稱它們是同一個module。 在存在同一module的兩個版本時,這尤其有趣。 由於module系統沒有版本的概念(除了將它們記錄為字符串之外),因此它將其視為重複module。 因此,遇到這種情況它會報錯。

module之間不得有靜態依賴循環。 在運行時,module相互訪問是可能的,甚至是必要的(想想使用Spring註釋和Spring反射的代碼),但這些不能是編譯依賴項。

包應具有唯一的源,因此沒有兩個module可以包含同一包中的類型。 如果他們這樣做,這被稱為拆分包,module系統將拒絕編譯或啓動此類配置。

當然,這種驗證不是絕對嚴謹,問題可能會隱藏很久才使運行中的應用程序崩潰。 例如,如果錯誤版本的module的確有,則應用程序將啓動(所有必需的module都存在),但稍後會崩潰,例如,當缺少類或方法時。 不過,它確實會儘早檢測到許多常見問題,從而降低了啓動的應用程序由於依賴關係問題而在運行時失敗的可能性。

可擴展的平台

通過將 JDK 拆分為從 XML 處理到 JDBC API 的所有module,最終可以手工製作一個僅包含您需要的 JDK 功能的運行映像runtime image,並將其與您的應用程序一起發佈。 如果您的項目是完全模塊化的,則可以更進一步,將module打包到該映像中,使其成為一個獨立的應用程序映像application image,其中包含它所需的一切,從代碼到依賴項,再到 JDK API 和 JVM。用户不需要JDK也能運行。

用反射訪問open module和open packages

使用open packages和open module,以允許反射訪問封裝包。

module系統的強大封裝也作用於反射,反射已經失去了闖入內部 API 的“超能力”。 當然,反射是Java生態系統的重要組成部分,因此module系統具有支持反射的特定指令。 它允許open packages,這使它們在編譯時無法訪問,但允許在運行時進行深度反射,並open了整個module。

為什麼導出的包不能用於反射

使類型在module外部可訪問的主要機制是使用module聲明中的exports導出包含它們的包。 這不能用於反射,原因有兩個:

  1. 導出包會使其成為模塊public API 的一部分。 這邀請其他module使用它包含的類型,並表示一定程度的穩定性。 這通常不適合處理 HTTP 請求或與數據庫交互的類。
  2. 一個更技術性的問題是,即使在導出的包中,也只能訪問public類型和成員。 但是依賴於反射的框架通常會訪問非public類型、構造、訪問器或字段,這樣會失敗。

open packages(和module)專門設計用於解決這兩點。

open packages進行反射

opens指令添加到module聲明:

module com.example.app {
    opens com.example.entities;
}

在編譯時,包被完全封裝,就好像指令不存在一樣。 這意味着module 外部調用com.example.entities將無法編譯。

另一方面,在運行時,包的類型可用於反射,包括public或非public成員(像通常對非public成員一樣AccessibleObject.setAccessible()。)

正如您可能知道的那樣,opens是專門為反射設計的,其行為與exports不同:

  • 允許訪問所有成員,不會影響您有關可見性的決定
  • 防止針對open 的包中的代碼進行編譯,並且只允許在運行時訪問
  • 跟基於反射的框架進行溝通

如有必要,package可以同時open和export。

open module

如果你有一個大module,其中包含許多需要暴露在反射下的包,你可能會發現單獨open 每個包很煩人。 雖然沒有像opens com.example.* 這樣的通配符,但存在接近它的東西。 通過在module聲明中將open放在module前面,將創建一個open module

open module com.example.entities {
    // ...
}

要注意這裏是open,不是opens

open module會open 它包含的所有包,就好像每個包都在指令中單獨使用一樣。 因此,手動open 更多包是沒有意義的,再添加opens指令會導致編譯錯誤。

可選的依賴項requires static

用於可選依賴項 - 以這種方式requires的模塊在編譯時可訪問,但運行時可以不存在。

module系統的依賴關係默認為強依賴,如果被requires(可訪問),需要在編譯和運行時都存在。 但可選依賴不是, requires static在編譯時要求存在,但運行時容忍不存在來解決此問題。

可選依賴項requires static

當一個module需要另一個module的類型進行編譯,但不想在運行時依賴它時,它可以使用requires static。 如果module A requires static module B,則module系統在編譯和運行時的行為不同:

  • 在編譯時,B 必須存在(否則會出現錯誤),並且 B 可由 A 讀取。 (這是依賴項的常見行為。
  • 在運行時,B 可能不存在,這既不會導致錯誤也不會導致警告。 如果存在,則 A 可讀取。

    JDK中,沒有依賴是可選的,所以我們必須提出自己的依賴。

讓我們想象一個應用程序,它能很好地解決了其業務案例,但存在額外專有庫的情況下可以做得更好。 比如應用 com.example.app 和庫 com.sample.solver*。如果代碼這樣聲明:

module com.example.app {
    requires com.sample.solver;//wrong
}

但是正如前面所説,這意味着如果 com.sample.solver 不存在,module系統將在運行時拋出錯誤 - 顯然依賴項不是可選的。 讓我們改用:requires static

module com.example.app {
    requires static com.sample.solver;
}

對於 com.example.app 的編譯,com.sample.solver 是必需的,並且必須存在。 但運行時,可以忽略,這會導致我們接下來要回答的兩個問題:

  • 在什麼情況下會出現可選依賴項?
  • 我們如何針對可選依賴項進行編碼?

可選依賴項的解析

module解析是從root module開始,通過解析requires指令構建module圖的過程。 解析module時,必須在運行時或module path中找到它所需的所有module,如果是,則將它們添加到module圖中;否則會發生錯誤。 (請注意,在解析期間未進入module圖的module在以後的編譯或執行期間也不可用。 在編譯時,module解析處理可選依賴項,就像常規依賴項一樣。 但是,在運行時,它們大多被忽略。

當module系統遇到requires static指令時,它不會嘗試執行它,這意味着它甚至不會檢查是否可以找到引用的module。 因此,即使module存在於module path上(或就此而言在 JDK 中),也不會添加到module圖中。 只有用--add-modules顯式添加時,它才會進入圖。 這種情況下,module系統將根據可選依賴添加邊。

換句話説,除非它以其他方式進入module圖,否則將忽略可選依賴項,在這種情況下,生成的module圖與非可選依賴項相同。

針對可選依賴項進行編碼

針對可選依賴項編寫代碼時需要多考慮一下。 一般來説,噹噹前正在執行的代碼引用某個類型時,Java 運行時會檢查該類型是否已加載。 如果沒有,它會告訴類加載器這樣做,如果失敗,結果是NoClassDefFoundError ,這通常會使應用程序崩潰或至少在正在執行的邏輯塊中失敗。

這是著名的 JAR hell,module系統希望在啓動應用程序時檢查聲明的依賴項來克服這一點。 但是,requires static將退出該檢查,這意味着我們最終可能會得到一個NoClassDefFoundError

檢查module是否存在

為了避免這種情況,我們可以查詢module系統是否存在module:

public class ModuleUtils {

    public static boolean isModulePresent(Object caller, String moduleName) {
        return caller.getClass()
                .getModule()
                .getLayer()
                .findModule(moduleName)
                .isPresent();
    }

}

調用方需要將自身傳遞給方法。

已建立的依賴項

但是,可能並不總是需要顯式檢查module的存在。 想象一個庫com.example.lib,它可以幫助使用各種現有API,其中包括java.sql中的JDBC API。 然後,可以假定不使用 JDBC 的代碼自然也不使用庫的那個部分。 換句話説,我們可以假設庫的 JDBC 部分只給已經使用 JDBC 的代碼調用,這意味着 java.sql 肯定已經是module圖的一部分。

一般來説,如果使用了可選依賴項,而調用它的代碼也已經知道,則可以假定它的存在,不需要檢查。

隱式讀取requires transitive

用於表示隱式可讀,一個模塊的依賴項傳遞給了依賴它的另一個模塊,允許讀取而不需要顯式聲明。

module系統對訪問其他module中的代碼有嚴格規定,其中之一是訪問module必須能讀取被訪問的module。 建立讀取關係的最常見方法是讓一個modulerequires另一個module。 如果一個module使用來自另一個module的類型,那使用第一個module的每個module也只能被迫requires第二個module。那得多麻煩。 其實可以讓第一個module聲明requires transitive於第二個module,這意味着第二個module對於讀取第一個module的任何module都能讀取。 這有點困惑,但你會在幾分鐘內理解它。

隱式讀取

常見情況下,module的依賴項只在內部使用,外界對此無需感知。 以java.prefs為例,modulerequiresjava.xml: 它需要XML解析功能,但它自己的API既不接受也不返回java.xml包中的類型。

但還有另一種情況,依賴項並不完全是內部的,而是存在於module之間的界限上。 這種情況下,一個module依賴於另一個module,並在其自己的public API 中引用了另一個module中的類型。 一個很好的例子是java.sql。 它也使用java.xml但與java.prefs不同的是,不僅在內部 - public類java.sql.SQLXML映射了SQL XML類型,因此使用了來自java.xml的API。 類似地,java.sqlDriver有一個方法 getParentLogger()) 返回一個Logger ,這是來自 java.logging module的類型。

這種情況下,想要調用module的代碼(例如java.sql)可能必須使用依賴module(例如java.xmljava.logging)中的類型。 但是,如果它不能讀取依賴的module,則無法執行此操作。 這樣的話,為了使module完全可用,客户端也必須顯式依賴第二個module。 識別和手動解決此類隱藏的依賴項將是一項繁瑣且容易出錯的任務。

這就是隱式讀取的用武之地。 它擴展了module聲明,以便一個module可以向依賴於它的任何module授予它所依賴的module的讀取權限。 這種隱式的讀取是通過在 require 子句中添加transitive來表示的。

這就是為什麼java.sql的module聲明如下所示:

module java.sql {
    requires transitive java.logging;
    requires transitive java.transaction.xa;
    requires transitive java.xml;

    exports java.sql;
    exports javax.sql;

    uses java.sql.Driver;
}

這意味着任何讀取java.sql的module(requires)也將自動讀取java.loggingjava.transaction.xajava.xml

何時使用隱式讀取

module系統對何時使用隱式讀取有個明確建議:

通常,如果一個module導出的包,裏面有個類型的簽名引用了第二個module的包,則第一個module應該聲明requires transitive。 這將確保依賴第一個module的其他module能夠自動讀取第二個module。

但要一直這麼嵌套下去嗎? 回顧java.sql的例子,使用它的module是否必須requiresjava.logging? 從技術上講,不需要,而且似乎多餘。

要回答這個問題,我們必須看看那個模塊究竟如何使用java.logging。 它可能只需要讀取它,然後調用Driver.getParentLogger() ,例如更改logger的日誌級別,僅此而已。 在這種情況下,您與java.logging的交互發生在它與java.sqlDriver交互附近。 就是上面所説的兩個模塊之間的邊界。

另一種,您的模塊實際上可能會在代碼中使用日誌記錄。 然後,來自java.logging的類型出現在許多獨立於Driver的地方,不再侷限於您的模塊和java.sql的邊界。

建議:如果第一個module聲明requires transitive第二個module的包,但調用的module只在邊界使用第二個包的類型,那不需要做什麼。否則,即使不是嚴格需要,也應該顯式聲明依賴。 這樣能清晰闡明系統結構,併為未來的重構提供保證。

限定的exportsopens

將導出或打開的包的可訪問性限制為特定模塊。

module系統允許export/open packages,使其可供外部代碼訪問,每個讀取它的module都可以訪問這些包中的類型。 這意味着這個包,我們要麼強封裝,讓別人都不能,要麼讓所有人都能隨時訪問它。 為了處理第三種情況,module系統為exportsopens 提供了限定變體,僅授予特定module訪問。

限定的export/open packages

exports指令可以後跟to $MODULES限定,其中 $MODULES是目標module名稱的逗號分隔列表。 opens指令也是同樣。

JDK本身中有很多限定導出的例子,但我們來看java.xml,它定義了用於XML處理的Java API(JAXP)。 它的六個內部包,前綴為com.sun.org.apache.xml.internalcom.sun.org.apache.xpath.internal,只為java.xml.crypto(XML加密的API)使用,因此只導出給它:

module java.xml {
    // lots of regular exports

    exports com.sun.org.apache.xml.internal.dtm to
        java.xml.crypto;
    exports com.sun.org.apache.xml.internal.utils to
        java.xml.crypto;
    exports com.sun.org.apache.xpath.internal to
        java.xml.crypto;
    exports com.sun.org.apache.xpath.internal.compiler to
        java.xml.crypto;
    exports com.sun.org.apache.xpath.internal.functions to
        java.xml.crypto;
    exports com.sun.org.apache.xpath.internal.objects to
        java.xml.crypto;
    exports com.sun.org.apache.xpath.internal.res to
        java.xml.crypto;

    // lots of services usages
}

關於編譯的兩個小説明:

  • 如果聲明瞭限定export/open ,但編譯時找不到目標module,編譯器會警告,但不是錯誤,因為目標module並不是必需的。
  • 不允許一個包同時存在於在exportsexports to ,或者 opensopens to 中,會導致編譯報錯。

有兩個細節:

  • 目標module可以依賴於所屬module(實際上java.xml.crypto依賴於java.xml),從而創建一個循環。 考慮到這一點,只能使用隱式讀取,實際上也必須如此
  • 每當新module需要訪問限定導出的包時,都需要更改那個module,以便它提供對這個新module的訪問權限。 雖然讓那個module控制誰可以訪問是限定導出的意義所在,但可能很麻煩。

何時使用限定export

如前所述,限定導出的使用場景是控制哪些module可以訪問相關包。 多久適用一次? 一般來説,每當一組module想要在不public情況下共享功能時。

在 Java 9 之前, 某個工具類要想跨包可用,它必須是public的,這意味着所有其他代碼都可以訪問它。 現在強封裝能解決這個問題,允許在module外部無法訪問這個public類。

同樣,我們想要隱藏一個包(以前是一個類),但一旦它想跨module(包)可用,它就必須被導出(public),來被所有其他module(所有其他類)訪問。 限定export則開始發揮作用。 它們允許module之間共享包,而不會普遍可用。 對於由多個module組成的庫和框架這非常有用,能夠在外人無法使用的情況下共享代碼。 對於想要限制對特定 API 依賴關係的大型應用程序,它也將派上用場。

限定的導出可以看作是將強封裝從保護module中的類型提升到保護module集。

何時使用限定open

限定open 的目標module通常是框架,使用場景要小得多。

限定open 的一個缺點是,在規範和實現分開的情況下(例如,JPA 和 Hibernate),您可能必須open 實體包到實現而不是 API(例如,Hibernate module而不是 JPA module)。 某些項目的編碼規範不允許這樣。

如果項目代碼使用大量反射, 聲明限定open需要指明每個包,會有很大工作量,這種完全應該避免。

module與service解耦

用 ServiceLoader API將服務的使用者和提供者分離,在聲明中使用 usesprovides

在Java中,通常將API建模為接口(有時是抽象類),然後根據情況選擇最佳實現。 理想情況下,API 的使用者與實現完全分離,這意味着它們之間沒有直接依賴關係。 Java的service加載器API允許將這種方法應用於JAR(模塊化或非模塊化)。作為module系統的重要概念,module聲明中可以使用usesprovides

Java module系統中的service

舉例説明問題

讓我們從一個示例開始,該示例在三個module中使用這三種類型:

  • com.example.app 的類Main
  • com.example.api 中的接口Service
  • com.example.impl 的實現類Implementation

Main想使用Service但需要創建Implementation才能得到實例:

public class Main {

    public static void main(String[] args) {
        Service service = new Implementation();
        use(service);
    }

    private static void use(Service service) {
        // ...
    }

}

這將導致以下module聲明:

module com.example.api {
    exports com.example.api;
}

module com.example.impl {
    requires com.example.api;
    exports com.example.impl;
}

module com.example.app {
    // dependency on the API: ✅
    requires com.example.api;
    // dependency on the implementation: ⚠️
    requires com.example.impl;
}

如您所見,按説使用接口應該將使用者和 API 提供者分離。挑戰在於,在某些時候必須實例化特定的實現。 如果這是作為常規構造函數調用發生的(如Main ),則會創建對實現module的依賴關係。 這就是service解決的問題。

service定位器模式作為解決方案

Java 通過實現service定位器模式來解決此問題,其中類 ServiceLoader 充當中央註冊表。 這是它的工作原理。

service是一種可訪問的類型(不必是接口;抽象甚至具體的類也可以),一個module想要使用它,另一個module需提供以下實例:

  • 使用service的module必須在其module聲明中使用uses $SERVICE其中$SERVICE 是service類型的完全限定名稱。
  • 提供service的module必須使用provides $SERVICE with $PROVIDER,其中$SERVICE與上面指令中的類型相同,$PROVIDER可以是以下類型:

    • 擴展或實現$SERVICE的具體類,具有public無參構造
    • 任意類型,具有public static provide()方法,返回$SERVICE的擴展實現

在運行時,依賴module調用ServiceLoader.load($SERVICE.class) 來獲取service的所有實現。 然後,module系統將返回一個ServiceLoader<$SERVICE>,您可以通過各種方式使用它來訪問service實現。 ServiceLoader 的 Javadoc 詳細介紹了所有與service相關的內容。

解決方案示例

以下是我們之前研究的三個類和module如何使用service。 我們從module聲明開始:

module com.example.api {
    exports com.example.api;
}

module com.example.impl {
    requires com.example.api;

    provides com.example.api.Service
        with com.example.impl.Implementation;
}

module com.example.app {
    requires com.example.api;

    uses com.example.api.Service;//✅
}

這裏,com.example.app 不再需要 com.example.impl。 而是使用Service,並且com.example.impl提供了Implementation. 此外,com.example.impl 不再導出包。 service加載器不要求service的實現在module外部可訪問,如果該包中其他類也不需要外部訪問,可以完全不導出它。 這是service的額外好處,因為它可以減少module的 API 。

以下是Main如何調用Service的實現:

public class Main {

    public static void main(String[] args) {
        Service service = ServiceLoader
            .load(Service.class)
            .findFirst()
            .orElseThrow();
        use(service);
    }

    private static void use(Service service) {
        // ...
    }

}

一些 JDK service

JDK 本身也使用service。 例如,包含 JDBC API 的 java.sql module用java.sql.Driver作service:

module java.sql {
    // requires...
    // exports...
    uses java.sql.Driver;
}

這也表明service可以是自己的類型。

JDK 中service的另一個示例性用法是java.lang.System.LoggerFinder 。 這個API允許用户將JDK的日誌消息(而不是運行時的!)通過管道傳輸到他們選擇的日誌框架中(例如,Log4J或Logback)。 簡單地説,JDK 不是寫入標準輸出,而是使用 LoggerFinder 創建Logger實例,來記錄所有消息。 由於它用LoggerFinder作service,日誌框架可以提供它的實現。

module com.example.logger {
    // `LoggerFinder` is the service interface
    provides java.lang.System.LoggerFinder
        with com.example.logger.ExLoggerFinder;
}

public class ExLoggerFinder implements System.LoggerFinder {

    // `ExLoggerFinder` must have a parameterless constructor

    @Override
    public Logger getLogger(String name, Module module) {
        // `ExLogger` must implement `Logger`
        return new ExLogger(name, module);
    }

}

module解析期間的service

如果您曾經使用--show-module-resolution啓動過一個簡單的模塊化應用程序,並觀察module系統到底在做什麼,您可能會對解析的平台module數量感到驚訝。 對於一個足夠簡單的應用程序,唯一的平台module應該是java.base,也許還有一兩個,那麼為什麼還有這麼多其他module呢? 答案就是service。

請記住,只有module解析期間進入圖的module在運行時才可用。 為了確保service的所有提供者都存在,解析過程會考慮usesprovides指令。 因此,除了跟蹤依賴關係之外,一旦它解析了使用service的module,它還會將所有提供該service的module也添加到圖中。 此過程稱為service綁定

class path上的代碼 - unamed module

class path上的所有 JAR,無論是否模塊化,都將成為unamed module的一部分。這使得“一切皆為模塊”,讓class path可以保持之前的混亂。

module系統希望所有內容都是module,可以統一規則,但同時,創建module並不是強制性的(考慮到兼容性)。 unamed module 包含了class path上所有類,當然也有一點點特殊規則。

這意味着,如果您從class path啓動代碼,則unamed module將發揮作用。 除非你的應用程序相當小,否則它可能需要漸進模塊化,那需要摻雜JARs,modules,class path和module path。 首先,需要了解module系統的“class path模式”如何工作。

unamed module

unamed module包含所有“非模塊化類”,包括

  • 在編譯時,沒有module描述符的類
  • 在編譯和運行時,從class path加載的所有類

所有module都有三個中心屬性,對於unamed module也是如此:

  • 名稱:unamed module沒有(也算吧?),這意味着沒有其他module可以在其聲明中提及它(例如requires它)
  • 依賴關係:unamed module能讀取進入module圖的所有其他module
  • 導出:unamed module對其所有包都 export/open

META-INF/services裏提供的service也可供ServiceLoader使用。

相反,之前的都稱為命名module。unamed module的概念讓有序的module圖變得完整。

class path的混亂

unamed module的主要目標是捕獲class path內容並使其在module系統中工作。 由於class path上的 JAR 之間從來沒有任何界限,試圖區分開它們並沒有意義,整個class path只需要一個unamed module。 在裏面,就像在class path上一樣,所有public類都可以相互訪問,並且包可以跨 JAR 拆分。

unamed module的獨特角色及其對兼容性的關注賦予了它一些特殊屬性。 一個能間接地訪問Java 9到16中的強封裝API。 另一個,許多應用於命名module的檢查都將跳過它。 因此,如果它和命名module之間有拆分包,一是不會發現,二是class path的部分會不可用。 (這意味着,如果命名module中也存在相同的包,則可能會因class path上缺少類而出錯)。

一個有點違反直覺且容易出錯的細節是unamed module的確切構成。 似乎很明顯,模塊化 JAR 成為module,因此普通 JAR 進入unamed module,對吧? 但事實並非如此,unamed module負責class path上的所有 JAR,無論是否模塊化。 因此,模塊化 JAR 不一定會作為module加載! 因此,如果一個庫開始提供模塊化的 JAR,它的用户絕不會被迫當module來用。 相反,他們可以將它們留在class path上,其中的代碼被捆綁到unamed module中。 這使得生態系統幾乎可以彼此獨立地進行模塊化。

若要嘗試此操作,可以將以下兩行代碼放入打包為模塊化 JAR 的類中:

String moduleName = this.getClass().getModule().getName();
System.out.println("Module name: " + moduleName);

從class path啓動時,輸出為Module name: null ,指示該類最終位於unamed module中。 從module path啓動時,您將獲得預期的Module name: $MODULE ,其中$MODULE是您為module指定的名稱。

unamed module的解析

unamed module與module圖的其餘部分是什麼關係,它可以讀取哪些其他module? 如前所述,module解析通過從root module(特別是初始module)開始,然後迭代添加所有直接和傳遞依賴項來構建module圖。 那麼代碼編譯過程具體怎樣的?如果應用程序的main方法位於unamed module中,就像從class path啓動應用程序時一樣,這將如何工作? 畢竟,普通 JAR 沒聲明任何依賴關係。

好,如果初始module是unamed module,則module解析將從一組預定義的root module開始。根據經驗,這些是在運行時能找到的module,但實際規則更詳細一些:

  • 成為 root 的 java.* module的精確集合取決於 java.se module(即表示整個 Java SE API 的module;它存在於完整的 JRE 映像中,但在使用jlink 創建的自定義運行映像中可能不存在):

    • 如果 java.se 存在,它就成為root 。
    • 如果不是,則每個非限定導出的 java.* module都將成為 root。
  • 除了 java.* module,運行中非孵化,而且至少非限定導出一個包的所有其他module都將成為root module。 這對於 jdk.* module尤其重要。
  • --add-modules 列出的始終是root module。

請注意,使用unamed module作為初始module時,root module集始終是運行映像module的子集。 除非使用 顯式--add-modules添加,否則將永遠不會解析module path上存在的module。 如果手動添加的module path已經準確包含所需的module,則可能需要--add-modules ALL-MODULE-PATH來添加所有module。

信任unamed module

module系統的主要目標之一是可靠的配置: module必須表達其依賴關係,module系統必須能夠保證它們的存在。 對於帶有module描述符的顯式module,我們討論了這個問題,但是如果我們嘗試將可靠配置擴展到class path會發生什麼?

一個頭腦風暴

想象一下,module可能依賴於class path內容,也許在它們的描述符中有一些類似requires class-path的東西。 module系統可以為這種依賴性提供哪些保證? 事實證明,幾乎沒有。 只要class path上至少有一個類,module系統就必須假定依賴關係已滿足。 那不會很有幫助。 更糟糕的是,它會嚴重破壞可靠的配置,因為您最終可能會依賴requires class-path . 但這幾乎不包含任何信息 - class path上到底需要什麼?

進一步假設,假設兩個module com.example.framework和com.example.library依賴於相同的第三個module,比如SLF4J。 一個依賴於尚未模塊化的SLF4J,因此requires class-path,另一個依賴已經模塊化的SLF4J,因此requires org.slf4j。 現在,依賴com.example.framework和com.example.library的人會將SLF4J JAR放置在哪條路徑上? 無論選擇哪種,module系統都只能滿足一個。

仔細考慮這一點可以得出結論,如果您想要可靠的module,那麼依賴任意class path內容不是一個好主意。 由於這個確切的原因,不要用requires class-path

因此,unamed

所以説,包含class path內容的module不應該被其他module依賴。 因為module系統中需要名稱來引用,而它是unamed,聽起來很合理。

總之,要使module顯式依賴某個製品,該製品必須位於module path上。 這很可能意味着您將普通 JAR 放置在module path上,會將它們轉換為自動module - 我們接下來將探討這一概念。

使用自動modules實現漸進模塊化

module path上的普通 JAR 成為自動模塊,它們可以充當從模塊化 JAR 到class path的橋樑。

module系統要求在module path(或運行時)中找到module的所有依賴項。 如果只允許模塊化 JAR ,那麼項目的所有依賴項都必須是module,大型項目則必須全部模塊化。 為了避免繁雜工作量,module系統允許module path上的普通 JAR變成自動module。 當然也有一點點特殊規則:自動module可以讀取unamed module,這允許它們充當從module path到class path的橋樑。

自動module

對於module path上沒有module描述符的每個 JAR,module系統都會創建一個自動module。 與任何其他module一樣,它具有三個中心屬性:

  • 名稱:可以在 JAR 的清單中使用標頭Automatic-Module-Name定義名稱;如果缺少,將從文件名自動生成
  • 依賴關係:自動module能讀取進入圖的所有其他module,包括unamed module
  • 導出:自動module對其所有包都export/open

META-INF/services提供的service將提供給 ServiceLoader

自動module是正規的命名module,這意味着:

  • 可以在其他module的聲明中通過名稱引用它們,例如需要它們。
  • 即使在Java 9到16上,它們也沒有受到JDKmodule強封裝的例外的影響。
  • 它們要像拆分包一樣進行可靠性檢查。

若要嘗試自動module,可以將以下兩行代碼放入打包為純 JAR 的類中:

String moduleName = this.getClass().getModule().getName();
System.out.println("Module name: " + moduleName);

從class path啓動時,輸出為Module name: null ,指示該類最終位於unamed module中。 從module path啓動時,您將獲得預期的Module name: $JAR ,其中 $JAR是 JAR 文件的名稱。 如果添加Automatic-Module-Name標頭到清單,則在從module path啓動 JAR 時將顯示該名稱。

自動module名稱 - 細節小,影響大

將普通 JAR 轉換為module的要點是能夠在module聲明中requires它們。 但缺少module描述符,名稱從何而來?

首先是清單條目,然後是文件名

確定純 JAR module名稱的一種方法依賴於其清單,該清單是 JAR 文件夾META-INF中的MANIFEST.MF文件。 如果module path上的 JAR 不包含描述符,則module系統將遵循兩步過程來確定自動module的名稱:

  1. 清單中查找標頭Automatic-Module-Name。 如果找到它,它將使用相應的值作為module的名稱。
  2. 如果清單中不存在標頭,module系統會從文件名推斷module名稱。

    從文件名推斷module名稱的確切規則有點複雜,但細節並不重要 - 這是要點:

  • JAR 文件名通常以版本字符串結尾(如-2.0.5 )。 這些可以被識別但會忽略。
  • 除了字母和數字之外的每個字符都變成一個點。

此過程可能會導致不幸的結果,即生成的module名稱無效。 一個例子是字節碼操作工具ByteBuddy: 它在 Maven Central 中以 byte-buddy-$VERSION.jar的形式發佈,這會導致自動module名稱byte.buddy(在它定義專有名稱之前)。 不幸的是,這是非法的,因為byte是一個Java關鍵字。

找出名字

jar --describe-module --file $FILE

  • 提取清單並手動查看。jar --file $JAR --extract META-INF/MANIFEST.MF
  • 在 Linux 上,將清單打印到終端,從而節省open 文件的時間。unzip -p $JAR META-INF/MANIFEST.MF
  • 重命名文件並再次運行。jar --describe-module

何時設置Automatic-Module-Name

如果您維護的是public發佈的項目,這意味着其製品可通過 Maven Central 或其他public存儲庫獲得,應仔細考慮何時在清單中設置Automatic-Module-Name。 如前所述,它使您的項目用作自動module更加可靠,同時也承諾,將來顯式module將替代當前 JAR。 你基本上是在説:“這就是module的樣子,我只是還沒有開始發佈它們”。

定義自動module名稱會邀請用户開始信任項目製品作為module,這一事實有以下幾個重要含義:

  • 未來module的名稱必須與您現在聲明的名稱完全相同。 (否則,可靠的配置會因為缺少module而撕咬您的用户)
  • 製品結構必須一致,因此無法將支持的類或包從一個 JAR 再移動到另一個。 (即使沒有module,這也是不推薦的做法)
  • 該項目在Java 9及更高版本上運行得相當好。 如果需要命令行選項或其他解決方法,都有很好的文檔。

自動module的module解析

自動module是從普通 JAR 創建的,因此沒有明確的依賴聲明,這就引出了一個問題,它們在解析過程中的行為方式。 JAR 傾向於相互依賴,如果module系統只解析顯式requires的自動module(或者用 --add-modules 添加的)。 想象一下,對於一個具有數百個依賴項的大型項目,要將所有都放置在module path上,這樣很恐怖。

為了防止這種過分和脆弱的手動操作,module系統一旦遇到第一個顯式requires的自動module,就會載入所有自動module。 換句話説,您要麼將所有普通 JAR 都作為自動module,要麼一個都沒。 另一方面,自動module之間都是隱式讀取,這意味着讀取任何一個自動module的module都會讀取所有自動module。

一旦在module path上放置了一個普通 JAR,它的所有直接依賴也必須在module path上,然後是下級依賴,依此類推,直到所有傳遞依賴都被視為module,顯式或自動的。

但是,將普通JAR轉換為自動module可能不起作用,因為不一定通過檢查(例如搜索拆分包)。 因此,能作為普通 JAR 保留在class path上並將它們加載為unamed module也是個辦法。 事實上,module系統允許自動module讀取unamed module,這意味着它們的依賴項可以位於class pathmodule path上。

當我們來看平台module,我們看到自動module無法聲明依賴關係。 因此,module圖可能包含依賴也可能不包含,如果沒有,自動module可能在運行時失敗,因缺少類而異常。 解決這個問題的唯一方法是讓項目的維護者公開説明他們需要哪些module,以便他們的用户可以確保所需的module存在。 用户可以通過顯式requires它們或使用 --add-modules

信任自動module

自動module的唯一目的是能夠依賴普通的 JAR,因此可以顯式創建module,而不必等到所有依賴項都模塊化。

但根據其設置,不同的項目可能會對相同的 JAR 使用不同的名稱。 大多數項目使用Maven支持的本地存儲庫,其中JAR文件以${artifactID}-$VERSION命名,module系統可能會從中推斷${artifactID}作為自動module的名稱。 這是有問題的,因為製品 ID 通常不遵循反向域命約定,這意味着一旦項目模塊化,module名稱可能會變。

總之,同一個 JAR 可能會在不同的項目(取決於它們的設置)和不同的時間(模塊化之前和之後)獲得不同的module名稱。 這有可能給下游造成嚴重破壞,需要不惜一切代價避免!

看起來,好像讓純 JAR的文件名跟module名稱一致就行。 但不是這麼簡單 - 使用此方法對於應用程序以及開發人員可以完全控制module描述符的場景是可以。 但不要將具有此類依賴項的module發佈到public存儲庫。那樣的話,module可能隱式依賴於用户無法控制的細節,這可能導致額外的工作甚至無法解決的衝突。

因此,您永遠不應該發佈(到可public訪問的存儲庫)這種module,這種依賴某個沒有Automatic-Module-Name的純 JAR的module。 有Automatic-Module-Name的自動module才可以穩定依賴。 是的,這可能意味着您必須等待依賴項添加了該條目,你的庫或框架的模塊化版本才能發佈。

在命令行上構建module

瞭解如何使用 javac、jar 和 java 命令手動編譯、打包和啓動模塊化應用程序 - 即使構建工具完成了大部分繁重的工作,也很高興知道。

創建module時,可能會使用構建工具。 但也要了解“正確的”應該是什麼樣子,以及如何配置javacjarjava 、 以及如何編譯、打包和運行應用程序。 這將使您更好地瞭解module系統,幫助調試問題。

基本構建

給定一個包含幾個源文件、一個module聲明和一些依賴項的項目,您可以通過這種方式以最簡單的方式編譯、打包和運行它:

# compile sources files, including module-info.java
$ javac
    --module-path $DEPS
    -d $CLASS_FOLDER
    $SOURCES
# package class files, including module-info.class
$ jar --create
    --file $JAR
    $CLASSES
# run by specifying a module by name
$ java
    --module-path $JAR:$DEPS
    --module $MODULE_NAME/$MAIN_CLASS

裏面有一堆佔位符:

  • $DEPS是依賴項的列表。通常是由 :(Unix) 或 ;(Windows) 分隔的 JAR 文件路徑,或者文件夾(沒有/*)。
  • $CLASS_FOLDER*.class保存的路徑。
  • $SOURCES是源文件列表,必須包含 *.javamodule-info.java
  • $JAR是將創建的 JAR 文件的路徑。
  • $CLASSES是在編譯的*.class文件列表,必須包含module-info.class
  • $MODULE_NAME/$MAIN_CLASS是初始module名稱,後跟包含main方法的類。

對於具有通用src/main/java結構的簡單“Hello World”樣式項目,只有一個源文件,deps為依賴項的文件夾,並使用Maven的target文件夾,如下所示:

$ javac
    --module-path deps
    -d target/classes
    src/main/java/module-info.java
    src/main/java/com/example/Main.java
$ jar --create
    --file target/hello-modules.jar
    target/classes/module-info.class
    target/classes/com/example/Main.class
$ java
    --module-path target/hello-modules.jar:deps
    --module com.example/com.example.Main

定義主類

jar--main-class $MAIN_CLASS選項指定包含main方法的類,它允許您啓動module時無需指定主類:

$ jar --create
    --file target/hello-modules.jar
    --main-class com.example.Main
    target/classes/module-info.class
    target/classes/com/example/Main.class
$ java
    --module-path target/hello-modules.jar:deps
    --module com.example

請注意,可以覆蓋該類並啓動另一個類,只需像以前一樣命名它:

# create a JAR with `Main` and `Side`,
# making `Main` the main class
$ jar --create
    --file target/hello-modules.jar
    --main-class com.example.Main
    target/classes/module-info.class
    target/classes/com/example/Main.class
    target/classes/com/example/Side.class
# override the main class and launch `Side`
$ java
    --module-path target/hello-modules.jar:deps
    --module com.example/com.example.Side

繞過強封裝

module系統對訪問內部 API 有嚴格限制: 如果未export/open packages,訪問將被拒絕。 但是包不能只由module的作者export/open - 還有命令行標誌--add-exports--add-opens,允許用户執行此操作。

例如,請參閲嘗試創建內部類實例的代碼:sun.util.BuddhistCalendar

BuddhistCalendar calendar = new BuddhistCalendar();

要編譯和運行它,我們需要使用 :--add-exports

javac
    --add-exports java.base/sun.util=com.example.internal
    module-info.java Internal.java
# package with `jar`
java
    --add-exports java.base/sun.util=com.example.internal
    --module-path com.example.internal.jar
    --module com.example.internal

如果訪問是反射性的...

Class.forName("sun.util.BuddhistCalendar").getConstructor().newInstance();

...編譯無需進一步配置即可工作,但我們需要在運行代碼時添加:--add-opens

java
    --add-opens java.base/sun.util=com.example.internal
    --module-path com.example.internal.jar
    --module com.example.internal

擴展module圖

從一組初始的root module開始,module系統計算它們的所有依賴關係並構建一個圖,其中module是節點,它們的讀取關係是有向邊。 此module圖可以使用--add-modules--add-reads 進行擴展,它們分別添加module(及其依賴項)和讀取邊。

例如,讓我們假設一個項目對java.sql具有可選的依賴關係,但該module不是必需的。 這意味着如果沒有一點幫助,它就不會添加到module圖中:

# launch without java.sql
$ java
    --module-path example.jar:deps
    --module com.example/com.example.Main

# launch with java.sql
$ java
    --module-path example.jar:deps
    --add-modules java.sql
    --module com.example/com.example.Main

可選依賴項的另一種方法是根本不列出依賴項,而只添加--add-modules--add-reads(這很少用,通常不推薦 - 只是一個示例):

$ java
    --module-path example.jar:deps
    --add-modules java.sql
    --add-reads com.example=java.sql
    --module com.example/com.example.Main

強封裝(JDK 內部)

強封裝是模塊系統的基石。它避免(意外)使用內部 API,主要是java.*包中的非public類型/成員以及sun.*com.sun.*的大部分 。

幾乎所有依賴項(無論是框架、庫、JDK API 還是您自己的(子)項目)都有一個public的、受支持的和穩定的 API ,以及所需的內部代碼。 強封裝説的是避免(意外)使用內部 API ,以使項目更加健壯和可維護。 我們將探討為什麼需要這樣做,內部 API 的構成究竟是什麼(特別是對於 JDK),以及強封裝在實踐中是如何工作的。

什麼是強封裝?

在許多方面,OpenJDK項目與任何其他軟件項目相似,一個常見的是重構。 代碼被更改、移動、刪除等,來保持項目乾淨可維護。 當然,並非所有代碼: 像publicAPI,是跟Java用户的約定,非常要求保持穩定。

如您所見,public API 和內部代碼之間的區別對於維護兼容性至關重要,對 JDK 開發人員和您來説也是如此。 您需要確保您的項目(代碼、依賴項)不依賴於經常在次要更新中更改的內部結構,從而導致令人驚訝和不必要的作業。 更糟糕的是,此類依賴項可能會妨礙系統正常工作。 還有,您可能想用內部 API 提供些特殊功能,為您的項目帶來點競爭力。

總之,這意味着需要一種機制,默認必須鎖定內部 API,但特定情況下又允許解鎖某部分。 強封裝就是這種機制。

由於只有export/open 的包中的類型才能在module外部訪問,其他所有都視為內部,也就無法訪問。 首先,JDK本身就是這樣的,自Java 9開始,JDK就拆分成了module。

什麼是內部 API?

那麼哪些JDK API是內部的呢? 要回答這個問題,我們先看三個命名空間:

第一: 當然,java.*這些包構成了public API,但只是public類的public成員。 其他可見性的都是內部的,並且由module系統強封裝。

然後是sun.*. 幾乎所有這樣的軟件包都是內部的,但有兩個例外:sun.miscsun.reflect,由module jdk.unsupported 導出和open 。因為它們提供了對許多項目至關重要的功能,並且在JDK內部或外部沒有可行的替代方案(sun.misc.Unsafe最突出)。 不過,也只是小例外: 一般來説,sun.*包應該被視為內部的。

最後是com.sun.* ,這比較複雜。 整個命名空間是特定於JDK的,這意味着它不是Java標準API的一部分,並且某些JDK可能不包含它。 其中大約 90% 是非export包,是內部的。 剩下的 10% 是由 jdk.* module導出的包,支持在 JDK 外部使用。 這是標準化 API 進化同時考慮兼容性造成的。這裏有個列表,jdk8裏的內部包與導出包,在jdk17裏並不存在。

綜上所述,使用java.*、避免sun.*、小心com.sun.*

強封裝實驗

為了試驗強封裝,讓我們創建一個使用來自public API 的類的簡單類:

public class Internal {

    public static void main(String[] args) {
        System.out.println(java.util.List.class.getSimpleName());
    }

}

由於它是一個單一的類,你可以直接運行它,而無需顯式編譯:

java Internal.java

這應該成功運行並打印“List”。

接下來,讓我們混合其中一個出於兼容性原因可訪問的異常:

// add to `main` method
System.out.println(sun.misc.Unsafe.class.getSimpleName());

您仍然可以立即運行它,打印“List”和“Unsafe”。

現在讓我們使用一個無法訪問的內部類:

// add to `main` method
System.out.println(sun.util.BuddhistCalendar.class.getSimpleName());

如果您嘗試像以前一樣運行它,則會出現編譯錯誤(java命令在內存中編譯):

Internal.java:8: error: package sun.util is not visible
                System.out.println(sun.util.PreHashedMap.class.getSimpleName());
                                      ^
  (package sun.util is declared in module java.base, which does not export it)
1 error
error: compilation failed

錯誤消息非常清楚: sun.util包屬於module java.base,因為它不導出它,所以它被認為是內部的,因此無法訪問。

我們可以在編譯期間避免使用類型,而是使用反射:

Class.forName("sun.util.BuddhistCalendar").getConstructor().newInstance();

執行會導致運行時出現異常:

Exception in thread "main" java.lang.IllegalAccessException:
    class Internal cannot access class sun.util.BuddhistCalendar (in module java.base)
    because module java.base does not export sun.util to unnamed module @1f021e6c
        at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:392)
        at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:674)
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:489)
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
        at org.codefx.lab.internal.Internal.main(Internal.java:9)

實踐中的強封裝

如果您確定需要訪問內部 API,有兩個命令行標誌可以讓您繞過:

  • --add-exports使導出包中的public類型和成員在編譯或運行時可訪問
  • --add-opens使open 包中的所有類型及成員在運行時可反射

在編譯期間應用--add-exports時,必須在運行應用程序時再次應用它,當然--add-opens只有在運行時才有意義。 這意味着無論任何代碼(您的或您的依賴項)只要想訪問 JDK 內部時,都需要在啓動時配置它。 這使應用程序的所有者完全透明地瞭解這些問題,允許他們評估情況並更改代碼/依賴項,或主觀接受使用內部 API 帶來的可維護性危害。

強封裝對所有有名module都有效。 這包括完全模塊化的整個 JDK,但也可能包括您的代碼和依賴項,也可能作為module path上的模塊化 JAR 出現。 在這種情況下,到目前為止所説的一切都適用於這些module:

  • 在編譯和運行時,只有導出包中的public類型和成員才能在module外部訪問
  • open 的包中的所有類型和成員都可以在運行時在module外部訪問
  • 其他類型和成員在編譯期間和運行時無法訪問
  • 可以使用--add-exports(對於靜態依賴項)和--add-opens(對於反射訪問)創建例外

這意味着您可以將強封裝的優勢擴展到 JDK API 之外,以涵蓋您的代碼和依賴。

強封裝的演變

強封裝是 Java 9 中引入的module系統的基石,但出於兼容性原因,class path中的代碼仍然可能訪問內部 JDK API。 這是用--illegal-access來管理的,該選項在JDK 9到15中具有默認值permit。 JDK 16 更改為deny,17 中已完全停用。

從JDK 17 開始,僅允許--add-exports--add-opens訪問內部 API。

繞過強封裝--add-exports--add-opens

通過在編譯或運行時導出包,或在運行時打開包進行反射,來授予對內部 API 的訪問,無論是 JDK 的一部分還是依賴項。

module系統對訪問內部 API 非常嚴格: 如果未export/open packages,訪問將被拒絕。 但是包不能只由module的作者export/open - 還有--add-exports--add-opens,允許module的用户執行此操作。

這樣,應用程序可以訪問到依賴項或 JDK API 的內部。 由於這需要在更多的功能或性能(大概),與更少的可維護性或破壞平台完整性之間進行權衡,因此不應輕易做出此決定。 而且由於它最終不僅涉及開發人員,還涉及應用程序的用户,因此必須在啓動時添加這些命令行標誌,讓用户需要知道自己正在權衡。

導出包時--add-exports

該選項--add-exports $MODULE/$PACKAGE=$READING_MODULE ,可用於 javajavac命令,將$MODULE$PACKAGE導出到 $READING_MODULE。 這樣,$READING_MODULE 中的代碼就可以訪問$PACKAGE中的所有public類型和成員,但其他module不能。 將 $READING_MODULE 設置為ALL-UNNAMED 時,class path中的所有代碼都可以訪問該包。$MODULE只對module項目有效。

後面的空格可以替換為等號,這有助於某些工具配置(例如 Maven): 。--add-exports`=`--add-exports=.../...=...

編譯時

例如,請參閲嘗試創建內部類實例的代碼:sun.util.BuddhistCalendar

BuddhistCalendar calendar = new BuddhistCalendar();

如果我們這樣編譯它,我們會得到以下錯誤,如果沒有導入:

error: package sun.util is not visible
  (package sun.util is declared in module java.base, which does not export it)

--add-exports可以解決此問題。 如果上面的代碼是在沒有module聲明的情況下編譯的,我們需要export packages到:ALL-UNNAMED

javac
    --add-exports java.base/sun.util=ALL-UNNAMED
    Internal.java

如果它在名為com.example.internal的module中,我們可以更精確,從而最大限度地減少內部的暴露:

javac
    --add-exports java.base/sun.util=com.example.internal
    module-info.java Internal.java

在運行時

啓動代碼(在 JDK 17 及更高版本上)時,我們收到運行時錯誤:

java.lang.IllegalAccessError:
    class Internal (in unnamed module @0x758e9812)
    cannot access class sun.util.BuddhistCalendar (in module java.base)
    because module java.base does not export sun.util to unnamed module @0x758e9812

為了解決這個問題,我們需要在啓動時重複--add-exports。 對於class path中的代碼:

java
    --add-exports java.base/sun.util=ALL-UNNAMED
    --class-path com.example.internal.jar
    com.example.internal.Internal

如果它位於名為com.example.internal的module中(定義了一個主類),我們可以再次更精確:

java
    --add-exports java.base/sun.util=com.example.internal
    --module-path com.example.internal.jar
    --module com.example.internal

open packages時--add-opens

命令行選項--add-opens $MODULE/$PACKAGE=$REFLECTING_MODULEopen $MODULE$PACKAGE$REFLECTING_MODULE。 因此,$REFLECTING_MODULE 中的代碼可以反射性地訪問$PACKAGE所有類型和成員,public和非public成員。 將 $REFLECTING_MODULE設置為ALL-UNNAMED 時,class path中的所有代碼都可以反射方式訪問該包。 $MODULE只對module項目有效。

--add-opens後面的空格可以用=代替,這有助於某些工具配置:--add-opens=.../...=...

由於--add-opens綁定到反射,一個純粹的運行時概念,它只對java命令有意義。 但是,鑑於許多命令行選項可以跨多個工具工作,因此報告和解釋選項何時不起作用是很有幫助的,因此javac不會拒絕該選項,而是發出警告“--add-open 在編譯時不起作用”。

在運行時

例如,嘗試使用反射創建內部類sun.util.BuddhistCalendar實例的類Internal

Class.forName("sun.util.BuddhistCalendar").getConstructor().newInstance();

由於代碼不針對內部類BuddhistCalendar進行編譯,編譯無需額外的命令行標誌即可工作。 但在 JDK 17 及更高版本上,運行時會出現異常:

Exception in thread "main" java.lang.IllegalAccessException:
    class Internal cannot access class sun.util.BuddhistCalendar (in module java.base)
    because module java.base does not export sun.util to unnamed module @1f021e6c
        at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:392)
        at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:674)
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:489)
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)

--add-opens可以解決此問題。 如果上面的代碼在class path上的 JAR 中,我們需要open packagessun.utilALL-UNNAMED

java
    --add-opens java.base/sun.util=ALL-UNNAMED
    --class-path com.example.internal.jar
    com.example.internal.Internal

還記得嗎,沒有必要opensun.miscsun.reflect包,因為它們是由 jdk.unsupport 導出的。

如果它位於名為 com.example.internal 的module(定義一個主類)中,我們可以更精確,從而最大限度地減少內部的暴露:

java
    --add-opens java.base/sun.util=com.example.internal
    --module-path com.example.internal.jar
    --module com.example.internal

擴展module圖--add-modules--add-reads

手動添加模塊(節點)和可讀關係(邊)來擴展模塊系統生成的module圖。

從一組初始的root module開始,module系統計算它們的所有依賴關係並構建一個圖,其中module是節點,它們的讀取關係是有向邊。 此module圖可以使用--add-modules--add-reads進行擴展,它們分別添加module(及其依賴項)和讀取邊。 前者有一些場景,後者非常小眾,但無論哪種方式,瞭解它們都很好。

添加root module--add-modules

--add-modules $MODULESjavacjlinkjava上可用,並且接受以逗號分隔的module列表,將它們添加到root module集中。 (root module構成module解析開始的初始module集。 這允許您將module(及其依賴項)添加到module圖中,否則不起作用。

--add-modules具有三個特殊值:

  • ALL-DEFAULT是從class path啓動時的root module集。 當應用程序是託管其他應用程序的容器時,這很有用,它們的依賴對容器本身並不必要。
  • ALL-SYSTEM將所有系統module(下一章會看到)添加到root 集,測試工具有時需要這樣做。 此選項將導致許多module被解析;一般來説,首選ALL-DEFAULT
  • ALL-MODULE-PATH將module path上找到的所有module添加到root 集。 這是供構建工具使用的(Maven等),這些工具已經確定需要module path上的所有module。 這也是將自動module添加到root 集的便捷方法。

前兩個僅在運行時工作,很少用到,本文不討論。 最後一個可能有用:有了它,module path上的所有module都成為root module,因此它們都進入了module圖。

--add-modules後面的空格可以用=代替,這有助於某些工具配置:--add-modules=...

添加module的場景

一個場景是添加可選的依賴項,這些依賴項不是必需的,因此不會進入module圖。 例如,讓我們假設一個項目對java.sql具有可選的依賴關係,但該module不是必需的:

# launch without java.sql
$ java
    --module-path example.jar:deps
    --module com.example/com.example.Main

# launch with java.sql
$ java
    --module-path example.jar:deps
    --add-modules java.sql
    --module com.example/com.example.Main

另一種方法是在使用 jlink 創建運行映像時定義root module集。

添加module時,可能需要讓其他module讀取它們,所以接下來讓我們這樣做。

添加讀取邊--add-reads

編譯器和運行時--add-reads $MODULE=$TARGETS$MODULE的讀取邊添加到逗號分隔列表$TARGETS中的所有module。 這允許$MODULE訪問這些module導出的包中的所有public類型,即使$MODULE沒有requires它們。 如果$TARGETS設置為ALL-UNNAMED$MODULE甚至可以讀取unamed module。

--add-reads後面的空格可以用=代替,這有助於某些工具配置。--add-reads=.../...

添加讀取的示例

讓我們回到前面的例子,其中代碼使用 java.sql,但不想總是依賴它。 可選依賴項的另一種方法是根本不列出依賴項,而只添加 --add-modules--add-reads(這很少有用,通常不推薦 - 只是一個示例):

# this only shows launch, but compilation
# would also need the two options
$ java
    --module-path example.jar:deps
    --add-modules java.sql
    --add-reads com.example=java.sql
    --module com.example/com.example.Main

使用 JLink 創建運行時和應用程序映像

瞭解如何創建自定義運行映像或自包含的應用程序映像。

使用jlink,您可以選擇許多模塊、平台模塊以及組成應用程序的模塊,並將它們鏈接到運行映像中。 這樣的運行映像的作用類似於您可以下載的 JDK,但僅包含您選擇的模塊及其運行所需的依賴項。 如果包含的是您的項目,則結果是獨立的應用程序,意味着它不依賴於目標系統上的 JDK。 在鏈接階段,jlink可以進一步優化映像大小並提高 VM 性能,尤其是啓動時間。

雖然不太重要,但區分運行映像(JDK 的子集)和應用程序映像(也包含特定於項目的模塊)很有幫助,我們按此順序進行。

注意:jlink “只是”鏈接字節碼 - 它不會將其編譯為機器代碼,因此這不是提前編譯。

創建運行映像

要創建映像,jlink需要兩條信息:

  • 從哪些模塊開始 --add-modules
  • 在哪個文件夾中創建映像 --output

給定這些命令行選項, jlink解析模塊,從 --add-modules列出的模塊開始。 但它有一些特點:

  • 默認情況下,service不包含 - 我們將在下面進一步看到如何處理
  • 可選依賴項不解析 - 需要手動添加
  • 不允許使用自動模塊 - 我們將在進入應用程序映像時討論這個問題

除非遇到任何問題,例如缺少或重複的模塊,否則解析的模塊(root module加上傳遞依賴項)最終會出現在運行映像中。

最小的運行時

讓我們來看看。 最簡單的運行映像僅包含基本模塊:

# create the image
$ jlink
    --add-modules java.base
    --output jdk-base
# use the image's java launcher to list all contained modules
$ jdk-base/bin/java --list-modules
> java.base

創建應用程序映像

可以使用類似的方法來創建包含整個應用程序的映像,這意味着包含應用程序module(應用本身及其依賴項)和支持它們所需的平台module。 要創建此類映像,您需要:

  • --module-path告知jlink在何處可以找到應用模塊
  • 根據需要與應用程序的主模塊和其他模塊一起使用--add-modules,例如service(見下文)或可選依賴

映像包含的平台和應用程序模塊一起稱為系統module。 請注意,jlink只在顯式模塊上運行,因此依賴於自動模塊的應用程序無法鏈接到映像中。

可選module path

例如,假設可以在mods文件夾中找到應用程序的模塊,並且其主模塊稱為 com.example.app。 然後以下命令在app-image文件夾中創建一個映像:

# create the image
$ jlink
    --module-path mods
    --add-modules com.example.main
    --output app-image

# list contained modules
$ app-image/bin/java --list-modules
> com.example.app
# other app modules
> java.base
# other java/jdk modules

由於映像包含整個應用程序,因此啓動它時無需使用module path:

$ app-image/bin/java --module com.example.app/com.example.app.Main

雖然您不必使用module path,但也可以用。 這種情況下,系統模塊將始終在module path上隱藏同名模塊。 因此,不能使用module path替換系統模塊,但可以添加其他模塊。 比如service的實現。這允許它隨應用程序一起發佈映像,同時也允許用户輕鬆地在本地擴展它。

生成本機啓動器

應用程序模塊可以包含一個自定義啓動器,它是映像bin文件夾中的可執行腳本(基於 Unix 的操作系統上的 shell,Windows 上的批處理),該腳本預配置為使用具體模塊和主類啓動 JVM。 要創建啓動器,請使用--launcher $NAME=$MODULE/$MAIN-CLASS

  • $NAME是您為可執行文件選擇的文件名
  • $MODULE是要用來啓動的模塊的名稱
  • $MAIN-CLASS是模塊主類

後兩個是你通常會放在java --module後面的。 就像那裏一樣,如果模塊定義了一個主類,你可以省略/$MAIN-CLASS

擴展上面的示例,這是如何創建一個名為app 的啓動器:

# create the image
$ jlink
    --module-path mods
    --add-modules com.example.main
    --launcher app=com.example.app/com.example.app.Main
    --output app-image

# launch
$ app-image/bin/app

不過,使用啓動器確實有一個缺點: 您嘗試應用於啓動 JVM 的所有選項都將被解釋為您將它們放在--module後面,使它們成為程序參數。 這意味着,在使用啓動器時,您不能臨時配置java命令,例如添加我們之前討論的其他服務。 一種方法是編輯腳本並將此類選項放在JLINK_VM_OPTIONS環境變量中。 另一種方法是回退到java命令本身,該命令在映像中仍然可用。

包含服務

若要啓用創建小型且有意組裝的運行映像,jlink默認情況下,在創建映像時不執行任何service綁定。 相反,必須通過--add-modules列出服務提供程序模塊來手動包含這些模塊。 要了解哪些模塊提供特定服務,請使用--suggest-providers $SERVICE ,該選項列出了運行時或module path上提供 $SERVICE實現 的所有模塊。 作為添加單個服務的替代方法,--bind-services可用於包含提供由另一個解析模塊使用的服務的所有模塊。

讓我們以 ISO-8859-1、UTF-8 或 UTF-16 等字符集為例。 基礎模塊知道您每天需要的模塊,但是有一個特定的平台模塊包含其他一些模塊:jdk.charsets。 基本模塊和 jdk.charset 通過服務分離 - 以下是其模塊聲明的相關部分:

module java.base {
    uses java.nio.charset.spi.CharsetProvider;
}

module jdk.charsets {
    provides java.nio.charset.spi.CharsetProvider
        with sun.nio.cs.ext.ExtendedCharsets
}

當模塊系統在常規啓動期間解析模塊時,服務綁定將載入 jdk.charsets,因此從標準 JDK 啓動時,其字符集始終可用。 但是,使用jlink 創建運行映像時,默認情況下不會發生這種情況,因此此類映像將不包含字符集模塊。 如果您確定需要它們,則只需--add-modules將模塊包含在映像中:

$ jlink
    --add-modules java.base,jdk.charsets
    --output jdk-charsets
$ jdk-charsets/bin/java --list-modules
> java.base
> jdk.charsets

跨操作系統生成映像

雖然應用程序和庫 JAR 包含的字節碼獨立於任何操作系統 (OS),但它需要特定於操作系統的 Java 虛擬機來執行它們 - 這就是您下載專門針對 Linux、macOS 或 Windows 的 JDK 的原因(例如)。 由於這是jlink提取平台模塊的位置,因此它創建的運行時和應用程序映像始終綁定到具體的操作系統。 幸運的是,它不一定是您正在運行jlink的那個。

如果下載並解壓縮其他操作系統的 JDK,則可以在從系統的 JDK 運行jlink版本時將其jmods文件夾放在module path上。 然後,鏈接器將確定要為該操作系統創建映像,從而能在那上面工作。 因此,給定應用程序支持的所有操作系統的 JDK,您可以在同一台計算機上為每個操作系統生成運行時或應用程序映像。 為了使它正常工作,建議僅引用與jlink二進制文件完全相同的JDK版本的模塊,例如,jlink版本為16.0.2,請確保它從JDK 16.0.2加載平台模塊。

讓我們回到之前創建的應用程序映像,並假設它是在 Linux 生成服務器上構建的。 然後,這是為Windows創建應用程序映像的方法:

# download JDK for Windows and unpack into `jdk-win`

# create the image with the jlink binary from the system's JDK
# (in this example, Linux)
$ jlink
    --module-path jdk-win/jmods:mods
    --add-modules com.example.main
    --output app-image

要驗證此映像是否特定於 Windows,請檢查app-image/bin ,其中包含 java.exe

優化映像

瞭解如何生成映像後,可以對其優化。 大多數優化會減小映像大小,有些會稍微縮短啓動時間。 查看 jlink 參考,瞭解您可以使用的選項的完整列表。 無論您應用什麼選項,都不要忘記徹底測試生成的映像,並在實際中衡量改進。

燒哥總結

(驗證中,代碼庫持續更新)

正常module 自動module unamed module
描述 模塊化jar放在module path 普通jar放在module path 所有jar放在class path(不論是否module)
module name 正常 根據MANIFEST/自動生成 null
可讀性 不能讀取unamed module 能讀取所有module 能讀取所有module
--add-reads this=ALL-UNNAMED 我能讀誰 this能讀取所有module
--add-exports this=ALL-UNNAMED 誰能讀我 unamed module能讀取this
--add-opens this=xxx 誰能反射我 xxx能反射訪問我
jlink 正常 依賴它的module無法打包
依賴它的module是否可訪問 public成員 protected/package成員 private成員
默認 私有 私有 私有
export 編譯時、運行時可訪問 私有 私有
open 運行時可反射 運行時可反射 運行時可反射
unamed module 全部export/open
自動module 全部export/open

requires static 不進入module圖,運行時不一定可用,除非--add-modules

requires transitive 使用傳遞,簡化了上層module聲明,如果有脱離這個API的越級調用,最好顯式requires

export ... to 的目標module,編譯時可以不存在

provides ... with的具體實現,運行時必須存在

--add-reads ...=ALL-UNNAMED 儘量別用,打破了module設計初衷,所有被依賴的都應該在module path上

自動module讓大部分現有jar成為module提供了便利,只需要注意名稱

自動module只需要聲明requires一個,其他所有都會全部加載,但可能缺少依賴項,因為沒法聲明

自動module之間都是隱式讀取,不需要顯式聲明依賴

自動module的依賴項可以在class path上,可以是unamed module

unamed module中的包,會被命名module中的包覆蓋

unamed module中的包做初始module的話,module path上都不會生效,除非--add-modules

獨立應用程序映像,運行java可以附加-m,但無法覆蓋映像裏面的包

jlik默認不包含service的實現,可以打包時用--add-modules添加

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.