ServiceLoader
plugins without a framework.
- chapter
- chapter 11.08
- reading time
- 8 min read
ServiceLoader finds implementations of an interface at runtime, including ones your code has never heard of. a provider jar announces itself with a file at META-INF/services/<interface name> on the class path, or with provides … with … in its module-info.java. the consumer just asks for the interface (and, in a module, says uses). providers are created lazily, one at a time, as you iterate.
that's how JDBC finds your database driver, javac finds annotation processors, and SLF4J finds its backend. no framework, it's been in the JDK since java 6.
the picture
you know how adding the postgres driver jar to your classpath makes DriverManager.getConnection("jdbc:postgresql://…") just work, even though nothing in your code mentions postgres? the driver jar registered itself.
think of a wall socket. the appliance (your code) is built against the socket's shape (an interface). whoever plugs in at the other end (a provider jar) supplies the power. the appliance never needs to know which power company it is, and you can switch companies by swapping a jar.
the three roles:
- service
- an interface (or abstract class) everyone agrees on.
java.sql.Driver,javax.annotation.processing.Processor, your owncom.shop.spi.Greeter. - provider
- a class implementing it, in some jar, plus a declaration saying so.
- consumer
- the code that calls
ServiceLoader.load(Greeter.class)and iterates whatever turns up.
hover a stage.
the smallest real one
the JDK uses it on itself. every command-line tool that can also run in-process registers as a java.util.spi.ToolProvider:
import java.util.ServiceLoader;
import java.util.spi.ToolProvider;
void main() {
var names = ServiceLoader.load(ToolProvider.class).stream()
.map(provider -> provider.get().name())
.sorted()
.toList();
IO.println(names);
ToolProvider javac = ToolProvider.findFirst("javac").orElseThrow();
IO.println(javac.getClass().getName());
}[jar, javac, javadoc, javap, jdeps, jlink, jmod, jnativescan, jpackage] com.sun.tools.javac.main.JavacToolProvider
nine tools, found at runtime, none of them named in your code. JavacToolProvider lives in the jdk.compiler module, in a package that module doesn't export: you couldn't write new JavacToolProvider() if you tried. you can still get one, because the module provides it.
a plugin, for real
an interface in one jar, two implementations in two more, and an app that only knows the interface:
package com.shop.spi;
public interface Greeter {
String lang();
String greet(String name);
}package com.shop.fr;
import com.shop.spi.Greeter;
public class French implements Greeter {
public French() { System.out.println(" (French constructed)"); }
public String lang() { return "fr"; }
public String greet(String name) { return "bonjour, " + name; }
}com.shop.fr.Frenchimport com.shop.spi.Greeter;
import java.util.ServiceLoader;
public class Main {
public static void main(String[] args) {
for (Greeter g : ServiceLoader.load(Greeter.class)) {
System.out.println(g.lang() + ": " + g.greet("ada"));
}
}
}greeter-en.jar is the same with English. inside the provider jar, the registration is one small text file:
- ├──
- │ ├── MANIFEST.MF
- │ └──
- │ └── com.shop.spi.Greetercontains one line: com.shop.fr.French
- └──
- └──
- └──
- └── French.classimplements com.shop.spi.Greeter
unzip -l greeter-fr.jar listed. the file's name is the service interface; its contents are the implementation class. that's the whole registration.build them and change nothing but the class path:
$ java -cp shop-api.jar:app.jar Main
$ java -cp shop-api.jar:app.jar:greeter-en.jar Main
en: hello, ada
$ java -cp shop-api.jar:app.jar:greeter-en.jar:greeter-fr.jar Main
en: hello, ada
(French constructed)
fr: bonjour, ada
$ java -cp shop-api.jar:app.jar:greeter-fr.jar:greeter-en.jar Main
(French constructed)
fr: bonjour, ada
en: hello, adasame app.jar, three behaviours. and look at where (French constructed) lands: after the English greeting, not at the start. the loader reads the service files up front but only instantiates each provider when the iterator reaches it.
$ java -cp … en: hello, ada (French constructed) fr: bonjour, ada
ServiceLoader asks the class loader for every META-INF/services/com.shop.spi.Greeter file on the class path and reads the class names inside, in class path order. click a jar to remove it, or swap the order and watch which provider comes first (and when French gets constructed).
uses. the blue text is each provider's declaration. every output is a real run.the module way
on the module path, the text file is replaced by declarations the JVM checks when it resolves the module graph:
module shop.fr {
requires shop.api;
provides com.shop.spi.Greeter with com.shop.fr.French;
}module shop.app {
requires shop.api;
uses com.shop.spi.Greeter;
}three things change:
- the provider's package can stay private.
shop.frexports nothing. nobody cannew French(), butServiceLoadercan still hand one out. - a provider can be a factory. if the class has a
public static Greeter provider()method, that's called instead of a constructor, so the provider can return a singleton or a cached instance. usesis mandatory. leave it out and using the loader throws (real message below). it's the module system's way of making service dependencies visible in the descriptor, wherejlinkcan read them (jlink --bind-servicespulls in every module that provides a service you use, see jlink).
Exception in thread "main" java.util.ServiceConfigurationError: com.shop.spi.Greeter: module shop.app does not declare `uses`old-style jars on the class path still work when the app itself is a module: i put greeter-en.jar on the class path next to a modular shop.app, and the loader found it through its META-INF/services file. migrating is one side at a time (class path to module path).
where it's already working for you
| service | provided by | what it buys you |
|---|---|---|
java.sql.Driver | every JDBC driver jar | no more Class.forName("org.postgresql.Driver") since JDBC 4 (jdbc) |
javax.annotation.processing.Processor | lombok, mapstruct, dagger | how javac used to discover processors (annotation processing) |
java.nio.file.spi.FileSystemProvider | jdk.zipfs, your own | FileSystems.newFileSystem(zipPath) opens a zip as a file system |
java.util.spi.ToolProvider | the JDK's tools | run javac or jar in-process |
System.LoggerFinder | logging backends | where System.getLogger output goes (logging) |
org.slf4j.spi.SLF4JServiceProvider | logback, log4j's slf4j bridge | SLF4J 2 picks its backend this way |
compared to a DI container, it's tiny: no injection, no scopes, no configuration. just "give me every implementation of this interface that's on the path". that's often all a plugin system needs.
laziness, caching and threads+
each ServiceLoader instance caches the providers it has already created, and iterating it again replays the cache before looking for more. reload() clears that. two different ServiceLoader instances create two sets of provider objects, so providers are not singletons unless they make themselves one (with a provider() factory, for example). the loader itself isn't thread-safe; load once and share the instances you got. and stream() gives you Provider handles, so you can filter by type() and only call get(), which instantiates, on the one you want.
- a service is an interface. providers declare themselves; the consumer just asks.
- class path: a text file at
META-INF/services/<interface>. module path:provides … withanduses. - providers are created lazily, in path order on the class path. a broken entry fails when it's reached.
- a provider package doesn't need to be exported, and can use a static
provider()factory. - shading jars? merge the service files, or your plugins vanish.