この記事の目的
ビルドツールGradleを使ってマルチプロジェクトを管理し、成果物として実行可能JARを作成する手順の備忘録です。
- マルチプロジェクトの管理
- 外部JARに依存する実行可能JARの作成(依存JARをすべて内包したJAR、fat-JARにはしない)
がおもなテーマです。
記事作成の背景
JavaFX1によってGUIアプリケーションを作る機会がありました。
アプリケーションは最終的にjavapackager2というツールによってEXE形式で配布するのですが、その前段階として、ダブルクリックで起動可能なJARを生成しておく必要がありました。
またアプリケーションは、内部処理がほとんど共通で、細かい挙動の異なる複数の種類を作成する必要がありました。
共通部分はcommon.jarとし、入り口となるJARをapp1.jar、app2.jar(それぞれcommon.jarに依存)などとしたかったのですが、社内のJenkinsを覗いて似た構成のマルチプロジェクトを探してみても、Gradleだけで完結したスクリプトがない状態でした。
commonのbuild.gradleを呼び、次いでapp1のbuild.gradleを呼ぶというようなシェルスクリプトがあるだけだったのです。
そのシェルスクリプトを真似すれば目的は果たせたのですが、学習のため、Gradleで完結するスクリプトを作成することとしました。
検証環境
- macOS 10.12.1 (Sierra)
- Java 1.8.0_102
- Gradle 3.2.1
マルチプロジェクトを作成し、実行可能JARを作成する
単純化のため、appプロジェクトは1つだけで始めてみます。
最小限のマルチプロジェクトで動作確認する
まずは最小限のマルチプロジェクトを作成し、動作確認のためhello
タスクを呼び出します。
作業ディレクトリー直下にapp, common, masterというディレクトリーを作り、master直下にbuild.gradleとsettings.gradleを作成します3:
master$ tree ../
../
├── app
├── common
└── master
├── build.gradle
└── settings.gradle
settings.gradleには、サブプロジェクトとして認識させたいディレクトリーを指定します。
ルートプロジェクトの指定がありませんが、masterという名前のディレクトリーが自動でルートプロジェクトとして認識されるので、指定していません4:
includeFlat 'app', 'common'
build.gradleでは、allprojects
ブロック内でhello
タスクを定義します5:
allprojects {
task hello {
doLast {
println "I`m ${project.name}."
}
}
}
ここまでできたら、masterディレクトリーでhello
タスクを呼びます6。
以下のように表示されればOKです:
master$ gradle -q hello
I`m master.
I`m app.
I`m common.
ちなみに、commonディレクトリーでhello
タスクを呼ぶと、commonのタスクだけが呼ばれます。
この性質を使えば、特定プロジェクトのみのビルドも可能です:
common$ gradle -q hello
I`m common.
appとcommonをEclipseのプロジェクトにする
サブプロジェクトにEclipseプラグインを追加します。
以下をallprojects
ブロックの後ろに追記します:
…
subprojects {
apply {
plugin 'eclipse'
}
}
これだけでも、eclipse
タスクが使えるようになって、Eclipseのプロジェクトを作ることが可能です:
master$ gradle eclipse
:app:eclipseProject
:app:eclipse
:common:eclipseProject
:common:eclipse
BUILD SUCCESSFUL
Total time: 2.067 secs
ただ、実際にはsrcディレクトリーも必要なので、gradleタスクで生成してみます7。
Javaプラグインを追加し、initSrcDirs
タスクを下記のように作成します。
…
subprojects {
apply {
plugin 'eclipse'
plugin 'java'
}
task initSrcDirs {
doLast {
sourceSets.all {
java.srcDirs*.mkdirs()
resources.srcDirs*.mkdirs()
}
}
}
}
initSrcDirs
タスクを実行すると、src/main/javaやsrc/main/resourcesなどが作成されます。
それらのパスにすでにファイルが存在していても、空になることはないので、安全に使えます:
master$ gradle initSrcDirs
:app:initSrcDirs
:common:initSrcDirs
BUILD SUCCESSFUL
Total time: 0.909 secs
master$ tree ../
../
├── app
│ └── src
│ ├── main
│ │ ├── java
│ │ └── resources
│ └── test
│ ├── java
│ └── resources
├── common
│ └── src
│ ├── main
│ │ ├── java
│ │ └── resources
│ └── test
│ ├── java
│ └── resources
└── master
├── build.gradle
└── settings.gradle
initSrcDirs
タスクを定義する際に現れた変数について説明します。
-
sourceSets
は、Javaプラグインが定義するsrc/main/javaなどのディレクトリー体系です。 -
mkdirs()
は、srcDirs
がjava.io.Fileのリストなので、java.io.Fileのmkdirsメソッドを呼び出しています。
それ以外の構文(たとえばイテレーションの*.
)が気になる方はGroovyの文法を調べてみてください。
srcディレクトリーができた状態で再度eclipse
タスクを呼ぶと、きちんとクラスパスも通ります(先ほどはなかったeclipseClaspath
タスクが呼ばれています):
master$ gradle eclipse
:app:eclipseClasspath
:app:eclipseJdt
:app:eclipseProject
:app:eclipse
:common:eclipseClasspath
:common:eclipseJdt
:common:eclipseProject
:common:eclipse
BUILD SUCCESSFUL
Total time: 0.994 secs
appプロジェクトをcommonプロジェクトへ依存させる
マルチプロジェクト管理で実現したかったことの本質に取りかかります。
appをcommonへ依存させます。
失敗するケース
まずは、失敗ケースを再現するため、build.gradleを変更せずに以下の2クラスを作成します。
Constant.javaはcommon配下へ、Main.javaはapp配下へ作成します。
当然ながら、Eclipseでも互いの依存関係は認識できていないので、補完できないうえエラーも出ます:
package com.github.kazuma1989.dec10_2016.common;
public class Constant {
public static final String NAME = "common";
}
package com.github.kazuma1989.dec10_2016.app;
import com.github.kazuma1989.dec10_2016.common.Constant;
public class Main {
public static void main(String[] args) {
System.out.println(Constant.NAME);
}
}
この状態でjar
タスクを実行すると、期待どおりにビルド失敗です。
jar
タスクの前段階のcompileJava
タスクで失敗しています:
master$ gradle jar
:app:compileJava
/Users/kazuma/qiita/2016/dec10/app/src/main/java/com/github/kazuma1989/dec10_2016/app/Main.java:3: エラー: パッケージcom.github.kazuma1989.dec10_2016.commonは存在しません
import com.github.kazuma1989.dec10_2016.common.Constant;
^
/Users/kazuma/qiita/2016/dec10/app/src/main/java/com/github/kazuma1989/dec10_2016/app/Main.java:7: エラー: シンボルを見つけられません
System.out.println(Constant.NAME);
^
シンボル: 変数 Constant
場所: クラス Main
エラー2個
:app:compileJava FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileJava'.
> Compilation failed; see the compiler error output for details.
* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output.
BUILD FAILED
Total time: 0.966 secs
成功するケース
次に、ビルドを成功させます。
subprojects
ブロックの最後へ以下のように追記して、commonへの依存関係を定義します:
…
subprojects {
…
// common以外の設定
if (project.name in ['common']) return
dependencies {
compile project(':common')
}
}
今度はjar
タスクが成功し、作成したJARも問題なく動作します。
プロジェクト名の昇順ではなく、commonのJAR化が先に実行されています:
master$ gradle jar
:common:compileJava
:common:processResources UP-TO-DATE
:common:classes
:common:jar
:app:compileJava
:app:processResources UP-TO-DATE
:app:classes
:app:jar
BUILD SUCCESSFUL
Total time: 1.154 secs
master$ java -cp ../app/build/libs/app.jar com.github.kazuma1989.dec10_2016.app.Main
common
jar
タスクは必ずしもmasterから呼ぶ必要はなく、appのものだけを呼んでも構いません。
その場合も、commonをコンパイルしてからappがJAR化されるので、きちんと成功します:
master$ gradle clean :app:jar
:app:clean
:common:clean
:common:compileJava
:common:processResources UP-TO-DATE
:common:classes
:common:jar
:app:compileJava
:app:processResources UP-TO-DATE
:app:classes
:app:jar
BUILD SUCCESSFUL
Total time: 0.938 secs
master$ cd ../app/
app$ gradle clean jar
:app:clean
:common:compileJava UP-TO-DATE
:common:processResources UP-TO-DATE
:common:classes UP-TO-DATE
:common:jar UP-TO-DATE
:app:compileJava
:app:processResources UP-TO-DATE
:app:classes
:app:jar
BUILD SUCCESSFUL
Total time: 0.922 secs
依存関係を設定後、再度eclipse
タスクを実行すれば、Eclipseでのエラーも解消し、コード補完も使えるようになります。
マニフェストを追加してappを実行可能JARにする
jar
タスクにマニフェストの設定をし、実行可能JARを生成します。
うまくいくこともあるが、基本は失敗するケース
build.gradleの最後に最低限の記述だけを追加して、とにかくjar
タスクを実行してみます:
…
project(':app') {
jar {
manifest {
attributes 'Main-Class': 'com.github.kazuma1989.dec10_2016.app.Main'
}
}
}
master$ gradle jar
:common:compileJava
:common:processResources UP-TO-DATE
:common:classes
:common:jar
:app:compileJava
:app:processResources UP-TO-DATE
:app:classes
:app:jar
BUILD SUCCESSFUL
Total time: 3.177 secs
作成したJARをjava
コマンドで呼び出すと、Mainクラスが呼ばれます:
master$ java -jar ../app/build/libs/app.jar
common
しかし、これが動作したのはたまたまです。
common.jarへの依存情報をマニフェストに記載しないと、Constantクラスが見つからないからです。
今回は、MainクラスがConstant文字列定数しか参照していないため、コンパイル時にその値がMainクラスに保持されてしまったと考えられます。
common.jarへの依存情報を設定し、常にうまくいくケース
通常はNoClassDefFoundErrorとなります。
コンパイルは成功するが、実行時に失敗するということです。
たとえば以下は、commonに基底クラスAbstractApplication
を作成し、appで継承して利用した場合です:
master$ gradle jar
:common:compileJava
:common:processResources UP-TO-DATE
:common:classes
:common:jar
:app:compileJava
:app:processResources UP-TO-DATE
:app:classes
:app:jar
BUILD SUCCESSFUL
Total time: 1.188 secs
master$ java -jar ../app/build/libs/app.jar
Exception in thread "main" java.lang.NoClassDefFoundError: com/github/kazuma1989/dec10_2016/common/AbstractApplication
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at com.github.kazuma1989.dec10_2016.app.Main.main(Main.java:9)
Caused by: java.lang.ClassNotFoundException: com.github.kazuma1989.dec10_2016.common.AbstractApplication
at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
... 13 more
master$ tree ../app/build/libs/
../app/build/libs/
└── app.jar
これを解決するため、jar
ブロック内を以下のように変更します。
マニフェストにはattributes 'Class-Path'
が、タスクにはcopy
操作が追加されています:
…
project(':app') {
jar {
manifest {
attributes 'Main-Class': 'com.github.kazuma1989.dec10_2016.app.Main'
attributes 'Class-Path': configurations.runtime.collect{it.name}.join(' ')
}
doLast {
copy {
from configurations.runtime
into destinationDir
}
}
}
}
configurations.runtime
は、appプロジェクトの実行時依存についての情報を持っています。
ここには、dependencies
ブロック内でcompile
として指定したcommonプロジェクトの情報も含まれています。
コンパイル依存は実行時依存でもあるからです。
attributes 'Class-Path'
のところでは、configurations.runtime
の内容をスペース区切りで文字列連結しています。
なのでマニフェストの中身はClass-Path: common.jar
となります(common以外にも依存関係を追加していた場合は、Class-Path: common.jar another.jar extra.jar
のようになる)。
マニフェストにClass-Path: common.jar
と書いてあるJARは、自分と同じディレクトリーのcommon.jarを探しにいくので、copy
ブロックでapp.jarと同じディレクトリーにcommon.jarをコピーしています。
マニフェストの追記と依存JARのコピーによって、今度はJARの実行が成功します。
libsディレクトリーを持ち運べば、アプリケーションが完結する状態になっています:
master$ gradle clean jar
:app:clean
:common:clean
:common:compileJava
:common:processResources UP-TO-DATE
:common:classes
:common:jar
:app:compileJava
:app:processResources UP-TO-DATE
:app:classes
:app:jar
BUILD SUCCESSFUL
Total time: 0.952 secs
master$ java -jar ../app/build/libs/app.jar
common
master$ tree ../app/build/libs/
../app/build/libs/
├── app.jar
└── common.jar
プロジェクトごとの設定値をルートプロジェクトから分離し、新しいプロジェクトを追加しやすくする
ここまでで、ひとまずやりたかったことはできるようになりました。
次はapp2プロジェクトを追加し、現実の例に近づけたいところですが、まずはスクリプトの整理をします。
appのみの設定をapp/build.gradleに分離する
ひとまず動かしたかったので、appプロジェクトの設定をmasterプロジェクトのbuild.gradleに記載していました。
ですが、このままでは、サブプロジェクトが増えるたびにmaster/build.gradleに似たような記述が増えていってしまいます。
サブプロジェクトを増やしても、変更はsettings.gradleのみに留めたいところです。
そのため、appプロジェクトの設定はappディレクトリーのbuild.gradleに分離します:
master$ tree ../ -L 2
../
├── app
│ ├── build.gradle
│ └── src
├── common
│ └── src
└── master
├── build.gradle
└── settings.gradle
失敗するケース
次のようにすると、一見成功しそうですが、失敗します。
app/build.gradleには、プロジェクトごとに異なるメインクラスのみを指定します。
master/build.gradleでは、project(':app')
ブロックに書いていた内容をsubprojects
ブロックに移します:
jar {
manifest {
attributes 'Main-Class': 'com.github.kazuma1989.dec10_2016.app.Main'
}
}
…
subprojects {
…
jar {
manifest {
attributes 'Class-Path': configurations.runtime.collect{it.name}.join(' ')
}
doLast {
copy {
from configurations.runtime
into libsDir
}
}
}
}
// project(':app') {
// }
33行目(attributes 'Class-Path'
の箇所)でエラーが発生します:
master$ gradle clean jar
FAILURE: Build failed with an exception.
* Where:
Build file '/Users/kazuma/qiita/2016/dec10/master/build.gradle' line: 33
* What went wrong:
A problem occurred evaluating root project 'master'.
> Could not resolve all dependencies for configuration ':app:runtime'.
> Project :app declares a dependency from configuration 'compile' to configuration 'default' which is not declared in the descriptor for project :common.
* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output.
BUILD FAILED
Total time: 0.837 secs
appで利用しているconfigurations.runtime
が解決できないためエラーとなったようです。
これは、commonプロジェクトでJavaプラグインを呼び出す前に、appプロジェクトの設定が実行されたためです。
subprojects {
// app -> commonの順でプロジェクトの設定をしているが(下に続く)
apply {
plugin 'java'
…
}
…
}
project(':app') {
// このブロックはcommonへのJavaプラグイン適用が済んだ後に実行されるため、問題なかった。
jar {
…
}
}
subprojects {
// app -> commonの順でプロジェクトの設定をしている。
apply {
plugin 'java'
…
}
// project(':app')にあった処理をsubprojectsブロックに持ってきたため、commonへのJavaプラグイン適用より前に実行されることとなり、エラー。
jar {
…
}
}
成功するケース
subprojects
ブロックを、「commonとappを含めたすべてのサブプロジェクト」と「common以外のサブプロジェクト」で分けることにより、うまくいくようになります。
ブロックの意味もはっきりするので、可読性としてもこちらのほうがよいでしょう:
// commonとappを含めたすべてのサブプロジェクト
subprojects {
apply {
plugin 'java'
…
}
…
}
// common以外のサブプロジェクト
subprojects {
if (project.name in ['common']) return
dependencies {
…
}
jar {
…
}
}
master$ gradle clean jar
:app:clean
:common:clean
:common:compileJava
:common:processResources UP-TO-DATE
:common:classes
:common:jar
:app:compileJava
:app:processResources UP-TO-DATE
:app:classes
:app:jar
BUILD SUCCESSFUL
Total time: 0.94 secs
master$ java -jar ../app/build/libs/app.jar
common
app/build.gradleをgradle.propertiesに置き換える
プロジェクトごとの設定が値だけの違いしかないので、app/build.gradleをgradle.propertiesに置き換えてしまいます。
gradle.propertiesは、build.gradle同様、プロジェクトディレクトリー直下に存在すると自動で読み込まれるプロパティーファイルです:
master$ tree ../ -L 2
../
├── app
│ ├── gradle.properties
│ └── src
├── common
│ └── src
└── master
├── build.gradle
└── settings.gradle
jar.manifest.attributes.Main-Class=com.github.kazuma1989.dec10_2016.app.Main
build.gradleからは、getProperty
メソッドによって値を取得します。
注意点は、afterEvaluate
ブロックを使う必要があることです。
プロジェクトは、masterプロジェクト、appプロジェクトの順で読み込まれるため、master/build.gradleの時点ではapp/gradle.propertiesがまだ読み込まれていないからです。
afterEvaluate
ブロック内は、各プロジェクトの評価後(プロジェクトの設定を完了した後)に実行されるため、うまくいきます:
…
subprojects {
…
afterEvaluate {
jar {
manifest {
attributes 'Main-Class': getProperty('jar.manifest.attributes.Main-Class')
attributes 'Class-Path': configurations.runtime.collect{it.name}.join(' ')
}
…
}
}
}
まとめ
最終的に、マルチプロジェクトの構成は次のようになりました:
master$ tree ../ -L 2
../
├── app
│ ├── gradle.properties // 各プロジェクトの設定値を書く
│ └── src // 各アプリケーションのソースコード。commonに依存する
├── common
│ └── src // 共通部品
└── master
├── build.gradle // 全プロジェクトで利用するタスクを定義する
└── settings.gradle // マルチプロジェクトに含めるディレクトリーを指定する
masterディレクトリーでgradle jar
とすればすべてのJARが生成されます。
各プロジェクトのディレクトリーでgradle jar
とするか、gradle :common:jar
とすることで個別のJARを生成できます。
生成したJARは、libsディレクトリーに依存するJARとまとまっているため、ディレクトリーごと持ち運べばそのまま使えます。
今後、さらにプロジェクトを増やしていくと、以下のようになります:
master$ tree ../ -L 2
../
├── app
│ ├── gradle.properties
│ └── src
├── app2
│ ├── gradle.properties
│ └── src
├── app3
│ ├── build.gradle // 特別な設定が必要なら、スクリプトを配置してもよい
│ ├── gradle.properties
│ └── src
├── common
│ ├── build.gradle // commonが依存するライブラリーなどを指定する
│ └── src
└── master
├── build.gradle
└── settings.gradle
以上です。
-
1 JavaFXの概要(リリース8) (https://docs.oracle.com/javase/jp/8/javafx/get-started-tutorial/jfx-overview.htm) ↩
-
自己完結型アプリケーションのパッケージ化 (https://docs.oracle.com/javase/jp/8/docs/technotes/guides/deploy/self-contained-packaging.html) ↩
-
Macでtreeコマンド - Qiita (http://qiita.com/kanuma1984/items/c158162adfeb6b217973) ↩
-
Gradleでマルチプロジェクト - Qiita (http://qiita.com/shiena/items/371fe817c8fb6be2bb1e) ↩
-
leftShift
メソッドと<<
演算子は非推奨となりました。(https://docs.gradle.org/3.2/release-notes#the-left-shift-operator-on-the-task-interface) ↩ -
println
の結果を見やすくするため、-q
オプションを使っています。 ↩ -
Gradleには
init
タスクがあらかじめ用意されていますが、余計なファイルを作成したくないので、自前で定義しました。 ↩