Практичные способы маппинга данных в Kotlin

в 17:18, , рубрики: android development, kotlin, layers, Алгоритмы, маппер, маппинг, Разработка под android

Маппинг данных – один из способов для разделения кода приложения на слои. Маппинг широко используется в Android приложениях. Популярный пример архитектуры мобильного приложения Android-CleanArchitecture использует маппинг как в оригинальной версии (пример маппера из CleanArchitecture), так и в новой Kotlin версии (пример маппера).

Маппинг позволяет развязать слои приложения (например, отвязаться от API), упростить и сделать код более наглядным.

Пример полезного маппинга изображен на схеме:

Практичные способы маппинга данных в Kotlin - 1

Нет нужды передавать все поля модели Person, если в интересующей нас части приложения нам нужно только два поля: login и password. Если для нас удобнее рассматривать Person как пользователя приложения, после маппига мы можем легко использовать модель с понятным нам именем.

Рассмотрим удобные и практичные методы маппинга данных на примере преобразования двух моделей Person и Salary из слоя Source в модели слоя Destination.

Практичные способы маппинга данных в Kotlin - 2

Для примера модели упрощены. Person содержит Salary в обоих слоях приложения.

В настоящем коде, если у вас одинаковые модели, возможно, стоит пересмотреть слои приложения и не использовать маппинг.

Метод №1: Методы-мапперы

Пример:

class PersonSrc(
    private val name: String,
    private val salary: SalarySrc
) {
    fun mapToDestination() = PersonDst(
        name, 
        salary.mapToDestination() // вызов маппера для Salary
    )
}

class SalarySrc(
    private val amount: Int
) {
    fun mapToDestination() = SalaryDst(amount)
}

Самый быстрый и простой метод. Именно он используется в CleanArchitecture Kotlin (пример маппинга).

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

Такой код быстрее писать и проще модифицировать – объявления полей и их использование находятся в одном месте. Не надо бегать по проекту и модифицировать разные файлы при изменении полей класса.

Однако, этот вариант сложнее тестировать. В методе-маппере класса PersonSrc прописан вызов метода-маппера SalarySrc. Значит протестировать маппинг только Person без маппинга Salary будет сложнее. Для этого придется использовать моки.

Еще проблема может возникнуть если по требованиям архитектуры слои приложения не могут знать друг о друге: т.е. в классе Src слоя нельзя работать со слоем Dst и наоборот. В этом случае такой вариант маппинга использовать не получится.

В рассмотренном примере слой Src зависим от слоя Dst и может создавать классы этого слоя. Для обратной ситуации (когда Dst зависим от Src) подойдет вариант со статическими методами-фабриками:

class PersonDst(
    private val name: String,
    private val salary: SalaryDst
) {
    companion object {
        fun fromSource(
            src: PersonSrc
        ) = PersonDst(src.name, SalaryDst.fromSource(src.salary))
    }
}

class SalaryDst(
    private val amount: Int
) {
    companion object {
        fun fromSource(src: SalarySrc) = SalaryDst(src.amount)
    }
}

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

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

Резюме метода маппинга:

+ Быстро писать код, маппинг всегда под рукой
+ Легкая модификация
+ Низкая связность кода
- Затруднено Unit-тестирование (нужны моки)
- Не всегда позволено архитектурой

Метод №2: функции-мапперы

Модели:

class PersonSrc(
    val name: String, 
    val salary: SalarySrc
)

class SalarySrc(val amount: Int)

class PersonDst(
    val name: String,
    val salary: SalaryDst
)

class SalaryDst(val amount: Int)

Мапперы:

fun mapPerson(
    src: PersonSrc,
    salaryMapper: (SalarySrc) -> SalaryDst = ::mapSalary // аргумент по-умолчанию
) = PersonDst(
    src.name,
    salaryMapper.invoke(src.salary)
)

fun mapSalary(src: SalarySrc) = SalaryDst(src.amount)

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

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

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

Резюме метода маппинга:

+ Простое Unit-тестирование
- Затруднена модификация
- Требуются открытые поля у классов с данными

Метод № 3: Функции-расширения

Мапперы:

fun PersonSrc.toDestination(
    salaryMapper: (SalarySrc) -> SalaryDst = SalarySrc::toDestination
): PersonDst {
    return PersonDst(this.name, salaryMapper.invoke(this.salary))
} 
fun SalarySrc.toDestination(): SalaryDst {
    return SalaryDst(this.amount)
}

В целом то же, что и функции-мапперы, но проще синтаксис вызова маппера: .toDestination().

При этом стоит учесть, что функции расширения могут приводить к неожиданному поведению из-за своей статической природы: https://kotlinlang.org/docs/reference/extensions.html#extensions-are-resolved-statically

Резюме метода маппинга:

+ Простое Unit-тестирование
- Затруднена модификация
- Требуются открытые поля у классов с данными

Метод №4: Классы-мапперы с интерфейсом

У примеров с функциями есть недостаток. Они позволяют в качестве маппера использовать любую функцию с сигнатурой (SalarySrc) -> SalaryDst. Сделать код более очевидным поможет наличие интерфейса Mapper<SRC, DST>.

Пример:

interface Mapper<SRC, DST> {
    fun transform(data: SRC): DST
}

class PersonMapper(
    private val salaryMapper: Mapper<SalarySrc, SalaryDst>
) : Mapper<PersonSrc, PersonDst> {
    override fun transform(src: PersonSrc) = PersonDst(
        src.name,
        salaryMapper.transform(src.salary)
    )
}

class SalaryMapper : Mapper<SalarySrc, SalaryDst> {
    override fun transform(src: SalarrSrc) = SalaryDst(         
        src.amount
    )
}

В данном примере SalaryMapper – зависимость PersonMapper. Это позволяет удобно подменять маппер Salary для unit-тестов.

Относительно маппинга в функции у этого примера только один недостаток – необходимость писать немного больше кода.

Резюме метода маппинга:

+ Лучше типизация
- Больше кода

Как и функции-мапперы:

+ Простое Unit-тестирование
- Затруднена модификация
- требует открытые поля у классов с данными

Метод 5: Рефлексия

Метод черной магии. Рассмотрим этот метод на других моделях.

Модели:

data class EmployeeSrc(
    val firstName: String,
    val lastName: String,
    val age: Int
    // много других полей
)

data class EmployeeDst(
    val name: String, // одно поле, а не два
    val age: Int
    // много других полей
)

Маппер:

fun EmployeeSrc.mapWithRef() = with(::EmployeeDst) {
    val propertiesByName = EmployeeSrc::class.memberProperties.associateBy { it.name }
    callBy(parameters.associateWith { parameter ->
        when (parameter.name) {
            EmployeeDst::name.name -> "$firstName $lastName" // маппит только поле name
            else -> propertiesByName[parameter.name]?.get(this@mapWithRef) // остальные поля без изменений
        }
    })
}

Пример подсмотрен тут.

В данном примере EmployeeSrc и EmployeeDst хранят имя в разных форматах. Мапперу нужно только составить имя для новой модели. Остальные поля обработаются автоматически, без написания кода (вариант else в when).

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

Большая проблема возникнет, например, если вы добавите обязательные поля в Dst и его случайно не окажется в Src или в маппере: cлучится IllegalArgumentException в runtime. Также рефлексия имеет проблемы с производительностью.

Резюме метода маппинга:

+ меньше кода
+ простое Unit-тестирование
- опасен
- может негативно сказаться на производительности

Выводы

Такие выводы можно сделать из нашего рассмотрения:

Методы-мапперы — наглядный код, быстрее писать и поддерживать

Функции-мапперы и функции расширения – просто тестировать маппинг.

Классы мапперы с интерфейсом — просто тестировать маппинг и яснее код.

Рефлексия – подходит для нестандартных ситуаций.

Автор: Oleg Koltunov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js