2

KSP for Code-Generation

 1 year ago
source link: https://medium.com/google-developer-experts/ksp-for-code-generation-dfd2073a6635
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

KSP for Code-Generation

1*WmbJXSVFug8vwdpopXgtDw.jpeg
Photo by Birmingham Museums Trust on Unsplash

As a developer, what we’re doing every day is writing code. Not just writing unconsciously, we all wish we can write flawless code that has no bugs. To do so, there are lots of tools that help us to increase the quality like unit tests and static analysis. But still, no one can guarantee that their code has no bugs at all.

Human error seems to be inevitable from time to time and that’s why we might want to leverage on code-generation tool to help us write less code. And that will not only reduce the time we have to spend but also increase the code quality. If you were familiar with Java, you probably heard annotation processing before, and if you’re a Kotlin developer, now you have the KSP.

KSP stands for Kotlin Symbol Processing. As the name suggests, it’s not just a code generation tool but a compiler plugin API that helps you to get complete source code information during compile time. You can do whatever like generate more codes, analyze the structure or even throw an additional error to break the build, and code generation is definitely a very strong use case and we’ll mainly focus on this topic today.

Code-Generation

Generally, we’ll create a separate processor module with some processors that will be triggered when compiled. Each processor can read the main application source code information to generate code and the generated code will compile together with your source code to form the final output.

Using predefined annotations is a common trick. The processor module and your application module both depend on the same annotation module, and the processor module can then filter the information from the application module by these annotations easily, but it’s totally up to your preference.

I’ll mix the detailed step-by-step introduction with a simple use case to showcase what KSP can do for us in the next section.

Example

Let’s say we like to build some factory pattern like the below:

public enum class AnimalType {
CAT,
DOG,
}

public fun AnimalFactory(key: AnimalType): Animal = when (key) {
AnimalType.CAT -> Cat()
AnimalType.DOG -> Dog()
}interface Animalclass Dog : Animalclass Cat : Animal

When we call AnimalFacoty(AnimalType.CAT), we’d like to get a Cat instance - not to worry about how to create it. This pattern is beautiful but not scalable as you can see whenever we want to add a new type, we need to modify the same function again and again. What can we do to make it better?

The most essential part is to define those animal classes and interfaces and the rest of them seem to just follow some rules behind. Let’s aim to build AnimalType and AnimalFactory automatically via KSP so we don’t have to write boilerplate code anymore.

Update project-level config

First, update your root build.gradle to enable the KSP feature like the below:

plugins {
id 'org.jetbrains.kotlin.jvm' version '1.7.10' apply false
id 'com.google.devtools.ksp' version '1.7.10-1.0.6' apply false
} buildscript {
dependencies {
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10'
}
}

KSP version can be found here: https://github.com/google/ksp/releases

Annotation Module

We’ll define annotations first in a new module for both the application and processor module to pass meta-data in between.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoElement@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoFactory

We assume AutoElement will add to the concrete class and AutoFactory is used to declare the return type. Later on, we’ll rely on this contract to filter the needed information to build the graph.

Here you can see annotation class itself also has two eligible annotations. @Target is used to limit where it can be used. Given we only want it to be label on class or interface, we choose CLASS. @Retention is used to declare the lifecycle of the annotation, and for KSP use-case, we don’t need it anymore after compile so we set it to SOURCE.

Processor Module

This is where all the magic happened! We’ll create the processor soon to generate the code automatically. Before that, we need to declare the KSP dependency like the below build.gradle:

plugins {
id 'org.jetbrains.kotlin.jvm'
}

dependencies {
implementation project(":annotation")
implementation 'com.google.devtools.ksp:symbol-processing-api:1.7.10-1.0.6'
}

A processor module might have several processors, and here is the base SymbolProcessor interface that all processors should implement.

interface SymbolProcessor {
fun process(resolver: Resolver): List<KSAnnotated> //main logic
fun finish() {}
fun onError() {}
}

The process function is the entry point to parse the code and Resolver is an object that helps us to get code symbols. We can build a helper function like the below:

fun Resolver.getSymbols(cls: KClass<*>) =
this.getSymbolsWithAnnotation(cls.qualifiedName.orEmpty())
.filterIsInstance<KSClassDeclaration>()
.filter(KSNode::validate)

KSClassDeclaration is represented as a class or interface symbol in source code, and Resolver.getSymbolsWithAnnotation is a function that returns every symbol that has the target annotation.

Let’s simplify the problem and assume we only have one factory need to be generated. We can get all the symbols annotated with our annotation like below:

val factory = resolver.getSymbols(AutoFactory::class).firstOrNull()
val elements = resolver.getSymbols(AutoElement::class).toList()

If you’re interested in full implementation, feel free to check here!

KotlinPoet

Now it’s time to write the graph into a file. We can leverage a great tool developed by Square — KotlinPoet. KotlinPoet also has great integration with KSP. Just simply add the dependency first:

dependencies {    implementation 'com.squareup:kotlinpoet:1.11.0'
implementation 'com.squareup:kotlinpoet-ksp:1.12.0'
}

Then create a function to generate the file like below:

private fun genFile(key: ClassName, list: List<ClassName>): FileSpec {
val packageName = key.packageName
val funcName = key.simpleName + "Factory"
val enumName = key.simpleName + "Type"

return FileSpec.builder(packageName, funcName)
.addType(TypeSpec.enumBuilder(enumName)
.apply {
list.forEach {
addEnumConstant(it.simpleName.uppercase())
}
}
.build())
.addFunction(FunSpec.builder(funcName)
.addParameter("key", ClassName(packageName, enumName))
.returns(key)
.beginControlFlow("return when (key)")
.apply {
list.forEach {
addStatement("${enumName}.${it.simpleName.uppercase()} -> %T()", it)
}
}
.endControlFlow()
.build())
.build()
}

It’s a bit long but quite declarative, you can tell KotlinPoet provides a more systematic way to generate Kotlin code than StringBuilder or other manual works.

After we generate the FileSpec, we can finish the whole flow in process function like below:

override fun process(resolver: Resolver): List<KSAnnotated> {
val factory = ...
if (factory != null) {
val elements = ...
genFile(
factory.toClassName(),
elements.map(KSClassDeclaration::toClassName)
).writeTo(codeGenerator, Dependencies(true))
}
return emptyList()
}

Seems great, but how does the compiler know where the processor is? The processor is created by a provider and here is the definition

fun interface SymbolProcessorProvider {fun create(env: SymbolProcessorEnvironment): SymbolProcessor
}

So we can create our processor in a provider like the below:

fun create(environment: SymbolProcessorEnvironment) =
BuilderProcessor(
environment.codeGenerator,
environment.logger
)

But is that all? The compiler still has no idea where the provider is. We need to register our provider with the full name including the package name in a file named as resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider for the compiler to link.

Application module

We’re close to what we want, now we can use the processor in the application module. The build.gradle file in the application module will look like the below:

plugins {
id 'com.google.devtools.ksp'
}

sourceSets {
main {
java {
srcDir "${buildDir.absolutePath}/generated/ksp/"
}
}
}dependencies {
implementation project(":annotation")
ksp project(":processor")
}

First, apply the KSP plugin. And declare the annotation and the processor module as dependencies. Noted that for the processor module we need to use ksp keyword. And since KSP is relatively new to IDE for now, if the auto-generated files are created but IDE doesn’t know how to find them manually add it as sourceSets section did.

@AutoFactory
interface Animal@AutoElement
class Dog : Animal@AutoElement
class Cat : Animal

Finally, add the annotation to your target class then rebuild the project and wait for KSP to generate the file for you and that’s it!!

Reference

https://kotlinlang.org/docs/ksp-overview.html

https://medium.com/@jintin/annotation-processing-in-java-3621cb05343a


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK