2

I am building a client/server application. The client runs a small loader that downloads the client in the form of a module jar, but only if the client.jar has changed. The loader then attempts to run the client through ServiceLoader.

Here is the code that is to run the service provider in the client jar.

static PokerGameInstance getPokerGame() {
    URL[] urls = null;

    try {
        urls = new URL[] { Paths.get("client.jar").toUri().toURL() };
        System.out.println(urls[0]);
    }
    catch (Exception e) {
        System.out.println("Could not create URL[] to use to create " +
                "ClassLoader for client.jar.jar.");
        return null;
    }

    URLClassLoader classLoader;
    try {
        classLoader = new URLClassLoader(urls);
    }
    catch (Exception e) {
        System.out.println("Could not create classloader for " +
                "client.jar.");
        return null;
    }

    try { // Test code
        classLoader.loadClass("com.brandli.jbpoker.client.PokerGame");
    }
    catch (ClassNotFoundException e) {
        System.out.println("Could not find PokerGame class");
    }

    ServiceLoader<PokerGameInstance> loader = ServiceLoader
            .load(PokerGameInstance.class, classLoader);
    Optional<PokerGameInstance> optional = loader.findFirst();
    if (optional.isEmpty()) {
        System.out.println("Could not load client service provider.");
        return null;
    }

    return optional.get();
}

The first time it runs, there is no client.jar. Other code downloads client.jar, and then the code above is run. Reviewing the output of this method, the URLClassLoader is able to load the service provider class (which happens to be called PokerTable). However, the ServiceLoader finds nothing, and the method prints "Could not load client service provider."

However, the second time it runs, client.jar is already there, and a fresh one is not downloaded. In that case, ServiceLoader returns the proper class and everything works.

I am running with a module path that includes the entire directory of jars. Client.jar is loaded there as well. So, in the second run, the system ClassLoader is picking up client.jar. In other words, the second pass works not because ServiceLoader is getting client.jar from URLClassLoader. I verified this by doing the second run with the ClassLoader parameter to ServiceLoader.load() set to null.

I also changed the module path to include only the discrete jars so that the system ClassLoader will not pick up client.jar if it is there. In that case, the code above always fails.

The upshot is that ServiceLoader is not recognizing the service in client.jar even though URLClassLoader will load the object. This has nothing to do with client.jar being downloaded, because the problem exists even if client.jar is there from the beginning (unless picked up by the system ClassLoader).

Remember that client.jar is a module jar. The code above is in a module that has this module-info.java:

module com.brandli.jbpoker.loader {
    exports com.brandli.jbpoker.loader;

    requires transitive javafx.controls;
    requires transitive com.brandli.jbpoker.core;
    uses com.brandli.jbpoker.loader.PokerGameInstance;
}

Client.jar has this module-info.java:

    module com.brandli.jbpoker.client {

    requires transitive javafx.controls;
    requires transitive com.brandli.jbpoker.core;
    requires transitive com.brandli.jbpoker.loader;
    requires transitive com.brandli.jbpoker.common;

    provides com.brandli.jbpoker.loader.PokerGameInstance with
    com.brandli.jbpoker.client.PokerGame;
}

I suspect that this has something to do with modules. Anyone has any ideas?

Steve Brandli
  • 556
  • 4
  • 14
  • Unlike a classpath, a module path’s entries must be directories only, not .jar files. It is those directories which should contain modular .jars. – VGR Apr 19 '20 at 16:30
  • I don't think that's true. In my question, I referred to the time in which I had the module path refer to discrete modules. The only directory it included was for javafx. Partially: "loader.jar:core.jar:common.jar" and a few more. When it ran, the client.jar that was there was not picked up, whereas it was when the module path referred to the entire directory. Unlike classpaths, module paths CAN refer to directories, but I don't think they have to. – Steve Brandli Apr 19 '20 at 16:35
  • [The documentation](https://docs.oracle.com/en/java/javase/14/docs/specs/man/java.html#standard-options-for-java) says it’s a list of directories. `java --help` also says it’s a list of directories. If you specified something else and it happened to work, I still would not consider that behavior I could rely on. – VGR Apr 19 '20 at 21:26
  • @VGR That's something I've always been confused about. The documentation you point out only mention "directory of modules", but [`ModuleFinder#of(Path...)`](https://docs.oracle.com/en/java/javase/14/docs/api/java.base/java/lang/module/ModuleFinder.html#of(java.nio.file.Path...)) accepts "directory of modules", "exploded modules", and "packaged modules". I would be surprised if the implementation of `java --module-path` didn't use that implementation of `ModuleFinder`, but there's no documentation saying one way or the other. – Slaw Apr 19 '20 at 23:27
  • @Slaw I have wondered that myself, since the first time I saw that documentation. But in the absence of any further clarification, I feel like the tool documentation takes precedence. It is of course possible that ModuleFinder provides more capabilities than a command line tool’s --module-path option does. – VGR Apr 20 '20 at 00:12

1 Answers1

1

A comment to my question caused me to look into ModuleLayer/ModuleFinder. I noticed that there is a ServiceLoader.load(ModuleLayer, Class). The following code works:

static PokerGameInstance getPokerGame() {
    ModuleFinder finder = ModuleFinder.of(Paths.get("client.jar"),
            Paths.get("common.jar"));
    ModuleLayer parent = ModuleLayer.boot();
    Configuration cf = null;
    try {
        cf = parent.configuration()
                .resolveAndBind(finder, ModuleFinder.of(),
                 Set.of("com.brandli.jbpoker.client"));
    }
    catch (Throwable e) {
        return null;
    }

    ClassLoader cl = ClassLoader.getSystemClassLoader();

    ModuleLayer layer = null;
    try {
        layer = parent.defineModulesWithOneLoader(cf, cl);
    }
    catch (Throwable e) {
        return null;
    }
    ServiceLoader<PokerGameInstance> loader = ServiceLoader
            .load(layer, PokerGameInstance.class);

    Optional<PokerGameInstance> optional = loader.findFirst();
    if (optional.isEmpty()) {
        return null;
    }

    return optional.get();
}

I don't know why the code in my question does not work.

EDIT: This explanation from @Slaw:

To keep backwards compatibility JPMS has the concept of the unnamed module (there's one per ClassLoader). This is where code on the class-path is placed. It's also where your client.jar ends up when loaded by your URLClassLoader, despite it having a module-info file. Classes in the unnamed module function as they did in the pre-module world; in order for a ServiceLoader to find a provider you need a provider-configuration file under META-INF/services. The uses and provides directives only take effect in named modules, which is what you get when creating a ModuleLayer.

Steve Brandli
  • 556
  • 4
  • 14
  • 1
    To keep backwards compatibility JPMS has the concept of the _unnamed module_ (there's one per `ClassLoader`). This is where code on the class-path is placed. It's also where your `client.jar` ends up when loaded by your `URLClassLoader`, despite it having a module-info file. Classes in the unnamed module function as they did in the pre-module world; in order for a `ServiceLoader` to find a provider you need a provider-configuration file under `META-INF/services`. The `uses` and `provides` directives only take effect in _named modules_, which is what you get when creating a `ModuleLayer`. – Slaw Apr 21 '20 at 05:51
  • I understand how unnamed modules are necessary for backwards compatibility. What is the rationale for `URLClassLoader` putting classes from modules (with module-info) in the unnamed module? Naively perhaps, I would think backwards compatibility would be served by `URLClassLoader` putting classes that are not in modules in the unnamed module while recognizing modules. – Steve Brandli Apr 22 '20 at 13:56
  • For one thing, I don't believe `URLClassLoader` is capable of loading classes from modules (i.e. it's not module-aware). For another thing, you need a way to let developers still use non-modular code, even when creating custom class loaders. If you need modules, you create a `ModuleLayer`, if you don't need modules, you create a `ClassLoader` directly. It's the code-equivalent of the separation between `--module-path` and `--class-path`. – Slaw Apr 22 '20 at 15:22
  • Also, named modules change the behavior of the code in an incompatible way, at least when non-automatic (i.e. module-info file is present). In particular, enforced encapsulation of internal code. Many frameworks/libraries which hacked into `ClassLoader#defineClass(...)` had issues since the class has become part of a named module. In that case, you can't help the module existing because it's part of the runtime image. But in general, it would be bad if applications/frameworks executing on the classpath suddenly broke simply because some dependency decided add a module-info file. – Slaw Apr 22 '20 at 15:27