Создание и тестирование процессоров аннотаций (с кодогенерацией) для Kotlin
В разработке с использованием 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-процессоров.