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:
- Each JAR file will contain just one plugin
- The class representing the plugin must implement the
IPlugin
interface - 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:
- We create a new class loader with the plugin path
- We create a new
JarInputStream
to read the contents of the JAR file - We get the manifest from the JAR
- Read all attributes from the manifest
- We are specifically interested in the
"Plugin-Class"
attribute required by our system - We use the
Class.forName()
method to get the class (not instance) that represents the plugin - We create a new plugin instance using
newInstance()
. By calling theasSubclass()
method, we say that the instance will be a descendant of a class (in our case of theIPlugin
interface) - We write to the console that we have loaded the plugin successfully
- 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 compilationCLASS
- The compiler keeps the annotation, but it won't be available at runtimeRUNTIME
- 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 onlyCONSTRUCTOR
- for constructorsFIELD
- fieldsLOCAL_VARIABLE
- local variablesMETHOD
- methodsPACKAGE
- packagesPARAMETER
- method parametersTYPE
- 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.