Files
obsidian/Kotlin/Инструментарий/KSP.md
T

384 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)