В разработке с использованием 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). Научимся использовать инструменты автоматических проверок и создавать собственные валидаторы для реализации сложных визуальных проверок.

Комментарии (0)