Skip to content

Repository files navigation

Railroad oriented programming

Anhand eines Beispiels mit einer traditionellen Implementierung und der anschließenden Refaktorierung näheren wir uns das Thema an.

Das Beispiel ist das Schalten einer Eisenbahn-Weiche. Aus dem Stellwerk sendet ein Operator ein Kommando an die Weiche. Die Weiche prüft dann folgendes:

  • Ist das Zertifikat der Nachricht und somit die Identität des Operators gültig
  • Ist die Weiche frei

Danach kann die Weiche in die richtige Richtung geschaltet werden.

Zertifikatsvalidierung

Wir verwenden eine vorhandene Bibliothek. Diese Bibliothek gibt ein OperatorResult basierend auf ein Zertifikat zurück. OperatorResult ist ein gemischter Typ wie ein Variant. Er hat eine Enumeration ValidationResult um die Felder dieses Types zu interpretieren. Je nach Wert sind unterschiedliche Felder gesetzt.

Weiche Stellen (Traditionell)

Wir prüfen die Strecke. Hierzu wird mit Telemetrie geschaut, wann der nächste Zug kommt.

privateCheckRailwayTrackResultCheckRailwayTrack(){varsignal=newRailwaySignal();varseconds=signal.GetArrivalTimeInSeconds();if(seconds<10){returnnewCheckRailwayTrackResult(CheckRailwayTrackResultStatus.Unknown,"Unknown error");}if(seconds<20){returnnewCheckRailwayTrackResult(CheckRailwayTrackResultStatus.SensorFailure,"Could not check the track, no sensor data arrived");}if(seconds<30){returnnewCheckRailwayTrackResult(CheckRailwayTrackResultStatus.Occupied,"Track is occupied by train");}returnnewCheckRailwayTrackResult(DateTimeOffset.Now.AddSeconds(seconds));}

Auch hier wird eine Variant Struktur verwendet. Somit haben wir folgenden Code der als nächster ausgeführt wird.

varcheckTrackResult=CheckRailwayTrack();switch(checkTrackResult.Status){caseCheckRailwayTrackResultStatus.Free:thrownewArgumentOutOfRangeException();caseCheckRailwayTrackResultStatus.Occupied:caseCheckRailwayTrackResultStatus.SensorFailure:caseCheckRailwayTrackResultStatus.Unknown:returncheckTrackResult.ErrorMessage;default:thrownewArgumentOutOfRangeException();}

Wen die Strecke frei ist, dann kann die Weiche gestellt werden.

privateSetSwitchGroupResultSetDirection(SwitchDirectionswitchDirection,DateTimeOffsetestimatedTimeOfArrival){varswitchGroup=newSwitchGroup();varres=switchGroup.Set(switchDirection,estimatedTimeOfArrival);returnres;}

Somit haben wir im Gutfall:

varsetSwitchGroupResult=SetDirection(cmd.Direction,checkTrackResult.EstimatedArrivalTimeOfNextTrain);switch(setSwitchGroupResult.SwitchResult){caseSwitchResult.Success:thrownewNotImplementedException();caseSwitchResult.SwitchIsStiff:caseSwitchResult.TooShort:caseSwitchResult.UnknownError:returnsetSwitchGroupResult.ErrorMessage;default:thrownewArgumentOutOfRangeException();}

Und jetzt haben wir die Möglichkeit, das erfolgreiche Schalten der Weiche zu auditieren.

privatevoidSetAudit(Operator?@operator,SwitchDirectiondirection){AuditLog.Info($"{@operator?.Name} has set the switch direction to {direction}");}

Und wir geben als Ergebnis eine Leere Zeichenfolge, als Zeichen, dass kein Fehler passiert ist zurück.

SetAudit(operatorResult.Operator,cmd.Direction);returnstring.Empty;

Unser Code zeigt mehrere Probleme:

  • Bei den Variant-Typen ist nicht ersichtlich, was ein gültiger Zustand ist. Der Compiler kann uns nicht helfen
  • Der Code ist unübersichtlich, weil der Happy-Path in den Switch-Cases versteckt ist
  • Wir haben zwei Belange vermischt: Wie verhält sich das Program im Gutfall und wie im Fehler-Fall.

Option & Result

Wir führen jetzt einen Result<T> ein. Ein Type der das Ergebnis einer Operation ist. Im Gutfall ist das Ergebnis T, der Einfachheit halber im Fehlerfall string. Weiter unten werden wir eigene Fehlertypen einführen.

Hierzu benötigen wir das Packet Funicular Switch von Alexander Wiedemann. Das Projekt ist hier: https://github.com/bluehands/Funicular-Switch. Die Binaries sind als Nuget-Packet verfügbar: https://www.nuget.org/packages/FunicularSwitch.

Wir beginnen mit CheckRailwayTrack. Statt einem Variant-Typen geben wir Result zurück.

privateResult<DateTimeOffset>CheckRailwayTrack(){varsignal=newRailwaySignal();varseconds=signal.GetArrivalTimeInSeconds();if(seconds<10){returnResult.Error<DateTimeOffset>("Unknown error");}if(seconds<20){returnResult.Error<DateTimeOffset>("Could not check the track, no sensor data arrived");}if(seconds<30){returnResult.Error<DateTimeOffset>("Track is occupied by train");}returnResult.Ok(DateTimeOffset.Now.AddSeconds(seconds));}

Es gibt auch eine Überladung, so dass man direkt das Ergebnis des Gutfalls zurück geben kann.

returnDateTimeOffset.Now.AddSeconds(seconds);

Im nächsten Schritt bauen wir SetDirection um. Hier haben wir im Gutfall kein Ergebnis. Da void nicht als generischer Parameter verwendet werden kann, wird analog zu F# Unit als "Nichts" verwendet.

privateResult<Unit>SetDirection(SwitchDirectionswitchDirection,DateTimeOffsetestimatedTimeOfArrival){varswitchGroup=newSwitchGroup();varres=switchGroup.Set(switchDirection,estimatedTimeOfArrival);if(res.SwitchResult!=SwitchResult.Success){returnResult.Error<Unit>(res.ErrorMessage);}returnResult.Ok(No.Thing);}

oder auch wieder direkt

returnNo.Thing;

Um von einer Operation zur nächsten zu kommen, gibt es Map und Bind. Map schachtelt das Ergebnis wieder in ein Result. Bind ist ein "flach geklopftes Bind". Die Namen kommen von F#, in anderen Sprachen heißt Bind "Flat Map". Um aus der Continuation auszusteigen, wird Match verwendet. Das ist ein switch über den Gut- und Schlechtfall.

Somit haben wir folgenden Code im "ValidationResult.Valid"-Case:

return CheckRailwayTrack().Bind<Unit>(eta =>{return SetDirection(cmd.Direction, eta).Bind<Unit>(_=>{
SetAudit(operatorResult.Operator, cmd.Direction);return No.Thing;});})
.Match(_=> string.Empty, e => e);

Störend ist immer noch die Verwendung von Variant-Typen. Wir wandeln diese in Union Type um.

Union Types

Die Idee ist, eine Enumeration in einen Union-Type umzuwandeln. Hier zu verwenden wir das Projekt Switchyard https://github.com/bluehands/Switchyard als eine Visual Studio Erweiterung. Diese ist im Marketplace verfügbar: https://marketplace.visualstudio.com/items?itemName=bluehands.Switchyard-Refactoring&ssr=false.

Für eine klarere Bezeichnung brennen wir ValidationResult in OperatorInformation um. Switchyard bietet ein Befehl Expand enum to union type an. Somit erhalten wir einen Union Type.

Im gültigen Fall bei Valid möchten wir den Operator Name mitgeben. Hierzu refaktorieren wir mit ReSharper und führen noch mal Expand enum to union type aus.

Unsere CertificateParser:GetOperatorFromCertificate Implementierung sieht jetzt folgendermaßen aus:

publicstaticOperatorInformationGetOperatorFromCertificate(X509Certificate2cert){varvalidator=newX509CertificateValidator(cert);if(validator.IsExpired()){returnOperatorInformation.Expired;}if(validator.IsNotYetValid()){returnOperatorInformation.NotYetValid;}if(validator.IsRevoked()){returnOperatorInformation.Revoked;}if(!validator.IsTrusted()){returnOperatorInformation.NotTrusted;}returnOperatorInformation.Valid(validator.GetOperator());}

Beim konsumieren verwenden wir jetzt Pattern-Matching. Unser switch sieht nun folgendermaßen aus:

varoperatorInfo=CertificateParser.GetOperatorFromCertificate(cmd.SigningCert);switch(operatorInfo){caseOperatorInformation.Expired_expired:caseOperatorInformation.NotTrusted_notTrusted:caseOperatorInformation.NotYetValid_notYetValid:caseOperatorInformation.Revoked_revoked:returnResult.Error<Unit>(operatorInfo.ToString());caseOperatorInformation.Valid_valid:returnCheckRailwayTrack().Bind<Unit>(eta =>{returnSetDirection(cmd.Direction,eta).Bind<Unit>(_ =>{SetAudit(valid.Name,cmd.Direction);returnNo.Thing;});}).Match(_ =>string.Empty, e =>e);default:thrownewArgumentOutOfRangeException(nameof(operatorInfo));}

Wir erweitern jetzt die Fachlichkeit aus der Praxis, in dem wir die Weiche trotzdem schalten möchten, wenn wir die aktuelle Revokationsliste nicht downloaden können. D.h. wenn die CRL nicht verfügbar ist, soll die Weiche trotzdem schalten.

Wir sehen, dass der Code ist ungeschützt gegen Erweiterung des Switches ist. Bevor wir die Fachlichkeit erweitern, gehen wir dieses Thema an.

Match

Der Union-Type von Switchyard bringt seinen einen "switch" mit. Das ist die Match-Methode.

operatorInfo.Match(
valid =>{},
expired =>{},
notYetValid =>{},
notTrusted =>{},
revoked =>{});

Wir lagern diesen "switch" in eine eigene Erweiterungs-Methode aus:

publicstaticclassOperatorInfoExtension{publicstaticResult<string>ToResult(thisOperatorInformationinfo){returninfo.Match(valid: valid =>Result<string>.Ok(valid.Name),expired: _ =>Result<string>.Error(info.ToString()),notYetValid: _ =>Result<string>.Error(info.ToString()),revoked: _ =>Result<string>.Error(info.ToString()),notTrusted: _ =>Result<string>.Error(info.ToString()));}}

Um DRY zu berücksichtigen, gestallten wir es noch um:

publicstaticResult<string>ToResult(thisOperatorInformationinfo){Result<string>Error(OperatorInformationerr)=>Result<string>.Error(err.ToString());returninfo.Match(valid: valid =>Result<string>.Ok(valid.Name),expired:Error,notYetValid:Error,revoked:Error,notTrusted:Error);}

Wenn wir jetzt die Enumeration erweitern und z.B. NoCrlAvailable hinzufügen, dann bekommen wir automatisch Compiler-Errors und keine Laufzeitfehler.

Da wir jetzt durchgängig Result<T> verwenden, können wir auch den Rückgabetype von Set in Result<string> umwandeln. Unsere Set-Methode sieht jetzt folgendermaßen aus:

returnCertificateParser.GetOperatorFromCertificate(cmd.SigningCert).ToResult().Bind(operatorName =>CheckRailwayTrack().Bind(eta =>{returnSetDirection(cmd.Direction,eta).Bind<string>(_ =>{SetAudit(operatorName,cmd.Direction);returnstring.Empty;});}));

Das Interpretieren des Ergebnisses ist jetzt beim Konsumenten:

varrailroadSwitch=newRailroadSwitch();railroadSwitch.Set(cmd).Match(
_ =>Console.WriteLine("Successfully set the switch"),
e =>Console.WriteLine($"Error set the switch {e}"));

Bis jetzt haben wir die Fehler als Zeichenkette verpackt. Was wir benötigen, sind aber Fehler im Kontext unserer Domäne. Hierzu lassen wir uns die entsprechenden Typen generieren.

Result mit eigenen Fehlertyp

Hierzu verwenden wir den SourceGenerator von Funicular-Switch über das Nuget-Packet https://www.nuget.org/packages/FunicularSwitch.Generators/. Dieser Generator kann die entsprechenden Methoden Bind, Map, Match und andere erzeugen.

Als erstes erzeugen wir uns einen Union-Type für die Domänen-Fehler mit Switchyard. Wir erzeugen einen enum mit erstmal einem Wert UntrustedOperator und erweitern ihn zu einem Union Type.

Den Union Type UntrustedOperator erzeugen wir mit OperatorInformation

publicstaticFailureUntrustedOperator(OperatorInformationoperatorInformation)=>newUntrustedOperator_(operatorInformation);

Danach definieren wir unseren Fehlertyp als partielle Klasse.

[ResultType(ErrorType=typeof(Failure))]publicpartialclassCustomResult<T>{}

Jetzt können wir nach und nach statt Result<T>CustomResult<T> verwenden. Die Fehlermeldung erweitern wir zu einem Failure mit entsprechenden Typ.

Somit haben wir folgenden Code:

[FunicularSwitch.Generators.UnionType(CaseOrder=CaseOrder.AsDeclared)]publicabstractclassFailure{publicstaticFailureUntrustedOperator(OperatorInformationoperatorInformation)=>newUntrustedOperator_(operatorInformation);publicstaticFailureSwitchError(stringerrorMessage)=>newSwitchError_(errorMessage);publicstaticFailureTelemetryError(stringerrorMessage)=>newTelemetryError_(errorMessage);publicstaticFailureTrackOccupied(TimeSpaneta)=>newTrackOccupied_(eta);publicclassUntrustedOperator_:Failure{publicOperatorInformationOperatorInformation{get;}publicUntrustedOperator_(OperatorInformationoperatorInformation):base(UnionCases.UntrustedOperator){OperatorInformation=operatorInformation;}}publicclassSwitchError_:Failure{publicstringErrorMessage{get;}publicSwitchError_(stringerrorMessage):base(UnionCases.SwitchError){ErrorMessage=errorMessage;}}publicclassTelemetryError_:Failure{publicstringErrorMessage{get;}publicTelemetryError_(stringerrorMessage):base(UnionCases.TelemetryError){ErrorMessage=errorMessage;}}publicclassTrackOccupied_:Failure{publicTimeSpanEta{get;}publicTrackOccupied_(TimeSpaneta):base(UnionCases.TrackOccupied){Eta=eta;}}internalenumUnionCases{UntrustedOperator,SwitchError,TelemetryError,TrackOccupied}internalUnionCasesUnionCase{get;}Failure(UnionCasesunionCase)=>UnionCase=unionCase;publicoverridestringToString()=>Enum.GetName(typeof(UnionCases),UnionCase)??UnionCase.ToString();boolEquals(Failureother)=>UnionCase==other.UnionCase;publicoverrideboolEquals(object?obj){if(ReferenceEquals(null,obj))returnfalse;if(ReferenceEquals(this,obj))returntrue;if(obj.GetType()!=GetType())returnfalse;returnEquals((Failure)obj);}publicoverrideintGetHashCode()=>(int)UnionCase;}
privateCustomResult<DateTimeOffset>CheckRailwayTrack(){varsignal=newRailwaySignal();varseconds=signal.GetArrivalTimeInSeconds();if(seconds<10){returnCustomResult.Error<DateTimeOffset>(Failure.TelemetryError("Unknown error"));}if(seconds<20){returnCustomResult.Error<DateTimeOffset>(Failure.TelemetryError("Could not check the track, no sensor data arrived"));}if(seconds<30){returnCustomResult.Error<DateTimeOffset>(Failure.TrackOccupied(TimeSpan.FromSeconds(seconds)));}returnDateTimeOffset.Now.AddSeconds(seconds);}
privateCustomResult<Unit>SetDirection(SwitchDirectionswitchDirection,DateTimeOffsetestimatedTimeOfArrival){varswitchGroup=newSwitchGroup();varres=switchGroup.Set(switchDirection,estimatedTimeOfArrival);if(res.SwitchResult!=SwitchResult.Success){returnCustomResult.Error<Unit>(Failure.SwitchError(res.ErrorMessage));}returnNo.Thing;}
publicCustomResult<string>Set(SetCommandcmd){returnCertificateParser.GetOperatorFromCertificate(cmd.SigningCert).ToCustomResult().Bind(operatorName =>CheckRailwayTrack().Bind(eta =>{returnSetDirection(cmd.Direction,eta).Bind<string>(_ =>{SetAudit(operatorName,cmd.Direction);returnstring.Empty;});}));}

Wenn wir die Set anschauen, kann man sich die Frage stellen, warum das Ergebnis der Methode ein string ist? Bei genauer Betrachtung kann man erkennen, dass Audit eigentlich mit der Set-Operation nichts zu tun hat. Es ist "Seiteneffekt". SetAudit muss vom Konsumenten aufgerufen werden. Somit können wir als Ergebnis der Operation den neuen Zustand der Weiche zurückgeben und es dann auditieren oder eine Fehlermeldung loggen.

publicCustomResult<SwitchInfo>Set(SetCommandcmd){returnCertificateParser.GetOperatorFromCertificate(cmd.SigningCert).ToCustomResult().Bind(operatorName =>CheckRailwayTrack().Bind(eta =>{returnSetDirection(cmd.Direction,eta).Bind<SwitchInfo>(_ =>newSwitchInfo(operatorName,cmd.Direction));}));}

Und beim Konsumenten

railroadSwitch.Set(cmd).Match(
info =>Audit.Log(info.OperatorName,info.Direction),
e =>Console.WriteLine($"Error set the switch: {e}"));

Parallele Ausführung und Aggregation

Bei der Set-Methode kann man erkennen, dass die GetOperatorFromCertificate und CheckRailwayTrack Operation unabhängig voneinander durchgeführt werden können. D.h. diese können parallel ausgeführt und deren Ergebnisse dann aggregiert werden.

Hierfür müssen wir unseren CustomResult dahingehend erweitern, dass er mehrere Failure aggregieren kann.

Wir erweitern Failure um ein Aggregate mit einer Liste von Failure-Einträgen.

publicstaticFailureAggregated(IReadOnlyList<Failure>innerFailures)=>newAggregated_(innerFailures);

Wir müssen darüber hinaus das Mergen definieren. Funicular-Switch erwartet eine Methode mit dem Attribut MergeError.

publicstaticclassFailureExtension{[MergeError]publicstaticFailureMerge(thisFailureerror,Failureother){returnFailure.Aggregated(new[]{error,other});}}

Somit haben wir schließlich:

publicCustomResult<SwitchInfo>Set(SetCommandcmd){varresult1=CertificateParser.GetOperatorFromCertificate(cmd.SigningCert).ToCustomResult();varresult2=CheckRailwayTrack();varaggregatedResult=result1.Aggregate(result2,(operatorName,eta)=>(operatorName,eta));returnaggregatedResult.Bind(t =>{returnSetDirection(cmd.Direction,t.eta).Bind<SwitchInfo>(_ =>newSwitchInfo(t.operatorName,cmd.Direction));});}

Der zweite Parameter Combine dient dazu, das Ergebnis-Tuple mit benannten Argumente zu versehen.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

3 watching

Forks

Releases

Packages

Contributors

Languages