Объектное отображение в Kotlin — плюсы и минусы


Что такое отображение объектов?

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

Простой пример

Допустим, у нас есть приложение REST API с двумя моделями. Одна для сервера, другая для клиента:

Пользователь (сервер):

data class User(
    val id: String,
    val name: String,
    val email: String,
    // isBlocked is server side only
    val isBlocked: Boolean
)
Войти в полноэкранный режим Выйти из полноэкранного режима

UserDTO (клиент):

data class UserDTO(
    val id: String,
    val name: String,
    val email: String
)
Войти в полноэкранный режим Выход из полноэкранного режима

Конвертация

У нас есть несколько вариантов:

Методы расширения

Мы можем использовать простые методы расширения.

fun User.toUserDTO(): UserDTO {
    return UserDTO(id, name, email)
}
Вход в полноэкранный режим Выйти из полноэкранного режима

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

Сопоставление объектов

Для примеров мы будем использовать библиотеку отображения объектов с открытым исходным кодом ShapeShift.

Аннотации

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

@DefaultMappingTarget(UserDTO::class)
data class User(
    @MappedField
    var id: String = "",
    @MappedField
    var name: String = "",
    @MappedField
    var email: String = "",
    // isBlocked is server side only and not mapped to client DTO
    var isBlocked: Boolean = false
)
Войти в полноэкранный режим Выход из полноэкранного режима

Осталось только конвертировать.

val shapeShift = ShapeShiftBuilder().build()
val user = User("xyz", "john doe", "john@email.com", false)
val userDTO = shapeShift.map<UserDTO>(user)
Вход в полноэкранный режим Выход из полноэкранного режима

DSL

В некоторых случаях мы не можем изменить код классов данных (или не хотим этого делать). Для таких случаев мы создаем отдельный связующий элемент между двумя классами, используя Kotlin DSL.

val mapper = mapper<User, UserDTO> {
    User::id mappedTo UserDTO::id
    User::name mappedTo UserDTO::name
    User::email mappedTo UserDTO::email
}
Вход в полноэкранный режим Выход из полноэкранного режима

А теперь преобразование. Обратите внимание, что мы регистрируем маппер на экземпляр ShapeShift.

val shapeShift = ShapeShiftBuilder().withMapping(mapper).build()
val user = User("xyz", "john doe", "john@email.com", false)
val userDTO = shapeShift.map<UserDTO>(user)
Вход в полноэкранный режим Выход из полноэкранного режима

Кажется, что много работы для простого преобразования, верно? Возможно… Давайте перейдем к следующему примеру.

Автоматическое отображение

Что, если я скажу вам, что есть способ удалить почти весь код, если не весь код с шаблонами отображения? Во многих случаях использования у нас есть схожие классы, между которыми нам нужно установить связь. Именно здесь на помощь приходит автоматическое отображение.

Давайте продолжим с теми же классами.

data class User(
    val id: String,
    val name: String,
    val email: String,
    val isBlocked: Boolean
)

data class UserDTO(
    val id: String,
    val name: String,
    val email: String
)
Вход в полноэкранный режим Выход из полноэкранного режима

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

val mapper = mapper<User, UserDTO> {
    autoMap(AutoMappingStrategy.BY_NAME_AND_TYPE)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вуаля! Никакого шаблонного кода для настройки полей вручную. Автоматическое отображение также поставляется с расширенными функциями для более сложных случаев использования.

Общий пример

Когда наши проекты начинают масштабироваться, мы хотим добавить общую функциональность для повышения производительности и снижения затрат на обслуживание. Например:

  • Общий CRUD-контроллер для сущностей.
  • Общий экспорт в excel/pdf/…

Чтобы мы могли реализовать эти общие функциональные возможности, нам нужно иметь возможность преобразовывать наши объекты из одного класса в другой.

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

Другой вариант (сюрприз-сюрприз) — использовать отображение объектов.

inline fun <reified ExportModel : Any> export(model: Any) {
    val shapeShift = ShapeShiftBuilder().build()
    val exportModel = shapeShift.map<ExportModel>(model)
    // export logic...
}

val user = User(/*...*/)
export<UserExport>(user)
Вход в полноэкранный режим Выход из полноэкранного режима

Вот и все. Ноль кодового кода, и мы получили общую функциональность, которую мы знаем и любим.

Безопасность типов

Давайте пойдем дальше и добавим безопасность типов в метод экспорта. Для этого мы добавим два интерфейса:

interface ExportModel {
}

interface BaseModel<EM: ExportModel> {
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы обновим наши классы, чтобы они реализовали эти интерфейсы.

class UserExport: ExportModel {
    // ...
}

class User: BaseModel<UserExport> {
    // ...
}
Вход в полноэкранный режим Выйти из полноэкранного режима

И изменим наш общий метод соответственно.

inline fun <reified EM : ExportModel> export(model: BaseModel<EM>) {
    val shapeShift = ShapeShiftBuilder().build()
    val exportModel = shapeShift.map<EM>(model)
    // export logic...
}
Войти в полноэкранный режим Выход из полноэкранного режима

Вот и все. Мы получили безопасный типовой метод экспорта.

val user = User(/*...*/)
export(user)
Войти в полноэкранный режим Выход из полноэкранного режима

Пример Spring

Поднимаемся на следующий уровень. У нас есть три модели.

  • Работа — сущность БД.
  • Пользователь — сущность БД.
  • UserDTO — модель клиента.
@Entity
@Table(name = "jobs")
class Job {
    var id: String = ""
    var name: String = ""
}

@Entity
@Table(name = "users")
class User {
    var id: String = ""
    var jobId: String? = null
}

class UserDTO {
    var id: String = ""
    var jobName: String? = null
}
Вход в полноэкранный режим Выход из полноэкранного режима

Нам нужно преобразовать jobId на User в jobName на UserDTO, запросив задание из БД и установив его на DTO.

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

На помощь приходит объектное отображение (сюрприз!). Давайте создадим пользовательский трансформатор, который сделает за нас всю тяжелую работу.

@Component
class JobIdToNameTransformer(
    private val jobDao: JobDao
) : MappingTransformer<String, String>() {
    override fun transform(context: MappingTransformerContext<out String>): String? {
        context.originalValue ?: return null
        val job = jobDao.findJobById(context.originalValue!!)
        return job.name 
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Сложная часть уже позади. Теперь нам просто нужно добавить аннотации к модели User.

@Entity
@Table(name = "users")
@DefaultMappingTarget(UserDTO::class)
class User {
    @MappedField
    var id: String = ""
    @MappedField(transformer = JobIdToNameTransformer::class, mapTo = "jobName")
    var jobId: String? = null
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

val shapeShift = ShapeShiftBuilder().build()
val user = User(/*...*/)
val userDTO = shapeShift.map<UserDTO>(user)
Вход в полноэкранный режим Выход из полноэкранного режима

Полный код весеннего примера доступен здесь.

Плюсы и минусы

У каждого метода преобразования есть свои плюсы и минусы.

Методы расширения

Плюсы

  • Простая реализация.
  • Понятный код — никакой «магии» аннотаций.

Минусы

  • Много «котлового» кода.
  • Ограниченная функциональность.
  • Не очень хорошо масштабируется.

Object Mapper

Плюсы

  • Мало или совсем нет кодового кода.
  • Автоматическое отображение.
  • Обобщенный код.
  • Расширенные возможности.
  • Повторное использование кода.

Минусы

  • Требуется изучение новой библиотеки.
  • Аннотации «волшебные».

Резюме

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

Оцените статью
Procodings.ru
Добавить комментарий