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

Каждый разработчик, работавший с проектом, где одни модели мапятся в другие, знает эту боль: добавил поле в модель и пошёл обновлять десятки мапперов вручную. Переименовал параметр - компилятор взрывается ошибками, и ты снова правишь код руками. Мапперы - это “мёртвая зона”: они пишутся механически, но при этом легко ломаются и не приносят никакой ценности бизнесу.

Мы решили избавиться от этой рутины раз и навсегда и сделали библиотеку - генератор кода маппинга на основе KSP. В этой статье я расскажу, как мы пришли к такому решению, как устроена библиотека изнутри и с какими подводными камнями столкнулись.

Проблема

В типичном Android-проекте моделей больше, чем хочется признавать:

  • сетевая модель (DTO из API),

  • модель для слоя данных,

  • модель бизнес-логики,

  • модель для UI.

Между ними постоянно нужен маппинг. Пока классов два-три - это терпимо. Когда их десятки, ручное написание мапперов превращается в источник когнитивной нагрузки, багов и ошибок сборки:

  • забыл прокинуть новое поле - получаешь дефолтное значение или просто ошибку компиляции там, где не ждешь;

  • переименовал поле - компилятор не поможет, если типы совпадают, а смысл - нет;

  • мапперы растут как снежный ком и их мало кто покрывает тестами.

При этом сам код маппера почти всегда выглядит одинаково: “возьми поле из одного класса и положи в конструктор другого”. Это идеальный кандидат для автоматизации.

Почему именно KSP

KSP - это инструмент для анализа исходного кода на этапе компиляции. В отличие от рефлексии, он работает на этапе компиляции, без рантайм-оверхеда, и отлично подходит для генерации кода. По сути библиотека делает то же, что Dagger2 делает для внедрения зависимостей, но для маппинга: аннотируешь, а дальше всё генерируется автоматически. Возможно, моя любовь к Dagger2 тоже стала одной из причин для этой библиотеки.

Как это работает

Всё начинается с аннотации CastCastleMapper. В начале написания проекта я задался целью сделать так, чтобы вся библиотека работала через единственный маркер - так понадобится минимум документации (без всяких комбо Assisted-AssistedInject-AssistedFactory, как в даггере), в которой проще будет разобраться команде.

Ею помечается либо класс/интерфейс/объект-маппер, либо отдельная standalone-функция.

Дальше в дело вступает KSP-процессор.

Пайплайн процессора

Процессор состоит из трёх логических частей:

Первая: сборка моделей. ComponentsResolver через Resolver (часть KSP API) находит все символы (символ - это абстрактное представление любой декларации в коде Kotlin, такое, как класс, интерфейс, функция, свойство, параметр или конструктор) с аннотацией и раскладывает их по двум корзинам: классы-мапперы и отдельные функции. Для каждой функции-маппера определяется исходный типа (параметр или receiver для extension-функций) и тип возвращаемого значения. Далее в каждом используемом типе получаем списки полей. Важно, для Kotlin используется именно первичный конструктор, потому что для data-классов он является обязательным, и поэтому для того, чтобы логика была единообразной, остальные конструкторы не рассматриваются. Хочешь сделать модельку не data-классом - сделай нормальный первичный конструктор. Для Java брался конструктор с самым меньшим количеством параметров.

Вторая: генерация текста. KotlinPoetSpecGenerator рекурсивно сопоставляет поля исходного типа и целевого по именам:

  • поле есть в обоих классах - просто копируется;

  • поля с одинаковым именем, но разными типами - рекурсивно ищется маппер в рассматриваемом символе или строится конструктор целевого типа;

  • поля, которых нет в исходном, превращаются в дополнительные параметры сгенерированной функции - особая фича библиотеки;

  • коллекции мапятся поэлементно через forEach { add(...) }, т.к. в map в Kotlin именно эта конструкция вызывается внутри, а значит, накладные расходы на map не нужны.

Третья: запись файла. FileWriter записывает cгенерированный текст через CodeGenerator и KotlinPoet в файл ИмяМаппераCastCastle.kt (или общий StandaloneFunctions.kt для standalone-функций).

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

Как выглядит результат

Допустим, есть две модели:

data class A(val first: Int, val second: Int)
data class B(val first: Int, val third: Int)

@CastCastleMapper
class AdditionalFieldsMapper {
    @CastCastleMapper
    fun map(a: A): B = mapCastCastle(a, 1)
}

Поля совпадают только частично (по first), а third в одном из них нет - процессор генерирует недостающее поле параметром функции.

public fun AdditionalFieldsMapper.mapCastCastle(
    a: ru.vafeen.samples.sample3.kotlin.A,
    third: kotlin.Int
): ru.vafeen.samples.sample3.kotlin.B {
    return ru.vafeen.samples.sample3.kotlin.B(
        first = a.first,
        third = third
    )
}

Что библиотека умеет

За время работы с библиотекой мы накопили приличный список сценариев, которые покрываются автоматически:

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

  • Поддерживаются коллекции List, Set, смешанные комбинации (List <-> Set), вложенные коллекции List<List<X>> и их мутабельные производные.

  • Недостающие поля становятся параметрами функции.

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

  • Extension-функции - маппер как extension, включая standalone-функции.

  • Companion object - мапперы как расширения для companion.

  • Java: совместимость Java-конструкторов и getter’ов.

  • Standalone-функции - вообще без классов, просто @CastCastleMapper fun mapA(a: A): B.

Подводные камни

Главный вывод, который мы вынесли из разработки: KSP - это мощно, но дьявол в деталях. Вложенные дженерики всё сломали.

Когда мы работали с маппингом List<List<X>>, библиотека сгенерировала вот это:

mutableListOf<List>().apply {
    source.forEach { it -> add(it) }
}

Причина была в KSP2: при резолве вложенного типа-аргумента через KSTypeReference.resolve() терялись его собственные аргументы - List<List<X>> стирался до сырого List. Поэтому мы перешли на извлечение аргументов из KSTypeReference.element (синтаксис типа), а не из резолва.

Вторая проблема - генерация строк типов. Сначала мы строили сигнатуру коллекции, парся строку регуляркой:

val regex = "<([^>]+)>".toRegex()

Она работала для List<X>, но отрезала закрывающую > у вложенных дженериков, ломая скомпилированный код. Пришлось писать свой парсер из вложенных типов.

Вывод простой: если генерируешь код, работай с структурой, а не со строками.

Что это даёт команде

Главная ценность - не скорость написания мапперов, а снятие ответственности за их поддержку. Изменение модели (добавление, удаление, переименование поля) больше не требует обновления всех зависимых файлов: при следующей сборке мапперы перегенерируются сами.

Дополнительные плюсы:

  • сгенерированный код - обычный Kotlin;

  • ноль рантайм-оверхеда - в приложение попадает только сгенерированный код.

P.S. Мы стали использовать его в проекте с самой первой версии: несколько сценариев покрыли сразу, в паре штук получили ошибку - оставили, как было, пометив себе, чтобы исправить к следующему релизу либы.

Ограничения

Библиотека не умеет читать мысли (к сожалению) и техдок, и не может определить, какое поле к какому мапить, если они разные - маппинг идёт по совпадению имён полей - если имена расходятся, нужно писать маппер вручную;

Map-коллекции пока не поддерживаются;

Generic-классы с собственными параметрами типов требуют доработки - я не уверен, что все кейсы учтены.

Это сознательные компромиссы: мы закрываем большую часть сценариев простым API, а сложные случаи оставляем на мануальный осмотр.

Заключение

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

Если вы тоже устали править мапперы руками - попробуйте перенести эту боль на компилятор. Он не устаёт и не забывает. Как говорит мой хороший друг Николай П “Быть хорошим роботом легко. А вот человеком…”.


Исходный код: github.com/vafeen/Cast-Castle. Документация и примеры - в репозитории.