Reputation: 461
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.
Upvotes: 3
Views: 820
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.
${project.basedir}/src/main/resources/application.properties
.
application-<profile>.properties
, are also used if provided.<deployment directory>/conf/application.properties
and can be edited and modified there if needed.
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
<deployment directory>/conf/application.properties
file if needed.<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
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
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):
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