LoginSignup
24
41

More than 5 years have passed since last update.

Gradleでマルチプロジェクトを実行可能JARにする

Posted at

この記事の目的

ビルドツール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

settings.gradle
includeFlat 'app', 'common'

build.gradleでは、allprojectsブロック内でhelloタスクを定義します5

build.gradle
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ブロックの後ろに追記します:

build.gradle


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タスクを下記のように作成します。

build.gradle


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でも互いの依存関係は認識できていないので、補完できないうえエラーも出ます:

Constant.java
package com.github.kazuma1989.dec10_2016.common;

public class Constant {
    public static final String NAME = "common";
}
Main.java
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への依存関係を定義します:

build.gradle


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タスクを実行してみます:

build.gradle


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操作が追加されています:

build.gradle


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ブロックに移します:

app/build.gradle
jar {
    manifest {
        attributes 'Main-Class': 'com.github.kazuma1989.dec10_2016.app.Main'
    }
}
master/build.gradle


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プロジェクトの設定が実行されたためです。

master/build.gradle(app/build.gradle分離前)
subprojects {
    // app -> commonの順でプロジェクトの設定をしているが(下に続く)
    apply {
        plugin 'java'
        
    }

    
}

project(':app') {
    // このブロックはcommonへのJavaプラグイン適用が済んだ後に実行されるため、問題なかった。
    jar {
        
    }
}
master/build.gradle(app/build.gradle分離後)
subprojects {
    // app -> commonの順でプロジェクトの設定をしている。
    apply {
        plugin 'java'
        
    }

    // project(':app')にあった処理をsubprojectsブロックに持ってきたため、commonへのJavaプラグイン適用より前に実行されることとなり、エラー。
    jar {
        
    }
}

成功するケース

subprojectsブロックを、「commonとappを含めたすべてのサブプロジェクト」と「common以外のサブプロジェクト」で分けることにより、うまくいくようになります。
ブロックの意味もはっきりするので、可読性としてもこちらのほうがよいでしょう:

master/build.gradle
// 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
app/gradle.properties
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ブロック内は、各プロジェクトの評価後(プロジェクトの設定を完了した後)に実行されるため、うまくいきます:

master/build.gradle


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. 1 JavaFXの概要(リリース8) (https://docs.oracle.com/javase/jp/8/javafx/get-started-tutorial/jfx-overview.htm

  2. 自己完結型アプリケーションのパッケージ化 (https://docs.oracle.com/javase/jp/8/docs/technotes/guides/deploy/self-contained-packaging.html

  3. Macでtreeコマンド - Qiita (http://qiita.com/kanuma1984/items/c158162adfeb6b217973

  4. Gradleでマルチプロジェクト - Qiita (http://qiita.com/shiena/items/371fe817c8fb6be2bb1e

  5. leftShiftメソッドと<<演算子は非推奨となりました。(https://docs.gradle.org/3.2/release-notes#the-left-shift-operator-on-the-task-interface

  6. printlnの結果を見やすくするため、-qオプションを使っています。 

  7. Gradleにはinitタスクがあらかじめ用意されていますが、余計なファイルを作成したくないので、自前で定義しました。 

24
41
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
41