Как стать автором
Обновить

Комментарии 15

Непонятно почему для расширерий-мапперов обязательно нужны публичные свойства — можно сделать просто конструктор в целевом объекте. И тогда недостатков у расширений, вроде как, не остаётся

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

Обычно маппинг происходит между моделями разных слоев. А значит они не имеют доступа к приватным свойствам друг друга. Да и по-хорошему вообще ничего не должны знать друг о друге, иначе получится так, что какая-то вьюха (фрагмент или активити) будет зависеть от моделей API слоя, или наоборот, модели из API слоя будут зависеть от вьюх, и то и другое – очень плохо.
fun SourceUser.mapToTargetUser(): TargetUser {
    return TargetUser(this.name, this.age, this.address)
}

data class SourceUser(
    val name: String,
    val age: Int,
    val address: String
)

class TargetUser(
    private val name: String,
    age: Int,
    val address: String
) {
    var login = name
        private set
    
    private val birthDate = Utils.parseAge(age)
}


Здесь:
В исходном классе все свойства неизменяемы. В целевом классе наружу видны только неизменяемые извне address, login, birthDate.

PS: Пример дан исключительно для иллюстрации моих слов в случае, когда по какой-то причине не все инициализируемые свойства нужно показывать вне объекта. Лучше все же сделать все свойства видимыми, но неизменяемыми — тогда вся связь между слоями будет содержаться в маппере слоев. А здесь уже никакого криминала.
Непонятно почему для расширерий-мапперов обязательно нужны публичные свойства

Так, у Вас в коде в дата-классе SourceUser все свойства публичные, как и было описано для этого подхода.

И да, это, пожалуй, самый красивый подход для маппинга.
Конструктор целевого объекта будет «знать» только про свою модель и, возможно, некие абстрактные свойства, из которых он свою модель построит (как в моем примере ниже). По-хорошем, это все стоит упростить, и оставлять в конструкторе только поля самого объекта, а все преобразования выполнять в маппре. Тогда связкой слоев будет только маппер, что вполне нормально.
Не знаю насколько применим MapStruct для котлина, но для Java он очень хорош и никакой магии в отличие от reflection.
Почему-то ничего не сказано про маппинг через промежуточный слой, напр. json|xml
Это уже называется serialization или маршалинг. Мы не можем его использовать, чтобы мапить модели слоя представления на модели бизнес логики или API модели. В подавляющем большинстве случаев эти модели имеют разную структуру.
Маршалинг же в чистом виде требует чтобы модель источника и приемника была одинаковой по структуре.
Да никто и не требует, чтобы сериализация/маршалинг были зеркальнымы, какие нужно свойства, те и мапте, можно с изменением структуры и названий
В пюсах — классы вообще ничего не знают друг о друге, могут работать в различных процессах-машинах, лёгкое тестирование. В минусах — скорость.
Метод №1: Методы-мапперы

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

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


В этом подходе связанность между моделями максимально высокая. Вы в примере показали, что класс PersonSrc знает про класс PersonDst и наоборот. На более сложной структуре данных эта связанность будет кошмарной.
class PersonSrc(private val name: String, private val salary: SalarySrc) {

    fun mapToDestination() = PersonDst(name, salary.mapToDestination())
}

class SalarySrc(private val amount: Int) {

    fun mapToDestination() = SalaryDst(amount)
}


Тут все же Высокая связность кода, и как раз из-за этой высокой связности вытекает сложность в тестировании.

ps: каким образом моки помогут в тестировании маппинга моделей?
Вы в примере показали, что класс PersonSrc знает про класс PersonDst и наоборот. На более сложной структуре данных эта связанность будет кошмарной.

Вовсе нет. Я показал один пример, когда Src зависим от Dst, и другой пример, когда Dst зависим от Src. Это разные примеры. Конечно, когда они зависимы друг от друга – это антипример будет.


Про низкую связанность:
Во-первый, ограничение видимости полей класса ведет к уменьшению связанности кода. В примере с методами как раз можно ограничить видимость. Не очень хорошо, когда маппинг заставляет делать поля открытыми.


Во-вторых, давайте сравним такие варианты кода.


Вариант А:


// файл 1
class EntitySrc(private val id: Int) {
    fun mapToDst(src: EntitySrc) = EntityDst(src.id)
}

// файл 2
class EntityDst(val id: Int)

Вариант Б:


// файл 1
class EntitySrc(val id: Int)

// файл 2
class EntityDst(val id: Int)

// файл 3
fun mapEntity(src: EntitySrc) = EntityDst(src.id)

В варианте А EntitySrc зависит от EntityDst: итого 1 зависимость.
В варианте Б mapEntity зависит от EntitySrc и от EntityDst: итого 2 зависимости.


Т.е. слои, конечно, в варианте Б лучше выражены, но весь код в итоге более связан.


Чтобы быть ближе к практике, можно рассмотреть типичную задачу: добавление одного поля и пробрасывание его через все слои. Задача возникает постоянно. В варианте А надо модифицировать только 2 файла, в варианте Б, надо модифицировать 3 файла. Меньше связей – меньше работы для модификации.


ps: каким образом моки помогут в тестировании маппинга моделей?

Если мы используем методы-мапперы и хотим протестировать только один маппер нам надо написать такой код:


internal class PersonSrcTest {
    @Test
    fun mapToDestination() {
        val salarySrc: SalarySrc = mock(SalarySrc::class.java)
        `when`(salarySrc.mapToDestination()).thenReturn(SalaryDst(0))
        val personSrc = PersonSrc("Somebody", salarySrc)
        val mapped = personSrc.mapToDestination()

        assertEquals("Somebody", mapped.name)
    }
}

Моки нужны чтобы при тестировании PersonSrc.mapToDestination не создавать настоящий объект SalarySrc и не вызывать SalarySrc. mapToDestination. И то и другое сделают этот тест избыточным.

ограничение видимости полей класса ведет к уменьшению связанности кода.

Связность кода определяется через зависимости: чем больше один модуль зависит от классов другого – тем больше связность, и на оборот.
upload.wikimedia.org/wikipedia/commons/9/9c/Coupling_sketches_cropped_1.svg

Наличие публичных пропертей, торчащих из модели наружу – никоим образом не сказывается на связности между модулями.

С открытыми пропертями другая «проблема» – мы делаем «глупые» объекты передачи данных (DTO).
– Это плата за слабую связанность кода и возможность его переиспользовать.

Яркий пример: если для того, чтобы перетянуть визуальный компонент в другой проект необходимо каким-либо образом изменять код визуального компонента – значит у компонента высокая связность с кодом проекта, аналогично и с API слоем, и с бизнес слоем.

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

В примере с Вариант А при переносе компонента в другой проект придется удалить все методы отвечающие за маппинг в другую модель, т.е. изменить код, который был скопирован. Это не сложно, но это и есть факт наличия связности между модулями.

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

В варианте А EntitySrc зависит от EntityDst: итого 1 зависимость.
В варианте Б mapEntity зависит от EntitySrc и от EntityDst: итого 2 зависимости.

Т.е. слои, конечно, в варианте Б лучше выражены, но весь код в итоге более связан.

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

При этом, компоненты остаются независимыми друг от друга и от проекта.

Меньше связей – меньше работы для модификации.

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

Если нужно добавить 1 пропертю, но при этом сохранить компоненты независимыми, нужно добавить эту пропертю в каждый из компонентов и в связующий их код (сделать 3 правки).

Но стоит отметить:
1. Связность имеет более широкий спектр градации: en.wikipedia.org/wiki/Coupling_(computer_programming)
2. Далеко не всем проектам необходимо достигать низкий уровень связности – иногда это бывает просто бессмысленной тратой ресурсов (для поддержания слабой связности, нужно тратить больше времени/денег)
Моки нужны чтобы при тестировании PersonSrc.mapToDestination не создавать настоящий объект SalarySrc и не вызывать SalarySrc. mapToDestination. И то и другое сделают этот тест избыточным.

Тестирование – это отдельный момент, на который хотелось бы обратить внимание.

Для начала стоит определиться с тем, что такое PersonSrc и SalarySrc: это DTO или это умные доменные объекты с бизнес-логикой.

• Если это модели в которых есть бизнес логика, то у нас уже речь про явно сильно связанную систему и тут mock-объекты уместны.
С моей точки зрения, системы с «умными» моделями – очень тяжело разрабатывать и поддерживать. В этом случае я задал бы лишь один вопрос: «почему был выбран такой путь?» :)

• Если же, эти классы – это просто DTO, то наши проблемы с тестированием из-за того, что мы в DTO засунули ответственность не только за передачу данных, но и за маппинг (нарушение SRP).

В случае, когда мы тестируем маппинг, мы должны тестировать его полностью. Абсолютно нету смысла разбивать его на части, у нас нету (и не может быть) вариативности в ожидаемом результате.
Единственное что должны проверять тесты для мапперов:
— в правильные ли поля были положены данные
— что будет в случае если у нас null

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

Использование моков – довольно громоздкий подход для тестирования мапперов, хотя его тоже можно использовать.
Но в случае с моками обязательно следует проверять вызывался ли конкретный метод и содержит ли результирующая модель тот объект, который вернул mock.
Все эти рассуждения о связанности имеют смысл только для полноценных объектов в понимании ООП. Т.е. с внутренним состоянием и ответственностью за него. Между тем маппинг практически всегда происходит между структурами(DTO, Entity), а не полноценными объектами. Так что нет никакой разницы — конструктор, метод или функция, никакой разницы здесь не будет.
И да — для полноценного объекта вытаскивать всё состояние наружу через геттеры это плохой подход, так делать нельзя.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории