Article explains the Spring Boot classloader (LaunchedURLClassLoader) and a way to temporarily override library classes with your custom ones.
Just a Little Fix
Let’s say you found a bug in some third party jar your app uses. As a good scout you fixed it and created a pull request with a solution. The pull request was merged, but the fix is critical for you and you can’t wait till next library release. Is using library snapshot the only way? Wouldn’t it be great if there existed a solution overriding temporarily only few particular classes?
As an imaginary example (follow the code) let’s say you found a bug in SpringBootBanner class and already have a solution fixing the banner’s colors - SpringBootBanner fixed
(I know we can easily define custom banners in Spring Boot, it’s just an useful example - it will be super easy to spot if the ‘fix’ is working or not)
So what can we do to have the solution working immediately?
Les’s just take the class (with the package) and paste is into our project (src/main/java).
Now let’s run the App from IDE and everything seems to work
Great! But the joy is premature…
If you build the app and run it
./gradlew build
cd build/libs
java -jar spring-boot-loader-play-0.0.1-SNAPSHOT.jar
Original banner is still being displayed, and this is not about a terminal not suporting ANSI colors.
The banner class (SpringBootBanner) was simply not overriden.
The difference is that when you launch the app from an IDE you have two kinds of artifacts: classes and jars. Classes are loaded before jars so even though you have two versions of a class (your fix in /src/main/java and original in spring-boot-2.0.0.M7.jar lib) only the fix will be loaded. (ClassLoaders don’t care about duplicates - the class that is found first is loaded).
Spring Boot ClassLoader
With jar the situation is harder. It’s a Spring Boot fat jar, with structure as below
+--- spring-boot-loader-play-0.0.1-SNAPSHOT.jar
+--- META-INF
+--- BOOT-INF
| +--- classes # 1 - project classes
| | +--- org.springframework.boot
| | | \--- SpringBootBanner.class # this is our fix
| | |
| | +--- pl.dk.loaderplay
| | \--- SpringBootLoaderApplication.class
| |
| +--- lib # 2 - nested jar libraries
| +--- javax.annotation-api-1.3.1
| +--- spring-boot-2.0.0.M7.jar # original banner class inside
| \--- (...)
|
+--- org.springframework.boot.loader # Spring Boot loader classes
+--- JarLauncher.class
+--- LaunchedURLClassLoader.class
\--- (...)
so actually it contains three type of entries:
- Project classes
- Nested jar libraries
- Spring Boot loader classes
Both Project classes (BOOT-INF/classes) and nested jars (BOOT-INF/lib) are handled by the same classLoader which in turn resides in the root of the jar (org.springframework.boot.loader.LaunchedURLClassLoader).
On might expect that LaunchedURLClassLoader will load the classes content before the lib content but the loader seems not the have that preference.
LaunchedURLClassLoader extends java.net.URLClassLoader which is created with a set of Urls that will be used for classloading.
Url might point to a location like jar archive or classes folder. When classloading all of the resources specified by urls will be traversed in the order the urls were provided and the first resource containing the searched class will be used.
So how the urls are provided to LaunchedURLClassLoader? Simply - the jar archive is parsed from up to bottom and when an archive is found it’s added to url’s list.
In our example:
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-starter-2.0.0.M7.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-autoconfigure-2.0.0.M7.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-2.0.0.M7.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-starter-logging-2.0.0.M7.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/javax.annotation-api-1.3.1.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-context-5.0.2.RELEASE.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-aop-5.0.2.RELEASE.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-beans-5.0.2.RELEASE.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-expression-5.0.2.RELEASE.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-core-5.0.2.RELEASE.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/snakeyaml-1.19.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/logback-classic-1.2.3.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/log4j-to-slf4j-2.10.0.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/jul-to-slf4j-1.7.25.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-jcl-5.0.2.RELEASE.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/logback-core-1.2.3.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/slf4j-api-1.7.25.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/log4j-api-2.10.0.jar!/"
"jar:file:/Users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/"
As we can see /BOOT-INF/classes is the last entry on the list - far after /BOOT-INF/lib/spring-boot-2.0.0.M7.jar so in the search for SpringBootBanner.class the version from the latter will be used - not an outcome we hoped for.
On our quest to “what can we do about it” it’s worth zooming into how classloaders work in hierachies.
Basically classLoaders form hierarchies, with every child loader having a reference to it’s parent.
With LaunchedURLClassLoader being the youngest descendant in Spring Boot case we end up with simple hierarchy like this:
+--- sun.misc.Launcher$ExtClassLoader # loading classes /jre/lib/ext/
+--- sun.misc.Launcher.Launcher$AppClassLoader # loading classes from the root of the jar - spring-boot-loader-play-0.0.1-SNAPSHOT.jar
+--- org.springframework.boot.loader.LaunchedUrlClassLoader # loading classes from /BOOT-INF/lib/ & /BOOT-INF/classes/
In Spring Boot when the class is about to be load we always start with LaunchedUrlClassLoader but the “parent first” rule applies. This means that child loader will try to load a given class only if the parent doesn’t find it.
First Idea - AppClassLoader
If LaunchedUrlClassLoader firstly delegates classloading to AppClassLoader then why not to use this one to load our class before it’s loaded by LaunchedUrlClassLoader?
You might be tempted to simply do
Thread.currentThread().getContextClassLoader()
.getParent()
.loadClass("org.springframework.boot.SpringBootBanner");
but this won’t work. Yes - Thread.currentThread().getContextClassLoader().getParent() gets us the correct AppClassLoader but this one is designed to work with standard jars - where classes (with packages) are placed in the root of the jar. So where AppClassLoader have no problems handling the org.springframework.boot.loader classes (see the jar directory tree above) it will not find classes in BOOT-INF/classes.
Thread.currentThread().getContextClassLoader()
.getParent()
.loadClass("BOOT-INF/classes/org/springframework/boot/SpringBootBanner");
won’t work neither. Yes - the class will be found, but the package will not match the path.
It appears there is no other way than copying SpringBootBanner from BOOT-INF/classes/org/ into the root of the jar. If we do it there is no need to call AppClassLoader directly to load the class as it will always have precedence before LaunchedUrlClassLoader.
This is easily done with gradle build:
bootJar {
with copySpec {
from "$buildDir/classes/java/main/org"
into 'org'
}
}
We can launch the app and find that trully SpringBootBanner was copied to the jar root and AppClassLoader was used to load it but it won’t work.
The problem is that SpringBootBanner depends on other classes - loaded by child LaunchedUrlClassLoader.
One thing we forgot about classLoaders hierarchy is that classes loaded by parents don’t see classes loaded by children.
The “load by AppClassLoader” idea seems to be a dead end - but worry not. We will use the knowledge with the second one!
LaunchedUrlClassLoader - resources order
It appears that parent loaders is not an option and we are stuck with the last loader in the hierarchy - LaunchedUrlClassLoader. You might remember that LaunchedUrlClassLoader loads classes traversing nested resources in the order they were provided to it. So let’s try to manupulate the order, so that /BOOT-INF/classes/ resource is first - not last on the list.
With org.springframework.boot.loader.JarLauncher this seems to be an easy task as it provides
protected void postProcessClassPathArchives(List<Archive> archives)
method to manipulate archives just before they are given to LaunchedUrlClassLoader.
So let’s write a custom launcher using this functionality
public class ClassesFirstJarLauncher extends JarLauncher {
@Override
protected void postProcessClassPathArchives(List<Archive> archives)
throws MalformedURLException {
for (int i = archives.size() - 1; i >= 0; i--) {
Archive archive = archives.get(i);
if (archive.getUrl().getPath().endsWith("/classes!/")) {
archives.remove(archive);
archives.add(0, archive);
break;
}
}
}
public static void main(String[] args) throws Exception {
new ClassesFirstJarLauncher().launch(args);
}
}
Quick reminder is that JarLauncher is the class launching your Spring Boot app. Check any Spring Boot MANIFEST.MF and you will find sth like:
Manifest-Version: 1.0
Start-Class: pl.dk.loaderplay.SpringBootLoaderApplication
Main-Class: org.springframework.boot.loader.JarLauncher
Main-Class being the class with main method launched when
java -jar spring-boot-loader-play-0.0.1-SNAPSHOT.jar
JarLauncher must be loaded by AppClassLoader (LaunchedUrlClassLoader is not even loaded itself yet) and to do that it’s must be placed in the root of the jar. Let’s use the trick we learned before
bootJar {
with copySpec {
from "$buildDir/classes/java/main/pl/dk/loaderplay/ClassesFirstJarLauncher.class"
into 'pl/dk/loaderplay'
}
}
What remained is to replace Main-Class in MANIFEST.MF. Spring Boot Gradle Plugin provides a way to do it
bootJar {
manifest {
attributes 'Main-Class': 'pl.dk.loaderplay.ClassesFirstJarLauncher'
}
}
Unfortunately, when replacing Main-Class , the original Spring Boot loader / launcher classes are not copied to the root of the jar - and we still need them. This is how Spring Boot Gradle Plugin works and I have not found a way around it.
(It happens because in plugin’s BootZipCopyAction decision on either or not to copy the loader files is based upon the fact if original JarLauncher was used or not)
So changing Main-Class by bootJar configuration is no use to us. One can try to change it in some other way. For me - it was enough to leave the original Main-Class in the manifesto and simply specify start class when launching the app.
java -cp spring-boot-loader-play-0.0.1-SNAPSHOT.jar \
pl.dk.loaderplay.ClassesFirstJarLauncher
When doing so the class was finally overriden:
Quick summary
To summarize shortly
Goal
override
spring-boot-loader-play-0.0.1-SNAPSHOT.jar
/BOOT-INF/lib/spring-boot-2.0.0.M7.jar/org/springframework/boot/SpringBootBanner.class
with
spring-boot-loader-play-0.0.1-SNAPSHOT.jar
/BOOT-INF/classes/org/springframework/boot/bootSpringBootBanner.class
Steps
- Place the overriding SpringBootBanner in src/main/java
- Create custom launcher ordering resources from which classes are load - ClassesFirstJarLauncher
- Copy the launcher to root of the jar by bootJar gradle task
- Launch the archive specifying the launcher class
java -cp spring-boot-loader-play-0.0.1-SNAPSHOT.jar \ pl.dk.loaderplay.ClassesFirstJarLauncher
Again you may check the code here