Kotlin Object Multiplatform Mapper

Вступление

Как AI видит Object Mapping

Как AI видит Object Mapping

Складывается такое впечатление, что дописать своё приложение для Android мне суждено не скоро. Каждый раз начиная писать новую версию (так как старая была написана не до конца, использовалась только мной, а через пару-тройку лет простоя проще написать заново) своего приложения, задуманного еще в 2012 году, я сталкиваюсь с ситуацией, что мне не хватает какого-то функционала и начинаю писать свои библиотеки для этого. В первую попытку это был свой ORM (UcaOrm 1, 2, 3). Во вторую KCron — KMP библиотека, реализующая Cron. И вот, начав следующую итерацию, я вновь в таком же положении. Но обо всем по порядку!

С чего все началось

В этот раз для разработки я выбрал Compose UI. Первым же делом я начал изучать, как все это сочетается с Room. Наткнулся на этот tutorial, и по началу все шло хорошо, до шага 9. Увидев такой код:

/**
* Extension function to convert [ItemDetails] to [Item]. If the value of [ItemDetails.price] is
* not a valid [Double], then the price will be set to 0.0. Similarly if the value of
* [ItemDetails.quantity] is not a valid [Int], then the quantity will be set to 0
*/
fun ItemDetails.toItem(): Item = Item(
    id = id,
    name = name,
    price = price.toDoubleOrNull() ?: 0.0,
    quantity = quantity.toIntOrNull() ?: 0
)

/**
* Extension function to convert [Item] to [ItemUiState]
*/
fun Item.toItemUiState(isEntryValid: Boolean = false): ItemUiState = ItemUiState(
    itemDetails = this.toItemDetails(),
    isEntryValid = isEntryValid
)

/**
* Extension function to convert [Item] to [ItemDetails]
*/
fun Item.toItemDetails(): ItemDetails = ItemDetails(
    id = id,
    name = name,
    price = price.toString(),
    quantity = quantity.toString()
)

мой глаз немного задергался и, имея огромный опыт работы с AutoMapper’ом, я задался вопросом:»Почему до сих пор нет альтернативы для KMP? ». А раз её нет, надо написать!

От идеи до начала реализации

Основной минус AutoMapper’а всем известен — это runtime. Чтобы не допустить проблем во время выполнения, надо тратить дополнительные силы на покрытие тестами. Однако, я очень часто встречал и другие проблемы его использования и даже написал AutoMapper.Analyzers.

Дабы избежать таких же проблем в своей новой библиотеке, я выбрал генерацию кода. Для KMP выбор невелик: либо устаревший KAPT, либо новый KSP. Конечно писать новую библиотеку на чем-то старом никакого смысла нет, и я принялся изучать KSP.

Он оказался не таким и сложным, и после пары опытов и формирования основных идей моей библиотеки, а так же, потратив некоторое время на придумывание названия, я создал новый repository KOMM.

KOMM

Основная идея библиотеки: генерация метода расширения для mapping’а одного класса в другой.

Предположим у нас есть исходный класс:

class SourceObject {

    val id = 150

    val intToString = 300

    val stringToInt = "250"
}

И класс-приёмник:

data class DestinationObject(
    val id: Int,
    val stringToInt: Int
) {
    var intToString: String = ""
}

Чтобы отобразить одно в другое, достаточно пометить класс-приёмник аннотацией KOMMMap:

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    val id: Int,
    val stringToInt: Int
) {
    var intToString: String = ""
}

В результате будет сгенерирован вот такой метод-расширение:

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    id = id,
    stringToInt = stringToInt.toInt()
).also { 
    it.intToString = intToString.toString()
}

Feature of KOMM

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

@KOMMMap(
    from = SourceObject::class,
    config = MapConfiguration(
        tryAutoCast = false
    )
)

В результате, при попытки отображения свойств без использования конвертера (о нем ниже), будет брошено исключение.

Конечно часто надо отображать свойства с одними именами в свойства с другими. Для этого в KOMM есть MapFrom аннотация:

class SourceObject {
    //...
    val userName = "user"
}

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    //...
    @MapFrom("userName")
    val name: String
)

Важное замечание!

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

@KOMMMap(
    from = FirstSourceObject::class
)
@KOMMMap(
    from = SecondSourceObject::class
)
data class DestinationObject(
    @MapFrom("userId", [SecondSourceObject::class])
    val id: Int
)

Для остальных будут применяться настройки по умолчанию.

Для особого конвертирования можно использовать конвертер. Например:

class CostConverter(source: SourceObject) : KOMMConverter(source) {

    override fun convert(sourceMember: Double) = "$sourceMember ${source.currency}"
}

class SourceObject {
    val cost = 499.99
}

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    @MapConvert(CostConverter::class)
    val cost: String
) {
    @MapConvert(CostConverter::class, "cost")
    var otherCost: String = ""
}

У конвертеров в качестве указателя, для какого именно источника применить аннотацию, выступает generic тип.

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

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    cost = CostConverter(this).convert(cost)
).also { 
    it.otherCost = CostConverter(this).convert(cost)
}

Как видно, весь объект-источник передается как свойство класса конвертера, а в метод конвертации передается еще и свойство-источник.

В случае, если свойство, объявленное через конструктор, не может быть получено из объекта-источника, его нужно пометить аннотацией с resolver’ом. Аннотацию можно применять и к свойствам, объявленным вне конструктора:

class DateResolver(destination: DestinationObject?) : KOMMResolver(destination) {
    
    override fun resolve(): Date = Date.from(Instant.now())
}

@KOMMMap(from = SourceObject::class)
data class DestinationObject(
    @MapDefault(DateResolver::class)
    val activeDate: Date
) {
    @MapDefault(DateResolver::class)
    var otherDate: Date = Date.from(Instant.now())
}

На выходе будет:

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    activeDate = DateResolver(null).resolve()
).also { 
    it.otherDate = DateResolver(it).resolve()
}

Обратите внимание, что в качестве параметра конструктора resolver’а мы получаем либо null — если отображаемое свойство объявлено в конструкторе, либо же уже существующий объект-приёмник.

Так же поддерживается null substitute:

class IntResolver(destination: DestinationObject?): KOMMResolver(destination) {

    override fun resolve() = 1
}

data class SourceObject(
    val id: Int?
)

@KOMMMap(
    from = SourceObject::class
)
data class DestinationObject(
    @NullSubatitute(MapDefault(IntResolver::class))
    val id: Int
) {
    @NullSubatitute(MapDefault(IntResolver::class), "id")
    var otherId: Int = 0
}

//...

fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject(
    id = id ?: IntResolver(null).resolve()
).also {
    it.otherId = id ?: IntResolver(it).resolve()
}

Есть еще несколько настроек и features (как поддержка Java объектов с их get-методами, например), но статья и так уже вышла очень длинной, так что о них можно почитать в README.

Что дальше?

В планах еще несколько улучшений и доработок.

Еще есть мысли на обратное отображение, вроде как MapTo, но тут начинается проблема с Java объектами. Так что пока хорошего решения я не придумал.

Так же в скором будущем библиотека будет доступна через Maven Central репозиторий.

Буду благодарен за любые идеи по развитию библиотеки или contributing'а.

© Habrahabr.ru