From 50ee2e7315d9ec6a8833ce1343bcfbdb53bcce84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=AFd?= <20957603+ayewo@users.noreply.github.com> Date: Mon, 2 Dec 2024 03:48:27 +0100 Subject: [PATCH] feat: add jlink & jpackage Java examples (#4038) This intended to close https://github.com/com-lihaoyi/mill/issues/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](https://github.com/com-lihaoyi/mill/pull/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 --- .../ROOT/pages/javalib/module-config.adoc | 10 +- example/javalib/module/16-jlink/build.mill | 55 +++++++++ .../16-jlink/foo/resources/application.conf | 1 + .../module/16-jlink/foo/src/foo/Bar.java | 11 ++ .../module/16-jlink/foo/src/module-info.java | 3 + .../bar/resources/application.conf | 1 + example/javalib/module/17-jpackage/build.mill | 107 ++++++++++++++++++ .../foo/resources/application.conf | 1 + .../module/17-jpackage/foo/src/foo/Bar.java | 69 +++++++++++ .../module/17-jpackage/foo/src/foo/Foo.java | 32 ++++++ .../17-jpackage/foo/src/module-info.java | 4 + scalalib/src/mill/scalalib/JavaModule.scala | 7 ++ scalalib/src/mill/scalalib/JlinkModule.scala | 103 +++++++++++++++++ .../src/mill/scalalib/JpackageModule.scala | 84 ++++++++++++++ 14 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 example/javalib/module/16-jlink/build.mill create mode 100644 example/javalib/module/16-jlink/foo/resources/application.conf create mode 100644 example/javalib/module/16-jlink/foo/src/foo/Bar.java create mode 100644 example/javalib/module/16-jlink/foo/src/module-info.java create mode 100644 example/javalib/module/17-jpackage/bar/resources/application.conf create mode 100644 example/javalib/module/17-jpackage/build.mill create mode 100644 example/javalib/module/17-jpackage/foo/resources/application.conf create mode 100644 example/javalib/module/17-jpackage/foo/src/foo/Bar.java create mode 100644 example/javalib/module/17-jpackage/foo/src/foo/Foo.java create mode 100644 example/javalib/module/17-jpackage/foo/src/module-info.java create mode 100644 scalalib/src/mill/scalalib/JlinkModule.scala create mode 100644 scalalib/src/mill/scalalib/JpackageModule.scala diff --git a/docs/modules/ROOT/pages/javalib/module-config.adoc b/docs/modules/ROOT/pages/javalib/module-config.adoc index 2a3ea2a66d1..88a5e4f545a 100644 --- a/docs/modules/ROOT/pages/javalib/module-config.adoc +++ b/docs/modules/ROOT/pages/javalib/module-config.adoc @@ -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[] @@ -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[] diff --git a/example/javalib/module/16-jlink/build.mill b/example/javalib/module/16-jlink/build.mill new file mode 100644 index 00000000000..6aae51422cc --- /dev/null +++ b/example/javalib/module/16-jlink/build.mill @@ -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! + +*/ diff --git a/example/javalib/module/16-jlink/foo/resources/application.conf b/example/javalib/module/16-jlink/foo/resources/application.conf new file mode 100644 index 00000000000..9341faacf2a --- /dev/null +++ b/example/javalib/module/16-jlink/foo/resources/application.conf @@ -0,0 +1 @@ +Bar Application Conf diff --git a/example/javalib/module/16-jlink/foo/src/foo/Bar.java b/example/javalib/module/16-jlink/foo/src/foo/Bar.java new file mode 100644 index 00000000000..a87ef2b02ad --- /dev/null +++ b/example/javalib/module/16-jlink/foo/src/foo/Bar.java @@ -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!"); + } +} diff --git a/example/javalib/module/16-jlink/foo/src/module-info.java b/example/javalib/module/16-jlink/foo/src/module-info.java new file mode 100644 index 00000000000..17b00987f29 --- /dev/null +++ b/example/javalib/module/16-jlink/foo/src/module-info.java @@ -0,0 +1,3 @@ +module foo { + requires java.logging; +} diff --git a/example/javalib/module/17-jpackage/bar/resources/application.conf b/example/javalib/module/17-jpackage/bar/resources/application.conf new file mode 100644 index 00000000000..9341faacf2a --- /dev/null +++ b/example/javalib/module/17-jpackage/bar/resources/application.conf @@ -0,0 +1 @@ +Bar Application Conf diff --git a/example/javalib/module/17-jpackage/build.mill b/example/javalib/module/17-jpackage/build.mill new file mode 100644 index 00000000000..ff88bb8bc8f --- /dev/null +++ b/example/javalib/module/17-jpackage/build.mill @@ -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 +// ---- diff --git a/example/javalib/module/17-jpackage/foo/resources/application.conf b/example/javalib/module/17-jpackage/foo/resources/application.conf new file mode 100644 index 00000000000..4f562e845c8 --- /dev/null +++ b/example/javalib/module/17-jpackage/foo/resources/application.conf @@ -0,0 +1 @@ +Foo Application Conf \ No newline at end of file diff --git a/example/javalib/module/17-jpackage/foo/src/foo/Bar.java b/example/javalib/module/17-jpackage/foo/src/foo/Bar.java new file mode 100644 index 00000000000..510bb668dd7 --- /dev/null +++ b/example/javalib/module/17-jpackage/foo/src/foo/Bar.java @@ -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()); + } + }); + } +} diff --git a/example/javalib/module/17-jpackage/foo/src/foo/Foo.java b/example/javalib/module/17-jpackage/foo/src/foo/Foo.java new file mode 100644 index 00000000000..f839da85062 --- /dev/null +++ b/example/javalib/module/17-jpackage/foo/src/foo/Foo.java @@ -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; + } +} diff --git a/example/javalib/module/17-jpackage/foo/src/module-info.java b/example/javalib/module/17-jpackage/foo/src/module-info.java new file mode 100644 index 00000000000..57513ced7a6 --- /dev/null +++ b/example/javalib/module/17-jpackage/foo/src/module-info.java @@ -0,0 +1,4 @@ +module foo { + requires java.logging; + requires java.desktop; +} diff --git a/scalalib/src/mill/scalalib/JavaModule.scala b/scalalib/src/mill/scalalib/JavaModule.scala index 3a2d71ad813..1c901525557 100644 --- a/scalalib/src/mill/scalalib/JavaModule.scala +++ b/scalalib/src/mill/scalalib/JavaModule.scala @@ -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. diff --git a/scalalib/src/mill/scalalib/JlinkModule.scala b/scalalib/src/mill/scalalib/JlinkModule.scala new file mode 100644 index 00000000000..ff0ebe0e48c --- /dev/null +++ b/scalalib/src/mill/scalalib/JlinkModule.scala @@ -0,0 +1,103 @@ +package mill +package scalalib + +import mill._ +import mill.util.Jvm + +/** + * Support building modular runtime images with the `jlink` tool, which is included in JDK 9 and later. + * + * The official `jlink` docs: https://docs.oracle.com/en/java/javase/23/docs/specs/man/jlink.html + */ +trait JlinkModule extends JavaModule { + + /** The base name for the runtime image */ + def jlinkImageName: T[String] = T { "jlink" } + + /** Name of the main module to be included in the runtime image */ + def jlinkModuleName: T[String] = T { "" } + + /** The main module's version number. */ + def jlinkModuleVersion: T[Option[String]] = T { None } + + /** The main class to use as the runtime entry point. */ + def jlinkMainClass: T[String] = T { finalMainClass() } + + /** + * Compress level for the runtime image. + * On newer versions of OpenJDK, valid values range between: + * "zip-0" (no compression) and "zip-9" (best compression). + * + * On all versions of Oracle's JDK, valid values range between: + * 0 (no compression), 1 (constant string sharing) and 2 (ZIP). + * + * Assumes you are on a recent OpenJDK version thus defaults to "zip-6". + */ + def jlinkCompressLevel: T[String] = T { "zip-6" } + + /** + * Creates a Java module file (.jmod) from compiled classes + */ + def jmodPackage: T[PathRef] = T { + + val mainClass: String = finalMainClass() + val outputPath = T.dest / "jlink.jmod" + + val libs = T.dest / "libs" + val cp = runClasspath().map(_.path) + val jars = cp.filter(os.exists).zipWithIndex.map { case (p, idx) => + val dest = libs / s"${p.last}" + os.copy(p, dest, createFolders = true) + dest + } + + val classPath = jars.map(_.toString).mkString(sys.props("path.separator")) + val args = { + val baseArgs = Seq( + Jvm.jdkTool("jmod", this.zincWorker().javaHome().map(_.path)), + "create", + "--class-path", + classPath.toString, + "--main-class", + mainClass, + "--module-path", + classPath.toString, + outputPath.toString + ) + + val versionArgs = jlinkModuleVersion().toSeq.flatMap { version => + Seq("--module-version", version) + } + + baseArgs ++ versionArgs + } + os.proc(args).call() + + PathRef(outputPath) + } + + /** Builds a custom runtime image using jlink */ + def jlinkAppImage: T[PathRef] = T { + val modulePath = jmodPackage().path.toString + val outputPath = T.dest / "jlink-runtime" + + val args = Seq( + Jvm.jdkTool("jlink", this.zincWorker().javaHome().map(_.path)), + "--launcher", + s"${jlinkImageName()}=${jlinkModuleName()}/${jlinkMainClass()}", + "--module-path", + modulePath, + "--add-modules", + jlinkModuleName(), + "--output", + outputPath.toString, + "--compress", + jlinkCompressLevel().toString, + "--no-header-files", + "--no-man-pages" + ) + os.proc(args).call() + + PathRef(outputPath) + } +} diff --git a/scalalib/src/mill/scalalib/JpackageModule.scala b/scalalib/src/mill/scalalib/JpackageModule.scala new file mode 100644 index 00000000000..af6c47908bd --- /dev/null +++ b/scalalib/src/mill/scalalib/JpackageModule.scala @@ -0,0 +1,84 @@ +package mill +package scalalib + +import mill._ +import mill.util.Jvm + +/** + * Support for building a native package / installer with the `jpackage` tool which comes bundled with JDK 14 and later. + * + * The official `jpackage` docs: https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html + */ +trait JpackageModule extends JavaModule { + + /** The application name */ + def jpackageName: T[String] = T { artifactName() } + + /** The main class to use as the entry point to the native package / installer. */ + def jpackageMainClass: T[String] = T { finalMainClass() } + + /** + * The type of native package / installer to be created. + * + * Valid values are: + * "app-image" - any OS + * "dmg", "pkg" - macOS (native package, installer) + * "exe", "msi" - Windows (native package, installer) + * "rpm", "deb" - Linux + * + * If unspecified, defaults to "app-image" which will build a package native to the host platform. + */ + def jpackageType: T[String] = T { "app-image" } + + /** + * The classpath used for the `jpackage` tool. The first entry needs to be the main jar. + * In difference to [[runClasspath]], it contains the built jars of all dependent modules. + */ + def jpackageRunClasspath: T[Seq[PathRef]] = T { + val recLocalClasspath = (localClasspath() ++ transitiveLocalClasspath()).map(_.path) + + val runCp = runClasspath().filterNot(pr => recLocalClasspath.contains(pr.path)) + + val mainJar = jar() + val recJars = transitiveJars() + + mainJar +: (recJars ++ runCp) + } + + /** Builds a native package of the main application. */ + def jpackageAppImage: T[PathRef] = T { + // materialize all jars into a "lib" dir + val libs = T.dest / "lib" + val cp = jpackageRunClasspath().map(_.path) + val jars = cp.filter(os.exists).zipWithIndex.map { case (p, idx) => + val dest = libs / s"${idx + 1}-${p.last}" + os.copy(p, dest, createFolders = true) + dest + } + + val appName = jpackageName() + val appType = jpackageType() + val mainClass = jpackageMainClass() + val mainJarName = jars.head.last + + val args: Seq[String] = Seq( + Jvm.jdkTool("jpackage", this.zincWorker().javaHome().map(_.path)), + "--type", + appType, + "--name", + appName, + "--input", + libs.toString(), + "--main-jar", + mainJarName, + "--main-class", + mainClass + ) + + // run jpackage tool + val outDest = T.dest / "image" + os.makeDir.all(outDest) + os.proc(args).call(cwd = outDest) + PathRef(outDest) + } +}