This post was originally published at 47deg.com on November 30, 2021.
In früheren Blogbeiträgen haben wir gesehen, wie Sie Ihre Domäne mit den Klassen data, enum und sealed modellieren und wie Sie die Validierung mit funktionalen Programmiertechniken beschreiben. Wie Sie Daten modellieren, ist ein sehr wichtiger Teil des Designs Ihrer Software. Die andere wichtige Seite ist, wie Sie Ihr Verhalten modellieren. Darüber werden wir heute sprechen: wie Sie Ihren Code mit Effekten vereinfachen und dabei schwergewichtige Dependency Injection Frameworks vermeiden können.
Für jede einzelne Funktion, die Sie schreiben, benötigen Sie oft einige Hauptdaten und einen Haufen zusätzlicher Teile, den Kontext. Ein typisches Beispiel ist eine Funktion, die einige Benutzerinformationen in die Datenbank schreibt: Neben der User selbst benötigen Sie eine DatabaseConnection und vielleicht eine Möglichkeit, log potenzielle Probleme zu lösen. Eine Möglichkeit ist, alles zu einem Parameter zu machen, wie folgt.
suspend fun User.saveInDb(conn: DatabaseConnection, logger: Logger) {
// prepare something
val result = conn.execute(update)
if (result.isFailure) logger.log("Big problem!")
// keep doing stuff
}
Das hat einen Vorteil: alles ist eindeutig. Sie wissen genau, was Sie brauchen, um diese Funktion auszuführen. Aber es gibt auch einen großen Nachteil: alles ist explizit. Das bedeutet, dass Sie all diesen Kontext manuell in Ihre Funktionen einfügen müssen. Das bedeutet auch, dass Sie Ihren Code mit einer Menge Standardformulierungen verunstalten und die Wartbarkeit beeinträchtigen, da jede Änderung des für einen Teil des Codes erforderlichen Kontexts einen Dominoeffekt im restlichen Code auslösen kann.
Dieses Problem ist natürlich nicht neu; es handelt sich um unseren alten Freund Dependency Injection (DI). Kurz gesagt, wir wollen alle Teile haben, die wir brauchen, aber wir wollen sie nicht miteinander verknüpfen. Die Welt kennt keinen Mangel an DI-Frameworks, aber fast alle von ihnen leiden unter zwei Nachteilen:
- Abhängigkeiten werden nicht als Teil des Typs reflektiert, so dass sie Teil eines versteckten Vertrags werden. Wir bevorzugen die explizite Darstellung aus zwei Gründen: (1) der Rest des Teams kann die Abhängigkeiten entdecken, ohne in den Körper der Funktion oder ihre Verwendung zu schauen, und (2) der Compiler kann für uns einige Sicherheitsprüfungen durchführen.
- Dependency Injection verwendet nicht dasselbe Vokabular wie der Rest des Codes. Wir bevorzugen Möglichkeiten, dieses Problem mit Hilfe von Funktionen der Sprache zu modellieren, anstatt uns auf Magie wie die Verarbeitung von Anmerkungen zu verlassen.
Abhängigkeiten als Empfänger
Die Technik, die wir Ihnen heute vorstellen möchten, geht von einem ähnlichen Ausgangspunkt aus wie viele andere: Definieren Sie jede Ihrer Abhängigkeiten als Schnittstellen, die wir gewöhnlich Effekte nennen. Denken Sie daran, alle Ihre Funktionen mit suspend zu kennzeichnen. Dies ermöglicht eine bessere Kontrolle sowohl auf der Ebene der Effekte als auch auf der Laufzeitebene. Im Falle einer Datenbankverbindung kann die Schnittstelle so einfach sein wie eine Funktion, die sie zurückgibt.
interface Database {
suspend fun connection(): DatabaseConnection
}
Wir empfehlen jedoch immer, eine eingeschränktere API bereitzustellen. In diesem Fall können wir, anstatt die gesamte Kontrolle über die Verbindung aufzugeben, eine Möglichkeit zur Ausführung einer Abfrage anbieten. Dies öffnet zum Beispiel die Tür zu execute, um die Abfragen in eine Warteschlange zu stellen.
interface Database {
suspend fun <A> execute(q: Query<A>): Result<A>
}
Unabhängig von Ihrer Wahl, besteht der letzte Schritt darin, diese Schnittstelle zu Ihrem Empfängertyp zu machen. Infolgedessen muss Ihr User nun als Argument erscheinen und Sie können execute direkt aufrufen, da es von der Empfängerschnittstelle Database stammt.
suspend fun Database.saveUserInDb(user: User) {
// prepare something
val result = execute<User>(update)
// do more things
}
Diese Lösung ist explizit, da Database als Teil des Typs von saveUserInDb erscheint. Sie vermeidet jedoch eine Menge Boilerplate, indem sie eine [Kotlin](https://xebia.com/blog/7-common-mistakes-in-kotlin/ "Kotlin") Funktion verwendet: Empfänger.
Injizieren
Wir können verschiedene Inkarnationen von Database bereitstellen, indem wir neue Objekte erstellen, die die oben genannte Schnittstelle implementieren. Am einfachsten ist es, einfach ein DatabaseConnection zu wickeln, wie wir es zuvor getan haben:
class DatabaseFromConnection(conn: DatabaseConnection) : Database {
override suspend fun <A> execute(q: Query<A>): Result<A> =
conn.execute(q)
}
Das ist nicht die einzige Möglichkeit: Die Ausführung kann in einem Pool von Verbindungen funktionieren, oder wir könnten eine In-Memory-Datenbank zum Testen verwenden.
class DatabaseFromPool(pool: ConnectionPool) : Database { ... }
class InMemoryDatabase() : Database { ... }
Die Scope-Funktion with ist eine der idiomatischen Möglichkeiten von Kotlin, den Wert eines Empfängers bereitzustellen. In den meisten Fällen würden wir einen Block direkt danach verwenden, in dem Database bereits zur Verfügung gestellt wird.
suspend fun main() {
val conn = openDatabaseConnection(connParams)
with(DatabaseFromConnection(conn)) {
saveUserInDb(User("Alex"))
}
}
Einige Teams ziehen es vor, das gesamte Erstellen des Wrapping-Objekts + den Aufruf von with in spezialisierten Runner-Funktionen zu bündeln. In diesem Fall zeigt sich ein klares Muster, bei dem der letzte Parameter eines solchen Läufers eine Funktion ist, die diesen Empfänger verbraucht.
suspend fun <A> db(
params: ConnectionParams,
f: suspend Database.() -> A): A {
val conn = openDatabaseConnection(connParams)
return with(DatabaseFromConnection(conn), f)
}
suspend fun main() {
db(connParams) { saveUserInDb(User("Alex")) }
}
Immer aussetzen
Starke Aussagen erfordern starke Begründungen, und wir haben eine gegeben, als wir sagten, dass wir die Funktionen in unseren Effekten immer mit dem Modifikator suspend markieren. Um die Rechtfertigung zu verstehen, müssen wir ein wenig auf den Unterschied zwischen der Beschreibung einer Berechnung und ihrer tatsächlichen Ausführung eingehen.
Meistens denken wir, dass Berechnungen "auf der Stelle" stattfinden. Wann immer das Programm auf println("Hello!") ankommt, wird es sofort ausgeführt. Dieses Modell ist einfach, aber aus mehreren Gründen auch problematisch:
- Wenn die
printlnin einem Code enthalten war, den wir nicht kontrollieren, zwingt sie uns einen so genannten Nebeneffekt auf, den wir nicht einfach handhaben oder loswerden können. - Die unmittelbare Ausführung des Codes verwehrt uns die Möglichkeit, die Parameter dieser Ausführung zu ändern: Vielleicht wollten wir, dass sie in einem anderen Thread stattfindet. Zum Beispiel, weil dies Teil einer grafischen Oberfläche ist, bei der einige Operationen in einem bestimmten "UI-Thread" ausgeführt werden müssen.
Der suspend Modifikator in Kotlin vermeidet diese Probleme, da eine solche Funktion nicht sofort ausgeführt wird. Wir können diese Funktionen immer noch zusammenstellen - Kotlin macht es uns sogar so leicht, dass es so aussieht, als würden wir "normalen" Code schreiben -. Aber () -> Int vorstellen: eine solche Funktion ist noch keine ganze Zahl, sondern ein Rezept, um eine solche zu erhalten.
Sobald Sie die gesamte Beschreibung erstellt haben, können Sie sie auf viele verschiedene Arten ausführen. Die einfachste davon ist runBlocking. Indem wir alle Funktionen in unseren Effekten als suspend kennzeichnen, geben wir sowohl den Implementierern von Instanzen (die z.B. zusätzliches Threading einführen können) als auch den Endbenutzern der Effekte (die letztendlich entscheiden können, wie die anfängliche Berechnung gestartet werden soll) die Freiheit.
Wenn Sie Ihre Augen ein wenig zusammenkneifen, sieht der Typ von runBlocking der oben definierten Funktion db verdächtig ähnlich.
fun <T> runBlocking(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> T): T
Hier ist CoroutineScope der Effekt, der sich auf die Handhabung der Gleichzeitigkeit bezieht, da er Funktionen wie cancel oder launch bereitstellt.
Der Gedanke, die Beschreibung von der Ausführung von Code zu trennen, ist keineswegs neu. Die Scala-Gemeinschaft spielt schon seit langem mit dieser Idee, wie Cats Effect oder ZIO zeigen. Diese Bibliotheken erreichen jedoch nicht das gleiche Maß an Integration wie die in Kotlin integrierte suspend. Einen tieferen Einblick in die Verwendung von suspend als Effekte finden Sie in der Dokumentation von Arrow Fx.
Mehr als eine Abhängigkeit
Ich weiß, was Sie jetzt denken: "Wo ist die Logger?" In der Tat erfordert diese Technik ein wenig Raffinesse, wenn Ihr Kontext aus mehr als einem Effekt besteht. Lassen Sie uns damit beginnen, die Schnittstelle für die Protokollierung zu definieren:
interface Log {
suspend fun log(message: String): Unit
}
Und jetzt kommt der Trick: Da Kontexte Schnittstellen sind, können wir beide Effekte als eine Unterklasse von beiden darstellen. Dazu verwenden wir eine nicht so bekannte Funktion in Kotlin: where Klauseln. Einfach ausgedrückt: where Klauseln erlauben es uns, mehr als eine Obergrenze für einen generischen Typ zu definieren. Lassen Sie uns das in Aktion sehen:
suspend fun <Ctx> Ctx.saveUserInDb(user: User)
where Ctx : Database, Ctx : Log {
// prepare something
val result = execute(update)
if (result.isFailure) log("Big problem!")
// keep doing stuff
}
Dieses Muster wächst Ihnen langsam über den Kopf, und am Ende definieren Sie Ihre Funktionen immer mit einem Kontextempfänger Ctx, dessen Auswirkungen Sie später mit where als Obergrenzen definieren.
Das Hauptproblem ist nun, wie Sie die benötigten Instanzen von Database und Log injizieren, damit Sie saveUserInDb aufrufen können. Leider funktioniert das Folgende nicht, auch wenn wir diese beiden Instanzen zur Hand haben.
suspend fun main() {
db(connParams) { stdoutLogger {
saveUserInDb(User("Alex"))
} }
}
Das Problem ist, dass saveUserInDb erwartet, dass beide in ein einziges Kontextobjekt gepackt werden. Wir können das Problem umgehen, indem wir ein Objekt an Ort und Stelle erstellen und die Delegation verwenden, um auf die zuvor erstellten Instanzen zu verweisen.
suspend fun main() {
db(connParams) { stdoutLogger {
with(object : Database by this@db, Logger as this@stdoutLogger) {
saveUserInDb(User("Alex"))
}
} }
}
Die wichtigste Einschränkung besteht darin, dass Sienicht zwei Werte injizierenkönnen, deren Typ nur von unterschiedlichen Typparametern abhängt, weil die JVM sie löscht. Stellen Sie sich zum Beispiel vor, wir hätten einen generischen Effekt "Umgebungswert" definiert.
interface Environment<A> {
suspend fun get(): A
}
Dann können wir keinen Kontext definieren, der Environment<ConnectionParams> und Environment<AppConfig> erfordert. In der Praxis ist dies nicht so problematisch, da Sie oft spezialisiertere Schnittstellen einführen und Ihre Verwendung einschränken wollen, wie oben für Database beschrieben.
Blick in die Zukunft
Die Zukunft sieht für Kotliners in dieser Hinsicht recht rosig aus. Die Sprachdesigner arbeiten an einer neuen Funktion: Kontextempfänger die ein saubereres Design für das, was wir mit Subtyping beschreiben, ermöglichen würde. So wie die Funktion jetzt aussieht, werden wir in der Lage sein, zu erklären:
context(Database, Logger)
fun User.saveInDb() { ... }
und injizieren Sie die Werte, indem Sie einfach die Aufrufe von db und stdoutLogger verschachteln.
Kontexte, Auswirkungen, Algebren
In der Kotlin-Dokumentation ist oft von Kontexten die Rede, wenn diese Verwendung von Empfängern beschrieben wird. Wir verwenden stattdessen oft effect. Dieser Begriff wird in der funktionalen Programmierung verwendet, um alles zu beschreiben, was eine Funktion außerhalb der Datenmanipulation tut. Einige Verwendungen in dieser Hinsicht sind "effect handlers" in vielen Haskell-Bibliotheken oder Cats effect in der Scala-Community.
Diese Technik ist auch mit dem taglosen Finale verwandt, wie es in Scala verwendet wird. In diesem Fall würden wir unsere Schnittstelle Database als eine Algebra bezeichnen. Es gibt jedoch einen wichtigen Unterschied: Während tagless final eine schwergewichtige Typ-Maschinerie verwendet, die nur in Scala (und Haskell) verfügbar ist, verwendet die Technik in diesem Blogpost einfachere Mechanismen, die Kotlin bietet.
Fazit
Wenn wir die Funktionen von Kotlin - Receiver, Scope-Funktionen, where Klauseln - sinnvoll nutzen, können wir den Kontext, der für die Ausführung unseres Codes erforderlich ist, kurz und bündig beschreiben. Dadurch werden DI-Frameworks überflüssig, während gleichzeitig die Anforderungen deutlicher werden, was zu besseren Compiler-Prüfungen führt. In einer der nächsten Ausgaben werden wir sehen, wie die scheinbar unbedeutende Ergänzung von suspend es uns ermöglicht, noch komplexere Effekte zu modellieren.
Verfasst von
Alejandro Serrano Mena
Unsere Ideen
Weitere Blogs
Contact




