Blog
Funktionale Domänenmodellierung in Kotlin - Validierung

Dieser Artikel wurde ursprünglich auf 47deg.com am 13. April 2021 veröffentlicht.
In einem früheren Blog-Beitrag über funktionale Domänenmodellierung in Kotlin haben wir erörtert, wie wir data class, enum class, sealed class und inline class verwenden können, um unsere Geschäftsdomäne so genau wie möglich zu beschreiben, um mehr Typsicherheit zu erreichen, die Nutzung des Compilers mit unserer Domäne zu maximieren und so Fehler zu vermeiden und Unit-Tests zu reduzieren.
Am Ende haben wir kurz besprochen, wie wir den Typ Either von Arrow verwenden können, um mehr Typsicherheit in unsere Geschäftslogik zu bringen. In diesem Blogbeitrag werden wir untersuchen, wie wir Either und Validated verwenden können, um verschiedene Ziele zu erreichen.
Lassen Sie uns mit der Domain aus dem vorherigen Blogbeitrag fortfahren, die Event war.
import java.time.LocalDate
inline class EventId(val value: Long)
inline class Organizer(val value: String)
inline class Title(val value: String)
inline class Description(val value: String)
data class Event(
val id: EventId,
val title: Title,
val organizer: Organizer,
val description: Description,
val date: LocalDate
)
Um eine richtige Event zu erstellen, müssen wir überprüfen, ob die Eigenschaften bestimmte Bedingungen erfüllen. Zu diesem Zweck können wir intelligente Konstruktoren erstellen, die ValidationError auf der Seite Left zurück, wenn eine Bedingung nicht erfüllt ist, oder ein Event auf der Seite Right, wenn alle Bedingungen erfüllt wurden.
import arrow.core.Either
import arrow.core.Either.Left
import arrow.core.Either.Right
data class ValidationError(val reason: String)
inline class EventId(val value: Long) {
companion object {
fun create(value: Long): Either<ValidationError, EventId> =
if (value > 0) Right(EventId(value))
else Left(ValidationError("EventId needs to be bigger than 0, but found $value."))
}
}
inline class Organizer(val value: String) {
companion object {
/* Same implementation for Title and Description */
fun create(value: String): Either<ValidationError, Organizer> = when {
value.isEmpty() -> Left(ValidationError("Organizer cannot be empty"))
value.isBlank() -> Left(ValidationError("Organizer cannot be blank"))
else -> Right(Organizer(value))
}
}
}
In dem obigen Beispiel fügen wir create intelligente Konstruktoren für die Begleitobjekte unserer Domänentypen hinzu. Der Einfachheit halber haben wir nur die Implementierung für
Wir können nun ganz einfach einen Event konstruieren, indem wir den either Berechnungsblock verwenden, der es uns ermöglicht, den Erfolgswert auf der linken Seite zu extrahieren. Wenn er auf einen Left Fall trifft, kehrt er sofort mit dem Left Ergebnis zurück. Dieses Verhalten wird gemeinhin als Kurzschlussverhalten bezeichnet, da der either Berechnungsblock einen Kurzschluss verursacht, wenn er auf einen Left Fall trifft.
Diese computation Blöcke bieten Unterstützung für suspend. Falls Sie die Funktion jedoch nicht über suspend fun aufrufen möchten, können Sie stattdessen either.eager verwenden, eine Implementierung, die @RestrictSuspensioncode>@RestrictSuspension </code verwendet.
import arrow.core.computations.either
suspend fun generateId(): Long =
-1L
suspend fun createEvent(): Either<ValidationError, Event> =
either {
val id = EventId.create(generateId()).bind()
val title = Title.create(" ").bind()
val organizer = Organizer.create("").bind()
val description = Description.create("").bind()
Event(id, title, organizer, description, LocalDate.now())
} // Left(ValidationError("EventId needs to be bigger than 0, but found -1."))
Da die Validierung von EventId fehlgeschlagen ist, kehrt der Block either sofort mit Left für EventId zurück. Wir haben also keine Ahnung, dass Title, Organizer und Description ebenfalls falsch waren, da Either einen Kurzschluss mit dem ersten gefundenen Left verursacht.
Stellen Sie sich vor, Sie verwenden diese Technik zur Validierung eines Formulars, um ein Event zu erstellen, und anstatt alle falsch ausgefüllten Felder anzuzeigen, sagt es Ihnen, dass der Titel falsch ist. Und wenn Sie ihn korrigieren, sagt es Ihnen, dass
Was wir stattdessen für die Validierung tun möchten, ist, alle aufgetretenen Fehler zu sammeln ODER den Erfolgswert zurückzugeben.
Lassen Sie uns also unser oben definiertes Validierungs-Snippet zu Validated umstrukturieren.
import arrow.core.Valid
import arrow.core.ValidatedNel
import arrow.core.invalidNel
data class ValidationError(val reason: String)
inline class EventId(val value: Long) {
companion object {
fun create(value: Long): ValidatedNel<ValidationError, EventId> =
if (value > 0) Valid(EventId(value))
else ValidationError("EventId needs to be bigger than 0, but found $value.").invalidNel()
}
}
inline class Organizer(val value: String) {
companion object {
fun create(value: String): ValidatedNel<ValidationError, Organizer> = when {
value.isEmpty() -> ValidationError("Organizer cannot be empty").invalidNel()
value.isBlank() -> ValidationError("Organizer cannot be blank").invalidNel()
else -> Valid(Organizer(value))
}
}
}
Wie Sie sehen können, hat sich zwischen unserem auf Either basierenden Code und unserem auf Validated basierenden Code nur wenig geändert. Das liegt daran, dass sie die gleiche Art von Beziehung modellieren, die wir in unserem vorherigen Blogbeitrag besprochen haben. Die OR-Beziehung und beide haben zwei Fälle: Left/Invalid und Right/Valid.
Wie Sie vielleicht schon erraten haben, besteht der Unterschied zwischen den beiden darin, dass Either einen Kurzschluss auf Left verursacht und Validated die Fehler in Invalid akkumuliert.
Wenn wir also versuchen, ein Event zu konstruieren, indem wir den Typ Validated anstelle von Either verwenden, können wir alle Fehler herausfinden, die bei der Konstruktion von Event aufgetreten sind.
Dazu verwenden wir ValidatedNel, was typealias ValidatedNel<E, A> = Validated<NonEmptyList<E>, A> ist. Dies ermöglicht uns, alle Fehler in NonEmptyList zu akkumulieren.
Die Verwendung von NonEmptyList anstelle von List ist genauer modelliert, da es immer mindestens ein ValidationError geben wird; andernfalls würden wir ein Valid Event erwarten.
suspend fun generateId(): Long =
-1L
suspend fun date(): LocalDate =
LocalDate.now()
suspend fun createEvent(): ValidatedNel<ValidationError, Event> =
EventId.create(generateId()).zip(
Title.create(""),
Organizer.create(""),
Description.create("")
) { id, title, organizer, description -> Event(id, title, organizer, description, date()) }
//Invalid(NonEmptyList(
// ValidationError("EventId needs to be bigger than 0, but found -1."),
// ValidationError("Title cannot be blank"),
// ValidationError("Organizer cannot be empty"),
// ValidationError("Description cannot be empty")
//))
In dem resultierenden Wert finden wir alle aufgelaufenen Fehler, die beim Versuch, eine Event zu erstellen, aufgetreten sind.
Indem wir unseren Aufruf an zip delegieren, können wir unabhängige Werte kombinieren. Um diese Werte zu kombinieren, müssen wir auch eine Möglichkeit bereitstellen, die Invalid Fälle zu kombinieren. Hier verwenden wir standardmäßig NonEmptyList delegiert sie einfach an NonEmptyList#plus, was zu einer nicht leeren Liste aller Elemente führt.
Wir können suspend Funktionen sowohl in den zip Parametern, als auch im map Lambda verwenden, da die zip Funktion in ihrer Definition inline ist. Dieser Konstruktor ist auch für Either verfügbar, wobei das Kurzschlussverhalten von Either beibehalten wird und somit kein Semigroup erforderlich ist.
An dieser Stelle fragen wir uns vielleicht, wann wir Either oder wann Validated verwenden sollten. Im Allgemeinen würden wir Either wählen, wenn wir ein Kurzschlussverhalten bei der Validierung von Werten wünschen, d.h. die Berechnung stoppt, sobald wir auf einen ungültigen Fall stoßen. Wir würden Validated, insbesondere die Form ValidatedNel, verwenden, wenn wir ungültige Fälle über mehrere unabhängige Validierungen hinweg akkumulieren und daher bei einem Fehlschlag keinen Kurzschluss verursachen wollen.
In allen Anwendungsfällen dienen toEither oder toValidated als Konvertierungsfunktionen. Außerdem werden sowohl Validated als auch Either Werte innerhalb von either Berechnungsblöcken akzeptiert und sie können .bind() bei Bedarf über eine Aussetzung kurzschließen.
suspend fun createEvent(): Validated<NonEmptyList<ValidationError>, Event> = ...
suspend fun createEventAndLog(): Either<NonEmptyList<ValidationError>, Unit> =
either {
val event = createEvent().bind()
println(event)
}
Nachdem wir nun gesehen haben, wie wir either Berechnungsblöcke und zip verwenden können, wollen wir uns nun einige andere interessante Operatoren und parallele Operatoren ansehen.
Oft müssen wir mit vielen Werten arbeiten, z.B. einer List von Identifikatoren, die wir verarbeiten oder validieren müssen. Ein üblicher Anwendungsfall mit List wäre die Verwendung von map, aber das hat einige unerwünschte Ergebnisse, wenn wir es in Kombination mit einem anderen Wrapper wie Either oder Validated verwenden.
data class User(val id: Long, val name: String)
fun createUser(id: Long, name: String): Either<ValidationError, User> {
val id = if(id > 0) Right(id) else Left(ValidationError("Id of name: $name needs to be bigger than 0, but found $id."))
val name = if (name.isNotBlank()) Right(name) else Left(ValidationError("Name of id: $id cannot be blank"))
return id.zip(name, ::User)
}
fun Iterable<Pair<Long, String>>.createUsers(): List<Either<ValidationError, User>> =
map { (id, name) -> createUser(id, name) }
Das Ergebnis von createUsers ist vom Typ List<Either<ValidationError, User>>, was nicht sehr nützlich ist, denn um irgendetwas mit User zu tun, müssen wir jeden Wert in der Liste überprüfen und prüfen, ob er Left oder Right ist, was sehr mühsam ist. Dies berücksichtigt auch nicht das Kurzschlussverhalten von Either und führt stattdessen createUser für alle Werte aus, auch wenn vorherige Operationen zu Left geführt haben.
Lassen Sie uns stattdessen traverseEither verwenden.
import arrow.core.traverseEither
fun Iterable<Pair<Long, String>>.createUsers(): Either<ValidationError, List<User>> =
ids.traverseEither { (id, name) -> createUser(id, name) }
traverseEither wendet eine Funktion auf jeden Wert in der Sammlung an und gibt eine Either mit den List aller erfolgreichen Anwendungen der Funktion zurück.
Andererseits macht traverseEither einen Kurzschluss, wenn es auf einen Left Wert stößt und gibt diesen sofort zurück und ignoriert die restlichen Elemente.
Wir können dasselbe für Validated tun, indem wir traverseValidated verwenden und ein Semigroup bereitstellen. Wenn wir dies mit Validated anstelle eines Kurzschlusses tun, werden alle Fehler aller Werte in der Sammlung akkumuliert.
import arrow.core.traverseValidated
fun createUser(id: Long, name: String): ValidatedNel<ValidationError, User> {
val id = if(id > 0) Valid(id) else ValidationError("Id of name: $name needs to be bigger than 0, but found $id.").invalidNel()
val name = if (name.isNotBlank()) Valid(name) else ValidationError("Name of id: $id cannot be blank").invalidNel()
return id.zip(name, ::User)
}
suspend fun Iterable<Pair<Long, String>>.createUsers(): ValidatedNel<ValidationError, List<User>> =
ids.traverseValidated { (id, name) -> createUser(id, name) }
Als letztes Beispiel werden wir etwas Parallelität mit einer anderen Arrow-Bibliothek namens Arrow Fx Coroutines einführen. In Arrow Fx Coroutines finden wir eine traverseXXX Variante, die die bereitgestellte Funktion parallel ausführt und parTraverseXXX heißt.
Sehen wir uns also das gleiche Beispiel für Either an, aber führen wir Parallelität ein.
import arrow.fx.coroutines.parTraverseEither
fun createUser(id: Long, name: String): Either<ValidationError, User> {
val id = if(id > 0) Right(id) else Left(ValidationError("Id of name: $name needs to be bigger than 0, but found $id."))
val name = if (name.isNotBlank()) Right(name) else Left(ValidationError("Name of id: $id cannot be blank"))
return id.zip(name, ::User)
}
suspend fun Iterable<Pair<Long, String>>.createUsers(): Either<ValidationError, List<User>> =
ids.parTraverseEither { (id, name) -> createUser(id, name) }
Hier starten wir N Coroutines parallel, wobei N die Größe des List ist, in dem wir die createUser Funktionen ausführen. Das Kurzschlussverhalten von Either wird beibehalten, und alle noch laufenden parallelen Aufgaben werden abgebrochen, bevor die Funktion den ersten angetroffenen Left Fall zurückgibt.
Wenn Sie dieselbe Technik auf Validated anwenden, werden stattdessen alle parallelen Aufgaben bis zum Ende ausgeführt und alle parallel aufgetretenen Fehler akkumuliert.
import arrow.core.typeclasses.Semigroup
import arrow.fx.coroutines.parTraverseValidated
fun Iterable<Pair<Long, String>>.createUsers(): ValidatedNel<ValidationError, List<User>> =
ids.parTraverseValidated(Semigroup.nonEmptyList()) { (id, name) -> createUser(id, name) }
Das macht parTraverseValidated zu einer leistungsstarken Funktion für die gleichzeitige Validierung, die ganz ohne Boilerplate auskommt und einfach den Datentyp Validated verwendet. Und parTraverseEither ist eine leistungsstarke Funktion für die Kombination paralleler Operationen, wenn wir nur an dem Ergebnis interessiert sind, wenn alle Aufgaben erfolgreich sind.
Either, Validated und traverse finden Sie innerhalb von Arrow Core, und die parallelen Operatoren finden Sie in Arrow Fx Coroutines.
depdendencies {
def arrowVersion = "0.13.1"
implementation "io.arrow-kt:arrow-core:$arrowVersion"
implementation "io.arrow-kt:arrow-fx-coroutines:$arrowVersion"
}
In diesem Beitrag haben wir gesehen, wie wir unsere Domain verbessern können, indem wir:
- Verwenden Sie
Either, wenn wir nur an dem ersten Fehler ODER dem Erfolgswert interessiert sind. - Verwenden Sie
Validated, wenn wir an allen Fehlern ODER dem Erfolgswert interessiert sind. - Die Verwendung von
traversefürmapundIterable, dieEitheroderValidated - Verwenden Sie
parTraverse, um eineIterableparallel abzubilden, dieEitheroderValidatedzurückgibt.
Einigen von Ihnen ist vielleicht aufgefallen, dass es bei einigen der obigen Codes noch Probleme gibt. Diese werden wir im nächsten und letzten Blog-Beitrag zur funktionalen Domänenmodellierung über Typverfeinerung in [Kotlin] (https://xebia.com/blog/7-common-mistakes-in-kotlin/ "Kotlin") besprechen! Bleiben Sie also dran für mehr funktionale Domänenmodellierung in Kotlin.
Verfasst von
Simon Vergauwen
Arrow maintainer & Principal Engineer @ Xebia Functional from Antwerp, Belgium Working with Kotlin since 2015 & Fanatic OSS'er
Unsere Ideen
Weitere Blogs
Contact



