Fixing serialization of Kotlin objects once and for all
Some time ago, I read an article describing a problem experienced with Kotlin objects and serialization using built-in Java methods. The author suggests an amazing solution involving adding the readResolve
method to each object implementing java.io.Serializable
rather than using instance checks.
Despite this approach seemingly being the most correct one to go with, supporting it might be quite a nightmare. Given that here, at Bumble — the parent company operating Badoo and Bumble apps — , we don’t use Serializable
anywhere except for Bundle
, we decided to keep is
for each clause of when
expression, and forget about that problem altogether. However, in the back of my mind, I just could not understand why Kotlin compiler does not generate readResolve by itself for JVM targets. It seemed to be a job more fitting for a code generation tool than a human being.
This problem popped up when I was researching Kotlin compiler plugins and this is how it can be solved in an elegant way. If Kotlin does not generate these functions by itself, let’s help it to do so! This article goes through the implementation of the compiler plugin to this purpose.
Planning ahead
First of all, let’s take a closer look at the code we are going to generate:
The plugin should generate a readResolve
function for each object extending java.io.Serializable
if that class does not contain any. The function has zero parameters and return type of Any?
. In the body, we return the singleton instance of the object.
We don’t need this method to be visible in IDE in Kotlin or Java code; as a matter of fact, it is preferable to have it hidden. Therefore, we can implement a generation on the backend of the compiler. The resulting method will exist inside JAR but will not be referenceable in the compiler or the editor.
Setting up
Now it is time to set up a compiler plugin build with Gradle and check that it is connecting to the compiler in a separate integration module.
Plugin has a compile time dependency on compiler; in runtime Kotlin already provides all classes. Thankfully, JetBrains publishes a separate version of the compiler that we can use here.
The plugin entry point is ComponentRegistrar
subclass, which gets instantiated when the compilation starts and allows to register custom pieces of the plugin.
The registrar is instantiated with ServiceLoader, so the plugin jar is required to have a resource file with the implementation class name in META-INF/services
folder. If you don’t feel like managing these files yourself, Google’s AutoService will generate them for you.
Now, we can wire up the plugin to the integration module:
This gradle configuration will add a compiler plugin and all of its dependencies to the project, so it will be loaded with gradle task execution. If we now try to compile any class in integration, it should print “Works” in the console.
Once the plugin is set up, we are moving to actual code generation. Kotlin currently supports three different platforms, of which we are only interested in JVM (just because java.io.Serializable
only exists there). Generation for this backend in its current state is supported with ExpressionCodegenExtension
which is the one we need to implement.
This extension is applied to every class encountered by the compiler on the bytecode generation stage. It allows you to modify function/property references and to generate some synthetic parts of a class. The last feature is exactly what we need to add the readResolve
.
For now, it will just print text representation of the class descriptor which generation was called for.
Possible points of extension are defined as a subclass of
ProjectExtensionDescriptor<T>
. They provide registerExtension function to add your own extension instances. For the purposes of bytecode generation, we will need to useExpressionCodegenExtension
only, but there are way more open possibilities inside the compiler.
The last step is to connect this extension in ComponentRegistrar
.
Now you can add some classes to integration-test
module and check what it prints on compilation!
Generating bytecode
Compiler gives us info of a class through its descriptor, which essentially is a collection of high-level information about the class, including the superclasses, functions and fields that it contains. Using that info we can figure out whether or not the class requires fixing.
The check consists of three steps:
- Confirm that we are dealing with an object
- Check object implements
Serializable
in any way - Verify that
readResolve
is not implemented yet.
For the first step, Kotlin already has a helpful method to check whether a class is an object, so we can jump to the next one.
We traverse the class hierarchy and find if any of the interfaces we encounter are java.io.Serializable
. Note that we need to check parents of both super classes and interfaces.
The last part is to figure out whether our serializable class already has readResolve
overridden:
Class descriptor gives easy access to every function available in the scope. We grab function descriptors from the scope and compare the name and number of parameters.
Once all these conditions are checked, we can start generating the method. Kotlin compiler uses ASM for bytecode manipulation, and exposes already prepared ClassBuilder
through the codegen object.
We set up a new method on exposed ClassBuilder
with public and synthetic modifiers, so it is visible neither in Java nor Kotlin classes in IDE. We also expose a lambda to generate function body.
Bytecode of the Example
object above gives us the desired bytecode to emit for the body:
GETSTATIC Example.INSTANCE : LExample;
ARETURN
InstructionAdapter
syntax is very close to the bytecode instructions above. Using them as a reference, we generate instructions inside the lambda parameter:
Testing
The Kotlin compiler team is testing their plugins in many different ways. However, many of those are a bit too complicated to set up ourselves (for example bytecode instruction validation), so I will turn instead to a higher level of testing. Firstly, we will test the compiler itself, checking output using Java reflection. And the second part is where our integration module comes into use.
For direct compiler testing, I am using kotlin-compile-testing. This amazing library allows testing against embedded snippets or files in the resources directory with many customizations.
The test is providing a code snippet as a virtual file to the compiler and checking the resulting class for the readResolve
function using Java reflection.
We already have an integration module set up, so we can add the tests there. The only thing that’s left to do is to select your favourite test framework and check instance of object after serialization:
And that’s it!
Conclusion
Kotlin compiler plugins are a perfect tool for code generation and meta programming. Despite the initial learning curve, the possibilities this platform opens are huge, and I encourage everyone to give it a try. If you’re keen to get started now, I recommend checking out Arrow Meta, they have done an amazing job of creating a platform for developing plugins right now.
It has to be said, though, that there are some hidden pitfalls I have not touched upon, such as lack of proper documentation on anything related to compiler or constantly changing API between major versions. Hopefully, we will get more official support at some point around Kotlin 1.4.
Full code for this plugin is available on Github. It is also published to gradle plugin repo, for you to try in your projects. If you are up for more Kotlin / Android/compiler plugin stuff, don’t hesitate to follow me on Twitter!
Our Bumble and Badoo Android team is growing! Feel free to get in touch and find out more about it here if you’re interested in joining us 🙂