Практическое применение KSP

Известно, что генерация кода в процессе сборки отнимает много времени. Один из ярких примеров  —  Dagger 2, использующий kapt для генерации огромного количества файлов, скрытых от глаз разработчика. Таким образом, вы выбираете одно из двух: писать вручную много шаблонного кода либо отдохнуть во время длительной сборки. В целом, это довольно хороший метод, если вы хотите сохранить код более структурным, чистым и безопасным.

Раньше нам приходилось использовать Kotlin Annotation Processing Tool (kapt), служащий мостом между Kotlin и процессором аннотаций Java (JSR 269) и предназначенный для генерации Java-заглушек.

Теперь у нас есть новый плагин под названием Kotlin Symbol Processing (KSP). Он предоставляет API, позволяющий разрабатывать легкие и быстрые плагины для компилятора. Утверждается, что KSP может работать в 2 раза быстрее, чем kapt.

Зачем нужен KSP?

Если нужно постоянно копировать и вставлять один и тот же код, можно упростить этот процесс с помощью аннотаций. В примере, приведенном в этой статье, будет продемонстрирована типичная ситуация, в которой можно применить KSP.


Создание процессора конфигурации сети

Проект Android обычно содержит несколько стендов разработки (develop, test, prod и т. д.) и множество URL-адресов микросервисов. В этом случае мы либо храним в одном файле все эти URL, связанные с различными стендами, либо просто копируем и вставляем один и тот же файл (например, файл конфигурации сети) в разные модули системы Gradle. В результате в первом случае сетевой модуль хранит информацию обо всех остальных модулях, а во втором  —  мы тратим время на рутинную работу и увеличиваем кодовую базу проекта. Короче говоря, ни один из этих архитектурных подходов не является оправданным.

Начнем с написания простой программы с использованием KSP, поскольку практика  —  лучший способ обучения. Чтобы следовать руководству, клонируйте этот репозиторий.

Зависимости

Прежде всего определимся с необходимыми зависимостями KSP. В основной gradle-файл сборки добавим следующее:

plugins {
kotlin("jvm")
}

repositories {
mavenCentral()
}

В gradle-файл настроек добавим следующее:

pluginManagement {
val kotlinVersion: String by settings
val kspVersion: String by settings

plugins {
id("com.google.devtools.ksp") version kspVersion
kotlin("jvm") version kotlinVersion
}

repositories {
gradlePluginPortal()
mavenCentral()
}
}

В gradle-файл свойств добавим следующее:

kotlinVersion=1.8.0
kspVersion=1.8.0-1.0.9

Создание модуля для процессора

После подготовки gradle-файлов создадим новый gradle-модуль под названием processor.

Здесь мы будем писать процессор аннотаций. Для начала нужно добавить зависимости в gradle-файл сборки:

val kspVersion: String by project

plugins {
kotlin("jvm")
}

repositories {
mavenCentral()
}

dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:$kspVersion")
}

Для процессора нужно создать несколько аннотаций. Этим и займемся на следующем этапе.

Чтобы определить весь файл конфигурации сети, создадим такую аннотацию с целевым уровнем Class:

@Target(AnnotationTarget.CLASS)
annotation class EnvironmentConfig

Далее определим аннотацию с целевым уровнем Property для этапов разработки и URL:

@Target(AnnotationTarget.PROPERTY)
annotation class Url(
val environment: Environment,
val name: String
)

enum class Environment(val env: String) {
PROD("prod"),
TEST("test"),
DEV("dev")
}

В итоге файл конфигурации сети будет выглядеть так (вернемся к нему позже):

@EnvironmentConfig
interface SampleConfig {
@Url(environment = Environment.PROD, name = "https://www.prod.com")
val prod: String

@Url(environment = Environment.TEST, name = "https://www.test.ru")
val test: String
}

Создание процессора

Первым шагом является реализация интерфейса SymbolProcessor. Он обладает тремя методами.

  • process  —  точка входа для KSP для запуска обработки. Этот метод должен возвращать только отложенные символы, которые не могут быть обработаны в данном раунде (о раундах поговорим в конце статьи).
  • finish  —  вызывается KSP для завершения обработки компиляции.
  • onError  —  вызывается KSP для обработки ошибок после раунда обработки.

Для нашей реализации также необходимы две зависимости: CodeGenerator и KSPLogger. Первая отвечает за создание и управление файлами, и лучше не использовать другой API, иначе файлы не будут участвовать в инкрементальной обработке. Вторая, как следует из названия, предназначена для логирования предупреждений и ошибок KSP.

Ниже можно увидеть, как это работает:

class NetworkConfigProcessor(
val codeGenerator: CodeGenerator,
val logger: KSPLogger
) : SymbolProcessor {

override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(checkNotNull(EnvironmentConfig::class.qualifiedName))
symbols
.filter {
val classDeclaration = it as? KSClassDeclaration
if (classDeclaration?.classKind != ClassKind.INTERFACE) {
logger.error("Use kotlin interface with @EnvironmentConfig!", classDeclaration)
}
classDeclaration?.classKind == ClassKind.INTERFACE
}
.forEach { it.accept(NetworkConfigVisitor(), Unit) }

return emptyList()
}

Рассмотрим подробнее работу этой функции.

Для получения всех символов под аннотацией будем использовать Resolver.getSymbolsWithAnnotation. При этом необходимо указать полное уточненное имя аннотации. Этот метод возвращает последовательность с элементами, аннотированными EnvironmentConfig.

Затем отфильтруем только интерфейсы, поскольку с ними будет работать процессор. Поэтому следует использовать логгер, чтобы быть в курсе, если что-то пойдет не так. После этого для каждого отфильтрованного экземпляра вызываем метод accept, используя в качестве первого аргумента Visitor-класс NetworkConfigVisitor, который будет написан ниже.

Затем возвращаем пустой список. Почему пустой? Обсудим это в разделе “Валидация”.

Существует несколько предопределенных Visitor-классов. Наш выбор  —  KSVisitorVoid, потому что он прост и не содержит никакой логики. Будем использовать только visitClassDeclaration, visitPropertyDeclaration и visitAnnotation. Первый является точкой входа в Visitor-класс, второй служит для свойств, а последний предназначен для обработки аннотаций и записи в файл Pair для интерфейса Map, содержащего среды. Ниже показан этот класс.

inner class NetworkConfigVisitor : KSVisitorVoid() {
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {

}

override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) {

}
override fun visitAnnotation(annotation: KSAnnotation, data: Unit) {

}
}

Теперь создадим файл, который заполним логикой. Это окончательный сгенерированный файл, который можно будет использовать в проекте.

inner class NetworkConfigVisitor : KSVisitorVoid() {
private var file by Delegates.notNull<OutputStream>()

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val packageName = classDeclaration.containingFile!!.packageName.asString()
val className = "${classDeclaration.simpleName.asString()}Url"

file = codeGenerator.createNewFile(
Dependencies(
true,
classDeclaration.containingFile!!
),
packageName,
className
)
}
}

Пакет файла совпадает с аннотированным интерфейсом, а имя файла представляет собой конкатенацию имени интерфейса и строки Url. Теперь можно заполнить этот файл кодом.

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val properties = classDeclaration.getAllProperties()
val packageName = classDeclaration.containingFile!!.packageName.asString()
val className = "${classDeclaration.simpleName.asString()}Url"

file = codeGenerator.createNewFile(
Dependencies(
true,
classDeclaration.containingFile!!
),
packageName,
className
)

file.appendText("package $packageName\n\n")
file.appendText("import com.strukov.processor.Environment\n\n")
file.appendText("public class $className(private val environmentSettings: EnvironmentSettings) {\n")
file.appendText("\tinternal val url get() = environments[environmentSettings.stage].orEmpty()\n")
file.appendText("\tprivate val environments = mapOf<String, String>(")

val iterator = properties.iterator()

while (iterator.hasNext()) {
visitPropertyDeclaration(iterator.next(), Unit)
if (iterator.hasNext()) file.appendText(",")
}

file.appendText("\n\t)\n")

file.appendText("}")
file.close()
}

private fun OutputStream.appendText(str: String) {
this.write(str.toByteArray())
}

Сначала создадим функцию расширения, чтобы упростить запись байтов в файл. Затем добавим пакет, импорт, класс и определение свойств. Далее займемся местом, в котором нужно вызвать visitPropertyDeclaration в цикле. Наконец, добавим круглую скобку и фигурную скобку и, конечно, не забудем закрыть файл.

Обработка свойств

override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) {
property.annotations.find { it.shortName.asString() == Url::class.java.simpleName }
?.accept(this, Unit)
?: logger.error("Use @Url for property!", property)
}

Прежде всего найдем Url аннотации и вызовем метод accept. В противном случае, в случае его отсутствия, выведем ошибку, чтобы обозначить проблему с определением свойства.

Обработка аннотаций

override fun visitAnnotation(annotation: KSAnnotation, data: Unit) {
val environment = annotation.arguments.find { it.name?.asString() == "environment" }
?.value.toString().substringAfter("processor.")
val name = annotation.arguments.find { it.name?.asString() == "name" }

file.appendText("\n\t\t${environment}.env to \"${name!!.value as String}\"")
}

Найдем оба необходимых аргумента environment и name и запишем их значения в файл. Это последний шаг по созданию файла конфигурации сети.

Создание класса Provider

class NetworkConfigProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return NetworkConfigProcessor(environment.codeGenerator, environment.logger)
}
}

Теперь необходимо сообщить KSP о нашем процессоре. Для этого нужно создать новый класс Provider, который реализует SymbolProcessorProvider. В нем есть только один метод, который нужно переопределить и который возвращает SymbolsProcessor, и нужно просто создать экземпляр NetworkConfigProcessor, чтобы вернуть его.

После этого зарегистрируем его. Создадим каталог resources.META-INF.services. Затем создадим файл с именем com.google.devtools.ksp.processing.SymbolProcessorProvider и добавим имя процессора. В нашем случае оно выглядит следующим образом:

com.strukov.processor.NetworkConfigProcessorProvider

Обратите внимание на то, что необходимо добавить полностью определенное имя провайдера.

Вот и все, что нужно знать о генерации в общих чертах. Переходим к созданию модуля для тестирования!

Создание функционального модуля

Зависимости

plugins {
id("com.google.devtools.ksp")
kotlin("jvm")
}

repositories {
mavenCentral()
}

dependencies {
implementation(project(":processor"))
ksp(project(":processor"))
}

kotlin {
sourceSets.main {
kotlin.srcDir("build/generated/ksp/main/kotlin")
}
}

Прежде всего добавим процессор в раздел зависимостей. Если вы используете KSP версии ниже 1.8.0–1.0.9, добавьте sourceSets, чтобы IDE знала о сгенерированных файлах. В противном случае добавлять его не нужно.

Создание шаблона

class EnvironmentSettings {
val stage get() = Environment.PROD.env

enum class Environment(val env: String) {
PROD("prod"),
TEST("test"),
DEV("dev")
}
}

Создадим класс-заглушку, который возвращает текущую среду. Допустим, он всегда должен возвращать производственную среду. Затем опишем конфигурационный файл с помощью аннотаций:

@EnvironmentConfig
interface SampleConfig {
@Url(environment = Environment.PROD, name = "https://www.prod.com")
val prod: String

@Url(environment = Environment.TEST, name = "https://www.test.com")
val test: String
}

Сгенерированный файл:

internal class SampleConfigUrl(private val environmentSettings: EnvironmentSettings) {
internal val url get() = environments[environmentSettings.stage].orEmpty()
private val environments = mapOf<String, String>(
Environment.PROD.env to "https://www.prod.com",
Environment.TEST.env to "https://www.test.com"
)
}

Теперь создадим функцию main и увидим сгенерированный класс в работе.

fun main() {
println(SampleConfigUrl(EnvironmentSettings()).url)
}

Вот и все  —  выводится https://www.prod.com. В реальном случае применения можно передать это в базовый URL в Retrofit.


Валидация

Существует предопределенный класс KSValidateVisitor, который отвечает за обработку валидации узла. Он нужен для того, чтобы узнать, валиден ли аннотированный элемент для продолжения работы. И если он не валиден, нужно добавить его в список и вернуть этот список с отложенными узлами в process-метод SymbolProcess, чтобы KSP попытался обработать его в следующем раунде.

В каких случаях стоит использовать эту операцию?

Следует понимать, что данная операция снижает производительность. Поэтому необходимо понимать, как она работает “под капотом”, чтобы решить, когда ее использовать.

Основная идея заключается в том, что каждый узел аннотированного элемента связан с проверкой на доступность в глубину. Например, если у вас есть аннотированный класс с большим количеством свойств и функций со значительным количеством аргументов, каждая конечная точка этих элементов проверяется KSValidateVisitor.

Таким образом, если какой-либо из узлов не доступен, он относится к KSErorrType, и процесс валидации останавливается. Например, если аннотированный элемент имеет какой-либо узел, который должен быть сгенерирован процессором ранее, этот узел недостижим, поэтому метод validate возвращает false, и вам следует пропустить его в текущем раунде.

Пример с валидацией

В NetworkConfigProcessor мы не использовали валидацию, так как знали, что все конечные точки достижимы. Поэтому не было никакой причины использовать валидацию.

Создадим еще один небольшой процессор под названием UrlPrinterProcessor. Он будет выводить URL из класса, который будет сгенерирован NetworkConfigProcessor. Создадим аннотацию @UrlPrinter и интерфейс, который будет аннотирован ею.

@Target(AnnotationTarget.CLASS)
annotation class UrlPrinter

@UrlPrinter
interface SampleUrl {
val sampleConfigUrl: SampleConfigUrl
}

В функции main вызовем print-метод SampleUrlPrinter:

fun main() {
SampleUrlPrinter().print()
}

Как видите, в примере выше использован тип свойства SampleConfigUrl, и в момент генерации SampleUrlPrinter он, возможно, не существовал, поэтому необходимо провести его валидацию. Метод process будет выглядеть следующим образом:

override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(checkNotNull(UrlPrinter::class.qualifiedName))

return symbols
.filter {
val classDeclaration = it as? KSClassDeclaration
if (classDeclaration?.classKind != ClassKind.INTERFACE) {
logger.error("Use kotlin interface with @UrlPrinter!", classDeclaration)
}
classDeclaration?.classKind == ClassKind.INTERFACE
}
.mapNotNull {
if (it.validate()) {
it.accept(UrlPrinterProcessorVisitor(), Unit)
null
} else {
it
}
}
.toList()
}

В данном случае метод validate возвращает false, так как свойство sampleConfigUrl имеет тип KSErorrType, поскольку класс SampleConfigUrl еще не создан.

Итак, это был пример того, когда нужно проверить узел и убедиться, существует ли данный элемент. Полный пример можно найти в ветке with-validation.

Сгенерированный файл:

internal class SampleUrlPrinter {
fun print() {
println(SampleConfigUrl(EnvironmentSettings()).url)
}
}

Вывод

Подводя итог, хочу посоветовать использовать валидацию, только если знаете, что элементы/узлы недоступны. В противном случае это приведет к ухудшению производительности.


Добавление аргументов

Это может пригодиться, если нужно установить процессор неявно. И такая возможность в KSP есть. Передавать аргументы можно, например, через командную строку или gradle. Затем нужно просто взять их из options-свойства SymbolProcessorProvider. Это свойство имеет тип Map<String, String>, поэтому при определении аргументов можно создавать только пары ключ-значение.

Начнем с добавления нескольких аргументов, описывающих различные среды, в файл рабочей нагрузки build.gradle:

ksp {
arg("PROD", "prod")
arg("TEST", "test")
arg("DEV", "dev")
}

Теперь создадим еще один процессор под названием EnvironmentSettingProcessor, который заменит класс EnvironmetSetting, созданный ранее, и обработаем аргументы из файла build.gradle. Ниже показан его метод visitClassDeclaration:

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val packageName = classDeclaration.containingFile!!.packageName.asString()
val className = "${classDeclaration.simpleName.asString()}Settings"

file = codeGenerator.createNewFile(
Dependencies(
true,
classDeclaration.containingFile!!
),
packageName,
className
)

file.appendText("package $packageName\n\n")
file.appendText("internal class $className {\n")
file.appendText(
"\tval stage get() = Environment.PROD.env\n" +
"\n\tenum class Environment(val env: String) {"
)

var counter = 0

options.forEach {
file.appendText("\n\t\t${it.key}(\"${it.value}\")")
if (options.size != ++counter) file.appendText(",")
}
file.appendText("\n\t}\n}")
file.close()
}

Как вы заметили, мы просто обработали map с аргументами и записали в файл.

Полный пример  —  в ветке with_passing_options_to_processor.

Сгенерированный файл:

internal class SampleEnvironmentSettings {
val stage get() = Environment.PROD.env

enum class Environment(val env: String) {
PROD("prod"),
TEST("test"),
DEV("dev")
}
}

Отладка

Самой сложной задачей, с которой я столкнулся, была отладка. В официальной документации об этом ничего нет. А многие предложения из интернета не работают.

Один из найденных мной подходов время от времени показывает свою эффективность. Можете использовать его. Для начала введите в командную строку текст выше команды:

gradlew :workload:build --no-daemon -Dorg.gradle.debug=true -Dkotlin.compiler.executio

Затем откройте вкладку Run и нажмите Attach to process. После этого в открывшемся окне следует выбрать KotlinCompileDaemon. Но по какой-то причине этого процесса может не быть. В таком случае я просто выбираю GradleDaemon, и все срабатывает.

Кроме того, на данный момент отладка не работает, если проект имеет версию Kotlin 1.8+. Поэтому для отладки необходимо понизить ее до 1.7.20.

После перечисленных манипуляций можно отлаживать код привычным способом.


Инкрементная обработка

Очевидно, что обработка занимает определенное время сборки, и хотелось бы его сократить. Это можно сделать, правильно используя режим агрегации. В предыдущих примерах агрегация была использована в режиме true  —  теперь выясним, почему это было плохой идеей.

Существует объект Dependencies, который мы используем для создания файла. Этот объект имеет два аргумента: aggregation и sources. Sources  —  это массив источников, от которых зависит вывод процессора. А что насчет aggregation? Если установить значение true, это будет означать, что каждое изменение в существующих файлах или создание новых файлов влияет на повторную обработку. Между тем установка значения false означает, что только изменения в sources приводят к повторной обработке. Таким образом, каждое новое изменение входного файла приводит к повторной обработке, потому что это может быть любая новая информация, которая должна быть обработана. Но для выявления “грязных” sources необходимо использовать такие методы Resolver, как getAllFiles/getSymbolsWithAnnotation/getClassDeclarationByName/getDeclarationsFromPackage.

Вернемся к примерам. Не следует устанавливать aggregation на true, потому что для наших выводов не имеет значения, появляются ли новые файлы. Только изменения во входных файлах должны привести к повторной обработке. Таким образом, использование значения aggregation = false  —  лучший выбор, чтобы сократить время сборки.

Используйте aggregation = true только в тех случаях, когда вы точно уверены, что добавление новых файлов должно вызвать регенерацию вывода.

А если хотите провести исследование количества “грязных” файлов во время сборки, просто добавьте строку, приведенную ниже, в Gradle-файл свойств:

ksp.incremental.log=true

Затем создайте приложение и перейдите к kspCaches/source/logs. Этот пример можно посмотреть в ветке под названием incremental_processing. Попробуйте добавить новый файл, затем перейдите в kspSourceToOutputs.log и увидите, что никакой повторной обработки не было.

Многораундовая обработка

KSP поддерживает многораундовую обработку. Это означает, что процессор может отложить выполнение текущего узла до следующего раунда. Чтобы определить, когда отложить обработку, метод process должен вернуть список отложенных узлов. И лучшим выбором, как вы уже догадались, является валидация, о чем шла речь выше.

Новые раунды будут продолжаться до тех пор, пока не появятся новые файлы, которые могут быть получены от Resolver.

Необходимо также учитывать, что для процессора “жив” только один экземпляр за раз, поэтому можно сохранить данные в свойстве класса. Но имейте в виду: это может привести к ошибкам в коде, так как после некоторых раундов данные могут стать невалидными. Если произошла ошибка, текущий раунд продолжается, а прекращение наступает только после завершения раунда. После этого процесс вызывает onError. При необходимости можно переопределить этот метод для обработки специфической логики.

Заключение

KSP  —  это ценный инструмент для любого Kotlin-разработчика, стремящегося повысить качество и эффективность кода. Он позволяет автоматизировать утомительные задачи, повысить качество кода, сделать код менее подверженным ошибкам и сократить время разработки.

Полезные ссылки:

  1. Репозиторий по этой теме.
  2. Официальная документация KSP.
  3. KSP-репозиторий.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Matthew Strukov: KSP through practise

Предыдущая статьяСамые полезные библиотеки Go
Следующая статьяЗачем усложнять разработку с AWS Lambda?