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

23 KiB
Raw Blame History

title, description, tags
title description tags
KSP Kotlin Symbol Processing — кодогенерация по аннотациям, написание процессора, миграция с kapt
kotlin
tooling
ksp

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)
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

[!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().

Далее: Линтеры и формат