Как сохранить свойства, не допускающие значения NULL, при поздней инициализации

Следующая проблема: в среде клиент / сервер с Spring-Boot и Kotlin клиент хочет создать объекты типа A и, следовательно, отправляет данные на сервер через конечную точку RESTful.

Сущность A реализована в Котлине как data class следующим образом:

data class A(val mandatoryProperty: String)

С точки зрения бизнеса это свойство (которое также является первичным ключом) никогда не должно быть нулевым. Однако клиент не знает об этом, поскольку довольно дорого генерируется Spring @Service Bean на сервере.

Теперь в конечной точке Spring пытается десериализовать полезную нагрузку клиента в объект типа A, однако mandatoryProperty в этот момент времени неизвестен, что приведет к исключению сопоставления.

Несколько способов обойти эту проблему, ни один из которых меня особо не удивляет.

  1. Не ожидайте, что объект типа A будет в конечной точке, но получите набор параметров, описывающих A, которые передаются до тех пор, пока объект не будет фактически создан и не будет присутствовать обязательное свойство. На самом деле это довольно громоздко, поскольку свойств гораздо больше, чем только одно.

  2. Очень похоже на 1, но создайте DTO. Однако один из моих любимых, поскольку data classes не может быть расширен, это означало бы дублировать свойства типа A в DTO (за исключением обязательного свойства) и копировать их. Более того, когда A растет, DTO тоже должен расти.

  3. Сделайте обязательное свойство обнуляемым и работайте с !! оператор по всему коду. Вероятно, худшее решение, поскольку оно лишает смысла переменные, допускающие и не допускающие значения NULL.

  4. Клиент установит фиктивное значение для обязательного свойства, которое будет заменено, как только свойство будет сгенерировано. Однако A проверяется конечной точкой, и поэтому фиктивное значение должно подчиняться ограничению @Pattern. Таким образом, каждое фиктивное значение будет действительным первичным ключом, что вызывает у меня плохое предчувствие.

Какие другие способы, которые я мог бы контролировать, более осуществимы?


person Jan B.    schedule 07.09.2018    source источник
comment
относительно 2 .: вы не можете расширяться из классов данных, но вы можете расширяться из общих (запечатанных, обычных и т. д.). Если бы вы затем поместили val в суперкласс, вы, по крайней мере, были бы вынуждены установить соответствующие override в подклассах данных ... Однако ... это может быть на один шаг лучше, чем необходимость копировать все самостоятельно ;-) Следующая проблема вероятно возникнет, когда вы захотите сопоставить или скопировать между ними. ;-)   -  person Roland    schedule 07.09.2018
comment
Как насчет добавления дополнительного конструктора к классу данных, который будет использовать какой-либо параметр по умолчанию вместо обязательного свойства и преобразовывать этот объект, когда свойство доступно? data class A(val a: String, val b: String) { constructor(b: String) : this("default", b) }.   -  person Demigod    schedule 07.09.2018


Ответы (1)


Я не думаю, что есть универсальный ответ на этот вопрос ... Так что я просто дам вам свои 2 цента за ваши варианты ...

У вашего первого варианта есть преимущество, которого нет у других, то есть вы не будете использовать данные объекты ни для чего другого, для чего они были предназначены (т.е. только для конечных точек или серверных целей), что, однако, вероятно, приведет к обременительной разработке.

Второй вариант хорош, но может привести к другим ошибкам разработки, например. когда вы думали, что используете реальный A, но вместо этого вы работаете с DTO.

Варианты 3 и 4 в этом отношении аналогичны 2 ... Вы можете использовать его как A, даже если он имеет все свойства только DTO.

Итак ... если вы хотите пойти по безопасному маршруту, то есть никто никогда не должен использовать этот объект для чего-либо еще, тогда для его конкретной цели вам, вероятно, следует использовать первый вариант. 4 звучит скорее как взлом. 2 и 3, вероятно, в порядке. 3, потому что у вас фактически нет mandatoryProperty, когда вы используете его как DTO ...

Тем не менее, поскольку у вас есть ваш любимый (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!")

Наконец, если у вас есть вспомогательные методы, такие как _31 _ (_ 32_ или что-то еще), из контекста также может быть ясно, что вы именно делаете. Самым важным, вероятно, является установление соглашения, которое будет понятно всем, чтобы они знали, когда применять то, что, а когда ожидать чего-то конкретного.

person Roland    schedule 07.09.2018
comment
Большое спасибо за подробный ответ, особенно за подход hasID. Как вы говорите, для этого не существует универсального решения. Думаю, я собираюсь не торопиться и полностью реализовать два моих любимых, чтобы посмотреть, как это выглядит. - person Jan B.; 10.09.2018
comment
Это хорошая идея. Дайте мне знать, как будет выглядеть ваш окончательный подход, когда вы закончите. Кстати. какие твои 2 фаворита сейчас? - person Roland; 10.09.2018
comment
В конце концов мы выбрали первый вариант, поскольку объект практически не изменится со временем, и у нас есть эта проблема только с одной конечной точкой, поэтому наличие группы параметров - это всего лишь небольшой недостаток, с которым мы можем справиться. Мы отказались от вариантов с наследованием, поскольку они, похоже, не слишком хорошо работают с классами данных, по крайней мере, по сравнению с простым наследованием в Java. Лично мне больше всего понравилось ваше предложение с использованием интерфейса, так как его влияние вполне управляемо, и я всегда был поклонником интерфейсов: D. .... - person Jan B.; 19.09.2018
comment
... в любом случае, теперь мы гораздо лучше осведомлены о возможностях, которые у нас есть всякий раз, когда мы снова сталкиваемся с такими проблемами. Еще раз большое спасибо за то, что поделились своими мыслями. - person Jan B.; 19.09.2018