如何在后期初始化中保留不可为空的属性

How to keep non-nullable properties in late initialization

以下问题:在具有 Spring-BootKotlin 的 client/server 环境中,客户端想要创建类型 A 的对象,因此通过 RESTful 端点发布数据到服务器。

实体 A 在 Kotlin 中实现为 data class,如下所示:

data class A(val mandatoryProperty: String)

从商业角度来看,属性(也是主键)绝不能为空。但是,客户端不知道它,因为它是由服务器上的 Spring @Service Bean 非常昂贵地生成的。

现在,在端点 Spring 尝试将客户端的有效负载反序列化为类型 A 的对象,但是,mandatoryProperty 在那个时间点是未知的,这将导致映射例外。

有几种方法可以解决这个问题,none 其中的方法让我很吃惊。

  1. 不要指望在端点处有一个 A 类型的对象,而是获取一组描述 A 的参数,这些参数会一直传递到实际创建实体并且出现 mandatoryProperty 为止。实际上非常麻烦,因为有很多属性,而不仅仅是一个属性。

  2. 与1非常相似,但创建一个DTO。然而,我的最爱之一是,由于 data classes 无法扩展,这意味着将类型 A 的属性复制到 DTO 中(强制性 属性 除外)并将它们复制过来。此外,当 A 增长时,DTO 也必须增长。

  3. 使 mandatoryProperty 可以为空并使用 !!整个代码中的运算符。可能是最糟糕的解决方案,因为它破坏了可空变量和不可空变量的意义。

  4. 客户端将为 mandatoryProperty 设置一个虚拟值,一旦 属性 生成,该值就会被替换。但是,A 由端点验证,因此虚拟值必须遵守其 @Pattern 约束。所以每个虚拟值都是一个有效的主键,这让我感觉很糟糕。

还有其他我认为更可行的方法吗?

我不认为对此有一个通用的答案......所以我只给你关于你的变体的 2 美分......

您的第一个变体有一个其他变体所没有的好处,即您不会将给定的对象用于它们被设计为的任何其他用途(即仅用于端点或后端目的),但是这可能会导致开发繁琐

第二个变体很好,但可能会导致其他一些开发错误,例如当您认为您使用的是实际的 A 但实际上是在 DTO 上操作时。

变体 3 和 4 在这方面类似于 2...您可以将其用作 A,即使它仅具有 DTO 的所有属性。

所以...如果您想走安全路线,即任何人都不应该将此对象用于任何其他用途,那么您可能应该使用第一个变体。 4 听起来很像 hack。 2和3应该没问题。 3 因为当你将它用作 DTO 时,你实际上没有 mandatoryProperty...

不过,因为你有你最喜欢的 (2),我也有一个,所以我将专注于 2 和 3,从 2 开始,使用子类方法,以 sealed class 作为超类型:

sealed class AbstractA {
  // just some properties for demo purposes
  lateinit var sharedResettable: String 
  abstract val sharedReadonly: String
}

data class A(
  val mandatoryProperty: Long = 0,
  override val sharedReadonly: String
  // we deliberately do not override the sharedResettable here... also for demo purposes only
) : AbstractA()

data class ADTO(
  // this has no mandatoryProperty
  override val sharedReadonly: String
) : AbstractA()

一些演示代码,演示用法:

// just some random setup:
val a = A(123, "from backend").apply { sharedResettable = "i am from backend" }
val dto = ADTO("from dto").apply { sharedResettable = "i am dto" }

listOf(a, dto).forEach { anA ->
  // somewhere receiving an A... we do not know what it is exactly... it's just an AbstractA
  val param: AbstractA = anA
  println("Starting with: $param sharedResettable=${param.sharedResettable}")

  // set something on it... we do not mind yet, what it is exactly...
  param.sharedResettable = UUID.randomUUID().toString()

  // now we want to store it... but wait... did we have an A here? or a newly created DTO? 
  // lets check: (demo purpose again)
  when (param) {
    is ADTO -> store(param) // which now returns an A
    is A -> update(param) // maybe updated also our A so a current A is returned
  }.also { certainlyA ->
    println("After saving/updating: $certainlyA sharedResettable=${certainlyA.sharedResettable /* this was deliberately not part of the data class toString() */}")
  }
}

// assume the following signature for store & update:
fun <T> update(param : T) : T
fun store(a : AbstractA) : A

示例输出:

Starting with: A(mandatoryProperty=123, sharedReadonly=from backend) sharedResettable=i am from backend
After saving/updating: A(mandatoryProperty=123, sharedReadonly=from backend) sharedResettable=ef7a3dc0-a4ac-47f0-8a73-0ca0ef5069fa
Starting with: ADTO(sharedReadonly=from dto) sharedResettable=i am dto
After saving/updating: A(mandatoryProperty=127, sharedReadonly=from dto) sharedResettable=57b8b3a7-fe03-4b16-9ec7-742f292b5786

我还没有向您展示丑陋的部分,但您自己已经提到了...您如何将 ADTO 转换为 A,反之亦然?我会把它留给你。这里有几种方法(手动、使用反射或映射实用程序等)。 此变体将所有特定于 DTO 的属性与非特定于 DTO 的属性完全分开。然而它也会导致冗余代码(所有的override,等等)。但至少你知道你操作的对象类型并可以相应地设置签名。

像 3 这样的东西可能更容易设置和维护(关于 data class 本身 ;-)),如果你正确设置边界,它甚至可能很清楚,当有 null 在那里,什么时候不……所以也展示了那个例子。首先从一个相当烦人的变体开始(烦人的意思是当你尝试访问尚未设置的变量时它会抛出异常),但至少你省去了 !!null-在这里检查:

data class B(
  val sharedOnly : String,
  var sharedResettable : String
) {
  // why nullable? Let it hurt ;-)
  lateinit var mandatoryProperty: ID // ok... Long is not usable with lateinit... that's why there is this ID instead
}
data class ID(val id : Long)

演示:

val b = B("backend", "resettable")
//  println(newB.mandatoryProperty) // uh oh... this hurts now... UninitializedPropertyAccessException on the way
val newB = store(b)
println(newB.mandatoryProperty) // that's now fine...

但是:即使访问 mandatoryProperty 会抛出一个 Exception 它在 toString 中不可见,如果您需要检查它是否已经初始化,它看起来也不好看(即通过使用 ::mandatoryProperty::isInitialized)。

所以我给你看另一个变体(同时我最喜欢,但是......使用null):

data class C(val mandatoryProperty: Long?,
  val sharedOnly : String,
  var sharedResettable : String) {
  // this is our DTO constructor:
  constructor(sharedOnly: String, sharedResettable: String) : this(null, sharedOnly, sharedResettable)
  fun hasID() = mandatoryProperty != null // or isDTO, etc. what you like/need
}
// note: you could extract the val and the method also in its own interface... then you would use an override on the mandatoryProperty above instead
// here is what such an interface may look like:
interface HasID {
  val mandatoryProperty: Long?
  fun hasID() = mandatoryProperty != null // or isDTO, etc. what you like/need
}

用法:

val c = C("dto", "resettable") // C(mandatoryProperty=null, sharedOnly=dto, sharedResettable=resettable)
when {
    c.hasID() -> update(c)
    else -> store(c)
}.also {newC ->
    // from now on you should know that you are actually dealing with an object that has everything in place...
    println("$newC") // prints: C(mandatoryProperty=123, sharedOnly=dto, sharedResettable=resettable)
}

最后一个有好处,您可以再次使用 copy-方法,例如:

val myNewObj = c.copy(mandatoryProperty = 123) // well, you probably don't do that yourself...
// but the following might rather be a valid case:
val myNewDTO = c.copy(mandatoryProperty = null)

最后一个是我最喜欢的,因为它需要最少的代码并使用 val 代替(因此也不会意外覆盖,或者您在副本上操作)。如果您不喜欢使用 ?!!,您也可以只为 mandatoryProperty 添加一个访问器,例如

fun getMandatoryProperty() = mandatoryProperty ?: throw Exception("You didn't set it!")

最后,如果您有一些辅助方法,例如 hasIDisDTO 或其他),从上下文中也可能清楚您正在做什么。最重要的可能是建立一个每个人都能理解的约定,这样他们就知道什么时候应用什么或什么时候期待特定的东西。