Help us understand the problem. What is going on with this article?

Gradle で Jigsaw メモ

More than 1 year has passed since last update.

Gradle で Java 9 から追加された Jigsaw (モジュール)を使う方法のメモ。

環境

OS

Windows 10

Java

java 10.0.1 (Oracle JDK)

Gradle

4.7

Jigsaw のサポート状況

While Gradle version 4.7 doesn’t have first-class support for Java 9 modules yet, this guide shows you how to experiment with them before that support is complete.

【訳】
Gradle の version 4.7 では、 Java 9 のモジュールについてのファーストクラスでのサポートはまだありません
このガイドでは、サポートが完了するまでの間にどのようにしたらモジュールを試せるのかについて説明します。

Building Java 9 Modules

ということで、 2018 年 6 月現在(version 4.7)、 Gradle は Jigsaw のファーストクラスでのサポートはまだ対応していない。

ただし、これはコンパイルができないわけではない。
従来のタスクのオプションをややハック気味にいじることによって、 Jigsaw に対応したプロジェクトをビルドすることはできるようになっている。

Jigsaw に特化したオプションなどは存在しないということで、ファーストクラスでのサポートはないという状況。

いつ完全にサポートされるようになる?

この「ファーストクラスでのサポートはまだない」という説明は、少なくとも Java 9 が出た当初から書かれているのを見ている。

Java 10 が出た今もまだ対応しておらず、ちょっと気になったので調べたら、 GitHub に次のような Issue があるのを見つけた。

[Epic] Java 9 Jigsaw support · Issue #890 · gradle/gradle

2016 年の 11 月に作られた Issue で、 Milestone が Unscheduled になっている。。。
2018 年になってからの書き込みもあるが、みんな Jigsaw のサポートがいつになるのか気になっているようだ。。。

まずは普通のプロジェクトをモジュール化する

モジュール化前

プロジェクト構成
|-build.gradle
`-src/main/java/
  `-sample/jigsaw/
    `-Main.java
build.gradle
apply plugin: 'java'

sourceCompatibility = 10
targetCompatibility = 10

compileJava.options.encoding = 'UTF-8'

jar.baseName = "jigsaw-sample"
Main.java
package sample.jigsaw;

public class Main {

    public static void main(String[] args) {
        System.out.println("Hello Jigsaw!!");
    }
}
ビルドして実行
> gradle jar

> java -cp build\libs\jigsaw-sample.jar sample.jigsaw.Main
Hello Jigsaw!!

モジュール化後

モジュール化するといっても、 module-info.java をルートに置くだけ。

プロジェクト構成
|-build.gradle
`-src/main/java/
  |-module-info.java <<NEW>>
  `-sample/jigsaw/
    `-Main.java
module-info.java
module sample.jigsaw {
}
ビルドして実行
> gradle jar

> java -p build\libs\jigsaw-sample.jar -m sample.jigsaw/sample.jigsaw.Main
Hello Jigsaw!!
  • クラスパス (-cp) ではなくモジュールパス (-p, --module-path) で jar を読み込む
  • メインクラスの指定は -m (--module) で指定する
    • 指定方法は <モジュール名>/<メインクラス名> になる

あんまりモジュール化した感はない。

実行可能 jar のモジュール版みたいなことはできないっぽい?

build.gradle
jar {
    manifest {
        attributes("Main-Class": "sample.jigsaw.Main")
    }
}
  • jar ファイル内の MANIFEST.MFMain-Class を指定しておくと、 -jar オプションで jar ファイルを指定するだけでメインクラスが呼ばれるようになる
> java -jar build\libs\jigsaw-sample.jar
  • モジュールを使用した場合も同様のことができるのか軽く調べたが、 jar コマンドで指定する --main-class が該当するっぽい気がする
    • 完全には一致していないが、一番近い気はする
  • jar コマンドで jar ファイルを生成するときに、 --main-class でメインクラスの FQCN を指定しておく
--main-classを指定してjarを作成する
> gradle compileJava

> jar -c -f build\jigsaw-sample.jar --main-class sample.jigsaw.Main  -C build\classes\java\main .

> java -p build\jigsaw-sample.jar -m sample.jigsaw
Hello Jigsaw!!
  • --main-class を指定して作成した jar ファイルは、 -m でモジュール名を指定するだけでメインクラスが呼ばれるようになる
  • どのクラスがメインクラスかは、 module-info.class に書き込まれる
module-info.classの中をjavapで確認
> javap -v module-info.class
...

ModuleMainClass: #8                     // sample.jigsaw.Main
ModulePackages:
  #11                                     // sample.jigsaw
  • ModuleMainClass とかいうのが追加されているのが確認できる
  • これを Gradle のビルドの際に指定できるのか試したが、結果としてはダメそうな感じがした
  • Gradle の jar タスクのクラスを見てみると zip を作成するタスクを継承して作られているのがわかる
  • つまり、 jar コマンドを呼び出しているとかいうわけではなく、自力で jar 構造のアーカイブを作っているっぽい気がする
    • おそらくなので、間違ってたらすみません
  • jar コマンドを呼んでいないとなると、 --main-class オプションを挟む余地は現状ないということになる

依存ライブラリのあるプロジェクトをモジュール化する

モジュール化前

プロジェクト構成
|-build.gradle
|-commons-lang3-3.7.jar
`-src/main/java/
  `-sample/jigsaw/
    `-Main.java
build.gradle
apply plugin: 'java'

sourceCompatibility = 10
targetCompatibility = 10

compileJava.options.encoding = 'UTF-8'

jar.baseName = "jigsaw-sample"

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-lang3:3.7'
}
Main.java
package sample.jigsaw;

import org.apache.commons.lang3.RandomStringUtils;

public class Main {

    public static void main(String[] args) {
        System.out.println("Hello Jigsaw!! : " + RandomStringUtils.random(10, "0123456789"));
    }
}
ビルドして実行
> gradle jar

> java -cp build\libs\jigsaw-sample.jar;commons-lang3-3.7.jar sample.jigsaw.Main
Hello Jigsaw!! : 5176080436
  • 依存関係として commons-lang3 を追加したプロジェクト

モジュール化後

プロジェクト構成
|-build.gradle
|-commons-lang3-3.7.jar
`-src/main/java/
  |-module-info.java <<NEW>>
  `-sample/jigsaw/
    `-Main.java
module-info.java
module sample.jigsaw {
    requires org.apache.commons.lang3;
}
build.gradle
apply plugin: 'java'

sourceCompatibility = 10
targetCompatibility = 10

compileJava.options.encoding = 'UTF-8'

jar.baseName = "jigsaw-sample"

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-lang3:3.7'
}

compileJava {
    doFirst {
        options.compilerArgs = [
            "--module-path", classpath.asPath
        ]
        classpath = files()
    }
}
  • Main.java は変更なし
ビルドして実行
> gradle jar

> java -p build\libs\jigsaw-sample.jar;commons-lang3-3.7.jar -m sample.jigsaw/sample.jigsaw.Main
Hello Jigsaw!! : 5579416699

説明

  • build.gradle を何も修正しないと、依存関係をクラスパスにいれてビルドしようとする
  • このため、モジュールが解決できずに次のようなエラーになる
デフォルトのままビルドしたときに発生するエラー
> gradle jar

> Task :compileJava FAILED
...\jigsaw\src\main\java\module-info.java:2: エラー: モジュールが見つかりません: org.apache.commons.lang3
    requires org.apache.commons.lang3;
                               ^
エラー1個

FAILURE: Build failed with an exception.
  • モジュールをビルドするためには、 compileJava のオプションを変更する必要がある
build.gradle
compileJava {
    doFirst {
        options.compilerArgs = [
            "--module-path", classpath.asPath
        ]
        classpath = files()
    }
}
  • compileJavadoFirst ブロックでオプションを変更する
  • options.compileArgs で、クラスパスに入っている依存関係(classpath.asPath)をモジュールパスに設定する(--module-path)
  • そして、逆に classpath には files() をセットすることでクラスパスを空にしておく
  • これでモジュールパスを使ったビルドが行われるようになる

依存ライブラリのモジュール名を調べる

依存ライブラリを使う場合は、 module-info.javarequires 指定で対象のモジュールを読み込む必要がある。

そのためには、依存ライブラリのモジュール名を知る必要がある。

しかし、依存ライブラリのモジュール名は優先度の高い順に次のいずれかに決まるので、調べるのがやや面倒。

  1. module-info.class で定義されているモジュール名
  2. MANIFEST.MFAutomatic-Module-Name で指定されたモジュール名
  3. jar ファイル名から自動決定されるモジュール名

特に 2018 年現在は世のライブラリもモジュール化の過渡期にあるので、ライブラリによっては Automatic-Module-Name の指定が入れられているものもあれば、まったく入っていないものもあったりと様々な状況になっている。

ちなみに、 commons-lang3 は最新版(3.3.7)では Automatic-Module-Name が設定されているので、そこで定義されているモジュール名を指定する必要がある。

jdeps コマンドで調べる

  • Java 8 から JDK に同梱されている jdeps というコマンドを使うと、依存するモジュールと、その名前を調べることができる
プロジェクト構成
|-build.gradle
`-src/main/java/
  `-sample/jigsaw/
    `-Main.java
build.gradle
apply plugin: 'java'

sourceCompatibility = 10
targetCompatibility = 10

compileJava.options.encoding = 'UTF-8'

jar.baseName = "jigsaw-sample"

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-lang3:3.7'
}

task jdeps(type: Exec, dependsOn: compileJava) {
    commandLine(
        "jdeps",
        "--module-path", compileJava.classpath.asPath,
        "-s",
        compileJava.destinationDir
    )
}
実行
> gradle jdeps
...
main -> java.base
main -> org.apache.commons.lang3
org.apache.commons.lang3 -> java.base
org.apache.commons.lang3 -> java.desktop
  • jdeps コマンドを使ってモジュールを調べるタスク (jdeps) を追加する
  • 非モジュールプロジェクトの状態jdeps タスクを実行することで、依存するモジュールを調べられる

IDE の機能を使って調べる

IntelliJ IDEA の場合は、クイックフィックスの機能を利用すれば簡単にモジュール名を補完できる。
(Eclipse は未調査だが、たぶん同等の機能があると信じてる)

module-info.java
module sample.jigsaw {
}
Main.java
package sample.jigsaw;

import org.apache.commons.lang3.RandomStringUtils;

public class Main {

    public static void main(String[] args) {
        System.out.println("Hello Jigsaw!! : " + RandomStringUtils.random(10, "0123456789"));
    }
}
  • module-info.java を追加しただけだと、 commons-lang3 を使用している Main.java 側は、次のようにコンパイルエラーになっている

jigsaw.jpg

  • import 文のエラー部分で Alt + Enter を入力すると、 module-info.java の補完が選択肢として現れる

jigsaw.jpg

  • module-info.java が補完されて、コンパイルエラーが解消される
module-info.java
module sample.jigsaw {
    requires org.apache.commons.lang3;
}

application プラグインでモジュール化

モジュール化前

プロジェクト構成
|-build.gradle
`-src/main/java/
  `-sample/jigsaw/
    `-Main.java
build.gradle
apply plugin: 'application'

sourceCompatibility = 10
targetCompatibility = 10

compileJava.options.encoding = 'UTF-8'

mainClassName = "sample.jigsaw.Main"

jar.baseName = "jigsaw-sample"

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-lang3:3.7'
}
実行する
> gradle run
Hello Jigsaw!! : 5790539280
  • application プラグインを使うと、 run タスクでプログラムを実行することができる
  • メインクラスは mainClassName で指定している

モジュール化後

build.gradle
apply plugin: 'application'

sourceCompatibility = 10
targetCompatibility = 10

compileJava.options.encoding = 'UTF-8'

ext.moduleName = "sample.jigsaw"

mainClassName = "${moduleName}/sample.jigsaw.Main"

jar.baseName = "jigsaw-sample"

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-lang3:3.7'
}

compileJava {
    doFirst {
        options.compilerArgs = [
            "--module-path", classpath.asPath
        ]
        classpath = files()
    }
}

run {
    doFirst {
        jvmArgs = [
            "--module-path", classpath.asPath,
            "--module", mainClassName
        ]
        classpath = files()
    }
}
実行
> gradle run
Hello Jigsaw!! : 9066254426

説明

build.gradle
ext.moduleName = "sample.jigsaw"

mainClassName = "${moduleName}/sample.jigsaw.Main"

compileJava {
    doFirst {
        options.compilerArgs = [
            "--module-path", classpath.asPath
        ]
        classpath = files()
    }
}

run {
    doFirst {
        jvmArgs = [
            "--module-path", classpath.asPath,
            "--module", mainClassName
        ]
        classpath = files()
    }
}
  • compileJava と同じように、 run タスクのオプションを拡張してモジュールに対応させる
  • doFirst の中で jvmArgs を変更する
  • 変更内容は compileJava と同じで、 --module-pathclasspath の代わりに設定する
  • 加えて、メインクラスを指定する --module (-m) オプションを追加する
  • ここには mainClassName の値を設定している
  • mainClassName は、 "${moduleName}"/sample.jigsaw.Main のようにしてモジュール用のメインクラス指定にしている

この方法の問題点

上述の run タスクを書き換える方法は、公式のドキュメント で紹介されている方法になる。

ただ、この方法には1つ大きな問題がある。

Main クラスを次のように修正すると、問題点が分かりやすくなる。

Main.java
package sample.jigsaw;

import org.apache.commons.lang3.StringUtils;

public class Main {

    public static void main(String[] args) {
        System.out.println("args = " + StringUtils.join(args, ","));
    }
}

コマンドライン引数を出力している。

これを run タスクで実行すると、次のようになる。

> gradle run
args = -Dfile.encoding=windows-31j,-Duser.country=JP,-Duser.language=ja,-Duser.variant,sample.jigsaw/sample.jigsaw.Main

何も指定していないはずなのに、なんかいっぱい出た。

何が起こったのか?

build.gradle
run {
    doFirst {
        jvmArgs = [
            "--module-path", classpath.asPath,
            "--module", mainClassName
        ]
        classpath = files()

        // ★追加★
        println("[allJvmArgs]")
        println(allJvmArgs.join("\n"))
    }
}

run タスクの中で allJvmArgs を出力するようにして再度 run を実行する。

> gradle run
[allJvmArgs]
--module-path
...\build\classes\java\main;...\build\resources\main;...\commons-lang3-3.7.jar
--module
sample.jigsaw/sample.jigsaw.Main
-Dfile.encoding=windows-31j
-Duser.country=JP
-Duser.language=ja
-Duser.variant

args = -Dfile.encoding=windows-31j,-Duser.country=JP,-Duser.language=ja,-Duser.variant,sample.jigsaw/sample.jigsaw.Main

jvmArgs で指定したオプションの後ろに、おそらく Gradle が自動的に付けたと思われるオプションがくっついている。

続いて java のヘルプを確認する。

javaコマンドのヘルプ
> java --help
使用方法: java [options] <mainclass> [args...]
           (クラスを実行する場合)
   または  java [options] -jar <jarfile> [args...]
           (jarファイルを実行する場合)
   または  java [options] -m <module>[/<mainclass>] [args...]
       java [options] --module <module>[/<mainclass>] [args...]
           (モジュールのメイン・クラスを実行する場合)

...

-m (--module) でメインクラスを指定する場合、その後ろに続く引数は全てプログラムに渡されるコマンドライン引数として扱われることになっている。

つまり、 jvmArgs--module を指定してしまうと、その後ろに続く JVM オプションとして渡すはずだった引数が、コマンドライン引数としと main メソッドの引数に渡されてしまうことになる。

さらに、よくよく args に渡された値を見てみると、最後に sample.jigsaw/sample.jigsaw.Main がついていることに気付く。
これは --module 指定でメインクラスを指定しているにも関わらず、 run タスクが mainClassName で指定した値を渡してしまっているためにくっついているものと思われる。

つまり、 run タスクは次のように java コマンドを組み立てようとしているが、

runタスクが組み立てるコマンド
java [jvmArgsで指定した値] [自動設定されるJVMオプション] [mainClassNameで指定された値] [argsで指定される値]
    |--ここがJVMオプション(のつもり)------------------||--メインクラスの指定--------| |--残りはプログラムに渡す引数.....

jvmArgs で無理やり --module を指定したため、 Java 10 の java コマンドは次のように引数を解釈してしまっている。

javaコマンドが解釈した引数の構成
java --module-path .........  --module ............. [自動設定されるJVMオプション] [mainClassNameで指定された値] [argsで指定される値]
     |--ここがJVMオプション--| |--メインクラスの指定--| |--残りは全部プログラムに渡す引数扱い..........................

一旦話を整理する

  • jvmArgs--module-path, --module を指定すれば、一応プログラムは動く
  • しかし、 --modulejvmArgs で指定してしまっているため、そこから後ろに繋げられる本来 JVM オプションなどに指定されるはずだった値が、全てプログラムの引数として扱われてしまっている
  • これはつまり、引数を利用するプログラムを作って動かそうとしたときに大きな問題となる

どうすべきか?

問題のある状態
java --module-path .........  --module ............. [自動設定されるJVMオプション] [mainClassNameで指定された値] [argsで指定される値]
     |--ここがJVMオプション--| |--メインクラスの指定--| |--残りは全部プログラムに渡す引数扱い..........................

これが、

求めている状態
java --module-path .........  [自動設定されるJVMオプション] --module .............  [argsで指定される値]
     |--ここがJVMオプション------------------------------| |--メインクラスの指定--| |--残りは全部プログラムに渡す引数...

こうなるのが理想。

JVM オプションの末尾に任意のパラメータを挿入できないか?

jvmArgs で指定した値は JVM オプションの先頭に渡される。

一方、自動設定されるものも含めた全てのオプションは allJvmArgs プロパティから参照できる。
--module オプションは jvmArgs ではなく、この allJvmArgs の末尾に追加すれば、うまくいくのではなかろうか?

build.gradle
run {
    doFirst {
        main = "" // main クラスの指定を空にして
        jvmArgs = [
            "--module-path", classpath.asPath
        ]
        allJvmArgs += ["--module", mainClassName] // allJvmArgs の末尾に --module を追加
        classpath = files()
    }
}
実行結果
> gradle run
Execution failed for task ':run'.
> java.lang.UnsupportedOperationException (no error message)

残念ながら、 allJvmArgs は読み取り専用なので書き換えることができなかった。

mainClassName に指定する値に --module を入れられるか?

build.gradle
run {
    doFirst {
        main = "--module $mainClassName"
        jvmArgs = [
            "--module-path", classpath.asPath
        ]
        classpath = files()
    }
}

main に、 --module を無理やりくっつけた値を渡してみる。

実行結果
> gradle run
> Task :run FAILED
Unrecognized option: --module sample.jigsaw/sample.jigsaw.Main

どうやら "--module sample.jigsaw/sample.jigsaw.Main" という単一の引数として java コマンドに渡されてしまっているようで、不明なオプションとして処理されてしまった。

args の先頭でメインクラスを指定する

build.gradle
run {
    doFirst {
        main = ""
        jvmArgs = [
            "--module-path", classpath.asPath
        ]
        args = ["--module", mainClassName]
        args += ["foo", "bar"]
        classpath = files()
    }
}

main は空にして、 args の先頭で --module を指定する。

実行結果
> gradle run

args = foo,bar

上手く動いた!

結論

コマンドライン引数が不要
run {
    doFirst {
        jvmArgs = [
            "--module-path", classpath.asPath,
            "--module", mainClassName
        ]
        classpath = files()
    }
}
コマンドライン引数が必要
run {
    doFirst {
        main = ""
        jvmArgs = [
            "--module-path", classpath.asPath
        ]
        args = ["--module", mainClassName]
        args += ["foo", "bar"]
        classpath = files()
    }
}

ファーストクラスでのサポートが待たれる。。。

startScript を修正する

  • ここまでの修正だけだと、 run タスクにしか対応できていない
  • installDist で生成される起動スクリプトはクラスパス指定のままになっているので、そのままだと起動ができない
現状で出力される起動スクリプト(bat)
...
set CLASSPATH=%APP_HOME%\lib\jigsaw-sample.jar;%APP_HOME%\lib\commons-lang3-3.7.jar

...

"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %JIGSAW_OPTS%  -classpath "%CLASSPATH%" sample.jigsaw/sample.jigsaw.Main %CMD_LINE_ARGS%
  • CLASSPATH に jar のパスがセットされ、 -classpath 指定で java コマンドを実行している
  • このため、このまま起動しようとしてもモジュールが見つからずにエラーになる
この状態で実行するとエラー
> build\install\jigsaw\bin\jigsaw
エラー: メイン・クラスsample.jigsaw.sample.jigsaw.Mainを検出およびロードできませんでした
原因: java.lang.ClassNotFoundException: sample.jigsaw.sample.jigsaw.Main
  • これに対応するために、公式ドキュメントでは次のように build.gradle を修正する方法が紹介されている
build.gradle(追加分+α)
ext.moduleName = "sample.jigsaw"

mainClassName = "${moduleName}/sample.jigsaw.Main"

...

import java.util.regex.Matcher

startScripts {
    doFirst {
        classpath = files()
        defaultJvmOpts = [
            "--module-path", "APP_HOME_LIBS",
            "--module", mainClassName
        ]
    }

    doLast {
        File bashFile = new File(outputDir, applicationName)
        String bashContent = bashFile.text
        bashFile.text = bashContent.replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('$APP_HOME/lib'))

        File batFile = new File(outputDir, applicationName + ".bat")
        String batContent = batFile.text
        batFile.text = batContent.replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('%APP_HOME%/lib'))
    }
}
  • これで、起動スクリプトは次のように出力されるようになる
修正後の起動スクリプト(bat)
...
set DEFAULT_JVM_OPTS="--module-path" "%APP_HOME%/lib" "--module" "sample.jigsaw/sample.jigsaw.Main"

...
set CLASSPATH=

...
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %JIGSAW_OPTS%  -classpath "%CLASSPATH%" sample.jigsaw/sample.jigsaw.Main %CMD_LINE_ARGS%
  • 起動スクリプトを叩くと、問題なく実行できる
実行する
> build\install\jigsaw\bin\jigsaw.bat
Hello Jigsaw!! : 9282500878
  • ただ、前述した run タスクの修正と同様の問題が発生している
  • それについては後述

説明

  • なにやら色々修正しているように見えるが、結局やっているのは次の2つだけ
    1. クラスパスを空にする
    2. 代わりにモジュールパスを設定する
  • つまり、 compileJava とかでやっていたのと同じ修正をしているにすぎない
build.gradle
startScripts {
    doFirst {
        classpath = files()
        defaultJvmOpts = [
            "--module-path", "APP_HOME_LIBS",
            "--module", mainClassName
        ]
    }
  • まずは、 classpathfiles() をセットすることでクラスパスを空にしている
  • 次に defaultJvmOpts--module-path (モジュールパス)を設定している
    • ここではまず APP_HOME_LIBS という文字列を設定している
      • この文字列は、あとでスクリプトファイルの種類ごとに置換を行う
    • また、 --module でメインクラスを指定している
  • この defaultJvmOpts で指定した値は、スクリプトファイル上では DEFAULT_JVM_OPTS という変数に格納するように出力される
defaultJvmOptsの出力結果
set DEFAULT_JVM_OPTS="--module-path" "%APP_HOME%/lib" "--module" "sample.jigsaw/sample.jigsaw.Main"
build.gradle
    doLast {
        File bashFile = new File(outputDir, applicationName)
        String bashContent = bashFile.text
        bashFile.text = bashContent.replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('$APP_HOME/lib'))

        File batFile = new File(outputDir, applicationName + ".bat")
        String batContent = batFile.text
        batFile.text = batContent.replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('%APP_HOME%/lib'))
    }
  • --module-path に指定した APP_HOME_LIBS は、そのままだと何の意味もないただの文字列でしかない
  • 実際には、ここにモジュールパスに含めたいパスを指定しなければならない
  • モジュールパスに指定するパスは、プログラムを構成する jar ファイル(もしくは、その jar が存在するディレクトリへのパス)になる
  • installDist で生成されるフォルダ構成では、 <インストールディレクトリ>/lib の下に jar ファイルがまとめられている
  • そして、 <インストールディレクトリ> は起動スクリプト上では APP_HOME という名前の変数で参照できるようになっている
  • よって、 APP_HOME_LIBS<APP_HOME>/lib に置き換えれば、良い感じにモジュールパスを指定できることになる
  • ただ、ここで問題なのは、変数の参照方法が bash ファイルと bat ファイルとで異なるという点
  • bash$APP_HOME で参照し、 bat では %APP_HOME% で参照しなければならない
  • doLast ブロックでは、この2つの違いを、それぞれのスクリプトで分けて実装している
  • 結果、生成された起動スクリプトは次のようになる
出力された起動スクリプト(bat)
...
set DEFAULT_JVM_OPTS="--module-path" "%APP_HOME%/lib" "--module" "sample.jigsaw/sample.jigsaw.Main"

...
set CLASSPATH=

...
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %JIGSAW_OPTS%  -classpath "%CLASSPATH%" sample.jigsaw/sample.jigsaw.Main %CMD_LINE_ARGS%

引数の問題

出力された起動スクリプト(bat)
...
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %JIGSAW_OPTS%  -classpath "%CLASSPATH%" sample.jigsaw/sample.jigsaw.Main %CMD_LINE_ARGS%
  • よく見ると、 run タスクのときと同じように、引数の指定がおかしくなっている
  • 実際、引数を出力するようにして実行すると次のようになる
> build\install\jigsaw\bin\jigsaw.bat
args = -classpath,,sample.jigsaw/sample.jigsaw.Main
  • -classpath 以下が引数として認識されてしまっている
  • これは、一応次のように build.gradle を修正すれば改善する
build.gradle
    doLast {
        File bashFile = new File(outputDir, applicationName)
        String bashContent = bashFile.text
        bashFile.text = bashContent
                            .replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('$APP_HOME/lib'))
                            .replace('-classpath "\\"$CLASSPATH\\"" ' + mainClassName, '')

        File batFile = new File(outputDir, applicationName + ".bat")
        String batContent = batFile.text
        batFile.text = batContent
                            .replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('%APP_HOME%/lib'))
                            .replace('-classpath "%CLASSPATH%" ' + mainClassName, '')
    }
  • やや無理矢理だが、 -classpath 以下の余計な部分を空文字に置換している
出力された起動スクリプト
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %JIGSAW_OPTS%   %CMD_LINE_ARGS%
実行結果
> build\install\jigsaw\bin\jigsaw hoge fuga
args = hoge,fuga

単体テストのあるプロジェクトをモジュール化する

まず最初に思いついたの

まず最初に思いついたのは、 test の方にも module-info.java を置いて、 sample.jigsaw.test のように main とは異なるモジュールを定義するという方法だった。

しかし、この方法は次のような問題があるのでダメだった。

  1. 異なるモジュール間では、同じパッケージを宣言できない
    • Hoge クラスのテストクラス HogeTest を、 Hoge と同じパッケージで宣言するとエラーになる
  2. exports していないパッケージはテストモジュールから参照できなくなってしまうので、内部 API のテストが書けない

1 の方は異なるパッケージに宣言すれば解決できそうだが、 2 の方が致命的なので、 test を異なるモジュールにするというのは無さそう。

推奨?されている方法

公式ドキュメント では、次のような方法が推奨?されている。

プロジェクト構成
|-build.gradle
`-src/
  |-main/java/
  | |-module-info.java
  | `-sample/jigsaw/
  |   `-Hoge.java
  |
  `-test/java/
    `-sample/jigsaw/
      `-HogeTest.java
  • test の方には module-info.java を置いていない
build.gradle
...

ext {
    moduleName = "sample.jigsaw"
    junitModuleName = "org.junit.jupiter.api"
}

...

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.2.0'
}

...

compileTestJava {
    doFirst {
        options.compilerArgs = [
            "--module-path", classpath.asPath,
            "--add-modules", junitModuleName,
            "--patch-module", "$moduleName=" + files(sourceSets.test.java.srcDirs).asPath,
            "--add-reads", "$moduleName=$junitModuleName"
        ]
        classpath = files()
    }
}
Hoge.java
package sample.jigsaw;

public class Hoge {

    public String method(int a, int b) {
        return a + " + " + b + " = " + (a + b);
    }
}
HogeTest.java
package sample.jigsaw;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class HogeTest {

    @Test
    void test() {
        Hoge hoge = new Hoge();

        String actual = hoge.method(10, 20);

        assertEquals("10 + 20 = 30", actual);
    }
}
とりあえずコンパイルが通るか確認
> gradle compileTestJava
BUILD SUCCESSFUL in 3s

説明

なにやら compileTestJava に色々追加しているが、1つずつ説明していく。

build.gradle
compileTestJava {
    doFirst {
        options.compilerArgs = [
            "--module-path", classpath.asPath,
            "--add-modules", junitModuleName,
            "--patch-module", "$moduleName=" + files(sourceSets.test.java.srcDirs).asPath,
            "--add-reads", "$moduleName=$junitModuleName"
        ]
        classpath = files()
    }
}

--module-path

build.gradle
"--module-path", classpath.asPath,
  • まずは、 compileJava のときと同じでクラスパスの代わりにモジュールパスを利用することでモジュール化をしている

--add-modules

  • --module-path を追加しただけだと、次のようなエラーが発生する。
> gradle compileTestJava

...\src\test\java\sample\jigsaw\HogeTest.java:3: エラー: パッケージorg.junit.jupiter.apiは表示不可です
import org.junit.jupiter.api.Test;
                        ^
  (パッケージorg.junit.jupiter.apiはモジュールorg.junit.jupiter.apiで宣言されていますが、モジュール・グラフにありません)
  • HogeTest が JUnit のパッケージを使用しようとしているが、 JUnit のモジュールがモジュール・グラフ上にないのでエラーになっている
  • というのも、 main の方の module-info.java には JUnit のモジュールを読み込むような記述はないので当然と言えば当然
  • そこで、 JUnit のモジュール (org.junit.jupiter.api) を --add-modules を使ってルート・モジュールに追加する
build.gradle
"--add-modules", junitModuleName,

--patch-module

  • まだエラーは消えない
> gradle compileTestJava
...\src\test\java\sample\jigsaw\HogeTest.java:11: エラー: シンボルを見つけられません
import sample.jigsaw.Hoge;
                    ^
  シンボル:   クラス Hoge
  場所: パッケージ sample.jigsaw
  • HogeTest クラスから Hoge クラスを参照できていない
  • これは、 sample.jigsaw モジュールの中に src/test/java 以下のテストクラス達が含まれていないために起こっているっぽい
  • この問題を解決するため、 --patch-module というオプションが用意されている
  • --patch-module を使うと、任意のモジュールに対してクラスやパッケージを後から追加できる
  • 書式は --patch-module <モジュール名>=<追加するjarやディレクトリ>[;<追加するjarやディレクトリ>...]
    • パス区切り文字は、 Linux とかならコロン(:)になる
build.gradle
"--patch-module", "$moduleName=" + files(sourceSets.test.java.srcDirs).asPath,
  • これで、 sample.jigsaw モジュールに対して src/test/java 以下のクラスがコンパイル時に追加されるようになる
  • つまり、 HogeTestsample.jigsaw の中に入れてもらえた状態になる

ちなみに、 --patch-module は JEP の説明の中で次のように説明されている。

The --patch-module option is intended only for testing and debugging. Its use in production settings is strongly discouraged.

【訳】
--patch-module オプションはテストやデバッグだけで使われることを意図しています。
本番環境での使用はお勧めしません。

Patching module content | JEP 261: Module System

--add-reads

最後のエラー。

> gradle compileTestJava

...\src\test\java\sample\jigsaw\HogeTest.java:3: エラー: パッケージorg.junit.jupiter.apiは表示不可です
import org.junit.jupiter.api.Test;
                        ^
  (パッケージorg.junit.jupiter.apiはモジュールorg.junit.jupiter.apiで宣言されていますが、モジュールsample.jigsawに読み込まれていません)
  • これは、 org.junit.jupiter.api モジュールはルート・モジュールとして読み込まれたが、 sample.jigsaw モジュールが org.junit.jupiter.apirequires していないために発生している
  • これについても、任意のモジュールが使用するモジュールを追加できるオプションが用意されている
  • それが --add-reads オプション
  • 書式は --add-reads <モジュール名>=<追加で使用するモジュール>[,<追加で使用するモジュール>...]
build.gradle
"--add-reads", "$moduleName=$junitModuleName"
  • sample.jigsaw モジュールに対して、 org.junit.jupiter.api モジュールを読み込めるように追加している
  • これでようやくコンパイルが通るようになる

整理

オプション 意味・目的
--module-path モジュールパスを利用してビルドするため
--add-modules JUnit のモジュールをルート・モジュールとして登録
--patch-module sample.jigsaw モジュールに src/test/java 以下のクラスを追加
--add-reads sample.jigsaw モジュールから JUnit のモジュールを読み込めるようにする

テストの実行

build.gradle
test {
    useJUnitPlatform()

    doFirst {
        jvmArgs = [
            '--module-path', classpath.asPath,
            '--add-modules', 'ALL-MODULE-PATH',
            '--patch-module', "$moduleName=" + files(sourceSets.test.java.outputDir).asPath,
            '--add-reads', "$moduleName=$junitModuleName",
            '--add-opens', "$moduleName/sample.jigsaw=org.junit.platform.commons"
        ]
        classpath = files()
    }
}

またちょっとオプションが増えている。
それぞれ説明していく。

useJUnitPlatform()

build.gradle
test {
    useJUnitPlatform()
  • こちらは、 Gradle で JUnit 5 を利用するときに必要になる記述

--module-path

build.gradle
'--module-path', classpath.asPath,
  • これは、これまで同様モジュールパスを使用するための設定

--add-modules

build.gradle
'--add-modules', 'ALL-MODULE-PATH',
  • compileTestJava のときとは少し変わっていて、値が ALL-MODULE-PATH になっている
  • ALL-MODULE-PATH は、名前の通りモジュールパスに設定されているものを全てルート・モジュールとして登録する指定になる
  • テストの JVM は Gradle のテストランナーによって起動されるが、この JVM は当然 sample.jigsaw モジュールなどをモジュール・グラフ上に読み込んではいない
    • Jigsaw へのファーストクラスサポートもまだなので、 JUnit 類のモジュールも読み込んでいないはず
  • なので、 ALL-MODULE-PATH で全てのモジュールパスをルート・モジュールとして読み込んでいる

ちなみに、この設定をしないと次のようなエラーが発生する。

Caused by: java.lang.ClassNotFoundException: sample.jigsaw.HogeTest
    ...
    at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:499)
    ...
    at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor.loadClass(JUnitPlatformTestClassProcessor.java:103)

Gradle がテストクラス (HogeTest) をロードしようとして、クラスが見つからずエラーになっている。
おそらく、モジュール・グラフ上に存在しないせいで見つかっていないのだと思う、たぶん。

--patch-module

  • --add-modules を設定しただけだと、実はエラーの内容は変化せず、相変わらず ClassNotFoundException: sample.jigsaw.HogeTest が発生する
  • ただ、これは compileTestJava のときと同じで src/test/java 以下のクラスが sample.jigsaw モジュールに含まれていないことが原因っぽいので、 --patch-module で追加してあげる必要がある
build.gradle
'--patch-module', "$moduleName=" + files(sourceSets.test.java.outputDir).asPath,
  • ちょっと注意なのが、 compileTestJava とはちょっとだけ指定値が変わっている
  • compileTestJava のときは srcDirs だったが、 test のときは outputDir を指定する

--add-reads

build.gradle
'--add-reads', "$moduleName=$junitModuleName",
  • これは compileTestJava のときと意味は同じなので割愛

--add-opens

  • 公式ドキュメント では、この設定は存在しない
  • これは、 JUnit 5 を使う場合に必要になるっぽい
    • 公式ドキュメントは JUnit 4 での説明が書かれていて、 JUnit 4 ではこの設定が無くてもテストは動作する
  • この設定をせずにテストを実行すると、次のようなエラーが発生する
java.lang.reflect.InaccessibleObjectException: Unable to make sample.jigsaw.HogeTest() accessible: module sample.jigsaw does not "opens sample.jigsaw" to module org.junit.platform.commons
  • なにやら、 JUnit 側がリフレクションで HogeTest にアクセスしようとして怒られているっぽい
  • sample.jigsaw パッケージが org.junit.platform.commons モジュールに対して opens になっていないために怒っているらしい
    • opens は、コンパイル時は不要だが実行時にリフレクションなどで参照されるような場合に指定する設定で、 module-info.java で定義する
    • 詳しくはこちらを参照
  • 一応 sample.jigsawmodule-info.javaopens sample.jigsaw とすればエラーはなくなる
  • しかし、テストのための設定を main の module-info.java に書くのはちょっとアレ
  • ということで、実行時に opens の定義を追加できる --add-opens オプションを指定することで回避している
build.gradle
'--add-opens', "$moduleName/sample.jigsaw=org.junit.platform.commons"
  • エラーメッセージに従って、 sample.jigsaw パッケージを org.junit.platform.commons に対して opens にした
    • 書式は --add-opens <モジュール名>/<パッケージ名>=<公開先のモジュール>[,<公開先のモジュール>...]

実行

これで、ようやくテストが実行できるようになる。

> gradle test

BUILD SUCCESSFUL in 6s

整理

オプション 意味・目的
--module-path モジュールパスを利用してビルドするため
--add-modules ALL-MODULE-PATH 指定で全てのモジュールをルートに登録
--patch-module sample.jigsaw モジュールに src/test/java 以下のクラスを追加
--add-reads sample.jigsaw モジュールから JUnit のモジュールを読み込めるようにする
--add-opens JUnit のモジュールから sample.jigsaw パッケージにアクセスできるようにする

マルチプロジェクトをモジュール化する

ここまでの話を全て総合して、マルチプロジェクトをモジュール化すると下のようになった。

プロジェクト構成
|-settings.gradle
|-build.gradle
|
|-foo/
| |-build.gradle
| `-src/main/java/
|   |-module-info.java
|   `-sample/jigsaw/foo/
|     `-Main.java
|
`-bar/
  |-build.gradle
  `-src/main/java/
    |-module-info.java
    `-sample/jigsaw/bar/
      `-Bar.java
/settings.gradle
include "foo", "bar"
/build.gradle
ext {
    junitModuleName = "org.junit.jupiter.api"
}

subprojects {
    apply plugin: 'java'

    sourceCompatibility = 10
    targetCompatibility = 10

    [compileJava, compileTestJava]*.options*.encoding = 'UTF-8'

    repositories {
        mavenCentral()
    }

    dependencies {
        testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.2.0'
    }

    compileJava {
        doFirst {
            options.compilerArgs = [
                "--module-path", classpath.asPath
            ]
            classpath = files()
        }
    }

    compileTestJava {
        doFirst {
            options.compilerArgs = [
                "--module-path", classpath.asPath,
                "--add-modules", junitModuleName,
                "--patch-module", "$moduleName=" + files(sourceSets.test.java.srcDirs).asPath,
                "--add-reads", "$moduleName=$junitModuleName"
            ]
            classpath = files()
        }
    }

    test {
        useJUnitPlatform()

        doFirst {
            jvmArgs = [
                '--module-path', classpath.asPath,
                '--add-modules', 'ALL-MODULE-PATH',
                '--patch-module', "$moduleName=" + files(sourceSets.test.java.outputDir).asPath,
                '--add-reads', "$moduleName=$junitModuleName",
                "--add-opens", "$moduleName/$testPackageName=org.junit.platform.commons"
            ]
            classpath = files()
        }
    }
}

foo プロジェクト

/foo/build.gradle
apply plugin: 'application'

ext {
    moduleName = 'sample.jigsaw.foo'
    testPackageName = 'sample.jigsaw.foo'
}

mainClassName = "${moduleName}/sample.jigsaw.foo.Main"

dependencies {
    implementation project(":bar")
}

run {
    doFirst {
        jvmArgs = [
            "--module-path", classpath.asPath
        ]
        main = ""
        args = ["--module", mainClassName]
        classpath = files()
    }
}

import java.util.regex.Matcher

startScripts {
    doFirst {
        classpath = files()
        defaultJvmOpts = [
            "--module-path", "APP_HOME_LIBS",
            "--module", mainClassName
        ]
    }

    doLast {
        File bashFile = new File(outputDir, applicationName)
        String bashContent = bashFile.text
        bashFile.text = bashContent
                .replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('$APP_HOME/lib'))
                .replace('-classpath "\\"$CLASSPATH\\"" ' + mainClassName, '')

        File batFile = new File(outputDir, applicationName + ".bat")
        String batContent = batFile.text
        batFile.text = batContent
                .replaceFirst("APP_HOME_LIBS", Matcher.quoteReplacement('%APP_HOME%/lib'))
                .replace('-classpath "%CLASSPATH%" ' + mainClassName, '')
    }
}
/foo/src/main/java/module-info.java
module sample.jigsaw.foo {
    requires sample.jigsaw.bar;
}
Main.java
package sample.jigsaw.foo;

import sample.jigsaw.bar.Bar;

public class Main {
    public static void main(String[] args) {
        System.out.println(new Bar().method(10));
    }
}

barプロジェクト

/bar/build.gradle
apply plugin: 'java-library'

ext {
    moduleName = 'sample.jigsaw.bar'
    testPackageName = 'sample.jigsaw.bar'
}

dependencies {
    implementation "org.apache.commons:commons-lang3:3.7"
}
/bar/src/main/java/module-info.java
module sample.jigsaw.bar {
    exports sample.jigsaw.bar;
    requires org.apache.commons.lang3;
}
Bar.java
package sample.jigsaw.bar;

import org.apache.commons.lang3.RandomStringUtils;

public class Bar {

    public String method(int size) {
        return RandomStringUtils.random(size, "0123456789");
    }
}
BarTest.java
package sample.jigsaw.bar;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class BarTest {

    @Test
    void testBar() {
        Bar bar = new Bar();

        String actual = bar.method(5);

        assertEquals(actual.length(), 5);
    }
}

動作確認

テスト
> gradle test

BUILD SUCCESSFUL in 4s
run
> gradle :foo:run

6569544042

結論

ファーストクラスサポートはよ

Gradle プラグインのリポジトリを見たらいくつかあった

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした