
Es hat viel reden In der Softwareentwicklung gibt es viele Diskussionen über die Probleme von OOP und insbesondere veränderlichem Zustand. Nebeneffektfreie funktionale Programmierung wird oft als Lösung angepriesen. Zwar gibt es einige wichtige Punkte zur Veränderlichkeit, die wahren Probleme entstehen jedoch durch die naive Verwendung implizit veränderlicher Objekte. Richtig eingesetzt kann veränderlicher Zustand ein nützliches Werkzeug für die Analyse von Code sein und zukünftige Entwickler auf natürliche und nahtlose Weise zu korrekten Codeänderungen führen. In diesem Beitrag teilen wir einige unserer Probleme, die zu einem Prinzip des Klassendesigns führten – der Verwendung von Typen zur Anzeige des Objektzustands.
Stateful-Mitarbeiter
Beginnen wir mit einem Beispiel. Bei Rescale verwenden wir eine REST-API, um die Metadaten eines Jobs zu verfolgen. Unsere Worker-Knoten fragen die API ab, um Informationen darüber zu erhalten, welche virtuelle Hardware hochgefahren werden soll, und unsere Cluster-Knoten fragen sie ab, um Informationen darüber zu erhalten, welche Analyse ausgeführt werden soll. Beide authentifizieren sich durch die Übergabe eines verschlüsselten Tokens. Unsere erste Client-Schnittstelle sah in etwa so aus:
öffentliche Schnittstelle JobMetadataClient { Analysis getAnalysis(lange Job-ID, Anmeldeinformationen Anmeldeinformationen); CoreSummary getCoreSummary(lange Job-ID, Anmeldeinformationen Anmeldeinformationen); … }
Diese Schnittstelle war zwar hilfreich, um sicherzustellen, dass wir bei jeder Anfrage immer die richtigen Anmeldeinformationen übermittelten, war aber etwas umständlich. Da wir alten Code für die Verwendung des neuen API-Clients umgestalteten, mussten wir für jeden Methodenaufruf Anmeldeinformationen erstellen. Jeder Worker übernimmt eine Aufgabe für einen Job und stellt dann viele Anfragen mit dem Authentifizierungstoken dieses Jobs. Daher mussten wir ständig identische Anmeldeinformationen erstellen oder eines weitergeben. Das machte es mühsam, sich so stark auf die API zu verlassen, wie wir wollten.
Unser nächster Gedanke war, dass ein Worker einmalig Anmeldeinformationen für seinen API-Client festlegen kann, wenn er eine Aufgabe für einen Job abruft, und dann alle Hilfsobjekte den Client verwenden lassen kann, in der Annahme, dass er seine Anmeldeinformationen bereits erhalten hat. Die Schnittstelle sah dann etwa so aus:
öffentliche Schnittstelle JobMetadataClient { void setCredentials(Credentials credentials); Analysis getAnalysis(long jobId); CoreSummary getCoreSummary(long jobId); ... }
Der meiste aufrufende Code konnte nun einfach die Abfragemethoden aufrufen, ohne sich um Anmeldeinformationen kümmern zu müssen. Dies vereinfachte zwar die Nutzung der API-Clientmethoden, vergaßen jedoch, Anmeldeinformationen festzulegen, wenn wir den Client in einen neuen Bereich der Codebasis einführten. Dies führte auch dazu, dass wir Code schrieben, der implizite Annahmen über den Kontext traf, in dem er ausgeführt wurde, und daher weniger wiederverwendbar und anfälliger war. Änderungen an einem Teil des Systems konnten andere Teile beschädigen, was bei Softwaresystemen grundsätzlich zu vermeiden ist.
Die erste Implementierung ist das, was ich einen Single-State-Collaborator nenne. Es handelt sich lediglich um eine Sammlung von Methoden, und Entwickler können sich anhand einer Instanz leicht ein Bild über ihr Verhalten machen. Bei der zweiten Implementierung ist die Sache nicht so offensichtlich, da sie einen impliziten Zustandswechsel beinhaltet. Sobald ein Entwickler eine Instanz erhält, sieht er, dass er Abfragemethoden aufrufen kann, die wahrscheinlich funktionieren. Das Verständnis des Authentifizierungszustands erfordert tiefere Systemkenntnisse.
Es gibt ein besseres Design, das statische Typisierung nutzt, um den Authentifizierungsstatus für zukünftige Entwickler sofort ersichtlich zu machen. Wir können zu unserer ersten Client-Schnittstelle zurückkehren und bei jedem Methodenaufruf Anmeldeinformationen anfordern, aber auch eine Wrapper-Klasse bereitstellen. dessen Typ seinen Zustand angibt:
öffentliche Schnittstelle JobMetadataClient { Analysis getAnalysis(lange Job-ID, Anmeldeinformationen Anmeldeinformationen); CoreSummary getCoreSummary(lange Job-ID, Anmeldeinformationen Anmeldeinformationen); … } … öffentliche Klasse AuthenticatedMetadataClient { private finale Anmeldeinformationen Anmeldeinformationen; private finale JobMetadataClient metadataClient; öffentliche Analysis getAnalysis(lange Job-ID) { return this.metadataClient.getAnalysis(Job-ID, this.credentials); } … }
Wenn Entwickler nun an einer Klasse arbeiten, die eine Instanz von AuthenticatedMetadataClient als Kollaborateur verwendet, wissen sie mit Sicherheit, dass diese über eine Authentifizierung verfügt und diese nicht verlieren wird. Neue Klassen, die eine Instanz von AuthenticatedMetadataClient in ihren Konstruktoren verwenden, können nur verwendet werden, wenn die Authentifizierung bereits erfolgt ist. Zukünftige Entwickler erkennen anhand der Klasse, was sie mit den Client-Objekten tun können, und ihre IDEs schlagen entsprechende Methoden vor. Sie müssen sich nicht so viele Informationen über das gesamte System merken, um über Teile davon nachzudenken. Dies sind leistungsstarke Tools für die Arbeit im Code.
Akkumulierung des Zustands im Speicher
Das war gut und schön für einen API-Client, aber diese Klasse hat nicht wirklich technische den Zustand zu ändern, da der tatsächliche Zustand in der API gespeichert ist. Was ist, wenn wir Zustandsänderungen im Speicher sammeln möchten, bevor wir sie persistent speichern? Nehmen wir ein weiteres Beispiel aus dem Rescale-Code. Wir verwenden Optimierungssoftware, die eine Analyse mit unterschiedlichen Werten für die Anfangsparameter durchführt und ein „optimales“ Ergebnis auswählt. Wir stellen diesen Workflow mit einer Klasse dar, beispielsweise CaseWorkflow, die die Werte der Anfangsparameter für den optimalen Durchlauf speichert, sobald dieser ermittelt wurde. Wir möchten diese Werte persistent speichern, sobald alles abgeschlossen ist.
Wir hatten also zunächst einen sehr imperativen Code, der alle Initialisierungs- und Bereinigungsaktionen in einer einzigen Methode ausführte:
public void runWorkflow(CaseWorkflow workflow) { doSomeInitialization1(); doSomeInitialization2(); for(InitialParameters initialParamters : workflow.getParams()) { //Parameter ausführen und sie im Workflow festlegen, wenn optimal } doSomeCleanup1(); doSomeCleanup2(); //die wichtige Zeile für dieses Beispiel persistOptimalParameters(workflow.getOptimalParameters()); }
Wir haben uns entschieden, dies mithilfe von Lebenszyklus-Listener um Verantwortlichkeiten zu trennen und den Code leichter verständlich und einheitlich testbar zu machen. Wir haben eine Schnittstelle wie diese geschrieben:
öffentliche Schnittstelle WorkflowLifecycleListener { void notifyWorkflowStarting(CaseWorkflow workflow); void notifyParamtersRun(IntialParameters initialParameters); void notifyWorkflowCompleted(); }
Und die ursprüngliche Methode wurde umgestaltet, um diese Listener zu verwenden:
public void run(CaseWorkflow workflow, ListenerFactory factory) { Collection lifecycleListener = factory.createListeners(workflow); für(WorkflowLifecycleListener listener : lifecycleListeners) { listener.notifyWorkflowStarting(workflow); } für(InitialParameters initialParamters : workflow.getParams()) { //Prozessparameter für(WorkflowLifecycleListener listener : lifecycleListeners) { listener.notifyParametersRun(initialParameters); } } für(WorkflowLifecycleListener listener : lifecycleListeners) { listener.notifyWorkflowCompleted(); } }
Wir haben ein Factory-Objekt verwendet, weil wir für verschiedene Workflow-Typen unterschiedliche Listener-Sets wollten, aber das ist hier nicht relevant. Wichtig ist, dass jedes Listener-Objekt im Gültigkeitsbereich erstellt und an einen einzelnen Workflow gebunden wurde. Deshalb war der folgende Listener damals sinnvoll:
öffentliche Klasse PersistOptimalParametersListener implementiert WorkflowLifecycleListener { private final InitialParameters optimalParameters; öffentliche PersistOptimalParametersListener(CaseWorkflow Workflow) { this.optimalParameters = workflow.getOptimalParameters(); } @Override öffentliche void notifyWorkflowStarting(CaseWorkflow Workflow) { } @Override öffentliche void notifyParamtersRun(IntialParameters params) { } @Override void notifyWorkflowCompleted() { persistOptimalParameters(this.optimalParameters); } }
Nach all diesen Erklärungen liegt der Fehler auf der Hand: Zum Zeitpunkt der Instanziierung dieses Listeners sind die optimalen Parameter für den Workflow nicht festgelegt. Zu diesem Zeitpunkt handelt es sich lediglich um eine leere Sammlung – doch während des Refactorings kann das leicht vergessen werden. Um dies im Auge zu behalten, ist viel Kontext über das gesamte Optimierungssystem erforderlich. Wir fragten uns: Könnten wir diesen Fehler durch feinkörnigere Typen vermeiden und den Workflow-Status an zukünftige Entwickler kommunizieren? Ja.
Ein zentrales Problem war hier die in Java übliche Verwendung von Getter- und Setter-Methoden. Eine Klasse mit der Methode getOptimalParameters teilt dem Entwickler nicht mit, wann diese Methode sinnvoll aufgerufen werden kann. Diese Klasse nutzt implizite Statusänderungen, wie unser API-Client, der das Setzen von Anmeldeinformationen für sich selbst ermöglichte. Stattdessen sollten wir die Objekte so schreiben, dass sie diese Methoden überhaupt nicht verwenden, wenn sie nicht sinnvoll aufgerufen werden können:
öffentliche Klasse CaseWorkflow { ... öffentlicher CompletedWorkflow abgeschlossen (InitalParameters optimaleInitialParameters) { ... } ... } öffentliche Klasse CompletedWorkflow erweitert CaseWorkflow { private final InitialParameters optimaleInitialParameters; öffentliche InitialParameters getOptimalParamters() { ... } }
Wie beim AuthenticatedClient im ersten Beispiel können wir nun Methoden schreiben, die auf einen CompletedWorkflow angewendet werden und dessen Status sicher sein. Wir müssen uns nicht alle Details merken, was wann gesetzt wird, da die in der Klasse verfügbaren Methoden uns dies mitteilen.
Zusammenfassung
Gemeinsam ist diesen Beispielen die Nutzung des Java-Typsystems als Werkzeug zur Dokumentation der möglichen Objektzustände. Mithilfe der IDE-Methodenvorschläge ist das Denken über Objekte mit informativen Typen natürlich und reibungslos. Die Typen reduzieren zudem den Kontext, der für das korrekte Verständnis des Objektverhaltens erforderlich ist, was die Produktivität steigert.
