Roman Kryvolapov Engineering Blog

Невеликий мануал з MapStruct

Для перетворення одних класів на інші зручно використовувати бібліотеки для автоматичного перетворення замість того, щоб писати мапери вручну. На мою думку, одна з найкращих бібліотек для цього MapStruct

https://mapstruct.org

Налаштування MapStruct

Її перевага — помилки при перетворенні, наприклад якщо формат не збігається, можуть показуватися при компіляції проєкту, а не в рантаймі, для цього створюємо

import org.mapstruct.MapperConfig
import org.mapstruct.ReportingPolicy
@MapperConfig(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface StrictMapperConfig

і використовуємо його надалі. Я буду використовувати базовий клас BaseMapper

abstract class BaseMapper<From, To> {
abstract fun map(from: From): To
open fun mapList(fromList: List<From>): List<To> {
return fromList.mapTo(ArrayList(fromList.size), this::map)
}
}

Важливі зауваження

УВАГА, якщо в однієї з моделей, які необхідно перетворити, лише одне поле, MapStruct видає помилку

error: Unmapped target property: "copy". public abstract com._.To map(@org.jetbrains.annotations.NotNull

Схожа помилка може бути, наприклад, і при успадкуванні класами інтерфейсів, не сумісних з MapStruct, або в деяких інших випадках.

УВАГА, MapStruct, як зрозуміло з використання анотацій, генерує код для перетворення, і якщо ви будете використовувати назви змінних у форматі is…, мапер видасть помилку через конфлікт у логіці. Найкраще рішення — перейменувати змінну, є й інші рішення на stackoverflow.

УВАГА, MapStruct не вміє нормально обробляти nullable і не nullable значення, стежте, щоб у початковому та кінцевому класі тип змінної був однаковий

УВАГА, якщо в інтерфейсі, позначеному анотацією Mapper, передбачаються звичайні функції для перетворення, використовуйте абстрактний клас, а не інтерфейс, при використанні функцій інтерфейсу велика ймовірність помилки при компіляції

Найпростіший варіант перетворення

Найпростіший варіант перетворення, коли класи повністю збігаються

data class To(
val one: String,
val two: String,
)
data class From(
val one: String,
val two: String,
)
import org.mapstruct.Mapper
import org.mapstruct.factory.Mappers
class SomeModelMapper : BaseMapper<From, To>() {
@Mapper(config = StrictMapperConfig::class)
fun interface ModelMapper {
fun map(from: From): To
}
override fun map(from: From): To {
return Mappers.getMapper(ModelMapper::class.java).map(from)
}
}

Мапінг з різними іменами полів

Якщо ім'я одного або кількох полів відрізняється, додаємо анотацію Mapping, або кілька анотацій, якщо це необхідно

data class To(
val oneNewName: String,
val twoNewName: String,
)
data class From(
val one: String,
val two: String,
)
import org.mapstruct.Mapper
import org.mapstruct.factory.Mappers
class SomeModelMapper : BaseMapper<From, To>() {
@Mapper(config = StrictMapperConfig::class)
fun interface ModelMapper {
@Mapping(source = "one", target = "oneNewName")
@Mapping(source = "two", target = "twoNewName")
fun map(from: From): To
}
override fun map(from: From): To {
return Mappers.getMapper(ModelMapper::class.java).map(from)
}
}

Перетворення полів різних типів

А ось приклад перетворення, якщо необхідно замінити одне з полів, наприклад вони різного типу

data class To(
val one: List<String>,
val two: String,
)
data class From(
val one: String,
val two: String,
)
import org.mapstruct.Mapper
import org.mapstruct.factory.Mappers
class SomeModelMapper : BaseMapper<From, To>() {
@Mapper(config = StrictMapperConfig::class)
abstract class ModelMapper {
abstract fun map(from: From): To
fun mapOne(one: String): List<String> {
return listOf(one)
}
}
override fun map(from: From): To {
return Mappers.getMapper(ModelMapper::class.java).map(from)
}
}

Використання анотації Named

також ви можете використовувати анотацію Named

data class To(
val one: List<String>,
val two: String,
)
data class From(
val one: String,
val two: String,
)
import org.mapstruct.Mapper
import org.mapstruct.factory.Mappers
class SomeModelMapper : BaseMapper<From, To>() {
companion object {
private const val MAP_ONE = "MAP_ONE"
}
@Mapper(config = StrictMapperConfig::class)
abstract class ModelMapper {
@Mapping(target = "one", source = "one", qualifiedByName = [MAP_ONE])
abstract fun map(from: From): To
@Named(MAP_ONE)
fun mapOne(one: String): List<String> {
return listOf(one)
}
}
override fun map(from: From): To {
return Mappers.getMapper(ModelMapper::class.java).map(from)
}
}

Перевикористання логіки

Щоб перевикористати логіку, можна розділити перетворення на різні класи / інтерфейси

data class To(
val one: String,
val two: List<ToItem>,
)
data class ToItem(
val three: List<String>,
val four: String,
)
data class From(
val one: String,
val two: List<FromItem>,
)
data class FromItem(
val three: String,
val four: String,
)
import org.mapstruct.Mapper
import org.mapstruct.factory.Mappers
class SomeModelMapper : BaseMapper<From, To>() {
@Mapper(config = StrictMapperConfig::class, uses = [ItemMapper::class])
fun interface ModelMapper {
fun map(from: From): To
}
@Mapper(config = StrictMapperConfig::class)
abstract class ItemMapper {
fun mapThree(three: String): List<String> {
return listOf(three)
}
}
override fun map(from: From): To {
return Mappers.getMapper(ModelMapper::class.java).map(from)
}
}

Виклик абстрактної функції зі звичайної

можна навпаки зі звичайної функції викликати абстрактну, якщо, наприклад, потрібно перетворити вкладені списки з іншими класами, вміст яких однаковий

data class To(
val one: String,
val two: List<ToItem>,
)
data class ToItem(
val three: String,
val four: String,
)
data class From(
val one: String,
val two: List<FromItem>,
)
data class FromItem(
val three: String,
val four: String,
)
import org.mapstruct.Mapper
import org.mapstruct.factory.Mappers
class SomeModelMapper : BaseMapper<From, To>() {
@Mapper(config = StrictMapperConfig::class)
abstract class ModelMapper {
fun map(from: From): To {
return with(from) {
To(
one = one,
two = two.map(::mapItem),
)
}
}
abstract fun mapItem(two: FromItem): ToItem
}
override fun map(from: From): To {
return Mappers.getMapper(ModelMapper::class.java).map(from)
}
}

За аналогією, можна створювати і складніші конструкції, використовуючи в абстрактному класі з анотацією Mapper абстрактні та звичайні функції, дотримуючись при цьому імен змінних і методів. MapStruct загалом досить примхлива бібліотека з великою кількістю нюансів, але з тих, що я пробував використовувати, вона виявилася найбезпечнішою в плані помилок при роботі.

Приклад з XML парсингом

Наведу ще один цікавий приклад, коли в моделі в текстовому полі знаходиться XML, який необхідно розпарсити в клас, для цього буду використовувати бібліотеку org.simpleframework.xml

https://javadoc.io/doc/org.simpleframework/simple-xml/latest/index.html

implementation 'org.simpleframework:simple-xml:2.7.1'

У прикладі, для зручності, у кінцевому класі зробив 2 вкладені класи — з даними з оригінального класу та даними з XML, але структура може бути будь-якою

<data>
<one>text one</one>
<two>text two</two>
</data>
data class To(
val data: ToData?,
val xml: ToXML?,
)
data class ToData(
val one: String?,
val two: String?,
)
data class ToXML(
val one: String?,
val two: String?,
)
data class From(
val one: String?,
val two: String?,
val xml: String?,
)
import org.simpleframework.xml.Element
import org.simpleframework.xml.Root
@Root(name = "data")
data class FromXML(
@field:Element(name = "one", required = false)
var one: String? = null,
@field:Element(name = "two", required = false)
var two: String? = null,
)
import org.mapstruct.Mapper
import org.mapstruct.factory.Mappers
import org.koin.core.component.inject
import org.koin.core.component.KoinComponent
import org.simpleframework.xml.core.Persister
class SomeModelMapper: BaseMapper<From, To>(), KoinComponent {
private val serializer: Persister by inject()
@Mapper(config = StrictMapperConfig::class)
interface ModelMapper {
fun mapData(from: From): ToData
fun mapXML(from: FromXML): ToXML
}
override fun map(from: From): To {
val data = Mappers.getMapper(FromXML::class.java).mapJSON(from)
return try {
val xml = serializer.read(FromXML::class.java, from.xml!!)!!
To(
data = data,
xml = Mappers.getMapper(ModelMapper::class.java).mapXML(xml),
)
} catch (e: Exception) {
logError("parse xml exception: ${e.message}", e, TAG)
To(
data = data,
xml = null,
)
}
}
}

Copyright: Roman Kryvolapov