В первой части статьи мы рассмотрели подход к обработке аннотаций (и возможной генерации дополнительных исходных текстов), который используется в мире Java и долгое время применялся также для Kotlin (при этом Kotlin-код предварительно преобразовывался в Java-классы, что занимало дополнительное время для компиляции). С 2021 года стал доступен новый плагин для gradle, который основан на непосредственном анализе исходных текстов Kotlin и позволяет генерировать код без необходимости создания текстового файла. В этой статье мы разберемся как создать процессор аннотаций для KSP и как его можно протестировать?
Первое, что важно отметить, что версия KSP-плагина зависит от версии используемого компилятора Kotlin, поскольку учитывает грамматику языка. Номер версии Kotlin-компилятора указывается также в версии плагина. Например, для поддержки проекта на Kotlin 1.8.20 можно установить плагин с версией 1.8.20-1.0.10. Общий шаблон конфигурации gradle может выглядеть так (для проекта, который будет использовать процессор аннотаций):
plugins {
kotlin("jvm") version "1.8.20"
application
id("com.google.devtools.ksp") version "1.8.20-1.0.10"
}
group = "tech.dzolotov"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
ksp("...") //идентификатор jar (или модуля) для процессора
}
tasks.test {
useJUnitPlatform()
}
application {
mainClass.set("MainKt")
}
Создадим проект с процессором аннотаций, для этого подключим зависимость symbol-processing-api
:
implementation("com.google.devtools.ksp:symbol-processing-api:1.8.20-1.0.10")
Обработкой исходных текстов будет заниматься реализация интерфейса SymbolProcessor
(основной метод - process), а созданием экземпляров процессора - реализация SymbolProcessorProvider
:
package tech.dzolotov
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider
import com.google.devtools.ksp.symbol.KSAnnotated
annotation class SampleAnnotation
class SampleAnnotationProcessor(val environment: SymbolProcessorEnvironment) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
return resolver.getSymbolsWithAnnotation("tech.dzolotov.SampleAnnotation", inDepth = false).toList()
}
override fun finish() {
environment.logger.info("Annotation processor is finished")
}
}
class SampleAnnotationProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = SampleAnnotationProcessor(environment)
}
SymbolProcessorEnvironment содержит информацию о среде выполнения (можно получить kotlinVersion
, compilerVersion
, список платформ проекта platforms
), а также обратиться к генератору кода (.codeGenerator
) и к выводу логов (.logger
). Также процессор может принимать конфигурацию (.options), которая определяется в блоке ksp
в build.gradle
через команды arg("name", "value")
.
Обработка кода начинается с поиска символов с подходящей аннотацией, для этого можно использовать метод getSymbolsWithAnnotation
из Resolver
(с возможностью дальнейшего отбора, например через filterIsInstance для проверки типа обнаруженных объектов), либо получить определения классов, методов или свойств (например через resolver.getDeclarationsFromPackage
или с использованием итераторов с последовательным обходом файлов через resolver.getAllFiles()
с поиском по определениям). Для обнаруженных объектов можно использовать как паттерн visitor
(аналогично Java Annotation Processor
), так и непосредственно работать с значениями через итератор.
Кодогенерация будет создавать файлы в каталогах:
build/generated/ksp/main/kotlin/
- исходные тексты (создаются через environment.codeGenerator)build/generated/ksp/main/resources/
- дополнительные ресурсы (могут быть созданы через environment)
Для правильной индексации нужно добавить эти каталоги к списку каталогов с исходными текстами:
kotlin {
sourceSets.main {
kotlin.srcDir("build/generated/ksp/main/kotlin")
}
sourceSets.test {
kotlin.srcDir("build/generated/ksp/test/kotlin")
}
}
Для запуска KSP
будем использовать задачу kspKotlin
в gradle, при этом чтобы исключить оптимизации отслеживания изменений в зависимостях между задачами, сразу отключим кэш сборки и добавим отображение сообщений с уровнем протоколирования info:
./gradlew kspKotlin --no-build-cache --info
Создадим простой вариант генерации toString
для аннотированного класса. Предположим, что аннотации будут применяться только для классов (отметим это в Target
), также выделим отдельными модулями определение аннотации (будет использоваться как в процессоре, так и в исходном коде) и непосредственно процессор KSP. Определим аннотацию (в нашем случае мы не используем аргументы, но они также могут быть добавлены и извлечены в дальнейшем через итератор annotations от найденного определения класса, функции или поля).
package tech.dzolotov.sampleksp.annotation
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class SampleAnnotation
Следующим действием мы хотим выделить все классы с соответствующими аннотациями, при этом избежать возможных ошибок при повторном применении кодогенератора (поскольку в пределах одного запуска Gradle-задачи kspKotlin
нельзя дважды создавать один и тот же файл). Здесь мы используем функцию-расширение toClassName()
, которая устанавливается вместе со вспомогательной библиотекой для преобразования типов KSP
в строковые названия (используется при генерации кода с использованием KotlinPoet
). Подключим зависимости в модуль процессора:
implementation("com.squareup:kotlinpoet:1.13.0")
implementation("com.squareup:kotlinpoet-ksp:1.13.0")
и реализуем обработку найденных классов с подходящей аннотацией (с исключением дублирования):
class SampleAnnotationProcessor(val environment: SymbolProcessorEnvironment) : SymbolProcessor {
val processed = mutableListOf<ClassName>()
override fun process(resolver: Resolver): List<KSAnnotated> {
val declarations = resolver.getSymbolsWithAnnotation(SampleAnnotation::class.qualifiedName!!, inDepth = false)
.filterIsInstance<KSClassDeclaration>()
declarations.forEach { declaration ->
val classSpec = declaration.asType(listOf()).toClassName()
//избегаем двойной обработки аннотаций
if (!processed.contains(classSpec)) {
processed.add(classSpec)
//здесь мы будем генерировать код
}
}
return declarations.toList()
}
}
KSP
процессор не обязательно должен выполнять генерацию кода, например он может использоваться для инициализации базы данных при запуске тестового окружения, выполнять анализ кода (например, проверять правила именования) и т.д. В нашем случае мы бы хотели создавать сгенерированный класс, название которого создается из исходного класса с префиксом Annotated
(при этом сохраняются названия и типы полей из основного конструктора).
Сначала определим название пакета и класса, они понадобятся нам для создания нового файла через генерацию кода (в том числе, для определения названия файла):
//получаем название пакета и класса
val packageName = classSpec.packageName
val className = classSpec.simpleName
val annotatedClassName = "Annotated$className"
val codeFile = environment.codeGenerator.createNewFile(
dependencies = Dependencies(false, declaration.containingFile!!),
packageName = packageName,
fileName = annotatedClassName,
extensionName = "kt"
)
val writer = codeFile.bufferedWriter()
writer.append("//Generated file")
writer.flush()
writer.close()
Теперь перейдем непосредственно к созданию кода, для этого будем использовать KotlinPoet
. Эта библиотека является развитием проекта JavaPoet и позволяет создавать с использованием builder-ов структурные единицы кода (классы, конструкторы, методы, поля и т.д.). Начнем с создания пустого класса с соответствующим названием:
val generatedClass =
TypeSpec.classBuilder(annotatedClassName).build()
val file = FileSpec.builder(packageName, "$annotatedClassName.kt").addType(
generatedClass
).build()
file.writeTo(writer)
//не забываем сохранить буфер в файл
writer.flush()
writer.close()
В основном проекте добавим аннотированный класс:
package tech.dzolotov.sampleksp
import tech.dzolotov.sampleksp.annotation.*
@SampleAnnotation
class UserData(val login:String, val fullname:String, val id:Int)
и подключим процессор как ksp:
ksp(project(":processor"))
После запуска gradle в каталоге build/generated/ksp/main/kotlin/<package>/AnnotatedUserName.kt
с указанием названия пакета и пустым классом с названием AnnotatedUserData
. Теперь добавим генерацию конструктора и определения полей (при генерации кода они будут оптимизированы и преобразованы в конструктор с val
-полями).
//извлекаем типы и названия полей исходного класса
val properties = declaration.getAllProperties()
//и создаем список свойств и основной конструктор
val poetProperties = mutableListOf<PropertySpec>()
val constructorParams = mutableListOf<ParameterSpec>()
properties.forEach {
val name = it.simpleName.getShortName()
poetProperties.add(
PropertySpec.builder(name, it.type.resolve().toClassName()).initializer(name).build()
)
constructorParams.add(ParameterSpec(name, it.type.resolve().toClassName()))
}
val annotatedClassName = "Annotated$className"
val generatedClass =
TypeSpec.classBuilder(annotatedClassName).addProperties(poetProperties).primaryConstructor(
FunSpec.constructorBuilder().addParameters(constructorParams).build()
).build()
//и далее как раньше
После запуска кодогенерации файл AnnotatedUserData.kt
будет содержать следующее определение:
package tech.dzolotov.sampleksp
public class AnnotatedUserData(
public val login:String,
public val fullname: String,
public val id:Int
)
Теперь добавим реализацию метода toString()
, который будет отображать текстовое представление всех полей объекта, для этого будем использовать CodeBlock, который может быть собран из текстового фрагмента или последовательности определений (например, addStatement
). Важно, что при определении метода toString
мы также должны добавить модификатор override
, поскольку он переопределяет реализацию по умолчанию в базовых классах.
//извлекаем типы и названия полей исходного класса
val properties = declaration.getAllProperties()
//и создаем список свойств и основной конструктор
val poetProperties = mutableListOf<PropertySpec>()
val constructorParams = mutableListOf<ParameterSpec>()
val resultTemplate = mutableListOf<String>()
properties.forEach {
val name = it.simpleName.getShortName()
poetProperties.add(
PropertySpec.builder(name, it.type.resolve().toClassName()).initializer(name).build()
)
constructorParams.add(ParameterSpec(name, it.type.resolve().toClassName()))
resultTemplate.add("$name=\$$name")
}
val annotatedClassName = "Annotated$className"
//теперь генерируем функцию toString и наполняем ее кодом
val toStringCode =
CodeBlock.builder().addStatement("""return "${resultTemplate.joinToString(", ")}"""").indent()
.build()
val toStringFunc =
FunSpec.builder("toString").returns(STRING).addModifiers(KModifier.OVERRIDE).addCode(toStringCode)
.build()
val generatedClass =
TypeSpec.classBuilder(annotatedClassName).addProperties(poetProperties).primaryConstructor(
FunSpec.constructorBuilder().addParameters(constructorParams).build()
).addFunction(toStringFunc).build()
Обратите внимание, что несмотря на использование в коде return, после кодогенерации он будет заменен на expression body (знак равно с выражением после заголовка метода). После всех действий сгенерированный класс будет выглядеть следующим образом:
package tech.dzolotov.sampleksp
import kotlin.Int
import kotlin.String
public class AnnotatedUserData(
public val login: String,
public val fullname: String,
public val id: Int,
) {
public override fun toString(): String = "login=$login, fullname=$fullname, id=$id"
}
Также хотелось бы отметить, что наряду с использованием итераторов при создании процессора можно использовать паттерн Visitor
(аналогично тому, как было сделано в kapt
) и это может быть полезно при миграции существующих Java Annotation Processors
в KSP
.
С тестированием на данный момент механизма, аналогичному рассмотренному в первой части статьи для kapt
, сейчас еще нет, но можно использовать возможность применения KSP
процессора в тестовом окружении (kspTest
) и проверки возможности компиляции сгенерированного кода и проверки созданных классов с использованием обычных unit-тестов.
Исходные тексты проекта размещены в GitHub-репозитории.
В завершение хочу пригласить вас на бесплатный урок, где разберем возможности, которые предоставляет Kotlin в части создания DSL и использование их для тестирования.