Создание и тестирование процессора аннотаций и кодогенератора на KSP

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

  • Зарегистрироваться на бесплатный урок

Источник: https://habr.com/ru/companies/otus/articles/728938/


Интересные статьи

Интересные статьи

Осень, ковид, «нерабочие дни». Это у разработчиков рабочее место там, где есть компьютер. А вот тестировщикам мобильных приложений на удалёнке гораздо сложнее: им, помимо прочего, нужно много девайсов...
Динамические библиотеки подключаются к программе во время выполнения. Это позволяет обновлять их реализацию и компилировать независимо от использующих программ. Такой подход открывает ряд дополнительн...
В заметке описан способ динамического добавления на страницу компонентов по JSON-описанию с помощью DynamicComponent из ASP.NET Core 6.0 (в настоящее время в статусе Preview).Динамическое создание ком...
Ноутбук осветил угол небольшой комнаты слепящим белым светом, красным загорелась подсветка на мыши. На рабочем столе горели две большие цифры: 5:59. Что ж, как всегда..Пе...
Как выдумаете, сложно ли написать на Python собственного чатбота, способного поддержать беседу? Оказалось, очень легко, если найти хороший набор данных. Причём это можно сделать даже без нейросет...