
Teilen Sie:
Guillaume ist ein Senior .Net Developer Advocate bei Vonage. Er arbeitet seit fast 15 Jahren in .Net und hat sich in den letzten Jahren auf die Förderung von Software Craftsmanship konzentriert. Zu seinen Lieblingsthemen gehören Codequalität, Testautomatisierung, Mobbing und Code Katas. Außerhalb der Arbeit verbringt er gerne Zeit mit seiner Frau und seiner Tochter, treibt Sport oder spielt Spiele.
Die Invasion der Monaden - Teil 3: Eisenbahnorientierte Programmierung
Lesedauer: 6 Minuten
Hallo Freunde,
Unser vorheriger Beitrag, "Die Invasion der Monaden - Teil 2: Monaden in Aktion!", wurde eine Reihe verschiedener Monaden vorgestellt und wie sie in realen Szenarien eingesetzt werden können.
Heute konzentrieren wir uns auf die Eisenbahnorientierte Programmierung (ROP), einen funktionalen Ansatz zur Fehlerbehandlung.
Spoiler: Wenn Sie unsere früheren Beiträge gelesen haben, haben Sie ROP beobachtet, ohne sich dessen wirklich bewusst zu sein.
Kurzer Rückblick
Wir haben unsere Zwei-Faktor-Authentifizierung (2FA) als reales Beispiel verwendet. Dieser zweistufige Arbeitsablauf erfordert zunächst die Initiierung einer Verifizierung und dann die Überprüfung des Codes, der an den Benutzer gesendet wird.
var myPhoneNumber = ...
var result = await StartVerificationRequest.Build()
.WithBrand("Monads Inc.")
.WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
.Create() // Build the authentication request
.BindAsync(request => verifyClient.StartVerificationAsync(request)) // Process the authentication request
.BindAsync(VerifyCodeAsync) // Start the second step if it's a success
.Match(AuthenticationSuccessful, AuthenticationFailed);
private async Task<Result<Unit>> VerifyCodeAsync(StartVerificationResponse response)
{
var myCode = ... // Receive verification code based on the specified workflow
return await response
.BuildVerificationRequest(myCode) // Build the verification request
.BindAsync(request => verifyClient.VerifyCodeAsync(request)); // Process the verification request
}Wie bereits angedeutet, wendet dieses Snippet bereits ROP an. Wahrscheinlich haben Sie jetzt eine Menge Fragen. Was ist ROP? Wie funktioniert es? Warum ist es nützlich?
Fangen wir also von vorne an.
Scheitern wird erwartet
Es ist absolut unmöglich, Software zu entwickeln, ohne mit Fehlern umzugehen. In der realen Welt können Fehler an jedem Punkt der Ausführung auftreten
während der Ausführung auftreten, und wir können sie nicht ignorieren.
Was wäre, wenn wir denselben Arbeitsablauf hätten, aber in einem imperativen Stil geschrieben?
var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();
var response = verifyClient.StartVerificationAsync(request);
var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
var result = verifyClient.VerifyCodeAsync(verificationRequest);
this.AuthenticationSuccessful();Es sieht ähnlich aus, aber es fehlt etwas Wichtiges - die Fehlerbehandlung.
In der Tat zeigt dieser Ausschnitt nur den glücklichen Weg, bei dem alles wie erwartet verläuft.
Das reicht natürlich nicht aus - wir streben eine Funktionsparität zwischen diesen Schnipseln an. In seinem derzeitigen Zustand würde jeder Fehler unser System zum Absturz bringen. Wie wir bereits erwähnt haben, kann dieser Fluss an verschiedenen Stellen versagen:
Bei der Erstellung der Authentifizierungsanfrage
Bei der Bearbeitung der Authentifizierungsanfrage
Bei der Erstellung des Überprüfungsantrags
Bei der Bearbeitung des Überprüfungsantrags
Was ist der Unterschied, wenn wir uns entscheiden, Fehler zu behandeln?
var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();
if (request.IsFailure)
{
// Can fail when Brand is invalid
// Can fail when PhoneNumber is invalid
return this.AuthenticationFailed(new Failure("Invalid input."));
}
var response = verifyClient.StartVerificationAsync(request);
if (response.IsFailure)
{
// Can fail when process can't be initiated
return this.AuthenticationFailed(new Failure("Cannot initiate verification process."));
}
var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
if (response.IsFailure)
{
// Can fail when the code input is invalid
return this.AuthenticationFailed(new Failure("Invalid code."));
}
var result = verifyClient.VerifyCodeAsync(verificationRequest);
if (result.IsFailure)
{
// Can fail when the verification fails
return this.AuthenticationFailed(new Failure("Verification failed."));
}
return this.AuthenticationSuccessful();
Wir müssen einen wichtigen Boilerplate-Code hinzufügen, um mit möglichen Fehlern umzugehen. Dieser Code ist sehr dicht und schwer zu lesen.
Wir haben jetzt mehr Code zur Behandlung von Fehlern als der glückliche Pfad selbst, was kein Zufall ist.
Wir haben nur einen einzigen glücklichen Weg, aber mehrere Gründe für ein Scheitern.
Hier gibt es etwas Wichtiges zu beachten:
Dieses Snippet bietet dasselbe Verhalten wie das Snippet aus dem Abschnitt "Quick Recap", nur dass es ungefähr dreimal mehr Code dafür benötigt.
Auch die zyklomatische Komplexität nimmt deutlich zu, von 1 auf 6innerhalb eines Wimpernschlages.
Habe ich schon Ihr Interesse geweckt? Dann wird es Zeit für eine Einführung in die Eisenbahnorientiertes Programmieren.
Eisenbahnorientiertes Programmieren
Hinweis: In diesem Abschnitt werden Ressourcen aus dem Vortrag "Eisenbahnorientiertes Programmieren - Fehlerbehandlung auf funktionale Weise" von Scott Wlaschin. Weitere Details sind verfügbar auf F#ForFunAndProfit.
Die eisenbahnorientierte Programmierung ist ein funktionaler Ansatz für die Fehlerbehandlung. Die Hauptidee besteht darin, den "glücklichen Weg" (grün) als das Hauptgleis zu betrachten, das das erwartete Verhalten liefert.
Im obigen Beispiel besteht unser Arbeitsablauf aus drei Schritten:
Eine Eingabe validieren.
Einen Datensatz aktualisieren - wenn die Eingabe gültig ist.
Eine Benachrichtigung senden - wenn die Datensatzaktualisierung erfolgreich war.
So sieht das Ganze in zwingender Form aus:
if (this.Validate(input))
{
try
{
var record = this.Update(input);
this.SendNotification(record);
}
catch (Exception)
{
...
}
}Wir betrachten unseren Arbeitsablauf nur dann als erfolgreich, wenn diese drei Schritte erfolgreich sind.
Wir bleiben auf diesem "glücklichen Weg", bis wir auf einen Fehler stoßen, der uns auf einen "misslungenen Weg" (rot) führt. Wenn das passiert, tragen wir den Fehler bis zum Ende des Ablaufs mit. Es ist von entscheidender Bedeutung, dass wir uns an den Hauptpfad halten, denn nur so können wir sicherstellen die vollständige Ausführung unseres Arbeitsablaufs.
Im obigen Beispiel wird der Aktualisierungsschritt übersprungen, wenn die Validierung fehlschlägt.
Dies ist nur möglich, weil unsere Funktionen mehrere mögliche Zustände zurückgeben: einen Erfolg oder ein Fehlschlag.
Sagt Ihnen das etwas? Was für ein Zufall, dass wir dafür Monaden verwenden!
Das haben Sie nicht kommen sehen - oder doch?
Unter unserem vorherigen Beitraghaben wir gesehen, wie man den Status einer Monade von einem Zustand in einen anderen ändern kann, indem man die .Bind() Methode.
Mit diesem grundlegenden Konzept können wir diese Verzweigung zwischen Tracks erstellen.
Hier gibt es keine Magie. Die Verzweigung ist nicht mit einem Zauberstab oder ähnlichem verschwunden. Die Verzweigung ist jetzt intern in unserer Monade.
Schauen Sie sich an, wie Bind in unserer benutzerdefinierten Monade implementiert ist:
return this.IsFailure ? Result<TB>.FromFailure(this.failure) : bind(this.success);Unser Entscheidungsmechanismus - ein ternärer Operator - prüft, ob der aktuelle Zustand ein Misserfolg oder ein Erfolg ist.
Im Falle eines Fehlers wird der aktuelle Fehler zurückgegeben; es passiert nichts.
Ist sie erfolgreich, rufen wir die
bindFunktion auf, um den Fluss fortzusetzen.
So können wir unsere Vorgänge miteinander verketten, und der Fluss wird automatisch auf den Fehlerpfad umschalten, wenn einer der Operationen fehlschlägt.
Hier ist derselbe Arbeitsablauf wie zuvor, aber dieses Mal unter Verwendung von ROP:
var result = input.Bind(Validate).Bind(Update).Bind(SendNotification);Beeindruckend, oder? Wir haben unseren Code auf eine einzige Zeile reduziert und dabei die gleichen Funktionen abgedeckt.
Lasst uns ein kleines Spiel spielen
Hier ist ein Codebeispiel aus unserem .NET SDK das uns die Authentifizierung für eine unserer Netzwerk-APIs ermöglicht. Können Sie herausfinden, wie die Bahngleise aussehen? Wo kann unser Arbeitsablauf scheitern?
Sie brauchen nicht zu wissen, was hier passiert; es gibt keine Logik. Konzentrieren Sie sich auf den Fluss.
public Task<Result<AuthenticateResponse>> AuthenticateAsync(Result<AuthenticateRequest> request) =>
request.Map(BuildAuthorizeRequest)
.BindAsync(this.SendAuthorizeRequest)
.Map(BuildGetTokenRequest)
.BindAsync(this.SendGetTokenRequest)
.Map(BuildAuthenticateResponse);Die Antwort ist ganz einfach: Unsere Bahn hat zwei Abzweigungen:
Beim Senden der Autorisierungsanfrage.
Beim Senden der Anforderung "get token".
Dies ist explizit, weil wir uns auf die Bind Methode für diese Operationen verwenden.
Zum Vergleich sehen Sie hier denselben Code, der in einem imperativen Stil geschrieben wurde:
public async Task<Result<AuthenticateResponse>> AuthenticateAsync(AuthenticateRequest request)
{
var authorizeRequest = BuildAuthorizeRequest(request);
var authorizeResponse = await this.SendAuthorizeRequest(authorizeRequest);
if (authorizeResponse.IsFailure)
{
return Result<AuthenticateResponse>.FromFailure(authorizeResponse.Failure);
}
var getTokenRequest = BuildGetTokenRequest(authorizeResponse.Success);
var getTokenResponse = await this.SendGetTokenRequest(getTokenRequest);
if (getTokenResponse.IsFailure)
{
return Result<AuthenticateResponse>.FromFailure(getTokenResponse.Failure);
}
return BuildAuthenticateResponse(getTokenResponse.Success);
}Wie Sie sehen können, ist das Lesen und Verstehen des Flusses viel einfacher als im imperativen Stil, und die Funktion ist kleiner. Das haben wir erreicht, indem wir die kognitive Belastung unserer Methode reduziert haben - die Reduzierung der erforderlichen Menge an technischen Informationen (Verzweigungen, Logik, Variablen usw.) trägt dazu bei, dass der Code "in den Kopf passt" ( siehe "Code, der in Ihren Kopf passt" von Mark Seemann).
Prozesse scheitern aus mehreren Gründen
Wenn wir unser vorheriges Beispiel betrachten, kann unser Arbeitsablauf an zwei Stellen scheitern - das könnte für uns ein Problem darstellen.
Unser Fehlerpfad trägt das, was wir als einen Failure Zustand definieren.
Wir haben unsere Monade so implementiert, dass sie entweder einen Success oder einen Failure Wert
unter Verwendung von Generika.
Wir können uns das als eine Result<TFailure, TSuccess.
Sehen Sie, worauf ich hinaus will?
Aufgrund der Art und Weise, wie Generics in C# funktionieren, muss eine Instanz von Result immer den gleichen Fehlertyp haben. Mit anderen Worten,
alle unsere Fehler müssen den gleichen Typ haben - Das ist ein Problem, denn Sie können beschließen, einen Parsing-Fehler anders zu behandeln
anders zu behandeln als einen API-Fehler.
Wir können nicht Monads oder ROP für dieses Problem verantwortlich machen - es ist eine Einschränkung der Sprache. Im Vergleich dazu ist dies der Punkt, an dem eine Sprache wie F# glänzen, da sie es uns ermöglicht, einen differenzierten Union-Typ zu definieren, der mehrere Fehlertypen enthalten kann. Trotzdem, ist Hoffnung möglich - diskriminierte Unions werden irgendwann kommen zu C# kommen.
Auch wenn wir diese Einschränkung nicht ignorieren können, bedeutet das nicht, dass wir völlig aufgeschmissen sind. Eine Möglichkeit, verschiedene Ausfälle unter demselben generischen Typ zu gruppieren, ist die Verwendung einer Basisklasse oder einer Schnittstelle.
Im .NET SDK habe ich eine
IResultFailure Schnittstelle
implementiert, die von allen Fehlern implementiert werden muss. Sie ermöglicht es uns, verschiedene Fehler (oder Gründe für einen Fehler - Parsing,
Authentifizierung usw.) auf demselben Fehlerpfad zu gruppieren und spezielle Verhaltensweisen mithilfe der Type Eigenschaft.
public interface IResultFailure
{
/// <summary>
/// The type of failure.
/// </summary>
Type Type { get; }
/// <summary>
/// Returns the error message defined in the failure.
/// </summary>
/// <returns>The error message.</returns>
string GetFailureMessage();
/// <summary>
/// Converts the failure to an exception.
/// </summary>
/// <returns>The exception.</returns>
Exception ToException();
/// <summary>
/// Converts the failure to a Result with a Failure state.
/// </summary>
/// <typeparam name="T">The underlying type of Result.</typeparam>
/// <returns>A Result with a Failure state.</returns>
Result<T> ToResult<T>();
}Dies ist keine perfekte Lösung und wahrscheinlich auch nicht die eleganteste. Aber dies ist ähnlich wie die Behandlung verschiedener Arten von Ausnahmen, wie im folgenden Beispiel:
try { ... }
catch (ExceptionA) { ... }
catch (ExceptionB) { ... }
catch (ExceptionC) { ... }
// ---
switch (failure)
{
case HttpFailure httpFailure:
DoSomethingWithHttpFailure(httpFailure);
break;
case ResultFailure resultFailure:
DoSomethingWithResultFailure(resultFailure);
break;
default:
DoSomethingWithFailure(failure);
break;
}Wie Sie sehen, können wir mit ROP die gleichen Funktionen wie bei Ausnahmen nutzen - es ist kein Ersatz, sondern nur eine Alternative zur Fehlerbehandlung, die ein wenig anders aussieht.
Schlussfolgerung
Und da haben wir ihn - unseren dritten Beitrag in der Reihe "Die Invasion der Monaden". Diesmal haben wir uns auf die eisenbahnorientierte Programmierung (Railway-Oriented Programmierung (ROP), um zu zeigen, wie Sie Monads noch weiter zur Fehlerbehandlung einsetzen können.
Die wichtigste Erkenntnis? Bei der Verwendung von Monads für die Fehlerbehandlung gibt es keinen Verlust im Vergleich zu einem standardmäßigen ausnahmebasierten Ansatz; Alles, was Sie mit Exceptions machen, können Sie auch mit Monads machen. Sie werden wahrscheinlich feststellen, dass Ihr Code sauberer, leichter zu lesen und deutlicher über seine Absicht.
Nun die eigentliche Frage: Sind Sie bereit, es zu versuchen?
Ich schlage vor, klein anzufangen und es langsam angehen zu lassen. Es gibt eine Lernkurve, wie bei allem Neuen, aber bleiben Sie dran, und Sie werden die Vorteile sehen.
Wenn Sie Fragen haben oder sich mit mir unterhalten wollen, können Sie mich gerne unter meine LinkedInund teilen Sie Ihr Feedback auf das .NET SDK-Repository oder schließen Sie sich uns an auf der Vonage Entwickler Slack. Sie können uns auch eine Nachricht senden auf @VonageDev auf X. Wir sitzen alle im selben Boot, und Ihre Stimme zählt.
Viel Spaß beim Programmieren, und bis später!
Teilen Sie:
Guillaume ist ein Senior .Net Developer Advocate bei Vonage. Er arbeitet seit fast 15 Jahren in .Net und hat sich in den letzten Jahren auf die Förderung von Software Craftsmanship konzentriert. Zu seinen Lieblingsthemen gehören Codequalität, Testautomatisierung, Mobbing und Code Katas. Außerhalb der Arbeit verbringt er gerne Zeit mit seiner Frau und seiner Tochter, treibt Sport oder spielt Spiele.
