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

В завершение приглашаю всех на бесплатный вебинар в рамках которого научимся проверять готовность мобильного приложения для использования людьми с ограничениями здоровья. Также готовность автоматически проверять соответствие требованиям визуальной контрастности, адаптации верстки под увеличенный шрифт, наличие семантической разметки для вспомогательных инструментов для приложений Android (XML и Compose) и iOS (Flutter и KMM). Научимся использовать инструменты автоматических проверок и создавать собственные валидаторы для реализации сложных визуальных проверок.


ссылка на оригинал статьи https://habr.com/ru/articles/726458/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *