Что такое отображение объектов?
Многоуровневые приложения часто имеют похожие, но разные объектные модели, где данные в двух моделях могут быть похожи, но структура и задачи моделей различны.
Написание кода отображения является утомительной и чреватой ошибками задачей. Объектное отображение позволяет легко преобразовать одну модель в другую.
Простой пример
Допустим, у нас есть приложение 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
Плюсы
- Мало или совсем нет кодового кода.
- Автоматическое отображение.
- Обобщенный код.
- Расширенные возможности.
- Повторное использование кода.
Минусы
- Требуется изучение новой библиотеки.
- Аннотации «волшебные».
Резюме
Как и в большинстве случаев в разработке, здесь нет правильного ответа. Ответ заключается в том, что это зависит от проекта. Для небольших простых проектов методов расширения более чем достаточно для преобразования классов, но для больших корпоративных проектов использование объектного маппера может дать свободу для достижения лучшей архитектуры и более универсального кода, который может продолжать двигаться вперед с большой кодовой базой и большим количеством моделей.