This is my second article on Kotlin annotations, where I’ll explore Moshi’s codebase as a case study for how a real-world library leverages annotations using annotation processing, reflection, and lint. Part 1 gives a high-level introduction to the three mechanisms, and I recommend reading it first.
Introduction to Moshi
Moshi is a popular library for parsing JSON to and from Java or Kotlin classes. I chose it for this case study because it’s a relatively small library whose API includes several annotations, and it supports both annotation processing and reflection.
It’s available through the following dependency:
implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)
And its basic usage to convert a JSON string to a BookModel
instance looks like this:
data class BookModel(
val title: String,
@Json(name = "page_count") val pageCount: Int,
val genre: Genre,
) {
enum class Genre {
FICTION,
NONFICTION,
}
}
private val moshi = Moshi.Builder().build()
private val adapter = moshi.adapter<BookModel>()
private val bookString = """
{
"title": "Our Share of Night",
"page_count": 588,
"genre": "FICTION"
}
"""
val book = adapter.fromJson(bookString)
Moshi provides a few annotations for customizing how classes are converted to and from JSON. In the example above, the @Json
annotation with its name
parameter tells the adapter to use page_count
as the key in the JSON string, even though the class field is named pageCount
.
Moshi works with the concept of adapter classes. An adapter is a typesafe mechanism to serialize a specific class into a JSON string and to deserialize a JSON string back into the correct type. By default, Moshi has built-in support for Java’s core data types, including primitives, Collections, and Strings, and is able to adapt other classes by writing them out field-by-field.
Moshi can either generate adapters at compile time via annotation processing or at runtime via reflection depending on which dependencies we include. I’ll go over both cases.
Moshi with Annotation Processor
To have Moshi generate adapter classes at compile time via annotation processing, we need to add either:
kapt(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”)
for kapt, or
ksp(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”)
for ksp.
Moshi will generate an adapter for each class annotated with @JsonClass(generateAdapter = true)
, for example:
@JsonClass(generateAdapter = true)
data class BookModel(
val title: String,
@Json(name = "page_count") val pageCount: Int,
val genre: Genre,
) { ... }
When we build the app, Moshi will generate a BookModelJsonAdapter
file in the /build/generated/source/kapt/
directory. Any generated adapters will extend JsonAdapter
and override its toString()
, fromJson()
, and toJson()
functions to work with the specific type.
When we call:
private val adapter = moshi.adapter<BookModel>()
Moshi.adapter()
will return the generated BookModelJsonAdapter
.
Most of Moshi’s codegen logic lives in AdapterGenerator, which is shared between Moshi’s kapt and KSP implementations.
AdapterGenerator
uses KotlinPoet to create a FileSpec
with the new adapter class.
Kapt
We create annotation processors in kapt by extending AbstractProcessor. Let’s look at how Moshi extends it with
JsonClassCodegenProcessor to process the
@JsonClass
annotation.
I copied the code below directly from the Moshi codebase, keeping only the parts related to handling@JsonClass
. Feel free to read the entire file on your own if you’re interested!
@AutoService(Processor::class) // 1
public class JsonClassCodegenProcessor : AbstractProcessor() {
...
private val annotation = JsonClass::class.java
...
// 2
override fun getSupportedAnnotationTypes(): Set<String> = setOf(annotation.canonicalName)
...
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
...
// 3
for (type in roundEnv.getElementsAnnotatedWith(annotation)) {
...
val jsonClass = type.getAnnotation(annotation) // 3a
// 3b
if (jsonClass.generateAdapter && jsonClass.generator.isEmpty()) {
// 3c
val generator = adapterGenerator(type, cachedClassInspector) ?: continue
val preparedAdapter = generator
.prepare(generateProguardRules) { … }
.addOriginatingElement(type)
.build()
preparedAdapter.spec.writeTo(filer) // 3d
preparedAdapter.proguardConfig?.writeTo(filer, type) // 3e
}
return false // 4
}
}
- Use
@Autoservice
to registerJsonClassCodeGenProcessor
to the compiler - Override
getSupportedAnnotationTypes()
to declareJsonClassCodegenProcessor
’s support for@JsonClass
annotations - In
process()
, iterate through allTypeElements
annotated with@JsonClass
and for each:
a) Get theJsonClass
for the current type
b) Use theJsonClass
’sgenerateAdapter
andgenerator
fields to determine if we should generate an adapter
c) Create anAdapterGenerator
for the current type
d) Write theAdapterGenerator
’s generatedFileSpec
for the current type to a file usingFiler
e) Write theAdapterGenerator
’s generated Proguard config for the current type to a file usingFiler
- Return
false
at the end ofprocess()
to specify this processor isn’t claiming the set of annotationTypeElements
passed into process. This lets other processors use the Moshi annotations as well.
KSP
Annotation processors in KSP extend SymbolProcessor. KSP also requires a class that implements
SymbolProcessorProvider
as the entry point for instantiating a custom SymbolProcessor
. Let’s see how Moshi’s JsonClassSymbolProcessorProvider processes
@JsonClass
.
@AutoService(SymbolProcessorProvider::class) // 1
public class JsonClassSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return JsonClassSymbolProcessor(environment) // 2
}
}
private class JsonClassSymbolProcessor(
environment: SymbolProcessorEnvironment,
) : SymbolProcessor {
private companion object {
val JSON_CLASS_NAME = JsonClass::class.qualifiedName!!
}
...
override fun process(resolver: Resolver): List<KSAnnotated> {
// 3
for (type in resolver.getSymbolsWithAnnotation(JSON_CLASS_NAME)) {
...
// 3a
val jsonClassAnnotation = type.findAnnotationWithType<JsonClass>() ?: continue
val generator = jsonClassAnnotation.generator
// 3b
if (generator.isNotEmpty()) continue
if (!jsonClassAnnotation.generateAdapter) continue
try {
val originatingFile = type.containingFile!!
val adapterGenerator = adapterGenerator(logger, resolver, type) ?: return emptyList()// create an AdapterGenerator for the current type
// 3c
val preparedAdapter = adapterGenerator
.prepare(generateProguardRules) { spec ->
spec.toBuilder()
.addOriginatingKSFile(originatingFile)
.build()
}
// 3d
preparedAdapter.spec.writeTo(codeGenerator, aggregating = false)
// 3e
preparedAdapter.proguardConfig?.writeTo(codeGenerator, originatingFile)
} catch (e: Exception) {
logger.error(...)
}
}
return emptyList() // 4
}
}
- Use
@Autoservice
to registerJsonClassSymbolProcessorProvider
to the compiler - Override
JsonClassSymbolProcessorProvider.create()
to return instance ofJsonClassSymbolProcessor
- In
process()
, iterate through allKsAnnotated
symbols annotated with@JsonClass
(seeResolver) and for each:
a) Get theJsonClass
for the current symbol
b) Use theJsonClass
’sgenerateAdapter
andgenerator
fields to determine if we should generate an adapter
c) Create anAdapterGenerator
for the current type
d) Write theAdapterGenerator
’s generatedFileSpec
for the current type to a file usingCodeGenerator
e) Write theAdapterGenerator
’s generated Proguard config for the current type to a file usingCodeGenerator
- Return an empty list at the end of
process()
to specify this processor isn’t deferring any symbols to later rounds
Moshi also registers JsonClassCodegenProcessor
in its incremental/annotation/processors file to have it work with incremental processing.
When reading through Moshi’s codebase, I was pleasantly surprised by how short and readable JsonClassCodegenProcessor and
JsonClassSymbolProcessorProvider were. We can build a very useful custom annotation processor without much code! And since the bulk of the codegen logic lives in the API-agnostic
AdapterGenerator, adding KSP support to Moshi didn’t require much additional work. The high-level steps for both annotation processors are nearly identical.
Job Offers
Moshi with Reflection
We can achieve the same JSON parsing behavior with reflection.
We’ll have to add the following dependency:
implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)
We no longer need to annotate BookModel
with @JsonClass
, because that annotation is only needed for codegen. Instead, we need to add a KotlinJsonAdapterFactory
when building Moshi. KotlinJsonAdapterFactory
is a general-purpose adapter factory that can create a JsonAdapter
for any Kotlin class at runtime via reflection.
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
Now when we call Moshi.adapter()
:
private val adapter = moshi.adapter<BookModel>()
It’ll return the BookModel
adapter created by KotlinJsonAdapterFactory
at runtime.
When invoked, Moshi.adapter<T>()
iterates through all the available adapters and adapter factories until it finds one that supports T
. Moshi comes with several built-in factories, including ones for primitives (int, float, etc) and enums, and we can add additional ones using MoshiBuilder().add()
. In this example, KotlinJsonAdapterFactory
is the only non-default one we added.
Here’s how KotlinJsonAdapterFactory handles the
@Json
annotation and its jsonName
field.
public class KotlinJsonAdapterFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
val rawType = type.rawType
val rawTypeKotlin = rawType.kotlin
val parametersByName = constructor.parameters.associateBy { it.name }
try {
val generatedAdapter = moshi.generatedAdapter(type, rawType) // 1
if (generatedAdapter != null) {
return generatedAdapter
}
} catch (e: RuntimeException) {
if (e.cause !is ClassNotFoundException) {
throw e
}
}
// 2
val bindingsByName = LinkedHashMap<String, KotlinJsonAdapter.Binding<Any, Any?>>()
for (property in rawTypeKotlin.memberProperties) { // 3
val parameter = parametersByName[property.name]
var jsonAnnotation = property.findAnnotation<Json>() // 3a
...
// 3b
val jsonName = jsonAnnotation?.name?.takeUnless { it == Json.UNSET_NAME } ?: property.name
...
val adapter = moshi.adapter<Any?>(...)
bindingsByName[property.name] = KotlinJsonAdapter.Binding(
jsonName, // 3c
adapter,
property as KProperty1<Any, Any?>,
parameter,
parameter?.index ?: -1,
)
}
val bindings = ArrayList<KotlinJsonAdapter.Binding<Any, Any?>?>()
...
for (bindingByName in bindingsByName) {
bindings += bindingByName.value.copy(propertyIndex = index++)
}
return KotlinJsonAdapter(bindings, …).nullSafe() // 4
}
}
- Check for an adapter generated via annotation processor first using
Moshi.generatedAdapter(). Continue on to creating a reflective adapter if a generated one isn’t found.
- Create
bindingsByName
, a map of property names to theirBinding
s.Binding includes information about the property’s JSON name, corresponding adapter, etc.
- Iterate through all the properties for given
Type
and for each:
a) Search for the@Json
annotation on the current property.
b) If one was found, setjsonName
to the annotation’sname
field (eg.page_count
) as thejsonName
field. If not, use the property’s name (eg.pageCount
) asjsonName
.
c) UsejsonName
when creating theBinding
for the current property - Return a new
KotlinJsonAdapter
with the populated bindings
When we call toJson()
or fromJson()
, it’ll use jsonName
from the bindings as the JSON field name.
Moshi Lint Check
Moshi doesn’t include any lint checks by default, but fortunately for this case study, Slack open sourced some of their custom Moshi-related lint checks, for example “Prefer List over Array” and “Constructors in Moshi classes cannot be private”.
The code for all these Moshi-related checks live in MoshiUsageDetector. I’ll go over the implementation for the “Prefer List over Array” issue as an example of working with lint API’s UAST tree. The issue is declared as
ISSUE_ARRAY
in MoshiUsageDetector
’s companion object, and captures that array types aren’t supported by Moshi.
class MoshiUsageDetector : Detector(), SourceCodeScanner {
override fun getApplicableUastTypes() = listOf(UClass::class.java) // 1
override fun createUastHandler(context: JavaContext): UElementHandler { // 2
return object : UElementHandler() {
override fun visitClass(node: UClass) {
...
// 3
val jsonClassAnnotation = node.findAnnotation(FQCN_JSON_CLASS)
if (jsonClassAnnotation == null) return // 4
...
val primaryConstructor =
node.constructors
.asSequence()
.mapNotNull { it.getUMethod() }
.firstOrNull { it.sourcePsi is KtPrimaryConstructor }
...
for (parameter in primaryConstructor.uastParameters) { // 5
val sourcePsi = parameter.sourcePsi
if (sourcePsi is KtParameter && sourcePsi.isPropertyParameter()) {
val shouldCheckPropertyType = ...
if (shouldCheckPropertyType) {
// 5a
checkMoshiType(
context,
parameter.type,
parameter,
parameter.typeReference!!,
...
)
}
}
}
}
}
}
private fun checkMoshiType(
context: JavaContext,
psiType: PsiType,
parameter: UParameter,
typeNode: UElement,
...
) {
if (psiType is PsiPrimitiveType) return
if (psiType is PsiArrayType) { // 6
...
context.report(
ISSUE_ARRAY,
context.getLocation(typeNode),
ISSUE_ARRAY.getBriefDescription(TextFormat.TEXT),
quickfixData =
fix()
.replace()
.name("Change to $replacement")
...
.build()
)
return
}
... // 7
}
companion object {
private const val FQCN_JSON_CLASS = "com.squareup.moshi.JsonClass"
...
private val ISSUE_ARRAY =
createIssue(
"Array",
"Prefer List over Array.",
"""
Array types are not supported by Moshi, please use a List instead…
"""
.trimIndent(),
Severity.WARNING,
)
...
}
}
getApplicableUastTypes()
returnsUClass
to run detector on all classes in the source code.createUastHandler()
returns aUElementHandler
that visits each class node. The remaining steps happen invisitClass()
.- Search for a
@JsonClass
annotation on current class node. - Return early if annotation isn’t found.
- Iterate through the node’s primary constructor parameters and for each:
a) CallcheckMoshiType()
on the parameter if if it passes a few checks that I won’t go into detail here. - In
checkMoshiType()
, reportISSUE_ARRAY
if the given type is an array. checkMoshiType()
makes a few recursive calls that I didn’t include for the sake of brevity.
Based on step 4, all the checks are only performed on classes annotated with @JsonClass
. This means MoshiUsageDetector
will only work on source code that uses the annotation processing version of Moshi.
Closing Thoughts
This article ended up including a few walls of code…but less code than I expected from such a useful and widely-used library! Writing a custom annotation processor, reflection code, or lint check isn’t as daunting as we might think, and I hope these real-world examples empower you to go forth and build your own.
This article is previously published on proandroiddev.com