将 Kotlin 数据对象映射到数据对象的更好方法

我想转换/映射一些“数据”类对象到类似的“数据”类对象。例如,Web 表单的类到数据库记录的类。

data class PersonForm(
val firstName: String,
val lastName: String,
val age: Int,
// maybe many fields exist here like address, card number, etc.
val tel: String
)
// maps to ...
data class PersonRecord(
val name: String, // "${firstName} ${lastName}"
val age: Int, // copy of age
// maybe many fields exist here like address, card number, etc.
val tel: String // copy of tel
)

我使用 ModelMapper 在 Java 中完成这些工作,但是不能使用它,因为数据类是 final (ModelMapper 创建 CGLib 代理来读取映射定义)。当我们打开这些类/字段时,我们可以使用 ModelMapper,但是我们必须手动实现“ data”类的特性。 (参考 ModelMapper 示例: https://github.com/jhalterman/modelmapper/blob/master/examples/src/main/java/org/modelmapper/gettingstarted/GettingStartedExample.java)

如何在 Kotlin 映射这些“数据”对象?

更新: ModelMapper 自动映射具有相同名称的字段(如 tel-> tel) ,而不需要映射声明。我想用 Kotlin 的数据类来做。

更新: 每个类的用途取决于应用程序的类型,但是这些类可能位于应用程序的不同层。

例如:

  • 从数据库(数据库实体)到 HTML 表单数据(模型/视图模型)
  • 数据库数据的 RESTAPI 结果

这些类相似,但不相同。

由于以下原因,我想避免正常的函数调用:

  • 这取决于论点的顺序。对于具有许多具有相同类型(如 String)的字段的类,函数将很容易被破坏。
  • 许多声明是必需的,尽管大多数映射可以用变数命名原则解析。

当然,目的是建立一个具有类似功能的库,但也欢迎提供 Kotlin 功能的信息(就像在 ECMAScript 中传播一样)。

106687 次浏览

Is this are you looking for?

data class PersonRecord(val name: String, val age: Int, val tel: String){
object ModelMapper {
fun from(form: PersonForm) =
PersonRecord(form.firstName + form.lastName, form.age, form.tel)
}
}

and then:

val personRecord = PersonRecord.ModelMapper.from(personForm)

Do you really want a separate class for that? You can add properties to the original data class:

data class PersonForm(
val firstName: String,
val lastName: String,
val age: Int,
val tel: String
) {
val name = "${firstName} ${lastName}"
}
  1. Simplest (best?):

    fun PersonForm.toPersonRecord() = PersonRecord(
    name = "$firstName $lastName",
    age = age,
    tel = tel
    )
    
  2. Reflection (not great performance):

    fun PersonForm.toPersonRecord() = with(PersonRecord::class.primaryConstructor!!) {
    val propertiesByName = PersonForm::class.memberProperties.associateBy { it.name }
    callBy(args = parameters.associate { parameter ->
    parameter to when (parameter.name) {
    "name" -> "$firstName $lastName"
    else -> propertiesByName[parameter.name]?.get(this@toPersonRecord)
    }
    })
    }
    
  3. Cached reflection (okay performance but not as fast as #1):

    open class Transformer<in T : Any, out R : Any>
    protected constructor(inClass: KClass<T>, outClass: KClass<R>) {
    private val outConstructor = outClass.primaryConstructor!!
    private val inPropertiesByName by lazy {
    inClass.memberProperties.associateBy { it.name }
    }
    
    
    fun transform(data: T): R = with(outConstructor) {
    callBy(parameters.associate { parameter ->
    parameter to argFor(parameter, data)
    })
    }
    
    
    open fun argFor(parameter: KParameter, data: T): Any? {
    return inPropertiesByName[parameter.name]?.get(data)
    }
    }
    
    
    val personFormToPersonRecordTransformer = object
    : Transformer<PersonForm, PersonRecord>(PersonForm::class, PersonRecord::class) {
    override fun argFor(parameter: KParameter, data: PersonForm): Any? {
    return when (parameter.name) {
    "name" -> with(data) { "$firstName $lastName" }
    else -> super.argFor(parameter, data)
    }
    }
    }
    
    
    fun PersonForm.toPersonRecord() = personFormToPersonRecordTransformer.transform(this)
    
  4. Storing Properties in a Map

    data class PersonForm(val map: Map<String, Any?>) {
    val firstName: String   by map
    val lastName: String    by map
    val age: Int            by map
    // maybe many fields exist here like address, card number, etc.
    val tel: String         by map
    }
    
    
    // maps to ...
    data class PersonRecord(val map: Map<String, Any?>) {
    val name: String    by map // "${firstName} ${lastName}"
    val age: Int        by map // copy of age
    // maybe many fields exist here like address, card number, etc.
    val tel: String     by map // copy of tel
    }
    
    
    fun PersonForm.toPersonRecord() = PersonRecord(HashMap(map).apply {
    this["name"] = "${remove("firstName")} ${remove("lastName")}"
    })
    

This works using Gson:

inline fun <reified T : Any> Any.mapTo(): T =
GsonBuilder().create().run {
toJson(this@mapTo).let { fromJson(it, T::class.java) }
}


fun PersonForm.toRecord(): PersonRecord =
mapTo<PersonRecord>().copy(
name = "$firstName $lastName"
)


fun PersonRecord.toForm(): PersonForm =
mapTo<PersonForm>().copy(
firstName = name.split(" ").first(),
lastName = name.split(" ").last()
)

with not nullable values allowed to be null because Gson uses sun.misc.Unsafe..

MapStruct lets kapt generate classes doing the mapping (without reflection).

Use MapStruct:

@Mapper
interface PersonConverter {


@Mapping(source = "phoneNumber", target = "phone")
fun convertToDto(person: Person) : PersonDto


@InheritInverseConfiguration
fun convertToModel(personDto: PersonDto) : Person


}




// Note this either needs empty constructor or we need @KotlinBuilder as dsecribe below
data class Person: this(null, null, null, null) (...)

Use:

val converter = Mappers.getMapper(PersonConverter::class.java) // or PersonConverterImpl()


val person = Person("Samuel", "Jackson", "0123 334466", LocalDate.of(1948, 12, 21))


val personDto = converter.convertToDto(person)
println(personDto)


val personModel = converter.convertToModel(personDto)
println(personModel)

Edit:

Now with @KotlinBuilder for avoiding constructor() issue:

GitHub: Pozo's mapstruct-kotlin

Annotate data classes with @KotlinBuilder. This will create a PersonBuilder class, which MapStruct uses, thus we avoid ruining the interface of the data class with a constructor().

@KotlinBuilder
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
val tel: String
)

Dependency :

// https://mvnrepository.com/artifact/com.github.pozo/mapstruct-kotlin
api("com.github.pozo:mapstruct-kotlin:1.3.1.1")
kapt("com.github.pozo:mapstruct-kotlin-processor:1.3.1.1")

https://github.com/mapstruct/mapstruct-examples/tree/master/mapstruct-kotlin

You can use ModelMapper to map to a Kotlin data class. The keys are:

  • Use @JvmOverloads (generates a constructor with no arguments)
  • Default values for data class member
  • Mutable member, var instead of val

    data class AppSyncEvent @JvmOverloads constructor(
    var field: String = "",
    var arguments: Map<String, *> = mapOf<String, Any>(),
    var source: Map<String, *> = mapOf<String, Any>()
    )
    
    
    val event = ModelMapper().map(request, AppSyncEvent::class.java)
    

For ModelMapper you could use Kotlin's no-arg compiler plugin, with which you can create an annotation that marks your data class to get a synthetic no-arg constructor for libraries that use reflection. Your data class needs to use var instead of val.

package com.example


annotation class NoArg


@NoArg
data class MyData(var myDatum: String)


mm.map(. . ., MyData::class.java)

and in build.gradle (see docs for Maven):

buildscript {
. . .
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
}
}


apply plugin: 'kotlin-noarg'


noArg {
annotation "com.example.NoArg"
}

Using ModelMapper

/** Util.kt **/


class MapperDto() : ModelMapper() {
init {
configuration.matchingStrategy = MatchingStrategies.LOOSE
configuration.fieldAccessLevel = Configuration.AccessLevel.PRIVATE
configuration.isFieldMatchingEnabled = true
configuration.isSkipNullEnabled = true
}
}


object Mapper {
val mapper = MapperDto()


inline fun <S, reified T> convert(source: S): T = mapper.map(source, T::class.java)
}

Usage

val form = PersonForm(/** ... **/)
val record: PersonRecord = Mapper.convert(form)

You might need some mapping rules if the field names differ. See the getting started
PS: Use kotlin no-args plugin for having default no-arg constructor with your data classes

You can use the DataClassMapper class taken from here: https://github.com/jangalinski/kotlin-dataclass-mapper

data class PersonForm(
val firstName: String,
val lastName: String,
val age: Int,
// maybe many fields exist here like address, card number, etc.
val tel: String
)


// maps to ...
data class PersonRecord(
val name: String, // "${firstName} ${lastName}"
val age: Int, // copy of age
// maybe many fields exist here like address, card number, etc.
val tel: String // copy of tel
)


fun mapPerson(person: PersonForm): PersonRecord =
DataClassMapper<PersonForm, PersonRecord>()
.targetParameterSupplier(PersonRecord::name) { "${it.firstName} ${it.lastName}"}
.invoke(person)


fun main() {
val result = mapPerson(PersonForm("first", "last", 25, "tel"))
println(result)
}

Result will be:

PersonRecord(name=first last, age=25, tel=tel)

Yet another mapper - LaMapper. E.g.

fun PersonForm.toPersonRecord() = LaMapper.copyFrom(this) {
PersonRecord::name from { "${it.firstName} ${it.lastName}" }
// add more mappings, everything else mapped by name
}


val rec = person.toPersonRecord()

In addition it has various data-type conversions by default.