Recently I've been experimenting with building layered modular Java applications with Layrry, a launcher and Java API for executing modularized Java applications. It so happens that version 1.0.0.Alpha1 was just released a couple of weeks ago, making it easier for anyone to give it a try. The base concept of Layrry is to organize your code in such a way that modules are grouped in a series of layers, enabling isolation between said layers as there's a single classloader per layer, thus allowing conflicting modules (such as binary incompatibilities between classes) to be used within the same application. Module layers are an integral part of the Java Platform Module System and I would expect them to be used more as time progress and more developers jump from the classpath into the modulepath. As great as modules and layers can be there are a few things you should watch out when writing code that must play nicely with the Java Platform Module System, both as producer and consumer.
The following is a list of things I discovered when porting a JavaFX application to run in a layered manner using Layrry. Some of these "gotchas" apply to all Java applications, not just JavaFX. It's worth mentioning that none of the gotchas are specific to Layrry, but the nature of layered modular applications as provided by Layrry make them rise to the surface, that is, those issues are still there but hidden from view if you never use module layers. Without any further ado...
1. Launching a JavaFX application
This is quite specific to JavaFX and its related to how the class that extends the javafx.application.Application
class is usually bootstrapped by the JavaFX toolkit. As it happens there are a couple of ways to define the entry point for a JavaFX application, depending on the chosen one you may get an error or not when launching the application with Layrry or running it with multiple module layers on your own. If the launch code is set as
public static void main(String[] args) { launch(); }
Then I'm afraid you're going to have a bad time. Why? Because the JavaFX initialization process in this case will jump through hoops (and the classloader) to locate the right class to use. Yes, even if this method is defined in the implementing class! Why is this the case? I believe this is done for historical reasons. Using this launch mechanism leads to an exception similar to this one
Exception in thread "main" java.lang.RuntimeException: Couldn't run module main class at org.moditect.layrry.internal.LayersImpl.run(LayersImpl.java:139) at org.moditect.layrry.Layrry.launch(Layrry.java:56) at org.moditect.layrry.Layrry.run(Layrry.java:50) at org.moditect.layrry.launcher.LayrryLauncher.launch(LayrryLauncher.java:53) at org.moditect.layrry.launcher.LayrryLauncher.main(LayrryLauncher.java:35) Caused by: java.lang.reflect.InvocationTargetException at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:64) at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.base/java.lang.reflect.Method.invoke(Method.java:564) at org.moditect.layrry.internal.LayersImpl.run(LayersImpl.java:136) ... 4 more Caused by: java.lang.RuntimeException: java.lang.ClassNotFoundException: app.App at javafx.graphics/javafx.application.Application.launch(Application.java:304) at app@12.0.1-SNAPSHOT/app.App.main(App.java:42) ... 9 more Caused by: java.lang.ClassNotFoundException: app.App at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:606) at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:168) at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522) at java.base/java.lang.Class.forName0(Native Method) at java.base/java.lang.Class.forName(Class.java:468) at javafx.graphics/javafx.application.Application.launch(Application.java:292)
On the other hand if the launch code were to be changed to
public static void main(String[] args) { Application.launch(App.class, args); }
Where App
is the class that extends javafx.application.Application
then the application will be successfully launched. Why? Because we avoid searching for the target class and we instruct the JavaFX loading mechanism to use it directly. Given that the class has already been loaded then the launch procedure continues without a problem. Lesson learned: use the explicit launch mechanism.
2. Loading an FXML file
FXML is one of those JavaFX features that I found to be lackluster at the beginning but then it grew on me. Nowadays I use FXML whenever I can. Typically, if the UI is static in nature then FXML is a god fit for it; on the other hand when dynamism is required I switch to using the widget API. You can even combine both approaches in the same window as needed. That's pretty neat. Imagine my surprise when the following code would bomb when used in combination with Layrry:
package app; import javafx.application.Application; import javafx.fxml.FXMLLoader; import javafx.scene.Scene; import javafx.scene.layout.HBox; import javafx.stage.Stage; import java.net.URL; public class App extends Application { @Override public void start(Stage stage) throws Exception { URL location = getClass().getResource("app.fxml"); FXMLLoader fxmlLoader = new FXMLLoader(location); HBox hbox = fxmlLoader.load(); Scene scene = new Scene(hbox); stage.setScene(scene); stage.setTitle("App"); stage.show(); } public static void main(String[] args) { Application.launch(App.class, args); } }
And a simple FXML file such as:
<?xml version="1.0" encoding="UTF-8"?> <?import javafx.geometry.Insets?> <?import javafx.scene.layout.HBox?> <?import javafx.scene.control.Button?> <HBox xmlns:fx="http://javafx.com/fxml"> <padding> <Insets top="10" left="10" bottom="10" right="10"/> </padding> <Button text="Click me"/> </HBox>
The exception produced was
Exception in Application start method Exception in thread "main" java.lang.RuntimeException: Couldn't run module main class at org.moditect.layrry.internal.LayersImpl.run(LayersImpl.java:139) at org.moditect.layrry.Layrry.launch(Layrry.java:56) at org.moditect.layrry.Layrry.run(Layrry.java:50) at org.moditect.layrry.launcher.LayrryLauncher.launch(LayrryLauncher.java:53) at org.moditect.layrry.launcher.LayrryLauncher.main(LayrryLauncher.java:35) Caused by: java.lang.reflect.InvocationTargetException at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:64) at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.base/java.lang.reflect.Method.invoke(Method.java:564) at org.moditect.layrry.internal.LayersImpl.run(LayersImpl.java:136) ... 4 more Caused by: java.lang.RuntimeException: Exception in Application start method at javafx.graphics/com.sun.javafx.application.LauncherImpl.launchApplication1(LauncherImpl.java:900) at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication$2(LauncherImpl.java:195) at java.base/java.lang.Thread.run(Thread.java:832) Caused by: javafx.fxml.LoadException: file:///tmp/app/app-1.0.0-SNAPSHOT.jar!/app/app.fxml at javafx.fxml/javafx.fxml.FXMLLoader.constructLoadException(FXMLLoader.java:2625) at javafx.fxml/javafx.fxml.FXMLLoader.importClass(FXMLLoader.java:2863) at javafx.fxml/javafx.fxml.FXMLLoader.processImport(FXMLLoader.java:2707) at javafx.fxml/javafx.fxml.FXMLLoader.processProcessingInstruction(FXMLLoader.java:2676) at javafx.fxml/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2542) at javafx.fxml/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2466) at javafx.fxml/javafx.fxml.FXMLLoader.load(FXMLLoader.java:2435) at app@12.0.1-SNAPSHOT/app.App.start(App.java:33) at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication1$9(LauncherImpl.java:846) at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runAndWait$12(PlatformImpl.java:455) at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:428) at java.base/java.security.AccessController.doPrivileged(AccessController.java:391) at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:427) at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96) Caused by: java.lang.ClassNotFoundException: javafx.geometry.Insets at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:606) at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:168) at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522) at javafx.fxml/javafx.fxml.FXMLLoader.loadTypeForPackage(FXMLLoader.java:2931) at javafx.fxml/javafx.fxml.FXMLLoader.loadType(FXMLLoader.java:2920) at javafx.fxml/javafx.fxml.FXMLLoader.importClass(FXMLLoader.java:2861)
Lovely. Another classloading issue, but in this case the FXMLLoader failed to find the right class. How could this be? Remember the application was defined using layers; the JavaFX dependencies were placed on their own layer while the application was in a separate layer with a link to the javafx layer, like so
[layers.javafx] modules = [ "org.openjfx:javafx-base:jar:{{os.detected.jfxname}}:{{javafx_version}}", "org.openjfx:javafx-controls:jar:{{os.detected.jfxname}}:{{javafx_version}}", "org.openjfx:javafx-graphics:jar:{{os.detected.jfxname}}:{{javafx_version}}", "org.openjfx:javafx-fxml:jar:{{os.detected.jfxname}}:{{javafx_version}}"] [layers.core] modules = [ "org.kordamp.ikonli:app:{{project_version}}"] parents = ["javafx"] [main] module = "app" class = "app.App"
The way the code was structured made the FXMLLoader load all classes using the context classloader, which is not the same as the classloader that loaded the application class, thus it was not aware of the relationship between layers, thus resulting in the previously shown error. What's the fix? Make sure FXMLLoader uses the correct classlader; this can be done by setting the classloader after instantiating the FXMLLoader instance
package app; import javafx.application.Application; import javafx.fxml.FXMLLoader; import javafx.scene.Scene; import javafx.scene.layout.HBox; import javafx.stage.Stage; import java.net.URL; public class App extends Application { @Override public void start(Stage stage) throws Exception { URL location = getClass().getResource("app.fxml"); FXMLLoader fxmlLoader = new FXMLLoader(location); fxmlLoader.setClassLoader(getClass().getClassLoader()); HBox hbox = fxmlLoader.load(); Scene scene = new Scene(hbox); stage.setScene(scene); stage.setTitle("App"); stage.show(); } public static void main(String[] args) { Application.launch(App.class, args); } }
Just one tiny change and that's enough to make it work. Lesson learned: sometimes being explicit is better than being implicit; in this case setting up the classloader is the right thing to do.
3. Exposing resources
It is quite common for a JavaFX library to expose resources (such as CSS) to consumers; this was the case of BootstrapFX, a JavaFX port of the Twitter Bootstrap stylesheet. In the past it was enough to set the bootstrapfx stylesheet on a scene in the following way
Scene scene = new Scene(panel); scene.getStylesheets().add("org/kordamp/bootstrapfx/bootstrapfx.css");
And that would be the end of that. However running a layered modular application will make the previous code raise a warning that the given stylesheet could not be found. This is again due to the use of a classloader per layer as implemented by Layrry. The solution is to provide the loaded resource (using a class found at the right layer) instead of expecting an outsider consumer to locate the resource. In this case BootstrapFX offers a "marker" class that knows how to load the target resource and provide a canonical URL (in literal form as required by its consumer), so that the consuming code looks like
Scene scene = new Scene(panel); scene.getStylesheets().add(BootstrapFX.bootstrapFXStylesheet());
And the producing code looks like
package org.kordamp.bootstrapfx; public final class BootstrapFX { private BootstrapFX() { // noop } public static String bootstrapFXStylesheet() { return BootstrapFX.class.getResource("bootstrapfx.css").toExternalForm(); } }
This gives consumers a stable, non-stringy typed (at least you don't have to type the resource's path) option to locate a given resurce. An added benefit is that consumers have a stable point of contact (the loading class) should the resource be moved to a different place or be renamed. Lesson learned: don't expect consumers to reach for your resources, offer them options from your POV.
4. ServiceLoader tweaks
Last but not least it's the matter of loading services. In the past we used to define services in files named after an implemented interface and located at META-INF/services
. The Java Platform Module System enhances the ServiceLoader
mechanism in two ways:
- You can define service implementations as part of the module desfriptor's DSL.
- The
ServiceLoader
can load services found in a particularModuleLayer
.
The first option is pretty neat as you no longer have to maintain a separate file that defines the implementations of a given service type. Yes, you can use annotatin processors to automate the file generation (AutoService, Jipsy) but that still requires setting up an extra step in your build, and no thse extra files are not compatible with the Java Platform Module System. However it's the second feature the one that tripped me when overhauling the Ikonli codebase to be fully modules compliant. Ikonli relies on the ServiceLoader
facility to locate suitable implementations of Ikon handlers. Once the updates to full modules were made I launched a test application hoping to see the new icon packs that were added. And indeed that was the case as the application was launched from the build, in classpath mode. Then I turned to the modulepath but without layers, and I also saw the correct result. Yay. Finally I decided to launch the application with Layrry and much to my dismay the application crashed with an exception stating that the given ikons could not be found. What?
That's right. It appears that if the code is running inside a layer then the version of ServiceLoader.load()
that takes a ModuleLayer
as argument should be used instead. To be sure that the ikon loading mechanism works on all cases I had to change the code to look like this
public static ServiceLoader<IkonHandler> resolveServiceLoader() { // Check if handlers must be loaded from a ModuleLayer if (null != IkonHandler.class.getModule().getLayer()) { ServiceLoader<IkonHandler> handlers = ServiceLoader.load(IkonHandler.class.getModule().getLayer(), IkonHandler.class); if (handlers.stream().count() > 0) { return handlers; } } // Check if the IkonHandler.class.classLoader works ServiceLoader<IkonHandler> handlers = ServiceLoader.load(IkonHandler.class, IkonHandler.class.getClassLoader()); if (handlers.stream().count() > 0) { return handlers; } // If *nothing* else works return ServiceLoader.load(IkonHandler.class); }
The code expects to find a non zero number of IkonHandler
instances, checking first if there's a non null
layer. If none are found the it tries using the same classloader used to load the utility class. If none are found again then uses whatever mechanism ServiceLoader
has as default, hoping to find something. Lesson learned: as a producer (library author) be mindful of ModuleLayer
, you don't know if your consumers will use that option or not.
Conclusion
That it is for now. These problems were not that hard to fix but they were surprising when found for the first time. Some serious head-scratching moment until you realize the classloader you think it's in use happens to be a different one due to the use of a single classloader per module layer, in a layered modular application.
Keep on coding!
Image by Александр Пургин from Pixabay
Just starting to look into something RCP-like for JavaFX using layrry for plugins. I’m sure this saved me many hours!