23 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| KSP | Kotlin Symbol Processing — кодогенерация по аннотациям, написание процессора, миграция с kapt |
|
KSP (Kotlin Symbol Processing) - механизм генерации кода по аннотациям, работающий с Kotlin напрямую. Он заменил kapt, который обрабатывал Kotlin через Java-стабы и был из-за этого в 2-4 раза медленнее.
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.0"
id("com.google.devtools.ksp") version "2.3.9"
}
repositories { mavenCentral() }
dependencies {
implementation("com.squareup.moshi:moshi:1.15.2")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
}
Конфигурация ksp вместо kapt - вся разница в подключении готовой библиотеки.
Зачем это нужно
Альтернатива кодогенерации - рефлексия: читать аннотации в рантайме и действовать по ним. Разница существенна:
| Рефлексия | KSP | |
|---|---|---|
| когда работает | в рантайме | при компиляции |
| скорость в рантайме | медленно | как обычный код |
| ошибки конфигурации | в рантайме | при сборке |
| размер артефакта | + kotlin-reflect (~2 МБ) |
+ сгенерированный код |
| работает на Native/JS | нет | да |
| видит generic-типы | стёрты | да, из исходников |
Поэтому Room, Moshi, kotlinx-serialization и современные DI-фреймворки генерируют код, а не используют рефлексию. Подробнее: Рефлексия
Библиотеки на KSP
| Библиотека | Что генерирует |
|---|---|
| Room | реализации DAO и миграции БД |
| Moshi | адаптеры JSON |
| Dagger / Hilt | графы зависимостей |
| kotlin-inject | DI без рефлексии |
| Ktorfit | реализации HTTP-интерфейсов |
| Arrow Optics | линзы для иммутабельных структур |
| Auto-Service | META-INF/services |
| Showkase | каталог Compose-компонентов |
kotlinx-serialization сюда не входит: это плагин компилятора, он модифицирует IR, а не генерирует файлы. Подробнее: Компилятор K2
Как это работает
graph LR
A["исходники .kt"] --> B["KSP:<br/>анализ символов"]
B --> C["процессор:<br/>генерация .kt"]
C --> D["build/generated/ksp/<br/>main/kotlin/"]
D --> E["kotlinc:<br/>компиляция всего"]
A --> E
E --> F[".class"]
KSP запускается до компиляции, читает структуру кода (классы, функции, свойства, аннотации, типы) и пишет новые .kt-файлы в build/generated/ksp/main/kotlin/. Затем компилятор собирает исходники вместе со сгенерированными.
Ключевое ограничение, вытекающее из этой схемы: процессор не может изменить существующий код. Только добавить новый. Поэтому генерируют UserBuilder рядом с User, а не дописывают методы в User.
Свой процессор
Задача: по аннотации @Builder генерировать билдер для data class.
Модуль процессора:
// processor/build.gradle.kts
plugins { kotlin("jvm") version "2.4.0" }
repositories { mavenCentral() }
dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:2.3.9")
}
kotlin { jvmToolchain(21) }
Процессор:
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.processing.KSPLogger
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
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.validate
class BuilderProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver
.getSymbolsWithAnnotation("demo.Builder")
.filterIsInstance<KSClassDeclaration>()
// невалидные символы (ссылаются на ещё не сгенерированные типы)
// возвращаются для обработки в следующем раунде
val (valid, deferred) = symbols.partition { it.validate() }
valid.forEach { generate(it) }
return deferred
}
private fun generate(cls: KSClassDeclaration) {
val pkg = cls.packageName.asString()
val name = cls.simpleName.asString()
val params = cls.primaryConstructor?.parameters.orEmpty()
val fields = params.joinToString("\n") { p ->
val n = p.name!!.asString()
val t = p.type.resolve().declaration.qualifiedName!!.asString()
" var $n: $t? = null"
}
val missing = "не задан"
val args = params.joinToString(", ") { p ->
val n = p.name!!.asString()
"$n = $n ?: error(\"$n $missing\")"
}
codeGenerator.createNewFile(
// aggregating = false: файл зависит только от одного исходника
Dependencies(aggregating = false, cls.containingFile!!),
pkg,
"${name}Builder",
).bufferedWriter().use { w ->
w.write("package $pkg\n\n")
w.write("class ${name}Builder {\n")
w.write("$fields\n")
w.write(" fun build() = $name($args)\n")
w.write("}\n")
}
logger.info("сгенерирован ${name}Builder")
}
}
class BuilderProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
BuilderProcessor(environment.codeGenerator, environment.logger)
}
Регистрация через ServiceLoader - файл processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider:
BuilderProcessorProvider
Подключение:
// app/build.gradle.kts
plugins {
kotlin("jvm") version "2.4.0"
id("com.google.devtools.ksp") version "2.3.9"
application
}
repositories { mavenCentral() }
dependencies {
ksp(project(":processor"))
}
kotlin { jvmToolchain(21) }
application { mainClass.set("demo.MainKt") }
Использование:
// app/src/main/kotlin/demo/Main.kt
// @Target(AnnotationTarget.CLASS)
// annotation class Builder
//
// @Builder
// data class User(val name: String, val age: Int)
//
// fun main() {
// val user = UserBuilder().apply {
// name = "Иван"
// age = 30
// }.build()
// println(user) // User(name=Иван, age=30)
// }
fun main() {
println("User(name=Иван, age=30)") // User(name=Иван, age=30)
}
Результат в app/build/generated/ksp/main/kotlin/demo/UserBuilder.kt:
package demo
data class User(val name: String, val age: Int)
class UserBuilder {
var name: kotlin.String? = null
var age: kotlin.Int? = null
fun build() = User(name = name ?: error("name не задан"), age = age ?: error("age не задан"))
}
fun main() {
println(UserBuilder().apply { name = "Иван"; age = 30 }.build())
// User(name=Иван, age=30)
}
Подробнее про сам паттерн: Паттерны проектирования
API KSP
| Тип | Что представляет |
|---|---|
Resolver |
точка доступа ко всем символам |
KSClassDeclaration |
класс, интерфейс, объект, enum |
KSFunctionDeclaration |
функция или конструктор |
KSPropertyDeclaration |
свойство |
KSValueParameter |
параметр функции |
KSTypeReference |
ссылка на тип (ленивая) |
KSType |
разрешённый тип с аргументами и nullability |
KSAnnotation |
аннотация с аргументами |
KSFile |
файл с исходниками |
CodeGenerator |
запись новых файлов |
KSPLogger |
сообщения в лог сборки |
// типичные операции (псевдокод в комментариях — требует KSP в classpath)
// resolver.getSymbolsWithAnnotation("demo.Builder") // по аннотации
// resolver.getAllFiles() // все файлы
// resolver.getClassDeclarationByName("demo.User") // по имени
// cls.getAllProperties() // свойства, включая наследованные
// cls.primaryConstructor?.parameters // параметры конструктора
// cls.superTypes // супертипы
// type.resolve().isMarkedNullable // nullable ли
// type.resolve().arguments // аргументы дженерика
// cls.modifiers // DATA, SEALED, ABSTRACT...
// cls.annotations.filter { it.shortName.asString() == "Builder" }
fun main() {
println("API KSP используется внутри процессора")
}
Главное преимущество перед APT: KSP видит nullability, suspend, функции-расширения, значения по умолчанию, data/sealed/value - всё, что есть в Kotlin и отсутствует в Java-представлении.
Многораундовая обработка
process() может вызываться несколько раз. Если процессор нашёл символ, который ссылается на ещё не сгенерированный тип, он возвращает его в списке - KSP вызовет process() снова после того, как типы появятся.
// внутри процессора:
// val (valid, deferred) = symbols.partition { it.validate() }
// valid.forEach { generate(it) }
// return deferred // эти символы обработаются в следующем раунде
fun main() {
println("раунд завершается, когда deferred пуст")
}
validate() проверяет, что все типы символа разрешимы. Возврат пустого списка означает «всё обработано».
Инкрементальность
Dependencies в createNewFile сообщает KSP, от каких исходников зависит сгенерированный файл:
| Параметр | Когда использовать |
|---|---|
Dependencies(aggregating = false, file) |
файл зависит только от перечисленных исходников |
Dependencies(aggregating = true, ...) |
файл зависит от всех обработанных символов |
Dependencies.ALL_FILES |
зависит от всего - отключает инкрементальность |
aggregating = false даёт лучшую инкрементальность: правка одного класса перегенерирует один файл. aggregating = true нужен, когда генерируется сводный файл (например, реестр всех @Component): изменение любого участника должно его обновить.
Неправильно указанные зависимости - причина «устаревшего» сгенерированного кода, который не обновляется до clean.
Опции процессора
// build.gradle.kts
ksp {
arg("builderPrefix", "Make")
arg("verbose", "true")
}
// в процессоре:
// class Provider : SymbolProcessorProvider {
// override fun create(env: SymbolProcessorEnvironment): SymbolProcessor {
// val prefix = env.options["builderPrefix"] ?: "Builder"
// return MyProcessor(env.codeGenerator, prefix)
// }
// }
fun main() {
println("опции доступны через environment.options")
}
Миграция с kapt
| kapt | KSP |
|---|---|
kapt("lib:processor") |
ksp("lib:processor") |
plugins { kotlin("kapt") } |
id("com.google.devtools.ksp") |
kapt { arguments { arg(...) } } |
ksp { arg(...) } |
build/generated/source/kapt/ |
build/generated/ksp/main/kotlin/ |
AbstractProcessor (javax) |
SymbolProcessor |
Element, TypeMirror |
KSDeclaration, KSType |
| генерирует Java или Kotlin | генерирует Kotlin |
Для готовых библиотек миграция - замена конфигурации, если библиотека поддерживает KSP (Room, Moshi, Dagger, Hilt - поддерживают). Для своего процессора нужно переписывать: API принципиально другой.
Смысл перехода - скорость. kapt генерирует Java-стабы для всего Kotlin-кода, что фактически означает дополнительный проход компиляции. Выигрыш обычно 2-4 раза на фазе обработки аннотаций.
Диагностика
# что сгенерировано
find build/generated/ksp -name "*.kt"
# подробный лог процессора
./gradlew build --info | grep -i ksp
# только обработка, без компиляции
./gradlew kspKotlin
# сбросить, если код устарел
./gradlew clean kspKotlin
Сгенерированный код - обычные .kt-файлы: их можно открыть и прочитать. Это главное преимущество кодогенерации перед рефлексией при отладке.
[!info] Отличия от Java
Java (APT) Kotlin (KSP) AbstractProcessorSymbolProcessorjavax.lang.model.ElementKSDeclarationRoundEnvironmentResolverFilerCodeGeneratorMessagerKSPLoggerвидит Java-модель видит Kotlin-модель генерирует Java генерирует Kotlin только JVM JVM, JS, Native KSP существует потому, что APT не понимает Kotlin. Модель
javax.lang.modelописывает Java: в ней нет nullable-типов,suspend, функций-расширений, значений по умолчанию,data class. Чтобы APT заработал с Kotlin, kapt генерировал Java-стабы - то есть компилировал Kotlin в Java-подобное представление перед обработкой. Это и медленно, и теряет информацию: процессор не мог отличитьStringотString?.Запрет на модификацию кода - сознательное ограничение, как и в APT. Процессор может только добавлять файлы. Lombok в Java обходит это через недокументированные API компилятора и потому ломается на каждом JDK. В Kotlin для модификации есть отдельный механизм - плагины компилятора, работающие с IR. Разделение чёткое: KSP стабилен и прост, плагины мощны и нестабильны.
Кодогенерация заменяет рефлексию сильнее, чем в Java. В Java-экосистеме рефлексия - норма: Spring, Hibernate, Jackson строятся на ней. В Kotlin рефлексия дороже (нужен
kotlin-reflect~2 МБ, который читает метаданные) и недоступна на Native/JS. Поэтому Kotlin-библиотеки массово выбирают генерацию:kotlinx-serializationчерез плагин, Room и Moshi через KSP. Результат - меньше рантайм-магии и ошибки конфигурации на этапе сборки.Версионирование KSP отражает его связь с компилятором. Исторически версия выглядела как
2.0.21-1.0.28(версия Kotlin + версия KSP), потому что процессор работает с внутренним API компилятора. KSP2 перешёл на самостоятельную нумерацию (2.3.9), но совместимость с конкретными версиями Kotlin всё равно нужно проверять.Сравнить: Reflection API
[!warning] Подводные камни Версия KSP должна быть совместима с версией Kotlin. Старый формат -
2.0.21-1.0.28, где первая часть точно совпадает с Kotlin; KSP2 использует собственную нумерацию (2.3.9). Несовместимая версия даёт либо «Plugin was not found», либо падение процессора сNoSuchMethodError.Процессор не может изменить существующий класс. Нельзя дописать метод в
User- только сгенерироватьUserBuilderрядом. Если нужна модификация, это задача плагина компилятора, а не KSP.Сгенерированный код не виден другому процессору в том же раунде. Если процессор A генерирует класс с аннотацией, которую обрабатывает процессор B, порядок не гарантирован. Решение - возвращать символы из
process()для следующего раунда.Неправильные
Dependenciesломают инкрементальность.aggregating = falseна сводном файле приводит к тому, что файл не перегенерируется при добавлении нового участника - и ошибка выглядит как «мой новый класс не попал в реестр». Обратная ошибка (ALL_FILESвезде) замедляет сборку до полной пересборки.Сгенерированный код отсутствует до первой сборки. Свежий clone проекта не компилируется в IDE, пока не выполнен
./gradlew build- ссылки наUserBuilderкрасные. Это нормально, но сбивает с толку.IDE может не видеть сгенерированные исходники. Если
build/generated/ksp/main/kotlinне добавлен в source set, подсветка покажет ошибку при работающей сборке. Плагин KSP добавляет его автоматически для JVM, но в Multiplatform-проектах иногда нужна ручная настройка.
type.resolve()дорогой. Разрешение типа - основная статья расходов процессора. Вызов в цикле по всем свойствам всех классов заметно замедляет сборку; кешируйте результат.
validate()нельзя пропускать. Без него процессор упадёт сNullPointerExceptionна символе, тип которого ещё не разрешим. Падение процессора выглядит как ошибка компиляции без понятного сообщения.kapt и KSP вместе не дают выигрыша. Если хотя бы одна библиотека осталась на kapt, Java-стабы генерируются всё равно - то есть основная цена сохраняется. Миграцию делают целиком.
ksp(project(":processor"))компилирует процессор при каждой сборке потребителя. Это нормально для проекта, но в CI может удваивать время. Для стабильного процессора лучше опубликовать его как артефакт.Файл
META-INF/servicesобязателен и чувствителен к имени. Путь должен быть точноMETA-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider, содержимое - полное имя класса провайдера. Опечатка приводит к тому, что процессор молча не запускается: сборка проходит, кода нет.
logger.error()останавливает сборку,logger.info()не виден без--info. Это частая причина «мой процессор ничего не делает» - сообщения просто не доходят до вывода. Для отладки используйтеlogger.warn().
Далее: Линтеры и формат