Я 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. Документация и примеры - в репозитории.