384 lines
23 KiB
Markdown
384 lines
23 KiB
Markdown
---
|
||
title: KSP
|
||
description: Kotlin Symbol Processing — кодогенерация по аннотациям, написание процессора, миграция с kapt
|
||
tags:
|
||
- kotlin
|
||
- tooling
|
||
- ksp
|
||
---
|
||
|
||
KSP (Kotlin Symbol Processing) - механизм генерации кода по аннотациям, работающий с Kotlin напрямую. Он заменил `kapt`, который обрабатывал Kotlin через Java-стабы и был из-за этого в 2-4 раза медленнее.
|
||
|
||
```kotlin
|
||
// 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-фреймворки генерируют код, а не используют рефлексию. Подробнее: [Рефлексия](../Рефлексия/Рефлексия.md)
|
||
|
||
## Библиотеки на 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](Компилятор%20K2.md)
|
||
|
||
## Как это работает
|
||
```mermaid
|
||
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.
|
||
|
||
**Модуль процессора:**
|
||
```kotlin
|
||
// 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) }
|
||
```
|
||
|
||
**Процессор:**
|
||
```kotlin
|
||
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
|
||
```
|
||
|
||
**Подключение:**
|
||
```kotlin
|
||
// 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") }
|
||
```
|
||
|
||
**Использование:**
|
||
```kotlin
|
||
// 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`:
|
||
```kotlin
|
||
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)
|
||
}
|
||
```
|
||
Подробнее про сам паттерн: [Паттерны проектирования](../Функциональный%20Kotlin/Паттерны%20проектирования.md)
|
||
|
||
## API KSP
|
||
| Тип | Что представляет |
|
||
|---|---|
|
||
| `Resolver` | точка доступа ко всем символам |
|
||
| `KSClassDeclaration` | класс, интерфейс, объект, enum |
|
||
| `KSFunctionDeclaration` | функция или конструктор |
|
||
| `KSPropertyDeclaration` | свойство |
|
||
| `KSValueParameter` | параметр функции |
|
||
| `KSTypeReference` | ссылка на тип (ленивая) |
|
||
| `KSType` | разрешённый тип с аргументами и nullability |
|
||
| `KSAnnotation` | аннотация с аргументами |
|
||
| `KSFile` | файл с исходниками |
|
||
| `CodeGenerator` | запись новых файлов |
|
||
| `KSPLogger` | сообщения в лог сборки |
|
||
|
||
```kotlin
|
||
// типичные операции (псевдокод в комментариях — требует 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()` снова после того, как типы появятся.
|
||
```kotlin
|
||
// внутри процессора:
|
||
// 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`.
|
||
|
||
## Опции процессора
|
||
```kotlin
|
||
// build.gradle.kts
|
||
ksp {
|
||
arg("builderPrefix", "Make")
|
||
arg("verbose", "true")
|
||
}
|
||
```
|
||
```kotlin
|
||
// в процессоре:
|
||
// 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) |
|
||
> |---|---|
|
||
> | `AbstractProcessor` | `SymbolProcessor` |
|
||
> | `javax.lang.model.Element` | `KSDeclaration` |
|
||
> | `RoundEnvironment` | `Resolver` |
|
||
> | `Filer` | `CodeGenerator` |
|
||
> | `Messager` | `KSPLogger` |
|
||
> | видит 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](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md)
|
||
|
||
> [!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()`.
|
||
|
||
Далее: [Линтеры и формат](Линтеры%20и%20формат.md)
|