--- 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:
анализ символов"] B --> C["процессор:
генерация .kt"] C --> D["build/generated/ksp/
main/kotlin/"] D --> E["kotlinc:
компиляция всего"] 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 { val symbols = resolver .getSymbolsWithAnnotation("demo.Builder") .filterIsInstance() // невалидные символы (ссылаются на ещё не сгенерированные типы) // возвращаются для обработки в следующем раунде 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)