Inconsistency with service loading by layer or by class loader
David Lloyd
david.lloyd at redhat.com
Fri Dec 13 23:57:47 UTC 2024
On Fri, Dec 13, 2024 at 12:06 PM Ron Pressler <ron.pressler at oracle.com>
wrote:
>
>
> > On 12 Dec 2024, at 14:35, David Lloyd <david.lloyd at redhat.com> wrote:
> >
> > The problem is that 100% of these frameworks are required to work on the
> class path as well (and some are not yet being tested on the module path,
> or even defining an automatic module name, though I've been working to
> improve that for a couple years now).
>
> If this is not because these frameworks still support JDK 8, could you
> explain why a single JAR should work both on the classpath and the module
> path, given that the two can be freely mixed? I've heard of such issues,
> but never in much detail, and my sense was that they stem from build tool
> limitations that would more easily and naturally be fixed by the build tool.
>
I want to start by making it clear (in case it isn't) that "classpath mode"
is being used as a widely-understood colloquial expression meaning "not
running in module mode" aka "loaded in unnamed module(s)". It doesn't
specifically mean that one is using, say, `URLClassLoader` to construct and
use a literal flat classpath, and it's referring to run time, not compile
time. Otherwise a lot of what follows isn't going to make a lot of sense.
That said: it really has nothing to do with JDK 8, or with build tools.
Most frameworks and libraries have moved past JDK 8, many of them long ago.
It's more because there are few application containers or even libraries
which are known to work in module mode (service loading is really the prime
example of how this can break; see below). The idea that once people move
past JDK 8 they will embrace modules and not look back is essentially a
complete fiction. As a result, if I design a library or framework to work
*only* in module mode, the user might still try to use it in a non-module
application container, possibly leading to (potentially subtle) breakage.
If I design it only for classpath mode, then it may subtly break in module
mode. If I design it for both, my users will be happy, but I need to test
in both environments (which is rarely done today) and be sure to cope with
behavioral differences that arise as a result. The build tool end is not a
problem. I can easily have Maven test in both modes for example. The
problem is the run time behavioral differences.
Ultimately it's the library (think Jakarta EE, Microprofile, Hibernate,
reactive frameworks, Netty, etc.) and application container authors (think
Quarkus, Micronaut, Helidon, Spring, etc.) who are doing nearly 100% of the
heavy lifting in terms of paving the way for users to be able to embrace
modules. However, we also have had approximately 0% of the corresponding
influence on the design, and the result is really not ergonomic for our
purposes, which is probably at least part of why we're seeing limited
uptake of modules even after 7 years and 15 major versions - a geological
age in Java terms.
However, we are crafty and can find a way to make things work. For example,
the recommended solution for fully dynamic loading of modules has been to
use a layer per module, hence my current experimental direction. But, one
(thus-far) insurmountable compatibility difficulty revolves around service
loading. A good puzzler is: can you guess which is the correct way for a
library to find a service provider for one of its interfaces in Java, so
that it works for modules *and* regular class loading?
Choice 1. ServiceLoader.load(TheService.class)
Choice 2. ServiceLoader.load(TheService.class,
Thread.currentThread().getContextClassLoader())
Choice 3. ServiceLoader.load(TheService.class, getClass().getClassLoader())
Choice 4. ServiceLoader.load(TheService.class,
TheService.class.getClassLoader())
Choice 5. ServiceLoader.load(TheService.class,
stackWalker.getCallerClass().getClassLoader())
Choice 6. ServiceLoader.load(getClass().getModule().getLayer(),
TheService.class)
Choice 7. ServiceLoader.load(TheService.class.getModule().getLayer(),
TheService.class)
Choice 8.
ServiceLoader.load(stackWalker.getCallerClass().getModule().getLayer(),
TheService.class)
It should not be a surprise, based on this thread at least, to discover
that the correct answer is "none of these". Other than recognizing that 1 &
2 are the same, which is only obvious to people who bother to read
documentation (a surprisingly small crowd, even amongst framework and
specification designers), none of these will work in an expected way for
the given environment, because no two environments work the same way in
terms of class loading or module layers. We normally recommend #4 (or
perhaps #3, when there is an effective difference) for our non-JPMS
products (which is basically all of our products) because historically
(i.e. outside of modules) we can reroute service loading requests fairly
easily, allowing our containers to "do the right thing" based on the user's
intent, and this way we can know "who is asking" for the service and route
accordingly.
Another behavioral quirk is that service loaders don't actually work the
same if the service was found in a named module. If a service provider
class was found in a named module, then the loader will also look for a
`provider` static method which can return the service instance, whereas
services found via the classic mechanism will only invoke the constructor
of the service class to acquire its instance. Additionally, the classic
`META-INF/services` mechanism is still used in module mode, however the
service provider classes are filtered out if they are found to be located
in a named module after loading the class. Since behavior differs between
module and non-module mode, a library author which seeks to run in both
environments while using ServiceLoader has to carefully consider how
services might be loaded in a given environment. The smartest solution is
probably "don't use ServiceLoader". However, this API is unfortunately
widely used among frameworks today, so short of boiling the ocean there's
not much to be done about it, except perhaps find a way to make it work
consistently.
If we could continue to have some control over how services are resolved,
we would not have an issue. The idea of hard-wiring the service graph in
custom layers seems attractively proximate to the concepts of static
compilation and the closed world (at least on paper), however in practice,
none of these kinds of benefits have ever materialized from enforcing these
strict limitations on custom layers. Having a custom layer in the first
place already invalidates these concepts from the JDK or JVM's perspective
(but importantly, not from the application container perspective, since
it's generally the application container that understands the static
properties of the application, not the JDK). In fact I would be shocked to
learn that this restriction has *ever*, in the past 7 years, been found to
be useful to anyone.
If it were my decision, I would let the class loader have control over
service resolution in *all* cases, modular or classpath, and abandon the
service catalog idea. Only one variation (perhaps
`ServiceLoader.load(TheService.class)`) would be recommended with the other
variants being deprecated for removal, and it would be caller-sensitive
(yes, I know it already is) so that the class loader's handler could
consider any or all of the caller class, the class loader of the caller
class, the TCCL, the class loader of the service, etc. This is because the
library author is the *last* one to have an accurate idea how its services
can or should be located. It is by definition dependent on the environment,
and in fact that is usually why they are using service loading in the first
place: so they don't have to care about *where* the service implementation
comes from, only that it is provided. Whenever the library author has a
great idea about how to locate their service (say, via TCCL or perhaps the
caller's layer), they're necessarily going to be wrong most (if not all) of
the time, and it's the container developers who have to deal with
the problems that result.
There are other behavioral differences too that have caused problems - for
example, once I added a class loader name to the class loader of a project
with a very large test suite. This ended up breaking thousands of tests
which were, as it happens, parsing the *text* of exception stack traces,
and breaking because the class loader name began appearing in the stack
frame text. Obviously, any fault here lies with the author of such a
brittle test. So while even innocuous behavior changes like this one could
have unexpected consequences, it is important that it is at least possible
to bridge the gap between old and new behaviors. For these bad tests, the
change would be to fix the test. But for something like ServiceLoader, the
answer is not so clear.
--
- DML • he/him
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/jigsaw-dev/attachments/20241213/8369217b/attachment-0001.htm>
More information about the jigsaw-dev
mailing list