Make multi-project executable JAR in Gradle

Purpose of this article

A reminder of the steps to manage a multi-project using the build tool Gradle and create an executable JAR as an artifact.

--Multi-project management --Creating an executable JAR that depends on an external JAR (JAR that includes all dependent JARs, not fat-JAR)

Is the main theme.

Background of article creation

I had the opportunity to create a GUI application with JavaFX [^ JavaFX]. The application is finally distributed in EXE format by a tool called javapackager [^ javapackager], but as a preliminary step, it was necessary to generate a JAR that can be started by double-clicking.

[^ JavaFX]: 1 JavaFX Overview (Release 8) (https://docs.oracle.com/javase/jp/8/javafx/get-started-tutorial/jfx-overview.htm) [^ javapackager]: Self-contained application packaging (https://docs.oracle.com/javase/jp/8/docs/technotes/guides/deploy/self-contained-packaging.html)

In addition, applications had to create multiple types with almost the same internal processing and different detailed behavior. I wanted to make the common part common.jar and the JAR as the entrance to app1.jar, app2.jar (each depends on common.jar), but I looked into Jenkins in the company and searched for a multi-project with a similar configuration. Even when I tried it, there was no script that was completed only with Gradle. There was only a shell script that called the common build.gradle and then the app1 build.gradle.

I could have achieved the purpose by imitating the shell script, but for learning, I decided to create a script that is completed in Gradle.

Verification environment

Create a multi-project and create an executable JAR

For simplicity, let's start with just one app project.

Check the operation with a minimum of multi-project

First, create a minimal multi-project and call the hello task to check the operation.

Create directories app, common, master directly under the working directory, and create build.gradle and settings.gradle directly under master [^ tree command on Mac]:

[^ Tree command on Mac]: Tree command on Mac --Qiita (http://qiita.com/kanuma1984/items/c158162adfeb6b217973)

master$ tree ../
../
├── app
├── common
└── master
    ├── build.gradle
    └── settings.gradle

In settings.gradle, specify the directory you want to recognize as a subproject. The root project is not specified, but the directory named master is automatically recognized as the root project, so it is not specified [^ Multi-project with Gradle]:

[^ Multi-project with Gradle]: Multi-project with Gradle-Qiita (http://qiita.com/shiena/items/371fe817c8fb6be2bb1e)

settings.gradle


includeFlat 'app', 'common'

In build.gradle, define a hello task inside the ʻall projects` block [Why use ^ doLast]:

[Reason for using ^ doLast]: The leftShift method and the<<operator have been deprecated. (https://docs.gradle.org/3.2/release-notes#the-left-shift-operator-on-the-task-interface)

build.gradle


allprojects {
    task hello {
        doLast {
            println "I`m ${project.name}."
        }
    }
}

At this point, call the hello task in the master directory [meaning ^ q]. If you see the following, it's OK:

[Opinion of ^ q]: The -q option is used to make the result of println easier to see.

master$ gradle -q hello
I`m master.
I`m app.
I`m common.

By the way, if you call the hello task in the common directory, only the common task will be called. You can also use this property to build only specific projects:

common$ gradle -q hello
I`m common.

Make app and common an Eclipse project

Add the Eclipse plug-in to your subproject. Add the following after the ʻall projects` block:

build.gradle


…

subprojects {
    apply {
        plugin 'eclipse'
    }
}

With this alone, you can use the ʻeclipse` task and create an Eclipse project:

master$ gradle eclipse
:app:eclipseProject
:app:eclipse
:common:eclipseProject
:common:eclipse

BUILD SUCCESSFUL

Total time: 2.067 secs

However, I actually need the src directory, so I will generate it with the gradle task [reason for not using the ^ init task]. Add a Java plugin and create a ʻinitSrcDirs` task as shown below.

[Reason for not using ^ init task]: Gradle comes with a ʻinit` task, but I didn't want to create extra files, so I defined it myself.

build.gradle


…

subprojects {
    apply {
        plugin 'eclipse'
        plugin 'java'
    }

    task initSrcDirs {
        doLast {
            sourceSets.all {
                java.srcDirs*.mkdirs()
                resources.srcDirs*.mkdirs()
            }
        }
    }
}

ʻWhen you run the initSrcDirs` task, src / main / java, src / main / resources, etc. are created. Even if the files already exist in those paths, they will never be empty, so it's safe to use:

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` Describes the variables that appeared when defining the task.

--sourceSets is a directory system such as src / main / java defined by Java plugins. --mkdirs () calls the mkdirs method of java.io.File because srcDirs is a list of java.io.File.

If you're curious about other syntax (for example, iteration * .), check out Groovy's grammar.

If you call the ʻeclipse task again with the src directory created, the classpath will also pass properly (the ʻeclipse Claspath task that was not there earlier is called):

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

Make the app project dependent on the common project

Let's get down to the essence of what we wanted to achieve with multi-project management. Make the app dependent on common.

Case of failure

First, in order to reproduce the failure case, create the following 2 classes without changing build.gradle. Constant.java is created under common, and Main.java is created under app. Of course, even Eclipse doesn't recognize each other's dependencies, so it can't be complemented and an error occurs:

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);
    }
}

If you run the jar task in this state, the build will fail as expected. The compileJava task, which precedes the jar task, has failed:

master$ gradle jar
:app:compileJava
/Users/kazuma/qiita/2016/dec10/app/src/main/java/com/github/kazuma1989/dec10_2016/app/Main.java:3:error:Package com.github.kazuma1989.dec10_2016.common does not exist
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:error:Can't find symbol
        System.out.println(Constant.NAME);
                           ^
symbol:Variable constant
place:Class Main
2 errors
: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

Successful case

Then make the build successful. Add the following to the end of the subprojects block to define the dependency on common:

build.gradle


…

subprojects {
    …

    //Settings other than common
    if (project.name in ['common']) return

    dependencies {
        compile project(':common')
    }
}

This time the jar task succeeds and the JAR I created works fine. JARization of common is executed first, not in ascending order of project names:

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

The jar task does not necessarily have to be called from the master, only the one in the app can be called. Even in that case, the app will be JARed after compiling common, so it will succeed properly:

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

After setting the dependency, if you execute the ʻeclipse` task again, the error in Eclipse will be resolved and you will be able to use code completion.

Add manifest to make app executable JAR

Set the manifest for the jar task and generate an executable JAR.

Sometimes it works, but the basics fail

Just add a minimal statement at the end of build.gradle and try running the jar task anyway:

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

When you call the created JAR with the java command, the Main class is called:

master$ java -jar ../app/build/libs/app.jar 
common

But it just happened to work. This is because the Constant class cannot be found unless the dependency information for common.jar is described in the manifest. This time, the Main class only references Constant string constants, so it's possible that the value was retained in the Main class at compile time.

Case where setting dependency information to common.jar and always works

Normally, it will be NoClassDefFoundError. It means that the compilation succeeds, but fails at run time.

For example, the following is the case where the base class ʻAbstractApplication` is created in common and inherited by 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

To solve this, change the inside of the jar block as follows. ʻAttributes'Class-Path'has been added to the manifest, and acopy` operation has been added to the task:

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 contains information about the runtime dependencies of the app project. It also contains information about the common project specified as compile in the dependencies block. This is because compilation dependencies are also runtime dependencies.

In ʻattributes'Class-Path', the contents of configurations.runtimeare concatenated with spaces. So the contents of the manifest will beClass-Path: common.jar (if you have added dependencies other than common, it will be Class-Path: common.jar another.jar extra.jar`. ).

The JAR that says Class-Path: common.jar in the manifest will look for common.jar in the same directory as itself, so copy common.jar to the same directory as app.jar in the copy block. I am.

By adding the manifest and copying the dependent JAR, the JAR execution is now successful. Carry the libs directory and your application is complete:

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

Separate the settings for each project from the root project, making it easier to add new projects

At this point, you can do what you wanted to do for the time being. Next, I would like to add an app2 project to bring it closer to a real-life example, but first I will organize the script.

Separate app-only settings into app / build.gradle

I wanted to move it for the time being, so I described the settings of the app project in the build.gradle of the master project. However, if nothing is done, the number of descriptions similar to master / build.gradle will increase as the number of subprojects increases.

Even if I increase the number of subprojects, I want to keep the changes only in settings.gradle. Therefore, the app project settings are separated into build.gradle in the app directory:

master$ tree ../ -L 2
../
├── app
│   ├── build.gradle
│   └── src
├── common
│   └── src
└── master
    ├── build.gradle
    └── settings.gradle

Case of failure

At first glance it seems to succeed, but it fails.

For app / build.gradle, specify only the main class that is different for each project. In master / build.gradle, move what was written in the project (': app') block to the subprojects block:

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') {
// }

An error occurs on line 33 (where ʻ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

It seems that an error occurred because the configurations.runtime used in the app could not be resolved. This is because the settings of the app project were executed before calling the Java plugin in the common project.

groovy:master/build.gradle(app/build.(Before gradle separation)


subprojects {
    // app ->The project is set in the order of common (continued below)
    apply {
        plugin 'java'
        …
    }

    …
}

project(':app') {
    //This block is executed after the Java plugin is applied to common, so there was no problem.
    jar {
        …
    }
}

groovy:master/build.gradle(app/build.After gradle separation)


subprojects {
    // app ->The project is set in the order of common.
    apply {
        plugin 'java'
        …
    }

    // project(':app')Since the processing that was in was brought to the subprojects block, it will be executed before applying the Java plug-in to common, and an error will occur.
    jar {
        …
    }
}

Successful case

Breaking the subprojects block into" all subprojects, including common and app "and" non-common subprojects "will work. The meaning of the block is also clear, so this is also better for readability:

master/build.gradle


//All subprojects including common and app
subprojects {
    apply {
        plugin 'java'
        …
    }

    …
}

//Subprojects other than 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

Replace app / build.gradle with gradle.properties

Since the settings for each project differ only in the value, I replace app / build.gradle with gradle.properties. Like build.gradle, gradle.properties is a property file that is automatically loaded when it exists directly under the project directory:

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

From build.gradle, get the value with the getProperty method.

The caveat is that you need to use the ʻafterEvaluate block. This is because the project is loaded in the order of master project and app project, so app / gradle.properties has not been loaded yet at the time of master / build.gradle. ʻAfterEvaluate blocks work after each project has been evaluated (after completing the project setup), so it works:

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(' ')
            }

            …
        }
    }
}

Summary

In the end, the multi-project configuration looks like this:

master$ tree ../ -L 2
../
├── app
│   ├── gradle.properties   //Write the settings for each project
│   └── src                 //Source code for each application. Depends on common
├── common
│   └── src                 //Common parts
└── master
    ├── build.gradle        //Define tasks to be used in all projects
    └── settings.gradle     //Specify directories to include in multi-project

All JARs will be generated by typing gradle jar in the master directory. You can generate a separate JAR with gradle jar in each project directory or gradle: common: jar. The generated JAR is organized as a JAR that depends on the libs directory, so you can use it as it is if you carry the entire directory.

If we increase the number of projects in the future, it will be as follows:

master$ tree ../ -L 2
../
├── app
│   ├── gradle.properties
│   └── src
├── app2
│   ├── gradle.properties
│   └── src
├── app3
│   ├── build.gradle        //Scripts may be placed if special settings are required
│   ├── gradle.properties
│   └── src
├── common
│   ├── build.gradle        //Specify the library that common depends on
│   └── src
└── master
    ├── build.gradle
    └── settings.gradle

that's all.

Recommended Posts

Make multi-project executable JAR in Gradle
Spring Boot 2 multi-project in Gradle
Set up Gradle multi-project in IntelliJ to build JAR file
Generate a single executable jar with dependent libraries in Gradle 4.9
Make an executable jar using Android Studio
Configure a multi-project with subdirectories in Gradle
How to build an executable jar in Maven
Make Blackjack in Java
Jigsaw notes in Gradle
Gradle settings memo (multi-project to all in one) for myself
Create a jar file that can be executed in Gradle
Java multi-project creation with Gradle
Gradle + Kotlin-Generate jar with DSL
Copy dependent jars in Gradle 5
Refactoring: Make Blackjack in Java
Control tasks performed in Gradle
Make html available in Vapor
[Gradle] The story that the class file did not exist in the jar file
How to make a jar file with no dependencies in Maven
How to create a new Gradle + Java + Jar project in Intellij 2016.03
Add a time stamp to the JAR file name in Gradle