6. Entwurfsmuster (Erweiterbarkeit)¶
Das Ziel der Übung¶
Ziele der Übung (anhand eines komplexeren, praxisnahen Beispiels):
- Üben einiger grundlegender Entwurfsprinzipien, die Erweiterbarkeit, Wiederverwendbarkeit, Codeübersichtlichkeit und Wartbarkeit fördern: SRP, OPEN-CLOSED, DRY, KISS usw.
- Anwendung einiger Entwurfsmuster, die besonders mit Erweiterbarkeit in Verbindung stehen (Template Method, Strategy, Dependency Injection).
- Üben und Kombinieren weiterer Techniken zur Unterstützung von Erweiterbarkeit und Wiederverwendbarkeit (z. B. Delegates/Lambda-Ausdrücke) mit Entwurfsmustern.
- Übung zur Refaktorisierung von Code.
Zugehörige Vorlesungen:
- Entwurfsmuster: Muster im Zusammenhang mit Erweiterbarkeit (Einführung, Template Method, Strategy) sowie das „Muster“ der Dependency Injection.
Voraussetzungen¶
Für die Durchführung der Übung benötigte Werkzeuge:
- Visual Studio 2022
Übung unter Linux oder macOS
Das Übungsmaterial wurde grundsätzlich für Windows und Visual Studio erstellt, kann aber auch unter anderen Betriebssystemen mit anderen Entwicklungsumgebungen (z. B. VS Code, Rider, Visual Studio for Mac) oder sogar mit einem Texteditor und CLI-Tools durchgeführt werden. Dies ist möglich, weil die Beispiele im Kontext einer einfachen Konsolenanwendung dargestellt werden (es gibt keine Windows-spezifischen Elemente), und das .NET 8 SDK wird unter Linux und macOS unterstützt. Hello World unter Linux.
Theoretischer Hintergrund, Denkweise *¶
Bei der Entwicklung komplexerer Anwendungen müssen wir zahlreiche Designentscheidungen treffen, bei denen oft mehrere Möglichkeiten zur Auswahl stehen. Wenn wir in diesen Punkten nicht auf einfache Wartbarkeit und einfache Erweiterbarkeit unserer Anwendung achten, kann die Entwicklung schnell zum Albtraum werden. Änderungs- und Erweiterungswünsche des Kunden erfordern eine ständige, umfassende Umstrukturierung des Codes: dabei entstehen neue Fehler, und es ist erheblicher Aufwand für umfassende Retests notwendig!
Unser Ziel ist es, solche Änderungs- und Erweiterungswünsche durch Erweiterungen an wenigen, gut definierten Stellen im Code – ohne wesentliche Änderungen am bestehenden Code – umsetzen zu können. Das Schlüsselwort ist: statt Änderung lieber Erweiterung. Damit verbunden gilt: Wenn bestimmte Logiken erweiterbar sind, dann sind sie auch allgemeiner und können in mehreren Kontexten leichter wiederverwendet werden. So kommen wir langfristig schneller voran, unser Code wird kürzer, wir vermeiden Code-Duplikationen (was wiederum die Wartbarkeit erhöht).
Entwurfsmuster zeigen bewährte Lösungen für häufig auftretende Entwurfsprobleme: Sie helfen dabei, unseren Code leichter erweiterbar, wartbar und so weit wie möglich wiederverwendbar zu gestalten. In dieser Übung konzentrieren wir uns auf solche Muster, Prinzipien und einige Programmierwerkzeuge, die helfen, die oben genannten Probleme zu lösen. Aber man soll es nicht übertreiben: Ein bestimmtes Entwurfsmuster sollte nur dann eingesetzt werden, wenn es tatsächlich einen Vorteil bringt. Andernfalls erhöht es nur unnötig die Komplexität der Implementierung. Vor diesem Hintergrund ist es nicht unser Ziel (und oft auch gar nicht möglich), alle zukünftigen Erweiterungsbedürfnisse vorherzusehen oder im Voraus zu durchdenken. Wichtig ist, dass wir – auch ausgehend von einer einfachen Lösung – unsere Probleme erkennen und den Code kontinuierlich so refaktorisieren, dass er den aktuellen (funktionalen und nicht-funktionalen) Anforderungen entspricht und an den richtigen Stellen besser erweiterbar und wiederverwendbar wird.
Es sei auch erwähnt, dass entsprechende Entwurfsmuster und Sprachmittel auch bei der Unit-Testbarkeit unseres Codes eine große Hilfe darstellen: In vielen Unternehmen ist es bei der Entwicklung von Softwareprodukten (zurecht) eine Grundanforderung an die Entwickler, dass sie Unit-Tests mit hoher Codeabdeckung erstellen. Dies ist jedoch praktisch unmöglich, wenn unsere Codeeinheiten/Klassen zu eng miteinander gekoppelt sind.
0. Aufgabe – Kennenlernen der Aufgabe und der Ausgangsanwendung¶
Klonen wir das Repository der Ausgangsanwendung zur 6. Übung von hier:
- Öffne eine Kommandozeile (Command Prompt)
- Navigiere in einen beliebigen Ordner, z. B. nach
c:\work\NEPTUN
- Führe folgenden Befehl aus:
git clone https://github.com/bmeviauab00/lab-patterns-extensibility-kiindulo.git
- Öffne die Solution Lab-Patterns-Extensibility.sln in Visual Studio.
Beschreibung der Aufgabe¶
Im Labor werden wir eine konsolenbasierte Datenverarbeitungsanwendung (genauer gesagt, eine Anonymisierungsanwendung) entsprechend den sich fortlaufend entwickelnder Anforderungen erweitern – entlang verschiedener Aspekte und unter Anwendung unterschiedlicher Techniken. In der ersten Aufgabe wird auch das Konzept der Anonymisierung vorgestellt.
Die Eingabe in die Anwendung ist eine CSV-Textdatei, in der jede Zeile Daten zu einer bestimmten Person enthält. Öffnen wir im Dateisystem die Datei us-500.csv im Ordner Data (durch Doppelklick oder mit Notepad). Wir sehen, dass die Daten zu den einzelnen Personen in Anführungszeichen (" ") und durch Kommas getrennt dargestellt sind (es handelt sich um fiktive Daten). Schauen wir uns die erste Zeile an:
"James","Rhymes","Benton, John B Jr","6649 N Blue Gum St","New Orleans ","Orleans","LA","70116","504-621-8927","504-845-1427","30","65","Heart-related","jRhymes@gmail.com"
Die Person in der ersten Zeile heißt James Rhymes, arbeitet bei der Firma "Benton, John B Jr", danach folgen einige Adressfelder, er ist 30 Jahre alt und wiegt 65 kg. Das nachfolgende Feld beschreibt eine schwerwiegende Erkrankung (in der obigen Zeile: „Heart-related“). Die letzte Spalte enthält die E-Mail-Adresse der Person.
Datenquelle und genaues Format *
Die Daten stammen von: https://www.briandunning.com/sample-data/, ergänzt um einige zusätzliche Spalten (Alter, Gewicht, Krankheit). Die Reihenfolge der Felder: First Name, Last Name, Company, Address, City, County (falls zutreffend), State/Province (falls zutreffend), ZIP/Postal Code, Phone 1, Phone 2, Age, Weight, Illness, Email
Die Hauptaufgabe der Anwendung besteht darin, diese Daten je nach aktueller Anforderung zu anonymisieren und in eine Ausgabedatei im CSV-Textformat zu schreiben. Ziel der Anonymisierung ist es, die Personen in der Datenmenge durch Transformationen unkenntlich zu machen – allerdings auf eine Weise, die dennoch statistische Auswertungen über die Daten ermöglicht. Anonymisierung ist ein eigenständiger, sehr anspruchsvoller und herausfordernder Bereich der Datenverarbeitung. In dieser Übung ist es nicht unser Ziel, Lösungen zu entwickeln, die in realen Szenarien einsetzbar oder in jeder Hinsicht sinnvoll sind. Für uns ist eigentlich die Anwendung eines beliebigen Datenverarbeitungsalgorithmus wichtig, um die Entwurfsmuster zu demonstrieren. Dies liefert einen etwas „spannenderen“ Rahmen als einfache Filter-, Sortier- oder ähnliche Datenverarbeitung (die von .NET standardmäßig unterstützt wird).
Einige Gedanken zur Anonymisierung
Man könnte meinen, dass die Anonymisierung ein einfaches Problem ist. Zum Beispiel müsste man nur die Namen der Personen entfernen oder durch Sternchen ersetzen, ebenso wie die Straßenadresse, Telefonnummern und E-Mail-Adresse – und schon wäre man fertig. Für die erste Zeile unserer Eingabedateri sähe die Ausgabe dann so aus:
"***","***","Benton, John B Jr","***","New Orleans ","Orleans","LA","70116","***","***","30","65","Heart-related","***"
Doch so einfach ist es nicht – vor allem nicht bei großen Datenmengen. Denken wir zum Beispiel an ein kleines Dorf mit wenigen Einwohnern. Angenommen, eine auf die oben beschriebene Weise anonymisierte Person ist 14 Jahre alt, aber extrem übergewichtig, etwa 95 kg. Das ist eine seltene „Kombination“, es ist sehr wahrscheinlich, dass niemand sonst im Dorf solche Merkmale aufweist. Wenn jemand aus seiner Schulklasse (8. Klasse, da 14 Jahre alt) die „anonymisierten“ Daten sieht, wird er sofort wissen, um wen es sich handelt (es gibt keinen anderen übergewichtigen Achtklässler an der Schule). So erfährt er z. B. auch, welche Krankheit die Person hat. Fazit: Zusammenhänge zwischen den Daten können Rückschlüsse auf die Identität zulassen.
Was ist die Lösung? Stadt, Alter und Gewicht können nicht entfernt oder mit Sternchen ersetzt werden, da genau zu diesen Merkmalen Auswertungen durchgeführt werden sollen. Eine typische Lösung: Anstelle des genauen Alters oder Gewichts werden nach der Anonymisierung Intervalle angegeben (also eine Generalisierung der Daten). Für die obige Person würde man z. B. „10–20 Jahre“ beim Alter und „80–100 kg“ beim Gewicht angeben – und genau diese Werte würden in die Ausgabedatei geschrieben. Auf diese Weise ist keine eindeutige Identifikation der Person mehr möglich. Diese Technik werden wir später noch anwenden.
Ausgangsanforderungen¶
Die Ausgangsanforderungen an die Anwendung:
- Es sollen von einem bestimmten Kunden empfangene Dateien (alle im gleichen Format) verarbeitet werden, die mit demselben Anonymisierungsalgorithmus in dasselbe Ausgabeformat konvertiert werden. Die Anonymisierung soll lediglich darin bestehen, Vor- und Nachnamen mit Sternchen zu ersetzen.
- Eine gewisse Datenbereinigung ist notwendig. In der Spalte mit dem Stadtnamen können am Anfang oder Ende überflüssige
_
- oder#
-Zeichen vorkommen, diese müssen entfernt werden (Trim-Operation). - Nach der Verarbeitung jeder Zeile soll eine Nachricht auf der Konsole ausgegeben werden, dass die Zeile verarbeitet wurde. Außerdem sollen nach der vollständigen Verarbeitung aller Daten zusammenfassende Informationen (Summary) angezeigt werden: wie viele Zeilen wurden verarbeitet, und in wie vielen Fällen musste der Stadtname getrimmt werden.
- Wichtiger Aspekt: Die Anwendung wird nur für kurze Zeit benötigt und soll in Zukunft nicht erweitert werden.
Hinweis: Um den Code übersichtlicher zu halten und weniger Felder verarbeiten zu müssen, werden einige Felder bei der Verarbeitung weggelassen.
Beispiel für die erwartete Ausgabe der ersten Zeile aus der Eingabedatei:
***; ***; LA; New Orleans; 30; 65; Heart-related
1. Lösung – Alles in einem (1-Start/Start)¶
Im Solution Explorer von Visual Studio sehen wir Ordner mit Namen, die mit den Zahlen 1 bis 4 beginnen. Diese enthalten die Lösungen zu den jeweiligen Arbeitsiterationen. Die erste Lösung befindet sich im Ordner „1-Start“ unter dem Projektnamen „Start“. Werfen wir einen Blick auf die im Projekt enthaltenen Dateien:
Person.cs
– Enthält die für uns relevanten Daten einer Person. Die Daten einer einzelnen Person werden in Objekte dieser Klasse eingelesen.Program.cs
– In der Main-Funktion dieser Datei ist die gesamte Logik implementiert, durch Kommentare "getrennt". Sobald die Logik etwas komplexer wird, wird es bereits nach wenigen Tagen (oder Stunden?) schwierig sein, den eigenen Code zu überblicken und zu verstehen. Diese Lösung ist daher nicht weiter von Interesse.
Insgesamt ist die Lösung sehr einfach gehalten, da für den Code keine lange Lebensdauer erwartet wird. Dennoch ist eine „skriptartige“ Lösung, bei der sich alles in einer einzigen Funktion befindet, auch in solchen Fällen nicht empfehlenswert – sie erschwert erheblich das Verständnis und die Übersichtlichkeit des Codes. Daher sollten wir uns damit nicht weiter beschäftigen.
2. Lösung (2-OrganizedToFunctions/OrganizedToFunctions-1)¶
Wechseln wir im Visual Studio zum Projekt „OrganizedToFunctions-1“, das sich im Ordner „2-OrganizedToFunctions“ befindet. Diese Lösung ist bereits deutlich sympathischer, da die Logik in Funktionen aufgeteilt wurde. Werfen wir einen kurzen Blick auf den Code:
Anonymizer.cs
- Die Funktion
Run
bildet das „Rückgrat“ der Anwendung. Sie enthält die Steuerlogik und ruft die einzelnen, für die jeweiligen Schritte zuständigen Funktionen auf. ReadFromInput
: Liest die Quelldatei ein, erstellt für jede Zeile einPerson
-Objekt und gibt eine Liste der eingelesenenPerson
-Objekte zurück.TrimCityNames
: Führt die Datenbereinigung durch (Trimmen der Städtenamen).Anonymize
: Wird für jedes eingelesenePerson
-Objekt aufgerufen und gibt ein neuesPerson
-Objekt zurück, das die anonymisierten Daten enthält.WriteToOutput
: Schreibt die bereits anonymisiertenPerson
-Objekte in die Ausgabedatei.PrintSummary
: Gibt eine Zusammenfassung der Verarbeitung am Ende in der Konsole aus.
Program.cs
- Erstellt ein
Anonymizer
-Objekt und führt es durch einen Aufruf derRun
-Funktion aus. Es ist ersichtlich, dass der String für das Maskieren während der Anonymisierung als Konstruktorparameter übergeben werden muss.
Probieren wir es aus, und führen wir das Projekt aus! Stellen wir sicher, dass „OrganizedToFunctions-1“ im Visual Studio als Startprojekt festgelegt ist (Rechtsklick darauf und Set as Startup Project), und starten wir es dann:
Die Ausgabedatei können wir im Datei-Explorer finden, sie befindet sich im Ordner „OrganizedToFunctions-1\bin\Debug\net8.0\" oder einem ähnlichen Ordner und hat den Namen „us-500.processed.txt“. Öffnen wir diese Datei und werfen einen Blick auf die Daten.
Bewertung der Lösung¶
- Die Lösung ist grundsätzlich gut strukturiert und leicht verständlich.
- Sie folgt dem KISS (Keep It Stupid Simple)-Prinzip, verwendet keine unnötigen Komplikationen. Das ist gut, da keine potenziellen zukünftigen Erweiterungen zu erwarten sind und keine unterschiedlichen Formate, Logiken usw. unterstützt werden müssen.
-
Unsere Lösung folgt jedoch nicht einem der grundlegendsten und bekanntesten Entwurfsprinzipien, das unter dem Namen Single Responsibility Principle (SRP) bekannt ist. Dieses besagt - vereinfacht ausgedrückt -, dass eine Klasse nur eine Verantwortung haben sollte (sich grundsätzlich nur mit einer Sache beschäftigen sollte).
- Zweifellos hat unsere
Anonymizer
-Klasse mehrere Verantwortlichkeiten: Eingabeverarbeitung, Datenbereinigung, Anonymisierung, Ausgabeerstellung usw. - Dieses Problem fällt bei uns jedoch nicht auf und verursacht keine Probleme, weil jede dieser Verantwortlichkeiten einfach umgesetzt ist und in eine kürzere Funktion passt. Wenn jedoch eine der Verantwortlichkeiten komplexer wäre und in mehreren Funktionen umgesetzt werden müsste, sollte sie auf jeden Fall in eine separate Klasse ausgelagert werden.
Warum ist es problematisch, wenn eine Klasse mehrere Verantwortlichkeiten hat? *
- Es wird schwieriger, ihr Verhalten zu verstehen, weil sie sich nicht nur auf eine Aufgabe konzentriert.
- Wenn Änderungen in einer der Verantwortlichkeiten erforderlich sind, muss eine große Klasse geändert und neu getestet werden.
- Zweifellos hat unsere
-
Für die Lösung können automatisierte Integrations- (Input-Output) Tests geschrieben werden, aber „echte“ Unit-Tests sind nicht möglich.
3. Lösung (OrganizedToFunctions-2-TwoAlgorithms)¶
Im Gegensatz zu den vorherigen "Plänen" sind neue Benutzeranforderungen aufgetreten. Unser Kunde hat seine Meinung geändert und bittet um die Implementierung eines anderen Anonymisierungsalgorithmus für einen anderen Datensatz: Das Alter der Personen muss in Bereichen gespeichert werden, das genaue Alter darf nicht erkennbar sein. Zur Vereinfachung werden in diesem Fall die Namen der Personen nicht anonymisiert, betrachten wir dies als eine Art "Pseudo"-Anonymisierung (es macht immer noch Sinn, ist es nicht ganz korrekt, dies Anonymisierung zu nennen).
Unsere Lösung, die sowohl den alten als auch den neuen Algorithmus unterstützt (aber immer nur einen von beiden), befindet sich im VS-Projekt OrganizedToFunctions-2-TwoAlgorithms. Werfen wir einen Blick auf die Anonymizer
-Klasse. Die Grundprinzipien der Lösung (lassen wir uns diese im Code durchgehen):
- Wir haben einen
AnonymizerMode
-Enum-Typ eingeführt, der festlegt, in welchem Modus (mit welchem Algorithmus) dieAnonymizer
-Klasse verwendet wird. - Die
Anonymizer
-Klasse hat zwei Anonymisierungsoperationen:Anonymize_MaskName
,Anonymize_AgeRange
. - Die
Anonymizer
-Klasse speichert im_anonymizerMode
-Feld, welcher Algorithmus verwendet werden soll: Für die beiden Modi haben wir zwei verschiedene Konstruktoren eingeführt, die den Wert von_anonymizerMode
festlegen. - Die
Anonymizer
-Klasse überprüft an mehreren Stellen (z.B. in den MethodenRun
undGetAnonymizerDescription
), welchen Wert_anonymizerMode
hat, und verzweigt sich entsprechend. -
In
GetAnonymizerDescription
muss dies getan werden, da diese Methode dafür verantwortlich ist, eine einzeilige Beschreibung des Anonymisierungsalgorithmus zu erstellen, die am Ende der Verarbeitung im "Summary" angezeigt wird. Werfen wir einen Blick auf den Code vonPrintSummary
, der diese Methode aufruft. Zum Beispiel wird dies als Zusammenfassung auf der Konsole angezeigt, wenn wir den Altersanonymisierer mit einem Bereich von 20 verwenden:Summary - Anonymizer (Age anonymizer with range size 20): Persons: 500, trimmed: 2
Bewertung der Lösung¶
Insgesamt ist die Qualität unseres Codes im Vergleich zum Vorherigen schlechter geworden.
Früher war es kein Problem, dass die Anonymisierungsalgorithmen nicht erweiterbar waren, da es keine Nachfrage danach gab. Aber sobald der Bedarf für einen neuen Algorithmus aufgetreten ist, war es ein Fehler, die Lösung in dieser Hinsicht nicht erweiterbar zu machen: Ab jetzt müssen wir lieber damit rechnen, dass weitere Algorithmen in der Zukunft eingeführt werden müssen.
Warum behaupten wir, dass unser Code nicht erweiterbar ist, wenn "nur" ein neuer Enum-Wert und ein paar zusätzliche if
/switch
-Zweige im Code hinzugefügt werden müssen, wenn ein neuer Algorithmus eingeführt wird?
Open/Closed Principle
Es ist entscheidend, dass wir eine Klasse nur dann als erweiterbar betrachten, wenn es möglich ist, neues Verhalten (in unserem Fall einen neuen Algorithmus) ohne Modifikation der Klasse einzuführen, indem wir nur den Code erweitern/vergrößern. Das bedeutet, dass der Code der Anonymizer
-Klasse nicht verändert werden sollte, was hier eindeutig nicht der Fall ist. Dies ist das berühmte Open/Closed Principle: Die Klasse sollte für Erweiterungen offen und für Änderungen geschlossen sein. Das Problem bei der Modifikation des Codes ist, dass durch diese Änderungen sehr wahrscheinlich neue Bugs eingeführt werden und der modifizierte Code immer wieder getestet werden muss, was erhebliche Zeit- und Kostenaufwände verursachen kann.
Was ist das genaue Ziel und wie erreichen wir es? Es gibt Teile in unserer Klasse, die wir nicht "einbrennen" möchten:
- Diese sind keine Daten, sondern Verhalten (Code, Logik).
- Wir lösen es nicht mit
if
/switch
-Befehlen: Wir führen "Erweiterungspunkte" ein und stellen sicher, dass an diesen Stellen "beliebiger" Code ausgeführt werden kann. - Den Code dieser variablen/fallspezifischen Teile legen wir in andere Klassen (die aus der Perspektive unserer Klasse "austauschbar" sind)!
Note
Denken wir nicht an irgendwelche Zauberei, wir werden dafür die bekannten Werkzeuge verwenden: Vererbung mit abstrakten/virtuellen Funktionen, Schnittstellen oder Delegaten.
Suchen wir nach den Teilen, die fallabhängige, variable Logik enthalten, und die daher nicht direkt in die Anonymizer
-Klasse eingebaut werden sollten:
- Eine der Logiken ist die Anonymisierungslogik:
Anonymize_MaskName
/Anonymize_AgeRange
- Die andere ist
GetAnonymizerDescription
Diese müssen vom Code der Klasse getrennt werden, und an diesen Stellen muss die Klasse erweiterbar gemacht werden. Die folgende Abbildung zeigt das allgemeine Ziel:
Wir werden uns drei spezifische Entwurfsmuster und -techniken ansehen, um die oben genannten Ziele zu erreichen:
- Template Method Entwurfsmuster
- Strategy Entwurfsmuster (einschließlich Dependency Injection)
- Delegate (optional mit Lambda-Ausdruck)
Eigentlich haben wir diese Konzepte bereits in unseren Studien verwendet, aber jetzt werden wir sie noch besser kennen lernen und ihre Anwendung umfassender üben. Die ersten beiden werden wir im Labor untersuchen, das dritte wird dann im Rahmen einer zugehörigen Hausaufgabe behandelt.
4. Lösung (3-TemplateMethod/TemplateMethod-1)¶
In diesem Schritt werden wir mit der Anwendung des Template Method Entwurfsmusters unsere Lösung an den erforderlichen Punkten erweiterbar machen.
Note
Der Name des Musters ist "irreführend": Es hat nichts mit den in C++ erlernten Template-Methoden zu tun!
Klassendiagramm der Template Method basierte Lösung
Das folgende UML-Klassendiagramm veranschaulicht die Template-Methoden-basierte Lösung mit einem Fokus auf die wesentlichen Punkte:
Im Muster wird die Trennung der "unveränderlichen" und "variablen" Teile nach den folgenden Prinzipien umgesetzt (es ist sinnvoll, diese anhand des oben gezeigten Klassendiagramms - angewendet auf unser Beispiel - zu verstehen):
- Die "gemeinsamen/unveränderlichen" Teile kommen in eine Basisklasse.
- Erweiterungspunkte werden hier durch die Einführung abstrakter/virtueller Funktionen geschaffen, die an den Erweiterungspunkten aufgerufen werden.
- Die fallabhängige Implementierung dieser Erweiterungspunkte kommt in die abgeleiteten Klassen.
Der bekannte "Trick" besteht darin, dass, wenn die Basisklasse die abstrakten/virtuellen Funktionen aufruft, der fallabhängige Code in den abgeleiteten Klassen ausgeführt wird.
In den folgenden Schritten werden wir die vorherige enum
- bzw. if
/switch
-basierte Lösung in eine Template Method Lösung umwandeln (es wird kein enum
mehr verwendet). Wir werden eine Basisklasse und zwei algorithmusabhängige abgeleitete Klassen einführen.
Lassen wir uns den Code entsprechend anpassen. Im Visual Studio Solution "3-TemplateMethod" befindet sich das Projekt "TemplateMethod-0-Begin", das eine Kopie unserer vorherigen Lösung enthält. In diesem Projekt werden wir arbeiten:
- Benennen wir die Klasse
Anonymizer
inAnonymizerBase
um (z. B. in der Quelldatei mit Rechtsklick auf den Klassennamen und Drücken von F2). - Fügen wir dem Projekt eine
NameMaskingAnonymizer
- und eineAgeAnonymizer
-Klasse hinzu (Rechtsklick im Projekt, Add/Class). - Erben wir die Klassen
NameMaskingAnonymizer
undAgeAnonymizer
von derAnonymizerBase
. -
Verschieben wir die entsprechenden Teile aus der
AnonymizerBase
in dieNameMaskingAnonymizer
:- Die
_mask
-Mitgliedsvariable. - Der Konstruktor mit den Parametern
string inputFileName, string mask
, umbenannt zuNameMaskingAnonymizer
, wobei:- Die Zeile
_anonymizerMode = AnonymizerMode.Name;
entfernt wird. -
Anstelle von
this
verwenden wirbase
für den Konstruktoraufruf.Der Konstruktor Code
public NameMaskingAnonymizer(string inputFileName, string mask): base(inputFileName) { _mask = mask; }
- Die Zeile
- Die
-
Verschieben wir die entsprechenden Teile aus der
AnonymizerBase
in dieAgeAnonymizer
:- Die
_rangeSize
-Mitgliedsvariable. - Der Konstruktor mit den Parametern
string inputFileName, string rangeSize
, umbenannt zuAgeAnonymizer
, wobei:- Die Zeile
_anonymizerMode = AnonymizerMode.Age;
entfernt wird. -
Anstelle von
this
verwenden wirbase
für den Konstruktoraufruf.Der Konstruktor Code
public AgeAnonymizer(string inputFileName, int rangeSize): base(inputFileName) { _rangeSize = rangeSize; }
- Die Zeile
- Die
-
In der
AnonymizerBase
:- Löschen wir den
AnonymizerMode
Aufzählungstyp. - Löschen wir das
_anonymizerMode
-Feld.
- Löschen wir den
Suchen wir die Teile, die fallabhängige, variable Logiken enthalten, die wir nicht in die wiederverwendbare AnonymizerBase
-Klasse einbetten möchten:
- Eine davon ist
Anonymize_MaskName
/Anonymize_AgeRange
, - die andere ist
GetAnonymizerDescription
.
Dem Muster folgend führen wir in der Basisklasse abstrakte (oder möglicherweise virtuelle) Methoden ein und rufen diese auf, wobei die fallabhängigen Implementierungen in den abgeleiteten Klassen platziert werden (mit override
):
- Machen wir die
AnonymizerBase
-Klasse abstrakt (indem wir das Schlüsselwortabstract
vorclass
setzen). -
Fügen wir in
AnonymizerBase
die folgende Methode hinzu:protected abstract Person Anonymize(Person person);
Diese Methode wird für die Durchführung der Anonymisierung verantwortlich sein.
-
Bewegen wir die Methode
Anonymize_MaskName
in dieNameMaskingAnonymizer
-Klasse und ändern wir ihre Signatur, sodass sie die abstrakte MethodeAnonymize
der Basisklasse überschreibt:protected override Person Anonymize(Person person) { return new Person(_mask, _mask, person.CompanyName, person.Address, person.City, person.State, person.Age, person.Weight, person.Decease); }
Der Körper der Methode muss nur so geändert werden, dass anstelle des entfernten
mask
-Parameters die_mask
-Membervariable verwendet wird. -
Auf die gleiche Weise wie im vorherigen Schritt verschieben wir die Methode
Anonymize_AgeRange
in dieAgeAnonymizer
-Klasse und ändern ihre Signatur so, dass sie die abstrakte MethodeAnonymize
der Basisklasse überschreibt:protected override Person Anonymize(Person person) { ... }
Der Körper der Methode muss nur so geändert werden, dass anstelle des entfernten
rangeSize
-Parameters die_rangeSize
-Membervariable verwendet wird. -
In der
Run
-Methode derAnonymizerBase
-Klasse können wir dieAnonymize
-Aufrufe imif
/else
-Ausdruck jetzt durch einen einfachen Aufruf der abstrakten Methode ersetzen:Person person; if (_anonymizerMode == AnonymizerMode.Name) person = Anonymize_MaskName(persons[i], _mask); else if (_anonymizerMode == AnonymizerMode.Age) person = Anonymize_AgeRange(persons[i], _rangeSize); else throw new NotSupportedException("The requested anonymization mode is not supported.");
Stattdessen:
var person = Anonymize(persons[i]);
Ein unserer Erweiterungspunkte ist fertig. Es bleibt jedoch noch einer, GetAnonymizerDescription
, deren Behandlung ebenfalls fallspezifisch ist. Die Umwandlung davon ist sehr ähnlich zu den vorherigen Schritten:
-
Kopieren wir die Methode
GetAnonymizerDescription
aus der KlasseAnonymizerBase
in die KlasseNameMaskingAnonymizer
, fügen wir das Schlüsselwortoverride
in die Signatur ein und lassen wir im Funktionskörper nur die Logik, die fürNameMaskingAnonymizer
gilt:protected override string GetAnonymizerDescription() { return $"NameMasking anonymizer with mask {_mask}"; }
-
Kopieren wir die Methode
GetAnonymizerDescription
aus der KlasseAnonymizerBase
auch in die KlasseAgeAnonymizer
, fügen wir das Schlüsselwortoverride
in die Signatur ein und lassen wir im Funktionskörper nur die Logik, die fürAgeAnonymizer
gilt:protected override string GetAnonymizerDescription() { return $"Age anonymizer with range size {_rangeSize}"; }
-
Die Frage ist, was wir mit der Methode
GetAnonymizerDescription
inAnonymizerBase
machen. Wir machen sie nicht abstrakt, sondern zu einer virtuellen Methode, da wir hier ein sinnvolles Standardverhalten bereitstellen können: Wir geben einfach den Namen der Klasse zurück (der z.B. für die KlasseNameMaskingAnonymizer
"NameMaskingAnonymizer" wäre). Auf diese Weise befreien wir uns von der starrenswitch
-Struktur:protected virtual string GetAnonymizerDescription() { return GetType().Name; }
Reflexion
Mit der aus der
object
-Klasse geerbten MethodeGetType()
erhalten wir einType
-Objekt, das Informationen über die Klasse enthält. Dies gehört zum Thema Reflexion, über das wir am Ende des Semesters in einer Vorlesung ausführlicher lernen werden.
Es bleibt nur noch eine Sache: In der Main
-Methode der Program.cs
versuchen wir nun, die Basisklasse AnonymizerBase
zu instanziieren (aufgrund der vorherigen Umbenennung). Stattdessen sollten wir eine der beiden abgeleiteten Klassen verwenden. Zum Beispiel:
NameMaskingAnonymizer anonymizer = new("us-500.csv", "***");
anonymizer.Run();
Wir sind fertig! Versuchen wir nun, die Erweiterungspunkte besser zu verstehen, um sicherzustellen, dass sie wirklich funktionieren (aber falls wir im Labor wenig Zeit haben, ist das nicht unbedingt wichtig; etwas Ähnliches haben wir bereits in früheren Semestern in C++/Java durchgeführt):
- Stellen wir sicher, dass das Projekt TemplateMethod-0-Begin das Startprojekt in Visual Studio ist, falls wir das noch nicht eingestellt haben.
- Setzen wir einen Haltepunkt in der
AnonymizerBase
-Klasse auf die Zeilevar person = Anonymize(persons[i]);
. - Wenn der Debugger während der Ausführung hier anhält, drücken wir
F11
, um in die Methode hineinzugehen. - Wir werden feststellen, dass die Methode der abgeleiteten Klasse
AgeAnonymizer
aufgerufen wird.
Werfen wir einen Blick auf das Klassendiagramm der Lösung:
Unsere bisherige Lösung ist im 3-TemplateMethod/TemplateMethod-1
Projekt zu finden, falls wir sie brauchen.
Warum heißt das Muster Template Method? *
Das Muster trägt den Namen "Template Method", weil - unter Verwendung unserer Anwendung als Beispiel - die Methoden Run
und PrintSummary
"Schablonenmethoden" sind, die eine schablonenartige Logik oder Struktur definieren, in der bestimmte Schritte nicht festgelegt sind. Diese Code-Teile werden an abstrakte/virtuelle Methoden delegiert, und die abgeleiteten Klassen bestimmen deren Implementierung.
Bewertung der Lösung¶
Überprüfen wir, ob die Lösung unsere Ziele erfüllt:
- Die
AnonymizerBase
-Klasse wurde wiederverwendbarer. - Wenn in Zukunft eine neue Anonymisierungslogik erforderlich ist, müssen wir nur davon ableiten. Dies ist keine Modifikation, sondern eine Erweiterung.
- Entsprechend wird das OPEN/CLOSED-Prinzip eingehalten, das heißt, wir können die Logik an den beiden Punkten im Basisklassen-Code anpassen und erweitern, ohne den Code der Basisklasse zu ändern.
Soll jede Methode unserer Klasse erweiterbar sein?
Beachten wir, dass wir nicht jede Methode der AnonymizerBase
-Klasse virtuell gemacht haben, um die Klasse an jeder Stelle erweiterbar zu machen. Wir haben dies nur dort getan, wo wir glauben, dass es in Zukunft erforderlich sein könnte, die Logik zu erweitern.
5. Lösung (3-TemplateMethod/TemplateMethod-2-Progress)¶
Nehmen wir an, dass es eine neue - relativ einfache - Anforderung gibt:
-
Beim
NameMaskingAnonymizer
bleibt die bisher einfache Fortschrittsanzeige bestehen (wir geben nach jeder Zeile an, wie weit wir sind), -
Beim
AgeAnonymizer
muss die Fortschrittsanzeige jedoch anders aussehen: Es soll nach jeder Zeile angezeigt werden, wie viel Prozent der Verarbeitung abgeschlossen sind.
Die Lösung ist sehr einfach: Wir wenden das Template Method-Muster in der Run
-Methode weiter an und führen auch für die Fortschrittsanzeige einen Erweiterungspunkt ein, indem wir die Implementierung in eine virtuelle Methode auslagern.
Springen wir direkt zur fertigen Lösung (3-TemplateMethod/TemplateMethod-2-Progress Projekt):
- In der
AnonymizerBase
-Klasse neue virtuelle FunktionPrintProgress
(gibt standardmäßig nichts aus) - Aufruf dieser Funktion in
Run
- Entsprechende Implementierung in
NameMaskingAnonymizer
undAgeAnonymizer
(override)
Dies hat zunächst keine wesentlichen Erkenntnisse, aber im nächsten Schritt wird es welche geben.
6. Lösung (3-TemplateMethod/TemplateMethod-3-ProgressMultiple)¶
Ein neuer - und völlig logischer - Bedarf ist aufgetaucht: In Zukunft soll jeder Anonymisierungsalgorithmus mit jeder Art der Fortschrittsanzeige verwendet werden können. Dies bedeutet derzeit vier Kreuzkombinationen:
Anonymisierer | Fortschritt |
---|---|
Namensanonymisierer | Einfache Fortschritte |
Namensanonymisierer | Prozentualer Fortschritt |
Altersanonymisierer | Einfache Fortschritte |
Altersanonymisierer | Prozentualer Fortschritt |
Springen wir zur fertigen Lösung (3-TemplateMethod/TemplateMethod-3-ProgressMultiple Projekt). Statt des Codes öffnen wir das Main.cd
Klassendiagramm im Projekt und betrachten die Lösung anhand dieses Diagramms (oder wir können das Diagramm unten in der Anleitung ansehen).
Es ist spürbar, dass etwas "nicht stimmt", da für jede Kreuzkombination eine separate abgeleitete Klasse erstellt werden musste. Um den Code-Duplikationen zu verringern, gibt es sogar zusätzliche, Zwischenklassen in der Hierarchie. Außerdem:
- Wenn wir in Zukunft einen neuen Anonymisierungsalgorithmus einführen, müssen wir so viele neue Klassen schreiben (mindestens), wie viele Fortschrittstypen wir unterstützen.
- Wenn wir in Zukunft einen neuen Fortschrittstyp einführen, müssen wir so viele neue Klassen schreiben (mindestens), wie viele Anonymisierungstypen wir unterstützen.
Was hat das Problem verursacht? Dass das Verhalten unserer Klassen entlang mehrerer Dimensionen/Aspekte (in unserem Beispiel Anonymisierung und Fortschritt) erweiterbar gemacht werden muss, und diese in vielen Kreuzkombinationen unterstützt werden müssen. Wenn wir weitere Aspekte hinzufügen müssten (z.B. Art des Lesens oder Generierung der Ausgabe), würde das Problem exponentiell "explodieren". In solchen Fällen ist das Template-Method-Designmuster nicht anwendbar.
7. Lösung (4-Strategy/Strategy-1)¶
In diesem Schritt werden wir das Strategy-Entwurfsmuster anwenden, um unsere ursprüngliche Lösung an den erforderlichen Stellen erweiterbar zu machen. Im Muster wird die Trennung der "unveränderlichen/wiederverwendbaren" und "veränderbaren" Teile wie folgt umgesetzt:
- Die "gemeinsamen/unveränderlichen" Teile werden in eine bestimmte Klasse eingefügt (aber es wird keine "Basisklasse" sein).
- Im Gegensatz zum Template Method-Muster verwenden wir hier keine Vererbung, sondern Komposition (Enthaltensein): Das Verhalten in den Erweiterungspunkten wird auf andere Objekte übertragen, die als Schnittstellen enthalten sind (und nicht auf abstrakte/virtuelle Funktionen).
- Dies wird für jeden Aspekt des Verhaltens der Klasse durchgeführt, den wir ersetzbar/erweiterbar machen wollen, unabhängig voneinander. Wie wir sehen werden, kann so die kombinatorische Explosion, die im vorherigen Kapitel auftrat, vermieden werden.
Das ist in der Praxis viel einfacher, als es in der Theorie erscheint (wir haben es auch schon in früheren Studien verwendet). Um das zu verstehen, betrachten wir unser Beispiel.
Im Folgenden betrachten wir das Klassendiagramm, das die Strategy-basierte Lösung veranschaulicht (auf die Erklärung nach dem Diagramm basierend).
Klassendiagramm der Strategie-basierte Lösung
Das folgende UML-Klassendiagramm veranschaulicht die strategie-basierte Lösung, mit Fokus auf das Wesentliche:
Der erste Schritt bei der Anwendung des Strategy-Musters ist die Bestimmung, wie viele verschiedene Aspekte des Verhaltens der Klasse wir erweiterbar machen möchten. In unserem Beispiel gibt es vorerst - zumindest - zwei:
- Verhalten im Zusammenhang mit der Anonymisierung, das zwei Operationen umfasst:
- Anonymisierungslogik
- Bestimmung der Beschreibung der Anonymisierungslogik (Erzeugung des Beschreibungstextes)
- Fortschrittsbehandlung, die eine Operation umfasst:
- Fortschrittsanzeige
Der schwierigste Teil ist damit erledigt, ab jetzt kann man grundsätzlich mechanisch arbeiten, indem man dem Strategy-Muster folgt:
- Für jeden der oben genannten Aspekte muss ein eigenes Strategy-Interface eingeführt werden, mit den oben definierten Operationen, und für jedes müssen die entsprechende Implementierungen erstellt werden.
- In der
Anonymizer
-Klasse muss für jedes Strategy-Interface eine Mitgliedsvariable eingeführt werden, und in den Erweiterungspunkten wird über diese Mitgliedsvariablen die aktuell eingestellte Strategy-Implementierung verwendet.
Diese Elemente erscheinen auch im obigen Klassendiagramm. Jetzt wechseln wir zum Code. Unsere Ausgangsumgebung befindet sich im "4-Strategy"-Ordner im "Strategy-0-Begin"-Projekt, in dem wir weiterarbeiten werden. Dies ist dieselbe Lösung, die das Enum verwendet, wie die, die wir auch als Ausgangspunkt für das Template Method-Muster verwendet haben.
Anonymisierungsstrategie¶
Wir beginnen mit der Verwaltung der Anonymisierungsstrategie/-aspekts. Führen wir die zugehörige Schnittstelle ein:
- Erstellen wir im Projekt einen Ordner namens
AnonymizerAlgorithms
(Rechtsklick auf das "Strategy-0-Begin"-Projekt, dann Add/New Folder Menü). In den nächsten Schritten fügen wir jede Schnittstelle und Klasse in eine separate Datei mit dem entsprechenden Namen im gewohnten Format ein! -
Fügen wir in diesem Ordner eine Schnittstelle
IAnonymizerAlgorithm
mit folgendem Code hinzu:IAnonymizerAlgorithm.cspublic interface IAnonymizerAlgorithm { Person Anonymize(Person person); string GetAnonymizerDescription() => GetType().Name; }
Es ist auch bemerkenswert, dass wir in modernen C#-Versionen bei Bedarf den Methoden in Schnittstellen eine Standardimplementierung geben können, wie es bei der Methode
GetAnonymizerDescription
der Fall ist!
Jetzt erstellen wir die Implementierung für die Anonymisierung von Namen (also eine Strategy-Implementierung).
- Fügen wir eine
NameMaskingAnonymizerAlgorithm
Klasse in denselben Ordner hinzu. - Verschieben wir die zugehörige
_mask
Mitgliedsvariable aus derAnonymizer
-Klasse in dieNameMaskingAnonymizerAlgorithm
Klasse. -
Fügen wir folgenden Konstruktor in die
NameMaskingAnonymizerAlgorithm
Klasse ein:public NameMaskingAnonymizerAlgorithm(string mask) { _mask = mask; }
-
Implementieren wir die
IAnonymizerAlgorithm
Schnittstelle. Nachdem wir den Schnittstellennamen nach dem Klassennamen als: IAnonymizerAlgorithm
hinzugefügt haben, ist es sinnvoll, mit Visual Studio das Grundgerüst für die Methoden zu erzeugen: Platzieren wir den Cursor auf den Schnittstellennamen (klicken wir im Quellcode darauf), verwenden wir die Tastenkombination 'ctrl' + '.', und wählen wir im Menü "Implement interface". Hinweis: Da es für dieGetAnonymizerDescription
Methode bereits eine Standardimplementierung in der Schnittstelle gibt, wird nur dieAnonymize
Methode generiert. Das ist momentan in Ordnung. -
Übernehmen wir den Code der
Anonymize_MaskName
Methode aus derAnonymizer
-Klasse in dieAnonymize
Methode derNameMaskingAnonymizerAlgorithm
. Der Methodenkörper muss nur so geändert werden, dass nicht mehr der nicht mehr existierendemask
Parameter, sondern die_mask
Membervariable verwendet wird. Löschen wir dann dieAnonymize
Methode in derAnonymizer
Klasse. -
Jetzt wenden wir uns der Implementierung der
GetAnonymizerDescription
Methode im Strategy Interface zu. Kopieren wir dieGetAnonymizerDescription
Methode aus derAnonymizer
Klasse in dieNameMaskingAnonymizerAlgorithm
Klasse und lassen wir nur die Logik für den Namensanonymisierer übrig, indem wir die Methode öffentlich machen:public string GetAnonymizerDescription() { return $"NameMasking anonymizer with mask {_mask}"; }
-
Mit dieser Implementierung haben wir die Strategy für die Namensanonymisierung abgeschlossen. Der vollständige Code sieht nun wie folgt aus:
NameMaskingAnonymizerAlgorithm.cspublic class NameMaskingAnonymizerAlgorithm: IAnonymizerAlgorithm { private readonly string _mask; public NameMaskingAnonymizerAlgorithm(string mask) { _mask = mask; } public Person Anonymize(Person person) { return new Person(_mask, _mask, person.CompanyName, person.Address, person.City, person.State, person.Age, person.Weight, person.Decease); } public string GetAnonymizerDescription() { return $"NameMasking anonymizer with mask {_mask}"; } }
Im nächsten Schritt erstellen wir die Implementierung des IAnonymizerAlgorithm
Strategy-Interfaces für die Anonymisierung von Alter.
- Erstellen wir eine
AgeAnonymizerAlgorithm
-Klasse im gleichen Ordner (AnonymizerAlgorithms). - Verschieben wir die zugehörige
_rangeSize
-Membervariable aus derAnonymizer
-Klasse in dieAgeAnonymizerAlgorithm
-Klasse. -
Fügen wir den folgenden Konstruktor in die
AgeAnonymizerAlgorithm
-Klasse ein:public AgeAnonymizerAlgorithm(int rangeSize) { _rangeSize = rangeSize; }
-
Implementieren wir die
IAnonymizerAlgorithm
-Schnittstelle. Nachdem wir den Schnittstellennamen: IAnonymizerAlgorithm
nach dem Klassennamen hinzugefügt haben, ist es ratsam, das Skelett derAnonymize
-Methode mithilfe von Visual Studio wie zuvor zu generieren. - Übertragen wir den Code der
Anonymize_AgeRange
-Methode aus derAnonymizer
-Klasse in dieAgeAnonymizerAlgorithm
.Anonymize
-Methode. Der Code muss nur so angepasst werden, dass anstelle des nicht mehr existierendenrangeSize
-Parameters nun die_rangeSize
-Membervariable verwendet wird. Löschen wir dann dieAnonymize_AgeRange
-Methode in derAnonymizer
-Klasse. -
Jetzt gehen wir weiter mit der Implementierung der
GetAnonymizerDescription
-Methode des Strategy-Interfaces. Kopieren wir dieGetAnonymizerDescription
-Methode aus derAnonymizer
-Klasse in dieAgeAnonymizerAlgorithm
-Klasse und lassen wir im Methodenkörper nur die Logik für die Altersanonymisierung, und machen wir die Methode öffentlich:public string GetAnonymizerDescription() { return $"Age anonymizer with range size {_rangeSize}"; }
-
Damit ist die Implementierung der Strategie für die Altersanonymisierung abgeschlossen, der gesamte Code lautet wie folgt
AgeAnonymizerAlgorithm.cspublic class AgeAnonymizerAlgorithm: IAnonymizerAlgorithm { private readonly int _rangeSize; public AgeAnonymizerAlgorithm(int rangeSize) { _rangeSize = rangeSize; } public Person Anonymize(Person person) { // This is whole number integer arithmetics, e.g for 55 / 20 we get 2 int rangeIndex = int.Parse(person.Age) / _rangeSize; string newAge = $"{rangeIndex * _rangeSize}..{(rangeIndex + 1) * _rangeSize}"; return new Person(person.FirstName, person.LastName, person.CompanyName, person.Address, person.City, person.State, newAge, person.Weight, person.Decease); } public string GetAnonymizerDescription() { return $"Age anonymizer with range size {_rangeSize}"; } }
Beachten wir unbedingt, dass die Schnittstelle und ihre Implementierungen ausschließlich mit der Anonymisierung zu tun haben, ohne jegliche andere Logik (z. B. Fortschrittsanzeige)!
Fortschrittsstrategie¶
Im nächsten Schritt führen wir die Schnittstelle und die Implementierungen für die Fortschrittsanzeige ein:
- Erstellen wir im Projekt einen Ordner namens
Progresses
. In den folgenden Schritten fügen wir jede Schnittstelle und jede Klasse in eine separate, benannte Quelldatei gemäß den üblichen Konventionen ein. -
Fügen wir in diesem Ordner eine
IProgress
-Schnittstelle mit folgendem Code hinzu:Lösung
IProgress.cspublic interface IProgress { void Report(int count, int index); }
-
Fügen wir eine Implementierung dieser Schnittstelle für den einfachen Fortschritt in denselben Ordner ein. Die Implementierung wurde aus der
PrintProgress
-Methode unsererAnonymizer
-Klasse abgeleitet:Lösung
SimpleProgress.cspublic class SimpleProgress: IProgress { public void Report(int count, int index) { Console.WriteLine($"{index + 1}. person processed"); } }
-
Fügen wir eine Implementierung dieser Schnittstelle für den Prozentsatz-Fortschritt in denselben Ordner ein. Wir werden uns nicht mit der Interpretation des Codes befassen. Diese Lösung existiert in unserer
Anonymizer
-Klasse nicht, da wir sie nur in unserer Template Method-basierten Lösung eingeführt haben (die wir dort nicht im Detail betrachtet haben, aber sie ist praktisch identisch in ihrer Logik):Lösung
PercentProgress.cspublic class PercentProgress: IProgress { public void Report(int count, int index) { int percentage = (int)((double)(index+1) / count * 100); Console.Write($"\rProcessing: {percentage} %"); if (index == count - 1) Console.WriteLine(); } }
Beachten wir unbedingt, dass die Schnittstelle und ihre Implementierungen ausschließlich mit der Fortschrittsanzeige zu tun haben, ohne jegliche andere Logik (z. B. Anonymisierung)!
Anwendung der Strategien¶
Der nächste wichtige Schritt ist es, die Basisklasse des Anonymisierers mithilfe der oben eingeführten Strategien wiederverwendbar und erweiterbar zu machen. Im Anonymizer.cs
-Datei:
-
Löschen wir das Folgende:
AnonymizerMode
-Enum-Typ_anonymizerMode
-Feld (sowie die Felder_mask
und_rangeSize
, falls diese vorher noch vorhanden sind)
-
Führen wir jeweils ein Strategie-Interface-Typ-Feld ein:
private readonly IProgress _progress; private readonly IAnonymizerAlgorithm _anonymizerAlgorithm;
-
Fügen wir am Anfang der Datei die entsprechenden
using
-Anweisungen hinzu:using Lab_Extensibility.AnonymizerAlgorithms; using Lab_Extensibility.Progresses;
-
Die in dem vorherigen Punkt eingeführten Felder
_progress
und_anonymizerAlgorithm
haben anfangs den Wertnull
. Im Konstruktor setzen wir diese Referenzen auf die für unsere Anforderungen passende Implementierung. Zum Beispiel:public Anonymizer(string inputFileName, string mask) : this(inputFileName) { _progress = new PercentProgress(); _anonymizerAlgorithm = new NameMaskingAnonymizerAlgorithm(mask); } public Anonymizer(string inputFileName, int rangeSize) : this(inputFileName) { _progress = new PercentProgress(); _anonymizerAlgorithm = new AgeAnonymizerAlgorithm(rangeSize); }
Im Anonymizer
-Klasse übergeben wir die aktuell eingebettete, aber anonymisierungsabhängige Logik an die von der _anonymizerAlgorithm
-Membervariable referenzierte Strategy-Implementierung:
-
In der
Run
-Methode der Klasse delegieren wir dieAnonymize
-Aufrufe, die sich imif
/else
-Ausdruck befinden, nun an das_anonymizerAlgorithm
-Objekt:Person person; if (_anonymizerMode == AnonymizerMode.Name) person = Anonymize_MaskName(persons[i], _mask); else if (_anonymizerMode == AnonymizerMode.Age) person = Anonymize_AgeRange(persons[i], _rangeSize); else throw new NotSupportedException("The requested anonymization mode is not supported.");
Stattdessen:
Person person = _anonymizerAlgorithm.Anonymize(persons[i]);
-
Falls wir dies noch nicht getan haben, löschen wir die
Anonymize_MaskName
- undAnonymize_AgeRange
-Methoden, da deren Code jetzt in die Strategy-Implementierungen verschoben wurde und vom Rest der Klasse getrennt ist. -
Unsere
PrintSummary
-Methode ruft die unflexible, aufswitch
basierendeGetAnonymizerDescription
-Methode auf. DieseGetAnonymizerDescription
-Methode ersetzen wir, delegieren sie an das_anonymizerAlgorithm
-Objekt. In derPrintSummary
-Methode (nur das Wesentliche hervorgehoben):... GetAnonymizerDescription() ...
Stattdessen:
... _anonymizerAlgorithm.GetAnonymizerDescription() ...
Ein paar Zeilen weiter unten löschen wir die
GetAnonymizerDescription
-Methode aus der Klasse (ihr Code wurde in die entsprechenden Strategy-Implementierungen verschoben).
Der letzte Schritt ist der Austausch der im Anonymizer
-Klasse eingebetteten Fortschrittsverwaltung:
-
Auch hier delegieren wir die Anfrage an das zuvor eingeführte
_progress
-Objekt. In derRun
-Methode muss eine Zeile ausgetauscht werden:PrintProgress(i);
Stattdessen:
_progress.Report(persons.Count, i);
-
Löschen wir die
PrintProgress
-Methode, da ihr Code nun in eine passende Strategy-Implementierung verschoben wurde und von der Klasse getrennt ist.
Wir sind fertig, die fertige Lösung befindet sich im "4-Strategy/Strategy-1"-Projekt (falls wir irgendwo stecken geblieben sind oder der Code nicht kompiliert, können wir ihn hier mit der Lösung vergleichen).
Bewertung der Lösung¶
Mit der Einführung des Strategy-Musters sind wir fertig. In seiner jetzigen Form wird es jedoch so gut wie nie verwendet. Überprüfen wir unsere Lösung: Ist sie tatsächlich wiederverwendbar, und ist es möglich, den Anonymisierungsalgorithmus sowie die Fortschrittsbehandlung zu ändern, ohne die Anonymizer
-Klasse zu modifizieren? Dafür müssen wir überprüfen, ob es im Code irgendwo Implementierungsabhängigkeiten gibt.
Leider finden wir solche Stellen. Im Konstruktor ist festgelegt, welche Implementierung des Algorithmus und der Fortschrittsbehandlung erstellt wird. Dies müssen wir unbedingt im Code beachten! Wenn wir den Algorithmus oder den Fortschrittsmodus ändern möchten, müssen wir den Typ hinter dem new
-Operator in diesen Zeilen ändern, was eine Modifikation der Klasse mit sich bringt.
Viele – völlig zu Recht – betrachten dies in dieser Form nicht als eine echte Strategie-basierte Lösung. Die vollständige Lösung werden wir im nächsten Schritt umsetzen.
8. Lösung (4-Strategy/Strategy-2-DI)¶
Dependency Injection (DI)
Die Lösung besteht in der Anwendung von Dependency Injection (kurz DI). Das Prinzip dabei ist, dass die Klasse ihre Verhaltensabhängigkeiten (diese sind die Strategy-Implementierungen) nicht selbst instanziiert, sondern diese von außen übergeben bekommt, z. B. als Konstruktorparameter oder sogar als Properties oder Setter-Methoden. Natürlich unter der Verwendung von Schnittstellentypen!
Passen wir die Anonymizer
-Klasse entsprechend an, sodass wir die Strategy-Implementierungen nicht selbst instanziieren, sondern diese über Konstruktorparameter erhalten:
- Löschen wir alle drei Konstruktoren.
-
Fügen wir den folgenden Konstruktor hinzu:
public Anonymizer(string inputFileName, IAnonymizerAlgorithm anonymizerAlgorithm, IProgress progress = null) { ArgumentException.ThrowIfNullOrEmpty(inputFileName); ArgumentNullException.ThrowIfNull(anonymizerAlgorithm); _inputFileName = inputFileName; _anonymizerAlgorithm = anonymizerAlgorithm; _progress = progress; }
Wie zu sehen ist, ist die Angabe des
progress
-Parameters nicht zwingend erforderlich, da der Benutzer der Klasse möglicherweise keine Fortschrittsinformationen benötigt. -
Da die _progress-Strategie auch null sein kann, müssen wir eine Nullprüfung während der Verwendung einführen. Anstelle des "."-Operators verwenden wir den "?."-Operator:
_progress?.Report(persons.Count,i);
-
Jetzt sind wir fertig, die
Anonymizer
-Klasse ist vollständig von den Strategy-Implementierungen entkoppelt. Wir haben nun die Möglichkeit, dieAnonymizer
-Klasse mit jeder beliebigen Kombination von Anonymisierungsalgorithmen und Fortschrittsbehandlungen zu verwenden (ohne die Klasse selbst zu ändern). Erstellen wir dreiAnonymizer
-Instanzen mit verschiedenen Kombinationen imMain
-Methode derProgram.cs
-Datei (löschen wir den bestehenden Code zuvor aus derMain
-Methode):Anonymizer p1 = new("us-500.csv", new NameMaskingAnonymizerAlgorithm("***"), new SimpleProgress()); p1.Run(); Console.WriteLine("--------------------"); Anonymizer p2 = new("us-500.csv", new NameMaskingAnonymizerAlgorithm("***"), new PercentProgress()); p2.Run(); Console.WriteLine("--------------------"); Anonymizer p3 = new("us-500.csv", new AgeAnonymizerAlgorithm(20), new SimpleProgress()); p3.Run();
-
Um sicherzustellen, dass der Code funktioniert, fügen wir am Anfang der Datei die erforderlichen
using
-Anweisungen ein:using Lab_Extensibility.AnonymizerAlgorithms; using Lab_Extensibility.Progresses;
Wir sind fertig, die Lösung ist im Projekt "4-Strategy/Strategy-2-DI" zu finden (falls wir irgendwo stecken bleiben oder der Code nicht kompiliert, können wir dies mit der Lösung vergleichen).
Überprüfung der Funktionsweise
Während der Übung wird wahrscheinlich keine Zeit für diese Überprüfung bleiben, aber wer sich unsicher ist, "warum das Strategy-Muster funktioniert" und warum sich das Verhalten in den oben genannten vier Fällen unterscheidet, sollte Breakpoints in der Program.cs
-Datei an den vier Run
-Funktionsaufrufen setzen und durch die Funktionen im Debugger eintreten, um zu überprüfen, dass immer die richtige Strategy-Implementierung aufgerufen wird.
Im Projekt befindet sich ein Klassendiagramm (Main.cd
), das die fertige Lösung ebenfalls veranschaulicht:
Klassendiagramm der Strategy-basierten Lösung
Das folgende UML-Klassendiagramm veranschaulicht unsere Strategy-basierte Lösung:
Bewertung der Lösung¶
Überprüfen wir, ob die Lösung unsere Ziele erreicht:
- Der
Anonymizer
ist zu einer wiederverwendba(re)ren Klasse geworden. - Wenn in der Zukunft eine neue Anonymisierungslogik benötigt wird, muss nur eine neue
IAnonymizerAlgorithm
-Implementierung eingeführt werden. Dies ist keine Änderung, sondern eine Erweiterung. - Wenn in der Zukunft eine neue Fortschrittslogik benötigt wird, muss nur eine neue
IProgress
-Implementierung eingeführt werden. Dies ist keine Änderung, sondern eine Erweiterung. - Die beiden obigen Punkte erfüllen das OPEN/CLOSED-Prinzip, d. h. wir können die Logik des
Anonymizer
anpassen und erweitern, ohne den Code der Klasse zu ändern. - Hier müssen wir nicht die kombinatorische Explosion für die Template Method befürchten: Jede
IAnonymizerAlgorithm
-Implementierung kann bequem mit jederIProgress
-Implementierung verwendet werden, ohne dass neue Klassen für die Kombinationen eingeführt werden müssen (dies haben wir in derProgram.cs
-Datei gesehen).
Weitere Vorteile von Strategy im Vergleich zur Template Method *
- Es kann auch ein Verhalten zur Laufzeit ersetzt werden. Wenn es notwendig wäre, dass wir nach der Erstellung eines bestimmten
Anonymizer
-Objekts das Anonymisierungs- oder Progress-Verhalten ändern können, dann könnten wir das leicht tun (wir müssten nur eineSetAnonimizerAlgorithm
- bzw.SetProgress
-Methode einführen, in der das erhaltene Implementierung auf das von der Klasse verwendete Strategy gesetzt wird). - Unterstützung der Unit-Tests (dies betrachten wir im Labor nicht).