🤖 Implementing a GraalVM custom Feature
I’ve been coding a GraalVM-powered Scala application for a while now, and the more complicated this project becomes, the more GraalVM-related issues arise. So, today I will show you how to implement a custom feature to enable the use of a heavily reflection-based library within a GraalVM application.
What is Feature?
Literally, Feature is a Java interface (javadoc) that provides methods to hook into different native-image generation stages. By implementing it, you can customize your image generation process and overcome some GraalVM restrictions within your application.
When something doesn’t work in the generated binary, the most common reason is dynamic features (reflection, resources, proxies, etc.). Usually, all you need to do is fill in the corresponding reachability metadata, but there are some cases when implementing an appropriate custom Feature is preferable. Generally speaking, this gives you more control over the process and allows you to define by code what to do, instead of hard-coding configuration files.
As an example, you can check some built-in GraalVM Features: GsonFeature, ScalaFeature, JUnitFeature.
Implementing a custom Feature
So, when should you implement a custom Feature? As I see, there are the following most common cases:
- When reachability metadata can’t cover all your needs.
- When you need to dynamically decide what should be allowed to use with “dynamic features”.
I’m sure there are more suitable cases to note, but that’s what I see.
Next, I will show you an example of implementing a custom Feature for the htmlunit library, which I’m using in my link saver bot to fetch OpenGraph metadata for a webpage. The problem with running this library under GraalVM is that it uses reflection really a lot. Even using a tracing agent to generate reflect-config.json
doesn’t help here because reflection calls depend on executed JavaScript code, and it is hard to cover all possible reflection calls during a tracing session. I tried several times, but each time I got different results, and there was no guarantee that all reflection calls were covered.
This case can be easily resolved by implementing a custom Feature. However, it would be even easier if the issue was fixed. But since it is still unresolved, using a custom Feature remains the only solution.
Presequences
Before we start, I would like to highlight some things about custom Features:
- Custom Feature implementation can be placed right inside your project. You don’t need to separate it into a library or separate module.
- It is better to avoid using many libraries inside a Feature, especially if they are used both in Feature and in project itself. It potentially leads to errors like (it literally means that you can’t initialize classes both during run-time and build-time):
Classes that should be initialized at run time got initialized during image building
. To fix it you can add--initialize-at-build-time=className
option, but it is better to avoid such issues. - Be careful when writing your custom Features in non-Java language, especially if your project is also written in the same non-Java language. This is because of the reason mentioned in the previous point.
- Be sure to add Graal SDK as compile-time dependency (latest version at mvnrepository.com):
libraryDependencies += "org.graalvm.sdk" % "graal-sdk" % Version.graalvm % "provided"
- If you are going to interact with
java.lang.reflect
, probably it worths to add reflections library too, but it’s up to you (but be careful with this library because it usesslf4j-api
dependency, which may break things a little bit (see second point)):
libraryDependencies += "org.reflections" % "reflections" % Version.reflections % "provided"
- Also, just in case, it would be better to have your Graal SDK version synchronized with your GraalVM JDK version.
Code
So, finally, the code. The full listing looks like this in my case:
package me.seroperson.graalvm;
import org.graalvm.nativeimage.hosted.Feature;
import org.graalvm.nativeimage.hosted.RuntimeReflection;
import org.reflections.Reflections;
import org.reflections.scanners.Scanners;
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.function.Consumer;
import java.util.stream.Stream;
public class HtmlUnitFeature implements Feature {
@Override
public String getDescription() {
return "Makes htmlunit headless browser work correctly";
}
@Override
public void beforeAnalysis(BeforeAnalysisAccess access) {
Consumer<Class<?>> registerAll = (accessClass) -> registerClass(access, accessClass);
doForEachClassInPackage(
access,
"org.htmlunit.corejs",
"org.htmlunit.corejs.javascript.Scriptable",
registerAll
);
doForEachClassInPackage(
access,
"org.htmlunit.javascript",
"org.htmlunit.corejs.javascript.Scriptable",
registerAll
);
doForEachClassInPackage(
access,
"org.htmlunit.svg",
"org.w3c.dom.Element",
registerAll
);
Stream
.of(
"org.htmlunit.BrowserVersionFeatures",
"org.htmlunit.corejs.javascript.jdk18.VMBridge_jdk18",
"org.htmlunit.javascript.host.ConsoleCustom",
"org.htmlunit.javascript.host.DateCustom",
"org.htmlunit.javascript.host.NumberCustom"
)
.forEach(str -> registerAll.accept(access.findClassByName(str)));
}
private void doForEachClassInPackage(BeforeAnalysisAccess access, String packageName, String parentName, Consumer<Class<?>> action) {
Reflections reflections = new Reflections(packageName, Scanners.SubTypes);
reflections
.getSubTypesOf(access.findClassByName(parentName))
.forEach(action);
}
private void registerClass(BeforeAnalysisAccess access, Class<?> accessClass) {
access.registerAsUsed(accessClass);
RuntimeReflection.register(accessClass);
RuntimeReflection.registerAllFields(accessClass);
RuntimeReflection.registerAllConstructors(accessClass);
RuntimeReflection.registerAllMethods(accessClass);
if(!(accessClass.isArray() || accessClass.isInterface() || Modifier.isAbstract(accessClass.getModifiers()))) {
try {
@SuppressWarnings("unused")
Constructor<?> nullaryConstructor = accessClass.getDeclaredConstructor();
RuntimeReflection.registerForReflectiveInstantiation(accessClass);
} catch (NoSuchMethodException ignored) {
}
}
Arrays.stream(accessClass.getMethods())
.forEach(method -> {
RuntimeReflection.registerMethodLookup(accessClass, method.getName(), method.getParameterTypes());
});
Arrays.stream(accessClass.getFields())
.forEach(field -> {
RuntimeReflection.registerFieldLookup(accessClass, field.getName());
access.registerAsAccessed(field);
});
}
}
Shortly, it simply enables most of the reflection features on the given packages and some standalone classes. We can’t easily do it with reflect-config.json
, so we have to do it with code. It might be a bit excessive and there is room for optimizations, but at least it is a good starting point. This way, we can also register proxies (RuntimeProxyCreation), manage resources (RuntimeResourcesAccess), handle JNI calls (RuntimeJNIAccess), and so on (link).
Also it worths to mention isInConfiguration method, which allows you to dynamically disable or enable a Feature. By doing so, you can disable your Feature if such a library is not present in the classpath at all. That’s how built-in ScalaFeature
works:
public class ScalaFeature implements InternalFeature {
// ...
@Override
public boolean isInConfiguration(IsInConfigurationAccess access) {
return access.findClassByName("scala.Predef") != null;
}
// ...
}
There are dozens of stages available to hook into besides beforeAnalysis
. It is quite possible that something else would suit your needs better.
So, the only thing left is to add the corresponding build parameter in order to enable the Feature (snippet for sbt
, but I think you’ll figure out):
nativeImageOptions ++= Seq(
// Enables htmlunit reflection
"--features=me.seroperson.graalvm.HtmlUnitFeature"
)
That’s it! Now you can try to start build, your output should contain a list of applied features somewhere, like so:
4 user-specific feature(s):
- com.oracle.svm.polyglot.scala.ScalaFeature
- com.oracle.svm.thirdparty.gson.GsonFeature
- me.seroperson.graalvm.HtmlUnitFeature: Makes htmlunit headless browser work correctly
- org.graalvm.home.HomeFinderFeature: Finds GraalVM paths and its version number
Probably you should now test it a little bit and ensure everything works.
Conclusion
Thank you for reading this little article, I hope it will be useful to someone. Feel free to reach me if you have something to say.
And also take a look on the posts on similar topics: