Skip to content

Commit

Permalink
feat: add jlink & jpackage Java examples (#4038)
Browse files Browse the repository at this point in the history
This intended to close #3638.

The `JlinkModule` creates a runtime image in two steps:
1. it creates a `jlink.jmod` file for the module containing the
`mainClass` using the
[`jmod`](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jmod.html)
tool;
2. it then uses the
[jlink](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jlink.html)
tool, to link the previously created `jlink.jmod` with a runtime image.

The generated runtime image can be executed with:
```sh
./out/foo/jlinkAppImage.dest/jlink-runtime/bin/jlink
```

As for the `JpackageModule` implementation, I decided to reuse most of
the `JpackageModule` code @lefou
[linked](#3201) to the original
issue. Of course his code completely avoids the `jmod` dance by using
[`jpackage`](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html)
to create a native package/installer directly.

`jpackage` supports up to 3 different package/installer outputs on
macOS:
```sh
jpackageType = "dmg"
./out/foo/jpackageAppImage.dest/image/foo-1.0.dmg

jpackageType = "pkg"
./out/foo/jpackageAppImage.dest/image/foo-1.0.pkg

jpackageType = "app-image"
./out/foo/jpackageAppImage.dest/image/foo.app
```

Would be interested to hear your thoughts on the slight difference
between how the 2 traits are implemented.

---------

Co-authored-by: Li Haoyi <haoyi.sg@gmail.com>
  • Loading branch information
ayewo and lihaoyi authored Dec 2, 2024
1 parent e793d31 commit 50ee2e7
Show file tree
Hide file tree
Showing 14 changed files with 487 additions and 1 deletion.
10 changes: 9 additions & 1 deletion docs/modules/ROOT/pages/javalib/module-config.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ include::partial$example/javalib/module/8-annotation-processors.adoc[]

include::partial$example/javalib/module/9-docjar.adoc[]


[[specifying-main-class]]
== Specifying the Main Class

include::partial$example/javalib/module/11-main-class.adoc[]
Expand All @@ -56,3 +56,11 @@ include::partial$example/javalib/module/3-override-tasks.adoc[]
== Native C Code with JNI

include::partial$example/javalib/module/15-jni.adoc[]

== Java App and JVM Bundle using jlink

include::partial$example/javalib/module/16-jlink.adoc[]

== Java App, JVM Bundle and Installer using jpackage

include::partial$example/javalib/module/17-jpackage.adoc[]
55 changes: 55 additions & 0 deletions example/javalib/module/16-jlink/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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.

//// SNIPPET:BUILD
package build
import mill._, javalib._
import mill.javalib.Assembly._
import mill.scalalib.JlinkModule

object foo extends JavaModule with JlinkModule {
def jlinkModuleName: T[String] = T { "foo" }
def jlinkModuleVersion: T[Option[String]] = T { Option("1.0") }
def jlinkCompressLevel: T[String] = T { "2" }
}
//// SNIPPET:END

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

// 1.0. 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.
// See xref:javalib/module-config.adoc#specifying-main-class[Specifying the Main Class] to learn more on how to influence the inference process.
// You can explicitly specify a `mainClass` like so in your build file:

//// SNIPPET:BUILD
// def mainClass: T[Option[String]] = { Option("com.foo.app.Main") }
//// SNIPPET:END

// 2.0. 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"].

// NOTE: 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".

/** Usage

// 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!

*/
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bar Application Conf
11 changes: 11 additions & 0 deletions example/javalib/module/16-jlink/foo/src/foo/Bar.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package foo;

import java.util.logging.Logger;

public class Bar {
private static final Logger LOG = Logger.getLogger(Bar.class.getName());

public static void main(String[] args) {
LOG.info("Hello World!");
}
}
3 changes: 3 additions & 0 deletions example/javalib/module/16-jlink/foo/src/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module foo {
requires java.logging;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bar Application Conf
107 changes: 107 additions & 0 deletions example/javalib/module/17-jpackage/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// This example illustrates how to use Mill to generate a native package/installer
// using the `jpackage` tool.

//// SNIPPET:BUILD
package build
import mill._, javalib._
import mill.javalib.Assembly._
import mill.scalalib.JpackageModule

object foo extends JavaModule with 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")
)
}
//// SNIPPET:END

// 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:
//
//// SNIPPET:TREE
//
// ----
// build.mill
// foo/
// src/
// Foo.java
// Bar.java
//
// module-info.java
// ----
//
//// SNIPPET:END

// The build defines a `foo` module that uses the `trait JpackageModule`.

// NOTE: The term `Module` is also used in Mill to refer to xref:fundamentals/modules.adoc[traits].
// 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`.

// NOTE: `jpackage` doesn't not support cross-targeting. Cross-targeting in this
// context means the `jpackage` binary shipped with a macOS JDK
// cannot be used to produce a native installer for another OS like Windows or Linux.

/** Usage

> mill foo.assembly

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

> java -jar ./out/foo/assembly.dest/out.jar
INFO: Loaded application.conf from resources: Foo Application Conf
INFO: Hello World application started successfully

> mill foo.jpackageAppImage

> mill show foo.jpackageAppImage
".../out/foo/jpackageAppImage.dest/image"
*/

// 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
// ----
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Foo Application Conf
69 changes: 69 additions & 0 deletions example/javalib/module/17-jpackage/foo/src/foo/Bar.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package foo;

import static foo.Foo.LOGGER;

import java.awt.*;
import java.io.IOException;
import javax.swing.*;

public class Bar {

public static boolean isCI() {
String[] ciEnvironments = {
"CI",
"CONTINUOUS_INTEGRATION",
"JENKINS_URL",
"TRAVIS",
"CIRCLECI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BITBUCKET_PIPELINE",
"TEAMCITY_VERSION"
};

for (String env : ciEnvironments) {
if (System.getenv(env) != null) {
return true;
}
}

return false;
}

public static void main(String[] args) throws IOException {
// Needed because Swing GUIs don't work in headless CI environments
if (isCI()) {
Foo.readConf();
LOGGER.info("Hello World application started successfully");
System.exit(0);
}

// Use SwingUtilities.invokeLater to ensure thread safety
SwingUtilities.invokeLater(() -> {
try {
// Set a modern look and feel
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());

// Create the main window
JFrame frame = new JFrame("Hello World");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(300, 200);
frame.setLocationRelativeTo(null);

// Create a label from the application.conf
JLabel label = new JLabel(Foo.readConf(), SwingConstants.CENTER);
label.setFont(new Font("Arial", Font.BOLD, 16));

// Add the label to the frame
frame.getContentPane().add(label);

// Make the frame visible
frame.setVisible(true);

LOGGER.info("Hello World application started successfully");
} catch (Exception e) {
LOGGER.severe("Error initializing application: " + e.getMessage());
}
});
}
}
32 changes: 32 additions & 0 deletions example/javalib/module/17-jpackage/foo/src/foo/Foo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package foo;

import java.io.IOException;
import java.io.InputStream;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;

public class Foo {
public static final Logger LOGGER = Logger.getLogger(Foo.class.getName());

static {
// Configure the logger to use a custom formatter
for (Handler handler : LOGGER.getParent().getHandlers()) {
handler.setFormatter(new SimpleFormatter() {
@Override
public String format(LogRecord record) {
// Return the log level, message, but omit the timestamp
return String.format("%s: %s%n", record.getLevel(), record.getMessage());
}
});
}
}

public static String readConf() throws IOException {
InputStream inputStream = Foo.class.getClassLoader().getResourceAsStream("application.conf");
String conf = new String(inputStream.readAllBytes());
LOGGER.info("Loaded application.conf from resources: " + conf);
return conf;
}
}
4 changes: 4 additions & 0 deletions example/javalib/module/17-jpackage/foo/src/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module foo {
requires java.logging;
requires java.desktop;
}
7 changes: 7 additions & 0 deletions scalalib/src/mill/scalalib/JavaModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,13 @@ trait JavaModule
T.traverse(transitiveModuleRunModuleDeps)(_.localClasspath)().flatten
}

/**
* Almost the same as [[transitiveLocalClasspath]], but using the [[jar]]s instead of [[localClasspath]].
*/
def transitiveJars: T[Seq[PathRef]] = Task {
T.traverse(transitiveModuleCompileModuleDeps)(_.jar)()
}

/**
* Same as [[transitiveLocalClasspath]], but with all dependencies on [[compile]]
* replaced by their non-compiling [[bspCompileClassesPath]] variants.
Expand Down
Loading

0 comments on commit 50ee2e7

Please sign in to comment.