Blog
Typsichere Fehlerbehandlung mit gestaltlosen Koprodukten in Scala

Einführung
Der Fehlerbehandlung wird oft nicht die Aufmerksamkeit geschenkt, die sie verdient. Wenn man von imperativen Sprachen kommt, ist es super einfach, überall Ausnahmen zu machen. Doch nur weil es einfach ist, heißt das noch lange nicht, dass es eine gute Praxis ist. Es unterbricht die referenzielle Transparenz und macht es schwierig, über den Code nachzudenken. Java hat zwar geprüfte Ausnahmen, aber das Problem verschwindet dadurch nicht, denn die referenzielle Transparenz wird dadurch immer noch unterbrochen. Es gibt noch viele weitere Gründe, warum das "Werfen von Ausnahmen" keine gute Praxis ist, aber darauf werde ich heute nicht eingehen. Meine Sorge bei der Verwendung von Ausnahmen ist, dass wir in vielen Fällen nicht über den "außergewöhnlichen" Teil sprechen, der in dem Wort "Ausnahme" enthalten ist.
Werfen wir einen Blick auf die Cambridge-Definition des Wortes "Ausnahme". Wenn wir uns die technischen Ausnahmen ansehen, kann ich dem letzten Teil der Definition voll und ganz zustimmen:
Ausnahme: "Jemand oder etwas, das nicht in einer Regel, Gruppe oder Liste enthalten ist oder das sich nicht in der erwarteten Weise verhält." --- Cambridge Wörterbuch
Hier finden Sie einige Beispiele für unerwartetes Verhalten aufgrund technischer Probleme:
- Während des Speicherns von Daten in einer Datenbank wird die Verbindung unterbrochen und die Daten können nicht gespeichert werden.
- Beim Hochladen von Bildern auf einen Server wird die Verbindung unterbrochen und das Bild kann nicht vollständig gesendet werden.
- Während der Verarbeitung einer Kafka-Nachricht wird der Client beendet und kann die Nachricht nicht verarbeiten.
Dies sind alles Beispiele für Prozesse, die sich irgendwie nicht erwartungsgemäß verhalten haben. Die Ausnahme ist hier auf ein technisches Problem zurückzuführen. Betrachtet man jedoch einen reinen Bereich, so sind viele Ausnahmen keine Ausnahmen.
Nachfolgend finden Sie einige Beispiele für alltägliche Situationen, die sich nicht erwartungsgemäß verhalten haben:
- Bevor wir einen neuen Benutzer registrieren, überprüfen wir die Daten und stellen fest, dass ein Feld fehlt. Daraufhin senden wir eine 400 bad-Anfrage an den Client zurück.
- Während der Verarbeitung einer Finanztransaktion vermutet das System , dass etwas nicht stimmt, informiert die Benutzer, dass etwas schief gelaufen ist, und benachrichtigt automatisch das Betrugsteam, um die Transaktion zu untersuchen.
- Wenn Sie versuchen, mit einer Geschenkkarte zu bezahlen, stellt das System fest, dass nicht genug Guthaben auf der Geschenkkarte vorhanden ist, um die Bestellung abzuschließen, und der Benutzer wird entsprechend informiert.
Die oben genannten Beispiele gehören zu Bereichen, in denen wir davon ausgehen, dass diese Situationen eintreten können und daher erwartet werden. Auch wenn die Wahrscheinlichkeit, dass sie eintreten, gering ist, sind dies Situationen, die wir erwarten. Anstatt Ausnahmen zu missbrauchen, können wir Fehler zu einem Teil unserer Domäne machen und das Konzept eines Domänenfehlers einführen. Der Vorteil der Verwendung eines anderen Typs (d.h. eines Typs, der nicht von Throwable erbt) ist, dass wir sie explizit behandeln müssen. Es gibt keine Möglichkeit, sie zu werfen; daher können wir keine try/catch-Konstruktion verwenden. Es gibt jedoch einen Nachteil bei der Verwendung von versiegelten Merkmalen.
In diesem Artikel werden wir eine einfache Kommandozeilenanwendung zum Dividieren von Zahlen erstellen. Wir gehen einige Iterationen des Codes durch und ich zeige Ihnen, auf welche Probleme wir stoßen werden und wie Shapeless-Koprodukte uns dabei helfen können.
Domainfehler sind überall
Warum sollten wir wieder Domänenfehler verwenden? Schauen wir uns die folgende Methode an:
def divide(x: Int, y: Int) = x / y
Der obige Code ist prägnant und lesbar, aber wir informieren den Verbraucher unserer Methode nicht darüber, dass sie Fehler produzieren kann. Das Dividieren durch Null sollte nicht möglich sein. Ein ArithmeticException wird ausgelöst, wenn der Verbraucher es wagt, es zu versuchen. Das macht jedoch nicht viel Sinn. Wir wissen bereits, dass wir nicht durch Null dividieren können, also ist das keine Ausnahme. Es ist Teil unserer Domäne, und wir sollten unsere Programmierkollegen darüber informieren, dass wir keine Divisionen durch Null zulassen. Wenn wir das ausdrücklich sagen, werden unsere Kollegen verstehen, dass die Methode eine Fehlerbehandlung erfordert. Wir sollten uns immer bemühen, Fehlerbehandlungsprogramme zu erstellen, die vollständige Funktionen sind. Auf diese Weise kann der Compiler uns helfen, wenn wir versehentlich nicht alle Fehler behandeln. Andernfalls verlieren Sie jeglichen Nutzen und können genauso gut Ausnahmen verwenden.
Warum versiegelte Züge nicht die beste Option sind
Eine gängige Methode, mit Fehlern umzugehen, ist die Verwendung einer versiegelten Eigenschaft. Wenn wir uns unsere kleine Befehlszeilen-Applikation "Zahlendividieren" ansehen, wissen wir, dass wir auf mindestens zwei Probleme stoßen können:
- Die Umwandlung von Eingaben aus der Befehlszeile in eine Zahl kann schief gehen
- Dividieren durch Null ist nicht möglich
Durch die Einführung einer versiegelten Eigenschaft könnten wir diese Fehler in unserer Codebasis wie folgt dokumentieren:
sealed trait DomainError
case object DivideByZeroError extends DomainError
case class ParseNumberError(value: String) extends DomainError
Durch die Verwendung von Traits müssen wir anfangen, über die Strukturierung der Fehlerhierarchie nachzudenken.
- Erben alle Domänenfehler von einer Wurzel? (z.B.
DomainErrorim obigen Beispiel) - Führen wir Unterbereiche für Fehler ein, die einen gemeinsamen Vorgänger haben können oder auch nicht? (z.B. pro Endpunkt, pro Funktionalität oder vielleicht etwas anderes?)
In jedem Fall werden wir nicht begeistert sein.
Im ersten Szenario werden wir nicht in der Lage sein, genau zu beschreiben, welche Fehler wir in dem Moment zurückbekommen, in dem wir anfangen, Methoden zu komponieren. Da alle Fehler von DomainError geerbt werden, können wir nicht verstehen, welche Fehler tatsächlich auftreten können. Weitere Einzelheiten entnehmen Sie bitte dem folgenden Beispiel.
sealed trait DomainError
case class ErrorA() extends DomainError
case class ErrorB() extends DomainError
case class ErrorC() extends DomainError
object SomeClass {
def methodA(s: String): Either[ErrorA, String] = if (s.isEmpty) Left(ErrorA()) else Right(s)
def methodB(s: String): Either[ErrorB, Int] = s.toIntOption.fold[Either[ErrorB, Int]](Left(ErrorB()))(Right.apply)
// This method has to return DomainError and is no longer accurate, as it can only ever return ErrorA or ErrorB.
def methodAB(s: String): Either[DomainError, Int] = methodA(s).flatMap(methodB)
}
Im zweiten Szenario werden wir Schwierigkeiten beim Zusammenstellen von Methoden haben. In dem Moment, in dem die Subdomänen zu interagieren beginnen, müssen wir sie auf einen neuen Fehler übertragen, der der anderen Subdomäne bekannt ist. Das bedeutet, dass wir bei jeder Interaktion eine Duplikation einführen, und zwar als
Die mangelnde Transparenz, welche Fehler zurückgegeben werden, ist nicht zu übersehen. Eine Methode, die DomainError in einer Codebasis zurückgibt, in der wir Hunderte von Domänenfehlern definiert haben könnten, bietet keinerlei Hilfe. Der Verbraucher unseres Ansatzes müsste unseren Code untersuchen, um zu verstehen, welche Domänenfehler tatsächlich auftreten können. Gleichzeitig kann uns unser Compiler nicht mehr helfen, wenn wir versehentlich einen Fehlerfall vergessen haben. Gesamtfunktionen werden nutzlos, wenn wir anfangen, Fälle einzubeziehen, die niemals auftreten können. In großen Codebasen ist es außerdem unmöglich, zahlreiche Codeschichten zu durchlaufen, um herauszufinden, was zurückgegeben wird. Das bremst Sie als Programmierer aus und gibt Ihnen gleichzeitig nichts als ein Bauchgefühl. Haben Sie alles gründlich geprüft? Könnte es sein, dass Sie einen Fehlerfall übersehen haben? Denken Sie nur an die automatische Aktualisierung von Abhängigkeiten und Sie werden schnell feststellen, dass Sie ständig den Code von Drittanbietern durchgehen müssten, um herauszufinden, was Sie aus der Fehlerperspektive erwarten können.
Lassen Sie Fehler zu Bürgern erster Klasse werden! Wenn wir alles explizit machen können, haben wir keines der oben genannten Probleme. Wenn Abhängigkeiten aktualisiert und neue Fehlerfälle eingeführt werden, kommt es automatisch zu Kompilierfehlern, da unsere vollständige Funktion, die die Fehler behandelt, nicht mehr alle Fälle abdeckt. Keine Zeitverschwendung. Und Sie wissen genau, wo Sie was korrigieren müssen. Leider gibt es in Scala 2.x keine Klasse oder Typklasse, die wir verwenden könnten:
- Die Typklasse
Optionbefasst sich mit der Optionalität eines einzelnen Typs. Wir wissen jedoch bereits, dass eine Methode wahrscheinlich mehr als einen einzigen Fehler produzieren kann. Wenn Sie Methoden zusammenstellen, wird die Wahrscheinlichkeit noch größer. - Die Typklasse
Eitherhat eine linke und eine rechte Projektion, wobei die linke für Fehlerfälle verwendet werden kann. Die linke Projektion hat nur Platz für einen einzelnen Typ, während wir mehr brauchen. - Die Typklasse
Listkann mehrere Werte speichern, aber nur für einen einzigen Typ.
Wir brauchen eine Art Struktur EitherN, die es uns ermöglicht, stattdessen zahlreiche Fehlertypen zurückzugeben, die nicht miteinander verbunden sein können und keinen gemeinsamen Vorfahren benötigen. Genau das werden wir mit Shapeless-Koprodukten erreichen.
Was ist eine HList?
Um mit verschiedenen Fehlern umgehen zu können, benötigen wir eine List Struktur, die verschiedene Typen speichern kann, ohne die Typsicherheit zu verlieren. Werfen wir einen Blick auf die Typklasse HList. Sie bietet eine Möglichkeit, eine Liste mit mehr als einem einzigen Typ zu erstellen. Erinnern Sie sich, dass die Typklasse HList können wir Listen mit mehr als nur einem Typ erstellen.
Siehe das folgende Beispiel für eine Liste, die mehrere Werte enthält (1, "world", 1.5) von verschiedenen Typen (String, Int, Double):
import shapeless.HNil
import shapeless.::
val hlist: String :: Int :: String :: Double :: HNil = "hello" :: 1 :: "world" :: 1.5 :: HNil
Was ist ein Koprodukt?
Koprodukt ist eine besondere Art von HList. Sie können es sich als Either in Scala vorstellen, mit einer beliebigen Anzahl von Auswahlmöglichkeiten.
import shapeless.{:+:, CNil, Coproduct}
type MyType = String :+: Int :+: Double :+: CNil
val coproduct: MyType = Coproduct[MyType](1.5)
In dem obigen Code enthält das Koprodukt entweder einen String, Int oder einen Double Wert.
Iterationen
Lassen Sie uns mit dem Code spielen, 7 Iterationen durchlaufen und versuchen, den Code jedes Mal so weit wie möglich zu verbessern.
Iteration 1: Keine Fehlerbehandlung
Kehren wir zu unserer Divide-App zurück und beginnen wir, ohne an die Fehlerbehandlung zu denken:
import scala.io.StdIn.readLine
object DivideAppIteration1 extends App {
// Can throw an ArithmeticException when dividing by zero.
def divide(x: Double, y: Double): Double = x / y
// Potential IOException when using readLine.
val x0 = readLine
val y0 = readLine
// Potential parse errors when converting String to Double.
val x = x0.toInt
val y = y0.toInt
println(divide(x, y))
}
Wir wissen bereits, dass dieser Code nicht widerstandsfähig ist:
- Wir verhindern keine Division durch Null.
- Wir fangen keine Parse-Fehler ab.
- Wir fangen die möglichen
IOExceptionnicht ab, die auftreten können.
Wussten Sie, dass readLine eine IOException auslösen kann? Das geht aus der eigentlichen Signatur der Methode überhaupt nicht hervor:
def readLine(): String = in.readLine()
Wenn wir eine Ebene tiefer gehen, sehen wir den folgenden Java-Code:
public String readLine() throws IOException {
return readLine(false, null);
}
Dies zeigt deutlich, wie wichtig es ist, Fehler zu Bürgern erster Klasse in einem Programm zu machen. Wenn Sie nicht explizit wissen, welche Fehlerfälle auftreten können, werden Sie auf Laufzeitfehler stoßen oder müssen jede Codezeile selbst überprüfen. Nicht nur in Ihrem Code, sondern auch in allen Bibliotheken, die Sie möglicherweise verwenden. Nicht wirklich eine Option, wenn Sie die Dinge gerne erledigen...
Iteration 2: Entweder verwenden
Versuchen wir es noch einmal mit unserer Anwendung und machen sie etwas robuster, indem wir die Typklasse Either verwenden:
import scala.io.StdIn.readLine
import scala.util.Try
object DivideAppIteration2 extends App {
// Using sealed traits to distinguish the type of errors
sealed trait DomainError
case object DivideByZeroError extends DomainError
case class ParseNumberError(value: String) extends DomainError
// Dividing an error now explicitly shows what kind of error it could produce.
def divide(x: Double, y: Double): Either[DomainError, Double] =
if (y == 0) Left(DivideByZeroError)
else Right(x / y)
// This now, unfortunately became an Object due to the mixing of DomainError and IOException
def runDivide: Either[Object, Double] = for {
x0 <- Try(readLine).fold(Left.apply, Right.apply)
y0 <- Try(readLine).fold(Left.apply, Right.apply)
x <- x0.toDoubleOption
.fold[Either[ParseNumberError, Double]](Left(ParseNumberError(x0)))(Right.apply)
y <- y0.toDoubleOption
.fold[Either[ParseNumberError, Double]](Left(ParseNumberError(y0)))(Right.apply)
r <- divide(x, y)
} yield r
runDivide.fold(
// As err is of type Object, we cannot create any sensible error handler based on the type
err => println(s"An error occurred: $err"),
result => println(s"Result: $result")
)
}
Obwohl der obige Code gut läuft, hat sich die Methode runDivide in ein untypisiertes Chaos verwandelt. Object beschreibt nicht genau, welche Art von
Fehlern wir erwarten können. Das liegt daran, dass DomainError und IOException vom Standpunkt der Vererbung aus gesehen nur Object gemeinsam haben.
Iteration 3: Schaffung von Typsicherheit mit Koprodukten
Schauen wir uns an, wie wir die Typsicherheit mit Shapeless-Koprodukten verbessern können:
import shapeless.{:+:, CNil, Coproduct}
import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try
object DivideAppIteration3 extends App {
// Notice that creating a sealed trait error hierarchy is no longer necessary.
case object DivideByZeroError
case class ParseNumberError(input: String)
// Definition of the errors that can occur
type TryDivideError = DivideByZeroError.type :+: CNil
type TryReadLineError = IOException :+: CNil
type TryParseNumberError = ParseNumberError :+: CNil
def tryDivide(x: Double, y: Double): Either[TryDivideError, Double] =
if (y == 0) Left(Coproduct(DivideByZeroError))
else Right(x / y)
def tryReadLine = Try(readLine).fold[Either[TryReadLineError, String]](
ex => Left(Coproduct(new IOException(ex))),
Right.apply
)
def tryParseDouble(s: String) =
s.toDoubleOption.fold[Either[TryParseNumberError, Double]](
Left(Coproduct(ParseNumberError(s)))
)(Right.apply)
type RunDivideError = TryReadLineError :+: TryParseNumberError :+: TryDivideError :+: CNil
// Yes! We now have an actual error type that accurately describes the error cases.
def runDivide: Either[RunDivideError, Double] = for {
x0 <- tryReadLine.left.map(Coproduct[RunDivideError](_))
y0 <- tryReadLine.left.map(Coproduct[RunDivideError](_))
x <- tryParseDouble(x0).left.map(Coproduct[RunDivideError](_))
y <- tryParseDouble(y0).left.map(Coproduct[RunDivideError](_))
r <- tryDivide(x, y).left.map(Coproduct[RunDivideError](_))
} yield r
runDivide.fold(println, println)
}
Jede Methode beschreibt immer noch explizit, was ihre Fehlersignatur ist. Der große Unterschied ist, dass wir jetzt ein neues Koprodukt RunDivideError eingeführt haben, das entweder eines der Koprodukte ist, die jede bestimmte Methode erzeugt (entweder ein TryReadLineError, TryParseNumberError oder TryDivideError oder):
// Each individual error defined.
type TryDivideError = DivideByZeroError.type :+: CNil
type TryParseNumberError = ParseNumberError :+: CNil
type TryReadLineError = IOException :+: CNil
// A composition of errors we expect for the divide 'flow'.
type RunDivideError = TryDivideError :+: TryReadLineError :+: TryParseNumberError :+: CNil
Um eine RunDivideError zurückgeben zu können, müssen wir sicherstellen, dass das Ergebnis jeder Methode verbreitert wird. Wir können das tun, indem wir das Ergebnis in ein neues Koprodukt wie:
Coproduct[RunDivideError](resultOfTheMethod) // Widens the type returned from the method.
Hier gibt es ein kleines "Problemchen". Da Koprodukte nichts anderes als eine spezielle Art von HList sind, bedeutet die Definition von RunDivideErrors unten, dass wir doppelte Typen in unserem Fehlerkanal haben könnten. Das ist nicht unbedingt ein Problem, aber es wäre zumindest gut, es zu erwähnen.
TryDivideError :+: TryReadLineError :+: TryParseNumberError :+: CNil
Lassen Sie uns ein einfaches Beispiel erstellen, das das potenzielle Problem der Typenduplizierung schnell veranschaulicht und wie wir es lösen können:
import shapeless.{:+:, CNil, Coproduct}
// Error definitions, there's a bit of overlap on the types.
type Method1Error = String :+: Int :+: CNil
type Method2Error = String :+: Double :+: CNil
type Method3Error = String :+: Int :+: Double :+: CNil
def method1 = Coproduct[Method1Error]("error 1")
def method2 = Coproduct[Method2Error]("error 2")
def method3 = Coproduct[Method3Error]("error 3")
// Wrapping in a new coproduct.
type StringErrors = Method1Error :+: Method2Error :+: Method3Error :+: CNil
val result1: String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil = Coproduct[StringErrors](method1).adjoined
val result2: String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil = Coproduct[StringErrors](method2).adjoined
val result3: String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil = Coproduct[StringErrors](method3).adjoined
// Embedding in an existing coproduct.
type StringError = String :+: Int :+: Double :+: CNil
val result4: String :+: Int :+: Double :+: CNil = method1.embed[StringError]
val result5: String :+: Int :+: Double :+: CNil = method2.embed[StringError]
val result6: String :+: Int :+: Double :+: CNil = method3.embed[StringError]
In dem obigen Beispiel sehen Sie, dass es einen großen Unterschied zwischen den beiden Ansätzen gibt.
Wenn Sie das Ergebnis in ein neues Koprodukt packen, entsteht ein größeres Koprodukt:
String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil
Das Einbetten flacht das Koprodukt ab und gibt nur die eindeutigen Typen zurück:
String :+: Int :+: Double :+: CNil
Beachten Sie, dass die Methode adjoined verwendet wird, um den Typ StringErrors in ein abgeflachtes Koprodukt zu verwandeln, um zu zeigen, dass es eine Typverdopplung gibt.
Iteration 4: Koprodukte einbetten
Da wir für unsere Anwendung nur an einer reduzierten Darstellung unserer Domänenfehler interessiert sind, verwenden wir die Funktion embed (einbetten), anstatt Koprodukte einzuschließen.
import shapeless.ops.adjoin.Adjoin
import shapeless.{:+:, CNil, Coproduct, Poly1}
import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try
object DivideAppIteration4 extends App {
case object DivideByZeroError
case class ParseNumberError(input: String)
type TryDivideError = DivideByZeroError.type :+: CNil
def tryDivide(x: Double, y: Double): Either[TryDivideError, Double] =
if (y == 0) Left(Coproduct(DivideByZeroError))
else Right(x / y)
type TryReadLineError = IOException :+: CNil
// Fold on Try will always give us a Throwable and is no longer an IOException.
// For educational purposes I'm not going to bother doing anything about this and
// I'll just wrap the exception into an IOException again.
def tryReadLine = Try(readLine).fold[Either[TryReadLineError, String]](
ex => Left(Coproduct(new IOException(ex))),
Right.apply
)
type TryParseNumberError = ParseNumberError :+: CNil
def tryParseDouble(s: String) =
s.toDoubleOption.fold[Either[TryParseNumberError, Double]](
Left(Coproduct(ParseNumberError(s)))
)(Right.apply)
// Flattens the types.
val runDivideError = Adjoin[TryReadLineError :+: TryParseNumberError :+: TryDivideError]
// Uses the resulting Out as our type signature.
type RunDivideError = runDivideError.Out
// We're now using the embed method instead.
def runDivide: Either[RunDivideError, Double] = for {
x0 <- tryReadLine.left.map(_.embed[RunDivideError])
y0 <- tryReadLine.left.map(_.embed[RunDivideError])
x <- tryParseDouble(x0).left.map(_.embed[RunDivideError])
y <- tryParseDouble(y0).left.map(_.embed[RunDivideError])
r <- tryDivide(x, y).left.map(_.embed[RunDivideError])
} yield r
// An error handler to demonstrate how we could deal with errors.
object errorHandler extends Poly1 {
implicit def ioException = at[IOException] { e =>
println(s"An IOException occurred: ${e.getMessage}")
}
implicit def parseDoubleError = at[ParseNumberError] { e =>
println(s"Cannot parse '${e.input}' as an integer")
}
implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
println("Cannot divide by zero")
}
}
runDivide.fold(_.fold(errorHandler), println)
}
Der obige Code ist immer noch ein wenig unübersichtlich. Zunächst einmal müssen wir den Typ für jeden Methodenaufruf erweitern. Das lässt sich nicht so gut komponieren. Schauen wir uns an, was passiert, wenn wir versuchen, die Zeile zu lesen und die Eingabe als neue Komposition zu parsen:
def runDivide: Either[RunDivideError, Int] = for {
x <- tryReadLine
.leftMap(_.embed[RunDivideError])
.flatMap(tryParseInt(_).leftMap(_.embed[RunDivideError]))
y <- tryReadLine
.leftMap(_.embed[RunDivideError])
.flatMap(tryParseInt(_).leftMap(_.embed[RunDivideError]))
r <- tryDivide(x, y).leftMap(_.embed[RunDivideError])
} yield r
Um ehrlich zu sein, sieht das nicht besonders schön aus. Es ist nicht nur mühsam, unsere Typen ständig von Hand zu erweitern, sondern es führt auch zu Störungen, die es schwierig machen, unser Programm zu überdenken.
Iteration 5: Reduzierung des Rauschens durch Einführung eines Syntaxhelfers
Wie können wir das Rauschen im vorherigen Beispiel reduzieren? Eine Möglichkeit, dieses Problem zu lösen, wäre, die Methoden die Einbettung bereits selbst vornehmen zu lassen:
import DivideAppIteration5Syntax.EitherSyntax
import shapeless.ops.adjoin.Adjoin
import shapeless.ops.coproduct.Basis
import shapeless.{:+:, CNil, Coproduct, Poly1}
import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try
object DivideAppIteration5Syntax {
implicit class EitherSyntax[E <: Coproduct, B](either: Either[E, B]) {
def leftMap[A1](f: E => A1): Either[A1, B] = either match {
case Left(l) => Left(f(l))
case _ => either.asInstanceOf[Either[A1, B]]
}
def leftEmbed[Super <: Coproduct](implicit basis: Basis[Super, E]): Either[Super, B] =
leftMap(_.embed)
}
}
object DivideAppIteration5 extends App {
case object DivideByZeroError
case class ParseNumberError(input: String)
type TryDivideError = DivideByZeroError.type :+: CNil
def tryDivide[Super <: Coproduct](x: Double, y: Double)(implicit
basis: Basis[Super, TryDivideError]
): Either[Super, Double] =
if (y == 0) Left(Coproduct[TryDivideError](DivideByZeroError).embed[Super])
else Right(x / y)
type TryReadLineError = IOException :+: CNil
def tryReadLine[Super <: Coproduct](implicit
basis: Basis[Super, TryReadLineError]
): Either[Super, String] =
Try(readLine)
.fold[Either[TryReadLineError, String]](
ex => Left(Coproduct(new IOException(ex))),
Right.apply
)
.leftEmbed[Super]
type TryParseNumberError = ParseNumberError :+: CNil
def tryParseNumber[Super <: Coproduct](
s: String
)(implicit basis: Basis[Super, TryParseNumberError]): Either[Super, Double] =
s.toDoubleOption
.fold[Either[TryParseNumberError, Double]](
Left(Coproduct(ParseNumberError(s)))
)(Right.apply)
.leftEmbed[Super]
val runDivideError = Adjoin[TryReadLineError :+: TryParseNumberError :+: TryDivideError]
type RunDivideError = runDivideError.Out
def runDivide: Either[RunDivideError, Double] = for {
x <- tryReadLine[RunDivideError].flatMap(tryParseNumber[RunDivideError])
y <- tryReadLine[RunDivideError].flatMap(tryParseNumber[RunDivideError])
r <- tryDivide[RunDivideError](x, y)
} yield r
object errorHandler extends Poly1 {
implicit def ioException = at[IOException] { e =>
println(s"An IOException occurred: ${e.getMessage}")
}
implicit def parseNumberError = at[ParseNumberError] { e =>
println(s"Cannot parse '${e.input}' as a number")
}
implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
println("Cannot divide by zero")
}
}
runDivide.fold(_.fold(errorHandler), println)
}
Auch wenn dies viel besser aussieht als frühere Versionen, wäre es toll, wenn wir die überflüssige RunDivideError eliminieren könnten, die wir beim Zusammenstellen der Methoden an jede einzelne Methode übergeben müssen.
Iteration 6: Reduzierung des Rauschens durch Verschieben der Implikate
Können wir das Rauschen noch weiter reduzieren? Ja, das können wir, wenn wir die Implikate verschieben. Wir können dies ganz einfach tun, indem wir die Implikate in der runDivide Signatur hinzufügen:
import DivideAppIteration6Syntax.EitherSyntax
import shapeless.ops.adjoin.Adjoin
import shapeless.ops.coproduct.Basis
import shapeless.{:+:, CNil, Coproduct, Poly1}
import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try
object DivideAppIteration6Syntax {
implicit class EitherSyntax[E <: Coproduct, B](either: Either[E, B]) {
def leftMap[A1](f: E => A1): Either[A1, B] = either match {
case Left(l) => Left(f(l))
case _ => either.asInstanceOf[Either[A1, B]]
}
def leftEmbed[Super <: Coproduct](implicit basis: Basis[Super, E]): Either[Super, B] =
leftMap(_.embed)
}
}
object DivideAppIteration6 extends App {
case object DivideByZeroError
case class ParseNumberError(input: String)
type TryDivideError = DivideByZeroError.type :+: CNil
def tryDivide[Super <: Coproduct](x: Double, y: Double)(implicit
basis: Basis[Super, TryDivideError]
): Either[Super, Double] =
if (y == 0) Left(Coproduct[TryDivideError](DivideByZeroError).embed[Super])
else Right(x / y)
type TryReadLineError = IOException :+: CNil
def tryReadLine[Super <: Coproduct](implicit
basis: Basis[Super, TryReadLineError]
): Either[Super, String] =
Try(readLine)
.fold[Either[TryReadLineError, String]](
ex => Left(Coproduct(new IOException(ex))),
Right.apply
)
.leftEmbed[Super]
type TryParseNumberError = ParseNumberError :+: CNil
def tryParseNumber[Super <: Coproduct](
s: String
)(implicit basis: Basis[Super, TryParseNumberError]): Either[Super, Double] =
s.toDoubleOption
.fold[Either[TryParseNumberError, Double]](
Left(Coproduct(ParseNumberError(s)))
)(Right.apply)
.leftEmbed[Super]
val runDivideError = Adjoin[TryReadLineError :+: TryParseNumberError :+: TryDivideError]
type RunDivideError = runDivideError.Out
def runDivide(implicit
b1: Basis[RunDivideError, TryReadLineError],
b2: Basis[RunDivideError, TryParseNumberError],
b3: Basis[RunDivideError, TryDivideError]
): Either[RunDivideError, Double] =
for {
x <- tryReadLine.flatMap(tryParseNumber(_))
y <- tryReadLine.flatMap(tryParseNumber(_))
r <- tryDivide(x, y)
} yield r
object errorHandler extends Poly1 {
implicit def ioException = at[IOException] { e =>
println(s"An IOException occurred: ${e.getMessage}")
}
implicit def parseNumberError = at[ParseNumberError] { e =>
println(s"Cannot parse '${e.input}' as a number")
}
implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
println("Cannot divide by zero")
}
}
runDivide.fold(_.fold(errorHandler), println)
}
Leider müssen wir tryParseNumber(_) schreiben, damit die Typinferenz funktioniert, und wir können tryParseNumber nicht einfach als Funktion übergeben.
Iteration 7: Umwandlung in eine REST-API
Das Schöne an einem funktionalen Programm ist, dass wir beschreiben können, was unser Programm tun soll, unabhängig von unserem Technologie-Stack. In dieser letzten Iteration werden Sie sehen, wie schnell wir unsere Befehlszeilen-App in eine einfache REST-API verwandeln können, ohne viel Aufwand. Wir verwenden das Akka HTTP Hallo-Welt Beispiel als Basis, aber Sie können jedes beliebige Framework verwenden.
import DivideAppIteration6Syntax.EitherSyntax
import akka.actor.typed.ActorSystem
import akka.actor.typed.scaladsl.Behaviors
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.StatusCodes.{BadRequest, InternalServerError, OK}
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
import shapeless.ops.adjoin.Adjoin
import shapeless.ops.coproduct.Basis
import shapeless.{:+:, CNil, Coproduct, Poly1}
import scala.io.StdIn
object DivideAppIteration7Syntax {
implicit class EitherSyntax[E <: Coproduct, B](either: Either[E, B]) {
def leftMap[A1](f: E => A1): Either[A1, B] = either match {
case Left(l) => Left(f(l))
case _ => either.asInstanceOf[Either[A1, B]]
}
def leftEmbed[Super <: Coproduct](implicit basis: Basis[Super, E]): Either[Super, B] =
leftMap(_.embed)
}
}
object DivideAppIteration7WebApp {
case object DivideByZeroError
case class ParseNumberError(input: String)
type TryDivideError = DivideByZeroError.type :+: CNil
def tryDivide[Super <: Coproduct](x: Double, y: Double)(implicit
basis: Basis[Super, TryDivideError]
): Either[Super, Double] =
if (y == 0) Left(Coproduct[TryDivideError](DivideByZeroError).embed[Super])
else Right(x / y)
type TryParseNumberError = ParseNumberError :+: CNil
def tryParseNumber[Super <: Coproduct](
s: String
)(implicit basis: Basis[Super, TryParseNumberError]): Either[Super, Double] =
s.toDoubleOption
.fold[Either[TryParseNumberError, Double]](
Left(Coproduct(ParseNumberError(s)))
)(Right.apply)
.leftEmbed[Super]
val runDivideError = Adjoin[TryParseNumberError :+: TryDivideError]
type RunDivideError = runDivideError.Out
def runDivide(x: String, y: String)(implicit
b1: Basis[RunDivideError, TryParseNumberError],
b2: Basis[RunDivideError, TryDivideError]
): Either[RunDivideError, Double] =
for {
a <- tryParseNumber(x)
b <- tryParseNumber(y)
r <- tryDivide(a, b)
} yield r
object divideEndpointErrorHandler extends Poly1 {
implicit def parseNumberError = at[ParseNumberError] { e =>
complete(HttpResponse(BadRequest, entity = "Invalid number specified"))
}
implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
complete(HttpResponse(InternalServerError, entity = "Cannot divide by zero"))
}
}
def divideEndpointSuccessHandler = (d: Double) => complete(HttpResponse(OK, entity = d.toString))
def main(args: Array[String]): Unit = {
implicit val system = ActorSystem(Behaviors.empty, "my-system")
// needed for the future flatMap/onComplete in the end
implicit val executionContext = system.executionContext
val tryDivideEnpointError = Adjoin[TryParseNumberError :+: TryDivideError]
type TryDivideEndpointError = tryDivideEnpointError.Out
val divideRoute =
path("divide") {
get {
// Normally you would leverage Akka's built-in functionality to parse strings.
// As I would not like to diverge from our existing application, I decided to
// keep doing the parsing our selves, and use the divideEndpointErrorHandler
// to convert our errors to HTTP responses.
parameters("x", "y").apply { case (x, y) =>
runDivide(x, y).fold(_.fold(divideEndpointErrorHandler), divideEndpointSuccessHandler)
}
}
}
val bindingFuture = Http().newServerAt("localhost", 8080).bind(divideRoute)
println(
s"Server now online. Please navigate to http://localhost:8080/dividenPress RETURN to stop..."
)
StdIn.readLine() // Let it run until user presses return
bindingFuture
.flatMap(_.unbind()) // Trigger unbinding from the port
.onComplete(_ => system.terminate()) // Shutdown when done
}
}
Fazit
Wir haben uns Shapeless-Koprodukte angeschaut und gesehen, dass es sich um einen Union-Typ handelt (d.h. es kann mehr als einen einzigen Typ zurückgeben). Das kann praktisch sein, wenn eine Methode mehr als nur einen Fehler zurückgeben kann. In diesem Artikel haben wir eine einfache Befehlszeilenanwendung erstellt und einige Iterationen von Verbesserungen durchgeführt. Dabei haben wir gesehen, wie einfach es ist, den Code unserer Befehlszeilenanwendung dank funktionaler Komposition in eine REST-API zu übertragen, ohne dass sich etwas ändert.
Die große Frage bleibt: Sollte ich Shapeless-Koprodukte in meiner Codebasis verwenden?
Die Antwort darauf lautet: Es kommt darauf an
- Angenommen, ich würde noch mit Scala 2.x arbeiten. In diesem Fall würde ich versuchen, die Codebasis auf Scala 3.0 zu bringen. Diese Migration sollte recht einfach sein, wenn Sie mit 2.13.8 arbeiten (denken Sie daran, dass die müheloseste Migration auf 3.0 statt auf eine spätere 3.x-Version erfolgt). Die Verwendung nativer Union-Typen in Scala 3.x ist eine bessere Lösung, da sie den Code nicht auf so unglückliche Weise verschmutzt. Ich werde demnächst einen Folgeartikel mit demselben Code in Scala 3.x veröffentlichen, und Sie werden sehen, wie einfach es ist, ihn zu verwenden.
- Wenn Sie noch an Scala 2 gebunden sind, könnte die Verwendung von Shapeless eine gute Möglichkeit sein, einen typsicheren Fehlerkanal zu erstellen.
Ob ein typsicherer Fehlerkanal (d.h. "Effektverfolgung") gut oder schlecht ist, wird heftig diskutiert:
Ich bin mir immer noch nicht ganz sicher, wo ich hier stehe. Ich denke, dass es keine gute Praxis ist, Fehler explizit zu machen, nur um sie explizit zu machen. Ich halte es jedoch für sinnvoll, Fehler explizit zu machen, die im vorgelagerten Bereich (d.h. beim Aufrufer oder noch weiter oben in der Kette) berücksichtigt werden müssen. Wie in vielen anderen Fällen hängt es davon ab, was Sie erreichen wollen, ob dies sinnvoll ist.
Verfasst von

Anton Lijcklama à Nijeholt
Unsere Ideen
Weitere Blogs
Contact



