IDs – wie Äpfel und Birnen?
Als Informatiker lieben wir es, uns kurz zu fassen, und wir lieben es Dinge zu numerieren. Ein wunderschönes Beispiel ist unsere Vorliebe für IDs, ob nun numerisch aufsteigend – wie von vielen Datenbanksystemen angeboten – oder auch bunt gewürfelt in der Geschmacksrichtung GUID/UUID. Solche IDs ermöglichen es uns, sehr kompakt Objekte zu referenzieren – es wäre doch sehr unhandlich, wenn das ganze Objekt z. B. in eine URL codiert werden müsste. In diesem Artikel möchte ich ein einfaches, aber sehr mächtiges Pattern vorstellen, mit dem diese "Platzhalter" ihre Funktion besser erfüllen können. Ich werde im ersten Teil das Pattern in Scala vorstellen und später auch kurz darstellen, wie sich die gleiche Funktionalität in anderen Sprachen wie Java oder Kotlin umsetzen lässt.
Zuerst einmal sind solche IDs wie auch Strings sehr beliebig. Man sieht ihnen von außen absolut nicht an, ob sie nun auf einen Kunden, eine Bestellung, ein Produkt oder evtl. doch einen Kommentar verweisen. Gerade diese Austauschbarkeit kann zu Signaturen führen, die der folgenden ähneln:
def loadReviews(productId: Long, customerId: Long): List[ProductReview]
// ...
loadReviews(customer, product)
Dass dieses Codefragment fehlerhaft ist, ist auf den zweiten Blick offensichtlich – auf den ersten aber leider nicht. Ein einfacher Dreher in der Reihenfolge der Argumente führt zu vorhersehbaren Problem, die aber erst zur Laufzeit entdeckt werden können. Der Compiler kann nicht sicherstellen, dass die Argumente zum Aufruf passen: Es sind schließlich alles nur Long-Werte. Das ist nicht wirklich befriedigend, da wir hier sehr viel eigentlich mechanisiert ausführbare Prüfung verlieren. Viel besser wäre es natürlich, wenn die Sprache uns hier unter die Arme greifen könnte, und diese Fehlerklasse gleich behebt.
Einigen Leser ist sicher schon aufgefallen, dass nicht alle IDs zueinander passen. Die ObjectID eines MongoDB-Dokuments wird nicht mit der Long-Id einer MySQL-Tabelle zusammenpassen. Dieses Prinzip können wir verallgemeinern und einfach jedem Typ von ID auch wirklich einen eigenen Typ spendieren.
Die Grundlagen
Das mag erstmal nach sehr viel Tippaufwand klingen, lässt sich aber durch einige Kniffe minimieren: Zuerst einmal die Tatsache, dass verschiedene IDs zwar unterschiedlich sein sollen, aber nicht zu unterschiedlich. Wir können nämlich einen Typen definieren, der über den "Typ" der ID parametrisiert wird. Zum Beispiel folgendermaßen:
case class Id[Type](value: Long)
Dieses auf den ersten Blick sehr unscheinbare Fragment hat bereits einiges an Wirkung. Was hier ins Auge sticht ist der generische Parameter Type, der scheinbar in der Luft hängt. Er wird nirgendwo im Klassen-Body verwendet, sondern bleibt einfach für sich stehen. Aber das ist in diesem Fall genau das, was wir brauchen: Etwas, das verschiedene IDs voneinander unterscheidet, so dass sie nicht mehr zueinander passen, wenn sie sich in ihrem Typ unterscheiden und ansonsten einen minimalen Impact haben.
def loadReviews(productId: Id[Product], customerId: Id[Customer]): List[ProductReview]
loadReviews(customer, product)
Nehmen wir an, unser Code ist wie im obigen Listing strukturiert: Wenn wir den gleichen Tippfehler wie vorher begehen, erhalten wir schon zur Compilezeit eine nützliche Fehlermeldung:
type mismatch;
found : Id[Customer]
required: Id[Product]
Verwendung & unterstützender Code
Der so definierte Wrapper ist dann wie ein "normaler" ID-typ verwendbar. Wir können ihn in Datenbank-Tabellen genauso verwenden, wie im normalen Applikationscode, und auch in DTOs fühlen sich diese IDs ganz normal zu hause.
Dafür muss unser neu definierter Typ natürlich an die verwendeten Libraries angepasst werden und die notwendigen Codecs definiert werden. Hier zeigt sich ein Vorteil dieses Designs, weil nämlich nur ein einziger entsprechender Codec definiert werden muss: Durch die gemeinsame Klasse ID kann der Codec ohne Ansicht der inneren Typen generiert werden.
Geschlossener oder offener ID-Typ
Hier steht dann die erste größere Designentscheidung an, die in der Anwendung gefällt werden muss: Haben wir einen offenen Typparameter (keine Einschränkung) oder einen geschlossenen (eingeschränkt auf einen basis-sealed trait)?
Im ersten Falle ist kein weiterer Aufwand für die ID "an sich" nötig und es kann nach Bedarf weitere Typen geben. Im zweiten ist die Liste der möglichen ID-Typen beschränkt und "unsinnige" Definitionen wie ID[NoSuchElementException] werden verhindert.
Die geschlossene Variante ist darüber hinaus auch noch in der Lage einen Fall abzubilden, den der offene Fall nicht ohne weiteres lösen kann: Prüfungen zur Laufzeit. Eine geschlossene Implementierung kann darüber hinaus weitere nützliche Funktionen implementieren, wie im folgenden Beispiel:
sealed trait IDType {
def apply(value: String): ID[this.type] = new ID(value, this)
def unapply(id: ID[_]): Option[String] = if (id.typeProof == this) Some(id.value) else None
}
object IDType {
implicit case object CustomerId extends IDType
implicit case object ProductId extends IDType
}
final case class ID[Type <: IDType](value: String, typeProof: Type) {
override def toString: String = value
}
object ID {
type Customer = ID[IDType.CustomerId.type]
type Product = ID[IDType.ProductId.type]
}
Vorteil der geschlossenen Variante ist also, dass noch gezielter definiert werden kann, welche Typen im System vorhanden sein sollen – also noch mehr Fehler im Typsystem gefangen werden können. Nachteil ist natürlich, dass der notationale Aufwand etwas steigt.
In der Verwendung ist der "Tippaufwand" relativ gleichwertig. Durch die apply und unapply-Methoden ist sowohl die Konstruktion als auch das Pattern-matching einfach.
import IDType._
val prod: ID.Product = ProductId("289292")
val ProductId(value) = prod
Allerdings hat der geschlossene Typ auch Nachteile, die nicht unter den Tisch fallen sollten: Neben der Notwendigkeit, mehr "unterstützenden Code" zu definieren, ist es auch mit einem erheblich höheren Sprachlevel verbunden. Wenn wir auf das vorherige Listing schauen, sehen wir unter anderem path dependent types, type aliases, implicits und extractors. Alle diese Features sind eher für erfahrene Entwickler. Um den notwendigen Unterstützungscode bereitzustellen sind dann wahrscheinlich auch noch implicit conversions notwendig.
Prüfen oder nicht prüfen?
Wie wir gerade gesehen haben, hat ein geschlossener ID-Typ auch durchaus Nachteile. Wenn man sich aber für den geschlossenen Typ entscheidet, bleibt trotzdem eine entscheidende Frage. Im obigen Beispiel wird der Typ mit einem "Beweis" konstruiert, der es erlaubt, zur Laufzeit zu entscheiden, ob die ID tatsächlich dem gewünschten Typ entspricht. Das ist im Grunde optional – es ginge auch ohne.
Der Nachteil dieses Beweises ist nämlich relativ einfach festzustellen: Es wird dadurch schwieriger, Codecs zu definieren. Beispielhaft ist ein Json-Codec für den oben beschriebenen ID-Typ in Listing X wiedergegeben. Wie wir sehen, ist dadurch ein wenig komplexeres Konstrukt notwendig, um den Beweis hinzuzufügen.
implicit def readsId[T <: IDType](implicit proof: T): Reads[ID[T]] = {
case JsString(str) => JsSuccess(proof(str))
case other => JsError(s"could not transform $other into an instance of $proof")
}
implicit def writesId[T <: IDType]: Writes[ID[T]] = it => JsString(it.value)
Durch die Kombination der Faktoren (höhere Komplexität, größere Objekte, keine Möglichkeit, die ID als Value Type zu definieren) würde ich normalerweise davon abraten, die geprüfte Variante zu verwenden. Stattdessen ist es – wenn man auf einen geschlossenen Typ setzt – meistens ausreichend, ungeprüft zu arbeiten. Die Fähigkeit, zur Laufzeit die verschiedenen Typen zu unterscheiden, ist selten wirklich notwendig.
Abschluss
Wie wir gesehen haben, ist es mit sehr geringem Aufwand möglich, eine bessere Abstraktion für Identifier als die "primitiven Typen" zu finden, die die sehr viel wahrscheinlicheren Fehler in der Verwendung finden. Dieser Mechanismus ist natürlich nicht perfekt, es kann nach wie vor zu Fehlern kommen. Aber er verursacht keine besonders hohen Kosten, kann alleinstehend verwendet werden und verursacht keine Kosten in anderen Teilen der Anwendung, sondern ist in einer einzigen Stelle konzentriert.
Beide Varianten (offen und geschlossen) sind dabei in der Lage, dieses Ziel zu erreichen. Die Frage, welche Variante am besten passt, ist also nicht generell zu beantworten. Oftmals ist es wahrscheinlich bei relativ kleinen Anwendungen wie Microservices möglich, den Typ offen zu lassen, weil wahrscheinlich wenig Risiko besteht, dass die Anzahl der ID-Typen versehentlich zu groß wird. Je komplexer und größer die Anwendung ist, desto wahrscheinlicher ist es, das der zusätzliche Aufwand der geschlossenen Variante sich auszahlt.
Kotlin
Die Sprache Kotlin bietet einige Möglichkeiten nicht, die Scala bietet. Da die Sprache die für das geschlossene System entscheidende fähigkeit hat, sealed Typen zu definieren können beide pattern umgesetzt werden. Wir können über die Singleton-typen von object-Instanzen hier genau wie die Singleton-typen im Scala-Beispiel verwenden, lediglich der Syntax unterscheidet sich ein wenig:
sealed class IdType {
companion object {
object Product : IdType() {
operator fun invoke(value: Long) = KotlinId(value, this)
}
object Customer : IdType() {
operator fun invoke(value: Long) = KotlinId(value, this)
}
}
}
class KotlinId<Type : IdType>(val value: Long, val proof: Type) {
operator fun component1() = value
override fun equals(other: Any?): Boolean = other is KotlinId<*> && other.proof == proof && other.value == value
override fun hashCode(): Int = value.hashCode()
override fun toString(): String = value.toString()
}
typealias CustomerId = KotlinId<IdType.Companion.Customer>
typealias ProductId = KotlinId<IdType.Companion.Product>
fun example() {
val customer = IdType.Companion.Customer(38383)
val (raw) = customer
}
Wie wir sehen, ist die geprüfte Variante in Kotlin leider nur schwerer umsetzbar, da weder path dependent types- noch implicit- Parameter zur Verfügung stehen. Wir sind hier also gezwungen, für jeden Typ doch wieder "händisch" Code zu definieren. Die offene Variante und die nicht geprüfte geschlossene sind jedoch beide umsetzbar und mit wenig Overhead sehr angenehm umsetzbar.
Java
Java ist mit Abstand die am stärksten eingeschränkte Sprache, die ich in diesem Artikel bespreche. Durch das Fehlen eines direkten Analogs zum sealed trait ist es hier nicht möglich, ohne weiteres einen Typparameter zu definieren, der wirklich eingeschränkt ist. Dadurch ist es schwierig bis unmöglich, die geschlossene Version des Patterns zu benutzen. Stattdessen steht für Java praktisch nur die offene Version zur Verfügung, die sich aber glücklicherweise auch relativ einfach und gut lesbar gestaltet:
public final class JavaId<T> {
private final long value;
public JavaId(long value) { this.value = value; }
public long getValue() { return value; }
@Override
public int hashCode() { return Long.hashCode(value); }
@Override
public boolean equals(Object obj) { return obj instanceof JavaId && ((JavaId<?>) obj).value == value; }
@Override
public String toString() { return Long.toString(value); }
}