При работе с многоуровневыми приложениями, внешними библиотеками, унаследованной кодовой базой или внешними API нам часто приходится создавать карты между различными объектами или структурами данных.
В этом руководстве мы рассмотрим некоторые расширенные возможности библиотек Object Mapping, которые упрощают эту задачу и экономят время разработки и сопровождения.
В наших примерах мы будем использовать библиотеку ShapeShift. Это легкая библиотека отображения объектов для Kotlin/Java с множеством интересных функций.
Автоматическое отображение
Мы начнем с самого главного. Автоматическое отображение может и сэкономит вам много времени и кода. В некоторых приложениях требуется ручное отображение объектов, но большинство приложений сэкономит массу времени, работая над скучным шаблонным кодом, просто используя эту функцию. И это становится еще лучше: с помощью стандартных трансформаторов ShapeShift мы можем даже использовать автоматическое отображение между различными типами данных.
Простое отображение
Давайте начнем с простого примера автоматического отображения. У нас есть два объекта, представьте, что у них могут быть десятки, сотни или даже тысячи (для сумасшедших) полей.
class User {
var id: String = ""
var name: String? = null
var email: String? = null
var phone: String? = null
}
class UserDTO {
var id: String = ""
var name: String? = null
var email: String? = null
var phone: String? = null
}
Мы хотим отобразить все поля из User
в UserDTO
. Используя автоматическое отображение, нам не нужно писать никакой код. Маппер будет определен следующим образом:
val mapper = mapper<User, UserDTO> {
autoMap(AutoMappingStrategy.BY_NAME_AND_TYPE)
}
Вуаля! Все поля будут отображены автоматически без какого-либо ручного кода.
Расширенное отображение
В этом примере мы используем возможности трансформаторов по умолчанию, чтобы продвинуть автоматическое отображение еще дальше.
class User {
var id: String = ""
var name: String? = null
var birthDate: Date? = null
}
class UserDTO {
var id: String = ""
var fullName: String? = null
var birthDate: Long? = null
}
Обратите внимание, что типы поля birthDate
различны в исходном и целевом классах. Но, используя возможности трансформаторов по умолчанию, мы можем использовать автосопоставление и здесь.
val mapper = mapper<User, UserDTO> {
autoMap(AutoMappingStrategy.BY_NAME)
}
Мы изменили стратегию автоматического отображения на BY_NAME
, поэтому она будет отображать поля с разными типами. Теперь нам нужно зарегистрировать трансформатор по умолчанию для экземпляра ShapeShift
, чтобы он знал, как преобразовать Date
в Long
.
val shapeShift = ShapeShiftBuilder()
.withTransformer(DateToLongMappingTransformer(), true)
.build()
Мы также можем добавить ручное отображение поверх автоматического отображения, чтобы добавить/изменить поведение. Классы источника и назначения имеют разные имена для поля name
, поэтому мы добавим для него ручное отображение.
val mapper = mapper<User, UserDTO> {
autoMap(AutoMappingStrategy.BY_NAME)
User::name mappedTo UserDTO::fullName
}
Автоматическое отображение отлично подходит для случаев использования, когда не требуется специфическое отображение. Оно помогает сократить количество ручного кода, необходимого для настройки отображения, а также помогает сохранить здравый смысл.
Трансформеры
Трансформаторы — очень полезная функция, которая позволяет преобразовывать тип/значение поля в другой тип/значение при отображении поля.
Некоторые примеры использования, которые мы широко применяем:
- Преобразование даты в long и наоборот между объектами сервера и клиента.
- Преобразование строки JSON к ее реальному типу и наоборот между объектами сервера и клиента.
- Преобразование строки, разделенной запятыми, в список перечислений.
- Преобразование идентификатора другого объекта в его объект или одно из его полей из БД с помощью трансформаторов Spring.
Базовые трансформаторы
Мы начнем с примера простого трансформатора. Трансформаторы Date-to-Long и Long-to-Date:
class DateToLongMappingTransformer : MappingTransformer<Date, Long> {
override fun transform(context: MappingTransformerContext<out Date>): Long? {
return context.originalValue?.time
}
}
class LongToDateMappingTransformer : MappingTransformer<Long, Date> {
override fun transform(context: MappingTransformerContext<out Long>): Date? {
context.originalValue ?: return null
return Date(context.originalValue)
}
}
Все, что нам теперь нужно сделать, это зарегистрировать их.
val shapeShift = ShapeShiftBuilder()
.withTransformer(DateToLongMappingTransformer(), true) // "true" is optional, we are registering the transformers as default transformers, more on that later.
.withTransformer(LongToDateMappingTransformer(), true)
.build()
Вот и все! Теперь мы можем использовать трансформаторы при отображении объектов.
class User {
var id: String = ""
var name: String? = null
var birthDate: Date? = null
}
class UserDTO {
var id: String = ""
var name: String? = null
var birthDate: Long? = null
}
val mapper = mapper<User, UserDTO> {
User::id mappedTo UserDTO::id
User::name mappedTo UserDTO::name
User::birthDate mappedTo UserDTO::birthDate withTransformer DateToLongMappingTransformer::class // We don't have to state the transformer here because it is a default transformer
}
Встроенные трансформаторы
В некоторых случаях мы хотим преобразовать значение, но нам не нужен многоразовый трансформатор, и мы не хотим создавать класс только для одноразового использования.
На помощь приходят линейные трансформаторы! Встроенные трансформаторы позволяют преобразовывать значение без необходимости создавать и регистрировать трансформатор.
val shapeShift = ShapeShiftBuilder()
.withMapping<Source, Target> {
// Map birthDate to birthYear with a transformation function
Source::birthDate mappedTo Target::birthYear withTransformer { (originalValue) ->
originalValue?.year
}
}
.build()
Расширенные трансформаторы
Трансформаторы также позволяют нам выполнять преобразования с БД или другими источниками данных.
В этом примере мы используем возможности интеграции Spring Boot для создания трансформаторов с доступом к БД.
У нас есть три модели:
- Работа — сущность БД.
- Пользователь — сущность БД.
- UserDTO — модель клиента.
class Job {
var id: String = ""
var name: String = ""
}
class User {
var id: String = ""
var jobId: String? = null
}
class UserDTO {
var id: String = ""
var jobName: String? = null
}
Мы хотим преобразовать jobId
на User
в jobName
на UserDTO
путем запроса задания из БД и установки его на DTO.
В случае Spring вы обычно избегаете взаимодействия с контекстом приложения из статических функций или функций на объектах домена.
Мы воспользуемся интеграцией Spring от ShapeShift для создания компонента в качестве трансформатора для доступа к нашему DAO-бобу.
@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
}
}
Осталось только использовать этот трансформатор в нашем маппинге.
val mapper = mapper<User, UserDTO> {
User::id mappedTo UserDTO::id
User::jobId mappedTo UserDTO::jobName withTransformer JobIdToNameTransformer::class
}
Еще одним преимуществом использования трансформаторов является возможность их повторного использования. В некоторых случаях мы можем создать более универсальные трансформаторы, которые будут использоваться во всех приложениях.
Трансформаторы по умолчанию
При регистрации трансформаторов вы можете указать, является ли трансформатор трансформатором по умолчанию. Трансформатор по умолчанию типов <A, B> используется при отображении поля типа <A> на поле типа <B> без указания используемого трансформатора.
Как мы уже видели, трансформаторы по умолчанию полезны для повторяющихся преобразований и особенно для автоматического отображения.
Глубокое отображение
Что, если мы хотим отобразить из/в поля, которые доступны внутри поля, являющегося объектом? Мы можем легко сделать это.
Для доступа к дочерним классам мы можем использовать оператор ...
. Давайте рассмотрим следующий пример.
class From {
var child: Child = Child()
class Child {
var value: String?
}
}
class To {
var childValue: String?
}
Мы хотим отобразить поле value
в классе Child
внутри класса From
на поле childValue
в классе To
. Мы создадим сопоставление с помощью оператора ...
.
val mapper = mapper<From, To> {
From::child..From.Child::value mappedTo To::childValue
}
Давайте сделаем еще один шаг вперед с многоуровневой глубиной.
class From {
var grandChildValue: String?
}
class To {
var child: Child = Child()
class Child {
var grandChild: GrandChild = GrandChild()
}
class GrandChild {
var value: String?
}
}
Для доступа к полю grand child мы просто дважды используем оператор ...
.
val mapper = mapper<From, To> {
From::grandChildValue mappedTo To::child..To.Child::grandChild..To.GrandChild::value
}
Условное отображение
Условия позволяют нам добавить предикат к определенному отображению поля, чтобы определить, должно ли это поле быть отображено.
Использовать эту функцию так же просто, как создать условие.
class NotBlankStringCondition : MappingCondition<String> {
override fun isValid(context: MappingConditionContext<String>): Boolean {
return !context.originalValue.isNullOrBlank()
}
}
И добавить условие к нужному отображению поля.
data class SimpleEntity(
val name: String
)
data class SimpleEntityDisplay(
val name: String = ""
)
val mapper = mapper<SimpleEntity, SimpleEntityDisplay> {
SimpleEntity::name mappedTo SimpleEntityDisplay::name withCondition NotBlankStringCondition::class
}
Встраиваемые условия
Как и трансформаторы, условия также можно добавлять в строку с помощью функции.
val mapper = mapper<SimpleEntity, SimpleEntityDisplay> {
SimpleEntity::name mappedTo SimpleEntityDisplay::name withCondition {
!it.originalValue.isNullOrBlank()
}
}
Отображение аннотаций
Эту особенность ненавидят за то, что она нарушает принцип разделения задач. Согласен, это может быть проблемой в некоторых приложениях, но в некоторых случаях, когда все объекты являются частью одного приложения, может быть очень полезно настраивать логику отображения поверх объекта. Ознакомьтесь с документацией и решите для себя.
Заключение
Библиотеки отображения объектов не являются решением для каждого приложения. Для небольших, простых приложений более чем достаточно использовать шаблонные функции отображения. Но при разработке больших, более сложных приложений библиотеки отображения объектов могут поднять ваш код на новый уровень, сэкономив время на разработку и сопровождение. Все это при одновременном сокращении количества шаблонизированного кода и общем улучшении опыта разработки.
От себя замечу, что раньше я работал с функциями отображения вручную, и меня это вполне устраивало. Это были «всего лишь» несколько простых строк кода. После модернизации наших приложений для использования объектного отображения как части нашей «свободной от шаблонов» структуры (мы обсудим эту структуру позже), я уже не могу вернуться назад. Теперь мы тратим больше времени на то, что важно и интересно, и почти не тратим времени на скучный код, состоящий из шаблонов.