Blogs

Extending Spring Boot apps with plugins

Category
Software development
Extending Spring Boot apps with plugins

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

Say you’re building a platform that you want other developers to extend, perhaps with their code packaged in jars. Say 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, and you also want to provide it as a distributable zip, Docker image, or something your users are going to be running on their premises. If you do – read on.

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.

What we’re going to build is a Spring boot application, set up a custom classloader that will scan the folder where plugin jars are stored, and then enable spring context restart in order 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 just 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, where a child can have one parent, and it’s not quite 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.

public class PluginClassloader extends ClassLoader {

   private final String pluginsFolder;
   private List<JarFile> jars;

   public PluginClassloader(String pluginsFolder, ClassLoader parent) {
       super(parent);
       this.pluginsFolder = pluginsFolder;

       init();
   }

   public void init() {
       File[] jarFiles = new File(pluginsFolder).listFiles((dir, name) -> name.endsWith(".jar"));
       if (jarFiles == null) {
           jars = Collections.emptyList();
           return;
       }

       this.jars = Arrays.stream(jarFiles).map(jarFile -> {
           try {
               return new JarFile(jarFile);
           } catch (IOException e) {
               // we've just listed them, they're here
               return null;
           }
       }).collect(Collectors.toList());
   }
}

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.

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
protected Class<?> findClass(final String name) throws ClassNotFoundException {
   String className = name.replace('.', '/').concat(".class");
   List<URL> resourceUrl = getResourceUrl(className);

   if (resourceUrl.size() > 0) {
       URL url = resourceUrl.iterator().next();
       byte[] bytes = getBytes(url);
       Class<?> clazz = defineClass(name, bytes, 0, bytes.length);
       resolveClass(clazz);
       return clazz;
   } else {
       throw new ClassNotFoundException();
   }
}


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

   return urls;
}

findResources

@Override
protected Enumeration<URL> findResources(final String name) {
   List<URL> urls = getResourceUrl(name);
   return Collections.enumeration(urls);
}

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
protected URL findResource(final String name) {
   List<URL> resourceUrls = getResourceUrl(name);
   return resourceUrls.isEmpty() ? null : resourceUrls.iterator().next();
}

Possibly a bit unexpected, but this method is used by Spring to fetch the class resource in order 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, which 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"'

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

@PostMapping("/upload")
@ResponseBody
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
   File targetFile = new File("plugins" + File.separator + file.getOriginalFilename());
   OutputStream fileOutputStream = new FileOutputStream(targetFile);

   FileCopyUtils.copy(file.getInputStream(), fileOutputStream);

   ClassLoader cl = Thread.currentThread().getContextClassLoader();
   if (cl.getParent() instanceof PluginClassloader) {
       ((PluginClassloader)cl.getParent()).init();
   }
   return "ok";
}

Refresh app

After the plugins are uploaded we need to scan them and, well, plug them in 🙂 This is done via one of spring boot’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, which shouldn’t be a problem via an event sent to a topic.

Next

Blog

Firebase Cloud Messaging setup for JHipster generated Angular 11 application

Company

Our people really love it here

How it all started

Est. in 2014., gathering eight employees with eyes set on the future. No matter how set they were, they couldn’t predict the success and extent of growth that would ensue. Today there are more than 100 of us, and people are here to stay.

Stability in unstable times

The turmoil of 2020 caused great inconvenience for people all over the world. However, this did not affect our business. Quite the opposite — we not only kept all jobs and salaries intact, but we also grew in size. And we keep expanding. 

Contact

We’d love to hear from you