Я расскажу в этой статье о Kotlin Symbol Processing, также известном как KSP.

Сначала обсудим теоретические основы работы KSP, после, конечно же, перейдём к практике. На примере небольшой задачи, подобной той, что мы решаем с помощью KSP в нашей компании, покажу, как анализировать код с помощью KSP, генерировать файлы, соблюдать контракты, описанные разработчиками KSP. 

Так как я специализируюсь на Android разработке, среда разработки и инструменты будут соответствующие. 

KSP — инструмент, который позволяет создавать легковесные плагины для компилятора. Задачи этих плагинов — анализ существующего Kotlin кода и создание файлов на основе проведённого анализа. KSP поддерживает Kotlin Multiplatform, а в случае работы с JVM, позволяет анализировать и Java код. Что и как будет записано в файл, решает разработчик плагина, KSP не предоставляет инструментов для этого. Генерировать можно файлы с любым содержанием, KSP лишь выставляет ряд требований к процессу создания файлов из-за особенностей своей работы. На всякий случай стоит отметить, что работа KSP осуществляется перед этапом компиляции, а не в рантайме. 

Поговорим немного об ограничениях.

Ограничения при работе с KSP 

Задача KSP – быть наиболее простым решением для большинства случаев. Поэтому существует несколько ограничений в работе с ним. KSP не позволяет анализировать код уровня выражений. Это означает, что вы можете получить информацию о классе, его свойствах и методах, но проанализировать содержание конкретного метода уже не удастся. 

Помимо этого, KSP не позволит модифицировать существующие файлы. Можно сгенерировать новый файл или перезаписать содержимое ранее сгенерированного файла, но изменить или дополнить код файлов, существующих на момент запуска плагина, не удастся. 

Также целью KSP не является 100% совместимость с Java Annotation Processing API. 

Теперь давайте рассмотрим процесс работы KSP верхнеуровнево.

Процесс работы в общем

После запуска KSP обращается к файлу по пути resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider (в каждом из модулей, где KSP подключён). В этом файле должны быть перечислены имена классов-наследников SymbolProcessorProvider. Следующим шагом KSP создаёт каждый из объявленных классов-провайдеров. Задача этих классов – создавать экземпляры KSP-процессоров. 

После KSP использует провайдеры для поочерёдного создания процессоров. Именно процессоры позволяют анализировать код. Как только все процессоры созданы, KSP начинает цикл работы этих процессоров. 

Каждая итерация цикла называется раундом. Раунд может быть как один, так и несколько. Внутри каждого раунда KSP вызывает у всех процессоров метод process() и получает некий результат. После получения результата от каждого из процессоров раунд завершается. Важно отметить, что порядок работы процессоров может быть любым. Каждый шаг в деталях будет разобран позже. Взгляните на схему описанного процесса работы и двинемся дальше. 

Способы запуска KSP

KSP запускается во время сборки Android проекта для всех модулей, к которым подключены модули с реализованными KSP плагинами. Как подключать модули с KSP плагинами к модулям проекта, упомяну позже. 

Можно срезать углы и запустить только минимальный набор задач, чтобы не ждать полной сборки проекта ради генерации кода. Для этого нужно вызвать команду ./gradlew kspDebugKotlin, где Debug можно заменить на любой тип сборки. Для release типа сборки нужно вызвать команду ./gradlew kspReleaseKotlin. Если нужно запустить KSP только на определённом модуле, можно указать его перед командой: ./gradlew :my_feature:kspDebugKotlin

Можно запускать KSP из командной строки без использования Gradle. При необходимости вы можете самостоятельно изучить этот процесс в официальной документации, а сейчас предлагаю рассмотреть провайдеры KSP процессоров.

Как KSP находит и создаёт провайдеры

KSP считывает список провайдеров из файла по пути src/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider и так понимает, какие провайдеры нужно использовать. Внутри этого файла нужно указать полное имя каждого класса-наследника SymbolProcessorProvider. В нём также можно добавлять комментарии, начиная строку с символа #. Содержание файла может быть следующим:

# comment
ru.cian.ksp_plugin_01_first_processor.FirstSymbolProcessorProvider

Теперь предлагаю взглянуть, как провайдер устроен внутри, и зачем ещё он нужен.

Зачем нужны провайдеры

Фактически, провайдер – аналог точки входа в программу. Здесь у вас есть возможность инициализировать всё, что требуется для работы процессора, и создать его экземпляр удобным вам способом. SymbolProcessorProvider имеет единственную функцию create(environment: SymbolProcessorEnvironment). Ряд полезной информации можно получить из параметра environment:

  • kotlinVersion – версия Kotlin в окружении, в котором происходит компиляция.

  • apiVersion – версия API Kotlin в окружении компиляции.

  • compilerVersion – версия Kotlin компилятора в окружении компиляции.

  • platforms – список платформ, для которых выполняется задача (JVM, JS, Native).

  • logger: KSPLogger – класс для логирования работы.

  • codeGenerator: CodeGenerator – класс для работы с файлами.

  • options: Map<String, String> – список дополнительных опций в виде ключ-значение, которые можно передать KSP при запуске.

Позже подробнее поговорим о KSPLogger, CodeGenerator и передаче дополнительных параметров KSP. Взгляните на код провайдера и далее разберёмся, как устроены процессоры.

fun interface SymbolProcessorProvider {
    fun create(environment: SymbolProcessorEnvironment): SymbolProcessor
}

Как работает отдельно взятый процессор

Как можно было заметить, каждый процессор создаётся только один раз, а значит, допустимо сохранять в классе информацию между раундами. Посмотрим на интерфейс процессора.

interface SymbolProcessor {
  fun process(resolver: Resolver): List<KSAnnotated>
  fun finish() {}
  fun onError() {}
}

Методы finish() и onError() вызываются после завершения всех раундов работы. Если работа завершена успешно, то вызывается finish(), иначе - onError(). KSP может вызвать только один из методов, не ожидайте вызова finish() после onError(). При ошибке метод onError() вызывается у всех процессоров, независимо от места её возникновения. Раунд мгновенно не прерывается, KSP продолжает работу других процессоров и только после завершения раунда вызовет onError(), после чего прекратит работу. Здесь хотелось бы пару слов добавить про KSPLogger. Кроме записи логов он позволяет завершить раунд с ошибкой. Если использовать методы error() или exception(), то после окончания раунда работа KSP будет прервана, и у всех процессоров будет вызван onError()

В методе process() доступен Resolver. Его основная задача – предоставить доступ к файлам и объектам (классы, функции и т.д.) для анализа. Resolver даёт доступ ко всем файлам через метод getAllFiles() и содержит несколько других методов для доступа к объектам. 

Также у метода process() есть возвращаемое значение, в качестве которого должен выступать список объектов, не прошедших валидацию. Причины, по которым объект может не пройти валидацию, будут рассмотрены ниже. Для проверки валидности объекта в KSP имеется отдельный метод.

Сейчас нужно понять, каким образом KSP принимает решение о запуске следующего раунда. Первая важная часть этого процесса – инкрементальная обработка данных, но перед тем как перейти к её разбору, нужно обратить внимание на ещё один аспект работы KSP.

Ссылки на типы и получение типов (type reference resolution)

Анализируя код, можно столкнуться с объектами разного вида. Если вызвать у Resolver метод getSymbolsWithAnnotation(), то в ответ вы получите последовательность KSAnnotated элементов.

Взглянув на диаграмму, описывающую, как Kotlin код смоделирован в KSP, можно увидеть что этот элемент имеет множество наследников. Если среди объектов, помеченных запрошенной аннотацией, существуют классы, то среди полученных объектов вы найдёте KSClassDeclaration. KSClassDeclaration описывает класс: его конструкторы, аннотации, тип, суперклассы, функции, свойства. Через поле KSClassDeclaration.superTypes:Sequence<KSTypeReference> можно получить информацию о том, какие интерфейсы реализует полученный класс и от каких классов наследуется. 

KSTypeReference – это ссылка на тип. Ссылка на тип обладает минимумом информации о типе, но в ряде случаев её может быть достаточно. Если под ссылкой на тип скрывается функция, то через поле KSTypeReference.element:KSReferenceElement можно прочитать её параметры и ссылку на возвращаемый тип (реализацией KSReferenceElement для функции будет KSCallableReference). Если это класс, то реализацией KSReferenceElement будет KSClassifierReference и информации там практически никакой нет. Когда требуется полная информация о типе, то можно вызвать метод KSTypeReference.resolve() и получить объект KSType. Таким образом, имея ссылку на тип, всегда можно получить полную информацию об объекте.

Здесь важно отметить, что операция resolve() дорогостоящая и её следует избегать везде, где это возможно. KSP быстрее KAPT во многом за счёт того, что ему не требуется для работы получение типа каждого объекта из исходного кода.

Момент получения типа из ссылки играет важную роль в процессе инкрементальной обработки данных.

Инкрементальная обработка

Инкрементальная обработка данных (incremental processing) – техника, направленная на уменьшение количества итераций повторной обработки данных. Процессоры могут осуществлять работу в несколько раундов. В первом раунде для обработки доступны все объекты. В последующих – сгенерированные в первом раунде объекты и объекты, которые необходимо обработать заново. Разберём, как определяется необходимость повторной обработки файлов.

Разработчик плагина должен указать, на основании анализа каких файлов генерируется новый файл, и является ли генерируемый файл агрегирующим (aggregating) или изолирующим (isolating). Это повлияет на то, какие файлы будут помечены как dirty и доступны для повторной обработки.

KSP, на основании переданных разработчиком данных и на основании своей внутренней логики, может пометить файлы, которые нужно обработать ещё раз. Они и называются dirty файлами. 

Не переживайте об отсутствии примеров кода в данном разделе. О настройке генерируемых файлов будем подробно говорить в рамках практического примера.

Рассмотрим случай, когда генерируемые файлы не являются агрегирующими. Например, мы генерируем файл Trips.kt на основании ранее сгенерированных Tickets.kt и Hotels.kt, а также Places.kt на основании ранее сгенерированных Hotels.kt и Museums.kt. Если файл Tickets.kt будет изменён (по условию он был сгенерирован KSP и поэтому изменение возможно) или каким-то образом задет изменением в другом файле (это возможно из-за resolution tracing, который мы рассмотрим ниже), то KSP пометит его как dirty. Так как файл Trips.kt был сгенерирован на основании инвалидированного Tickets.kt, то Trips.kt должен быть сгенерирован заново, а значит, все его входные данные должны быть повторно проанализированы. KSP пометит Hotels.kt как dirty, чтобы при повторном анализе вы обладали полной информацией для генерации Trips.kt. Так как Hotels.kt теперь требует повторной обработки, то по этому же принципу Museums.kt будет помечен как dirty, то есть информация о необходимости повторной обработки распространяется транзитивно на все связанные между собой файлы. 

Теперь попробуем сравнить агрегирующий файл с не агрегирующим.

Начнём с другого примера без агрегации. Пусть файл Honey.kt генерируется на основании сгенерированного Bees.kt, а Profit.kt на основании сгенерированного Workers.kt. Сначала изменим файл Bees.kt. В результате только он будет заново обработан в следующем раунде. Если мы добавим файл Managers.kt, то только Managers.kt будет обработан в следующем раунде.

Если мы сделаем файл Profit.kt агрегирующим, то поведение изменится. Агрегирующие файлы потенциально могут зависеть от изменений в любом из файлов. При изменении файла Bees.kt заново обработан будет не только Bees.kt, но и Workers.kt, так как генерация агрегирующего Profit.kt потенциально может зависеть от изменений в Bees.kt. Аналогично с добавлением Managers.kt. После добавления в следующем раунде будет обработан не только Managers.kt, но и Workers.kt, так как генерация агрегирующего Profit.kt потенциально может зависеть от изменений в Managers.kt.

Удаление каких-либо файлов, на основании которых генерируются файлы, не повлечёт за собой никакой повторной обработки. То есть если Bees.kt будет удалён, то ни в одном из примеров это не повлечёт за собой повторной обработки файлов.

Теперь понятно, каким образом настройка генерируемых файлов влияет на повторную обработку связанных с ними исходных файлов.

Файлы помечаются как dirty не только на основании настроек разработчика, но и на основании resolution tracing.

Resolution tracing – отслеживание моментов получения типов из ссылок на типы (type reference resolution). Получение типа может быть как явным, когда вы в коде процессора вызываете метод resolve(), так и неявным. Единственный способ навигации между файлами с точки зрения KSP - получение типа из ссылки. В итоге, если в каком-либо файле происходит изменение, которое потенциально могло повлиять на результат получения типа из ссылки, то файлы, имеющие данную ссылку, будут помечены как обязательные для повторной обработки.

Важным выводом из описания resolution tracing будет то, что мы должны всегда опираться на возможность запуска ещё одного раунда работы процессоров, так как пометка файлов для повторной обработки может быть неявной. Не забудьте заглянуть в соответствующий раздел документации, если хотите ещё глубже погрузиться в процесс инкрементальной обработки данных.

Сейчас я предлагаю окунуться в описание возможности намеренно проводить обработку данных в несколько раундов.

Обработка данных в несколько раундов

Иногда вам может потребоваться генерировать файлы на основании ранее сгенерированных файлов.

Предположим, у вас есть три процессора. Первый генерирует классы Clay.kt, второй – Bricks.kt, третий – Building.kt. Генерация каждого следующего класса опирается на предыдущий. То есть нам нужно найти классы Clay.kt для того, чтобы сгенерировать Bricks.kt и найти Bricks.kt для генерации Building.kt. В такой ситуации выполнение будет последовательным и не вызовет никаких сложностей. В первом раунде будет сгенерирован только Clay.kt. Обратите внимание, что файлы, сгенерированные в течение раунда, будут доступны для анализа только в следующем раунде. Аналогично во втором раунде генерируется Bricks.kt и в третьем – Building.kt

Теперь возьмем пример посложнее. Пусть в первом раунде у нас генерируются два объекта – Building.kt и People.kt. Далее нам нужно на основании Building.kt создать Neighbourhood.kt, а на основании People.kt создать Services.kt, но при этом Building.kt использует Services.kt в своей реализации. Например, имеет поле, типом которого является класс, объявленный в Services.kt

Выходит, что во втором раунде работы будет запущен процессор для генерации Neighbourhood.kt на основании Building.kt, который, в свою очередь, содержит ссылку на несуществующий ещё Services.kt. Если вы точно уверены, что для генерации Neighbourhood.kt не потребуется считывать данные поля, ссылающегося на Services.kt, то можно ни о чём не задумываться и Neighbourhood.kt будет сгенерирован корректно. Если же для генерации Neighbourhood.kt всё-таки потребуется прочитать информацию из поля, ссылающегося на отсутствующий файл, то вы получите некорректные данные. Например, пустую строку, вместо имени пакета.

Реальные решения обычно намного сложнее и уместить все возможные развилки в голове не удастся. Чтобы обезопасить себя от попыток чтения информации о несуществующих объектах, есть встроенное решение. Функция KSNode.validate() позволяет проверить, является ли объект валидным, то есть все ли его зависимости достижимы. KSP также предлагает удобный способ работы с подобными ситуациями. Можно не только проверить объект на корректность, но и отложить обработку объекта на следующий раунд, для чего достаточно вернуть его как результат метода process():List<KSAnnotated>

Возвращаемся к примеру. Во втором раунде мы можем проверить Building.kt с помощью функции validate() и получить в ответ false. Далее следует добавить этот объект в список некорректных объектов и после работы процессора вернуть их из метода process(), таким образом отложив обработку Building.kt на следующий раунд. В третьем раунде Services.kt уже будет сгенерирован и Building.kt станет валидным, а значит, можно будет сгенерировать Neighbourhood.kt.

Здесь важно отметить несколько ключевых моментов:

  • Можно откладывать обработку объектов сколько угодно раз.

  • Разработчики KSP не рекомендуют откладывать обработку объектов, проходящих валидацию, ведь если они проходят валидацию, значит, они пригодны для анализа. Технически вас никто не ограничивает в том, чтобы отложить на следующий раунд валидные объекты.

  • Условием выхода из цикла раундов KSP является отсутствие новых сгенерированных файлов, а не отсутствие отложенных объектов, поэтому бесконечный цикл вам создать не удастся.

  • Если возникнет ситуация, в которой ни одного нового файла не сгенерировано, но существуют отложенные объекты, то KSP завершит работу и выведет сообщение о невозможности обработать отдельные файлы. Ошибки при этом не возникает, это лог warning уровня.

  • Если вы попытаетесь отложить обработку объектов, которые не относятся к вашему исходному коду (классы подключённых библиотек, например), то KSP их отфильтрует, так как согласно документации вы не должны откладывать обработку объектов, проходящих валидацию, а подключённый к проекту код уже был кем-то скомпилирован, а значит, точно корректен.

  • Вы можете модифицировать логику валидации объектов, если это вам нужно.

  • Разработчики KSP рекомендуют делать валидацию объектов только при необходимости, так как процесс валидации требует получения всех зависимых от объекта типов из ссылок (type reference resolution, который мы уже обсуждали), а это дорогая операция.

На этом предлагаю закончить разбирать работу в несколько раундов. Как и в случае с инкрементальной обработкой данных, рекомендую заглянуть в официальную документацию

Настало время перейти к практической части и углубиться в детали работы KSP ещё больше.

Задача

Сделаем набросок приложения. Не будем его запускать, а сконцентрируемся на логике работы KSP, которая, как вы помните, происходит до этапа компиляции. Будем исходить из многомодульной архитектуры проекта, так как она сейчас наиболее распространена.

Пусть будет модуль network, который через интерфейс NetworkDependencies будет предоставлять OkHttpClient для работы с сетью. Другие модули проекта не должны создавать экземпляры OkHttpClient, а получать их через NetworkDependencies.

Пусть также будет модуль core, через который мы сможем получать зависимости других модулей, в том числе NetworkDependencies. Делаться это будет через интерфейс DependenciesProvider. Если некоторому модулю потребуется OkHttpClient, то из него нужно обратиться к DependenciesProvider, запросить у него реализацию NetworkDependencies и из неё получить OkHttpClient. В итоге DependenciesProvider будет выступать хранилищем всех публичных зависимостей различных модулей.

Следующий модуль будет фичей, неким списком контактов. Назовём его contacts. В нём создадим ContactsActivity, которой для работы нужен ContactsComponent. ContactsComponent – это Dagger компонент, предоставляющий необходимые для ContactsActivity объекты. ContactsComponent в качестве зависимости потребуется NetworkDependencies

В случае обычной работы с Dagger нам потребовалось бы из ContactActivity обратиться к сгенерированному DaggerContactsComponent и передать ему в конструктор NetworkDependencies, получив их из DependenciesProvider. Если представить, что для работы компонента вам нужно получить из DependenciesProvider 10–15 объектов, а таких компонентов у вас сотни, то может возникнуть желание этот процесс упростить, чтобы не писать однотипный код. 

DaggerContactsComponent.builder()
  .networkDependencies(DependenciesProvider.instance.provide())
  .internalProvidableDependencies(DependenciesProvider.instance.provide())
  //...
  .build()

В этом и будет состоять наша задача. Нужно для каждого Dagger компонента сгенерировать фабрику, которая умеет получать определённые зависимости из DependenciesProvider самостоятельно.

Схема желаемого решения выглядит следующим образом:

Теперь задача ясна, теория разобрана, пора писать код.

Решение

Для самых нетерпеливых прилагаю ссылку на репозиторий с решением.

Я постараюсь очень кратко касаться рутинных задач и уделять основное внимание работе с KSP. Если у вас возникают вопросы, то заглядывайте в код проекта. 

Первым делом добавим в наш Android проект модуль core. У него не будет никаких зависимостей. Внутри него следует добавить интерфейс ProvidableDependency. Этим интерфейсом мы будем маркировать объекты, которые могут быть получены из DependenciesProvider, оговоренного в задании. Далее создадим сам DependenciesProvider. Он должен уметь хранить ссылку на свою реализацию, чтобы быть доступным кому и откуда угодно. Также он должен содержать метод provide(), который может принимать ссылку на KClass, типизированный наследником ProvidableDependency и возвращать в ответ экземпляр класса, реализующего переданный тип. Для удобства работы рядом добавим функцию c reified параметром. 

interface DependenciesProvider {
  fun <T : ProvidableDependency> provide(clazz: KClass<T>): T

  companion object {
    val instance: DependenciesProvider
      get() = TODO("Somehow implemented")
  }
}

inline fun <reified T : ProvidableDependency> DependenciesProvider.provide(): T {
  return provide(T::class)
}

Также добавим в модуль core аннотацию @GenerateComponentFactory. Мы будем её использовать, чтобы помечать компоненты, для которых нужно сгенерировать фабрику.

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
annotation class GenerateComponentFactory

Далее добавим модуль network. В зависимостях он будет содержать core модуль и библиотеку okhttp. Добавим в него интерфейс NetworkDependencies. Интерфейс будет уметь предоставлять ссылку на OkHttpClient и будет промаркирован интерфейсом ProvidableDependency из модуля core, обозначая таким образом свою способность быть полученным из DependenciesProvider.

interface NetworkDependencies : ProvidableDependency {
  fun geOkHttpClient(): OkHttpClient
}

Далее добавим модуль с фичей contacts. В качестве зависимостей ему понадобятся модули core и network, а также okhttp и dagger. Внутри нужно добавить пустую ContactsActivity. Добавим InternalProvidableDependencies:ProvidableDependency по аналогии с NetworkDependencies. Также создадим интерфейс UnknownDependencies, который выступит в роли зависимости, которую невозможно получить из DependenciesProvider.

Затем Dagger компонент. Назовём его ContactsComponent. Компонент будет уметь делать inject() в ContactsActivity и будет иметь разнообразные зависимости. В качестве Dagger модулей выступят интерфейс InterfaceModule, класс с конструктором по умолчанию AllDefaultParametersModule и класс с обязательными параметрами RequiredParameterModule. В блоке dependencies нужно указать NetworkDependencies из модуля network, только что созданные InternalProvidableDependencies и UnknownDependencies. Не забываем применить к компоненту аннотацию @GenerateComponentFactory из модуля core.

@GenerateComponentFactory
@Component(
  modules = [
    RequiredParameterModule::class,
    AllDefaultParametersModule::class,
    InterfaceModule::class,
  ],
  dependencies = [
    NetworkDependencies::class,
    InternalProvidableDependencies::class,
    UnknownDependencies::class,
  ]
)
internal interface ContactsComponent {
  fun inject(activity: ContactsActivity)
}

Теперь окружение для решения задачи готово. Можем приступить к описанию модуля с KSP процессором.

Для KSP не нужно создавать модуль Android библиотеки. Достаточно сделать модуль с JVM плагином. Подключим к нему библиотеку KSP и Kotlin Poet для создания содержимого файлов. В модуле ksp-component-builder файл build.gradle будет выглядеть следующим образом:

plugins {
  id 'org.jetbrains.kotlin.jvm'
  id 'kotlin'
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
  kotlinOptions { jvmTarget = "11" }
}

dependencies {
  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20"
  implementation "com.google.devtools.ksp:symbol-processing-api:1.7.20-1.0.8"
  implementation "com.squareup:kotlinpoet:1.12.0"
  implementation "com.squareup:kotlinpoet-ksp:1.12.0"
}

Предлагаю сразу подключить вновь созданный модуль к модулю фичи contacts. Одной из особенностей KSP на текущем этапе развития является то, что среда разработки пока что не знает ничего о сгенерированном KSP коде, поэтому проекту нужно явно указывать, где сгенерированный код будет располагаться. В итоге build.gradle файл модуля contacts, как и любой модуль, к которому мы хотим подключить KSP, должен быть дополнен следующим кодом:

plugins {
    //...
    id 'com.google.devtools.ksp'
}

android {
    //...
    sourceSets {
        debug { java.srcDirs += "build/generated/ksp/debug/kotlin" }
        release { java.srcDirs += "build/generated/ksp/release/kotlin" }
    }
}

dependencies {
    //...
    ksp project(":ksp-component-builder")
}

Также существует возможность передавать собственные параметры, которые будут доступны каждому провайдеру, через свойство SymbolProcessorEnvironment.options.

ksp {
    arg("test_parameter_1", "false")
    arg("test_parameter_2", "100")
}

Перейдём к содержимому KSP модуля. Для начала определимся, какие данные требуется собрать. 

Dagger модули, не имеющие конструктора или не имеющие параметров в конструкторе, нужно пропустить. Dagger создаст их экземпляры самостоятельно, а пользователь никак не может модифицировать их. Если у Dagger модуля есть параметры, то нужно разделить их на два вида: с конструкторами без обязательных параметров и с обязательными. 

Если в конструкторе нет обязательных параметров, то такой модуль можно создать за пользователя, но дать ему возможность передать другой экземпляр модуля. Остальные модули пользователь должен передать явно.

Также нам понадобится информация о внешних зависимостях компонента. Их можно разделить на зависимости, которые мы можем получить из DependenciesProvider и зависимости, способ получения которых нам неизвестен.

Собирать будем KSType – он содержит всю возможную информацию об объекте и потребуется Kotlin Poet для генерации содержимого файлов. 

Итого у нас получится класс следующего вида:

data class ComponentAnnotationData(
  val providableDependencies: List<KSType>,
  val requiredDependencies: List<KSType>,
  val requiredModules: List<RequiredModule>,
) {
  data class RequiredModule(
    val type: KSType,
    val hasDefaultConstructor: Boolean,
  )
}

Как вы помните, при генерации файла нужно указывать исходные файлы, на основании которых он создаётся. Поэтому обернем вышеописанный класс в другой и добавим ещё немного информации, нужной для генерации фабрик. 

data class ComponentData(
  val containingFile: KSFile,
  val componentDeclaration: KSClassDeclaration,
  val annotationData: ComponentAnnotationData,
)

Пришло время добавить провайдер процессора. Для начала создадим его без реализации.

class ComponentFactoryProcessorProvider : SymbolProcessorProvider {
  override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
    TODO()
  }
}

Теперь важно не забыть подсказать KSP факт наличия этого провайдера. Создадим файл src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider. В его содержимом просто укажем полный путь к классу провайдера: com.example.ksp_component_builder.ComponentFactoryProcessorProvider. Теперь KSP сможет обратиться к провайдеру, чтобы создать процессор и запустить его.

Пора описывать сам процессор. Я предлагаю выделить в отдельный класс логику генерации файла, где будет, в основном, описана работа с Kotlin Poet.

class ComponentFactoryFileGenerator(
  private val codeGenerator: CodeGenerator,
)

Самому процессору понадобится не только генератор файлов, но и KSPLogger.

class ComponentFactoryProcessor(
    private val logger: KSPLogger,
    private val fileGenerator: ComponentFactoryFileGenerator,
) : SymbolProcessor

И, конечно же, не забываем создать процессор в провайдере.

internal class ComponentFactoryProcessorProvider : SymbolProcessorProvider {
  
  override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
    return ComponentFactoryProcessor(
      logger = environment.logger,
      fileGenerator = ComponentFactoryFileGenerator(
        codeGenerator = environment.codeGenerator,
      )
    )
  }
}

Мы подобрались к самому важному. Настало время начать анализ кода. Подливаем чай, разминаем шею и приступаем!

Первым делом нужно получить все файлы, помеченные аннотацией @GenerateComponentFactory. В рамках такой простой задачи можно обойтись и без валидации, но потренироваться стоит.

override fun process(resolver: Resolver): List<KSAnnotated> {
  val annotatedSymbols = resolver
    .getSymbolsWithAnnotation("com.example.core.GenerateComponentFactory")
    .groupBy { it.validate() }
  val validSymbols = annotatedSymbols[true].orEmpty()
  val symbolsForReprocessing = annotatedSymbols[false].orEmpty()

  return symbolsForReprocessing
}

Объекты, помеченные определённой аннотацией, можно получить через Resolver с помощью функции getSymbolsWithAnnotation(). Достаточно передать полный путь к классу аннотации. Есть и другие методы получения объектов для анализа. Все они размещены в интерфейсе Resolver. Чтобы не проходить список дважды, используем функцию groupBy и сгруппируем полученные объекты, используем функцию KSNode.validate(), которая, как говорилось ранее, проверяет, годится ли объект для анализа, или ему недостает каких-то данных. Не забываем вернуть невалидные объекты из функции, чтобы отложить их обработку на следующий раунд. 

Если вы посмотрите на реализацию метода validate(), то обратите внимание, что KSP позволяет использовать паттерн Visitor для анализа кода. Мы его использовать не будем, так как я не вижу причин для этого. Использование Visitor, на мой взгляд, не принесёт какой-то пользы в нашем случае, а только усложнит чтение решения.

Опираясь на знания о том, что аннотация @GenerateComponentFactory может быть применена только к классу, отфильтруем список валидных объектов по типу KSClassDeclaration. Обратите внимание, что KSClassDeclaration имеет свойство classKind, по которому можно понять, что под KSClassDeclaration понимаются не только классы, но и интерфейсы, классы аннотаций, перечисления и другие объекты.

val markedClassDeclarations = validSymbols.filterIsInstance<KSClassDeclaration>()

Начнём анализировать полученный список. Первое, что нужно сделать для каждого объекта – получить файл, в котором он объявлен. Мы будем использовать этот файл как основание для генерируемой фабрики. Свойство KSDeclaration.containingFile может быть равно null. Такая ситуация может возникнуть, если полученный объект объявлен в .class файле, то есть в уже скомпилированном коде. Например, если мы нашли некоторый Dagger компонент, получили его аннотацию @dagger.Component и перешли к её объявлению. Содержащего файла у такого объекта не будет, так как он объявлен в скомпилированной библиотеке. 

Для простоты мы просто будем пропускать объекты без файла.

private fun getComponentsData(markedClassDeclarations: List<KSClassDeclaration>): List<ComponentData> {
  val componentsData = mutableListOf<ComponentData>()
  for (componentDeclaration in markedClassDeclarations) {
    val containingFile = componentDeclaration.containingFile
    if (containingFile == null) {
      continue
    }
    // ...
  }
  return componentsData
}

Следующим шагом следует получить саму аннотацию @dagger.Component со всем содержимым. Давайте взглянем на блок кода и после разберёмся с каждым шагом.

private fun getDaggerComponentAnnotation(componentDeclaration: KSAnnotated): KSAnnotation? {
  return componentDeclaration.annotations
    .filter { annotation -> annotation.shortName.asString() == "Component" }
    .find { annotation ->
      annotation.annotationType
        .resolve()
        .declaration
        .qualifiedName
        ?.asString() == "dagger.Component"
    }
}

У объектов, реализующих KSAnnotated, есть свойство annotations, содержащее ссылки на типы аннотаций объекта. Чтобы получить полную информацию об аннотации, нужно из ссылки на тип получить сам тип, то есть вызвать функцию resolve(), что является дорогой операцией. 

Давайте попробуем сэкономить ресурсы. Отфильтруем другие аннотации, как минимум @GenerateComponentFactory, используя свойство KSAnnotated.shortName. Оно доступно без дополнительных действий. Чтобы окончательно убедиться, что аннотация с именем Component принадлежит Dagger, нужно получить KSType из ссылки на него и обратившись к объявлению прочитать полное имя класса. 

Если вдруг аннотации не нашлось, то мы просто пропустим такой класс и не будем считать ошибкой пометку какого-либо класса аннотацией @GenerateComponentFactory без аннотации @Component.

val componentAnnotation = getDaggerComponentAnnotation(componentDeclaration)
if (componentAnnotation == null) {
    continue
}

Теперь мы знаем, как получить информацию об аннотациях объекта. Давайте обработаем ещё один случай некорректного объявления. Если у Dagger компонента объявить фабрику компонента (@dagger.Component.Factory), то билдер для компонента сгенерирован не будет, а мы планируем использовать именно его в сгенерированных файлах. Нужно получить все объявления классов внутри компонента и проверить, нет ли среди них помеченного аннотацией @dagger.Component.Factory. Фактически одна и та же работа, но с дополнительным шагом.

private fun hasComponentFactory(componentDeclaration: KSClassDeclaration): Boolean {
  return componentDeclaration.declarations
    .filterIsInstance<KSClassDeclaration>()
    .any { childDeclaration ->
      childDeclaration.annotations
        .any { annotation ->
          annotation.annotationType
            .resolve()
            .declaration
            .qualifiedName
            ?.asString() == "dagger.Component.Factory"
        }
    }
}

Каждый объект, который может иметь вложенные объекты, реализует интерфейс KSDeclarationContainer, у которого есть свойство declarations. В нём вы найдёте объявления всех вложенных объектов: классов, функций, свойств и так далее. В нашем случае нам нужны только интерфейсы, поэтому отфильтруем список по типу KSClassDeclaration и проверим аннотации вложенных классов так же, как делали выше.

Предположим, что при обнаружении подобных компонентов нужно прервать работу процессора. Как и говорилось в теоретической части, для этого достаточно записать лог error уровня. Напоминаю, что работа будет остановлена не мгновенно, а только по окончании раунда, поэтому после логирования необходимо перейти к обработке следующего элемента.

val hasComponentFactory = hasComponentFactory(componentDeclaration)
if (hasComponentFactory) {
  val componentDeclarationName = componentDeclaration.qualifiedName?.asString()
    ?: componentDeclaration.simpleName.asString()
  logger.error("Remove @Component.Factory from '$componentDeclarationName'")
  continue
}

Мы уже проанализировали достаточно много разных случаев, но собрали совсем немного информации. Перейдем к анализу аннотаций. По контракту аннотации @dagger.Component параметрами аннотации выступают два массива объектов Class: dependencies и modules. Напишем функцию, которая сможет получить любой из этих параметров. 

private fun getComponentAnnotationParamValue(annotation: KSAnnotation, paramName: String): List<KSType> {
    val annotationArgument = annotation.arguments
        .find { argument -> argument.name?.asString() == paramName }
    val annotationArgumentValue = annotationArgument?.value as? List<KSType>

    return annotationArgumentValue.orEmpty().distinct()
}

Как видите, всё достаточно просто. У аннотации есть список аргументов, в котором мы ищем параметр по имени, потом берём значение параметра и делаем каст. Массиву объектов Class соответствует список KSType, что было выяснено экспериментально. Также будет полезно избавиться от дублирующихся значений, чтобы это не мешало при кодогенерации.

Для начала получим список классов из параметра dependencies и попробуем выделить среди них те, которые можно получить из DependenciesProvider. Для этого нужно проанализировать все супертипы класса и проверить, не является ли какой-либо из них ProvidableDependency, что, как мы договаривались, является маркером возможности получения из DependenciesProvider.

private fun getProvidableDependencies(allDependencies: List<KSType>): List<KSType> {
  return allDependencies
    .filter { dependencyType ->
      val declaration = dependencyType.declaration as KSClassDeclaration
      declaration.getAllSuperTypes()
        .map { it.declaration }
        .filterIsInstance<KSClassDeclaration>()
        .any { superTypeDeclaration ->
          superTypeDeclaration.qualifiedName?.asString() == "com.example.core.ProvidableDependency"
        }
    }
}

Опираясь на требования аннотации @dagger.Component мы можем смело делать каст поля declaration к KSClassDeclaration. Далее используем библиотечную функцию getAllSuperTypes() для рекурсивного получения всех супертипов класса. Помним, что это дорогая операция, но в такой ситуации другого выхода нет. Для каждого полученного типа проверяем его полное имя и таким образом понимаем, реализует ли класс требуемый интерфейс.

Остальные зависимости будем считать обязательными.

val dependencies = getComponentAnnotationParamValue(componentAnnotation, paramName = "dependencies")
val providableDependencies = getProvidableDependencies(dependencies)
val requiredDependencies = dependencies - providableDependencies

Впереди ещё много интересных нюансов. Переходим к параметру modules. Анализируя каждый модуль, нам следует отталкиваться от поведения Dagger. В случае если модуль не содержит параметров в конструкторе, то Dagger сам может создавать такие модули, и нам нет нужды передавать их. Таким образом, нет необходимости анализировать модули, которые не являются классами. 

private fun getRequiredModules(allModules: List<KSType>): List<ComponentAnnotationData.RequiredModule> {
  val requiredModules = mutableListOf<ComponentAnnotationData.RequiredModule>()
  for (moduleKSType in allModules) {
    val moduleDeclaration = moduleKSType.declaration
    if (moduleDeclaration !is KSClassDeclaration || moduleDeclaration.classKind != ClassKind.CLASS) {
      continue
    }
    //...
  }
  return requiredModules
}

Как уже упоминалось выше, для каждого KSClassDeclaration можно определить его тип, поэтому отсеиваем всё, что не является классом, проверяя поле classKind. Следующим шагом нужно отсеять классы с пустыми конструкторами. Их поведение невозможно модифицировать, поэтому можно положиться на Dagger.

val constructors = moduleDeclaration.getConstructors()
val isRequired = constructors.any { constructor -> constructor.parameters.isNotEmpty() }
if (!isRequired) {
  continue
}

Для KSClassDeclaration существует встроенная функция-расширение, позволяющая получить конструкторы. Опираясь на уже полученные знания, достаточно просто понять, как она работает. Берутся все вложенные объявления класса, фильтруются по типу функции и отбираются по специальному имени, которое имеет любой конструктор. Далее мы смотрим на список параметров конструктора. Если их нет, значит отсеиваем кандидата.

Последний шаг в работе с обязательными модулями – определение наличия конструктора с параметрами по умолчанию. Если такой есть, то мы можем создать класс за пользователя. Каждый параметр имеет свойство hasDefault, чтобы определить, имеет ли он значение по умолчанию.

private fun getRequiredModules(allModules: List<KSType>): List<ComponentAnnotationData.RequiredModule> {
  val requiredModules = mutableListOf<ComponentAnnotationData.RequiredModule>()
  for (moduleKSType in allModules) {
    //...
    val hasDefaultConstructor = constructors.any { constructor ->
      constructor.parameters.all { it.hasDefault }
    }

    requiredModules += ComponentAnnotationData.RequiredModule(
      type = moduleKSType,
      hasDefaultConstructor = hasDefaultConstructor,
    )
  }

  return requiredModules
}

Ну вот и всё, подготовка данных по одному классу закончена. Финальным шагом собираем информацию в подготовленный заранее контейнер. 

private fun getComponentAnnotationData(componentAnnotation: KSAnnotation): ComponentAnnotationData {
  val dependencies = getComponentAnnotationParamValue(componentAnnotation, paramName = "dependencies")
  val providableDependencies = getProvidableDependencies(dependencies)
  val requiredDependencies = dependencies - providableDependencies

  val modules = getComponentAnnotationParamValue(componentAnnotation, paramName = "modules")
  val requiredModules = getRequiredModules(modules)

  return ComponentAnnotationData(
    providableDependencies = providableDependencies,
    requiredDependencies = requiredDependencies,
    requiredModules = requiredModules,
  )
}

Далее к информации об аннотации добавляем данные исходного файла и объявления компонента, и мы готовы перейти к генерации кода!

private fun getComponentsData(markedClassDeclarations: List<KSClassDeclaration>): List<ComponentData> {
  val componentsData = mutableListOf<ComponentData>()

  for (componentDeclaration in markedClassDeclarations) {
    val containingFile = componentDeclaration.containingFile
    //...
    val componentAnnotation = getDaggerComponentAnnotation(componentDeclaration)
    //...
    val annotationData = getComponentAnnotationData(componentAnnotation)

    componentsData += ComponentData(
      containingFile = containingFile,
      componentDeclaration = componentDeclaration,
      annotationData = annotationData,
    )
  }
  return componentsData
}

Как вы помните, мы решили описать генерацию кода в отдельном классе. Код файла разбит на два основных метода. Первый отвечает за подготовку объекта FileSpec, являющегося частью библиотеки Kotlin Poet. Так как разбор работы Kotlin Poet не является темой статьи, то я оставляю вам этот участок на самостоятельное изучение. Основное же внимание уделим небольшой функции, реализующей запись в файл с использованием CodeGenerator из KSP.

fun generateFile(componentsData: ComponentData) {
  val fileSpec = getFileSpec(componentsData)
  write(fileSpec, componentData.containingFile)
}

private fun write(spec: FileSpec, source: KSFile) {
  val dependencies = Dependencies(aggregating = false, source)
  val fos = try {
    codeGenerator.createNewFile(
      dependencies = dependencies,
      packageName = spec.packageName,
      fileName = spec.name,
    )
  } catch (e: FileAlreadyExistsException) {
    e.file.outputStream()
  }

  OutputStreamWriter(fos, StandardCharsets.UTF_8).use(spec::writeTo)
}

Первое, что мы видим здесь – создание класса Dependencies, которому мы говорим, является ли файл агрегирующим, и указываем файлы, на основании которых генерируем новый файл. Если уже забыли, зачем нам это нужно – вернитесь к описанию логики инкрементальной обработки данных. 

Далее мы получаем OutputStream через метод CodeGenerator.createNewFile, передавая ему dependencies и дополнительную информацию о файле. Опираясь на то, что мы знаем об инкрементальной обработке данных и работе в несколько раундов, следует всегда рассчитывать на то, что мы можем находиться не в первом раунде, и анализировать грязные (dirty) исходники. Поэтому оборачиваем создание файла в try-catch и при возникновении FileAlreadyExistsException берем OutputStream существующего файла. После просто записываем содержимое файла. В нашем случае – через функцию класса FileSpec

Пришло время посмотреть на результат работы. 

/**
 * Файл сгенерирован [com.example.ksp_component_builder.ComponentFactoryProcessor]
 */
internal object ContactsComponentFactory {

  internal fun createComponent(
    unknownDependencies: UnknownDependencies,
    requiredParameterModule: RequiredParameterModule,
    allDefaultParametersModule: AllDefaultParametersModule = AllDefaultParametersModule(),
    networkDependencies: NetworkDependencies = DependenciesProvider.instance.provide(),
    internalProvidableDependencies: InternalProvidableDependencies = DependenciesProvider.instance.provide(),
  ): ContactsComponent {
    val builder = DaggerContactsComponent.builder()
      .unknownDependencies(unknownDependencies)
      .requiredParameterModule(requiredParameterModule)
      .allDefaultParametersModule(allDefaultParametersModule)
      .networkDependencies(networkDependencies)
      .internalProvidableDependencies(internalProvidableDependencies)
    return builder.build()
  }
}

Как видите, InternalProvidableDependencies и NetworkDependencies, которые можно получить из DependenciesProvider, достаются из него; AllDefaultParametersModule, имеющий все параметры по умолчанию, создаётся автоматически, но его можно переопределить; RequiredParameterModule, имеющий некоторые обязательные параметры, пользователь должен передать сам, как и UnknownDependencies, которые невозможно получить из DependenciesProvider.

Заключение

Вы проделали большую работу, дочитав статью до конца. Поздравляю вас и спасибо за терпение!

У вас, скорее всего, сформировалось общее понимание процесса работы KSP и способов анализа кода с его помощью. Если всё вышеописанное не улеглось у вас в голове сразу же, то не переживайте. Я, например, потратил далеко не один день на это. Надеюсь, что изложение было понятно и статья поможет вам в работе. Если увидите ошибку или решите, что какой-то информации в статье не достаёт, то смело пишите комментарий.

Ссылки

KSP на GitHub: github.com/google/ksp

О порядке работы KSP процессоров: github.com/google/ksp/issues/1043 

Как Kotlin код смоделирован в KSP: https://kotlinlang.org/docs/ksp-additional-details 

Сравнение KSP и KAPT: https://kotlinlang.org/docs/ksp-why-ksp.html#comparison-to-kapt 

Об инкрементальной обработке в KSP: https://kotlinlang.org/docs/ksp-incremental.html

Изменение логики валидации объектов: ​​https://kotlinlang.org/docs/ksp-multi-round.html#write-your-own-validation-logic 

Работа в несколько раундов: https://kotlinlang.org/docs/ksp-multi-round.html

Репозиторий с решением тестовой задачи: https://github.com/umpteenthdev/sample-ksp-component-builder 

Комментарии (2)


  1. visirok
    14.12.2022 01:14

    Спасибо за очень основательную и позновательную статью. Вы правы, требуеся много времени, чтобы понять назначение, область применения и значение KSP.

    Ваша статья для меня многое прояснила, нои породила множество вопросов. Постараюсь сам найти на них ответ.


    1. umpteenthdev Автор
      14.12.2022 08:20

      Рад, что статья оказалась вам полезна.