Packaging Java Projects

This page will discuss common topics around packaging your Java projects for distribution.

Building Executable Assemblies

Mill’s built in .assembly task makes it easy to generate an executable assembly jar from any JVM module. These assembly jars contain the transitive classpath of the module flattened into a single jar file, along with a Bash/Bat script prefix allowing you to run the jar via ./out.jar on Mac/Linux and ./out.bat on Windows. JVM assemblies are convenient mostly-self-contained executables, with the end user only needing to ensure they have a JVM installed on the machine they wish to run them on.

You can also customize the assembly jar as shown below:

build.mill (download, browse)
package build
import mill.*, javalib.*
import mill.javalib.Assembly.*

object foo extends JavaModule {
  def moduleDeps = Seq(bar)
  def assemblyRules = Seq(
    // all application.conf files will be concatenated into single file
    Rule.Append("application.conf"),
    // all *.conf files will be concatenated into single file
    Rule.AppendPattern(".*\\.conf"),
    // all *.temp files will be excluded from a final jar
    Rule.ExcludePattern(".*\\.temp"),
    // the `shapeless` package will be relocated under the `shade` package
    Rule.Relocate("shapeless.**", "shade.shapeless.@1")
  )
}

object bar extends JavaModule {}

The most common way of configuring an assembly is excluding some files from a final jar (like signature files, and manifest files from library jars), and merging duplicated files (for instance reference.conf files from library dependencies). This is done by overriding def assemblyRules as shown above

By default mill excludes all *.sf, *.dsa, *.rsa, and META-INF/MANIFEST.MF files from assembly, and concatenates all reference.conf files. You can also define your own merge/exclude rules.

> ./mill foo.assembly

> unzip -p ./out/foo/assembly.dest/out.jar application.conf || true
Bar Application Conf
Foo Application Conf

> java -jar ./out/foo/assembly.dest/out.jar
Loaded application.conf from resources:...
...Foo Application Conf
...Bar Application Conf

Note that when running the assembly directly via ./out.jar, you can configure JVM flags via the JAVA_OPTS environment variable, and select the JVM to use via JAVA_HOME.

> JAVA_OPTS=-Dtest.property=1337 ./out/foo/assembly.dest/out.jar
Loaded test.property: 1337

Building Native Image Binaries with Graal VM

This example uses NativeImageModule to generate a native executable using Graal VM. We recommend you configure a specific JDK version via a custom JvmWorkerModule overriding def jvmVersion (shown above), as not every JVM can build Graal native images.

build.mill.yaml (download, browse)
extends: JavaModule
foo/package.mill.yaml (download, browse)
extends: [JavaModule, NativeImageModule]
jvmVersion: graalvm-community:17.0.7
nativeImageOptions: ["--no-fallback"]
> ./mill show foo.nativeImage
GraalVM Native Image: Generating...native-executable...
Finished generating...native-executable...

> ./out/foo/nativeImage.dest/native-executable
Hello, World!

For another example building a slightly less trivial project into a Graal native image, see below:

This example shows how to generate native images for projects using third-party libraries, in this case Scalatags and Mainargs. We also demonstrate setting using -Os to optimize for the smallest binary size which is available in the graalvm-community:23 JDK selected above

build.mill.yaml (download, browse)
extends: JavaModule
foo/package.mill.yaml (download, browse)
extends: [JavaModule, NativeImageModule]
mvnDeps:
  - net.sourceforge.argparse4j:argparse4j:0.9.0
  - org.thymeleaf:thymeleaf:3.1.1.RELEASE
  - org.slf4j:slf4j-nop:2.0.7
nativeImageOptions:
  - "--no-fallback"
  - "-H:IncludeResourceBundles=net.sourceforge.argparse4j.internal.ArgumentParserImpl"
  - "-Os"
jvmVersion: graalvm-community:23.0.1
> ./mill show foo.nativeImage

> ./out/foo/nativeImage.dest/native-executable --text hello-world
<h1>hello-world</h1>

You can see the Graal documentation to see what flags are available:

Or access the native-image compiler directly via show foo.nativeImageTool if you want to experiment it or view its --help text to see what you need to pass to nativeImageOptions:

> ./mill show foo.nativeImageTool # mac/linux
".../bin/native-image"

> ./mill show foo.nativeImageTool # windows
".../bin/native-image.cmd"

For more details on using Graal, check this blog post:

Building Repackage Assemblies

An alternative way to produce self-executable assemblies is the RepackageModule which used the Spring Boot Tools suite. Instead of copying and merging dependencies classes and resources into a flat jar file, it embeds all dependencies as-is in the final jar. One of the pros of this approach is, that all dependency archives are kept unextracted, which makes later introspection for checksums, authorship and copyright questions easier.

build.mill (download, browse)
package build

import mill.*, javalib.*, publish.*
import mill.javalib.repackage.RepackageModule

trait MyModule extends JavaModule, PublishModule {
  def publishVersion = "0.0.1"

  def pomSettings = PomSettings(
    description = "Hello",
    organization = "com.lihaoyi",
    url = "https://github.com/lihaoyi/example",
    licenses = Seq(License.MIT),
    versionControl = VersionControl.github("lihaoyi", "example"),
    developers = Seq(Developer("lihaoyi", "Li Haoyi", "https://github.com/lihaoyi"))
  )

  def mvnDeps = Seq(mvn"org.thymeleaf:thymeleaf:3.1.1.RELEASE")

  object test extends JavaTests, TestModule.Junit4
}

object foo extends MyModule, RepackageModule { (1)
  def moduleDeps = Seq(bar, qux)
}

object bar extends MyModule {
  def moduleDeps = Seq(qux)
}

object qux extends MyModule
1 Add the mill.javalib.repackage.RepackageModule to the executable module.
> ./mill foo.run
Foo.value: <h1>hello</h1>
Bar.value: <p>world</p>
Qux.value: 31337

> ./mill __.test
...Test run foo.FooTests finished: 0 failed, 0 ignored, 1 total, ...s
...Test run bar.BarTests finished: 0 failed, 0 ignored, 1 total, ...s

> ./mill show foo.repackagedJar
".../out/foo/repackagedJar.dest/out.jar"

> ./out/foo/repackagedJar.dest/out.jar
Foo.value: <h1>hello</h1>
Bar.value: <p>world</p>
Qux.value: 31337

> unzip -l ./out/foo/repackagedJar.dest/out.jar "BOOT-INF/lib*"
...BOOT-INF/lib/thymeleaf-3.1.1.RELEASE.jar
...BOOT-INF/lib/ognl-3.3.4.jar
...BOOT-INF/lib/attoparser-2.0.6.RELEASE.jar
...BOOT-INF/lib/unbescape-1.1.6.RELEASE.jar
...BOOT-INF/lib/slf4j-api-2.0.5.jar
...BOOT-INF/lib/javassist-3.29.0-GA.jar
...BOOT-INF/lib/qux-0.0.1.jar
...BOOT-INF/lib/bar-0.0.1.jar

Further notes:

  • a small wrapper application needs to be added, which is run as entry point and transparently manages loading the embedded jars and running your main method. This works for all Java (also Scala or Kotlin) applications.

  • It’s not necessary to use the Spring Framework in the application.

  • The resulting jar is a self-executable application, but it might not suitable to be used on the classpath of other applications.

  • Since the final jar produced with the RepackageModule.repackagedJar task often contains significantly less ZIP entries then the jar file produced with .assembly, it’s possible to workaround an issue where JavaModule.assembly cannot produce executable assemblies due to some JVM limitations in ZIP file handling of large files.

This example illustrates how to use Mill to generate a runtime image using the jlink tool. Starting with JDK 9, jlink bundles Java app code with a stripped-down version of the JVM.

build.mill (download, browse)
package build
import mill.*, javalib.*
import mill.javalib.Assembly.*
import mill.scalalib.JlinkModule

object foo extends JavaModule, JlinkModule {
  def jlinkModuleName: T[String] = Task { "foo" }
  def jlinkModuleVersion: T[Option[String]] = Task { Option("1.0") }
  def jlinkCompressLevel: T[String] = Task { "2" }
}

Most of the work is done by the trait JlinkModule in two steps:

  • It uses the jmod tool to create a jlink.jmod file for the main Java module. The main Java module is typically the module containing the mainClass.

If your build file doesn’t explicitly specify a mainClass, JlinkModule will infer it from JavaModule, which is its parent trait. You can explicitly specify a mainClass like so in your build file:

def mainClass: T[Option[String]] = { Some("com.foo.app.Main") }
  • It then uses the jlink tool, to link the previously created jlink.jmod with a runtime image.

With respect to the jlinkCompressLevel option, on recent builds of OpenJDK and its descendants, jlink will accept [0, 1, 2] but it will issue a deprecation warning. Valid values on OpenJDK range between: ["zip-0" - "zip-9"].

The version of jlink that ships with the Oracle JDK will only accept [0, 1, 2] as valid values for compression, with 0 being "no compression" and 2 being "ZIP compression".

To use a specific JDK, first set your JAVA_HOME environment variable prior to running the build.

export JAVA_HOME=/Users/mac/.sdkman/candidates/java/17.0.9-oracle/
> ./mill foo.jlinkAppImage

> ./mill show foo.jlinkAppImage
".../out/foo/jlinkAppImage.dest/jlink-runtime"

> ./out/foo/jlinkAppImage.dest/jlink-runtime/bin/jlink
... foo.Bar main
INFO: Hello World!

Java Installers using jpackage

This example illustrates how to use Mill to generate a native package/installer using the jpackage tool.

build.mill (download, browse)
package build
import mill.*, javalib.*
import mill.javalib.Assembly.*
import mill.scalalib.JpackageModule

object foo extends JavaModule, JpackageModule {
  def jpackageType = "app-image"

  def assemblyRules = Seq(
    // all application.conf files will be concatenated into single file
    Rule.Append("application.conf"),
    // all *.conf files will be concatenated into single file
    Rule.AppendPattern(".*\\.conf")
  )
}

JPMS (Java Platform Module System) is a modern distribution format that was designed to avoid several of the shortcomings of the ubiquitous JAR format, especially "JAR Hell".

A defining characteristic of module-based Java applications based on the JPMS format is that a module-info.java must be defined at the root of the module’s source file hierarchy. The module-info.java must explicitly list modules that it depends on, and also list packages that it exports, to make the integrity of these relationships easy to verify, both at compile-time and run-time.

Starting with version 14, the JDK ships with the jpackage tool which can assemble any module-based Java application into a native package/installer.

The above build file expects the following project layout:

build.mill
foo/
    src/
        Foo.java
        Bar.java

    module-info.java
> ./mill foo.jpackageAppImage

> ./mill show foo.jpackageAppImage
".../out/foo/jpackageAppImage.dest/image"
The term Module is also used in Mill to refer to Mill Modules. This is not to be confused with Java app code structured as modules according to the JPMS format.

The JpackageModule trait will infer most of the options needed to assemble a native package/installer, but you can still customize its output. In our example, we specified:

def jpackageType = "pkg"

This tells jpackage to generate a .pkg, which is the native installer format on macOS. Valid values on macOS are: dmg, pkg and app-image.

jpackage doesn’t support cross-build to a different OS/CPU combination than the one you the build is running on. For example, the jpackage binary shipped with a macOS JDK cannot be used to produce a native installer for another OS like Windows or Linux.

On macOS, jpackageType accepts 3 values: "dmg" or "pkg" or "app-image" (default).

  • Setting def jpackageType = "dmg" will produce:

> ls -l ./out/foo/jpackageAppImage.dest/image
... foo-1.0.dmg
  • Setting def jpackageType = "pkg" will produce:

> ls -l ./out/foo/jpackageAppImage.dest/image
... foo-1.0.pkg
  • Setting def jpackageType = "app-image" will produce:

> ls -l ./out/foo/jpackageAppImage.dest/image
... foo.app/
./out/foo/jpackageAppImage.dest/image/foo.app/Contents/MacOS/foo
... foo.Foo readConf
INFO: Loaded application.conf from resources: Foo Application Conf
... foo.Bar ...
INFO: Hello World application started successfully