javaspring-bootjavafxresourcesjava-module

Resource Loading with SpringBoot @Value and JavaFX requires files to be in resources/static with java 9 modules. Why?


As the title says. To load a xml file (and any other file for that matter) via spring boots value annotation, the file has to sit in resources/static/path-to-file when using a module-info file. not including the static subfolder leads to a FileNotFoundException.

Can anyone explain why this is the case? Solutions include deleting the module-info.java file, removing the org.openjfx.javafxplugin import or, as stated, moving the xml into resources/static/test/ or not using the Value annotation, but i generally have difficulties understanding modules, and am therefore curious why this problem occurs.

Minimal example:

Project Structure:

build.gradle
src
├───main
│   ├───java
│   │   │   module-info.java
│   │   │
│   │   └───com
│   │       └───example
│   │               App.java
│   │               TestLoader.java
│   │
│   └───resources
│       │   application.properties
│       │
│       ├───static
│       │   └───test
│       │           test.xml
│       │
│       └───test
│               test.xml
│
└───test
    ├───java
    │   └───com
    │       └───example
    │               TestTest.java
    │
    └───resources
            unused

unused is an empty file, which is necessary to generate the test resource folder and avoid an error.

Spring Boot App:

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App
{
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

Loader:

package com.example;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class TestLoader
{
    private TestLoader(@Value("classpath:test/test.xml") Resource fxml) throws IOException
    {
        fxml.getInputStream();
    }
}

Module info:

module com.example {
    requires spring.boot;
    requires spring.boot.autoconfigure;
    requires spring.context;
    requires spring.core;
    requires spring.beans;

    opens com.example to spring.beans, spring.context, spring.core;
}

Gradle build file:

plugins {
  id 'java'
  id 'application'
  id 'org.openjfx.javafxplugin' version '0.0.13'
  id 'org.springframework.boot' version '3.4.2'
  id 'io.spring.dependency-management' version '1.1.7'
}

group 'com.example'
version '0.0.1-SNAPSHOT'

repositories {
  mavenCentral()
}

java {
  modularity.inferModulePath = true
}

application {
  mainModule = 'com.example'
  mainClass = 'com.example.App'
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter'

  testImplementation('org.springframework.boot:spring-boot-starter-test'){
    // https://github.com/spring-cloud/spring-cloud-deployer-kubernetes/issues/142
    exclude group: "com.vaadin.external.google", module:"android-json"
  }
  testImplementation("org.junit.jupiter:junit-jupiter-api")

  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
  testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
  useJUnitPlatform()
}

Test:

package com.example;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class TestTest {
    @Autowired
    TestLoader loader;

    @Test
    public void test() {
    }
}

I have reviewed similar questions, but they all concern different questions. Thanks in advance!


Solution

  • Resource Encapsulation

    The behavior you're seeing has to do with resource encapsulation:

    A resource in a named module may be encapsulated so that it cannot be located by code in other modules. Whether a resource can be located or not is determined as follows:

    • If the resource name ends with ".class" then it is not encapsulated.

    • A package name is derived from the resource name. If the package name is a package in the module then the resource can only be located by the caller of this method when the package is open to at least the caller's module. If the resource is not in a package in the module then the resource is not encapsulated.

    In the above, the package name for a resource is derived from the subsequence of characters that precedes the last '/' in the name and then replacing each '/' character in the subsequence with '.'. A leading slash is ignored when deriving the package name. As an example, the package name derived for a resource named "a/b/c/foo.properties" is "a.b.c". A resource name with the name "META-INF/MANIFEST.MF" is never encapsulated because "META-INF" is not a legal package name.

    This method returns null if the resource is not in this module, the resource is encapsulated and cannot be located by the caller, or access to the resource is denied by the security manager.

    Why /static/test/test.xml is found

    As noted in the documentation, META-INF/MANIFEST.MF is not encapsulated because META-INF is an illegal package name. The same thing is happening with /static/test/test.xml. That resource location puts test.xml in the static.test package, but that's an illegal package name because static is a keyword (it cannot be used as an identifier). Thus, any resources under /static/* will not be encapsulated and be accessible to anyone.

    Why /test/test.xml is not found

    When your resource is under /test/test.xml, that puts test.xml in the test package. That is a legal package name and so the resource is encapsulated. And you have not opens the test package, which means resources contained within it cannot be accessed by other modules via Java's resource API.

    Solution (when code is modular)

    You have to opens that package to at least the caller module, though I'm not sure which Spring (automatic) module is responsible for loading the resource.

    So, if you keep the module-info.java file, you need to add the following directive:

    opens test [to <spring-module>];
    

    Note if Spring is using ClassLoader::getResource to load the resource, then you'll have to use an unqualified opens. From that method's documentation:

    Resources in named modules are subject to the encapsulation rules specified by Module.getResourceAsStream. Additionally, and except for the special case where the resource has a name ending with ".class", this method will only find resources in packages of named modules when the package is opened unconditionally [emphasis added].


    Modularity

    The Spring libraries are not modular (in the sense that they don't have a module-info descriptor). When you depend on non-modular libraries, it's typically not worth it to make your own code modular. An automatic module in the dependency graph precludes many benefits of modules, particularly the ability to bundle all modules in a runtime image (created with jlink).

    So, I would recommend you delete the module-info.java file in this case. You can still make sure JavaFX is resolved as named modules by putting JavaFX on the module-path and using --add-modules. If you use jpackage to distribute your application then you can configure it to put JavaFX in the runtime image while having everything else be loaded from the class-path. GraalVM Native Image can probably do something similar.