Simple java-plugin-mechanism with SPI

This post is about „How to build your own pluginnable application“. As a playground example I want to create a core application that is easily extendable by just putting a plugin-jar into a plugins folder.

So first the base application („animal-core“):

├── pom.xml
└── src
    ├── assembly
    │   └── dist-descriptor.xml
    └── main
        ├── java
        │   └── eu
        │       └── christophburmeister
        │           └── examples
        │               ├── AnimalFactory.java
        │               ├── IAnimal.java
        │               ├── plugins
        │               │   ├── Dog.java
        │               │   └── Unicorn.java
        │               └── Start.java
        └── resources
            └── scripts
                └── run.sh

This application contains an Interface IAnimal that implements all the methods that our plugin-classes have to implement:

package eu.christophburmeister.examples;

public interface IAnimal {
        String getSound();
}

The core application of course will deliver to already existing implementations of that interface:

The dog-implementation:

package eu.christophburmeister.examples.plugins;

import eu.christophburmeister.examples.IAnimal;
import org.kohsuke.MetaInfServices;

@MetaInfServices
public class Dog implements IAnimal {

    public Dog(){
        // default ctor for SPI-loading
    }

    public String getSound(){
        return "wuff wuff";
    }
}

and the Unicorn (the default animal as you will see later)

package eu.christophburmeister.examples.plugins;

import eu.christophburmeister.examples.IAnimal;
import org.kohsuke.MetaInfServices;

@MetaInfServices
public class Unicorn implements IAnimal {

    public Unicorn(){
        // default ctor for SPI-loading
    }

    public String getSound(){
        return "i'm special";
    }
}

As you can see these classes are annotated with @MetaInfServices. This annotation is required as we will use the org.kohsuke.metainf-services:metainf-services library to dynamically generate the service provider entries in the jar’s META-INF directory. Of course you could do that manually, but if the creator of Hudson and Jenkins provides such a nice library, why don’t use ist? 🙂

The secret of plugin-loading (and plugin-finding) is encapsulated in the AnimalFactory:

package eu.christophburmeister.examples;

import eu.christophburmeister.examples.plugins.Dog;
import eu.christophburmeister.examples.plugins.Unicorn;

import java.util.Iterator;
import java.util.ServiceLoader;

public class AnimalFactory {

    private final static String pluginPackageName="eu.christophburmeister.examples.plugins.";

    public static IAnimal newAnimalInstance(String desiredAnimalName) {
        String desiredAnimalFqcn = pluginPackageName + desiredAnimalName;
        IAnimal desiredAnimalImplementation = null;
        ServiceLoader<IAnimal> loader = ServiceLoader.load(IAnimal.class);

        showAvailableAnimals(loader.iterator());

        Iterator<IAnimal> availableAnimals = loader.iterator();

        while (availableAnimals.hasNext()) {
            IAnimal current = availableAnimals.next();
            if (current.toString().startsWith(desiredAnimalFqcn)) {
                return current;
            }
       }

       if (desiredAnimalImplementation == null){
           return new Unicorn(); // unicorn is default
       }

        // pick one of the provided implementations and return it.
        return desiredAnimalImplementation;
    }

    private static void showAvailableAnimals(Iterator<IAnimal> availableAnimals){
        System.out.println("---------------------------------------------" );
        System.out.println("found animals: " );
        while (availableAnimals.hasNext()) {
            IAnimal current = availableAnimals.next();
            System.out.println(current.toString());
        }
        System.out.println("---------------------------------------------" );
    }

}

This factory’s method „newAnimalInstance“ is requested with a class name and tries to obtain it from the classpath. That means, if you want an instance „Dog“, it will search for an IAnimal-implementation-class with name „eu.christophburmeister.examples.plugins.Dog“. If no class can be found, the Unicorn is returned.

This all is started Main-method:

package eu.christophburmeister.examples;

import java.io.BufferedReader;
import java.io.InputStreamReader;

public class Start {

    public static void main(String[] args) throws  Exception{

        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
        String input;

        do {
            System.out.print("Choose your animal (type 'exit' for exit): ");

            input = in.readLine();

            if (!input.equals("exit")) {
                IAnimal animal = AnimalFactory.newAnimalInstance(input);
                System.out.println("Your animal makes: \"" + animal.getSound() + "\"\n\n\n");
            }

        } while (!input.equals("exit"));
    }
}

The pom will simply compile, generate the META-INF/services-files, and package the application together with a run script to a tar.gz (see dist-descriptor.xml). So in the resulting jar there are the filtered run.sh-file:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>eu.christophburmeister.examples</groupId>
    <artifactId>animal-core</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <plugins.directory>./plugins/</plugins.directory>
    </properties>

    <build>

        <resources>
            <resource>
                <directory>src/main/resources/scripts</directory>
                <filtering>true</filtering>
            </resource>
        </resources>

        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.7.0</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>false</addClasspath>
                            <mainClass>eu.christophburmeister.examples.Start</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>

            <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <version>2.6</version>
                <executions>
                    <execution>
                        <id>copy-run-script</id>
                        <phase>package</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>${basedir}/target/dist/</outputDirectory>
                            <resources>
                                <resource>
                                    <directory>${basedir}/target/classes/</directory>
                                    <includes>
                                        <include>run.sh</include>
                                    </includes>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                    <execution>
                        <id>copy-runnable-jar</id>
                        <phase>package</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>${basedir}/target/dist/</outputDirectory>
                            <resources>
                                <resource>
                                    <directory>${basedir}/target/</directory>
                                    <include>*.jar</include>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <descriptors>
                        <descriptor>src/assembly/dist-descriptor.xml</descriptor>
                    </descriptors>
                </configuration>
                <executions>
                    <execution>
                        <id>create-dist-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.kohsuke.metainf-services</groupId>
            <artifactId>metainf-services</artifactId>
            <version>1.7</version>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
    <id>dist</id>
    <baseDirectory></baseDirectory>
    <formats>
        <format>tar.gz</format>
    </formats>
    <fileSets>
        <fileSet>
            <directory>${project.build.directory}/dist</directory>
            <outputDirectory>/</outputDirectory>
        </fileSet>
    </fileSets>
</assembly>

#!/bin/bash

VERSION="0.0.1-SNAPSHOT"
PLUGINS_DIR="./plugins/"

if [ -d "$PLUGINS_DIR" ];
then
    echo ">>> loading plugins from '$PLUGINS_DIR':"
    PLUGIN_JARS=$(find "$PLUGINS_DIR" -name '*.jar' -printf '%p:' | sed 's/:$//')
    echo $PLUGIN_JARS

    echo ">>> constructing classpath:"
    CLASSPATH=$PLUGIN_JARS:animal-core-$VERSION.jar
    echo $CLASSPATH

    echo ">>> calling application with extended classpath:"
    java -Xbootclasspath/a:$CLASSPATH -jar animal-core-$VERSION.jar

else
    echo ">>> no plugins-directory found, calling application with standard classpath:"
    java -Xbootclasspath/a:$CLASSPATH -jar animal-core-$VERSION.jar
fi

In the packaged jar, the META-Inf/services contains following file:
eu.christophburmeister.examples.IAnimal

eu.christophburmeister.examples.plugins.Dog
eu.christophburmeister.examples.plugins.Unicorn

This file is used up by the ServiceLoader to find (and load) services.

After successful build, you can ship the resulting tar.gz assembly and untar it to a destination of your choice, let’s say to your home directory:

christoph@goibniu:~/animal-core-0.0.1-SNAPSHOT-dist$ bash run.sh
>>> no plugins-directory found, calling application with standard classpath:
Choose your animal (type 'exit' for exit): Dog
---------------------------------------------
found animals: 
eu.christophburmeister.examples.plugins.Dog@14ae5a5
eu.christophburmeister.examples.plugins.Unicorn@135fbaa4
---------------------------------------------
Your animal makes: "wuff wuff"



Choose your animal (type 'exit' for exit): Cat
---------------------------------------------
found animals: 
eu.christophburmeister.examples.plugins.Dog@2503dbd3
eu.christophburmeister.examples.plugins.Unicorn@4b67cf4d
---------------------------------------------
Your animal makes: "i'm special"



Choose your animal (type 'exit' for exit): exit
christoph@goibniu:~/animal-core-0.0.1-SNAPSHOT-dist$ 

As you can see, the run-script cannot find a plugins directory, so it doesn’t add any plugin jars to classpath and voila, the Dog class can be found where the Cat class is not found.

So now we have the core application, but how to add a plugin with the desired Cat implementation?

Let’s create a new Maven-project:

├── pom.xml
└── src
    └── main
        └── java
            └── eu
                └── christophburmeister
                    └── examples
                        └── plugins
                            └── Cat.java

The Cat implementation of IAnimal (that is available as the animal-core is a depencency of the plugin project)

package eu.christophburmeister.examples.plugins;


import eu.christophburmeister.examples.IAnimal;
import org.kohsuke.MetaInfServices;

@MetaInfServices
public class Cat implements IAnimal {

    public Cat(){

    }

    public String getSound(){
        return "meow meow";
    }
}

The build definition:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>eu.christophburmeister.examples</groupId>
    <artifactId>cat-plugin</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>eu.christophburmeister.examples</groupId>
            <artifactId>animal-core</artifactId>
            <version>0.0.1-SNAPSHOT</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.kohsuke.metainf-services</groupId>
            <artifactId>metainf-services</artifactId>
            <version>1.7</version>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>
christoph@goibniu:~/animal-core-0.0.1-SNAPSHOT-dist$ bash run.sh
>>> loading plugins from './plugins/':
./plugins/cat-plugin-1.0-SNAPSHOT.jar
>>> constructing classpath:
./plugins/cat-plugin-1.0-SNAPSHOT.jar:animal-core-0.0.1-SNAPSHOT.jar
>>> calling application with extended classpath:
Choose your animal (type 'exit' for exit): Dog
---------------------------------------------
found animals: 
eu.christophburmeister.examples.plugins.Cat@70dea4e
eu.christophburmeister.examples.plugins.Dog@75b84c92
eu.christophburmeister.examples.plugins.Unicorn@6bc7c054
---------------------------------------------
Your animal makes: "wuff wuff"



Choose your animal (type 'exit' for exit): Cat
---------------------------------------------
found animals: 
eu.christophburmeister.examples.plugins.Cat@74a14482
eu.christophburmeister.examples.plugins.Dog@14ae5a5
eu.christophburmeister.examples.plugins.Unicorn@7f31245a
---------------------------------------------
Your animal makes: "meow meow"



Choose your animal (type 'exit' for exit): exit
christoph@goibniu:~/animal-core-0.0.1-SNAPSHOT-dist$ 

It works 🙂 Now you can add more animals in form of plugins, without touching the core implementation.