How to create a plugin library for Java

In this tutorial I will show you how to create a library that can load plugin classes at runtime from ServiceLoader JAR files.

The artifact that I created in the context of this article is a rework of the Plugin Loader library from https://gitlab.com/marvinh/plugin-system-for-java. It enhances its functionality by allowing the user to pass constructor arguments to the instantiated plugin.

The complete source code can be found on my Gitlab repo

As I will use Maven and Lombok, I will first setup the pom.xml:

<project>
    ...
    <groupId>de.osshangar</groupId>
    <artifactId>java-plugin-framework</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.target>1.8</maven.compiler.target>
        <maven.compiler.source>1.8</maven.compiler.source>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <cucumber.version>7.6.0</cucumber.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.11.0</version>
        </dependency>
        <dependency>
            <groupId>io.cucumber</groupId>
            <artifactId>cucumber-java</artifactId>
            <version>${cucumber.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    ...
</project>

Maven is the build tool that packages the source files and their dependencies, while Lombok ist a framework for avoiding some boiler plate code (Getters, Setters, Builders, parameterized Constructors).

The interface class for loading the plugins will be called Plugin and I it will have 2 load() methods:

/**
 * This class loads the plugins
 * @param <T> type of the object created
 * @param <C> the interface class that the plugin has to implement
 */
@RequiredArgsConstructor
public class Plugin<T, C extends Class<T>> {
    @NonNull
    private final C interfaceClass;

    /**
     * This loads the plugin from the provided JarInputStream.
     * @param jarInputStream The input stream to a jar package to load the plugin from
     * @return The instantiated plugin instance
     */
    public T load(JarInputStream jarInputStream) throws FileFormatException, IOException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
        return load(jarInputStream, null);
    }

    public T load(JarInputStream jarInputStream, Arguments constructorArguments) throws FileFormatException, IOException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
        Inspector inspector = Inspector.inspect(jarInputStream, interfaceClass);
        PluginClassLoader classLoader = new PluginClassLoader(inspector.getClassFiles());
        return createInstance(classLoader, inspector, constructorArguments);
    }

    ...
}

The first one loads the plugin without any constructor arguments and the second one can take arguments to be passed to the constructor while the instantiation. I’m using a JarInputStream, as it is teh most flexible way to get the JAR archive. The JAR file referenced by the stream could for example be somewhere on the hard disk, in the RAM or a network transmission that is being received in real time. As you can see, the actual loading process is done in the second load() method:

First the inspector inspects the file:

@Getter
public class Inspector {
    // the name of the class that is to be loaded as a plugin
    private String pluginClassName;

    // the name of the file (it has the name of the interface the plugin implements)
    // that specifies the class to be loaded as a plugin
    private String pluginClassSpecificationFile;

    // stores all entries of the plugin jar file with their name
    private final Map<String, byte[]> entries = new HashMap<>();

    // contains all entries of the plugin jar that are class files with their name
    private final Map<String, byte[]> classFiles = new HashMap<>();

    ...

    public static Inspector inspect(@NonNull JarInputStream jarInputStream, @NonNull Class<?> interfaceClass) throws IOException, FileFormatException {
        Inspector inspector = new Inspector();

        JarEntry jarEntry = jarInputStream.getNextJarEntry();
        while (jarEntry != null){
            if (!jarEntry.isDirectory()){
                inspector.entries.put(jarEntry.getName(), IOUtils.toByteArray(jarInputStream));
            }
            jarEntry = jarInputStream.getNextJarEntry();
        }

        inspector.setClassFiles();
        inspector.setPluginInterface(interfaceClass);
        inspector.setPluginClassName();

        return inspector;
    }
}

It first copies all files of the JAR entries into a hashmap, because all data that has been read from the stream can not be read again. So it must be cached for the further processing. Then the inspector identifies the class files from all stored entries and copies them to another map, because they will later be needed by a custom class loader. After setting the class files, the inspector looks for the plugin class specification file. This is the file that specifies which class needs to be loaded when instantiating the plugin:

    ...

    private void setPluginInterface(@NonNull Class<?> interfaceClass) throws FileNotFoundException {
        String pluginClassSpecificationFile = String.format("META-INF/services/%s", interfaceClass.getCanonicalName());

        if (!entries.containsKey(pluginClassSpecificationFile)){
            throw new FileNotFoundException(pluginClassSpecificationFile);
        }

        this.pluginClassSpecificationFile = pluginClassSpecificationFile;
    }

    ...

For this he checks if a file with the same name as the interface class name is contained within the META-INF/services folder within the JAR.

Next the inspector sets the plugin class name:


private void setPluginClassName() throws FileFormatException {
assert pluginClassSpecificationFile != null;
assert entries.containsKey(pluginClassSpecificationFile);

List<String> pluginClassSpecificationFileLines = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(entries.get(pluginClassSpecificationFile)))).lines().collect(Collectors.toList());
if (pluginClassSpecificationFileLines.isEmpty()){
throw new FileFormatException(String.format("File must contain the name of the class to be loaded as plugin: %s", pluginClassSpecificationFile));
}
if (pluginClassSpecificationFileLines.size() > 1){
throw new FileFormatException(String.format("File must not specify multiple classes to be loaded as plugins: %s", pluginClassSpecificationFile));
}
String pluginClassName = pluginClassSpecificationFileLines.get(0).trim();
if (pluginClassName.isEmpty()){
throw new FileFormatException(String.format("File must contain the name of the class to be loaded as plugin: %s", pluginClassSpecificationFile));
}
this.pluginClassName = pluginClassName;
}

To get the plugin class name, the inspector reads the plugin class specification file it has identified in the previous steps. The file must contain the full qualified name of the plugin class to instantiate.

Now the inspector is finished and the load method continues by adding the class files that were found to the PluginClassLoader. This step is needed because the plugin JAR could contain multiple classes that are needed by the plugin, so the class loader needs to know where to find their bytecode before it can start to load the actual plugin class. Now the instance is created by the createInstance() method of the plugin loader:

    ...

    private T createInstance(PluginClassLoader classLoader, Inspector inspector, Arguments constructorArguments) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Class<?> pluginClass = classLoader.findClass(inspector.getPluginClassName());
        if (!interfaceClass.isAssignableFrom(pluginClass)){
            throw new ClassCastException(String.format("The compiled class is no implementation or subclass of %s", interfaceClass.getCanonicalName()));
        }

        Class<?>[] signature = new Class[]{};
        Object[] arguments = new Object[]{};
        if (constructorArguments != null){
            signature = constructorArguments.getConstructor();
            arguments = constructorArguments.getArguments();
        }

        Constructor<?> constructor = pluginClass.getConstructor(signature);
        //noinspection unchecked
        return (T) constructor.newInstance(arguments);
    }

    ...

First the method calls the PluginClassLoader to find the class. Then it checks wether the returned class implements the given interface. If yes, the constructor to use is being looked up and then it is provided with the necessary arguments to create the instance and the instance is returned to the caller of the plugin loader.

Whenever the PluginClassLoader is requested to load a class, it first looks for its byte code within its internal cache:

public class PluginClassLoader extends ClassLoader {
    // contains all class files that are known to the class loader with their name
    private final Map<String, byte[]> classFiles;

    public PluginClassLoader(@NonNull Map<String, byte[]> classesFiles) {
        super(PluginClassLoader.class.getClassLoader());
        this.classFiles = classesFiles;
    }

    ...

    @Override
    public Class<?> loadClass(String fullQualifiedClassName) throws ClassNotFoundException {
        if (classFiles.containsKey(fullQualifiedClassName)){
            return findClass(fullQualifiedClassName);
        }
        ClassLoader defaultLoader = Thread.currentThread().getContextClassLoader();
        Class<?> loadedClass = defaultLoader.loadClass(fullQualifiedClassName);
        return loadedClass;
    }

}

If the byte code is not in the cache, the load request is forwarded to the ContextClassLoader of the curren Thread. That way, the PluginClassLoader is able to also resolv classes that are part of libraries other than the JAR package of the plugin to be loaded.

If the class byte code is contained in the cache, the PluginClassLoader will define the class from the cached byte code instead, so that the class now is known and usable by the Java program:

public class PluginClassLoader extends ClassLoader {
    // contains all class files that are known to the class loader with their name
    private final Map<String, byte[]> classFiles;

    public PluginClassLoader(@NonNull Map<String, byte[]> classesFiles) {
        super(PluginClassLoader.class.getClassLoader());
        this.classFiles = classesFiles;
    }

    @Override
    public Class<?> findClass(@NonNull String fullQualifiedClassName) throws ClassNotFoundException {
        if (!classFiles.containsKey(fullQualifiedClassName)){
            throw new ClassNotFoundException(String.format("Did not find class '%s'", fullQualifiedClassName));
        }
        return defineClass(fullQualifiedClassName, classFiles.get(fullQualifiedClassName), 0, classFiles.get(fullQualifiedClassName).length);
    }

    @Override
    public Class<?> loadClass(String fullQualifiedClassName) throws ClassNotFoundException {
        if (classFiles.containsKey(fullQualifiedClassName)){
            return findClass(fullQualifiedClassName);
        }
        ClassLoader defaultLoader = Thread.currentThread().getContextClassLoader();
        Class<?> loadedClass = defaultLoader.loadClass(fullQualifiedClassName);
        return loadedClass;
    }

}
Now the created library can be used to load plugin JAR that match the specification of ServiceLoader plugins. To load such a plugin JAR, just call the load() function of the Plugin class and provide it with a JarInputStream to the plugin file you wish to load.

Related articles