GenerationLost
GenerationLost

Reputation: 461

Copy external resource files after building JavaFX app with Maven

Our JavaFX application is built with mvn clean javafx:jlink to create a standalone package for distribution. Now I need to include external resources (by that I mean config/content files in JSON that are not packaged into the application but reside outside in a freely accessible folder structure) into that bundle, preferably within the build process with maven.

So I would like to achieve the following: Copy MyProject/res/* to MyProject/target/MyProject/res

Many solutions I've found use the maven resources plugin and I tried the following to no avail:

<plugin>
            <artifactId>maven-resources-plugin</artifactId>
            <version>3.3.0</version>
            <executions>
                <execution>
                    <id>copy-external-resources</id>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>copy-resources</goal>
                    </goals>
                    <configuration>
                        <outputDirectory>${basedir}/target/res</outputDirectory>
                        <resources>
                            <resource>
                                <directory>res</directory>
                            </resource>
                        </resources>
                    </configuration>
                </execution>
            </executions>
        </plugin>

I know the path itself (/target/res) isn't necessarily right since I want it in the MyProject folder, but either way, no folder is copied at all. What am I doing wrong here? Please note that I'm not too familiar with Maven and it's phases and different stages.

This is how it's supposed to look like: The red path is what's supposed to be copied to the target folder after build

The red path is what's supposed to be copied to the target folder after build.

Upvotes: 3

Views: 820

Answers (3)

jewelsea
jewelsea

Reputation: 159281

You can use an assembly to create a zip of the jlink output as well as the additional "loose files".

The Assembly Plugin for Maven enables developers to combine project output into a single distributable archive that also contains dependencies, modules, site documentation, and other files.

Using jpackage (and perhaps JPackageScriptFX) would be an alternative to this approach if you also want a platform specific installer and uninstaller, or James's solution is also simpler if you that fits your requirements. This alternate approach of using jlink+assembly includes more functionality and is presented in case it is useful to you. If the additional complexity and functionality aren't required, then use James's solution instead.

Here is an incomplete example.

The example does a bit more than what you are asking, just discard the bits you don't need.

  • It assumes a modular application (though it can use some kinds of automatic modules, such as the postgres driver, if needed).
  • It uses jlink to link the application for the target architecture of the build machine.
  • It uses an assembly to create a zip distribution of the application.
    • The assembly includes "loose files", jlink image, launcher, default config, and non-modular libraries.
    • The jlink image includes a Java runtime, JavaFX runtime, all module dependencies for your application, and your application's module.
    • The "loose files" are any files that you want visible on the file system such as editable config files, launcher scripts, non-modular libraries to be loaded from the classpath, etc.
  • The zip is targeted to just one operating architecture (e.g. Mac or Linux or Windows, but not all of them). The architecture used is the one on which the project is built.
  • When creating the zip it copies all files from an image-overlay directory into the zip, adding to and replacing, where needed, files generated by the jlink process.
  • One of the overlay files is a custom launcher file which replaces the jlink generated launcher.
  • Non-modular libraries are copied by the assembly process into a lib directory and the custom launcher puts them on the classpath.
    • This allows you to use jlink to link your modular application, but also include the automatic modules, which jlink doesn't handle, on the classpath, so the app will still run. However, it will only work for service libraries like the postgres driver. If you have a library need that you call the API directly from your code (e.g. the apache HTTP client, which is currently an automatic module), then you will need to use a different packaging solution (such as jpackage) rather than jlink.
  • Additional exports are used at the compile and execution stage to break modularity and allow access to private API in the JDK/JavaFX (if needed).
  • Profiles are used to allow switchable customized config files based on an environment selector.
  • Command line arguments are processed to handle the config setup and profile setup.
  • Custom JVM switches are used to enable Java preview functions.
  • Default config properties for the application originate from ${project.basedir}/src/main/resources/application.properties.
    • Profile-specific config properties, application-<profile>.properties, are also used if provided.
  • Once deployed and the zip is expanded, the config properties will end up in <deployment directory>/conf/application.properties and can be edited and modified there if needed.
    • Profile-specific config properties, application-<profile>.properties, are also used if provided.

Usage

Replace myapp with your app name and build target directory name, and com.example with your package, preserving case where necessary.

Run mvn:package to generate the assembly.

The assembly output ends up in:

${project.basedir}/target/myapp-<version>.zip

Run mvn:install to publish the assembly to a maven repository. The assembly will end up in the same maven repository location as other output artifacts of the build (such as the pom.xml and jar file), and will be classified using the assembly type (zip). For example, the following maven co-ordinates (groupId:artifactId:classifier:version):

com.example:myapp:zip:1.0-SNAPSHOT

For a user to deploy your app, they

  1. Download the zip file then unzip it to a deployment directory.
  2. Modify the <deployment directory>/conf/application.properties file if needed.
  3. Execute the launch script <deployment directory>/bin/myapp.

If they want to select a specific config profile on startup, they can provide the argument --profile <profile-name> to the launch script.


${project.basedir}/src/main/assembly/zip.xml

If you don't have non-modular libs, you can omit that section, otherwise, replace the files in that section with your non-modular (automatic module) libs.

<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2 http://maven.apache.org/xsd/assembly-1.1.2.xsd">
    <id>zip</id>
    <includeBaseDirectory>true</includeBaseDirectory>

    <formats>
        <format>zip</format>
    </formats>
    <fileSets>
        <fileSet>
            <directory>${project.basedir}/image-overlay</directory>
            <outputDirectory>/</outputDirectory>
        </fileSet>
        <fileSet>
            <directory>${project.basedir}/target/myapp</directory>
            <outputDirectory>/</outputDirectory>
        </fileSet>
        <fileSet>
            <directory>${project.basedir}/src/main/resources</directory>
            <includes>
                <include>*.properties</include>
            </includes>
            <outputDirectory>/conf</outputDirectory>
        </fileSet>
    </fileSets>
    <dependencySets>
        <dependencySet>
            <outputDirectory>non-modular-libs</outputDirectory>
            <includes>
                <include>org.postgresql:postgresql:jar:*</include>
            </includes>
        </dependencySet>
    </dependencySets>
</assembly>

plugin config in pom.xml

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.10.1</version>
    <configuration>
        <source>18</source>
        <target>18</target>
        <compilerArgs>
            <arg>--enable-preview</arg>
            <arg>--add-exports</arg>
            <arg>javafx.web/com.sun.javafx.webkit=com.example</arg>
        </compilerArgs>
    </configuration>
</plugin>
<plugin>
    <groupId>org.openjfx</groupId>
    <artifactId>javafx-maven-plugin</artifactId>
    <version>0.0.8</version>
    <executions>
        <execution>
            <id>default-cli</id>
            <phase>package</phase>
            <goals>
                <goal>jlink</goal>
            </goals>
            <configuration>
                <mainClass>
                    com.example/com.example.MyApp
                </mainClass>
                <launcher>myapp</launcher>
                <jlinkImageName>myapp</jlinkImageName>
                <noManPages>true</noManPages>
                <stripDebug>true</stripDebug>
                <noHeaderFiles>true</noHeaderFiles>
                <jlinkExecutable>${jlinkExecutable}</jlinkExecutable>
                <!--<jlinkVerbose>true</jlinkVerbose>-->
            </configuration>
        </execution>
    </executions>
</plugin>
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>single</goal>
            </goals>
            <configuration>
                <appendAssemblyId>false</appendAssemblyId>
                <descriptors>
                    <descriptor>src/main/assembly/zip.xml</descriptor>
                </descriptors>
            </configuration>
        </execution>
    </executions>
</plugin>

Customized launcher image-overlay/bin/myapp

Overwrites the default launcher created by jlink.

Place other "loose files" that you want included in your distribution in the image-overlay directory or appropriate sub-directories.

#!/bin/sh
# disable glob (* wildcard) expansion
set -f
DIR=`dirname $0`
CONFIG_DIR=$DIR/../conf
JLINK_VM_OPTIONS="--enable-preview -cp $DIR/../non-modular-libs/* --add-exports javafx.web/com.sun.javafx.webkit=com.example"
$DIR/java $JLINK_VM_OPTIONS -m com.example/com.example.MyApp --configdir "$CONFIG_DIR" "$@"

Config.java

import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.Properties;
import java.util.stream.Collectors;

public class Config {
    private static final Logger log = LoggerFactory.getLogger(Config.class);

    private String configPropertiesResource =
            "/application"
                    + (configProfile != null
                            ? "-" + configProfile
                            : "")
                    + ".properties";

    private static final Properties properties = new Properties();

    private static Config instance;
    private static String configDir;
    private static String configProfile;

    public static String getConfigProfile() {
        return configProfile;
    }

    public static Config getInstance() {
        if (instance == null) {
            instance = new Config();
        }

        return instance;
    }

    private Config() {
        try {
            // use the config override directory if defined, otherwise use the module resource path.
            InputStream configInputStream;
            if (configDir != null) {
                Path configFilePath = Paths.get(configDir, configPropertiesResource);
                if (!Files.exists(configFilePath)) {
                    log.error("Config file does not exist: {}", configFilePath.toAbsolutePath());
                    System.exit(-1);
                }

                configInputStream = Files.newInputStream(configFilePath);
                log.info("Loading config properties from config file: {}", configFilePath.toAbsolutePath());
            } else {
                configInputStream = Config.class.getResourceAsStream(configPropertiesResource);

                log.info("Loading config properties from config resource: {}", Config.class.getResource(configPropertiesResource));
            }

            properties.load(configInputStream);

            StringWriter stringWriter = new StringWriter();
            PrintWriter printWriter = new PrintWriter(stringWriter);
            properties.list(printWriter);

            String sortedProperties =
                    stringWriter.toString()
                            .lines()
                            .sorted()
                            .collect(
                                    Collectors.joining(
                                            "\n"
                                    )
                            );

            log.info("Config properties: {}", sortedProperties);
        } catch (IOException e) {
            log.error("Unable to read configuration", e);
            Platform.exit();
        }
    }

    public static void initConfigDir(String newConfigDir) {
        configDir = newConfigDir;
    }

    public static void initConfigProfile(String profileName) {
        configProfile = profileName;
    }

    public boolean isTheSkyBlue() {
        return Boolean.parseBoolean(
                properties.getProperty(
                        "sky.blue",
                        "true"
                )
        );
    }
}

You can then access a config property from anywhere in your app:

boolean skyBlue = Config.instance().isTheSkyBlue();

/src/main/resources/application.properties

sky.blue=true

To create configuration defaults for different environments, define separate properties files such as application-dev.properties, then when executing the app, pass a profile name, e.g. --profile dev to select the configuration setup for that environment.

The default naming of the config file application-properties and the profile selection, follows the same naming conventions used in SpringBoot configuration, so if you wish to convert this method to use SpringBoot at some time in the future, the conversion is simpler. It also means that configuration of your application should be easy to understand for anybody used to configuration a SpringBoot application.


The main method in your JavaFX application. Place this in your class with extends the JavaFX Application class.

The custom Config class will load the configuration from the properties files for the appropriate environment profile in the initialized config directory.

Alternatively, you could use SpringBoot to load the configuration as that has lots of support for such things, but that is out of scope for this answer and SpringBoot (currently) is hard to adapt and use with the Java Platform Module System used by JavaFX apps.

public static void main(String[] args) {
    processArguments(args);

    launch();
}

private static void processArguments(String[] args) {
    for (int i = 0; i < args.length; i++) {
        switch (args[i]) {
            case "--configdir" -> {
                ensureValueArgAfter(args, i);

                Config.initConfigDir(args[++i]);

                log.info("Initialized config directory as: {}", args[i]);
            }

            case "--profile" -> {
                ensureValueArgAfter(args, i);

                Config.initConfigProfile(args[++i]);

                log.info("Initialized config profile as: {}", args[i]);
            }
            default -> incorrectUsage();
        }
    }
}

private static void ensureValueArgAfter(String[] args, int idx) {
    if (idx == args.length - 1) {
        incorrectUsage();
    }
}

private static void incorrectUsage() {
    System.err.println(
            """
            Usage: java com.example.MyApp --configdir <dirname> --profile <local|dev|qa|prod>

            Adjust the command line for the "java" command based on your usage, see the "java" command man page for more info.
            """
    );

    System.exit(-1);
}

Example unzipped output directory

This example omits display of most of the paths and files added for the JRE by the jlink process, as most of those files are irrelevant for demonstration purposes. The additional jlink generated files will, however, be included in the resultant zip, so the zip created will have more files than are shown here.

myapp-1.0-SNAPSHOT
├── bin (overlaid app launcher script)
│   └── myapp
├── conf (overlaid app config files)
│   ├── application-dev.properties
│   ├── application-local.properties
│   ├── application-qa.properties
│   ├── application.properties
├── legal (jre legal documents)
├── lib (jre libraries and jlink created modular image) 
├── non-modular-libs (overlaid non modular libraries)
│   └── postgresql-42.5.0.jar
└── release (defines jre and base modules in the jlink image)

Upvotes: 1

James_D
James_D

Reputation: 209225

As suggested in the comments, one strategy for this is to use a "known location" (typically somewhere in the hierarchy under the user's home directory) for the file. Keep a default version of the file as a resource in the application bundle. At startup, check if the file exists in the expected location, and if not, copy the contents from the resource.

Here is a complete example. Note this will generate a folder (.configApp) in your home directory, and a file (config.properties) inside that folder. For convenience, pressing "OK" in the dialog on exit will remove these artifacts. You probably don't want this in production, so press "Cancel" to see how it works, and run it again and press "OK" to keep your filesystem clean.

package org.jamesd.examples.config;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Properties;

public class ConfigExample extends Application {

    private static final Path CONFIG_LOCATION
            = Paths.get(System.getProperty("user.home"), ".configApp", "config.properties");

    private Properties readConfig() throws IOException {
        if (!Files.exists(CONFIG_LOCATION)) {
            Files.createDirectories(CONFIG_LOCATION.getParent());
            Files.copy(getClass().getResourceAsStream("config.properties"), CONFIG_LOCATION);
        }
        Properties config = new Properties();
        config.load(Files.newBufferedReader(CONFIG_LOCATION));
        return config ;
    }
    
    @Override
    public void start(Stage stage) throws IOException {
        Properties config = readConfig();
        Label greeting = new Label(config.getProperty("greeting"));
        Button exit = new Button("Exit");
        exit.setOnAction(e -> Platform.exit());
        VBox root = new VBox(10, greeting, exit);
        root.setAlignment(Pos.CENTER);
        root.setPadding(new Insets(20));
        Scene scene = new Scene(root);
        stage.setScene(scene);
        stage.show();
    }

    @Override
    // Provide option for cleanup. Probably don't want this for production.
    public void stop() {
        Alert confirmation = new Alert(Alert.AlertType.CONFIRMATION, "Delete configuration after exit?");
        confirmation.showAndWait().ifPresent(response -> {
            if (response == ButtonType.OK) {
                try {
                    Files.delete(CONFIG_LOCATION);
                    Files.delete(CONFIG_LOCATION.getParent());
                } catch (IOException exc) {
                    exc.printStackTrace();
                }
            }
        });
    }


    public static void main(String[] args) {
        launch();
    }
}

with config.properties under src/main/resources and in the same package as the application class:

greeting=Hello

Upvotes: 5

GenerationLost
GenerationLost

Reputation: 461

To solve both the problem of deploying "external" files (e.g. configurations) as well as accidentally deleting such files after deployment, the following strategy would be advisable (taken from James_D's comment):

  • Include default configurations/data as regular resources in the application bundle
  • At app startup, check if the necessary files exists in the expected location
  • If not, copy the defaults from the app's resources to the expected location
  • Load configuration/data as needed

An example method could look like this:

public static String loadData(String file) throws IOException {
    Path filePath = Path.of(file);

    if (!Files.exists(filePath)) {
        InputStream is = App.class.getResourceAsStream("/" + filePath);
        Files.copy(is, file);
        is.close();
    }

    return Files.readString(filePath);
}

Upvotes: 0

Related Questions