Gradle で Java モジュールを導入する場合の問題と対応方法


はじめに

Gradle でモジュールシステムを扱う場合、特にモジュールシステムに未対応な依存JARを扱うのは色々と注意しなければならないことが多い。

ここでは最初に、モジュールシステム自体について概説した後、Gradle でのモジュールの扱いの実例を見ていく。

最後に、Gradle で発生するモジュールシステムにまつわる問題と、その対応方法を紹介する。


Java Platform Module System (JPMS) の振り返り

クラスパスとモジュールパス

  • クラスパス
    • コンパイル時のクラス検索場所を --class-path,-classpathまたは-cp で指定
    • 旧来互換
  • モジュールパス
    • コンパイル時のモジュール検索場所を --module-path, -p で指定

どちらのパスで読み込んだかにより、モジュールシステム上での扱いが変わる(後述)。

モジュラーJAR

  • モジュラーJAR:モジュール記述子 module-info.class のあるJAR
  • 非モジュラーJAR:モジュール記述子 module-info.class のないJAR(旧来通り)

モジュール

JARは、クラスパスに置かれるか、モジュールパスに置かれるかで、扱いが変わる。

  • 明示モジュール(Explicit Module)
    • モジュールパスに置かれたモジュール記述子のあるJARで定義されたモジュール
  • 自動モジュール(Automatic Module)
    • モジュールパスに置かれたモジュール記述子の無いJAR
    • MANIFEST.MFAutomatic-Module-Name 属性が定義されていれば、その名前がモジュール名となる
    • 定義が無い場合は JAR ファイル名から自動的にモジュール名が設定(後述)される
  • 無名モジュール(Unnamed Module)
    • クラスパスに置かれたJAR(モジュール記述子の有無を問わない)

モジュールパスに置かれたモジュール記述子のあるJARは Named Module と呼ばれることも多いがModuleFinder.html#of(java.nio.file.Path...)) の記載に合わせて、ここでは明示モジュール(Explicit Module)と呼ぶことにする

JARの種類と配備場所の関係をまとめると以下のようになる。

クラスパスへ配備 モジュールパスへ配備
モジュラーJAR 無名モジュール 明示モジュール
非モジュラーJAR(Automatic-Module-Name) 無名モジュール 自動モジュール(Automatic-Module-Name で定義されたモジュール名)
非モジュラーJAR 無名モジュール 自動モジュール(JARファイル名から自動設定されたモジュール名)

クラスパスへ配備した場合は、全て無名モジュールの扱いとなり、これはモジュールシステム以前の旧来互換となる。

自動モジュールのモジュール名

モジュールパスに置かれた非モジュラーJARにAutomatic-Module-Name 属性が定義されていれば、その名前がモジュール名となる。

この定義が無い場合は、JARファイル名からモジュール名が自動的に決定される。 モジュール名自動決定のロジックは ModuleFinder.of(Path...) で定義されており、以下のルールとなる。

  • .jar のサフィックスを削除
  • 正規表現 -(\\d+(\\.|$)) にマッチする場合、モジュール名は最初に出現したハイフンより前の部分となり、ハイフンの後の部分はバージョンと解釈される
  • 英数字以外の文字([^A-Za-z0-9])はすべて. に置き換えられる(連続したドットは統合され、先頭と末尾のドットは削除される)

つまり foo-bar-1.2.3-SNAPSHOT.jarという名前のJARファイルは、foo.bar というモジュール名となり、1.2.3-SNAPSHOT がバージョンとして解釈される。

明示/自動/無名モジュールの参照範囲

モジュールが参照(依存)する他のモジュールと、モジュールが他のモジュールに公開するパッケージは module-info.java にて定義する(コンパイルされ module-info.class としてJARのルートに配備される)。

module-info.class が無いJARをモジュールパスに配備した場合、自動モジュールとなることは前述の通りだが、この時、以下のような module-info.java が暗黙的に追加されたように振る舞う(この例はあくまでもイメージ)。

opens module <moduleName> {
    requires *;
    exports *;
}
  • 全てのパッケージが exports 扱い
  • モジュールグラフ上のすべてのモジュールを requires している扱い

クラスパスへ配備され、無名モジュールとなっているJARについても同様に、全てのパッケージが exports 扱いで、すべてのモジュールを requires している扱いとなるが、明示モジュール(Explicit Module) から無名モジュール(Unnamed Module)を参照することはできない。

明示モジュール → 自動モジュールの参照は可能なので、どうしても必要な場合は、自動モジュールをブリッジする形で無名モジュールを参照できる。


Gradleにおけるモジュールシステムの扱い

JPMS は後発で、Gradle 内部でのモジュールパスの扱いは場当たり的な対応になっている。

Gradle 8.5 時点でも、APIを介してモジュールパスの操作は行えず、java.modularity.inferModulePath で、モジュールパス推測を行うかどうかを設定できるのみである(拡張予定はあるものの)。

java.modularity.inferModulePath は Gradle 7.0 からデフォルト true となり、依存ライブラリをクラスパスに配備するかモジュールパスに配備するかを JavaModuleDetector で判定する。

結果、モジュール記述子が定義されている、または Automatic-Module-Name 属性があれば、モジュールパスへの配備となり、それ以外はクラスパスに配備される。この挙動の決め打ちが問題となる。

モジュラーアプリケーションを作成する場合、module-info.javarequires なモジュールを指定する。非モジュラーJARでも、モジュールパスに配備されれば、JAR名がモジュール名となるため参照できるが、Gradle ではクラスパスに配備するため(名前が定義されないため)モジュール名を指定することができない。

このケースにおいて、例えば IntellJ ではモジュールパスに配備されたものとして扱うため、IDEではコンパイルできるがGradleではコンパイルエラーとなる、といった状況が発生する。


Gradleのモジュールの扱い具体例

具体例でGradleでのモジュールの扱いをまとめておく。

アプリケーションが依存するJARとして以下を考える。

ライブラリ 説明
jakarta.annotation:jakarta.annotation-api:2.1.1 モジュラーJAR
org.apache.commons:commons-lang3:3.14.0 非モジュラーJAR(Automatic-Module-Name エントリあり)
org.apache.commons:commons-math3:3.6.1 非モジュラーJAR

アプリケーション側(自身で作成しているアプリケーション)で、module-info.java を定義しない場合は、今まで通りに、全てのJARはクラスパスに配備される。 これは、MANIFEST.MFAutomatic-Module-Name を定義した場合でも同じで、モジュラーアプリケーションとは解釈されない(resources/META-INF/MANIFEST.MF に定義しても、JARタスク無いで manifest { attributes["Automatic-Module-Name"] = "xxx" } のようにしても同じで全てのJARはクラスパスに配意される)。


アプリケーション側で module-info.java を定義した場合、以下のようにコンパイル時引数が指定されることになる。

-classpath .../commons-math3-3.6.1.jar
--module-path .../jakarta.annotation-api-2.1.1.jar;.../commons-lang3-3.14.0.jar

commons-math3 は無名モジュールとなるので、以下はコンパイルエラーとなり、commons-math3 を使うことができない。

module foo {
    requires jakarta.annotation;
    requires org.apache.commons.lang3;
    requires commons.math3; // error
    exports com.example.hello;
}

なお、java.modularity.inferModulePath.set(false) とすれば、ライブラリは全て -classpath への配備となる。 モジュラーアプリケーション側からは無名モジュールを参照できないので、この場合もコンパイルすることはできない。


モジュールパスの個別指定による対応

コンパイル時の引数として直接モジュールパスを設定することで、前述の問題に対応できる。

tasks.withType<JavaCompile> {  
    options.compilerArgs.addAll(listOf("--module-path", classpath.asPath))  
    classpath = files()  // clear class path
}

これによりクラスパスは利用せず、依存ライブラリは全てモジュールパスに配備される。

--module-path .../jakarta.annotation-api-2.1.1.jar;.../commons-lang3-3.14.0.jar;.../commons-math3-3.6.1.jar
--module-path .../jakarta.annotation-api-2.1.1.jar;.../commons-lang3-3.14.0.jar;.../commons-math3-3.6.1.jar 

--module-path が2重定義されるのは、Gradleの従来バグによるもの。特に動作に影響はない。


プラグインによる対応

org.javamodularity.moduleplugin プラグインにて対応することもできる。

plugins {
    ...
    id("org.javamodularity.moduleplugin") version "1.8.12"
}  

java.modularity.inferModulePath.set(false)

inferModulePathfalse に設定しておく。

これにより、依存は全て --module-path に配備されるようになる(--module-path が2重定義されるのは、前述と同)。

--module-path .../jakarta.annotation-api-2.1.1.jar;.../commons-lang3-3.14.0.jar;.../commons-math3-3.6.1.jar
--module-path .../jakarta.annotation-api-2.1.1.jar;.../commons-lang3-3.14.0.jar;.../commons-math3-3.6.1.jar 

org.javamodularity.moduleplugin プラグインでは、テスト時のモジュールパスによる対応もサポートされるため、一先ずorg.javamodularity.moduleplugin プラグインを適用しておくのが楽。


アーティファクト変換による対応

非モジュラーJARに無理やり module-info.class を追加することでモジュラーJARに変換するのが org.gradlex:extra-java-module-info プラグインであり、これにより対応することもできる。

plugins {
    ...
    id("org.gradlex.extra-java-module-info") version "1.6.1"
}

extraJavaModuleInfo {
    module("org.apache.commons:commons-math3", "commons.math3") {  
        exports("org.apache.commons.math3.util")  
        requires("java.logging")  
    }  
}

extraJavaModuleInfo ブロックの定義により、モジュール記述子がJARに追加される。上記の例では、以下の module-info.class が自動的に追加される。

open module commons.math3 {  
    requires java.base;  
    requires java.logging;  
  
    exports org.apache.commons.math3.util;  
}

これによりモジュールパスは以下のように配備される。

--module-path .../jakarta.annotation-api-2.1.1.jar;.../commons-lang3-3.14.0.jar;.../commons-math3-3.6.1-module.jar

commons-math3-3.6.1-module.jar がモジュール記述子追加済みのJARとなる。

モジュールシステム上で考えると理にかなった対応となるが、多くの非モジュラーJARに依存する場合は、それぞれについてモジュール記述子を定義する必要があるため手軽さという点で劣る。

さらに署名付きJARの場合は、改ざんとみなされるのでこのプラグインによる対応は採用できないといった問題もある。