В разработке с использованием Kotlin (или Java) для создания классов по верхнеуровневому описанию часто используется маркировка аннотациями (например, для моделей таблиц баз данных, сетевых запросов или инъекции зависимостей) и подключение процессоров аннотаций, которые также могут генерировать код, доступный из основного проекта. Запуск процессоров аннотаций выполняется внутри gradle (для Java-проектов через annotationProcessor, для Kotlin - kapt) и встраивается как зависимость для целей сборки проекта. И конечно же, как и для любого другого кода, для процессора аннотаций необходимо иметь возможность разрабатывать тесты. В этой статье мы рассмотрим основы использования кодогенерации (с использованием kapt) и разработки тестов для созданных генераторов кода. Во второй части статьи речь пойдет о разработке процессоров на основе Kotlin Symbol Processing (KSP) и созданию тестов для них.
Начнем с классического механизма кодогенерации kapt (Kotlin Annotation Processing Tool). kapt встраивается в gradle (как плагин) или в maven (через добавление <goals><goal>kapt</goal></goals>
в описание execution
в конфигурации проекта). В общем виде конфигурация проекта с kapt может быть такой:
plugins {
kotlin("jvm") version "1.8.20"
kotlin("kapt") version "1.8.20"
application
}
group = "tech.dzolotov"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
kotlin {
jvmToolchain(17)
}
application {
mainClass.set("MainKt")
}
После подключения кодогенерации на kapt становится возможным подключать процессоры через команду kapt, например подключен кодогенерацию Autovalue для создания иммутабельных классов (фактически в Kotlin они могут быть реализованы через data‑классы и AutoValue решает ту же задачу для Java и во многом похож на Lombok, но работает иначе).
dependencies {
implementation("com.google.auto.value:auto-value-annotations:1.10.1")
kapt("com.google.auto.value:auto-value:1.10.1")
}
Kapt-процессор работает аналогично процессору аннотаций для Java, но при этом сначала исходный текст Kotlin преобразуется в Java-код и потом передается генератору. Это в целом снижает скорость кодогенерации (даже по сравнению с проектом на Java) и для решения этой проблемы и создавался альтернативный механизм KSP, о котором мы поговорим далее. В принципе кодогенерация может создавать код не только на Java, но и на Kotlin или любом другом языке, но многие используемые с kapt генераторы разработаны изначально для Java (например, Room, Hilt и т.п.).
Добавим простой класс для описания пользователей с автоматическим определением идентификатора:
import com.google.auto.value.AutoValue
@AutoValue
abstract class UserInfo {
abstract fun getId(): Int
abstract fun getLogin(): String
abstract fun getPassword(): String
companion object {
var id = 0
fun create(login:String, password:String):UserInfo {
id++
return AutoValue_UserInfo(id, login, password)
}
}
}
Для выполнения кодогенерации запустим задачу gradle (:
./gradlew kaptKotlin
Сгенерированный код в большинстве размещается в build-каталоге (build/generated/source/kapt/main
) и представляет из себя исходный текст на Java (кроме создания get-методов, также переопределяет equals
, hashCode
и toString
). Отдельно импортировать его нет необходимости, поскольку размещается в том же пакете, где находится исходный аннотированный класс. Созданный класс будет помечен аннотацией @Generated
с указанием класса процессора, который создал этот класс:
@Generated("com.google.auto.value.processor.AutoValueProcessor")
final class AutoValue_UserInfo extends UserInfo {
//определения полей
//get-функции
//toString, equals, hashCode
}
Теперь сделаем пример кода для использования сгенерированного класса:
fun main() {
val users = mutableListOf<UserInfo>()
users.add(UserInfo.create("user1", "password1"))
users.add(UserInfo.create("user2", "password2"))
println(users)
}
Результатом будет строковое представление списка:
[UserInfo{id=1, login=user1, password=password1}, UserInfo{id=2, login=user2, password=password2}]
Теперь разберемся с созданием собственного кодогенератора. За основу будем использовать шаблон из трех модулей (модуль с приложением, который будет использовать процессор аннотаций, модуль с аннотацией и модуль процессора). Определим аннотацию для использования в кодогенераторе:
@Retention(AnnotationRetention.SOURCE)
annotation class SampleAnnotation
И реализуем сам процессор, который будет определяться в методе process в классе-расширения от javax.annotation.processing.AbstractProcessor
:
@AutoService(Processor::class)
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@SupportedAnnotationTypes("kaptexample.annotation.SampleAnnotation")
class SampleAnnotationProcessor : AbstractProcessor() {
override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(SampleAnnotation::class.java).forEach {
processingEnv.messager.printMessage(Diagnostic.Kind.WARNING, "${it.simpleName} is processed.")
}
return true
}
}
Здесь используется библиотека auto-service для автоматической регистрации класса, как процессора кодогенерации (подключается в build.gradle.kts):
dependencies {
//...
implementation("com.google.auto.service:auto-service:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1")
}
Метод process будет вызываться при обнаружении аннотаций, перечисленных в @SupportedAnnotationTypes
и может иметь доступ к определениям исходного кода через реализацию RoundEnvironment
(получает в roundEnv
). Также внутри AbstractProcessor
есть доступ к processingEnv
, через который можно получать аргументы для kapt (через options), создавать файлы (через поле filer
) и выводить сообщения в IDE и консоль gradle (для сообщения указывается тип из перечисления Diagnostics.Kind: ERROR
- при ошибке, WARNING
отображается как информационное сообщение, OTHER
- для любого другого типа сообщения, не прерывающего выполнение кодогенерации). Через roundEnv можно получить информацию об аннотированных определениях (может быть перед пакетом, интерфейсом/классом, функцией/методом, или определением переменной), каждое определение представлено реализацией интерфейса Element и позволяет получить метаинформацию об определении:
simpleName
- название (без пакета)kind
- тип элемента (определены в ElementKind)getAnnotation(type)
- получение объекта аннотации (вместе с аргументами, если определены)modifiers
- модификаторы определения (например, private или static)enclosingElement
- дает доступ к элементу верхнего уровня (например, определению класса для аннотированного метода)enclosedElements
- возвращает список вложенных элементов (например, определений свойств и методов для аннотированного класса)
Определим простой класс (аннотация @JvmField здесь используется для исключения автоматической генерации get-методов).
@SampleAnnotation
class SampleClass {
@JvmField
val x: Int = 0
@JvmField
val y: Int = 0
}
и создадим процессор, который будет обнаруживать и отображать все найденные свойства класса:
@AutoService(Processor::class)
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@SupportedAnnotationTypes("kaptexample.annotation.SampleAnnotation")
class SampleAnnotationProcessor : AbstractProcessor() {
override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(SampleAnnotation::class.java).forEach { outer ->
outer.enclosedElements.forEach {inner ->
if (inner.kind== ElementKind.FIELD) {
processingEnv.messager.printMessage(
Diagnostic.Kind.WARNING,
"Field ${inner.simpleName}, modifier: ${inner.modifiers}"
)
}
}
}
return true
}
}
Теперь добавим генерацию кода, для этого получим объект Filer и через него мы можем создать байт-код (createClassFile
), ресурс (createResource
) или сгенерировать новый файл с исходными текстами (createSourceFile
). Далее к созданному файлу можно получить доступ через writer
и записать туда сгенерированный исходный текст (после завершения работы, созданный файл будет проверен на корректность синтаксиса). Например, мы хотим добавить поле id с автоматическим увеличением, для этого сначала подготовим шаблон исходного кода (на Java, но можно и на Kotlin):
public class GeneratedSampleClass {
GeneratedSampleClass(<список полей>) {
//заполнение полей по значениям из конструктора
}
static int id = 0;
int getId() {
id++;
return id;
}
//здесь подставляем определение полей из исходного класса
}
используя шаблон и информацию из обнаруженных объектов (название пакета извлекается из enclosingElement для аннотированного класса, название и сигнатуры определения полей из enclosedElements от класса)
@AutoService(Processor::class)
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@SupportedAnnotationTypes("kaptexample.annotation.SampleAnnotation")
class SampleAnnotationProcessor : AbstractProcessor() {
override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(SampleAnnotation::class.java).forEach { outer ->
val fields = mutableListOf<Element>()
var pkgName:String? = null
val pkg = outer.enclosingElement
if (pkg.kind==ElementKind.PACKAGE && pkg.toString()!="unnamed package") {
pkgName = pkg.toString()
}
processingEnv.messager.printMessage(Diagnostic.Kind.WARNING, "Package is $pkgName")
outer.enclosedElements.forEach { inner ->
if (inner.kind == ElementKind.FIELD) {
fields.add(inner)
processingEnv.messager.printMessage(
Diagnostic.Kind.WARNING,
"Field ${inner.simpleName}, modifier: ${inner.modifiers}"
)
}
}
processingEnv.messager.printMessage(Diagnostic.Kind.WARNING, processingEnv.options.toString())
val className = "Generated${outer.simpleName}"
val classFile = processingEnv.filer.createSourceFile(className)
classFile.openWriter().use {
val varFields = fields.map { "${it.asType()} ${it.simpleName}" }
var initFields = fields.map { "this.${it.simpleName} = ${it.simpleName};" }
val definitions = mutableListOf<String>()
fields.map { field ->
//добавляем модификатор для доступа к полю (и исключаем дублирование)
val accessModifiers = listOf("public", "private", "protected")
definitions.add("public ${field.modifiers.filter { !accessModifiers.contains(it.toString())}.joinToString(" ")} ${field.asType()} ${field.simpleName};")
}
it.write(
"""
${if (pkgName!=null) "package $pkgName;" else ""}
public class $className {
public $className(${varFields.joinToString(",")}) {
${initFields.joinToString("\n")}
}
static int id = 0;
public int getId() {
id++;
return id;
}
//здесь подставляем определение полей из исходного класса
${definitions.joinToString("\n")}
}
""".trimIndent()
)
}
}
return true
}
}
Здесь дополнительно заменяются модификаторы доступа на public (чтобы тест в дальнейшем мог прочитать поля, альтернативно можно добавить генерацию get-методов). Также важно, чтобы сам класс и конструктор были public, иначе возникнет ошибка на этапе создания объекта через рефлексию. Аналогично можно сгенерировать любые структуры данных и фрагменты кода.
Для генерации кода также можно использовать библиотеки, например JavaPoet дает возможность представлять код в виде дерева объектов и генерировать форматированный код на языке Java.
Теперь перейдем к тестированию разработанного кодогенератора. Для этого подключим библиотеку kotlin-compile-testing и добавим наш проект для
dependencies {
testImplementation("com.github.tschuchortdev:kotlin-compile-testing:1.5.0")
}
Библиотека позволяет выполнить программную компиляцию заданного фрагмента кода на Java или Kotlin (при этом можно добавлять процессоры аннотаций, в том числе KSP). Важно добавить в компиляцию файл с определением аннотации, поскольку при сборке библиотека не знает о существовании gradle-проектов и работает непосредственно с фрагментом кода.
Начнем с простой проверки небольшого класса без использования кодогенерации:
@Test
fun testSimpleCode() {
val result = KotlinCompilation().apply {
sources = listOf(SourceFile.kotlin("MySimpleTest.kt", """
class Calculator {
fun sum(a:Int, b:Int) = a+b
}
""".trimIndent()))
}.compile()
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode)
}
Полученный объект result содержит информацию о сгенерированных файлах в поле generatedFiles
(в данной случае только исходный текст, байт-код и META-INF), также можно узнать результат компиляции (exitCode
), получить список файлов, созданных процессорами аннотаций (sourcesGeneratedByAnnotationProcessor
), а также получить доступ к загрузчику классов для рефлексии по созданному классу и создания его экземпляров через конструкторы и newInstance
. Добавим тесты сигнатуры метода sum и проверим функциональность созданного класса:
fun testSimpleCode() {
val result = KotlinCompilation().apply {
sources = listOf(SourceFile.kotlin("MySimpleTest.kt", """
class Calculator {
fun sum(a:Int, b:Int) = a+b
}
""".trimIndent()))
}.compile()
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode)
val calculatorDescription = result.classLoader.loadClass("Calculator")
assertDoesNotThrow("Sum method is defined") { calculatorDescription.getDeclaredMethod("sum", Int::class.java, Int::class.java) }
val calculatorInstance = calculatorDescription.constructors.first().newInstance()
assertEquals(8, calculatorDescription.getDeclaredMethod("sum", Int::class.java, Int::class.java).invoke(calculatorInstance, 3, 5))
}
Тут важно помнить, что создание объектов, выполнение методов и доступ к свойств учитывает модификаторы доступности и, поскольку код теста сейчас находится в другом пакете, то нужно следить чтобы соответствующие модификаторы были public.
Теперь перейдем к тестированию нашего кодогенератора. Для добавления процессоров аннотаций в объекте класса KotlinCompilation (или JavaCompilation) есть список в свойстве annotationProcessors:
@Test
fun testCodegen() {
val result = KotlinCompilation().apply {
annotationProcessors = listOf(SampleAnnotationProcessor())
val source = SourceFile.kotlin("MyTestClass.kt", """
import kaptexample.annotation.SampleAnnotation
@SampleAnnotation
class MyTestClass {
val x:Int = 1
val y:Double = 0.0
}
""".trimIndent())
//подключаем аннотацию
val ann = SourceFile.fromPath(File("../kapt-example-core/src/main/kotlin/kaptexample/annotation/Sample.kt"))
this.sources = listOf(source, ann)
}.compile()
//проверим успешность компиляции
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode)
}
Здесь исходный текст с определением аннотации (в kapt-example-core
) компилируется вместе с нашим фрагментом, чтобы корректно сработал import
и применение аннотации. Дальнейший тест выполняется аналогично предыдущему примеру:
fun testCodegen() {
//...компиляция кода (из предыдущего примера)
//--------------------------------------------
//можно проверить отображенные сообщения через result.messages
//используем рефлексию для проверки результата
val rc = result.classLoader.loadClass("GeneratedMyTestClass")
assertDoesNotThrow("getId is defined") { rc.getDeclaredMethod("getId") }
assertEquals(3, rc.declaredFields.size, "Valid fields")
assertContentEquals(rc.declaredFields.map { it.name }.sorted(), listOf("x", "y", "id").sorted())
assertEquals(1, rc.declaredConstructors.size)
assertEquals(2, rc.declaredConstructors.first().parameters.size)
//создаем экземпляр объекта через конструктор
val instance = rc.constructors.first().newInstance(2, 3.0)
//здесь мы не имеем доступа к определению объекта, поэтому вызываем через invoke от метода
assertEquals(1, rc.getMethod("getId").invoke(instance))
assertEquals(2, rc.getField("x").get(instance))
assertEquals(3.0, rc.getField("y").get(instance))
//проверим создание второго экземпляра и корректное заполнение id
val instance2 = rc.constructors.first().newInstance(5, 8.0)
assertEquals(2, rc.getMethod("getId").invoke(instance2))
}
Исходные тексты проекта можно найти в репозитории https://github.com/dzolotov/kapt-template (ветка codegen-test).
Мы рассмотрели основные вопросы по разработке процессоров аннотаций с возможностью кодогенерации для Java или Kotlin-проектов и способы тестирования корректности их работы. Во второй части статьи мы изучим новый подход к генерации кода на Kotlin с использованием Kotlin Symbol Processing (KSP) и, конечно, научимся разрабатывать тесты для KSP-процессоров.
В завершение приглашаю всех на бесплатный вебинар в рамках которого научимся проверять готовность мобильного приложения для использования людьми с ограничениями здоровья. Также готовность автоматически проверять соответствие требованиям визуальной контрастности, адаптации верстки под увеличенный шрифт, наличие семантической разметки для вспомогательных инструментов для приложений Android (XML и Compose) и iOS (Flutter и KMM). Научимся использовать инструменты автоматических проверок и создавать собственные валидаторы для реализации сложных визуальных проверок.