Kotlin guide done. (20.10.26)
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user