理解 Java 9 的模組

模組是什麼及什麼時候要使用它們

Du Spirit
Java Magazine 翻譯系列
20 min readOct 9, 2017

--

Translated from “Understanding Java 9 Modules” By Paul Deitel, Java Magazine September/October 2017, page 18–32. Copyright Oracle Corporation.

本文中,我將介紹 Java 9 Platform Module System (JPMS) [譯註:這專案名稱就不翻譯了,翻成中文也沒什麼意思],自 Java 出現以來,最重要的新軟體工程技術。模組化 (Modularity) — Jigsaw 專案的延續 — 全面性幫助開發者在建置、維護及發展軟體系統更有效率,特別是開發大型系統時。

什麼是模組?

模組化在套件 (package) 之上提供更高層次的聚合,關鍵的新語言元素是模組 (module),具有獨一無二的名稱,可重複使用的相關套件組合,還有資源 (圖片與 XML 檔案) 及一個模組描述器說明

  • 模組的名稱
  • 模組的相依性 (即這模組相依的其他模組)
  • 明確可以讓其他模組使用的套件 (隱含著模組中其它套件是無法讓其他模組使用)
  • 提供的服務
  • 使用的服務
  • 允許什麼模組使用 reflection

歷史

Java SE 平台從 1995 年到現在,有近千萬開發者用它建置任何事,從資源有限的設備 — 像是物聯網 (IoT)或嵌入式裝置 — 的小應用程式,到大型關鍵商業系統及緊急任務系統。大量的遺留程式碼在那邊,到目前為止,Java 平台主要是單一一體式的解決方案,多年來,有許多努力試著模組化 Java,但沒有一個被廣泛使用,也無法用來模組化 Java 平台。

模組化 Java SE 平台的實現一直是具挑戰性的,且已經花了很多年的努力,最初在 2005 年針對 Java 7 提出的 JSR 277: Java Module System,之後被 JSR 376: Java Platform Module System 取代,作為 Java 8 的目標,Java SE 平台現在在 Java 9 已模組化,但僅在 Java 9 延遲到 2017 年九月之後 [譯註:JSR 376 曾在 JCP 投票中被否決]。

目標

根據 JSR 376,模組化 Java SE 平台的關鍵目標有

  • 可靠的配置 — 模組提供機制明確宣告模組間的相依關係,能在編譯期與執行期加以識別,系統能通過這相依關係確保所有模組的子集合能滿足程式的需求。
  • 嚴謹的封裝 — 模組中的套件只有在模組明確匯出它們時才能被其他模組存取,即使如此,其他的模組在沒聲明它需要其他模組的功能前亦不能使用那些套件。這改善平台的安全性,因為潛在的功能只能存存取更少的類別,您會發現模組化的思考可以幫助您做出更簡潔、更符合邏輯的設計。
  • 可擴展的 Java 平台 — 在之前,Java 平台是一整個龐然大物,包含大量的套件,這使其難以開發、維護和發展,不容易取其子集合。現在,平台被模組化成為 95 個模組 (這數字可能隨著 Java 發展變化),您可以建立客製的執行環境,只包含您的應用程式或是您目標裝置所需的模組。例如,如果一個裝置不支援 GUI,您可以建立一個不含 GUI 模組的執行環境,大幅減少執行環境的大小 [譯註:在將 JRE 一起打包的情境中特別有用]。
  • 更佳的平台完整性 — 在 Java 9 之前,使用平台中許多不預期被應用程式使用的類別是有可能的,在嚴謹的封裝下,這些內部 API 會被真正地封裝並讓使用平台的應用程式看不見。如果您的程式依賴這些內部 API,這可能會對轉移遺留程式碼到以模組化的 Java 9 造成問題。
  • 改善的性能 — JVM 使用各式優化技術改善應用程式的性能,JSR 376 指出,當事先知道某技術僅在特定模組中被使用,這些技術會更有效。
Table 1. Java Modularity JEPs and JSRs

列出 JDK 的模組

Java 9 的一個重要觀念是將 JDK 切割成模組以支持各式配置 (參閱 JEP 200: The Modular JDK,與 Java 模組化有關的 JEP 和 JSR 都在 Table 1 中)。使用 JDK bin 目錄中的 java 指令搭配 --list-modules 選項,如

java --list-modules

列出 JDK 的模組集合,其中包含實現 Java 語言 SE 規範的標準模組(名稱以 java 開頭)、JavaFX 模組 (名稱以 javafx 開頭)、JDK 特定的模組 (名稱以 jdk 開頭) 及 Oracle 特定的模組 (名稱以 oracle 開頭)。每個模組名稱都接著一個版本字串 — @9表示這模組屬於 Java 9。

模組宣告

如我們提到,一個模組必須提供一個模組描述器 — 描述模組相依性、該模組能讓其他模組使用的套件等中介資料。模組描述器是一個經過編譯的模組宣告,定義於 module-info.java 檔案中,每個模組宣告從關鍵字 module 開始,緊接著一個獨一無二的模組名稱,以及括在大括弧內的模組內容:

module modulename {}

模組宣告的主體可以是空的,或是包含各種模組指令 (directive),像是 requiresexportsprovides…withusesopens (稍後會討論到每一個),如您稍後會見到,編譯模組宣告會建立模組描述器,存放在模組根目錄的 module-info.class 檔案中,這裡將簡單介紹每個模組指令,之後,我們將呈現實際的模組宣告。

我們稍後介紹的關鍵字 exportsmoduleopenopensprovidesrequiresuseswith以及 totransitive都是限制關鍵字,只能用在模組宣告中,仍可在您的程式中任何地方作為變數名稱。

requiresrequire 模組指令指定該模組依賴其他模組 — 這關係稱作模組相依性,每個模組必須明確聲明它的相依性,當模組 A 需要模組 B,模組 A 稱為讀取模組 B,模組 B 則是給模組 A 讀取。要指定與另一個模組的相依性,使用 requires,如下:

requires modulename;

亦有一個 requires static 指令表示在編譯期間需要一個模組,但在執行期間是非必需的,這是所謂的可選相依性,不在本介紹中討論。

requires transitive — 隱含的可讀性,指定相依另一個模組,並確保其他模組讀取您的模組時亦能讀取這相依模組,所謂的隱含的可讀性,如下使用 requires transitive

requires transitive modulename;

思考 java.desktop 模組宣告中的下列指令:

requires transitive java.xml;

在這情況下,任何讀取 java.desktop 的模組也隱含地能讀取 java.xml。例如:一個 java.desktop 模組中的函式還傳一個 java.xml 模組中的型別,那讀取 java.desktop 模組的程式變成依賴 java.xml。若 java.desktop 的模組宣告中沒有 requires transitive 指令,相依的模組則無法編直到牠們明確讀取 java.xml

根據 JSR 379,Java SE 的標準模組必須在任何情況下給予隱含可讀性,如前所述般,另外,即便一個 Java SE 標準模組可能依賴一個非標準模組,它不該給予隱含可讀性,這確保程式只依賴 Java SE 標準模組,可以執行於任何 Java SE 的實作版本中 [譯註:OpenJDK 或是 IBM 的實作版本]。

exportsexports…toexports 指令指定模組中的某套件的 public 型別 (及其內部的 publicprotected 型別) 可以被所有其他模組存取,而exports…to 指令能讓您以逗號分隔的列表精確地指定哪些模組能存取匯出的套件 — 所謂有限制的匯出。

usesuses 模組指令指定該模組使用的服務 — 讓此模組成為一個服務使用者。服務是一個實作或繼承 uses 指令指定的介面或抽象類別的物件。

provides…withprovides…with 模組指令指定模組提供服務的實作 — 讓該模組成為一個服務提供者。指令中 provides 的部分指定列在 uses 指令中的介面或抽象類別,with 的部分指定實作介面或繼承抽象類別以提供服務的類別名稱。

openopensopens…to。在 Java 9 之前,reflection 可以用來得知套件中所有的型別以及一個型別中所有的成員,即便是它私有的成員,不論您是否允許此能力,因此,沒有什麼是真正被封裝的。

模組系統的關鍵驅動力是可靠的封裝,預設情況下,一個模組中的型別是無法讓其他模組存取的,除非它是一個公開型別且您匯出他所屬的套件,您只需公開您希望公開的套件,在 Java 9 中,這同樣適用於 reflection。

只允許在執行期間能存取特定套件,open 模組指令的形式:

opens package

表示特定套件的 public 型別 (及它內部的 publicprotected 型別) 只能在執行期間讓其他模組的程式存取。同樣,特定套件中的所有型別 (及其成員) 可以透過 reflection 存取。

只允許在執行期間讓指定模組能存取特定套件opens…to 指令的形式:

opens package to comma-separated-list-of-modules

表示特定套件的 public 型別 (及它內部的 publicprotected 型別) 只能在執行期間讓在條列中模組的程式所存取。同樣,指定的模組可以透過 reflection 存取特定套件中的所有型別 (及其成員) 。

只允許在執行期間能存取模組中所有套件,如果某模組中的所有套件能在執行期間或透過 reflection 讓其他模組存取,您可以公開整個模組:

open module modulename {
// module directives
}

Reflection 預設行為

預設情況下,一個模組有在執行期間存取一個套件的能力時,能看到該套件中 public 型別 (及其內部的 publicprotected 型別)。然而,其他模組可以存取公開套件中的所有型別,包含透過setAccessible 存取私有成員,和先的 Java 版本相同,更多關於 setAccessible 和 reflection 的資訊請參考 Oracle’s 文件

關係注入,reflection 常用在注入關係,一個這類的例子是基於 FXML 的 JavaFX 應用程式 [編按:關於更多使用 FXML 的資訊請參閱本期 Define Custom Behavior in FXML with FXMLLoader],當一個 FXML 應用程式載入時,相依的 controller 物件與 GUI 元件以下列順序動態建立:

  • 首先,由於應用程式依賴一個 controller 物件處理 GUI 互動,FXMLLoader 注入 controller 物件到執行中的應用程式 — FXMLLoader 利用 reflection 取得並載入 controller 的類別到記憶體中,然後建立該類別的物件實體。
  • 接著,由於 controller 依賴宣告於 FXML 中的 GUI 元件,FXMLLoader 建立宣告於 FXML 中的 GUI 元件,然後將它們注入到 controller 物件中,指派給有對應 @FXML 的成員變數,此外,用 @FXML 宣告的 controller 的事件處理程序會綁定到宣告於 FXML 的元件上。

當這步驟完成,controller 可以和 GUI 互動並處理其事件,我們用 opens…to 指令允許 FXMLLoader 在 JavaFX 應用程式中透過 reflection 使用自訂模組。

模組化 Welcome 應用程式

這一節中,我們建立一個簡單的 Welcome 應用程式說明模組的基礎,我們將

  • 建立一個位於模組中的類別
  • 提供一個模組宣告
  • 編譯模組宣告及 Welcome 類別成為一個模組
  • 執行在模組中有 main 函式的類別

在涵蓋到這些基礎後,我們同樣示範

  • 將 Welcome 應用程式打包成一個模組化的 JAR 檔案
  • 從 JAR 檔案啟動應用程式

Welcome 應用程式的結構。在這一節呈現的應用程式有二個 .java 檔案:包含 Welcome 應用程式主類別的 Welcome.java,及包含模組宣告的 module-info.java。照慣例,一個模組化的應用程式有以下目錄結構:

AppFolder
src
ModuleNameFolder
PackageFolders
JavaSourceCodeFiles
module-info.java

我們的應用程式將會放在 com.deitel.welcome 套件中,目錄結構如 Figure 1 所示。

Figure 1. Folder structure for the Welcome app

src 目錄有應用程式所有原始碼,它包含模組的根目錄,名稱同模組名稱 com.deitel.welcome (稍後討論模組命名),模組根目錄包含巢狀的目錄呈現套件的層次結構 com/deitel/welcome以對應套件com.deitel.welcome。最底層目錄包含 Welcome.java,根目錄包含必要的模組宣告module-info.java

模組命名慣例,如同套件名稱,模組名稱必須是唯一的,為確保獨一無二的套件名稱,您通常以您組織網域名稱的反轉作為前綴,我們的網域名稱是 deitel.com,所以我們的套件名稱以 com.deitel 開始,比照慣例,模組名稱一樣用網域名稱反轉的慣例。

編譯時,如果有多個模組有相同的名稱會出現編譯錯誤,執行期間,若有多個模組有相同名稱則會拋出例外。

這例子中模組名稱和其所屬的套件名稱使用相同的名稱,因為這模組只有一個套件,這不是必需的,但是一個常見慣例,在一個模組化的應用程式中,Java 將模組的名稱、套件名稱及套件中的型別名稱分開維護,所以允許模組名稱和套件名稱是相同的。

模組通常組合相關的套件,因此,這些套件的名稱時常有相同的部分,例如,有個模組包含這些套件:

com.deitel.sample.firstpackage;
com.deitel.sample.secondpackage;
com.deitel.sample.thirdpackage;

您通常會用套件共同的部分作為模組的名稱 — com.deitel.sample。如果沒有共同的部分,您可以選擇一個代表這模組用途的名稱,例如,java.base 模組包含對 Java 程式來說是基礎的核心套件 (譬如 java.langjava.iojava.timejava.util), java.sql模組包含透過 JDBC 與資料庫互動的套件 (像是 java.sqljavax.sql)。這只是眾多標準模組的其中兩個,每個模組 (例如 java.base) 的線上文件提供模組公開的套件列表。

Welcome 類別。下列程式碼是一個 Welcome 應用程式,簡單顯示一個字串在命令列上,當定義一個要被放在模組中的型別,每個型別必須被放在一個套件中 (如程式中的第三行):

模組宣告。下列程式碼是 module-info.java 檔案中針對 com.deitel.welcome 模組的宣告。

再次,模組宣告由關鍵字 module 開始,緊接著模組的名稱以及由大括弧括住的本體,這模組宣告包含一個 requires 模組指令,表示這應用程式相依 java.base 模組中的型別,實際上,所有模組都相依 java.base,所以這模組指令是隱含在所有模組宣告中,可以省略,如:

編譯一個模組。要編譯 Welcome 應用程式模組,開啟命令視窗,用 cd 指令進到 WelcomeApp 目錄,然後輸入:

javac -d mods/com.deitel.welcome ^
src/com.deitel.welcome/module-info.java ^
src/com.deitel.welcome/com/deitel/welcome/Welcome.java

[注意:^符號是 Microsoft Windows 行接續字元,上述指令可以輸入成單獨一行沒有任何行接續字元,Linux 和 macOS 的使用者,當要把指令拆成數行,可以把 ^ 替換成反斜線 \]。-d 選項指示 javac 把編譯後的程式碼放到指定的目錄,這例子中,一個 mods 目錄包含名為 com.deitel.welcome 的子目錄,表示編譯的模組,mods 是放模組的目錄的慣例命名。

編譯後 Welcome 程式的目錄結構。若原始碼正確地編譯過,WelcomeApp 目錄中 mods 的子目錄結構應包含編譯過後的程式 (參見 Figure 2),這是所謂已展開模組 (exploded-module) 目錄,因為這資料夾及 .class 檔案不在 JAR 檔中。已展開模組結構與上述應用程式的 src 目錄相似,很快我們將會把應用程式打包成一個 JAR 檔。已展開模組目錄或模組化的 JAR 檔稱為模組成品,當編譯或執行模組化的程式時,可以被在模組路徑 (一串模組成品的位置) 中。

Figure 2. Welcome app’s mods folder structure

檢視模組描述器。您可以使用 java 指令搭配 --describe-module 選項顯示com.deitel.welcome 的模組,在 WelcomeApp 目錄輸入下列指令:

java --module-path mods --describe-module com.deitel.welcome

輸出結果是:

com.deitel.welcome pathContainingModsFolder/mods/com.deitel.welcome/
requires java.base
contains com.deitel.welcome

輸出結果從模組的名稱和位置開始,剩下的部分顯示這模組需要標準模組 java.base 及包含一個套件 com.deitel.welcome,雖然這模組包含此套件,但沒有被匯出,因此,它的內容無法被其他模組使用,這例子中,模組宣告明確要求 java.base,所以前面的輸出包含

requires java.base

如果模組宣告中是以隱含的方式要求 java.base,則列表會換成

requires java.base mandated

並沒有 mandated 模組指令,它出現在 --describe-module 的輸出中,單純指示所有的模組都相依於 java.base

從模組的已展開目錄啟動程式。要從模組的已展開目錄啟動 Welcome 應用程式,在 WelcomeApp 目錄中輸入以下指令 (同樣,macOS/Linux 的使用者要將 ^ 換成 \):

java --module-path mods ^
--module com.deitel.welcome/com.deitel.welcome.Welcome

--module-path 選項指定模組的位置,在這例子中是 mods 目錄,--module選項指定模組的名稱以及應用程式進入點 (包含 main 的類別) 完整的類別名稱,該程式執行後應顯示

Welcome to the Java Platform Module System!

在前述的指令中,--module-path 可縮寫成 -p,而 -module 可縮寫成 -m。

將模組打包成一個模組化的 JAR 檔。您可以用 jar 指令將已展開的模組目錄打包成一個模組化的 JAR 檔包含模組的所有檔案,module-info.class 包含在內,放在 JAR 裡的根目錄。當啟動應用程式時,您在模組路徑中指定 JAR 檔,您希望放置輸出 JAR 檔的目錄須在執行 jar 指令前就存在。

如果一個模組包含應用程式地進入點,您可以用 jar 指令的 --main-class 選項指定該類別,例如:

jar --create -f jars/com.deitel.welcome.jar ^
--main-class com.deitel.welcome.Welcome ^
-C mods/com.deitel.welcome .

選項解釋如下:

  • --create 指示指令應該建立一個新的 JAR 檔
  • -f 指定 JAR 檔的名稱並緊接著著模組名稱,此例中,會在 jars 目錄中建立 com.deitel.welcome.jar 檔案。
  • --main-class 指定程式進入點 (包含 main 函式的類別) 的完整名稱。
  • -C 指定包含將被打包進 JAR 中的檔案的目錄,緊接著是要被包進去的檔案,那一點 (.) 是指所有目錄中的檔案都要被包進去。

從模組化的 JAR 檔啟動 Welcome 應用程式。當您將一個應用程式放在一個模組化並已經指定進入點的 JAR 檔中,您可以如下的方式啟動程式:

java --module-path jars com.deitel.welcome

或是以縮寫的形式:

java -p jars -m com.deitel.welcome

如果您建立 JAR 檔時未指定進入點,您仍可透過指定模組的名稱及類別的全命來啟動應用程式:

java — module-path jars ^
com.deitel.welcome/com.deitel.welcome.Welcome

或是

java -p jars -m com.deitel.welcome/com.deitel.welcome.Welcome

類別路徑 (classpath) vs. 模組路徑 (module path)。在 Java 9 之前,編譯器與執行環境透過類別路徑 (含有編譯後的 Java 類別的目錄或 JAR 檔的列表) 尋找型別,在早期的 Java 版本中,類別路徑的定義是由 CLASSPATH 環境變數、JRE 某個特殊目錄中的擴充,以及 javacjava 指令選項提供的資訊組成。

由於型別可能從不同的位置載入,那些路徑的搜尋順序造成脆弱的應用程式,例如:幾年前,我在我系統上安裝一個第三方廠商的應用程式,這應用程式的安裝管理員在 JRE 的擴充路徑中放了一個舊版的第三方 Java 函式庫,我系統上的幾個其他 Java 程式依賴該函式庫較新的版本,其中有額外的型別和該函式庫舊型別的改良版本,由於 JRE 擴充目錄中的類別會比在類別目錄中的類別早載入,相依於新版函式庫的應用程式停止運作,在執行期因 NoClassDefFoundErrorsNoSuchMethodErrors而失敗,或是在應用程式已經執行很久後停止運作 (更多關於類別載入的資訊請參閱 Understanding Extension Class Loading)。

模組與模組描述器提供的可靠配置有助於改善許多執行期間類別路徑引起的問題,每個模組明確聲明自己的相依關係,並在啟動時解決。模組路徑中每個模組只會有一個,每個套件只能被定義在一個模組中,如果有兩個或多個模組有相同的名稱或匯出相同的套件,執行環境在啟動程式前立即中止。

learn more

Project Jigsaw: Module System Quick-Start Guide
Paul Deitel’s live online course Introduction to Modularity with the Java 9 Platform Module System (JPMS), available at no additional charge to SafariBooksOnline.com subscribers

Java 9 Modularity: Patterns and Practices for Developing Maintainable Applications by Sander Mak and Paul Bakker (O’Reilly Media, 2017)

譯者的告白

這一篇很長,不過看完很開心,當初看到 JCP 針對 JPMS 投票沒過時我有點意外,畢竟它解決我長久寫 Java 程式時一個苦惱的問題:我不想讓其他程式使用我內部的實作類別。確實,就相依性管理上,Maven 已經做得很出色了,JPMS 加入的新方式,讓很多既有的建置工具 (Maven 或 Gradle 都要) 和程式 (Eclipse 要額外安裝 Java 9 Support) 都需要搭配的調整 (還好在執行既有舊程式上,Java 9 有做向下相容的處理),但我不覺得這是該讓 Java 止步不前的理由,Java 是個不錯的語言,希望之後能導入更多語言特性,讓開發更具生產力。

--

--