Get up to 80 % extra points for free! More info:

Lesson 14 - Java Server - Plugin System Improvements

In the previous lesson, Java Server - Local Area Network Propagation (Part 3), we finished the propagation of the server in the local network.

In today's Java tutorial, we're going to improve the plugin system by loading external plugins. We'll also add priority initialization of individual plugins.

Loading external plugins

Preparation

To be able to load individual plugins, we need to create some rules so our system recognizes each plug-in correctly. We'll have three rules:

  1. Each JAR file will contain just one plugin
  2. The class representing the plugin must implement the IPlugin interface
  3. A fully-qualified plugin class name must be present in the plugin manifest

Implementation

All magic will happen in the PluginModule class. Let's start by creating a PLUGIN_FILTER class constant of the FilenameFilter type and a PLUGIN_IDENTIFIER String constant:

private static final FilenameFilter PLUGIN_FILTER = (file, name) -> name.contains(".jar");
public static final String PLUGIN_IDENTIFIER = "Plugin-Class";

The filter ensures that we load JAR files only when browsing the plugin folder. The second constant contains the key value that we'll later look for in the manifest to meet our 3rd rule.

Next, we'll create a pluginsFolderPath instance constant with the path to the plugins folder. We'll initialize this constant in the constructor from a parameter:

private final String pluginsFolderPath;
PluginModule(String pluginsFolderPath) {
    this.pluginsFolderPath = pluginsFolderPath;
}

To load a plugin, we'll create a private loadPlugin() method that accepts a variable of the File type as a parameter. This variable represents the plugin file:

private Optional <IPlugin> loadPlugin(File pluginFile) {
  try {
    final ClassLoader loader = URLClassLoader.newInstance(new URL[] {pluginFile.toURI().toURL()});
    final JarInputStream jis = new JarInputStream(new FileInputStream(pluginFile));
    final Manifest mf = jis.getManifest();
    final Attributes attributes = mf.getMainAttributes();
    final String pluginClassName = attributes.getValue(PLUGIN_IDENTIFIER);
    final Class << ? > clazz = Class.forName(pluginClassName, true, loader);
    final IPlugin plugin = clazz.asSubclass(IPlugin.class).newInstance();
    System.out.println("Adding plugin: " + plugin.getName());
    return Optional.of(plugin);
  } catch (Exception e) {
    return Optional.empty();
  }
}

The method is quite complicated, so we'll explain it nicely line by line:

  1. We create a new class loader with the plugin path
  2. We create a new JarInputStream to read the contents of the JAR file
  3. We get the manifest from the JAR
  4. Read all attributes from the manifest
  5. We are specifically interested in the "Plugin-Class" attribute required by our system
  6. We use the Class.forName() method to get the class (not instance) that represents the plugin
  7. We create a new plugin instance using newInstance(). By calling the asSubclass() method, we say that the instance will be a descendant of a class (in our case of the IPlugin interface)
  8. We write to the console that we have loaded the plugin successfully
  9. We return the plugin packed as an Optional class instance

If any of the steps fails, we catch the exception and return an empty Optional.

To keep the code readable, we'll create another private method loadExternalPlugins(), which will take care of searching the plugins folder and calling the loadPlugin() method. The method will accept one MapBinder parameter to register each plugin:

private void loadExternalPlugins(MapBinder <String, IPlugin> pluginBinder) {
  final File pluginsFolder = new File(pluginsFolderPath);
  if (!pluginsFolder.exists() || !pluginsFolder.isDirectory()) {
    return;
  }

  final File[] plugins = pluginsFolder.listFiles(PLUGIN_FILTER);
  if (plugins == null) {
    return;
  }

  Arrays.stream(plugins)
    .map(this::loadPlugin)
    .filter(Optional::isPresent)
    .map(Optional::get)
    .forEach(plugin -> pluginBinder.addBinding(plugin.getName()).to(plugin.getClass()).asEagerSingleton());
}

In the method, we first check that the plugin folder exists and that it really is a folder, not a file. Next, we use our filter to get an array of files that should represent our plugins. If the folder is empty, we don't do anything. We're approaching the most interesting part of the method. By calling the Arrays.stream() method, we get the stream from the plugin array. Using the map() method, we try to load the plugin. Next, we filter only the plugins we were able to load. Next call of the map() method unboxes the Optional and gets a direct reference to the plugin. Finally, we go through all these references and register them along with other plugins.

All we have to do is call the method above. We'll do this at the end of the configure() method:

loadExternalPlugins(pluginBinder);

Finally, we'll move to the Server class, where we have to modify the PluginModule constructor call. It accepts the plugin folder path as a parameter. Since there's no such folder yet, we'll pass only an empty string to the constructor:

final Injector injector = Guice.createInjector(new ServerModule(), new PluginModule(""));

Priority Plugin Initialization

In the second part of today's lesson we're going to implement priority initialization of the plugins. In the future, it may happen that some plugins will need to be loaded before others. So far we have had no control over the order in which the plugins will be loaded.

Configuration by Annotations

We'll set the plugin initialization priority through a PluginConfiguration annotation. We'll create this annotation in the plugins package.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
public @interface PluginConfiguration {
    int DEFAULT_PRIORITY = 0;
    int priority() default DEFAULT_PRIORITY;
}

Using the @Retention annotation we specify the level at which our annotation will be usable. There are three options:

  • SOURCE - The annotation is available in source code only and it's removed during compilation
  • CLASS - The compiler keeps the annotation, but it won't be available at runtime
  • RUNTIME - The compiler keeps the annotation and it'll be available at runtime

Using the @Target annotation we specify to which members we can use the annotation for. The options are:

  • ANNOTATION_TYPE - restricted to other annotations only
  • CONSTRUCTOR - for constructors
  • FIELD - fields
  • LOCAL_VARIABLE - local variables
  • METHOD - methods
  • PACKAGE - packages
  • PARAMETER - method parameters
  • TYPE - restricted to classes, interfaces, annotations, or enumerations

With @Documented we say that if we were to create JavaDoc, this annotation would be included in the documentation.

Plugin Comparator

To compare plugins according to their priority, we'll create a new PriorityPluginComparator class that will implement the Comparator interface. This interface will be typed to the IPlugin interface.

public class PriorityPluginComparator implements Comparator<IPlugin> {}

The interface requires us to implement a single compare() method:

@Override
public int compare(IPlugin o1, IPlugin o2) {
    final PluginConfiguration o1Configuration = o1.getClass().getAnnotation(PluginConfiguration.class);
    final PluginConfiguration o2Configuration = o2.getClass().getAnnotation(PluginConfiguration.class);

    if (o1Configuration == null && o2Configuration == null) {
        return 0;
    }

    final int o1Priority = o1Configuration == null ? PluginConfiguration.DEFAULT_PRIORITY : o1Configuration.priority();
    final int o2Priority = o2Configuration == null ? PluginConfiguration.DEFAULT_PRIORITY : o2Configuration.priority();

    return Integer.compare(o1Priority, o2Priority);
}

In the first part of the method we use getAnnotation() to get either our PluginConfiguration annotation or null if the annotation isn't there. Annotations are obtained for both plugins being compared. If neither plugin has an annotation, they are equal, so we return 0. If at least one plugin contains an annotation, its priority is read. Finally, the comparison of priorities from the annotations is returned.

Sorting Plugins

Let's move to the Server class, where plugins are initialized. We'll add the a getSortedPlugins() method to return a collection of plugins ordered by priority, from highest to lowest:

private List<IPlugin> getSortedPlugins() {
    final List<IPlugin> pluginList = new ArrayList<>(plugins.values());
    pluginList.sort(new PriorityPluginComparator());
    Collections.reverse(pluginList);

    return pluginList;
}

The standard comparator sorts the values in ascending order. If we want the values in descending order, we should NEVER modify the comparator itself, but use the reverse() library method from the Collections class.

At the beginning of the initPlugins() method, we'll get ordered plugins by calling the getSortedPlugins() method and store them in a local pluginList variable. This variable will replace the source collection in all three loops:

private void initPlugins() {
    final List<IPlugin> pluginList = getSortedPlugins();

    for (IPlugin plugin : pluginList) {
        plugin.init();
    }

    for (IPlugin plugin : pluginList) {
        plugin.registerMessageHandlers(eventBus);
    }

    for (IPlugin plugin : pluginList) {
        plugin.setupDependencies(plugins);
    }
}

We've fished priority initialization of plugins and also the first half of the series. If you made it through here, congratulations! You can write your opinions and comments in the comments below the article.

In the next lesson, Java Chat - Client - Introducing the Application Structure, we'll focus on chat implementation.


 

Previous article
Java Server - Local Area Network Propagation (Part 3)
All articles in this section
Server for Client Applications in Java
Skip article
(not recommended)
Java Chat - Client - Introducing the Application Structure
Article has been written for you by Petr Štechmüller
Avatar
User rating:
1 votes
Activities