Blog

Extending Spring Boot Apps with Plugins

Category
Software development
Extending Spring Boot Apps with Plugins

A guide on how to extend Spring Boot Apps with plugins

Let’s say you’re building a platform you want other developers to extend, perhaps with their code packaged in jars. Or you want to reach as many users as possible and you’re deploying the platform in the cloud.

Say you have some potential customers that are not cloud-ready. You also want to provide it as a distributable zip, Docker image, or something your users will be running on their premises. If you do – read on.

Git: https://github.com/darkospoljaric/plugin-platform

Constraints

  • You can’t count on your cloud provided to do the restart of your system, cause your platform might not be running in one at all
  • Some of your users probably won’t appreciate that they have to do a manual restart or – god forbid – a rebuild or file operations every time a plugin is installed, updated or deleted.
  • You can’t package the plugins with the system cause users will come and install/update/delete plugins when they want, obviously!
  • Since you want to be cloud native and have the plugins survive restarts, you have to be able to load the plugins from an external location or get them into the container every time it’s started

If these constraints are what you’re dealing with, we might have just what you need.

How to build a Spring boot app

We’re going to build a Spring boot app, and set up a custom classloader that will scan the folder where plugin jars are stored. Then we’ll enable spring context restart to pick up new plugins as they’re installed – all without a JVM restart.

Let’s start with the heart – the classloader. I will assume that you’re familiar with the classloading basics and need a gentle refresher that:

  • Classes are only loaded by the classloader, not stored – VM stores the loaded classes in Metaspace
  • Classloader is never invoked directly by your code; it’s always done by the runtime
  • Classloaders are wired in a parent-child hierarchy. A child can have one parent, and it’s not quite an app code friendly to have multiple children of one parent

The classloader will have a plugin folder path passed to it. We’ll initialize it right after instantiation, but we’ll keep the method public for invocation after plugin upload.

<strong>public class </strong>PluginClassloader <strong>extends </strong>ClassLoader {
   <strong>private final </strong>String <strong>pluginsFolder</strong>;
   <strong>private </strong>List<JarFile> <strong>jars</strong>;
   <strong>public </strong>PluginClassloader(String pluginsFolder, ClassLoader parent) {
       <strong>super</strong>(parent);
       <strong>this</strong>.<strong>pluginsFolder </strong>= pluginsFolder;
       init();
   }
   <strong>public void </strong>init() {
       File[] jarFiles = <strong>new </strong>File(<strong>pluginsFolder</strong>).listFiles((dir, name) -> name.endsWith(<strong>".jar"</strong>));
       <strong>if </strong>(jarFiles == <strong>null</strong>) {
           <strong>jars </strong>= Collections.<em>emptyList</em>();
           <strong>return</strong>;
       }
       <strong>this</strong>.<strong>jars </strong>= Arrays.<em>stream</em>(jarFiles).map(jarFile -> {
           <strong>try </strong>{
               <strong>return new </strong>JarFile(jarFile);
           } <strong>catch </strong>(IOException e) {
               <em>// we've just listed them, they're here</em>
<em>               </em><strong>return null</strong>;
           }
       }).collect(Collectors.<em>toList</em>());
   }
}Code language: HTML, XML (xml)

Nothing special here, just browsing through the filesystem and creating a list of jars to use later. Now onto the class loading.


A gentle reminder on how classes are loaded

The default classloading algorithm is called “parent first”. Meaning, if a system class loader, or PluginClassLoader from our example, is asked to load a class, they will both delegate to their parent. And the parent will delegate to it’s parent, all the way up to the bootstrap classloader. Bootstrap classloader doesn’t have a parent and the process of loading a class ends with it. 

Bit awkward, right? Why not just load via the first one?

That’s because Java runtime wants to prevent JVM extensions and applications from overriding classes in the JRE such as java.lang.Object and friends. If the bootstrap or extension or system class loader fails to load a class from a plugin you’re developing – which they certainly will – they will delegate downstream to their child classloader, until a class is found. If the class isn’t found you will be greeted with a ClassNotFoundException.

spring boot apps

There are a couple of methods from the base ClassLoader that we’ll need to override.

findClass

The first and obvious one is findClass which will iterate through all the plugin jars and see if a class is in any of them. It will fetch a list of URLs and just use the first one to define the class and return it. This is the opportunity to create a bit of logic to avoid nasty ClassCastException and others (depending on what the app code is doing), but that’s beyond the scope of this blog.

@Override
<strong>protected </strong>Class<?> findClass(<strong>final </strong>String name) <strong>throws </strong>ClassNotFoundException {
   String className = name.replace(<strong>'.'</strong>, <strong>'/'</strong>).concat(<strong>".class"</strong>);
   List<URL> resourceUrl = getResourceUrl(className);
   <strong>if </strong>(resourceUrl.size() > 0) {
       URL url = resourceUrl.iterator().next();
       <strong>byte</strong>[] bytes = getBytes(url);
       Class<?> clazz = defineClass(name, bytes, 0, bytes.<strong>length</strong>);
       resolveClass(clazz);
       <strong>return </strong>clazz;
   } <strong>else </strong>{
       <strong>throw new </strong>ClassNotFoundException();
   }
}

<strong>private </strong>List<URL> getResourceUrl(String className) {
   List<URL> urls = <strong>new </strong>ArrayList<>();
   <strong>for </strong>(JarFile jar : <strong>jars</strong>) {
       ZipEntry entry = jar.getEntry(className);
       <strong>if </strong>(entry != <strong>null</strong>) {
           urls.add(createUrl(entry.getName(), jar));
       }
   }
   <strong>return </strong>urls;
}Code language: HTML, XML (xml)

findResources

@Override
<strong>protected </strong>Enumeration<URL> findResources(<strong>final </strong>String name) {
   List<URL> urls = getResourceUrl(name);
   <strong>return </strong>Collections.<em>enumeration</em>(urls);
}Code language: HTML, XML (xml)

This method is being used by Spring to see which jars it has to scan for classes. After it does it’s scanning (note: no classloader is used for scanning), then the classes get loaded, instantiated, wired, and more.

findResource

@Override
<strong>protected </strong>URL findResource(<strong>final </strong>String name) {
   List<URL> resourceUrls = getResourceUrl(name);
   <strong>return </strong>resourceUrls.isEmpty() ? <strong>null </strong>: resourceUrls.iterator().next();
}Code language: HTML, XML (xml)

Possibly a bit unexpected. However, this method is used by Spring to fetch the class resource to read annotation metadata via it’s own ClassReader and other supporting classes. Why this way, and not some other, is beyond the scope of this blog.

Digression

The first classloader that comes to mind when loading an external jar is a UrlClassLoader. This would work if we only had one jar to load or can settle for a tall classloader tree that needs to be rebuilt every time a plugin is created/upgraded/deleted.

Upload plugins

For convenience, there are 2 plugins available for you to use in the sample repository. Each has one controller in it. Upload them with:

curl --location --request POST 'http://localhost:8080/plugins/upload' --form 'file=@"/fullPathTo/plugin-platform/spring-plugin/target/spring-plugin-0.0.1-SNAPSHOT.jar"'
 
curl --location --request POST 'http://localhost:8080/plugins/upload' --form 'file=@"/fullPathTo/plugin-platform/another-spring-plugin/target/another-spring-plugin-0.0.1-SNAPSHOT.jar"'Code language: JavaScript (javascript)

This POST will trigger a file copy to the filesystem and an immediate refresh of the PluginClassLoader.

@PostMapping(<strong>"/upload"</strong>)
@ResponseBody
<strong>public </strong>String upload(@RequestParam(<strong>"file"</strong>) MultipartFile file) <strong>throws </strong>IOException {
   File targetFile = <strong>new </strong>File(<strong>"plugins" </strong>+ File.<strong><em>separator </em></strong>+ file.getOriginalFilename());
   OutputStream fileOutputStream = <strong>new </strong>FileOutputStream(targetFile);
   FileCopyUtils.<em>copy</em>(file.getInputStream(), fileOutputStream);
   ClassLoader cl = Thread.<em>currentThread</em>().getContextClassLoader();
   <strong>if </strong>(cl.getParent() <strong>instanceof </strong>PluginClassloader) {
       ((PluginClassloader)cl.getParent()).init();
   }
   <strong>return "ok"</strong>;
}Code language: HTML, XML (xml)

Refresh app

After the plugins are uploaded we need to scan them and, well, plug them in! This is done via one of the spring boot app’s actuator endpoints, namely “restart”. There’s a way to do the restart immediately after upload, by auto wiring the actuator into our own controller. But we’re using a plain actuator for a more controlled experience. After all, an administrator might want to upload all plugins first and then trigger the restart instead of restarting on every upload.

curl --location --request POST 'http://localhost:8080/actuator/restart'

This will immediately trigger app context restart – which will not restart the JVM in which the app is running. Before you can call the endpoint you have to enable it in application.properties 

management.endpoints.web.exposure.include=health,info,restart,refreshmanagement.endpoint.restart.enabled=true

curl --location --request GET 'http://localhost:8080/introspect/endpoints'

You should be seeing the endpoints that came with the uploaded plugins in the response.

Wrap up

The only thing remaining if you’re running the container in k8s, or any cloud, is to pay attention to how your liveness probe is defined. Especially if your application is large and the context restart takes a bit longer. Also, if you’re running multiple pods, you’ll have to somehow schedule and/or sync restarts. This shouldn’t be a problem via an event sent to a topic.

CONTACT US

Exceptional ideas need experienced partners.