Aufgaben - Developer Week

Transcription

Aufgaben - Developer Week
#1
Stefan Lieser, Tilman Börner
Dojos
für Entwickler
15 Aufgaben und Lösungen in .NET
EINLEITUNG
Wer übt, gewinnt
E
in Profimusiker übt täglich mehrere
Stunden. Er übt Fingerfertigkeit,
Phrasierung, Ansatz beziehungsweise Haltung, Intonation und VomBlatt-Spielen. Als Hilfsmittel verwendet er Tonleitern, Etüden, Ausschnitte von Stücken und
Unbekanntes. Ohne Üben könnte er die Qualität
seines Spiels nicht halten, geschweige denn verbessern. Üben gehört für ihn dazu.
Wie sieht das bei Ihnen und der Programmiererei aus? Sie sind doch auch Profi. Nicht in der
Musik, aber doch beim Codieren an der Computertastatur. Üben Sie auch? Gemeint ist nicht die
Aufführung, sprich das Program-mieren, mit dem
Sie sich Ihr Einkommen verdienen. Gemeint sind
die Etüden, das Üben von Fingerfertigkeit, Intonation, Ansatz und Vom-Blatt-Spielen.
Wie sehen diese Aufgaben denn bei einem
Programmierer aus? Freilich ließe sich die Analogie bis zum Abwinken auslegen. Hier mag ein
kleiner Ausschnitt genügen: Sie könnten als Etüde zum Beispiel trainieren, dass Sie immer erst
den Test schreiben und dann die Implementation der Methode, die den Test erfüllt. Damit verwenden Sie künftig nicht immer wieder den falschen Fingersatz, sondern immer gleich die richtige Reihenfolge: Test – Implementation.
Klar, Üben ist zeitraubend und manchmal
nervtötend – vor allem für die, die zuhören.
Aber Üben kann auch Spaß machen. Kniffeln,
eine Aufgabe lösen und dann die eigene Lösung
mit einer anderen Lösung vergleichen. Das ist der
Grundgedanke beim dotnetpro.dojo. In jeder
Ausgabe stellt dotnetpro eine Aufgabe, die in maximal drei Stunden zu lösen sein sollte. Sie investieren einmal pro Monat wenige Stunden und ge-
www.dotnetpro.de dotnetpro.dojos.2011
winnen dabei jede Menge Wissen und Erfahrung.
Den Begriff Dojo hat die dotnetpro nicht erfunden. Dojo nennen die Anhänger fernöstlicher
Kampfsportarten ihren Übungsraum. Aber auch
in der Programmierung hat sich der Begriff eines
Code Dojo für eine Übung eingebürgert.
Das können Sie gewinnen
Der Gewinn lässt sich in ein Wort fassen: Lernen.
Das ist Sinn und Zweck eines Dojo. Sie können/
dürfen/sollen lernen. Einen materiellen Preis loben wir nicht aus.
Ein dot-netpro.dojo ist kein Contest. Dafür gilt
aber :
❚ Falsche Lösungen gibt es nicht. Es gibt möglicherweise elegantere, kürzere oder schnellere,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht haben. Das können Sie, indem Sie Ihre
Lösung mit der vergleichen, die Sie eine Ausgabe später in der dotnetpro finden.
Wer stellt die Aufgabe? Wer liefert die
Lösung?
Die kurze Antwort lautet: Stefan Lieser. Die lange
Antwort lautet: Stefan Lieser, seines Zeichens
Mitinitiator der Clean Code Deve-loper Initiative. Stefan ist freiberuflicher Trainer und Berater
und Fan von intelligenten Entwicklungsmethoden, die für Qualität der resultierenden Software
sorgen. Er denkt sich die Aufgaben aus und gibt
dann auch seine Lösung zum Besten. Er wird
auch mitteilen, wie lange er gebraucht und wie
viele Tests er geschrieben hat. Das dient – wie
oben schon gesagt – nur als Anhaltspunkt. Falsche Lösungen gibt es nicht.
Der Spruch „Übung
macht den Meister“
ist abgedroschen,
weil oft bemüht,
weil einfach richtig.
Deshalb finden
Sie in diesem
Sonderheft 15
dotnetpro.dojos, also
Übungsaufgaben
inklusive einer
Musterlösung und
Grundlagen.
3
INHALT
15 Aufgaben und Lösungen
5
Aufgabe 1 : Vier gewinnt
Ein Spielfeld, zwei Spieler und jede Menge Spaß beim
Programmieren : Das kleine Brettspiel ist genau das Richtige
zum Warmwerden.
9 Aufgabe 2 : Data Binding
Knüpfe Kontrollelement an Eigenschaft, und schon wirkt
der Zauber: Veränderungen der Eigenschaft spiegeln sich im
Control wider und auch andersherum.
66 Aufgabe 12 : Twitter
Es treten auf: mehrere Threads, eine Synchronisation, ein
Timer, ein Control – wahlweise in WPF-, Windows-Forms- oder
Silverlight-Qualität – und ein API. Fertig ist das Twitter-Band.
71 Aufgabe 13 : Graphen
Entwerfen Sie ein API für den Umgang mit gerichteten
Graphen, implementieren Sie die Datenstruktur und einen
beliebigen Algorithmus dazu, wie etwa topologische
Sortierung. Und los.
14 Aufgabe 3 : Testdatengenerator
Meier, Müller, Schulze – ganze 250 000 Mal: Für einen
Testdatengenerator ist das eine Sache von Sekunden. Aber
wie baut man einen solchen?
22 Aufgabe 4 : Mogeln mit EVA
Statt Rein-Raus-Kaninchentechnik die Eingabe,
Verarbeitung, Ausgabe: modernste Technik im Dienst des
Mogelns beim Minesweeper-Spiel. Na super.
26 Aufgabe 5 : Boxplot
Packen Sie den Sandsack wieder weg: nicht Box, platt,
sondern Boxplot: Diese spezielle Grafikform zeigt kleinsten
und größten Wert, Mittelwert und die Quartile.
31 Aufgabe 6 : RavenDB
Computer aus, Daten weg? Von wegen: Eine Persistenzschicht sorgt für deren Überleben. Mit RavenDB braucht
man dafür auch keinen SQL-Server.
38 Aufgabe 7 : Stack und Queue
Wie bitte? Stack und Queue bietet doch das .NET
Framework. Stimmt. Aber die Selbstimplementierung bringt
viel Selbsterkenntnis. Sie werden es sehen.
44 Aufgabe 8 : Windows-Dienst
Er arbeitet im Verborgenen, im Untergrund. Ist aber so
wichtig, dass auf ihn nicht verzichtet werden kann. Bauen
Sie doch mal einen.
50 Aufgabe 9 : Event-Based Components
Was, bitte schön, hat Silbentrennung mit EBC zu tun?
Erst einmal gar nichts. Es sei denn, die Aufgabe lautet:
Baue Silbentrennservice mit EBCs.
56 Aufgabe 10 : ITree<T>
Ich bau ’nen Baum für dich. Aus Wurzel, Zweig und Blatt
und den Interfaces ITree<T> und INode<T>. Und Sie dürfen
ihn erklettern.
61 Aufgabe 11 : LINQ
Frage: Wie heißt die bekannteste Abfragesprache? Richtig:
SQL. Aber in dieser Aufgabe geht es um eine andere:
Language Integrated Query.
4
77 Aufgabe 14 : ToDo, MVVM und Datenfluss
Am Ende haben Sie eine nützliche ToDo-Listen-Anwendung.
Am Anfang haben Sie ein Problem: Wie modellieren Sie die
Softwarearchitektur? Aber nur Mut: Auch das klappt.
87 Aufgabe 15 : ToDo und die Cloud
Die ToDo-Listen-Anwendung soll jetzt noch richtig cool
werden: durch eine Synchronisation über die Cloud. Ein
bisschen Hirnschmalz ...
Grundlagen
82 MVVM und EBC
Model View ViewModel und Event-Based Components: Das
sind zwei aktuelle Technologien, die sich aber gut miteinander
kombinieren lassen. Stefan Lieser zeigt, wie das geht.
95 Klassische Katas
Sie heißen Kata Potter, Kata BankOCR oder Kata FizzBuzz:
An klassischen Programmieraufgaben gibt es inzwischen
schon ganze Kataloge. Tilman Börner stellt die wichtigsten vor.
Impressum
94 Impressum
dotnetpro.dojos.2011 www.dotnetpro.de
AUFGABE
„Stefan, vielleicht sollten wir erst einmal mit etwas Einfacherem
anfangen. Vielleicht wäre ein kleines Spiel zum Warmwerden genau
K
lar, können wir machen. Wie
wäre es beispielsweise mit dem
Spiel 4 gewinnt? Bei dieser Aufgabe geht es vor allem um eine
geeignete Architektur und die Implementierung der Logik und nicht so sehr um eine
schicke Benutzeroberfläche.
4 gewinnt wird mit einem aufrecht stehenden Spielfeld von sieben Spalten gespielt. In
jede Spalte können von oben maximal sechs
Spielsteine geworfen werden. Ein Spielstein
fällt nach unten, bis er entweder auf den Boden trifft, wenn es der erste Stein in der Spalte ist, oder auf den schon in der Spalte liegenden Steinen zu liegen kommt. Die beiden Spieler legen ihre gelben beziehungsweise roten Spielsteine abwechselnd in das
Spielfeld. Gewonnen hat der Spieler, der zuerst vier Steine direkt übereinander, nebeneinander oder diagonal im Spielfeld platzieren konnte.
Implementieren Sie ein Spiel …
Ein Spiel, das zwei Spieler gegeneinander
spielen. Die Implementierung soll die Spielregeln überwachen. So soll angezeigt werden,
welcher Spieler am Zug ist (Rot oder Gelb).
Ferner soll angezeigt werden, ob ein Spieler
gewonnen hat. Diese Auswertung erfolgt
nach jedem Zug, sodass nach jedem Zug angezeigt wird, entweder welcher Spieler an
der Reihe ist oder wer gewonnen hat. Hat ein
Spieler gewonnen, ist das Spiel zu Ende und
kann neu gestartet werden.
Damit es unter den Spielern keinen Streit
gibt, werden die Steine, die zum Gewinn führten, ermittelt. Bei einer grafischen Benutzeroberfläche könnten die vier Steine dazu farblich markiert oder eingerahmt werden. Bei
einer Konsolenoberfläche können die Koordinaten der Steine ausgegeben werden.
Die Bedienung der Anwendung erfolgt so,
dass der Spieler, der am Zug ist, die Spalte angibt, in die er einen Stein werfen will. Dazu
sind die Spalten von eins bis sieben nummeriert. Bei einer grafischen Benutzeroberfläche können die Spalten je durch einen Button gewählt werden. Wird das Spiel als Konsolenanwendung implementiert, genügt die
Eingabe der jeweiligen Spaltennummer per
Tastatur.
www.dotnetpro.de dotnetpro.dojos.2011
Die Abbildungen 1 und 2 zeigen, wie eine
Oberfläche aussehen könnte. Ist die Spalte,
in die der Spieler seinen Stein legen möchte,
bereits ganz mit Steinen gefüllt, erfolgt eine
Fehlermeldung, und der Spieler muss erneut einen Spielstein platzieren.
Programmieraufgabe
Die Programmieraufgabe lautet, ein Spiel
4 gewinnt zu implementieren. Dabei liegt der
Schwerpunkt auf dem Entwurf einer angemessenen Architektur, der Implementierung
der Spiellogik und zugehörigen automatisierten Tests.
Die Benutzerschnittstelle des Spiels steht
eher im Hintergrund. Ob animierte WPFOberfläche, WinForms, ASP.NET oder Konsolenanwendung, das ist nicht wichtig. Im Vordergrund soll eine Lösung stehen, die leicht
in eine beliebige Oberflächentechnologie integriert werden kann. Evolvierbarkeit und
Korrektheit sollen hier also stärker bewertet
werden als eine superschicke Oberfläche.
Im nächsten Heft zeigen wir eine exemplarische Musterlösung. „Die“ Lösung kann es in
einem solchen Fall bekanntlich eh nicht geben. Damit möchte ich Sie, lieber Leser, noch
mal ermutigen, sich der Aufgabe anzunehmen. Investieren Sie etwas Zeit, und erarbeiten Sie eine eigene Lösung. Die können Sie
dann später mit der hier vorgestellten vergleichen. Viel Spaß !
Wer übt, gewinnt
das Richtige. Fällt dir dazu eine Aufgabe ein?“
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie eine
Ausgabe später in der dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten …
[Abb. 1 und 2]
Eine mögliche
Oberfläche
(links) und die
Anzeige der
siegreichen vier
Steine (rechts).
Aber auf die
Oberfläche
kommt es bei
dieser Übung
nicht an.
5
LÖSUNG
Eine Übung, bei der Sie nur gewinnen konnten
Vier gewinnt. Eine Lösung.
O
Die Aufgabe war, das Spiel „Vier gewinnt“ zu implementieren. Auf den ersten Blick ist das eine eher leichte Übung.
Erst bei genauerem Hinsehen erkennt man die Schwierigkeiten. Wie zerlegt man beispielsweise die Aufgabenstellung,
um überschaubare Codeeinheiten zu erhalten?
L
eser, die sich der Aufgabe angenommen haben, ein Vier-gewinnt-Spiel zu implementieren
[1], werden es gemerkt haben:
Der Teufel steckt im Detail. Der Umgang mit
dem Spielfeld, das Erkennen von Vierergruppen, wo soll man nur anfangen? Wer zu
früh gezuckt hat und sofort mit der Codeeingabe begonnen hat, wird es vielleicht
gemerkt haben: Die Aufgabe läuft aus dem
Ruder, wächst einem über den Kopf.
Das ging mir nicht anders. Früher. Heute setze ich mich erst mit einem Blatt Papier hin, bevor ich beginne, Code zu
schreiben. Denn die erste Herausforderung besteht nicht darin, das Problem zu
lösen, sondern es zu verstehen.
Beim Vier-gewinnt-Spiel war eine Anforderung bewusst ausgeklammert: die Benutzerschnittstelle. In der Aufgabe geht es
um die Logik des Spiels. Am Ende soll demnach eine Assembly entstehen, in der die
Spiellogik enthalten ist. Diese kann dann in
einer beliebigen Benutzerschnittstelle verwendet werden.
Beim Spiel selbst hilft es, sich die Regeln
vor Augen zu führen. Zwei Spieler legen abwechselnd gelbe und rote Spielsteine in ein
7 x 6 Felder großes Spielfeld. Derjenige, der
als Erster vier Steine seiner Farbe nebeneinander liegen hat, hat das Spiel gewonnen.
Hier hilft es, sich mögliche Vierergruppen
aufzumalen, um zu erkennen, welche Konstellationen im Spielfeld auftreten können.
Nachdem ich das Problem durchdrungen habe, zeichnet sich eine algorithmische Lösung ab. Erst jetzt beginne ich, die
gesamte Aufgabenstellung in Funktionseinheiten zu zerlegen. Ich lasse zu diesem
Zeitpunkt ganz bewusst offen, ob eine
Funktionseinheit am Ende eine Methode,
Klasse oder Komponente ist. Wichtig ist
erst einmal, dass jede Funktionseinheit eine klar definierte Aufgabe hat.
Hat sie mehr als eine Aufgabe, zerlege ich
sie in mehrere Funktionseinheiten. Stellt
man sich die Funktionseinheiten als Baum
vor, in dem die Abhängigkeiten die ver-
6
schiedenen Einheiten verbinden, dann
steht auf oberster Ebene das gesamte Spiel.
Es zerfällt in weitere Funktionseinheiten,
die eine Ebene tiefer angesiedelt sind. Diese können wiederum zerlegt werden. Bei
der Zerlegung können zwei unterschiedliche Fälle betrachtet werden:
❚ vertikale Zerlegung,
❚ horizontale Zerlegung.
Der Wurzelknoten des Baums ist das
gesamte Spiel. Diese Funktionseinheit ist jedoch zu komplex, um sie „in einem Rutsch“
zu implementieren. Also wird sie zerlegt.
Durch die Zerlegung entsteht eine weitere
Ebene im Baum. Dieses Vorgehen bezeichne ich daher als vertikale Zerlegung.
Kümmert sich eine Funktionseinheit um
mehr als eine Sache, wird sie horizontal
zerlegt. Wäre es beispielsweise möglich, einen Spielzustand in eine Datei zu speichern, könnte das Speichern im ersten
Schritt in der Funktionseinheit Spiellogik
angesiedelt sein. Dann stellt man jedoch
fest, dass diese Funktionseinheit für mehr
als eine Verantwortlichkeit zuständig wäre,
und zieht das Speichern heraus in eine eigene Funktionseinheit. Dies bezeichne ich
als horizontale Zerlegung.
Erst wenn die Funktionseinheiten hinreichend klein sind, kann ich mir Gedanken
darum machen, wie ich sie implementiere.
Im Falle des Vier-gewinnt-Spiels zerfällt
das Problem in die eigentliche Spiellogik
und die Benutzerschnittstelle. Die Benutzerschnittstelle muss in diesem Fall nicht
weiter zerlegt werden. Das mag in komplexen Anwendungen auch mal anders sein.
Diese erste Zerlegung der Gesamtaufgabe
zeigt Abbildung 1.
Die Spiellogik ist mir als Problem noch zu
groß, daher zerlege ich diese Funktionseinheit weiter. Dies ist eine vertikale Zerlegung, es entsteht eine weitere Ebene im
Baum. Die Spiellogik zerfällt in die Spielregeln und den aktuellen Zustand des Spiels.
Die Zerlegung ist in Abbildung 2 dargestellt.
Die Spielregeln sagen zum Beispiel aus, wer
das Spiel beginnt, wer den nächsten Zug
machen darf et cetera.
Der Zustand des Spiels wird beim echten
Spiel durch das Spielfeld abgebildet. Darin
liegen die schon gespielten Steine. Aus
dem Spielfeld geht jedoch nicht hervor,
wer als Nächster am Zug ist. Für die Einhaltung der Spielregeln sind beim echten Spiel
die beiden Spieler verantwortlich, in meiner Implementierung ist es die Funktionseinheit Spielregeln.
Ein weiterer Aspekt des Spielzustands ist
die Frage, ob bereits vier Steine den Regeln
entsprechend zusammen liegen, sodass
ein Spieler gewonnen hat. Ferner birgt der
Spielzustand das Problem, wohin der
nächste gelegte Stein fällt. Dabei bestimmt
der Spieler die Spalte und der Zustand des
Spielbretts die Zeile: Liegen bereits Steine
in der Spalte, wird der neue Spielstein zuoberst auf die schon vorhandenen gelegt.
Damit unterteilt sich die Problematik
des Spielzustands in die drei Teilaspekte
❚ Steine legen,
❚ nachhalten, wo bereits Steine liegen,
❚ erkennen, ob vier Steine zusammen liegen.
Vom Problem zur Lösung
Nun wollen Sie sicher so langsam auch mal
Code sehen. Doch vorher muss noch geklärt werden, was aus den einzelnen Funktionseinheiten werden soll. Werden sie jeweils eine Klasse? Eher nicht, denn dann
wären Spiellogik und Benutzerschnittstelle
nicht ausreichend getrennt. Somit werden
Benutzerschnittstelle und Spiellogik mindestens eigenständige Komponenten. Die
Funktionseinheiten innerhalb der Spiellogik hängen sehr eng zusammen. Alle leisten einen Beitrag zur Logik. Ferner scheint
mir die Spiellogik auch nicht komplex
genug, um sie weiter aufzuteilen. Es bleibt
also bei den beiden Komponenten Benutzerschnittstelle und Spiellogik.
Um beide zu einem lauffähigen Programm
zusammenzusetzen, brauchen wir noch ein
weiteres Projekt. Seine Aufgabe ist es, eine
EXE-Datei zu erstellen, in der die beiden
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Komponenten zusammengeführt werden.
So entstehen am Ende drei Komponenten.
Abbildung 3 zeigt die Solution für die
Spiellogik. Sie enthält zwei Projekte: eines für
die Tests, ein weiteres für die Implementierung.
Die Funktionseinheit Spielzustand zerfällt
in drei Teile. Beginnen wir mit dem Legen
von Steinen. Beim Legen eines Steins in das
Spielfeld wird die Spalte angegeben, in die
der Stein gelegt werden soll. Dabei sind drei
Fälle zu unterscheiden: Die Spalte ist leer,
enthält schon Steine oder ist bereits voll.
Es ist naheliegend, das Spielfeld als zweidimensionales Array zu modellieren. Jede
Zelle des Arrays gibt an, ob dort ein gelber,
ein roter oder gar kein Stein liegt. Der erste
Index des Arrays bezeichnet dabei die Spalte, der zweite die Zeile. Beim Platzieren eines Steins muss also der höchste Zeilenindex innerhalb der Spalte ermittelt werden.
Ist dabei das Maximum noch nicht erreicht, kann der Stein platziert werden.
Bleibt noch eine Frage: Wie ist damit
umzugehen, wenn ein Spieler versucht, einen Stein in eine bereits gefüllte Spalte zu
legen? Eine Möglichkeit wäre: Sie stellen
eine Methode bereit, die vor dem Platzieren eines Steins aufgerufen werden kann,
um zu ermitteln, ob dies in der betreffenden Spalte möglich ist. Der Code sähe
dann ungefähr so aus :
if(spiel.KannPlatzieren(3)) {
spiel.LegeSteinInSpalte(3);
}
Dabei gibt der Parameter den Index der
Spalte an, in die der Stein platziert werden
soll. Das Problem mit diesem Code ist, dass
er gegen das Prinzip „Tell don’t ask“ verstößt. Als Verwender der Funktionseinheit,
die das Spielbrett realisiert, bin ich gezwungen, das API korrekt zu bedienen. Bevor ein Spielstein mit LegeSteinInSpalte() in
das Spielbrett gelegt wird, müsste mit
KannPlatzieren() geprüft werden, ob dies
überhaupt möglich ist. Nach dem „Tell
don’t ask“-Prinzip sollte man Klassen so erstellen, dass man den Objekten der Klasse
mitteilt, was zu tun ist – statt vorher nachfragen zu müssen, ob man eine bestimmte
Methode aufrufen darf. Im Übrigen bleibt
bei der Methode LegeSteinInSpalte() das
Problem bestehen: Was soll passieren,
wenn die Spalte bereits voll ist?
Eine andere Variante könnte sein, die
Methode LegeSteinInSpalte() mit einem
Rückgabewert auszustatten. War das Platzieren erfolgreich, wird true geliefert, ist die
Spalte bereits voll, wird false geliefert. In
www.dotnetpro.de dotnetpro.dojos.2011
[Abb. 1] Die Aufgabe in Teile zerlegen: erster Schritt ...
[Abb. 2] ... und zweiter Schritt.
[Abb. 3] Aufbau der Solution.
dem Fall müsste sich der Verwender der
Methode mit dem Rückgabewert befassen.
Am Ende soll der Versuch, einen Stein in eine bereits gefüllte Spalte zu platzieren, dem
Benutzer gemeldet werden. Also müsste der
Rückgabewert bis in die Benutzerschnittstelle transportiert werden, um dort beispielsweise eine Messagebox anzuzeigen.
Die Idee, die Methode mit einem Rückgabewert auszustatten, verstößt jedoch
ebenfalls gegen ein Prinzip, nämlich die
„Command/Query Separation“. Dieses
Prinzip besagt, dass eine Methode entweder ein Command oder eine Query sein
sollte, aber nicht beides. Dabei ist ein Command eine Methode, die den Zustand des
Objekts verändert. Für die Methode LegeSteinInSpalte() trifft dies zu: Der Zustand
des Spielbretts ändert sich dadurch. Eine
Query ist dagegen eine Methode, die eine
Abfrage über den Zustand des Objekts enthält und dabei den Zustand nicht verändert. Würde die Methode LegeSteinInSpalte() einen Rückgabewert haben, wäre sie
dadurch gleichzeitig eine Query.
Nach diesen Überlegungen bleibt nur
eine Variante übrig: Die Methode LegeSteinInSpalte() sollte eine Ausnahme auslösen,
wenn das Platzieren nicht möglich ist. Die
Ausnahme kann in der Benutzerschnittstelle abgefangen und dort in einer entsprechenden Meldung angezeigt werden. Damit
entfällt die Notwendigkeit, einen Rückgabewert aus der Spiellogik bis in die Benutzerschnittstelle zu transportieren. Ferner sind
die Prinzipien „Tell don’t ask“ und „Command/Query Separation“ eingehalten.
Vier Steine finden
Nun sind mit dem zweidimensionalen Array und der Methode LegeSteinInSpalte()
bereits zwei Teilprobleme des Spielzu-
stands gelöst: Im zweidimensionalen Array
ist der Zustand des Spielbretts hinterlegt,
und die Methode LegeSteinInSpalte() realisiert die Platzierungslogik. Das dritte Problem ist die Erkennung von Vierergruppen,
also eines Gewinners.
Vier zusammenhängende Steine können
beim Vier-gewinnt-Spiel in vier Varianten
auftreten : horizontal, vertikal, diagonal
nach oben, diagonal nach unten.
Diese vier Varianten gilt es zu implementieren. Dabei ist wichtig zu beachten, dass
die vier Steine unmittelbar zusammen liegen müssen, es darf sich also kein gegnerischer Stein dazwischen befinden.
Ich habe zuerst versucht, diese Vierergruppenerkennung direkt auf dem zweidimensionalen Array zu lösen. Dabei habe
ich festgestellt, dass das Problem in zwei
Teilprobleme zerlegt werden kann :
❚ Ermitteln der Indizes benachbarter Felder.
❚ Prüfung, ob vier benachbarte Felder mit
Steinen gleicher Farbe besetzt sind.
Für das Ermitteln der Indizes habe ich
daher jeweils eigene Klassen implementiert, welche die Logik der benachbarten Indizes enthalten. Eine solche Vierergruppe
wird mit einem Startindex instanziert und
liefert dann die Indizes der vier benachbarten Felder. Diese Vierergruppen werden anschließend verwendet, um im Spielfeld zu
ermitteln, ob die betreffenden Felder alle
7
LÖSUNG
Listing 1
Vierergruppe ermitteln.
internal struct HorizontalerVierer : IVierer
{
private readonly int x;
private readonly int y;
public HorizontalerVierer(int x, int y) {
this.x = x;
this.y = y;
}
public Koordinate Eins {
get { return new Koordinate(x, y); }
}
public Koordinate Zwei {
get { return new Koordinate(x + 1, y); }
}
public Koordinate Drei {
get { return new Koordinate(x + 2, y); }
}
public Koordinate Vier {
get { return new Koordinate(x + 3, y); }
}
public override string ToString() {
return string.Format("Horizontal X: {0},
Y: {1}", x, y);
}
}
Steine derselben Farbe enthalten. Die betreffenden Klassen heißen HorizontalerVierer, VertikalerVierer, DiagonalHochVierer
und DiagonalRunterVierer. Listing 1 zeigt
exemplarisch die Klasse HorizontalerVierer.
Zunächst fällt auf, dass die Klasse internal ist. Sie wird im Rahmen der Spiellogik
nur intern benötigt, daher soll sie nicht außerhalb der Komponente sichtbar sein.
Damit Unit-Tests für die Klasse möglich
sind, habe ich auf der Assembly das Attribut InternalsVisibleTo gesetzt. Dadurch
kann die Assembly, welche die Tests enthält, auf die internen Details zugreifen.
Aufgabe der Klasse HorizontalerVierer ist
es, vier Koordinaten zu horizontal nebeneinander liegenden Spielfeldern zu liefern.
Dies erfolgt in den Properties Eins, Zwei,
Drei und Vier. Dort werden jeweils die Indizes ermittelt.
Das Ermitteln eines Gewinners geschieht
anschließend in einem Flow aus zwei
Schritten. Im ersten Schritt wird aus einem
Spielfeld die Liste der möglichen Vierergruppen bestimmt. Im zweiten Schritt wird
aus dem Spielfeld und den möglichen Vierergruppen ermittelt, ob eine der Vierergruppen Steine derselben Farbe enthält.
Die beiden Schritte des Flows sind als
Extension Methods realisiert. Dadurch
sind sie leicht isoliert zu testen. Anschließend können sie hintereinander ausge-
8
führt, also als Flow zusammengeschaltet
werden :
var gewinnerVierer = spielfeld
.AlleVierer()
.SelbeFarbe(spielfeld);
Der Flow wird an zwei Stellen verwendet: zum einen beim Ermitteln des Gewinners, zum anderen, um zu bestimmen,
welche Steine zum Sieg geführt haben. Da
die Methode AlleVierer() ein IEnumerable
liefert und SelbeFarbe() dies als ersten Parameter erwartet, können die beiden Extension Methods hintereinander geschrieben werden. Da das Spielfeld in beiden
Methoden benötigt wird, verfügt SelbeFarbe() über zwei Parameter.
Das Ermitteln von vier jeweils nebeneinander liegenden Feldern übernimmt
die Methode AlleVierer(). Ein kurzer Ausschnitt zeigt die Arbeitsweise :
internal static IEnumerable<IVierer>
AlleVierer(this int[,] feld) {
for (var x = 0; x <=
feld.GetLength(0) - 4; x++) {
for (var y = 0; y <
feld.GetLength(1); y++) {
yield return new
HorizontalerVierer(x, y);
}
}
// Ebenso für Vertikal und Diagonal
}
Auch diese Methode ist internal, da sie
außerhalb der Komponente nicht benötigt
wird. In zwei geschachtelten Schleifen werden die Anfangsindizes von horizontalen
Vierergruppen ermittelt. Für jeden Anfangsindex wird mit yield return eine Instanz eines HorizontalerVierers geliefert.
Dieser übernimmt das Ermitteln der drei
anderen Indizes.
Eine Alternative zur gezeigten Methode
wäre, die möglichen Vierer als Konstanten
zu hinterlegen. Es würde dann die Berechnung in AlleVierer() entfallen, ferner die
Klassen HorizontalerVierer et cetera. Ob die
Felder einer Vierergruppe alle mit Steinen
der gleichen Farbe besetzt sind, analysiert
die Methode SelbeFarbe(). Durch die Verwendung der Klassen HorizontalerVierer et
cetera ist dies einfach: Jeder Vierer liefert
seine vier Koordinaten. Damit muss nur
noch im Spielfeld nachgesehen werden, ob
sich an allen vier Koordinaten Steine gleicher Farbe befinden, siehe Listing 2.
Am Ende müssen die einzelnen Funktionseinheiten nur noch gemeinsam verwendet werden. Die dafür verantwortliche
Klasse heißt VierGewinntSpiel. Sie ist public und repräsentiert nach außen die
Komponente. Die Klasse ist für die Spielregeln zuständig. Da das abwechselnde
Ziehen so einfach ist, habe ich mich entschlossen, diese Logik nicht auszulagern.
In der Methode LegeSteinInSpalte(int spalte) wird der Zustand des Spiels aktualisiert.
Dies geht ansatzweise wie folgt :
if (Zustand == Zustaende.RotIstAmZug) {
spielbrett.SpieleStein(Spieler.Rot,
spalte);
Zustand = Zustaende.GelbIstAmZug;
}
Es wird also ein entsprechender Spielstein gelegt und anschließend ermittelt,
wer am Zug ist. Etwas später folgt dann die
Auswertung eines möglichen Gewinners:
if (spielbrett.Gewinner == Spieler.Rot){
Zustand = Zustaende.RotHatGewonnen;
}
Die Ermittlung eines Gewinners erfolgt
also im Spielbrett, während hier nur der
Zustand des Spiels verwaltet wird.
Fazit: Die richtigen Vorüberlegungen sind
der Schlüssel zu einer erfolgreichen Implementierung.
[ml]
[1] Stefan Lieser, Wer übt, gewinnt,
dotnetpro 3/2010, Seite 118 f.,
www.dotnetpro.de/A1003dojo
Listing 2
Prüfen auf gleiche Farben.
internal static IEnumerable<IVierer> SelbeFarbe(this IEnumerable<IVierer> vierer,
int[,] feld) {
foreach (var vier in vierer) {
if ((feld[vier.Eins.X, vier.Eins.Y] != 0) &&
(feld[vier.Eins.X, vier.Eins.Y] == feld[vier.Zwei.X, vier.Zwei.Y]) &&
(feld[vier.Eins.X, vier.Eins.Y] == feld[vier.Drei.X, vier.Drei.Y]) &&
(feld[vier.Eins.X, vier.Eins.Y] == feld[vier.Vier.X, vier.Vier.Y])) {
yield return vier; } } }
dotnetpro.dojos.2011 www.dotnetpro.de
AUFGABE
INotifyPropertyChanged-Logik automatisiert testen
Zauberwort
D
ataBinding ist beliebt. Lästig
daran ist: Man muss die INotifyPropertyChanged-Schnittstelle implementieren. Sie fordert,
dass bei Änderungen an den Eigenschaften
eines Objekts das Ereignis PropertyChanged
ausgelöst wird. Dabei muss dem Ereignis der
Name der geänderten Eigenschaft als Parameter in Form einer Zeichenkette übergeben
werden. Die Frage, die uns diesmal beim
dotnetpro.dojo interessiert, ist: Wie kann
man die Implementierung der INotifyPropertyChanged-Schnittstelle automatisiert testen?
Die Funktionsweise des Events für eine einzelne Eigenschaft zu prüfen ist nicht schwer.
Man bindet einen Delegate an den PropertyChanged-Event und prüft, ob er bei Änderung
der Eigenschaft aufgerufen wird. Außerdem
ist zu prüfen, ob der übergebene Name der
Eigenschaft korrekt ist, siehe Listing 3.
Um zu prüfen, ob der Delegate aufgerufen
wurde, erhöhen Sie im Delegate beispielsweise eine Variable, die außerhalb definiert
ist. Durch diesen Seiteneffekt können Sie
überprüfen, ob der Event beim Ändern der
Eigenschaft ausgelöst und dadurch der Delegate aufgerufen wurde. Den Namen der Eigenschaft prüfen Sie innerhalb des Delegates mit einem Assert.
Solche Tests für jede Eigenschaft und jede
Klasse, die INotifyPropertyChanged implementiert, zu schreiben, wäre keine Lösung,
weil Sie dabei Code wiederholen würden. Da
die Eigenschaften einer Klasse per Reflection
ermittelt werden können, ist es nicht schwer,
den Testcode so zu verallgemeinern, dass
damit alle Eigenschaften einer Klasse getestet werden können. Also lautet in diesem
Monat die Aufgabe: Implementieren Sie eine
Klasse zum automatisierten Testen der INotifyPropertyChanged-Logik. Die zu implementierende Funktionalität ist ein Werkzeug
zum Testen von ViewModels. Dieses Werkzeug soll wie folgt bedient werden:
NotificationTester.Verify<MyViewModel>();
Die Klasse, die auf INotifyPropertyChangedSemantik geprüft werden soll, wird als generischer Typparameter an die Methode über-
www.dotnetpro.de dotnetpro.dojos.2011
geben. Die Prüfung soll so erfolgen, dass per
Reflection alle Eigenschaften der Klasse gesucht werden, die über einen Setter und Getter verfügen. Für diese Eigenschaften soll geprüft werden, ob sie bei einer Zuweisung an
die Eigenschaft den PropertyChanged-Event
auslösen und dabei den Namen der Eigenschaft korrekt übergeben. Wird der Event
nicht korrekt ausgelöst, muss eine Ausnahme
ausgelöst werden. Diese führt bei der Ausführung des Tests durch das Unit-Test-Framework zum Scheitern des Tests.
Damit man weiß, für welche Eigenschaft
die Logik nicht korrekt implementiert ist,
sollte die Ausnahme mit den notwendigen
Informationen ausgestattet werden, also
dem Namen der Klasse und der Eigenschaft,
für die der Test fehlschlug.
In einer weiteren Ausbaustufe könnte das
Werkzeug dann auch auf Klassen angewandt
werden, die ebenfalls per Reflection ermittelt
wurden. Fasst man beispielsweise sämtliche
ViewModels in einem bestimmten Namespace zusammen, kann eine Assembly nach
ViewModels durchsucht werden. Damit die
so gefundenen Klassen überprüft werden
können, muss es möglich sein, das Testwerkzeug auch mit einem Typ als Parameter aufzurufen :
Wer übt, gewinnt
DataBinding ist eine tolle Sache: Objekt an Formular binden und wie von Zauberhand stellen die Controls die
Eigenschaftswerte des Objekts dar. DataBinding ist aber auch knifflig. Stefan, kannst du dazu eine Aufgabe stellen?
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten …
NotificationTester.Verify
(typeof(MyViewModel));
Im nächsten Heft finden Sie eine Lösung
des Problems. Aber versuchen Sie sich zunächst selbst an der Aufgabe.
[ml]
Listing 3
Property changed?
[Test]
public void Name_Property_loest_PropertyChanged_Event_korrekt_aus() {
var kunde = new Kunde();
var count = 0;
kunde.PropertyChanged += (o, e) => {
count++;
Assert.That(e.PropertyName, Is.EqualTo("Name"));
};
kunde.Name = "Stefan"; Assert.That(count,Is.EqualTo(1));
}
9
LÖSUNG
INotifyPropertyChanged-Logik automatisiert testen
Kettenreaktion
Das automatisierte Testen der INotifyPropertyChanged-Logik ist nicht schwer. Man nehme einen Test, verallgemeinere
ihn, streue eine Prise Reflection darüber, fertig. Doch wie zerlegt man die Aufgabenstellung so in Funktionseinheiten,
dass diese jeweils genau eine definierte Verantwortlichkeit haben? Die Antwort: Suche den Flow!
W
ie man die INotifyPropertyChanged-Logik automatisiert testen kann, habe
ich in der Aufgabenstellung zu dieser Übung bereits gezeigt [1].
Doch wie verallgemeinert man nun diesen
Test so, dass er für alle Eigenschaften einer
Klasse automatisiert ausgeführt wird?
Im Kern basiert die Lösung auf folgender
Idee: Suche per Reflection alle Properties
einer Klasse und führe den Test für die gefundenen Properties aus. Klingt einfach, ist
es auch. Aber halt: Bitte greifen Sie nicht
sofort zur Konsole! Auch bei vermeintlich
unkomplizierten Aufgabenstellungen lohnt
es sich, das Problem so zu zerlegen, dass
kleine, überschaubare Funktionseinheiten
mit einer klar abgegrenzten Verantwortlichkeit entstehen.
Suche den Flow!
Ich möchte versuchen, die Aufgabenstellung mit einem Flow zu lösen. Doch dazu
sollte ich ein klein wenig ausholen und zunächst erläutern, was ein Flow ist und wo
seine Vorteile liegen.
Vereinfacht gesagt ist ein Flow eine Aneinanderreihung von Funktionen. Ein Argument geht in die erste Funktion hinein, diese berechnet damit etwas und liefert ein Ergebnis zurück. Dieses Ergebnis geht in die
nächste Funktion, auch diese berechnet damit wieder etwas und liefert ihr Ergebnis an
die nächste Funktion. Auf diesem Weg wird
ein Eingangswert nach und nach zu einem
Ergebnis transformiert, siehe Listing 1.
Die einzelnen Funktionen innerhalb
eines Flows, die sogenannten Flowstages,
Listing 1
Ein einfacher Flow.
var
var
var
var
10
input = "input";
x1 = A(input);
x2 = B(x1);
result = C(x2);
O
sind zustandslos, das heißt, sie erledigen
ihre Aufgabe ausschließlich mit den Daten
aus ihren Argumenten. Das hat den Vorteil,
dass mehrere Flows asynchron ausgeführt
werden können, ohne dass dabei die Zugriffe auf den Zustand synchronisiert werden müssten. Ferner lassen sich zustandslose Funktionen sehr schön automatisiert
testen, weil das Ergebnis eben nur von den
Eingangsparametern abhängt.
Einer nach dem anderen
Ein Detail ist bei der Realisierung von
Flows ganz wichtig: Weitergereicht werden
sollten nach Möglichkeit jeweils Daten
vom Typ IEnumerable<T>. Dadurch besteht nämlich die Möglichkeit, auf diesen
Daten mit LINQ zu operieren. Ferner können die einzelnen Flowstages dann beliebig
große Datenmengen verarbeiten, da bei
Verwendung von IEnumerable<T> nicht alle Daten vollständig im Speicher existieren
müssen, sondern Element für Element bereitgestellt werden können. Im Idealfall
fließt also zwischen den einzelnen Flowstages immer nur ein einzelnes Element.
Es wird nicht etwa das gesamte Ergebnis
der ersten Stage berechnet und dann vollständig weitergeleitet.
Im Beispiel von Listing 2 führt die Verwendung von yield return dazu, dass der
Compiler einen Enumerator erzeugt. Dieser Enumerator liefert nicht sofort die gesamte Aufzählung, sondern stellt auf Anfrage Wert für Wert bereit. Bei Ausführung der
Methode Flow() werden also zunächst nur
die einzelnen Aufzählungen und Funktionen miteinander verbunden. Erst wenn das
erste Element aus dem Ergebnis entnommen werden soll, beginnen die Enumeratoren, Werte zu liefern. Der Flow kommt also
erst dann in Gang, wenn jemand hinten das
erste Element „herauszieht“.
Als erste ist die Funktion C an der Reihe.
Sie entnimmt aus der ihr übergebenen Aufzählung x2 das erste Element. Dadurch
kommt B ins Spiel und entnimmt ihrerseits
der Aufzählung x1 den ersten Wert. Dies
setzt sich fort, bis die Methode Input den
ersten Wert liefern muss. Im Flow werden
die einzelnen Werte sozusagen von hinten
durch den Flow gezogen. Ein Flow bietet in
Verbindung mit IEnumerable<T> und yield
return die Möglichkeit, unendlich große
Datenmengen zu verarbeiten, ohne dass
eine einzelne Flowstage die Daten komplett im Speicher halten muss.
Lesbarkeit durch Extension
Methods
Verwendet man bei der Implementierung
der Flowstages Extension Methods, kann
man die einzelnen Stages syntaktisch hintereinanderschreiben, sodass der Flow im
Code deutlich in Erscheinung tritt. Dazu
muss lediglich der erste Parameter der
Funktion um das Schlüsselwort this ergänzt
werden, siehe Listing 3. Natürlich müssen
die Parameter und Return-Typen der Flowstages zueinander passen.
Lösungsansatz
Der erste Schritt des INotifyPropertyChanged-Testers besteht darin, die zu testenden Properties des Typs zu ermitteln.
Anschließend muss er jedem dieser Properties einen Wert zuweisen, um zu prüfen,
ob der Event korrekt ausgelöst wird. Zum
Zuweisen eines Wertes benötigen Sie zur
Laufzeit einen Wert vom Typ der Property.
Wenn Sie auf eine string-Property stoßen,
müssen Sie einen string-Wert instanzieren,
das ist einfach.
Komplizierter wird die Sache, wenn der
Typ der Property ein komplexer Typ ist.
Denken Sie etwa an eine Liste von Points
oder Ähnliches. Richtig knifflig wird es,
wenn der Typ der Property ein Interfacetyp
ist. Dann ist eine unmittelbare Instanzierung nicht möglich. Das Instanzieren der
Werte scheint eine eigenständige Funktionseinheit zu sein, denn die Aufgabe ist
recht umfangreich.
Wenn Sie die Properties und ihren jeweiligen Typ gefunden haben, müssen Sie für
jede Property einen Test ausführen. Jeder
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 2
Rückgabedaten vom Typ IEnumerable nutzen.
public void Flow() {
var input = Input();
var x1 = A(input);
var x2 = B(x1);
var result = C(x2);
foreach(var value in result) {
...
}
}
public IEnumerable<string> Input() {
yield return "Äpfel";
yield return "Birnen";
yield return "Pflaumen";
}
public IEnumerable<string> A(IEnumerable<string> input) {
foreach (var value in input) {
yield return string.Format("({0})", value);
}
}
public IEnumerable<string> B(IEnumerable<string> input) {
foreach (var value in input) {
yield return string.Format("[{0}]", value);
}
}
public IEnumerable<string> C(IEnumerable<string> input) {
foreach (var value in input) {
yield return string.Format("-{0}-", value);
}
}
Listing 3
Die Stages syntaktisch koppeln.
public static IEnumerable<string> A(this IEnumerable<string> input) {
foreach (var value in input) {
yield return string.Format("({0})", value);
}
}
...
var result = Input().A().B().C();
dieser Tests ist eine Action<object>, die auf
einer Instanz der Klasse ausgeführt wird,
die zu testen ist. Wenn also die Klasse KundeViewModel überprüft werden soll, wird
für jede Property eine Action<KundeViewModel> erzeugt. Sind die Actions erzeugt,
müssen sie nur nacheinander ausgeführt
werden. Dabei soll jede Action eine neue
Instanz der zu testenden Klasse erhalten.
Andernfalls könnte es zu Seiteneffekten
beim Testen der Properties kommen.
www.dotnetpro.de dotnetpro.dojos.2011
Funktionseinheiten identifizieren
Die erste Aufgabe ist also das Ermitteln der
zu testenden Properties. Eingangsparameter in diese Funktionseinheit ist der Typ,
für den die INotifyPropertyChanged-Implementierung überprüft werden soll. Das Ergebnis der Flowstage ist eine Aufzählung
der Property-Namen.
static IEnumerable<string>
FindPropertyNames(this Type type)
An dieser Stelle fragen Sie sich möglicherweise, warum ich die Property-Namen
als Strings zurückgebe und nicht etwa eine
Liste von PropertyInfo-Objekten. Schließlich stecken in PropertyInfo mehr Informationen, insbesondere der Typ der Property,
den ich später ebenfalls benötige. Ich habe
mich dagegen entschieden, weil dies das
Testen der nächsten Flowstage deutlich
erschwert hätte. Denn diese hätte dann auf
einer Liste von PropertyInfo-Objekten arbeiten müssen. Und da PropertyInfo-Instanzen
nicht einfach mit new hergestellt werden
können, wären die Tests recht mühsam geworden.
Nachdem die Property-Namen bekannt
sind, kann die nächste Flowstage dazu den
jeweiligen Typ ermitteln. Die Flowstage erhält also eine Liste von Property-Namen
sowie den Typ und liefert eine Aufzählung
von Typen.
static IEnumerable<Type> FindPropertyTypes(
this IEnumerable<string> propertyNames,
Type type)
Im Anschluss muss für jeden Typ ein Objekt instanziert werden. Diese Objekte werden später im Test den Properties zugewiesen. Die Flowstage erhält also eine Liste
von Typen und liefert für jeden dieser Typen eine Instanz des entsprechenden Typs.
static IEnumerable<object> GenerateValues(
this IEnumerable<Type> types)
Dann wird es spannend: Die Actions
müssen erzeugt werden. Dabei lässt es sich
leider nicht vermeiden, die Property-Namen aus der ersten Stage nochmals zu verwenden. Die Ergebnisse der ersten Stage
fließen also nicht nur in die unmittelbar
nächste Stage, sondern zusätzlich auch
noch in die Stage, welche die Actions erzeugt. Die Namen der Properties werden
benötigt, um mittels Reflection die jeweiligen Setter aufrufen zu können.
static IEnumerable<Action<object>>
GenerateTestMethods(this IEnumerable<object>
values, IEnumerable<string> propertyNames,
Type type)
Der letzte Schritt besteht darin, die gelieferten Actions auszuführen. Dazu muss jeweils eine Instanz der zu testenden Klasse erzeugt und an die Action übergeben werden.
Abbildung 2 zeigt den gesamten Flow.
Die einzelnen Flowstages sind als Extension
Method implementiert. Der Flow selbst
wird in der öffentlichen Methode NotificationTester.Verify zusammengesteckt. Testen
11
LÖSUNG
[Abb. 2] Die zu testenden
Listing 4
Properties ermitteln.
Eine einfache Testklasse.
public class ClassWithPublicGettersAndSetters
{
public string StringProperty { get; set;}
public int IntProperty { get; set;}
}
möchte ich die einzelnen Stages aber isoliert. Denn nur so kann ich die Implementierung Schritt für Schritt vorantreiben und
muss nicht gleich einen Integrationstest
für den gesamten Flow schreiben. Einige
Integrationstests sollten am Ende aber
auch nicht fehlen.
Diese Vorgehensweise hat einen weiteren Vorteil: Um den NotificationTester testen zu können, müssen Testdaten her. Da
er auf Typen arbeitet, müssen also Testdaten in Form von Klassen erstellt werden.
Das ist nicht nur aufwendig, sondern wird
auch schnell unübersichtlich. Ganz kommt
man zwar am Erstellen solcher Testklassen
auch nicht vorbei, aber der Aufwand ist
doch reduziert.
Interna testbar machen
Um die einzelnen Flowstages isoliert testen
zu können, habe ich ihre Sichtbarkeit auf
internal gesetzt. Damit sind die Methoden
zunächst nur innerhalb der Assembly, in
der sie implementiert sind, sichtbar. Um
auch in der Test-Assembly darauf zugreifen
zu können, muss diese zusätzliche Sichtbarkeit über das Attribut InternalsVisibleTo
hergestellt werden :
[assembly:InternalsVisibleTo(
"INotifyTester.Tests")]
Das Attribut kann prinzipiell in einer beliebigen Quellcodedatei in der Assembly
untergebracht werden. Üblicherweise werden Attribute, die sich auf die Assembly be-
12
ziehen, in der Datei AssemblyInfo.cs untergebracht. Diese finden Sie im Visual Studio
Solution Explorer innerhalb des Ordners
Properties.
Das Sichtbarmachen der internen Methoden nur zum Zwecke des Testens halte
ich auf diese Weise für vertretbar. UnitTests sind Whitebox-Tests, das heißt, die
Art und Weise der Implementierung ist
bekannt. Im Gegensatz dazu stehen Blackbox-Tests, die ganz bewusst keine Annahmen über den inneren Aufbau der zu
testenden Funktionseinheiten machen.
Durch Verwendung von internal ist die
Sichtbarkeit nur so weit erhöht, dass die
Methoden in Tests angesprochen werden
können. Eine vollständige Offenlegung mit
public wäre mir zu viel des Guten. Übrigens halte ich es für keine gute Idee, auf die
Interna einer zu testenden Klasse mittels
Reflection zuzugreifen. Dabei entziehen
sich nämlich die Interna, die über Reflection angesprochen werden, den Refaktorisierungswerkzeugen. Und wie man sieht,
ist internal in Verbindung mit dem InternalsVisibleTo-Attribut völlig ausreichend.
FindPropertyNames
Die Namen der Properties werden durch
die Flowstage FindPropertyNames geliefert. Dabei entscheidet diese Funktion bereits, welche Properties geprüft werden
sollen. Es werden nur Properties berücksichtigt, die über öffentliche Getter und
Setter verfügen.
Um diese Funktion testen zu können,
müssen Testklassen angelegt werden. Das
lässt sich leider nicht vermeiden, da die
Funktion auf einem Typ als Argument arbeitet. Bei der testgetriebenen Entwicklung
steht der Test vor der Implementierung,
also gilt es, Testdaten zu erstellen. Ich habe
mich zunächst um das „Happy Day Szenario“ gekümmert, also einen Testfall, der
später bei der Verwendung typisch ist, siehe Listing 4.
Als Nächstes folgt eine Klasse, deren Properties private sind. Diese sollen in den
Tests unberücksichtigt bleiben, ihr Name
darf also nicht geliefert werden. Die Implementierung der Funktion ist mit LINQ
ganz einfach, siehe Listing 5.
Die beiden Where-Klauseln sorgen dafür, dass nur Properties berücksichtigt werden, die sowohl einen Getter als auch einen Setter haben. Durch die Binding Flags
werden schon Properties ausgeschlossen,
die nicht public sind. Durch die SelectKlausel wird festgelegt, wie die zu liefernden Ergebnisse aufgebaut sein sollen.
FindPropertyTypes
Die Funktion FindPropertyTypes erhält als
Argumente die Liste der Property-Namen,
die berücksichtigt werden sollen, sowie
den Typ, zu dem die Properties gehören.
Dazu liefert sie jeweils den Typ der Properties. Auch diese Tests benötigen wieder
Testklassen. Ich habe einfach die schon
vorhandenen Testklassen verwendet. Auch
hier ist die Implementierung dank LINQ
nicht schwierig.
GenerateValues
Um die Property-Setter später aufrufen zu
können, muss jeweils ein Objekt vom Typ
der Property erzeugt werden. Diese Aufgabe
übernimmt die Funktion GenerateValues.
Sie erhält als Argument die Liste der Typen
und liefert dazu jeweils eine Instanz. Die
Funktion ist derzeit recht einfach gehalten.
Die Instanz wird einfach durch Verwendung
von Activator.CreateInstance erzeugt. Ledig-
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 5
Listing 7
Die zu prüfenden Properties finden.
Flowstages zusammenstecken.
internal static IEnumerable<string> FindPropertyNames(Type type) {
return type.GetProperties(PropertyBindingFlags)
.Where(propertyInfo => propertyInfo.CanRead)
.Where(propertyInfo => propertyInfo.CanWrite)
.Select(propertyInfo => propertyInfo.Name);
}
public static void Verify(Type type) {
var propertyNames = type
.FindPropertyNames();
propertyNames
.FindPropertyTypes(type)
.GenerateValues()
.GenerateTestMethods(
propertyNames, type)
.ExecuteTestMethods(type);
}
Listing 6
Passende Objekte erzeugen.
internal static IEnumerable<object> GenerateValues(this IEnumerable<Type> types) {
return types.Select(type => CreateInstance(type));
}
JetBrains ReSharper bezahlt. Der weist
nämlich mit der Warnung „Access to modified closure“ auf das Problem hin.
internal static object CreateInstance(Type type) {
if (type == typeof(string)) {
return "";
}
return Activator.CreateInstance(type);
}
ExecuteTestMethods
Der letzte Schritt im Flow ist die Ausführung der erzeugten Testmethoden. Diese
Methode ist erst durch eine Refaktorisierung entstanden, daher teste ich sie nicht
isoliert, sondern nur im Integrationstest.
Und jetzt alle!
lich Strings werden gesondert behandelt, da
die Klasse über keinen parameterlosen Konstruktor verfügt, siehe Listing 6.
Die Methode CreateInstance muss sicher
im Laufe der Zeit angepasst werden. Sie ist
in der gezeigten Implementierung nicht in
der Lage, mit komplexen Typen zurechtzukommen.
GenerateTestMethods
Nun stehen alle Informationen zur Verfügung, um für jede Property eine Testmethode zu erzeugen. Die Funktion GenerateTestMethods erhält drei Argumente :
❚ die Liste der Werte für die Zuweisung,
❚ die Liste der Property-Namen,
❚ den Typ, auf den sich die Tests beziehen.
Das Ergebnis ist eine Liste von Actions.
static IEnumerable<Action<object>>
GenerateTestMethods(this IEnumerable<object>
values, IEnumerable<string> propertyNames,
Type type)
Das Testen dieser Funktion kommt leider auch wieder nicht ohne Testklassen
aus, denn der Typ geht ja als Argument in
die Funktion ein. Die erzeugten Testmethoden werden im Test aufgerufen, um so
zu prüfen, dass sie jeweils einen bestimmten Aspekt der INotifyPropertyChangedSemantik überprüfen. Hier wird es schon
www.dotnetpro.de dotnetpro.dojos.2011
schwierig, die Vorgehensweise zu beschreiben, da es sich um Tests handelt, die testen,
dass generierte Testmethoden richtig testen, sozusagen Metatests.
Die Implementierung der Funktion hat es
ebenfalls in sich. Zunächst müssen zwei
Aufzählungen „im Gleichschritt“ durchlaufen werden. Dazu wird der Enumerator einer der beiden Aufzählungen ermittelt. Anschließend wird der andere Enumerator in
einer foreach-Schleife durchlaufen. Innerhalb der Schleife wird der erste Enumerator
dann „per Hand“ mit MoveNext und Current
bedient. Ich hätte dies gerne in eine Methode ausgelagert, das ist jedoch durch die Verwendung von yield return nicht möglich.
Damit sind wir bei der zweiten Besonderheit der Funktion. Die einzelnen Testmethoden werden jeweils mit yield return
zurückgeliefert. Da das Ergebnis der Funktion eine Aufzählung von Actions ist, liefert
das yield return jeweils eine Action in Form
einer Lambda Expression. Dabei müssen
die Werte, die aus den Enumeratoren in der
Schleife entnommen werden, in lokalen
Variablen abgelegt werden, damit sie als
Closure in die Lambda Expression eingehen können. Andernfalls würden am Ende
alle Lambda Expressions auf demselben
Wert arbeiten, nämlich dem aus dem letzten Schleifendurchlauf. Auch hier macht
sich übrigens wieder mal der Einsatz von
Nun müssen nur noch alle Flowstages zusammengesteckt werden. Das ist einfach,
da die Stages als Extension Methods implementiert sind. Dadurch können sie hintereinandergereiht werden, wie Listing 7 zeigt.
Der Flow wird lediglich dadurch etwas
unterbrochen, dass die Namen der Properties in zwei Flowstages benötigt werden.
Daher werden diese nach Ausführung der
ersten Stage in einer Variablen zwischengespeichert, die dann weiter unten wieder in
eine andere Stage einfließt.
Fazit
Die Realisierung dieses Testwerkzeugs ging
mir recht leicht von der Hand. Dabei hat
der Entwurf des Flows relativ viel Zeit in Anspruch genommen. Die anschließende Implementierung ging dafür rasch. Was mir an
der Lösung gut gefällt, ist die Tatsache, dass
Erweiterungen leicht vorzunehmen sind,
weil es klar abgegrenzte Verantwortlichkeiten gibt. Bedarf für Erweiterungen erwarte
ich vor allem beim Erzeugen der Testwerte,
also in der Funktion CreateInstance. Diese
ist bislang relativ einfach gehalten, kann
aber leicht erweitert werden.
[ml]
[1] Stefan Lieser, Zauberwort, INotifyPropertyChanged-Logik automatisiert testen,
dotnetpro 4/2010, S. 107,
www.dotnetpro.de/A1004dojo
13
AUFGABE
Testdaten automatisch generieren
Meier, Müller, Schulze …
Nach wie vor spielt die klassische „Forms over Data“-Anwendung eine große Rolle. Daten aus einer Datenbank sollen
per Formular bearbeitet werden. Wenn diese Applikationen getestet werden, spielen Testdaten eine zentrale Rolle.
Möglichst viele sollten es sein und möglichst realistisch geformt noch dazu. Stefan, fällt dir dazu eine Übung ein?
Wer übt, gewinnt
dnpCode: A1005dojo
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten …
I
mmer wieder begegnet man der Anforderung, Daten aus einer Datenbank in einem
Formular zu visualisieren. Oft sind die Datenmengen dabei so groß, dass man nicht
einfach alle Daten in einem Rutsch laden sollte.
Stattdessen müssen die Daten seitenweise abgerufen und visualisiert werden. Suchen und Filtern
kommen meistens hinzu, und schon stellt sich
die Frage, ob der gewählte Ansatz auch noch
funktioniert, wenn mehr als nur eine Handvoll
Testdaten in der Datenbank liegen.
Solche Tests auf Echtdaten Ihrer Kunden vorzunehmen wäre übrigens keine gute Idee. Diese unterliegen dem Datenschutz und sollten keinesfalls
zu Testzwecken verwendet werden. Und für eine
völlig neue Anwendung stehen natürlich noch gar
keine Echtdaten zurVerfügung. Folglich bleibt nur
die Möglichkeit, Testdaten zu generieren. Und genau darum geht es in dieser Übung: Erstellen Sie
eine Bibliothek zum Erzeugen von Testdaten.
Verschiedene Arten von Testdaten
Die generierten Testdaten sollen eine Tabellenstruktur haben. Für jede Spalte wird definiert,
von welchem Typ die Werte sind und wie sie erzeugt werden. Anschließend gibt man an, wie
viele Zeilen generiert werden sollen, und die
Testdaten werden generiert.
Die Anforderungen an die Daten können sehr
vielfältig sein. Um hier ausreichend flexibel zu sein,
sollen die Daten nach verschiedenen Strategien erzeugt werden können. Reine Zufallsdaten sind ein
erster Schritt, dürften aber in vielen Fällen nicht
ausreichen. Zumindest eine Beschränkung innerhalb vorgegebener Minimum- und Maximumwerte erscheint sinnvoll.
Eine weitere Strategie könnte darin bestehen, eine Liste
von möglichen Werten vorzugeben, aus denen dann zufällig ausgewählt wird. So könnten beispielsweise Straßennamen generiert werden, die in
[Abb. 1] So könnte das GUI für einen Testdatengenerator
den Formularen dann auch
aussehen.
wie Straßennamen aussehen
14
statt wie zufällig zusammengewürfelte Zeichenfolgen. Es müssen lediglich einige Straßennamen
vorgegeben werden. Das Gleiche bietet sich für
die Namen von Personen an. Auch hier kann gut
mit einer Liste von Namen gearbeitet werden,
aus der dann zufällig Werte ausgewählt werden.
Die Strategie für die Testdatenerzeugung soll
möglichst flexibel sein. Ein Entwickler sollte mit
wenig Aufwand einen eigenen Generator ergänzen können. Endergebnis der Datenerzeugung
soll eine Aufzählung von Zeilen sein :
IEnumerable<object[]>
Die generierten Zeilen können dann beliebig
verwendet werden. Sie können direkt in Tests
einfließen oder auch zuerst als Datei gespeichert
werden. Hier bietet sich beispielsweise die Speicherung als CSV-Datei an. Auch das Speichern in
einer Datenbank ist natürlich ein typisches Szenario. Das konkrete Speichern der Daten sollte
unabhängig sein vom Erzeugen. Es lohnt sich also wieder, sich vor der Implementierung ein paar
Gedanken zur Architektur zu machen.
Auch bei dieser Übung geht es wieder primär
um eine Bibliothek und weniger um eine Benutzerschnittstelle. Wer mag, kann sich aber auch
um eine Benutzerschnittstelle kümmern, denn
die dürfte hier etwas anspruchsvoller sein.
Schließlich benötigen die verschiedenen Generatoren unterschiedliche Eingabedaten. Genügen bei einem Zufallsgenerator vielleicht Minimum und Maximum, müssen bei einem anderen
Generator Wertelisten eingegeben werden. Hinzu kommt, dass die Eingabedaten von unterschiedlichem Typ sein können, wofür unterschiedliche Eingabevalidierungen nötig sind. Abbildung 1 zeigt eine erste Skizze einer Benutzerschnittstelle.
Und denken Sie stets an die Musiker: Die verbringen die meiste Zeit mit Üben, nicht mit Auftritten! Wir Softwareentwickler sollten auch regelmäßig üben, statt immer nur zu performen.
Schließlich sollte man beim Auftritt keine Fehler
machen, nur beim Üben ist das zulässig und sogar erwünscht: ohne Fehler keine Weiterentwicklung. Also üben Sie und machen Sie Fehler! [ml]
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Testdaten automatisch generieren
Tückisches GUI
Bei dieser Übung ging der Kern der Anwendung relativ leicht von der Hand. Die eigentliche Herausforderung lag in der
dynamischen Benutzerschnittstelle. Jeder Datentyp verlangt andere Oberflächenelemente. Und der Anwender will
seine Daten individuell strukturieren können.
A
uch bei dieser Aufgabe zeigte
sich wieder, wie wichtig es
ist, sich vor der Implementierung ein paar Gedanken zur
Architektur zu machen. Der erste Gedanke,
das Erzeugen der Daten vom Speichern zu
trennen, liegt auf der Hand und wurde in
der Aufgabenstellung schon erwähnt.
Doch wie geht man generell vor, wenn für
eine Aufgabenstellung eine Architektur
entworfen werden soll? Ganz einfach: Man
malt den „kleinen König“. Den gibt es immer, denn er ist schließlich derjenige, der
die Anforderungen formuliert hat. Er ist
der Grund dafür, dass das System überhaupt gebaut wird. Das zu implementierende System als Ganzes kann man auch
sofort hinmalen. Damit liegt man nie verkehrt. Es ergibt sich damit das in Abbildung 1 gezeigte Bild.
Das Diagramm nennt sich System-Umwelt-Diagramm, da es das System in seiner
Umwelt zeigt. In der Umwelt des Systems
gibt es immer mindestens einen Client, den
kleinen König, der das System bedient. Bei
manchen Systemen mag es mehrere unterschiedliche Clients geben, das spielt für den
Testdatengenerator jedoch keine Rolle. Die
zweite Kategorie von Elementen in der Umwelt stellen Ressourcen dar. Diese liegen
außerhalb des zu erstellenden Systems und
sollten daher in das System-Umwelt-Diagramm aufgenommen werden, denn unser
System ist von diesen Ressourcen abhän-
gig. Im Fall des Testdatengenerators sind
als Ressourcen in der Umwelt CSV-Dateien
und Datenbanken denkbar. Irgendwo müssen die generierten Testdaten schließlich
hin. Folglich ergänze ich das System-Umwelt-Diagramm um diese Ressourcen. Das
Ergebnis ist in Abbildung 2 zu sehen.
Wer nun glaubt, ein solches Diagramm
sei ein Taschenspielertrick, um Zeit zu
schinden, ohne Nutzen für den Architekturentwurf, der irrt. Denn aus diesem Bild
wird bereits deutlich, welche Komponenten mindestens entstehen müssen. Den
Begriff Komponente verwende ich hier mit
einer festen Bedeutung, siehe dazu die Erläuterungen im Kasten.
Der Kern des Systems sollte gegenüber
der Umwelt abgeschirmt werden, weil das
System die Umwelt nicht kontrollieren kann.
Die Umwelt kann sich verändern. Es können etwa neue Clients hinzukommen oder
auch zusätzliche Ressourcen. Folglich müssen auf der Umrandung des Systems Komponenten entstehen, die den Kern des Systems über definierte Schnittstellen gegenüber der Umwelt isolieren. Andernfalls würde der Kern des Systems immer wieder von
Änderungen in der Umwelt betroffen sein
und wäre damit sehr anfällig. Und darin
liegt die Bedeutung des System-UmweltDiagramms: Es zeigt, welche Komponenten
das System von der Umwelt abschirmen.
Für Clients, die das System verwenden,
bezeichnen wir die Komponente, über wel-
Komponente
Eine Komponente ist eine binäre Funktionseinheit mit separatem Kontrakt :
Binär bedeutet hier, dass die Komponente an den Verwendungsstellen binär referenziert wird. Es
wird also bei der Verwendung keine Referenz auf das entsprechende Visual-Studio-Projekt gesetzt,
sondern eine Referenz auf die erzeugte Assembly.
Separater Kontrakt bedeutet, dass das Interface für die Komponente in einer eigenen Assembly
abgelegt ist und nicht in der Assembly liegt, in welcher die Komponente implementiert ist. Daraus
folgt, dass eine Komponente immer aus mindestens zwei Assemblies besteht, nämlich einer für den
Kontrakt und einer für die Implementierung. Und natürlich gehören Tests dazu – also besteht jede
Komponente aus mindestens drei Projekten.
www.dotnetpro.de dotnetpro.dojos.2011
[Abb. 1] SystemUmweltDiagramm,
Version 1.
[Abb. 2] System-Umwelt-Diagramm, Version 2.
che der Client mit dem System interagiert,
als Portal. In Abhängigkeitsdiagrammen
werden Portale immer als Quadrate dargestellt. Die Interaktion des Systems mit Ressourcen erfolgt über Adapter. Diese werden
durch Dreiecke symbolisiert. Im konkreten
Fall des Testdatengenerators können wir
aufgrund des System-Umwelt-Diagramms
also schon vier Komponenten identifizieren, siehe Abbildung 3:
❚ Portal,
❚ CSV-Adapter,
❚ Datenbank-Adapter,
❚ Testdatengenerator.
Die Komponenten sollten normalerweise allerdings nicht im System-Umwelt-Diagramm eingezeichnet werden, weil dort
sonst zwei Belange vermischt werden. Es
soll hier nur gezeigt werden, dass sich Portal und Adapter immer sofort aus dem System-Umwelt-Diagramm ergeben. Aus dem
in Abbildung 3 gezeigten Diagramm lässt
sich das in Abbildung 4 gezeigte Abhängigkeitsdiagramm ableiten.
Den Kern zerlegen
Nachdem ich diese Komponenten identifiziert hatte, habe ich die Aufgabenstellung
15
LÖSUNG
[Abb. 3] System-UmweltKomponenten.
[Abb. 4] Abhängigkeitsdiagramm.
[Abb. 6] Abhängigkeitsdiagramm der Komponenten.
[Abb. 5] Generatoren, nach Typ geordnet, in
Unterverzeichnissen.
Erzeugen der Daten zerlegt. Aufgabe des
Testdatengenerators ist es, Datenzeilen zu
erzeugen. Dabei soll jede Datenzeile aus
mehreren Spalten bestehen. Diese Aufgabe
kann in folgende Funktionseinheiten zerlegt werden:
❚ Erzeugen eines einzelnen Wertes,
❚ Erzeugen einer Zeile,
❚ Erzeugen mehrerer Zeilen.
Dabei scheint die Trennung in das Erzeugen einer Zeile und das Erzeugen mehrerer
Zeilen auf den ersten Blick möglicherweise
etwas merkwürdig. Wenn eine Zeile erzeugt
werden kann, genügt doch eine simple
Schleife, und schon können mehrere Zeilen
erzeugt werden. Dennoch halte ich es für
wichtig, diese beiden Funktionseinheiten
zu identifizieren. Denn für die testgetriebene Entwicklung ist es nützlich, im Vorfeld
zu wissen, welche Funktionseinheiten auf
einen zukommen. So fällt es nämlich viel
leichter, ausreichende Testfälle zu finden,
sprich: die Anforderungen zu klären. Und
bei den Anforderungen liegt die Herausfor-
16
derung eher darin, klar zu definieren, was
die Anforderungen an das Erzeugen einer
einzelnen Zeile sind. Dies dann zu übertragen auf die Erzeugung mehrerer Zeilen ist
in der Tat trivial. Aber ohne die Trennung
würde möglicherweise nur eine Funktionseinheit entstehen, die mehrere Datenzeilen
erzeugt. Das würde die testgetriebene Entwicklung unnötig erschweren.
Nachdem ich für das Erzeugen der Daten
die Funktionseinheiten identifiziert hatte,
habe ich überlegt, welche davon Komponenten werden sollen. Erst Komponenten
erlauben eine parallele Entwicklung von
Funktionseinheiten durch mehrere Entwickler oder Teams gleichzeitig. Dies ist zwar
hier nicht das Ziel, doch resultiert aus der
Trennung von Kontrakt und Implementierung, dass die Komponenten austauschbar
sind. Dies betrachte ich beim Testdatengenerator an einer Stelle für besonders wichtig: bei den Generatoren. Die werden später sicher immer wieder ergänzt werden.
Da ist es hilfreich, wenn dann nicht jeweils
die gesamte Anwendung neu übersetzt
werden muss, sondern neue Generatoren
mit geringem Aufwand ergänzt werden
können. In einer weiteren Ausbaustufe wäre es sogar denkbar, die Generatoren zur
Laufzeit zu laden. Dann könnten später beliebige zusätzliche Generatoren verwendet
werden, ohne dass am Testdatengenerator
selbst etwas geändert werden muss.
Damit sind die Generatoren zunächst
einmal eine Komponente. Eine andere Aufteilung wäre ebenfalls denkbar, man könnte
Generatoren zum Beispiel nach Typ in Komponenten zusammenfassen. Eine Komponente mit Stringgeneratoren, eine für intGeneratoren et cetera. Zurzeit sind es nur
wenige Generatoren, daher habe ich mich
dafür entschieden, sie alle in einer Komponente unterzubringen. Innerhalb der Komponente habe ich die Generatoren nach
Typ in Unterverzeichnisse geordnet. Dies
ist in Abbildung 5 zu sehen.
Eine weitere Komponente bildet die
Funktionseinheit, die dafür zuständig ist,
Zeilen aus Einzelwerten zu bilden. Diese
Komponente habe ich DataPump genannt.
Eine dritte Komponente bildet das Speichern der Daten. Implementiert habe ich
einen CsvDataAdapter. Ein DbDataAdapter zum Speichern der Testdaten in einer
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 1
Listing 2
Einen generischen
Typparameter verwenden.
Spalten definieren.
public interface IGenerator<T>
{
T GenerateValue();
}
Datenbank liegt auf der Hand, auf diesen
habe ich aus Zeitgründen jedoch verzichtet. Übergangsweise kann man sich damit
behelfen, die CSV-Dateien mit einem ETLProzess (Extract, Transform, Load) in die
Datenbank zu schaufeln.
Die Komponenten Generators, DataPump, DbDataAdapter und CsvDataAdapter haben nur geringe Abhängigkeiten, wie
Abbildung 6 zeigt. Der CsvDataAdapter ist
nicht von den anderen Komponenten abhängig, weil er lediglich auf dem gemeinsamen Datenmodell aufsetzt.
Einzelne Werte
Für das Erzeugen eines einzelnen Wertes
habe ich mich für die Verwendung eines
Generators entschieden. Dieser hat die
Aufgabe, zu einem gegebenen Typ einen
Wert zu liefern. Die dabei verwendete Strategie bestimmt der Generator. So ist ein
Generator denkbar, der zufällige Werte erzeugt. Genauso kann aber auch ein Generator erstellt werden, der eine Liste von Daten erhält und daraus zufällig auswählt.
Die Beschreibung der zu erzeugenden
Datenzeilen besteht also darin, pro Spalte
einen Generator zu definieren. Ferner wird
pro Spalte der Name der Spalte benötigt.
Im Kontrakt der Generatoren habe ich
einen generischen Typparameter verwendet, siehe Listing 1. Dadurch wird bereits
zur Übersetzungszeit geprüft, ob der Rückgabewert der Methode GenerateValue zum
Generatortyp passt.
Die Generatoren werden in den Spaltendefinitionen verwendet. Da sie einen generischen Typparameter haben, muss dieser
bei Verwendung des Generators entweder
durch einen konkreten Typ oder an der Verwendungsstelle durch einen generischen
Typparameter belegt werden. Für die Klasse
ColumnDefinition würde das bedeuten,
dass diese ebenfalls einen generischen Typparameter erhält, siehe Listing 2.
So weit, so gut. Doch eine Zeile besteht
aus mehreren Spalten. Daher müssen meh-
www.dotnetpro.de dotnetpro.dojos.2011
public class ColumnDefinition<T>
{
public ColumnDefinition(string columnName, IGenerator<T> generator)
{
ColumnName = columnName;
Generator = generator;
}
public string ColumnName { get; private set; }
public IGenerator<T> Generator { get; private set; }
}
Listing 3
Werte erzeugen.
public static IEnumerable<object> GenerateValues(this IEnumerable<ColumnDefinition>
columnDefinitions) {
return columnDefinitions
.Select(x => x.Generator)
.Select(x => x.GenerateValue());
}
Listing 4
Eine Datenzeile generieren.
public static Line GenerateLine(this IEnumerable<object> values)
{
return new Line(values);
}
Listing 5
Mehrere Zeilen generieren.
public IEnumerable<Line> GenerateTestData(IEnumerable<ColumnDefinition>
columnDefinitions, int rowCount)
{
for (var i = 0; i < rowCount; i++)
{
yield return
columnDefinitions
.GenerateValues()
.GenerateLine();
}
}
rere ColumnDefinition<T>-Objekte in einer
Liste zusammengefasst werden. Da natürlich jede Spalte einen anderen Typ haben
kann, muss es möglich sein, beispielsweise
eine ColumnDefinition<string> sowie eine
ColumnDefinition<int> in diese Liste auf-
zunehmen. Dies ist jedoch mit C# 3.0 aufgrund der fehlenden Ko-/Kontravarianz
noch nicht möglich. Würde man die Liste
als List<object> definieren, müsste die Liste
Kovarianz unterstützen. Das tut sie jedoch
nicht, Ko- und Kontravarianz stehen erst
17
LÖSUNG
mit C# 4.0 zur Verfügung. Ich habe daher
den Generator in der ColumnDefinition als
IGenerator<object> definiert, statt ColumnDefinition generisch zu machen. Dies kann
man dann mit Erscheinen von Visual Studio 2010 ändern.
[Abb. 7] Flow zum
Erzeugen einer Datenzeile.
Eine Zeile
Durch die Generatoren können die einzelnen Werte der Spalten erzeugt werden. Um
eine ganze Datenzeile zu erzeugen, muss
jeder Generator einmal aufgerufen werden, um seinen jeweils nächsten Wert zu
liefern. Dies ist bei Verwendung von LINQ
ganz einfach, siehe Listing 3.
In der Methode wird über die Aufzählung der Spaltendefinitionen iteriert und
durch das erste Select jeweils der Generator aus der Spaltendefinition entnommen.
Durch das zweite Select wird aus jedem Generator ein Wert abgerufen. Das Ergebnis
ist eine Aufzählung der von den Generatoren gelieferten Werte. Diese Aufzählung
wird später an den Konstruktor einer Zeile
übergeben, siehe Listing 4.
eines Generators für int-Werte gezeigt werden, der zufällige Werte innerhalb vorgegebener Minimum- und Maximumwerte erzeugt.
Die Implementierung des Generators ist
ganz einfach. Ich verwende einen Zufallszahlengenerator System.Random aus dem
.NET Framework und weise ihn an, einen
Wert innerhalb der definierten Grenzen zu
liefern, siehe Listing 6. Die spannende Frage ist nun: Wie kann man einen solchen
Generator testen, der zufällige Werte liefern soll? Sie werden bemerkt haben, dass
oben im Listing der Zufallszahlengenerator
random nirgendwo instanziert und zugewiesen wird. Dies liegt in der Notwendig-
Listing 6
Ein Generator für int-Werte.
Mehrere Zeilen
Die Erzeugung mehrerer Zeilen erfolgt in
einer for-Schleife. Dabei wird die Schleife
so oft durchlaufen, dass die Anzahl der gewünschten Datensätze erzeugt wird. Dabei
kommt wieder einmal ein yield return zum
Einsatz, siehe Listing 5.
public class RandomIntGenerator : IGenerator<object>
{
private readonly int minimum;
private readonly int maximum;
private readonly Random random;
...
public object GenerateValue() {
return random.Next(minimum, maximum + 1);
}
Flow
Und schon wieder konnte ich einen Flow
identifizieren. Die Aufzählung der ColumnDefinitions fließt in die Methode GenerateValues. Heraus kommt eine Aufzählung
mit Werten. Diese wird weitergeleitet in
die Methode GenerateLine, die aus den
Werten eine Zeile erstellt :
columnDefinitions
.GenerateValues()
.GenerateLine();
Um den Flow so formulieren zu können,
sind die beiden Methoden als Extension
Methods realisiert. Dadurch wird das Aneinanderreihen der Methoden besonders
einfach. Abbildung 7 zeigt den Flow.
Damit ist die Komponente DataPump
bereits beschrieben. Weiter geht es bei den
Generatoren.
}
Listing 7
Den Zufallsgenerator testen.
[TestFixture]
public class RandomIntGeneratorTests
{
private RandomIntGenerator sut;
[SetUp]
public void Setup() {
sut = new RandomIntGenerator(1, 5, new Random(0));
}
[Test]
public void Zufaellige_Werte_zwischen_Minimum_und_Maximum_werden_geliefert() {
Assert.That(sut.GenerateValue(), Is.EqualTo(4));
Assert.That(sut.GenerateValue(), Is.EqualTo(5));
Generatoren
Ein Generator ist für das Erzeugen von
Werten eines bestimmten Typs zuständig.
Welche Strategie dabei verfolgt wird, ist Sache des Generators. Dies soll am Beispiel
18
...
}
}
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
keit begründet, den Generator automatisiert testen zu können. Würde der Generator den Zufallszahlengenerator selbst instanzieren, würde er immer zufällige Werte liefern. Dies soll er natürlich tun, aber im
Test benötigen wir die Kontrolle darüber,
welche Werte „zufällig“ geliefert werden,
siehe Listing 7.
Die Testmethode ist hier verkürzt dargestellt. Ich rufe im Test so lange Werte ab, bis
alle möglichen Werte innerhalb von Minimum und Maximum mindestens einmal
geliefert wurden.
Der Trick, dass sich der Random-Generator immer gleich verhält, liegt darin, dass
ich ihn im Test immer mit demselben
Startwert (Seed) 0 instanziere.
Um das zu ermöglichen, habe ich einen
internal-Konstruktor ergänzt, der nur im
Test verwendet wird, um den Random-Generator in die Klasse zu injizieren. Der öffentliche Konstruktor der Klasse instanziert den Random-Generator ohne Seed,
sodass dieser zufällige Werte liefert, siehe
Listing 8.
Bei Konstruktoren sollte man übrigens
generell das Highlander-Prinzip beachten:
Es kann nur einen geben (eine Anspielung
auf den Film Highlander – Es kann nur einen
geben). Der interne Konstruktor ist derjenige, der die eigentliche Arbeit verrichtet.
Der öffentliche Konstruktor verfügt nur
über die beiden Parameter für Minimum
und Maximum. Er bezieht sich auf den internen Konstruktor und übergibt diesem,
neben den beiden Grenzwerten, auch einen mit new Random() erzeugten Random-Generator. Das Highlander-Prinzip
sollte beachtet werden, damit es in den Konstruktoren nicht zur Verletzung des Prinzips Don’t Repeat Yourself (DRY) kommt.
Der öffentliche Konstruktor könnte ja die
Grenzwerte selbst an die Felder zuweisen,
dann würden diese Zuweisungen jedoch
an zwei Stellen auftreten.
Eine weitere interessante Implementierung bietet der RollingIntGenerator. Er liefert, ausgehend von einem Minimumwert,
[Abb. 8] Das fertige Portal.
www.dotnetpro.de dotnetpro.dojos.2011
Listing 8
Zufallsgenerator für int-Werte.
public RandomIntGenerator(int minimum, int maximum)
: this(minimum, maximum, new Random()) {
}
internal RandomIntGenerator(int minimum, int maximum, Random random) {
this.minimum = minimum;
this.maximum = maximum;
this.random = random;
}
Listing 9
Zufällig einen Stringwert auswählen.
internal RandomSelectedStringsGenerator(Random random, params string[] values) {
this.random = random;
this.values = values;
}
immer den nächsten Wert, bis er beim Maximumwert angekommen ist. Dann wird
wieder von vorn begonnen. Bei diesem Generator lag die Herausforderung darin,
korrekt mit dem größtmöglichen int-Wert
(int.MaxValue) umzugehen. Ohne UnitTests wäre das ein elendiges Rumprobieren
geworden. So war es ganz leicht.
Für Stringwerte habe ich einen Generator implementiert, der eine Liste von
Strings erhält und daraus zufällig einen
auswählt. Die zur Verfügung stehenden
Strings habe ich im Konstruktor als Parameter-Array definiert, siehe Listing 9.
Das ist für die Unit-Tests ganz angenehm, weil man einfach eine beliebige Liste von Stringwerten übergeben kann :
sut = new RandomSelectedStringsGenerator(
new Random(0), "Apfel", "Birne",
"Pflaume");
Bei der Verwendung des Generators aus
Sicht einer Benutzerschnittstelle ist es
wünschenswert, einen String zu übergeben, der eine Liste von Werten enthält, die
mit Semikolon getrennt sind:
"Apfel; Birne; Pflaume"
Um das zu ermöglichen, habe ich eine
separate Extension Method ToValues() implementiert, die einen String entsprechend
zerlegt. Diese Methode kann bei Bedarf in
den Konstruktoraufruf eingesetzt werden:
"Apfel; Birne; Pflaume".ToValues().ToArray()
Natürlich hätte ich das Zerlegen des
Strings in die Einzelwerte auch im entsprechenden Generator implementieren können. Dann hätte der sich aber um mehr als
eine Verantwortlichkeit gekümmert. Ferner
war die Implementierung so etwas einfacher, da ich mich jeweils auf eine einzelne
Aufgabenstellung konzentrieren konnte.
Portal
Das Portal hatte es in sich. Obwohl ich mit
WPF schon einiges gemacht habe, fühlte
ich mich etwas unsicher, diese sehr dynamische Aufgabenstellung mit WPF anzugehen, und entschied mich daher, das Problem mit Windows Forms zu lösen, weil
mir das schneller von der Hand geht. Doch
der Reihe nach. Wie die Benutzerschnittstelle des Testdatengenerators ungefähr
aussehen könnte, habe ich in der Aufgabenstellung bereits durch ein Mockup angedeutet. Abbildung 8 zeigt, wie mein Ergebnis aussieht.
19
LÖSUNG
Listing 10
Eine Spalte definieren.
public class SpaltenDefinition {
public string Bezeichnung { get; set; }
public Type ControlType { get; set; }
public Func<string, object, ColumnDefinition> Columndefinition { get; set; }
}
[Abb. 9] Document Outline.
Listing 11
Spalten definieren.
new SpaltenDefinition {
Bezeichnung = "Random DateTime",
ControlType = typeof(MinimumMaximum),
Columndefinition = (columnName, control) => new ColumnDefinition(columnName,
new RandomDateTimeGenerator(
DateTime.Parse(((MinimumMaximum)control).Minimum),
DateTime.Parse(((MinimumMaximum)control).Maximum)))
}
Ich sehe beim Portal zwei Herausforderungen: Die Anzahl der Spalten in den zu
generierenden Daten ist variabel. Daraus
ergibt sich, dass die Anzahl der Controls für
Spaltendefinitionen variabel sein muss. Im
Mockup habe ich daher Schaltflächen vorgesehen, mit denen eine Spaltendefinition
entfernt bzw. hinzugefügt werden kann.
Die zweite Herausforderung sehe ich im
Aufbau der Spaltendefinitionen. Je nach
ausgewähltem Generatortyp sind unterschiedliche Eingaben notwendig. Mal sind
zwei Textfelder für Minimum und Maximum erforderlich, mal nur eine für die Elemente einer Liste. Das heißt, dass sich der
Aufbau der Benutzeroberfläche mit der
Wahl des Generatortyps ändert.
Um diese beiden Herausforderungen
möglichst isoliert angehen zu können, habe ich für die variablen Anteile einer Spaltendefinition mit UserControls gearbeitet.
So habe ich für einen Generator, der Minimum- und Maximumwerte benötigt, ein
UserControl erstellt, in dem zwei Textboxen mit zugehörigen Labels zusammengefasst sind.
Wird aus der Dropdownliste ein Generator ausgewählt, muss das zum Generator
passende Control angezeigt werden. Ferner muss zum ausgewählten Generator
später die zugehörige ColumnDefinition
erzeugt werden, um damit dann die Daten
zu generieren. Diese Informationen habe
ich im Portal in einer Datenklasse Spalten-
20
Definition zusammengefasst. Objekte dieser Klasse werden direkt in der Dropdownliste verwendet. Daher enthält die SpaltenDefinition auch eine Beschreibung. Diese
wird als DisplayMember in der Dropdownliste angezeigt, siehe Listing 10.
Die Eigenschaft ControlType enthält den
Typ des zu verwendenden Controls. Genügt
ein Textfeld, kann hier typeof(TextBox) gesetzt werden. In komplizierteren Fällen
wird der Typ eines dafür implementierten
UserControls gesetzt.
Um für den ausgewählten Generatortyp
eine ColumnDefinition erzeugen zu können, habe ich eine Eigenschaft ergänzt, die
eine Funktion erhält, die genau dies bewerkstelligt: Sie erzeugt eine ColumnDefinition. Dazu erhält sie als Eingangsparameter zum einen den Namen der Spalte,
zum anderen das Control mit allen weiteren Angaben. Da der Typ des Controls variabel ist, wird es vom Typ object übergeben. Die Funktion muss dieses Objekt
dann auf den erwarteten Typ casten.
Bei der Initialisierung des Portals wird
für die verfügbaren Generatoren jeweils
eine Spaltendefinition erzeugt und in die
Item-Liste des Dropdown-Controls gestellt, siehe Listing 11.
Interessant hierbei ist die Lambda Expression. Diese erhält die beiden Parameter columnName und control und erzeugt
daraus eine ColumnDefinition mit dem
ausgewählten Generator. Da diese Lambda
Expression im Kontext einer SpaltenDefinition steht, kann das übergebene Control
gefahrlos auf den Typ gecastet werden, der
auch in der Eigenschaft ControlType verwendet wird. Auch hier sähe eine Lösung
mit Generics sicher eleganter aus, ist aber
ohne Ko-/Kontravarianz nicht möglich.
Wird nun in der Combobox ein anderer
Generatortyp ausgewählt, muss das in der
Spaltendefinition angegebene Control angezeigt werden. Um dynamisch die zugehörigen Controls zu finden, füge ich alle
Controls, die zu einer Spalte gehören (Plusund Minus-Button, Textfeld für den Spaltennamen, Combobox, Platzhalter für
UserControl) in ein Panel ein. Um in diesem Panel später dynamisch das UserControl austauschen zu können, füge ich dieses zusätzlich in ein weiteres Panel. Dieses
dient jeweils als Platzhalter für das auszutauschende Control.
In der Form sind nur wenige statische
Elemente vorhanden. Den Aufbau der Form
zeigt die Document Outline in Abbildung 9.
Darin ist dargestellt, wie die einzelnen Controls ineinandergeschachtelt sind.
Abbildung 10 zeigt, wie die Controls für
eine Spaltendefinition dynamisch zur Laufzeit zusammengesetzt werden. Dabei zeigen die Pfeile an, auf welches Control gegebenenfalls die Tag-Eigenschaft verweist.
Nun zur zweiten Herausforderung, dem
dynamischen Ergänzen und Löschen von
Spaltendefinitionen. Jede Spaltendefinition
verfügt über die beiden Schaltflächen zum
Hinzufügen und Löschen von Spaltendefinitionen. Zurzeit füge ich eine neue Spaltendefinition jeweils ans Ende an, künftig
könnte diese aber auch an der betreffenden Position eingefügt werden. Daher habe
ich bereits an jeder Spaltendefinition einen
Plus-Button vorgesehen. Für die Aufnahme aller Spaltenbeschreibungen ist im statischen Teil der Form ein Panel zuständig.
Wird mit der Minus-Schaltfläche versucht,
eine Spaltenbeschreibung zu entfernen,
müssen die zugehörigen Controls aus die-
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
[Abb. 10] Controls
im Panel.
sem Panel entfernt werden. Um dies zu
vereinfachen, ist das zugehörige Panel an
der Tag-Eigenschaft des Buttons gesetzt. So
„weiß“ der Button, zu welchem Panel er
gehört und kann dieses aus dem umschließenden Panel entfernen.
Wird im Portal die Schaltfläche Generieren angeklickt, muss für jede Spaltenbeschreibung eine ColumnDefinition erzeugt
werden, um dann die Testdaten zu generieren. Dazu wird die Liste der Spaltenbeschreibungen im statischen Panel durchlaufen. Darin befindet sich jeweils ein Textfeld, das den Namen der Spalte enthält. Ferner befindet sich im Platzhalterpanel ein
Control, in dem die Parameter für den Generator enthalten sind. In der Dropdownliste enthält das SelectedItem eine SpaltenDefinition, aus der sich die ColumnDefinition erstellen lässt. Dazu wird aus der SpaltenDefinition die Funktion zum Erzeugen
der ColumnDefinition aufgerufen.
Insgesamt hat das Erstellen des Portals
knapp zwei Stunden in Anspruch genommen. Automatisierte Tests habe ich dazu
fast keine erstellt. Diese würde ich allerdings in einem „echten“ Projekt im Nachhinein ergänzen, da die Logik für den dynamischen Aufbau des Portals doch recht
umfangreich geworden ist. Um hier bei
späteren Erweiterungen Fehler auszuschließen, würde ich die typischen Bedienungsschritte eines Anwenders automatisiert testen.
Host
Am Ende benötigen wir für die gesamte
Anwendung noch eine EXE-Datei, mit der
die Anwendung gestartet werden kann.
Aufgabe dieses Hosts ist es, die benötigten
Komponenten zu beschaffen und sie den
Abhängigkeiten gemäß zu verbinden. Die
Abhängigkeiten sind hier in Form von Konstruktorparametern modelliert. Folglich
muss der Host die Komponenten in der
richtigen Reihenfolge instanzieren, im Abhängigkeitsbaum von unten nach oben,
von den Blattknoten zur Wurzel. Anschließend übergibt er die Kontrolle an das Portal. Für die vorliegende Anwendung, bestehend aus einer Handvoll Komponenten, ist
diese Aufgabe trivial. Bei größeren Anwendungen kostet diese Handarbeit Zeit und
sollte automatisiert werden. Die Grund-
www.dotnetpro.de dotnetpro.dojos.2011
idee dabei ist: Man überlässt das Instanzieren der Komponenten einem DI-Container
wie StructureMap [2] oder Castle Windsor
[3]. Über ein eigenes Interface identifiziert
man den Startpunkt der Anwendung, und
los geht’s. Ein solcher Host kann dann sogar generisch sein und in allen Anwendungen verwendet werden.
Denkbare Erweiterungen
Für wiederkehrende Aufgaben wäre es
sinnvoll, das Schema der Datengenerierung speichern und laden zu können. Dies
kann beispielsweise mit dem Lounge Repository [4] erfolgen. In der Architektur
würde dafür ein weiterer Adapter ergänzt,
mit dem ein Schema gespeichert und geladen werden kann. Natürlich müssten im
Portal entsprechende Anpassungen vorgenommen werden, um entsprechende Menüfunktionen zu ergänzen.
Des Weiteren wäre es denkbar, die Generatoren zur Laufzeit dynamisch zu laden.
Damit könnten Entwickler ihre eigenen
Generatoren implementieren und verwenden, ohne dazu die gesamte Anwendung
übersetzen zu müssen. Mithilfe eines DIContainers wie StructureMap oder des
Managed Extensibility Framework MEF [5]
sollte auch diese Erweiterung keine große
Hürde darstellen.
Wir liefern passgenaue
Strategien und Lösungen
für Ihre Inhalte auf
iPhone/iPad
Android
BlackBerry
Windows Phone 7
dem mobilen
Browser
Fazit
Bei dieser Aufgabe stellte sich heraus, dass
die Benutzerschnittstelle einer Anwendung durchaus einige Zeit in Anspruch
nehmen kann. Die eigentliche Funktionalität war dagegen schnell entworfen und implementiert. Das lag maßgeblich daran,
dass ich mir im Vorfeld einige Gedanken
über die Architektur gemacht hatte. Danach ging die testgetriebene Entwicklung
flüssig von der Hand.
[ml]
[1] Stefan Lieser, Meier, Müller, Schulze …,
Testdaten automatisch generieren,
dotnetpro 5/2010, Seite 108 ff.,
www.dotnetpro.de/A1005dojo
[2] http://structuremap.sourceforge.net/
[3] http://www.castleproject.org/container/
[4] http://loungerepo.codeplex.com/ und Ralf
Westphal, Verflixte Sucht, dotnetpro 11/2009,
Seite 52 f. www.dotnetpro.de/A0911Sandbox
[5] http://mef.codeplex.com/
Besuchen Sie
uns unter
www.digitalmobil.com
AUFGABE
Daten umformen
Mogeln mit EVA
Eingabe, Verarbeitung, Ausgabe: Das EVA-Prinzip durchdringt die gesamte Softwareentwicklung. Eine Analyse
der Datenstrukturen und die Verwendung der passenden Algorithmen spielen dabei eine herausragende Rolle.
Stefan, fällt dir dazu eine Übung ein?
Wer übt, gewinnt
dnpCode: A1006dojo
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten …
W
er kennt nicht Minesweeper,
das beliebte Spiel, welches
zum Lieferumfang von Windows gehört? Doch keine Sorge, es geht dieses Mal nicht wieder um eine aufwendige Benutzerschnittstelle, sondern um eine
kleine Kommandozeilenanwendung. Die soll aus
einer Eingabedatei eine Ausgabedatei erzeugen.
Die Eingabedatei enthält die Beschreibung eines
Minesweeper-Spielfeldes. Das Programm erzeugt
als Ausgabedatei einen dazu passenden „Mogelzettel“. Der Aufruf erfolgt folgendermaßen:
mogelzettel spiel1.txt mogelzettel1.txt
Der Aufbau der Eingabedatei ist wie folgt: Die
erste Zeile enthält die Anzahl der Zeilen und
Spalten des Spielfeldes. Beide Zahlen sind durch
ein Leerzeichen getrennt. Die nachfolgenden
Zeilen enthalten dann jeweils die Konfiguration
einer Zeile des Spielfeldes. Dabei sind freie Felder
durch einen Punkt dargestellt, Felder mit einer
Mine durch einen Stern. Hier ein Beispiel:
5 6
....*.
...*..
......
*....*
.*....
Für diese Eingabedatei soll eine Ausgabedatei
erzeugt werden. Die Ausgabedatei gibt für jedes
Feld die Anzahl der Minen in der unmittelbaren
Nachbarschaft an. Jedes Feld hat maximal acht
Nachbarn, folglich können maximal acht Minen
in der Nachbarschaft eines Feldes vorkommen,
am Rand des Spielfeldes sind es natürlich weniger. Felder, die selbst eine Mine enthalten, sollen
mit der Ziffer 0 belegt sein, es sei denn, in der
Nachbarschaft befinden sich Minen. Dann sollen diese ebenfalls gezählt werden.
Der Mogelzettel gibt also keine direkte Auskunft darüber, wo die Minen liegen, sondern nur
über die Anzahl der Minen in den jeweils benachbarten Feldern.
Für das obige Beispiel soll folgende Ausgabedatei erzeugt werden :
22
001211
001121
111121
121010
211011
Sie dürfen davon ausgehen, dass die Eingabedatei im korrekten Format vorliegt. Geht in der
Folge etwas schief, ist gegebenenfalls das Ergebnis inkorrekt, oder das Programm bricht sogar
ab. Dies soll hier keine Rolle spielen.
Die Vorgehensweise bei der Lösung dieser
Übung dürfte Lesern der vorhergehenden Übungen inzwischen geläufig sein. Zunächst sollten
die Anforderungen geklärt werden. Dazu ist es
sinnvoll, Beispiele aufzuschreiben. Eines habe ich
Ihnen gegeben, vielleicht definieren Sie weitere,
um beispielsweise Randfälle zu definieren. Da
ich Ihnen als „Kunde“ nicht so ohne Weiteres für
Rückfragen zur Verfügung stehe, treffen Sie gegebenenfalls selber sinnvolle Annahmen. Nach der
Klärung der Anforderungen beginnt die Planung.
Dieser Phase sollten Sie viel Aufmerksamkeit und
Zeit schenken. Je intensiver Sie sich mit dem Problem und seiner Lösung auseinandersetzen, desto besser sind Sie vorbereitet für die Implementierung. Zerlegen Sie das Gesamtproblem in kleinere Teilprobleme, suchen Sie mögliche Flows.
Wenn Sie sich bei der Herangehensweise nicht
sicher sind, ob eine Idee tatsächlich zur Lösung
des Problems führen wird, lohnt es sich, einen
Spike zu erstellen. Ein Spike dient dazu, eine Idee
zu überprüfen oder eine Technik oder Technologie
zu explorieren. Code, der bei einem Spike entsteht, wird nicht produktiv verwendet, daher sind
keine Tests erforderlich, und auch gegen Prinzipien darf gern verstoßen werden. Es geht um den
Erkenntnisgewinn. Es könnte sich dabei schließlich auch herausstellen, dass eine Idee nicht zum
Ziel führt. Im Anschluss an den Spike beginnen
Sie dann testgetrieben mit der Entwicklung des
Produktionscodes. Dabei beachten Sie selbstverständlich alle Prinzipien und Praktiken.
Und nun frisch ans Werk! Mogeln Sie aber bitte
nur beim Minesweeper-Spielen, nicht bei der
Softwareentwicklung.
[ml]
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Daten verarbeiten und auswerten
So mogeln Sie mit EVA!
Den Mogelzettel für ein Minesweeper-Minenfeld erstellen Sie nach dem EVA-Prinzip: Eingabe, Verarbeitung, Ausgabe.
Die Lösung gestaltet sich nach gründlicher Planung recht einfach. Da im Detail aber Variationen möglich sind, können
Sie diese Übung mit Gewinn auch mehrmals lösen.
D
ie Aufgabenstellung lautete
vorigen Monat: Entwickle ein
Programm, das einen Mogelzettel für Minesweeper erstellt [1]. Es soll so aufgerufen werden:
mogelzettel spiel1.txt mogelzettel1.txt
Hier ein Beispiel für den Aufbau der Eingabedatei :
5 6
....*.
...*..
......
*....*
.*....
Für das obige Beispiel soll folgende Ausgabedatei erzeugt werden :
001211
001121
111121
121010
211011
In der Aufgabenstellung zum Minesweeper-Mogelzettel habe ich erwähnt, dass vor
der Implementierung die Planung stehen
sollte. Und so habe ich dieses Mal wieder
mit einem Blatt Papier begonnen. Obwohl
ich die Aufgabenstellung schon mehr als
einmal selbst gelöst habe und in zahlreichen Seminaren beobachten durfte, wie
andere Entwickler an die Lösung herange-
hen, habe ich dennoch nicht sofort begonnen, Code zu schreiben. Die Planung auf
Papier hilft mir, mich zu fokussieren. Und
sie hilft, auch mal auf andere Lösungen zu
kommen. Schließlich soll Üben dem Gewinnen von Erkenntnissen dienen.
www.dotnetpro.de dotnetpro.dojos.2011
weder Mine
für Mine...
Erste Zerlegung
Die Aufgabenstellung lässt sich auf der
obersten Ebene in drei Funktionseinheiten
zerlegen :
❚ Lesen des Minenfeldes,
❚ Ermitteln des Mogelzettels,
❚ Schreiben des Mogelzettels.
Diese drei Funktionseinheiten bilden
einen Flow, wie Abbildung 1 zeigt. In die
Methode LeseMinenfeld geht der Dateiname als Parameter ein, die Methode liefert
eine Aufzählung von Zeichenketten. Dabei
wird die erste Zeile der Datei einfach ignoriert. Sie enthält die Anzahl der Zeilen und
Spalten. Diese Information wird jedoch
nicht benötigt, da sie aus dem Inhalt der
Datei ebenso hervorgeht.
Das Ergebnis von LeseMinenfeld wird an
die Methode BerechneMogelzettel weitergeleitet. Ergebnis ist wieder eine Aufzählung von Zeichenketten, diesmal ist es aber
nicht das Minenfeld, sondern der dazu generierte Mogelzettel. Die einzelnen Zeilen
des Mogelzettels werden zuletzt, gemeinsam mit dem Dateinamen, an die Methode
SchreibeMogelzettel übergeben und von
dieser als Datei geschrieben.
Mogelzettel berechnen
[Abb. 1] Der Flow der Funktionen.
[Abb. 2] Ent-
Natürlich muss die Methode BerechneMogelzettel weiter zerlegt werden. Dies war in
der Planung mein nächster Schritt. Die beiden Methoden zum Laden und Speichern
von Minenfeld und Mogelzettel erscheinen
mir recht einfach, daher habe ich auf eine
weitere Zerlegung verzichtet.
Prinzipiell sehe ich zwei Möglichkeiten,
den Mogelzettel zu erstellen. Man kann entweder die Felder suchen, auf denen eine
Mine liegt und dann die darum herum lie-
[Abb. 3] ... oder
Feld für Feld
vorgehen.
genden Zähler jeweils um eins erhöhen.
Abbildung 2 zeigt dieses Verfahren, das aus
zwei Schritten besteht:
❚ Minenpositionen ermitteln,
❚ Nachbarfelder inkrementieren.
Oder man geht Feld für Feld vor und
sucht in der Umgebung nach Minen. Man
erhöht den Zähler für das in Bearbeitung
befindliche Feld für jede gefundene Mine.
Diese Variante zeigt Abbildung 3.
Ich habe nach Variante 1 implementiert,
suche also alle Minen und erhöhe dann bei
den Zellen, die um die Mine herum liegen,
den Zähler jeweils um eins. Die algorithmische Vorgehensweise lässt sich wieder sehr
schön in einem Flow implementieren, er
ist in Abbildung 4 zu sehen. Aus der StringRepräsentation werden zuerst die Koordinaten der Minen und die Dimensionen des
Minenfeldes extrahiert. Beides wird dann
verwendet, um daraus den Mogelzettel in
Form eines zweidimensionalen Arrays zu
berechnen. Das Array wird danach wieder
in eine Aufzählung von Strings übersetzt.
Das Ermitteln der Minenkoordinaten habe ich zunächst für eine einzelne Zeile implementiert. Listing 1 zeigt einen Test dazu.
23
LÖSUNG
Listing 1
Methoden auch erst im Nachhinein durch
Refaktorisieren. Diese belasse ich bei einer
privaten Sichtbarkeit und teste sie nur in der
Integration mit der verwendenden Methode.
Das Einlesen einer Zeile testen.
[Test]
public void Eine_Zeile_mit_einigen_Minen() {
Assert.That(Mogelzettel.MinenErmitteln("..*.*..*"), Is.EqualTo(new[] {2, 4, 7}));
}
Listing 2
Das Einlesen mehrerer Zeilen testen.
[Test]
public void Mehrere_Zeilen_voller_Minen() {
Assert.That(Mogelzettel.MinenErmitteln(new[] {"***", "***", "***"}),
Is.EqualTo(new[] {
new Point(0, 0), new Point(1, 0), new Point(2, 0),
new Point(0, 1), new Point(1, 1), new Point(2, 1),
new Point(0, 2), new Point(1, 2), new Point(2, 2)
}));
}
Listing 3
Minen ermitteln.
internal static IEnumerable<int> MinenErmitteln(string zeile) {
var x = 0;
foreach (var zeichen in zeile) {
if (zeichen == '*') {
yield return x;
}
x++;
} }
internal static IEnumerable<Point> MinenErmitteln(IEnumerable<string> zeilen) {
var y = 0;
foreach (var zeile in zeilen) {
foreach (var x in MinenErmitteln(zeile)) {
yield return new Point(x, y);
}
y++;
} }
Die Methode liefert eine Aufzählung der
Indizes, an denen sich in der Zeile eine Mine befindet. Wenn man eine Schleife um
die Methode macht, lassen sich die Koordinaten ganzer Minenfelder ermitteln. Listing 2 zeigt einen Test.
Die Implementierung der beiden Methoden ist einfach. Interessant ist die Verwendung von yield return und internal. Der
Rückgabewert beider Methoden ist eine
Aufzählung, technisch gesprochen vom Typ
IEnumerable<T>. Wenn der Rückgabewert
einer Methode von diesem Typ ist, steht innerhalb der Methode das Schlüsselwort
yield zur Verfügung. Wie in [2] erläutert, erstellt der C#-Compiler einen Automaten für
24
die Methode. So entfällt die Notwendigkeit,
innerhalb der Methode eine Liste für das
Sammeln der Ergebniswerte zu erstellen.
Der Code wird kompakter und besser lesbar.
Ein zweiter Aspekt ist die Verwendung
von Methoden, die mit internal sozusagen
halb versteckt werden, siehe Listing 3. Auch
darauf wurde an anderer Stelle bereits hingewiesen, etwa bei der Lösung zum INotifyPropertyChanged-Tester in [3]. Ich verwende dieses Muster immer dann, wenn sich im
Architekturentwurf Methoden abzeichnen,
die weiter zerlegt werden können. Erfolgt
diese Zerlegung bereits im Rahmen der
Planung, teste ich diese Methoden isoliert,
so wie hier gezeigt. Manchmal entstehen
Fummelei beim Index
Eine Besonderheit gibt es bei den Indizes
zu beachten: Berücksichtigen Sie an den
Rändern, dass es nicht in allen Richtungen
benachbarte Felder gibt. Das bedeutet, dass
Sie bei jedem Zugriff auf die Nachbarfelder
prüfen müssen, ob die Zelle an einem der
Ränder liegt. Diese Indexprüfungen machen
den Code recht unübersichtlich.
if ((point.Y - 1 >= 0) && (point.X - 1 >= 0)) {
result[point.Y - 1, point.X - 1]++;
}
Um die Indizes nicht prüfen zu müssen,
können Sie einen Trick anwenden: Wenn
Sie das Array mit einem zusätzlichen Rand
anlegen und dann nur Indizes von 1 bis
Length – 2 statt von 0 bis Length – 1 verwenden, können Sie sich die Indexprüfungen sparen. Da der zusätzliche Randbereich mit Nullen initialisiert ist, macht es
nichts, dort auch die Minen aufzuaddieren. Nach der Berechnung wird der Rand
einfach wieder entfernt.
Ob diese Lösung besser lesbar ist als bei
der Variante, bei der die Indizes vor jedem
Zugriff geprüft werden, sei dahingestellt.
Letztlich gewinnt man durch den „RandTrick“ etwas Lesbarkeit beim Zugriff auf
das Array, muss aber zusätzlich das Umkopieren zum Entfernen des Randes implementieren, siehe Listing 4.
Da ich mit dieser Lösung nicht zufrieden
war, entschied ich mich zu einer weiteren
Variante. Ich wollte versuchen, das Bestimmen der Indizes der acht benachbarten
Felder zu trennen vom Test auf Gültigkeit
der Indizes. Die Grundidee ist hier also:
Erst mal alle acht möglichen Indizes bestimmen, dann prüfen, welche davon gültig sind. Listing 5 zeigt das Ergebnis. Am
Ende waren die beiden Lösungen vom
Umfang des Codes her miteinander vergleichbar. Die Lesbarkeit und Verständlichkeit der Lösung ohne „Rand-Trick“ scheint
mir etwas besser, da man den Trick mit
dem Rand eben nicht benötigt.
Struktur
Auch dieses Mal verwende ich bei der Lösung die Projekt- und Verzeichnisstruktur,
die ich durchgängig immer in allen Projekten verwende. Das hat den Vorteil, dass mir
diese Schritte so zur Gewohnheit werden,
dass ich nicht mehr darüber nachdenken
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
und die einzelnen Komponenten würden je
eine eigene Solution erhalten.
[Abb. 4] Der Flow
für die Vorgehensweise nach Mine.
Fazit
Der Minesweeper-Mogelzettel ist eine zeitlich überschaubare Übung. Der Umgang
mit den Indizes bietet ein reichhaltiges Betätigungsfeld. Falls Sie es also bislang noch
nicht selbst versucht haben: Mogeln Sie
mal wieder beim Minesweeper, aber nicht
beim Übungspensum !
[ml]
[Abb. 5] Die bewährte Projektstruktur.
muss. Ich erstelle die Verzeichnisse, lege
Projekte an, setze Referenzen, ändere Ausgabepfade, alles ist immer gleich. Wenn Sie
jetzt denken, dass das eine Wiederholung
ist, welche gegen das Prinzip Don't Repeat
Yourself (DRY) verstößt, dann haben Sie
mich erwischt. Eigentlich sollte ich diese
immer wiederkehrenden Handgriffe automatisieren.
Ein Vorteil der immer gleichen Struktur
ist, dass ich mich in allen Projekten sofort
zurechtfinde. Alles liegt immer am gleichen
Platz. Um eine solche Konvention zu etablieren, muss sie sich allerdings zunächst in
der Praxis bewähren. Denn wenn das nicht
der Fall ist, nützt die schönste Konvention
nichts.Wenn ich regelmäßig Projektstrukturen anlege und verwende, zeigt sich, ob im
Detail noch Verbesserungen möglich sind.
Die in Abbildung 5 gezeigte Struktur hat
sich in vielen Projekten, Übungen und Seminaren bewährt. Die Pfeile zeigen, welche
Projekte referenziert werden. Beim Mogelzettel habe ich mich für zwei Implementierungsprojekte sowie ein Testprojekt entschieden. Bei der Implementierung unterscheide ich zwischen der Logik und dem
Host. Im Host wird lediglich die Konsolenschnittstelle zur Verfügung gestellt. Die beiden Parameter der Kommandozeile werden als Dateinamen interpretiert. Durch
die Abtrennung des Hosts lässt sich die Logik des Mogelzettels auch einmal in einem
GUI-Host verwenden.
Diesmal habe ich keine komponentenorientierte Architektur gewählt, da ich mich
www.dotnetpro.de dotnetpro.dojos.2011
auf das Problem der Indizes konzentrieren
wollte. Dennoch sind in der Solution mehrere Projekte mit klar definierten Aufgaben
vorhanden. Für eine komponentenorientierte Lösung kämen die Kontrakte hinzu,
[1] Stefan Lieser: Mogeln mit EVA, Daten umformen, dotnetpro 6/2010, Seite 116 ff.
www.dotnetpro.de/A1006dojo
[2] Golo Roden: yield return, yield break, yield ...
Golos scharfes C, dotnetpro 5/2010, S. 122 f.
www.dotnetpro.de/A1005ScharfesC
[3] Stefan Lieser: Kettenreaktion, INotifyPropertyChanged-Logik automatisiert testen,
dotnetpro 5/2010, S. 108 ff.
www.dotnetpro.de/A1005dojo
Listing 4
Den Rand beim Rand-Trick entfernen.
internal static int[,] RandEntfernen(int[,] array) {
var result = new int[array.GetLength(0) - 2,array.GetLength(1) - 2];
for (var i = 1; i < array.GetLength(0) - 1; i++) {
for (var j = 1; j < array.GetLength(1) - 1; j++) {
result[i - 1, j - 1] = array[i, j];
}
}
return result;
}
Listing 5
Erst Indizes bestimmen, dann prüfen.
internal static int[,] MogelzettelBerechnen(Size groesse, IEnumerable<Point> minenKoordinaten) {
var result = new int[groesse.Height,groesse.Width];
foreach (var mine in minenKoordinaten) {
var indizes = new[] {
new Point(mine.X - 1, mine.Y - 1),
new Point(mine.X - 1, mine.Y),
new Point(mine.X - 1, mine.Y + 1),
new Point(mine.X, mine.Y - 1),
new Point(mine.X, mine.Y + 1),
new Point(mine.X + 1, mine.Y - 1),
new Point(mine.X + 1, mine.Y),
new Point(mine.X + 1, mine.Y +1),
};
foreach (var index in indizes) {
if ((index.X >= 0) && (index.X < groesse.Width) && (index.Y >= 0) &&
(index.Y < groesse.Height)) {
result[index.Y, index.X]++;
}
}
}
return result;
}
25
AUFGABE
Zahlenreihen visualisieren mit Boxplots
Papa, was ist ein Boxplot?
Wie lange dauert und was kostet dies und jenes im Durchschnitt, höchstens, mindestens und am wahrscheinlichsten?
Statistik ist das halbe Leben, in Form von Zahlen und in Form von Grafiken. Stefan, kannst du dazu eine Aufgabe stellen?
Wer übt, gewinnt
dnpCode: A1007DojoAufgabe
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
H
aben Sie sich auch schon mal gefragt, wie Sie eine Zahlenreihe anschaulich darstellen können? Eine
übersichtliche Form bieten die sogenannten Boxplots [1]. Darauf hat mich meine
Tochter gebracht. Sie musste Boxplots als Hausaufgabe in Mathe zeichnen. 7. Klasse! Da werden
Sie sich als gestandener Softwareentwickler doch
nicht wegducken wollen, oder?
Boxplots dienen dazu, die Verteilung der Werte
zu visualisieren. Dazu werden neben dem kleinsten und dem größten Wert auch die sogenannten
Quartile visualisiert. Die Zahlenreihe wird in vier
Bereiche unterteilt. Sind diese Bereiche gleich
groß, bedeutet das, dass die Werte in der Zahlenreihe gleichmäßig verteilt sind. Dazu ein Beispiel. Wenn Sie sich fragen, ob der Pizzadienst
um die Ecke immer gleich lange braucht, um die
Pizza anzuliefern, oder ob es auch schon mal
große Ausreißer gibt, können Sie die Werte mit
einem Boxplot schön visualisieren. Ich habe
nicht gemessen, aber die Werte könnten so aussehen :
18, 24, 19, 19, 20, 25, 24, 18, 24, 17
Aus den reinen Zahlen wird man nicht sofort
etwas erkennen können. Helfen würde schon
mal der Mittelwert. Wie der berechnet wird, ist
jedem sofort klar. Aber wie sieht es mit dem Median aus? Erinnern Sie sich noch?
Zur Berechnung des Medians müssen die Werte zunächst sortiert werden. Dann nimmt man
einfach den mittleren Wert. Wenn die Anzahl der
Werte gerade ist, nimmt man die beiden mittleren Werte und bildet daraus den Mittelwert. Im
obigen Beispiel sind es zehn Werte. Nach dem
Sortieren sieht die Zahlenreihe wie folgt aus:
17, 18, 18, 19, 19, 20, 24, 24, 24, 25
Die beiden mittleren Werte sind 19 und 20. Der
Mittelwert aus diesen ist (19 + 20) / 2 = 19,5.
Beim unteren und oberen Quartil geht es sinngemäß, wie Abbildung 1 zeigt. Aus den Werten
wird dann ein Boxplot erstellt, in dem Minimum
und Maximum die untere bzw. obere Begrenzung bilden. Dazwischen werden die beiden
Quartile sowie der Median eingezeichnet, fertig
26
[Abb. 1] Die Ausgangs-Zahlenreihe.
[Abb. 2] Grafische Darstellung mit einem Boxplot.
ist der Boxplot. Abbildung 2 zeigt das Ergebnis.
Aufgabe des dojos ist es, ein Control zu entwickeln, das einen Boxplot darstellt. Ob Sie das mit
Windows Forms, WPF oder Silverlight lösen, ist
egal. Selbst eine Ausgabe auf der Konsole wäre
reizvoll.
Die Logik von der Umsetzung trennen
Bedenken Sie, dass eine saubere Trennung der Belange wichtig ist. Die Logik des Boxplots sollte
unabhängig sein von den technischen Details
des Controls. Das hilft in jedem Fall beim automatisierten Testen. Ferner schaffen Sie durch
diese Trennung die Grundlage dafür, dass Sie das
Control später auch einmal in einer anderen
Technologie realisieren können.
Denken Sie auch darüber nach, wie die Schnittstellen der beteiligten Funktionseinheiten am
besten aussehen. Sollte das Control die komplette Zahlenreihe erhalten? Oder direkt die Quartile
und die anderen benötigten Werte? Viele Fragen,
nächsten Monat gibt es hier wieder Antworten.
Bis dahin, viel Spaß mit Boxplots!
[ml]
[1] http://de.wikipedia.org/wiki/Boxplot
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Mit Boxplots Zahlenreihen visualisieren
So boxen Sie mit Silverlight!
Statistik hat immer mit Zahlen zu tun. Und Zahlen kann man immer irgendwie grafisch darstellen, eine Zahlenreihe
zum Beispiel in einem Boxplot. Aber wer versucht, ein entsprechendes Silverlight-Control testgetrieben zu entwickeln,
muss feststellen, dass auch Silverlight 4 die testgetriebene Entwicklung nur mangelhaft unterstützt.
W
er sich an der Aufgabenstellung versucht hat und
auf die Schnelle keine Definition für Quartile im
Kopf hatte, wird die Suchmaschine seines
Vertrauens bemüht haben. Das Ergebnis
dürfte überraschen: Man findet unterschiedliche Vorschläge, wie das untere und
obere Quartil zu bestimmen seien. Unter [1]
ist eine Erklärung zu finden, aus der auch
hervorgeht, wie Excel die Quartile berechnet. Unter [2] finden sich Beispielaufgaben.
Dabei wird, soweit erkennbar, das Verfahren verwendet, das [3] beschreibt. Ich habe
ebenfalls nach dem dort beschriebenen
Verfahren implementiert.
Doch bevor ich meine Implementierung
beschreibe, möchte ich auf ein Problem
hinweisen, welches mich „während der
Fahrt“ erwischt hat. Ich habe die Aufgabe
als Silverlight-Anwendung begonnen. Ein
Grund dafür war, dass ich sehen wollte, ob
Visual Studio 2010 im Bereich Testen von
Silverlight-Anwendungen endlich etwas zu
bieten hat. Doch leider – Fehlanzeige. Es
gibt von Microsoft nach wie vor nur das
Silverlight Unit Test Framework aus dem
Toolkit [4]. Dies ist jedoch für die testgetriebene Entwicklung nicht geeignet, da man
nicht die Möglichkeit hat, aus Visual Studio
heraus einen einzelnen Test zu starten.
Mir wurde es nach kurzer Zeit zu lästig,
immer mit [Ctrl] + [F5] den Test Runner im
Browser zu starten. Also habe ich nach einer Alternative gesucht. Doch selbst beim
Versionsstand 4 von Silverlight sieht es im
Bereich automatisiertes Testen nach wie vor
dürftig aus. Zwar gibt es einige Werkzeuge,
mit denen Silverlight-Controls und Anderes getestet werden können. Und natürlich
müssen diese Tests im Browser laufen, um
eine vollständige Silverlight-Umgebung
abzubilden. Was fehlt, ist Unterstützung
für das Testen von Nicht-UI-Klassen.
Ich bin schließlich doch noch fündig geworden. Roy Osherove hat eine Ergänzung
zu TypeMock Isolator [5] entwickelt, mit
der Silverlight-Tests innerhalb von Visual
www.dotnetpro.de dotnetpro.dojos.2011
Studio laufen können. Das SilverUnit genannte Open-Source-Projekt ist unter [6] zu
finden. Es setzt allerdings eine kostenpflichtige Lizenz von TypeMock Isolator voraus.
Doch zurück zum Boxplot. Die Aufgabenstellung lässt sich grob in zwei Bereiche
unterteilen :
❚ Benutzerschnittstelle (UI, Control),
❚ Berechnung.
Ausgangspunkt eines Boxplots ist eine
Aufzählung von Werten. Für diese Werte
müssen Sie für die Visualisierung folgende
Größen ermitteln : Minimum, Unteres
Quartil, Median, Oberes Quartil, Maximum. Zur Ermittlung dieser Größen ist es
erforderlich, die Werte zu sortieren. Es ist
naheliegend, die Implementierung so vorzunehmen, dass die Ausgangswerte nur
einmal sortiert werden. Aber Vorsicht vor
Optimierungen! Die Größen sind unabhängig voneinander und können daher
auch unabhängig implementiert werden.
Widerstehen Sie dem Reflex, von Anfang
an eine Implementierung vorzusehen, in
der die Sortierung herausgezogen wird.
Sollte sich später herausstellen, dass das
mehrfache Sortieren zu Problemen bei der
Geschwindigkeit führt, können Sie immer
noch nach Abhilfe suchen.
Fest steht: Bei der Berechnung benötigen
Sie einige Methoden, die aus den Grunddaten die zur Visualisierung benötigten
Größen ermitteln. Diese Methoden lassen
sich testgetrieben recht gut entwickeln.
Control-API
Die Schnittstelle des Controls sollte für den
Verwender möglichst komfortabel sein: Ich
möchte das Control auf ein Formular ziehen, die Größe einstellen, fertig. Insbesondere mit der Skalierung sollte der Verwender nichts zu tun haben. Auf der anderen
Seite sollte das Control allerdings auch
nicht zu viel tun. Insbesondere das Berechnen der darzustellenden Größen aus den
Werten liegt nicht im Verantwortungsbereich des Controls. Die Ermittlung des
Medians hat nichts mit der Funktionalität
eines Controls zu tun.
Damit sich das Control um die Skalierung kümmern kann, müssen die Größen
wie Minimum, Maximum, Median und so
weiter nichtskaliert, das heißt als Originalwert, angegeben werden. Aufgabe des Controls ist es, den Bereich zwischen Minimum
und Maximum in der zur Verfügung stehenden Breite darzustellen. Folglich müssen sämtliche Größen auf die zur Verfügung stehende Breite skaliert werden. Ich
Listing 1
Eine Dependency-Property beschreiben.
public static readonly DependencyProperty LowScaledProperty =
DependencyProperty.Register("LowScaled", typeof(double),
typeof(BoxPlot), new PropertyMetadata(Changed));
Listing 2
Eine Eigenschaft für die Dependency-Property.
public double LowScaled {
get { return (double)GetValue(LowScaledProperty); }
}
27
LÖSUNG
habe mich daher entschlossen, für die
darzustellenden Werte jeweils zwei Eigenschaften im Control bereitzustellen: eine
Eigenschaft für den nichtskalierten Originalwert sowie eine für den skalierten Wert.
Das Control übernimmt das Skalieren. Immer wenn einer der nichtskalierten Werte
verändert wird, muss das Control den skalierten Wert anpassen. Realisiert man die
Eigenschaften als sogenannte Dependency-Properties, können sie per Data-Binding im Control verwendet werden.
Mit dieser Idee zur grundsätzlichen Vorgehensweise habe ich mich allerdings etwas
unsicher gefühlt. Mir war nämlich nicht
ganz klar, ob dies tatsächlich funktioniert.
Dazu fehlt mir die Praxis mit Silverlight. Da
dies ein übliches Problem in Projekten ist,
will ich es hier kurz thematisieren.
Entwickler stehen immer wieder vor Herausforderungen, zu denen sie zwar eine
grobe Vorstellung über mögliche Lösungswege entwickeln können. Am Ende bleiben
jedoch manchmal Unsicherheiten über
den konkreten Lösungsweg. Diese können
beispielsweise in konkreten Details der zu
verwendenden Technologie oder auch in
Algorithmen liegen.
Um diese Unsicherheit in den Griff zu
bekommen, kann man einen sogenannten
Spike implementieren. Ein Spike ist eine
Art „Forschungsprojekt“. Der Spike soll dazu dienen, Unsicherheiten zu beseitigen.
Ziel eines Spikes ist also der Erkenntnisgewinn, nicht etwa produktionsfertige Software. Daher werden an den Spike andere
Anforderungen gestellt. Er muss nicht testgetrieben entwickelt werden.
Es gibt allerdings auch ein großes „Aber“:
Beim Spike ist zwar alles erlaubt, aber das
Ergebnis wird nicht in der Produktion verwendet. Nachdem der Spike zum Erkenntnisgewinn geführt hat, muss die betreffende Funktionalität anschließend nach allen
Regeln der Kunst testgetrieben implementiert werden.
Nachdem ich also per Spike geklärt hatte,
dass einer Implementierung mittels Dependency-Properties nichts im Wege steht, begann ich mir über die Architektur Gedanken
zu machen. Diese erwies sich als trivial: Auf
der einen Seite gibt es ein Control mit Eigen-
Listing 3
set veranlasst die Skalierung.
public double Low {
get { return (double)GetValue(LowProperty); }
set {
SetValue(LowProperty, value);
SetValue(LowScaledProperty, Scaled(value));
}
}
Listing 4
Die Skalierung berechnen.
private double Scaled(double value) {
return (value - Min + 1) * ((ActualWidth - StrokeThickness) / (Max - Min));
}
Listing 5
Testbeispiele entwickeln.
[TestMethod]
public void Drei_sortierte_Werte() {
Assert.AreEqual(2.0, Zahlenreihe.Median(new[] {1.0, 2.0, 3.0}));
}
[TestMethod]
public void Vier_sortierte_Werte() {
Assert.AreEqual(2.5, Zahlenreihe.Median(new[] {1.0, 2.0, 3.0, 4.0}));
}
28
schaften für Minimum, Quartile, Median
und Maximum. Auf der anderen Seite gibt es
einige statische Methoden, um diese Werte
zu berechnen. Damit sind Control und Berechnungslogik unabhängig voneinander.
Beim Control habe ich eine einfache Lösung gewählt: Minimum und Maximum
liegen jeweils am Rand des Controls. Damit
füllt der Boxplot den für das Control zur
Verfügung stehenden Platz vollständig aus.
Nun habe ich mir eine Skizze gemacht, die
visualisiert, aus welchen Primitiven der
Boxplot aufgebaut ist, siehe Abbildung 1.
[Abb. 1] Den Boxplot aus einzelnen Linien aufbauen.
Ich habe dazu als Primitive nur Linien
verwendet. Bei einem waagerecht liegenden Boxplot sind die y-Koordinaten nicht
von den darzustellenden Größen abhängig, sondern nur vom zur Verfügung stehenden Platz. Folglich sind lediglich die x-Koordinaten per Data-Binding an die Dependency-Properties gebunden.
Die Implementierung des Controls besteht somit aus drei Teilen :
❚ XAML-Datei zur Definition der Linien
und der Data-Bindings,
❚ Dependency-Properties für die darzustellenden Größen,
❚ Skalierungslogik.
Die XAML-Datei enthält einen Canvas
als Container. Darin liegen die neun LineElemente, bei denen die in Abbildung 1
markierten x-Koordinaten über Data-Binding von den Dependency-Properties abhängen. Damit das Data-Binding sich auf
eigene Eigenschaften des Controls bezieht,
müssen Sie im UserControl den DataContext wie folgt setzen :
<UserControl ...
DataContext="{Binding RelativeSource={RelativeSource Self}}">
Dadurch können Sie in den Line-Elementen beim Data-Binding Eigenschaften
des Controls verwenden :
<Line
X1="1" Y1="50"
X2="{Binding Path=LowScaled}" Y2="50"
Canvas.Left="1" Canvas.Top="1"
StrokeThickness="2" Stroke="Black"
/>
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Im Beispiel ist LowScaled die Dependency-Property, welche den skalierten Wert für
das untere Quartil enthält. Für die Dependency-Properties wird jeweils ein statisches Feld definiert, welches die Dependency-Property beschreibt, siehe Listing 1.
Zusätzlich sind normale C#-Eigenschaften definiert, um in der üblichen Art und
Weise auf die Eigenschaften zugreifen zu
können, siehe Listing 2. Der Umweg über
die Dependency-Properties ist erforderlich, damit die Visualisierung jeweils aktualisiert wird, wenn sich an den zugrunde liegenden Werten etwas ändert.
Die Skalierung der Größen erfolgt in den
Settern der jeweiligen zugehörigen nichtskalierten Größe. Dort wird sowohl der zu
setzende Originalwert als auch der berechnete skalierte Wert in die jeweiligen
Dependency-Properties übertragen, wie in
Listing 3 zu sehen.
Auf diese Weise wird beim Setzen der
Low-Eigenschaft auch die LowScaled-Eigenschaft gesetzt. Das Skalieren übernimmt
die in Listing 4 gezeigte Methode. Um die
korrekte Visualisierung des Controls prüfen zu können, bleibt nichts anderes übrig,
Listing 6
Gerade und ungerade Anzahl von Werten testen.
[TestMethod]
public void Drei_unsortierte_Werte() {
Assert.AreEqual(2.0, Zahlenreihe.Median(new[] {2.0, 3.0, 1.0}));
}
[TestMethod]
public void Vier_unsortierte_Werte() {
Assert.AreEqual(2.5, Zahlenreihe.Median(new[] {3.0, 1.0, 4.0, 2.0}));
}
Listing 7
Das Minimum über LINQ ermitteln.
[TestMethod]
public void Minimum() {
Assert.AreEqual(1, new[]{5.0, 1.0, 2.0}.Min());
}
als einen kleinen Testrahmen zu erstellen,
in dem das Control angezeigt wird. Das bedeutet allerdings nicht, dass in solchen
Tests nichts zu automatisieren wäre. Der
Testrahmen kann immerhin dazu verwendet werden, Beispieldaten automatisiert
zum Control zu übertragen. So entfällt die
manuelle Interaktion mit dem Control zum
codekicker.de
Die deutschsprachige Q&A-Plattform
für Software-Entwickler
codekicker.de – Antworten für Entwickler
LÖSUNG
Testzeitpunkt. Zudem sind dadurch die verwendeten Testdaten dokumentiert.
Berechnungen
Nachdem das Control fertiggestellt ist, geht
es an die Berechnung der benötigten Größen. Dabei kann man dank SilverUnit und
TypeMock Isolator wieder testgetrieben
vorgehen. Ich habe zunächst einige Beispiele zusammengestellt und diese dann
nach und nach in automatisierte Tests
überführt, siehe Listing 5.
Da mir der Algorithmus zur Berechnung
des Medians vor der Implementierung vertraut war, habe ich die Tests so gewählt,
dass ich den Algorithmus schrittweise implementieren konnte. Zunächst habe ich
daher den Median aus einer bereits sortierten Aufzählung ermittelt. Dabei sind zwei
Fälle zu unterscheiden: Die Anzahl der
Werte kann ungerade oder gerade sein. Bei
einer geraden Anzahl von Werten werden
die beiden mittleren Werte herangezogen
und aus diesen der Mittelwert berechnet.
Die Ergänzung um das Sortieren war keine
große Sache, wie Listing 6 zeigt.
Nach dem Median kamen Minimum und
Maximum an die Reihe – auch kein großes
Problem. Nach dem Sortieren den ersten beziehungsweise letzten Wert zu verwenden ist
einfach. Aber halt: Gibt es diese Funktionalität nicht in LINQ? Listing 7 zeigt es.
Siehe da, ganz einfach. Bleiben noch die
beiden Quartile. Hier war, wie eingangs
schon erwähnt, eher die Frage, welcher Algorithmus verwendet werden sollte. Die
Tests sind wieder keine große Sache. Das
Beispiel von Listing 8 ist auf SilverUnit und
NUnit ausgelegt, daher sehen die Attribute
an der Testmethode etwas anders aus als in
den vorigen Beispielen. Listing 9 zeigt die
zugehörige Implementierung.
Die Methode verwendet zum Sortieren
die in Listing 10 gezeigte Extension Method.
Dadurch wird die Anwendung des Sortierens besser lesbar. Zudem ist die Implemen-
Listing 8
Die Berechnung von Quartilen testen.
[Test]
[SilverlightUnitTest]
public void Quartil_25_bei_7_Werten() {
Assert.AreEqual(2.0, Zahlenreihe.UnteresQuartil(new[] {1.0, 2.0, 3.0, 4.0,
5.0, 6.0, 7.0}));
}
Listing 9
Quartile berechnen.
public static double UnteresQuartil(IEnumerable<double> zahlenreihe) {
var werte = zahlenreihe.Sort();
if (werte.Count % 4 == 0) {
var x1 = werte[werte.Count / 4 - 1];
var x2 = werte[werte.Count / 4];
return (x1 + x2) / 2;
}
return werte[(int)Math.Ceiling(werte.Count / 4.0) - 1];
}
Listing 10
Die Werte sortieren.
public static class ArrayExtensions {
public static IList<T> Sort<T>(this IEnumerable<T> enumerable) {
var values = enumerable.ToArray();
Array.Sort(values);
return values;
}
}
30
[Abb. 2] Geschafft: Ein Boxplot im Browser.
tierung des Sortierens damit in einer Methode zusammengefasst. Sollte sich später
zeigen, dass das Sortieren über Arrays zu Performance- oder Speicherproblemen führt,
kann dies an einer einzigen Stelle behoben
werden. Abbildung 2 zeigt das fertige Control im Browser.
Fazit
Die Herausforderung lag diesmal im Tooling. Automatisiertes Testen von SilverlightAnwendungen ist immer noch ein schwieriges Unterfangen. Bei dem kostenlos verfügbaren Tool aus dem Silverlight Toolkit stört
mich persönlich vor allem, dass es auf
MSTest basiert. Als NUnit-Anwender fällt es
mir schwer, Assert.AreEqual zu schreiben
statt Assert.That.
Ferner ist die ausschließliche Ausführung
im Browser nicht zu tolerieren. Hier sollte
Microsoft schnell nachbessern und einen in
Visual Studio integrierten Unit Test Runner
liefern. Dass dies möglich ist, zeigt Testdriven.NET [7]. Leider kann damit aber immer
nur ein einziger Test ausgeführt werden. Die
Alternative lautet zurzeit SilverUnit. Dazu
ist zwar eine kostenpflichtige Lizenz von
TypeMock Isolator erforderlich, das dürfte
aber für ernsthafte kommerzielle Entwicklungen im Silverlight-Umfeld kein Problem
[ml]
darstellen.
[1] Nach welchem Verfahren berechnet Excel
eigentlich Quartile? Hinter die Kulissen
von Excel geschaut,
www.dotnetpro.de/SL1008dojo1
[2] Übungen zu Boxplots,
www.dotnetpro.de/SL1008dojo2
[3] Zeichnen von Boxplots mithilfe von Excel,
Anleitung, www.dotnetpro.de/SL1008dojo3
[4] http://code.msdn.microsoft.com/silverlightut/
[5] http://typemock.com
[6] http://cthru.codeplex.com/
[7] http://testdriven.net
dotnetpro.dojos.2011 www.dotnetpro.de
AUFGABE
Experimentieren mit Raven DB
Was kann der Rabe?
Kaum eine Software kommt ohne Persistenz aus. Auf diesem Gebiet stehen die relationalen
Datenbanken in fest gefügter Phalanx. Aber geht Persistenz nicht auch anders? Da gibt es doch diese
NoSQL-Dokumentendatenbanken. Stefan, fällt dir dazu eine Übung ein?
P
www.dotnetpro.de dotnetpro.dojos.2011
dnpCode: A1008DojoAufgabe
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
[Abb. 1] Ungefähr so könnte die Seite für Produktbewertungen aussehen.
sehen. Überlegen Sie sich also ein kleines Datenmodell, bestehend aus Produkten, Kategorien, in
die ein Produkt fällt, sowie Bewertungen und
Kommentaren zu einem Produkt. Da Raven DB
eben gerade nicht relational ist, besteht die Herausforderung möglicherweise darin, sich von der
in uns schlummernden relationalen Denkweise
ganz bewusst zu lösen.
Um die Fähigkeiten von Raven DB zu erkunden, sollten Sie in der Anwendung ein Feature
vorsehen, das Daten aggregiert. Sie können beispielsweise aus allen abgegebenen Bewertungen
zu einem Produkt den Mittelwert bilden. Oder
die Bewertungen aller Produkte einer Kategorie
aggregieren. Oder das Produkt mit der besten Bewertung innerhalb einer Kategorie ermitteln.
Lassen Sie Ihrer Fantasie freien Lauf. Einige Ideen liefert das in Abbildung 1 gezeigte Mockup.
Somit sind Sie diesen Monat eigentlich in
zweifacher Weise herausgefordert: Die erste Herausforderung ist einfach die Beschäftigung mit
Raven DB. Die zweite besteht darin, beispielhafte Anforderungen mit Raven DB umzusetzen.
Viel Spaß bei der Arbeit als Forscher auf unbekanntem Terrain.
[ml]
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
[1] Raven DB, http://ravendb.net/
31
Wer übt, gewinnt
ersistenz ist ein wichtiger Aspekt in
vielen Anwendungen. Seit Jahrzehnten
bewährt sich die Technologie der relationalen Datenbanken. Sie ist allerdings nicht in allen Fällen gut geeignet, die Anforderungen umzusetzen. Wenn das Schema der Daten flexibel sein muss, bieten sich Alternativen an.
Mit dieser Problematik befasst sich unter dem
Stichwort „NoSQL“ inzwischen eine ganze Reihe
von Projekten. Sie setzen ganz bewusst nicht auf
SQL. Zugleich wollen diese Projekte die relationalen Datenbanken nicht ersetzen, sondern verstehen sich als Alternative, die in bestimmten
Kontexten sinnvoll ist. Daher wird NoSQL oft
auch mit „not only SQL“ übersetzt.
Da liegt es doch nahe, sich im Rahmen des
dotnetpro.dojo einmal mit einem NoSQL-Projekt
zu befassen. Schließlich bedeutet regelmäßiges
Üben für Softwareentwickler auch, sich ab und
zu mal mit völlig neuen Dingen zu beschäftigen.
Da Ayende Rahien gerade sein neuestes Projekt
Raven DB [1] veröffentlicht hat, bietet sich die
Chance, zu den Early Adoptern zu gehören. Daher lautet die Aufgabe des Monats: Schreibe eine
kleine Raven-DB-Anwendung.
Beim Einsatz einer neuen Technologie, mit der
man noch nicht vertraut ist, bietet es sich an,
dies in Form eines sogenannten Spikes zu bewerkstelligen. Ziel eines Spikes ist nicht, Code zu
schreiben, der Produktionsqualität erreicht, sondern Ziel ist Erkenntnisgewinn. Doch wenn sich
automatisierte Tests für ein flüssiges Entwickeln
von Produktionscode eignen, mögen sie auch in
einem Spike nützlich sein, um schnell voranzukommen. Denn nach dem Speichern und Laden
eines Objektes mit Raven DB wird schnell der
Wunsch entstehen, auch die anderen Fähigkeiten des APIs auszuloten. Da kommt man mit einer Reihe von Tests, die im Unit Test Runner einzeln gestartet werden können, zügig voran.
Nach den ersten Schritten, die vor allem dazu
dienen, sich mit dem API vertraut zu machen,
soll eine kleine Aufgabenstellung bearbeitet werden. Implementieren Sie daher eine kleine Anwendung zur Bewertung von Produkten. Die Anwender sollen damit in die Lage versetzt werden,
Produktbewertungen abzugeben und sie einzu-
LÖSUNG
Die NoSQL-Dokumentendatenbank Raven DB ausprobieren
So sammeln Raben Daten
Zum Entwickleralltag gehört es, sich in neue Technologien einzuarbeiten, beispielsweise in eine NoSQL-Datenbank.
Der Code, der dabei entsteht, muss nicht die Qualität von Produktionscode haben. Ein testgetriebener Ansatz ist dafür
aber dennoch nützlich, denn die Tests dokumentieren die gewonnenen Erkenntnisse in leicht nachvollziehbarer Form.
I
m vergangenen Monat war das dotnetpro.dojo etwas anders gelagert
als sonst. Es ging nicht darum, eine
konkrete Aufgabenstellung zu implementieren, sondern darum, sich mit einem bislang unbekannten Framework ausein-anderzusetzen. Auch das ist eine Form
der Übung: das schnelle Sich-Einarbeiten
in eine neue Technologie über einen Spike.
Ein Spike dient vor allem dem Erkenntnisgewinn. Dieser steht im Vordergrund und
mag im Zweifel auch schon mal Prinzipien
und Praktiken zurückdrängen, die man bei
Produktionscode in jedem Fall anwenden
würde. Das bedeutet jedoch nicht, dass
Spikes ein Freifahrtschein für schlechte
Angewohnheiten wären.
Bei der Überlegung, welche Prinzipien
und Praktiken ich anwende, lasse ich mich
auch beim Spike vom Wertesystem der
Clean-Code-Developer-Initiative leiten [1].
Einer dieser Werte ist die Produktionseffizienz. Daraus ergibt sich für mich beispielsweise, dass ich auch Spikes in der gewohnten Verzeichnis- und Projektstruktur
anlege. Das hat zum einen den Vorteil, dass
der Spike eine weitere Gelegenheit bietet,
diese Struktur anzuwenden und zu hinterfragen. Zum anderen ergibt sich daraus ein
Effizienzvorteil, weil ich es eben immer
gleich tue. Ich gestehe, es fehlt ein Stück
Automatisierung, viele der Schritte erledige ich in Handarbeit. Aber da ich sie so oft
anwende, gehen sie flüssig von der Hand.
So landen bei mir auch in Spikes die
Tests in einem eigenen Projekt. Und auch
die benötigten Frameworks wie NUnit und
in diesem Fall RavenDB werden nicht aus
dem GAC oder sonst woher referenziert,
sondern „nach den Regeln der Kunst“ aus
einem Verzeichnis innerhalb der Projektstruktur. Würde ich nicht so verfahren, hät-
32
ten Sie als Leser später das Nachsehen.
Denn dann würden sich die Beispiele, die
Sie zu diesem Artikel auf der Heft-DVD finden, nicht sofort übersetzen lassen.
Diese Situation ist keinesfalls speziell,
nur weil ich den Code zur Veröffentlichung
in einem Artikel schreibe. Auch in Ihren
täglichen Projekten werden andere Entwickler den Code aus der Quellcodeverwaltung entnehmen und übersetzen wollen.
Wenn Projekte dann nicht „self-contained“,
also in sich abgeschlossen, sind, fängt der
Ärger an: Referenzierte Assemblies werden
nicht gefunden. Oder noch „gemeiner“: Sie
liegen in einer anderen Version vor und
verursachen dadurch Probleme. Und gehen Sie nicht davon aus, dass vermeintliche
Selbstverständlichkeiten wie NUnit zu der
Umgebung gehören würden, die Sie bei jedem Entwickler voraussetzen können. Je
weniger Abhängigkeiten das Projekt zu seiner Umgebung hat, desto besser.
Sie werden möglicherweise fragen, was
denn automatisierte Tests in einem Spike
zu suchen haben. Automatisierte Tests haben bei der Erkundung neuer Techniken
zwei Vorteile: Zum einen verwende ich sie,
um die verschiedenen Szenarien damit
starten zu können. Anstatt eine Konsolenanwendung zu erstellen, welche mit Console.WriteLine versucht darzustellen, was
gerade passiert, verwende ich automatisierte Tests. Das bietet den Vorteil, dass ich
in einem Projekt mehrere Szenarien unterbringen kann, die sich alle einzeln starten
lassen. Ferner sind diese Tests ebenfalls
„self-contained“, also in sich abgeschlossen. Ein Blick auf den Test genügt, um zu
verstehen, was da passiert. Es ist nicht notwendig, eine Anwendung laufen zu lassen,
um zusätzlich noch die Konsolenausgabe
zu sehen.
Der zweite Vorteil von Tests ist, dass ich
sie zur Dokumentation der Funktionalität
verwenden kann. Wenn ich mir nicht sicher bin, ob eine bestimmte Funktionalität
sich nun so oder anders verhält, erstelle ich
einen Test, der das Verhalten dokumentiert. Im weiteren Verlauf des Artikels wird
ein solcher Test beispielsweise zeigen, zu
welchem Zeitpunkt RavenDB Schlüsselwerte erzeugt.
Woher nehmen, den Raben?
Nachdem ich eine Solution mit zwei Projekten angelegt hatte und die Referenz auf
NUnit gesetzt war, stand ich vor der Frage:
Woher RavenDB nehmen? Klar, dass sich
die Antwort hinter der URL [2] verbirgt, ich
fragte mich aber, ob ich eine fertig übersetzte, binäre Version verwenden oder auf
den Quellcode setzen sollte. Ich entschied
mich für eine binäre Version, die jeweils aktualisiert unter [3] zum Download erhältlich ist. Wenn schon täglich aktualisierte
Binärversionen zur Verfügung stehen, muss
ich mir nicht die Mühe machen, lokal jeweils eine aktuelle Version zu übersetzen.
Leider ist bei Weitem nicht für alle OpenSource-Projekte ein täglich aktualisierter
Listing 1
Ein Objekt speichern.
var store = new DocumentStore {Url = "http://localhost:8080"};
store.Initialize();
var produkt = new Produkt();
using (var session = store.OpenSession()) {
session.Store(produkt);
session.SaveChanges();
}
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Build verfügbar, daher bietet sich in anderen Fällen die Arbeit mit den Quellen an.
Nach dem Download stand mir RavenDB
nun zur Verfügung. Ich habe es komplett in
das lib-Verzeichnis innerhalb der Projektstruktur abgelegt. Damit unterliegt es der
Versionierung, und das Projekt ist in sich
abgeschlossen.
Client und Server
RavenDB kann auf verschiedenen Wegen
verwendet werden: eingebettet in die Anwendung oder getrennt in Client und Server. Ich entschied mich dafür, RavenDB als
Server zu starten. Im Implementierungsprojekt muss dann nur die Assembly Raven.Client.Lightweight.dll aus dem ClientVerzeichnis referenziert werden. Der Server
befindet sich im Verzeichnis Server. Klingt
logisch, oder? Dennoch sind solche klaren
Strukturen nicht selbstverständlich. Oft befinden sich alle binären Artefakte eines
Frameworks gemeinsam in einem bin-Verzeichnis, aus dem man sich selbst heraussuchen muss, was man benötigt. Da gefällt
mir diese Aufteilung bei RavenDB doch
sehr gut. Sie vereinfacht die ersten Schritte.
Doch zurück zum Server. Der kann innerhalb eines IIS gehostet werden oder
auch als Windows-Dienst laufen. Ich habe
nur den Windows-Dienst ausprobiert. Dazu muss man zwei Befehle ausführen :
❚ RavenDb.exe /install
❚ RavenDb.exe /start
Der erste Befehl installiert RavenDB als
Windows-Dienst, der zweite startet den
Dienst. Weil das Installieren und Starten von
Diensten unterWindows nicht jedem Nutzer
erlaubt sind, kümmert sich RavenDB bei
Bedarf um die Elevation, also das Beschaffen der nötigen Rechte. Das ist vorbildlich!
Statt eine kryptische Fehlermeldung auszugeben, eventuell mit dem Hinweis, man
möge das Programm als Administrator starten, wird’s mir hier sehr einfach gemacht.
CRUDe Methoden
Nun möchte ich als Erstes ein Objekt in der
RavenDB-Datenbank abspeichern. Dazu
habe ich die Klasse Produkt angelegt. Die
Klasse berücksichtigt keine Infrastruktur,
Instanzen sind sogenannte POCOs: Plain
Old CLR Objects. Damit wird im Allgemeinen die sogenannte Infrastrukturignoranz
bezeichnet. RavenDB stellt (fast) keine Anforderungen an eine zu persistierende
Klasse. Es muss nicht von einer Basisklasse
abgeleitet werden, es muss kein spezielles
Interface implementiert werden, und es
sind keine Attribute erforderlich. Nur eine
www.dotnetpro.de dotnetpro.dojos.2011
Konvention ist einzuhalten: RavenDB benötigt eine Eigenschaft namens Id vom Typ
string. In dieser Eigenschaft wird der eindeutige Schlüssel des Objekts von RavenDB
erwartet. Natürlich kann diese Konvention
geändert werden, wenn sie nicht passt.
public class Produkt {
public string Id { get; set; }
public string Name { get; set; }
public string Kategorie { get; set;
}
Um eine Instanz der Klasse mit RavenDB
zu persistieren, benötigt man einen sogenannten DocumentStore. Dieser sollte pro
Anwendung nur einmal erzeugt werden.
Mithilfe des DocumentStore wird eine Do-
cumentSession erzeugt. Innerhalb einer
Session werden Änderungen vorgenommen und am Ende persistiert. Listing 1
zeigt das Speichern eines Objektes.
Der DocumentStore enthält keinen Zustand, dafür ist die DocumentSession zuständig. Während der Lebenszeit der Session sorgt diese für die Objektidentität:
Wird ein und dasselbe Dokument mehrfach aus der Datenbank geladen, liefert die
Session jeweils ein und dasselbe Objekt. So
ist sichergestellt, dass innerhalb einer
Session nur genau eine Instanz eines Dokumentes der Datenbank existiert. Ohne
diese Objektidentität wäre die Gefahr sehr
groß, dass an unterschiedlichen Objekten
Änderungen vorgenommen werden, die
Listing 2
RavenDB sichert die Objektidentität.
[Test]
public void Session_stellt_Objektidentität_sicher() {
var store = new DocumentStore {Url = "http://localhost:8080"};
store.Initialize();
var produkt = new Produkt();
using (var session = store.OpenSession()) {
session.Store(produkt);
session.SaveChanges();
var produkt2 = session.Load<Produkt>(produkt.Id);
Assert.That(produkt2, Is.SameAs(produkt));
var produkt3 = session.Load<Produkt>(produkt.Id);
Assert.That(produkt3, Is.SameAs(produkt));
}
using (var session = store.OpenSession()) {
var produkt2 = session.Load<Produkt>(produkt.Id);
Assert.That(produkt2, Is.Not.SameAs(produkt));
}
}
Listing 3
Jedes Objekt erhält eine eigene Id.
[Test]
public void Id_wird_durch_Save_erzeugt_aber_Entity_noch_nicht_gespeichert() {
var store = new DocumentStore {Url = "http://localhost:8080"};
store.Initialize();
var produkt = new Produkt();
Assert.That(produkt.Id, Is.Null);
using (var session = store.OpenSession()) {
session.Store(produkt);
}
Assert.That(produkt.Id, Is.Not.Null);
Produkt result;
using (var session = store.OpenSession()) {
result = session.Load<Produkt>(produkt.Id);
}
Assert.That(result, Is.Null);
}
33
LÖSUNG
sich tatsächlich aber auf dasselbe Dokument beziehen. Um zu dokumentieren, wie
sich RavenDB in diesem Punkt verhält,
dient der in Listing 2 gezeigte Test.
Der Test zeigt, was passiert, wenn man
innerhalb einer Session ein Dokument
mehrfach liest. Die Session liefert jeweils
identische Objekte. Im zweiten Teil des
Tests ist zu sehen, dass dies nur innerhalb
einer Session gilt. Objekte, die in der zweiten Session geladen werden, beziehen sich
zwar auf dasselbe Dokument, es wird jedoch nicht dasselbe Objekt geliefert.
Dieser Test wirft gleich eine weitere Frage auf: Offensichtlich sorgt RavenDB dafür,
dass das Dokument eine Id erhält, über die
es später wieder aus der Datenbank geholt
werden kann. Doch zu welchem Zeitpunkt
passiert das? Wird die Id bereits bei session.Store gebildet oder erst bei session.SaveChanges? Die Frage ist insofern wichtig,
als davon abhängt, wie viele Zugriffe auf
den Server erforderlich sind. Ferner hängt
davon ab, zu welchem Zeitpunkt man die
Id verwenden kann, um Referenzen zwischen Dokumenten herzustellen (auch
wenn man dies vermeiden sollte, es ist
schließlich keine relationale Datenbank).
Der in Listing 3 gezeigte Test dokumentiert
das Verhalten von RavenDB: Die Id wird
bereits bei session.Store gebildet.
Erst mit session.SaveChanges werden die
Änderungen zur Datenbank übertragen.
Die Id wird bei session.Store lokal gebildet,
dazu ist keine Kommunikation mit dem
Server erforderlich. Natürlich kann die Id
auch vorgegeben werden. Ist dies der Fall,
erzeugt RavenDB keine Id, sondern übernimmt die vorhandene. Natürlich muss
diese eindeutig sein, andernfalls wird das
bereits vorhandene Dokument von RavenDB einfach überschrieben.
Das Laden eines Dokumentes ist in den
obigen Tests bereits zu sehen: Mittels
session.Load laden Sie ein Dokument, dessen Id bekannt ist. Dabei ist der Typ des
Dokuments in Form eines generischen
Methodenparameters anzugeben, damit
RavenDB weiß, welcher Typ instanziert
werden soll. Damit hätten wir Create und
Retrieve aus CRUD gelöst. Wie sieht es mit
Update aus? Ganz einfach: Wenn Sie
session.Store mit einem Objekt aufrufen,
das bereits eine Id hat, dann sorgt Ra-
venDB dafür, dass das Dokument entweder neu angelegt oder aktualisiert wird.
Dies soll in einem größeren Kontext gezeigt
werden. In einer Anwendung wird man RavenDB nicht unmittelbar verwenden, da es
sich bei der Datenbank um eine externe
Ressource handelt. Der Kern der Anwendung sollte generell über einen Adapter
von Ressourcenzugriffen isoliert werden.
Dies wurde in zurückliegenden Artikeln
der Dojo-Serie bereits thematisiert. Für
Datenbanken wird hier der Begriff „Repository“ verwendet. Um zu sehen, wie so ein
Repository, realisiert mit RavenDB, aussehen kann, habe ich ein solches bei meinen weiteren Spike-Schritten implementiert. Der Vorteil: Jetzt ist das Repository die
Stelle, an der die Konfiguration des DocumentStore erfolgt. Innerhalb eines Repositorys kann über den darin vorhandenen
DocumentStore jeweils bei Bedarf eine DocumentSession eröffnet werden. Listing 4
zeigt den Test für Updates, und Listing 5
zeigt die Implementation des Repositorys.
Als letzte CRUD-Operation steht das Löschen an. Dazu verfügt DocumentSession
über die Methode Delete, der das zu lö-
LÖSUNG
Listing 4
Ein Update überprüfen.
[TestFixture]
public class ProductRepositoryTests
{
private ProduktRepository sut;
[SetUp]
public void Setup() {
sut = new ProduktRepository();
}
[Test]
public void Ein_Produkt_ändern() {
var produkt = new Produkt {
Id = "#1",
Name = "iPad",
Kategorie = "Zeugs"
};
sut.Save(produkt);
produkt.Kategorie = "Gadget";
sut.Save(produkt);
var result = sut.Load("#1");
Assert.That(result.Kategorie,
Is.EqualTo("Gadget"));
}
}
schende Objekt übergeben wird. Dabei
stellte sich mir die Frage, wie man ein Dokument aus der Datenbank löscht, dessen
Id man kennt, das aber nicht als Objekt geladen wurde. Natürlich kann man das Objekt zuerst über seine Id laden, um es dann
an Delete zu übergeben. Dabei fallen aber
zwei Zugriffe auf den Server an, das sollte
doch auch mit einem Zugriff zu machen
sein. Das Löschen über die Id ist im ClientAPI nicht vorgesehen. Das bedeutet aber
nicht, dass es unmöglich ist. Das Client-API
ist nur ein Wrapper, der über das HTTPProtokoll gelegt ist, und in diesem API ist
Löschen per Id nicht vorgesehen. Wie man
das API erweitert, habe ich mir allerdings
in diesem Spike nicht weiter angesehen.
Konkurrierende Zugriffe
Beim Erforschen des APIs ist mir an dieser
Stelle die Frage gekommen, wie RavenDB
sich bei konkurrierenden Zugriffen verhält.
Damit meine ich Zugriffe, die in zwei unterschiedlichen Sessions stattfinden. Stellen
Sie sich dazu eine Anwendung vor, von der
mehrere Instanzen laufen. Was passiert,
wenn ein Dokument aus der Datenbank von
beiden Anwendern geladen und verändert
wird. Merkt RavenDB das beim Update? Um
die Frage zu klären, habe ich einen Test geschrieben. Um dabei mit zwei Sessions ar-
www.dotnetpro.de dotnetpro.dojos.2011
beiten zu können, habe ich die Sessions ineinandergeschachtelt. Das heißt, während
die erste Session noch aktiv ist, wird innerhalb des using-Blocks eine zweite Session
geöffnet, um dort ein Update vorzunehmen. Dann wird aus der ersten Session
ebenfalls ein Update abgesetzt.
Das Verhalten von RavenDB hängt an
dieser Stelle davon ab, ob die Session Optimistic Concurrency unterstützen soll. Dies
kann man per Session einstellen, standardmäßig ist es abgestellt. Das bedeutet, wenn
man keine weitere Vorkehrung trifft, werden Konflikte bei konkurrierenden Zugrif-
Listing 5
Das Repository implementieren.
public class ProduktRepository {
private readonly DocumentStore store;
public ProduktRepository() {
store = new DocumentStore {Url = "http://localhost:8080"};
store.Initialize();
}
public void Save(Produkt produkt) {
using (var session = store.OpenSession()) {
session.Store(produkt);
session.SaveChanges();
}
}
public Produkt Load(string id) {
using (var session = store.OpenSession()) {
var result = session.Load<Produkt>(id);
return result;
}
}
}
Listing 6
Konkurrierende Zugriffe erkennen.
[Test]
public void Optimistic_Concurrency_bei_Updates_in_mehreren_Sessions() {
var store = new DocumentStore {Url = "http://localhost:8080"};
store.Initialize();
// Initialzustand der Datenbank herstellen
var produkt = new Produkt {Name = "a"};
using (var session = store.OpenSession()) {
session.Store(produkt);
session.SaveChanges();
}
// Erste Session lädt das Dokument als 'referenz1'
using (var session1 = store.OpenSession()) {
session1.UseOptimisticConcurrency = true;
var referenz1 = session1.Load<Produkt>(produkt.Id);
// Zweite Session lädt das Dokument als 'referenz2'
// und modifiziert es
using (var session2 = store.OpenSession()) {
var referenz2 = session2.Load<Produkt>(produkt.Id);
Assert.That(referenz2, Is.Not.SameAs(referenz1));
referenz2.Name = "b";
session2.SaveChanges();
}
// 'referenz1' hat die Änderungen aus der zweiten
// Session noch nicht gesehen, daher Bumm!
referenz1.Name = "c";
Assert.Throws<ConcurrencyException>(session1.SaveChanges);
}
}
35
LÖSUNG
fen nicht erkannt und Updates einfach der
Reihe nach ausgeführt. Um diese Erkennung zu aktivieren, müssen Sie in der Session die Option UseOptimisticConcurrency
auf true setzen. Listing 6 zeigt den Test für
die Erkennung konkurrierender Zugriffe.
Um die ConcurrencyException zu vermeiden, können Sie das Objekt vor der Änderung mit session.Refresh(referenz1) auf
den aktuellen Stand bringen. Allerdings gehen damit natürlich alle Änderungen verloren, die am Objekt zuvor bereits vorgenommen wurden. Eine Strategie könnte
dann sein, das Refresh nur dann auszuführen, wenn die ConcurrencyException tatsächlich aufgetreten ist. Dann könnten die
Änderungen, die in den beiden konkurrierenden Sessions vorgenommen wurden,
zusammengeführt werden. Wie das im Einzelnen geschieht, hängt von der Business
Domain ab.
Query
Der nächste Schritt in meinem Spike sollte
die Frage klären, wie man Dokumente aus
der Datenbank lesen kann, die bestimmte
Anforderungen erfüllen. Ich wollte zum Beispiel alle Produkte einer Kategorie ermitteln. In einer relationalen Datenbank muss
man dazu lediglich das passende SELECTKommando absetzen. Bei RavenDB ist es
zunächst erforderlich, einen Index anzulegen. Das liegt daran, dass die RavenDB-Datenbank Dokumente als JSON-Strings [4]
speichert. Damit entziehen sie sich einem
effizienten suchenden Zugriff, denn das
würde bedeuten, dass bei jeder Suche die
JSON-Strings aller Dokumente interpretiert
werden müssten. Bei einer relationalen
Datenbank ist die Suche nur möglich, weil
es dort von vornherein ein Schema gibt,
welches dafür sorgt, dass die Daten in Spalten abgelegt werden. Dieses Schema fehlt in
RavenDB ganz bewusst. Um einen Index
anzulegen, muss man eine LINQ-Query
definieren, die angibt, welche Eigenschaften des Objekts in den Index aufgenom-
Listing 7
Einen Index erstellen.
store.DatabaseCommands.PutIndex(
"ProdukteNachKategorie",
new IndexDefinition<Produkt> {
Map = produkte =>
from produkt in produkte
select new {produkt.Kategorie}
});
36
Listing 8
Alle Produkte einer Kategorie suchen.
using (var session = store.OpenSession()) {
var result = session.LuceneQuery<Produkt>("ProdukteNachKategorie")
.Where(string.Format("Kategorie:{0}", kategorie))
.ToArray();
return result;
}
Listing 9
Die Produktsuche testen.
[Test]
public void Alle_Produkte_einer_Kategorie_ermitteln() {
sut.Save(new Produkt {Id = "#1", Name = "iPad", Kategorie = "Elektronik"});
sut.Save(new Produkt {Id = "#2", Name = "iPod", Kategorie = "Elektronik"});
sut.Save(new Produkt {Id = "#3", Name = "Apfel", Kategorie = "Obst"});
sut.Save(new Produkt {Id = "#4", Name = "Kartoffel", Kategorie = "Gemüse"});
sut.Save(new Produkt {Id = "#5", Name = "Banane", Kategorie = "Obst"});
var result = sut.ProdukteDerKategorie("Elektronik");
Assert.That(result.Select(x => x.Id).ToArray(), Is.EquivalentTo(new[] {"#1", "#2"}));
Assert.That(result.Select(x => x.Name).ToArray(), Is.EquivalentTo(new[] {"iPad", "iPod"}));
Assert.That(result.Select(x => x.Kategorie).ToArray(), Is.EquivalentTo(new[]
{"Elektronik", "Elektronik"}));
}
men werden sollen. Ferner muss der Index
einen Namen erhalten, damit man ihn bei
der Suche benennen kann. Listing 7 zeigt,
wie Sie einen Index für den Zugriff auf alle
Produkte einer Kategorie erstellen.
Das Erstellen dieses Index muss einmalig erfolgen. RavenDB sorgt dafür, dass der
Index jeweils aktualisiert wird, wenn sich
die zugehörigen Daten ändern. Soll die Definition des Index geändert werden, muss
man ihn zunächst löschen. Das geht ganz
einfach:
store.DatabaseCommands.DeleteIndex(
"ProdukteNachKategorie");
Wenn man, wie in den bisherigen Beispielen gezeigt, anonym auf den RavenDBServer zugreift, trifft man an dieser Stelle
auf ein Problem: Der Server verweigert das
Anlegen des Index. Da das Erstellen von
Dokumenten gestattet ist, habe ich mich
zunächst gewundert. Die Lösung ist auf
zwei Wegen möglich: Entweder man übergibt beim Öffnen einer Session Anmeldedaten, sogenannte Credentials, oder man
erlaubt auch anonymen Nutzern den vollständigen Zugriff. In einer lokalen Testumgebung ist es einfacher, den Server zu öffnen. Im Produktivbetrieb sollte man das
natürlich keinesfalls tun. Um anonyme Zugriffe für sämtliche Operationen zu berechtigen, muss man im Server-Verzeichnis
die Datei RavenDb.exe.config bearbeiten.
Darin muss man in der folgenden Zeile das
Get durch ein All ersetzen :
<add key="Raven/AnonymousAccess"
value="Get"/>
Anschließend müssen Sie den RavenDBServer-Dienst neu starten.
Doch zurück zum Index. Der wichtigste
Teil beim Erstellen des Index ist die LINQQuery, die für den Map-Vorgang zuständig
ist. Diese Query wird von RavenDB für jedes Dokument ausgeführt. Das Ergebnis
der Query, in diesem Fall ein Objekt mit der
Eigenschaft Kategorie, wird in den Index
übernommen. Der durch die Map-Funktion ermittelte Wert dient im Index als
Schlüssel. Im Ergebnis sind zu einer gegebenen Kategorie im Index Referenzen auf
die betreffenden Produkte abgelegt. Damit
wird eine Suche nach sämtlichen Produkten einer Kategorie möglich, siehe dazu
Listing 8.
Wichtig ist hier der Aufruf von ToArray().
Da die Query innerhalb einer Session aufgerufen wird, die mit Verlassen des using-
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Blocks geschlossen wird, muss das tatsächliche Lesen der Daten innerhalb der Session passieren. Lässt man das ToArray()
weg, findet das Lesen erst beim Iterieren
durch das Ergebnis statt, dann allerdings
zu einem Zeitpunkt, da die Session bereits
geschlossen ist. RavenDB verwendet für
die Indizierung übrigens Lucene, was
ebenfalls ein Open-Source-Projekt ist [5].
Listing 9 zeigt, wie Sie die so erstellte Methode zum Ermitteln aller Produkte einer
Kategorie gegen eine Datenbank testen
können.
Auch hier gilt es, noch einen weiteren
Stolperstein zu beachten. RavenDB führt
das Aktualisieren der Indizes im Hintergrund aus. Es kann daher sein, dass der Index zum Zeitpunkt des Lesevorgangs noch
nicht aktualisiert ist. Zu Testzwecken kann
man es bei Anwendung der Query mit der
Methode WaitForNonStaleResults() erzwingen, dass auf die Aktualisierung des
Index gewartet wird, bevor Ergebnisse geliefert werden. In einer Produktivumgebung sollten Sie diese Option allerdings
nicht verwenden, da sie mit Performance-
einbußen verbunden ist. An dieser Stelle
zeigt es sich, dass Dokumentdatenbanken
einen anderen Schwerpunkt setzen als relationale Datenbanken. Bei einer relationalen Datenbank geht die Konsistenz der Daten immer vor. Bei Dokumentdatenbanken
wie RavenDB dagegen steht die Konsistenz
hinter Skalierbarkeit und Ausfallsicherheit
zurück.
Map/Reduce
Als Nächstes habe ich mir ein Feature angeschaut, das bei Dokumentdatenbanken
sehr häufig zum Einsatz kommt und großen Einfluss auf die Skalierbarkeit hat:
Map/Reduce. Eine Map-Funktion wurde
bereits für den Index benötigt, mit dem alle Produkte einer Kategorie ermittelt werden können. Fügt man einem solchen Index noch eine Reduce-Methode hinzu,
können Daten aus den Dokumenten aggregiert werden. Damit ist es möglich, beispielsweise einen Index zu erstellen, der
aus den Produkten alle Kategorien ermittelt. Zusätzlich ist es durch die Aggregation
möglich zu zählen, wie viele Produkte in
Listing 10
Map und Reduce verwenden.
store.DatabaseCommands.PutIndex(
"ProduktKategorien",
new IndexDefinition<Produkt, KategorieMitAnzahl> {
Map = produkte => from produkt in produkte
select new { produkt.Kategorie, Anzahl = 1 },
Reduce = results => from result in results
group result by result.Kategorie
into g
select new { Kategorie = g.Key, Anzahl = g.Sum(x => x.Anzahl) }
});
Listing 11
Den Index testen.
[Test]
public void Alle_Kategorien_ermitteln() {
sut.Save(new Produkt {Id = "#1", Name = "iPad", Kategorie = "Elektronik"});
sut.Save(new Produkt {Id = "#2", Name = "iPod", Kategorie = "Elektronik"});
sut.Save(new Produkt {Id = "#3", Name = "Apfel", Kategorie = "Obst"});
sut.Save(new Produkt {Id = "#4", Name = "Kartoffel", Kategorie = "Gemüse"});
sut.Save(new Produkt {Id = "#5", Name = "Banane", Kategorie = "Obst"});
var result = sut.Kategorien();
Assert.That(result.ToArray(), Is.EquivalentTo(new[] {
new KategorieMitAnzahl { Kategorie = "Elektronik", Anzahl = 2},
new KategorieMitAnzahl { Kategorie = "Obst", Anzahl = 2},
new KategorieMitAnzahl { Kategorie = "Gemüse", Anzahl = 1},
}));
}
www.dotnetpro.de dotnetpro.dojos.2011
der jeweiligen Kategorie enthalten sind.
Das Erstellen dieses Index gleicht dem vorhergehenden Beispiel. Der wesentliche
Unterschied besteht in der zusätzlichen
Reduce-LINQ-Query. Dabei liegt das
Hauptproblem darin, dass man darauf
achten muss, dass Map- und Reduce-Query auf gleich aufgebauten Objekten arbeiten. Da hier anonyme Typen verwendet
werden, ist das Unterfangen fehleranfällig,
siehe Listing 10.
Die Map-Query liefert für jedes Produkt
ein Objekt zurück. Dieses Objekt hat zwei
Eigenschaften: Kategorie und Anzahl. Die
Anzahl ist immer 1, dies ist der Startwert
für die spätere Aufsummierung. In der Reduce-Query wird nun auf Objekten vom
Typ KategorieMitAnzahl gearbeitet. Der
Typ muss als zweiter generischer Typparameter im Konstruktor von IndexDefinition
angegeben werden.
Die Herausforderung liegt darin, dass
man in den beiden LINQ-Queries anonyme Typen verwenden muss. Wenn diese
Queries Ergebnisse vom Typ KategorieMitAnzahl liefern, erhält man eine Fehlermeldung vom RavenDB-Server, die besagt,
man müsse anonyme Typen verwenden.
Dennoch müssen diese anonymen Typen
den gleichen Aufbau haben wie der definierte Typ. Wenn man diese Hürde genommen hat, steht einem Test des Index nichts
mehr im Weg, wie Listing 11 zeigt.
Das Schöne an diesem Index ist, dass RavenDB ihn jeweils auf dem aktuellen Stand
hält. Jede Änderung an Dokumenten, die
im Index verwendet werden, führt zu einem entsprechenden Update des Index.
Fazit
Ich habe mir in diesem Spike die Funktionalität von RavenDB nur ausschnittweise
angesehen. RavenDB hat darüber hinaus
noch mehr zu bieten wie etwa die Verteilung einer Datenbank auf mehrere Server.
Die ersten Schritte gingen zügig voran.
Aber beim Map/Reduce hat es doch etwas
länger gedauert, bis ich die zu berücksichtigenden Konventionen alle zusammenhatte. Hier wäre ein Beispiel in der Dokumentation sicherlich hilfreich. Gelernt habe ich wieder einiges, und das war schließ[ml]
lich Zweck der Übung.
[1] http://clean-code-developer.de
[2] http://ravendb.net/
[3] http://builds.hibernatingrhinos.com/builds/
ravendb
[4] http://de.wikipedia.org/wiki/JSON
[5] http://lucene.apache.org/lucene.net/
37
AUFGABE
Algorithmen und Datenstrukturen
Was ist im Stapel?
In den Zeiten der großen Programmier-Frameworks geht leicht das Wissen um die grundlegenden
Algorithmen und Datenstrukturen verloren. Stefan, kannst du mal eine Aufgabe stellen, die zu den Wurzeln
der Programmierung zurückführt?
Wer übt, gewinnt
dnpCode: A1009DojoAufgabe
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
atürlich ist mir bekannt, dass es
im .NET Framework eine Klasse
Stack<T> gibt. Aus diesem Grund
muss man eine solche elementare
Datenstruktur nicht mehr selbst implementieren. Aber gerade weil die Funktionalität so gut
bekannt ist, bietet sich ein Stack als Übung an.
Hier können Sie sich voll auf den Entwurf einer
Lösung konzentrieren und anschließend testgetrieben implementieren.
Die Aufgabe soll gelöst werden, ohne dass vorhandene Datenstrukturen aus dem .NET Framework verwendet werden. Aus einer Liste einen
Stack zu machen scheidet also aus. Und natürlich
soll der Stack generisch sein. Das bedeutet, dass
man den Typ der Elemente als generischen Typparameter angeben kann. Ein Stack für IntegerElemente wird also folgendermaßen instanziert:
N
Stack zur Laufzeit abzubilden. Die Verwendung
von Collections aus dem .NET Framework scheidet aus. Auch ein Array scheidet aus, da der Stack
keine Größenbeschränkung haben soll.
Die in einem Stack angewandte Strategie beim
Entnehmen eines Elementes lautet: Last In/First
Out, abgekürzt LIFO. Das Element, welches als
letztes in den Speicher gegeben wurde, wird als
erstes entnommen. Eine andere Strategie ist die
FIFO-Strategie: First In/First Out. Hier wird das
Element, welches als erstes gespeichert wurde,
auch wieder als erstes entnommen. Kommt Ihnen bekannt vor? Ja, so funktioniert die Schlange
an der Kasse im Supermarkt.
Und damit sind wir beim zweiten Teil der
Übung: Implementieren Sie eine Warteschlange,
engl. Queue. Das Interface der zu implementierenden Methoden sieht folgendermaßen aus:
var stack = new Stack<int>();
public interface IQueue<TELement>
{
void Enqueue(TELement element);
TELement Dequeue();
}
Dabei stellt der Typ int in spitzen Klammern
den generischen Typparameter dar. Alle Elemente des Stacks sind somit vom Typ int. Die zwei
Operationen auf dem Stack sind schnell erklärt:
❚ Mit Push(element) kann ein Element oben auf
den Stack gelegt werden. Jede weitere PushOperation legt ein weiteres Element obendrauf.
❚ Das oberste Element des Stacks kann mit der
Pop()-Operation wieder vom Stack entfernt
werden. Die Pop()-Operation macht also genau
genommen zwei Dinge: Sie liefert das oberste
Element an den Aufrufer und entfernt es vom
Stack.
Hier die Signaturen der beiden Methoden in
Form eines Interfaces:
public interface IStack<TElement> {
void Push(TElement element);
TElement Pop();
}
In diesem Interface ist TElement der generische Typ. Er wird jeweils durch den konkreten
Typ ersetzt. Im obigen Beispiel ist TElement mit
dem Typ int belegt.
Ein Tipp zur Implementierung : Überlegen Sie
sich, welche Datenstruktur geeignet ist, einen
38
Auch hier gilt: Überlegen Sie sich eine Datenstruktur, mit welcher die Aufgabenstellung gelöst
werden kann. Wie immer hilft es, sich dazu ein
Blatt Papier zu nehmen. Oder lösen Sie die
Übung mit Kollegen gemeinsam im Team, und
planen Sie am Whiteboard. Die testgetriebene
Entwicklung wird häufig so verstanden, dass
man einfach mit einem ersten Test loslegt und
sich von da an alles schon irgendwie ergeben
wird. Ich halte das für falsch. Ein bisschen Planung vor dem Codieren schadet nicht, ganz im
Gegenteil.
Wer sich mit der Übung unterfordert fühlt,
kann übrigens noch eine weitere Methode auf
Queue<T> ergänzen:
void Reverse();
Diese Methode soll die Reihenfolge der Elemente in der Warteschlange umkehren. Dazu
sollen die Verweise zwischen den Elementen so
verändert werden, dass die Queue „in place“ verändert wird. Wie immer gilt: test first! Viel Spaß.
[ml]
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Stack und Queue implementieren
Der Nächste bitte!
Immer hübsch der Reihe nach: Das gilt nicht nur im Wartezimmer, sondern auch im Stack und in der Queue der
Informatiker. Und wer sich das Entwicklerleben vereinfachen will, sollte auch bei ihrer Implementierung
die richtige Reihenfolge einhalten: Erst planen, dann Tests entwickeln, dann implementieren.
A
ls Entwickler nehmen wir
Stack und Queue, wie viele
andere Datenstrukturen, als
selbstverständlich hin, sind
sie doch im .NET Framework enthalten.
Weil ihre Funktionsweise so einfach ist, besteht sicherlich die Versuchung, direkt mit
der Implementierung zu beginnen. Doch
schon kommen die ersten Fragen um die
Ecke: Wie soll der erste Test aussehen? Wie
testet man einen Stack überhaupt, das
heißt, kann man Push isoliert testen? Oder
kann man Push nur testen, indem man Pop
ebenfalls testet?
Bevor Sie versuchen, diese Fragen zu beantworten, sollten Sie den Konsolendeckel
besser erst mal schließen und zu einem
Stück Papier greifen. Denn die zentrale Frage vor dem ersten Test lautet, wie denn die
interne Repräsentation des Stacks überhaupt aussieht. Dies mit Papier und Bleistift zu planen ist einfacher, als es einfach
so im Kopf zu tun. Dabei übersieht man
schnell mal ein Detail.
Ein Stack muss in der Lage sein, jeweils
das oberste Element zu liefern. Das ist die
Aufgabe der Pop-Methode. Nachdem das
oberste Element geliefert wurde, muss der
Stack beim nächsten Mal das nächste Element liefern, das also unmittelbar auf das
oberste folgt. Daraus ergibt sich die Notwendigkeit, innerhalb des Stacks jeweils zu
wissen, welches das oberste Element ist.
Ferner muss zu jedem Element bekannt
sein, welches das nächste Element ist. So ergibt sich eine ganz einfache interne Datenstruktur für den Stack, siehe Abbildung 1.
Hat man diese Datenstruktur erst einmal
aufgemalt, ist es ein Leichtes, sie in Code
zu übersetzen. Aber Achtung, den Test
nicht vergessen! Bei einem Stack bietet es
sich an, zwei unterschiedliche Zustände zu
betrachten:
❚ einen leeren Stack,
❚ einen nicht leeren Stack.
In solchen Fällen erstelle ich für die unterschiedlichen Szenarien gerne getrennte
www.dotnetpro.de dotnetpro.dojos.2011
[Abb. 1] top- und
Listing 1
next-Zeiger im
Stack.
Testklassen. Dann kann nämlich das Setup
des Tests dafür sorgen, dass das Szenario
bereitgestellt wird. Die Testklasse, welche
sich mit einem leeren Stack befasst, instanziert einfach einen neuen Stack. In der Testklasse zum Szenario „nicht leerer Stack“
wird der Stack im Setup gleich mit ein paar
Werten befüllt. So können sich die Tests jeweils auf das konkrete Szenario beziehen.
Doch wie sieht nun der erste Test aus?
Ich habe mich entschieden, den ersten Test
zu einem leeren Stack zu erstellen. Es erscheint mir nicht sinnvoll, bei dem Szenario eines nicht leeren Stacks zu beginnen,
weil dann vermutlich für den ersten Test
bereits sehr viel Funktionalität implementiert werden muss. Ich möchte lieber in
kleinen Schritten vorgehen, um sicher zu
sein, dass ich wirklich nur gerade so viel
Code schreibe, dass ein weiterer Test erfolgreich verläuft.
Für den ersten Test zu einem leeren Stack
überlege ich mir, wie die interne Repräsentation eines leeren Stacks aussehen soll, und
komme zu dem Schluss, dass der Zeiger, der
auf das erste Element verweist, null sein soll.
Listing 1 zeigt den ersten Test. In der SetupMethode der Testklasse wird ein leerer Stack
für int-Elemente instanziert. Der erste Test
prüft, ob dann der top-Zeiger gleich null ist.
Nun wird vielleicht dem einen oder anderen der Einwand im Kopf herumkreisen,
dass damit ja erstens die Sichtbarkeit der
internen Repräsentation nach außen getragen wird und zweitens Interna getestet
werden. Zur Sichtbarkeit sei gesagt, dass
Einen leeren Stack testen.
[TestFixture]
public class Ein_leerer_Stack {
private Stack<int> sut;
[SetUp]
public void Setup() {
sut = new Stack<int>();
}
[Test]
public void Hat_kein_top_Element() {
Assert.That(sut.top, Is.Null);
}
}
ich das Feld top auf internal setze. Damit
ist es zunächst nur innerhalb der Assembly
sichtbar, in der die Stack-Implementierung
liegt. Durch ein Attribut in der Datei AssemblyInfo.cs wird die Sichtbarkeit dann
dosiert erweitert auf die Testassembly. Das
Attribut sieht wie folgt aus :
[assembly:
InternalsVisibleTo("stack.tests")]
Damit kann nun auch aus der Testassembly auf die Interna zugegriffen werden. Und schon sind wir beim zweiten Einwand, dass nun diese Interna im Test verwendet werden. Das halte ich für vernachlässigbar. Sicher ist es erstrebenswert, Tests
so zu schreiben, dass sie möglichst wenige
Abhängigkeiten zu den Interna der Klasse
haben. Denn wenn nur über die öffentliche
Schnittstelle getestet wird, sind die Tests
Listing 2
Datenstruktur für die StackElemente.
internal class Element<TData> {
public TData Data { get; set; }
public Element<TData> Next { get; set; }
}
39
LÖSUNG
Listing 3
Erste Stack-Implementierung.
public class Stack<TElement> :
IStack<TElement> {
internal Element<TElement> top;
public void Push(TElement element) {
throw new NotImplementedException();
}
public TElement Pop() {
throw new NotImplementedException();
}
}
weniger zerbrechlich. Allerdings sind sie
dann häufig weniger fokussiert. Im Fall eines Stacks stellt sich nämlich die Frage, wie
man Push ausschließlich über die öffentliche Schnittstelle testen kann, ohne dabei
andere Methoden des Stacks zu verwenden. Natürlich wird man auch einen Test
schreiben, der Push und Pop in Beziehung
setzt, und beide Methoden in einem Test
verwenden. Aber gerade bei den ersten
Schritten der Implementierung ist es vorteilhaft, wenn man eine einzelne Methode
isoliert betrachten kann. Greift man dabei
auf Interna zu, dann ist dies möglich. Um
mit der Implementierung weiterzukommen, müssen Sie überlegen, von welchem
Typ der top-Zeiger sein soll. Das ist dank
der Skizze ganz einfach. Denn aus der Skizze ergibt sich, dass jedes Element im Stack
neben den Daten einen Zeiger auf das
nächste Element hat. Folglich ist der topZeiger einfach das erste Element im Stack.
Listing 2 zeigt die Datenstruktur für die
Elemente. Da diese Datenstruktur außerhalb des Stacks nicht in Erscheinung tritt,
wird sie durch die Sichtbarkeit internal verborgen. Wie weiter oben erwähnt, kann
dennoch in Tests darauf zugegriffen werden. Listing 3 zeigt die Implementierung
des Stacks für den ersten Test.
Die Methoden Push und Pop werden eigentlich noch nicht benötigt, sind aber
syntaktisch erforderlich aufgrund des Interfaces IStack<T>.
Nun stand ich vor der Wahl, ob ich als
Nächstes mit Push oder Pop weitermachen wollte. Ich halte Push für naheliegender, denn bei einem leeren Stack wird Pop
ohnehin nur zu einer Ausnahme führen.
Gerade zu Beginn der Implementierung
Listing 4
Erster Test für Push.
[Test]
public void Macht_das_mit_Push_übergebene_Element_zum_top_Element() {
sut.Push(5);
Assert.That(sut.top.Data, Is.EqualTo(5));
}
Listing 5
Der Next-Zeiger ist null.
[Test]
public void Enthält_nach_einem_Push_nur_dieses_eine_Element() {
sut.Push(5);
Assert.That(sut.top.Next, Is.Null);
}
Listing 6
Push und Pop testen.
[Test]
public void Kann_ein_Element_aufnehmen_und_wieder_abliefern() {
sut.Push(5);
Assert.That(sut.Pop(), Is.EqualTo(5));
}
40
möchte ich weiterkommen und mich
nicht mit Rand- und Fehlerfällen befassen.
Aber das ist sicherlich Geschmackssache.
Listing 4 zeigt den ersten Test für Push.
Ferner kann man beim ersten Push feststellen, dass das übergebene Element das
einzige auf dem Stack ist, der Next-Zeiger
also null ist, siehe Listing 5.
Ob man dies tatsächlich in einem eigenständigen Test überprüft oder ein zweites
Assert im vorhergehenden Test zulässt, sei
dahingestellt. Ich habe mich für zwei getrennte Tests entschieden, weil ich keine
treffende Bezeichnung für einen Test finden konnte, der beides prüft. Die Regel,
nur ein Assert pro Test zuzulassen, halte ich
jedenfalls für dogmatisch. Ein Test sollte
sich mit einer Sache befassen. Wenn diese
eine Sache mit mehr als einem Assert überprüft werden muss, finde ich das völlig in
Ordnung.
An dieser Stelle kann man mit einem leeren Stack nicht viel mehr anstellen, außer
nun doch Push und Pop in Beziehung zu
setzen. Also sieht mein nächster Test so aus
wie in Listing 6. Bei diesem Test liegt die
Versuchung nahe, auch noch zu prüfen, ob
der Stack nach dem Pop auch wieder leer
ist. Doch diesen Test habe ich in den Szenarien angesiedelt, die sich mit einem
nicht leeren Stack befassen. Bis hierher besteht die Implementierung nur darin, bei
Push ein neues top-Element zu erzeugen
und dieses bei Pop als Ergebnis zu liefern.
Der wichtigste Teil der Implementierung
folgt nun bei den Szenarien mit nicht leerem Stack.
Das Szenario wird in der Setup-Methode
dadurch hergestellt, dass der Stack direkt
mit einem Element gefüllt wird. Somit kann
Listing 7
Pop testen.
[TestFixture]
public class Ein_Stack_mit_einem_Element
{
private Stack<string> sut;
[SetUp]
public void Setup() {
sut = new Stack<string>();
sut.Push("a");
}
[Test]
public void
Kann_das_top_Element_liefern() {
Assert.That(sut.Pop(),
Is.EqualTo("a"));
}
}
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 8
Listing 10
Auf einen leeren Stack testen.
Die fertige Implementierung
des Stacks.
[Test]
public void Enthaelt_nach_der_Entnahme_des_top_Elementes_keine_weiteren_Elemente()
{
sut.Pop();
Assert.That(sut.top, Is.Null);
}
Listing 9
Mehrere Push-Aufrufe.
[Test]
public void Macht_das_naechste_uebergebene_Element_zum_top_Element() {
sut.Push("b");
Assert.That(sut.top.Data, Is.EqualTo("b"));
}
[Test]
public void Legt_das_vorhandene_Element_bei_Uebergabe_eines_weiteren_unter_dieses() {
sut.Push("b");
Assert.That(sut.top.Next.Data, Is.EqualTo("a"));
}
in einem ersten Test geprüft werden, ob
dieses Element bei Aufruf der Pop-Methode
zurückgegeben wird, siehe Listing 7.
Nun kann überprüft werden, ob der
Stack denn nach dem Pop auch wieder leer
ist, siehe Listing 8. Und jetzt hilft alles
nichts, wir müssen uns mit mehr als einem
Push befassen. Dabei kommt es darauf an,
das neue Element vor das bisherige topElement einzuordnen. Dazu muss der topZeiger geändert werden sowie der NextZeiger des top-Elements. Listing 9 zeigt die
entsprechenden Tests.
Daraus ergibt sich dann die fertige Implementierung des Stacks wie in Listing 10.
Reflexion
Die testgetriebene Vorgehensweise hat mir
keine großen Probleme bereitet. Das lag
zum Großteil daran, dass ich meine Skizze
zur Hand hatte. So konnte ich bei Fragen
sofort nachsehen, wie die top- und NextZeiger jeweils aussehen müssen. Und dadurch, dass die Tests auf die Interna zugreifen können, musste ich nicht schon für den
ersten Test gleich zwei Methoden implementieren. Das ist ein großer Vorteil, der
bei einem so kleinen Beispiel wie einem
Stack möglicherweise nicht so deutlich
wird. Ich habe diesen Effekt jedoch schon
in einigen Fällen als vorteilhaft empfunden, bei denen es um den internen Zustand von Klassen ging. Immer da, wo
www.dotnetpro.de dotnetpro.dojos.2011
mehrere Methoden auf einem internen Zustand arbeiten, kann es sich lohnen, den
Zustand für die Tests sichtbar zu machen,
um bei der Implementierung Methode für
Methode vorgehen zu können.
Queue<T>
Bei der Warteschlange bin ich so verfahren
wie schon beim Stack: Ich habe mir überlegt,
wie man eine Warteschlange in einer Datenstruktur darstellen kann. Meine Überlegung
hier: Bei einer Warteschlange sollte es offensichtlich zwei Zeiger geben, die jeweils auf
ein Element verweisen. Zum einen auf das
zuletzt eingefügte Element, um dort bei der
Enqueue-Methode ein weiteres Element ergänzen zu können, sowie einen Zeiger auf
das nächste zu entnehmende Element für
die Dequeue-Methode. In meiner ersten
Skizze malte ich also das erste und letzte
Element einer Warteschlange und verwies
darauf jeweils mit den Zeigern enqueue und
dequeue, wie es Abbildung 2 zeigt.
[Abb. 2]
enqueueund
dequeueZeiger.
public class Stack<TElement> :
IStack<TElement> {
internal Element<TElement> top;
public TElement Pop() {
if (top == null) {
throw new
InvalidOperationException();
}
var result = top.Data;
top = top.Next;
return result;
}
public void Push(TElement element) {
var newTop = new Element<TElement> {
Data = element,
Next = top
};
top = newTop;
}
}
Im Anschluss habe ich überlegt, wie die
Elemente untereinander sinnvoll zu verbinden sind. Dabei gibt es mehrere Möglichkeiten :
❚ Jedes Element zeigt mit Next auf das ihm
folgende.
❚ Jedes Element zeigt mit Prev auf das
hinter ihm liegende.
❚ Jedes Element enthält sowohl Next- als
auch Prev-Zeiger.
Die doppelte Verkettung habe ich nicht
weiter berücksichtigt, da ich vermutete,
dass es auch ohne gehen muss. Dabei stand
nicht der Reflex im Vordergrund, dass eine
doppelte Verkettung mehr Speicher braucht
als eine einfache. Ich dachte eher daran,
dass ich bei doppelter Verkettung beim Einfügen und Entfernen von Elementen zwei
Zeiger korrigieren muss. Das erschien mir in
jedem Fall mühsamer, als nur einen Zeiger
korrigieren zu müssen. Es siegte sozusagen
die pure Faulheit.
Es blieb noch die Frage zu klären, ob
Next- oder Prev-Zeiger sinnvoller sind. Das
habe ich mir wieder anhand meiner Skizze
überlegt. Wenn ein neues Element in die
Warteschlange eingefügt wird, muss enqueue anschließend auf das neue Element
zeigen. Bei Verwendung von Next-Zeigern
muss dann nichts korrigiert werden, bei
Prev-Zeigern muss das bisher erste Element auf das neue erste zurückverweisen.
Beides ist kein Problem, es ergibt sich also
hier noch keine Präferenz für eines der bei-
41
LÖSUNG
den Verfahren. Beim Entfernen eines Elements aus der Warteschlange ist ebenfalls
klar, welches Element geliefert werden
muss, denn darauf verweist ja der dequeueZeiger. Dieser muss anschließend auf das
vorhergehende Element verändert werden.
Wenn die Elemente mit Next jeweils auf das
nächste verweisen, wäre diese Korrektur nur
möglich, indem die gesamte Warteschlange
durchlaufen wird, bis das vorletzte Element
erreicht ist. Bei Verwendung von Prev-Zeigern enthält das letzte Element den benötigten Verweis auf seinen Vorgänger. Damit
war klar: Prev-Zeiger sind hier eindeutig einfacher, siehe Abbildung 3.
Das bedeutete aber auch, dass es für die
Queue<T> eine eigene Klasse Element<T>
geben muss, da beim Stack auf Next gezeigt
wird. Hier bemerkte ich einen weiteren Reflex, nämlich den Versuch, die Klasse Element<T> wiederzuverwenden. Das lag irgendwie nahe, hätte jedoch dazu geführt,
dass Stack<T> und Queue<T> durch eine gemeinsam verwendete Klasse nicht völlig entkoppelt wären. „Glücklicherweise“ kam es
aber durch die unterschiedlichen Anforderungen erst gar nicht zurWiederverwendung.
Doch nun zum ersten Test. Auch bei der
Warteschlange ging es mit einer leeren
Queue los. Diese zeichnet sich dadurch
aus, dass enqueue- und dequeue-Zeiger
beide null sind, siehe Listing 11.
Der nächste Test sollte ausdrücken, was
beim Hinzufügen des ersten Elements in
die Warteschlange passiert. Beide Zeiger
verweisen dann nämlich auf das neue Element, siehe Listing 12.
Als Nächstes kam wieder ein Test der öffentlichen Schnittstelle, der prüft, was bei
der Entnahme eines Elements aus der Warteschlange passiert. Zum einen wird das
einzige Element der Warteschlange zurückgegeben, zum anderen ist die Warte-
Listing 11
Listing 12
Ein erstes Element hinzufügen.
[Test]
public void Setzt_bei_Enqueue_enqueue_und_dequeue_auf_das_neue_Element() {
sut.Enqueue(42);
Assert.That(sut.enqueue.Data, Is.EqualTo(42));
Assert.That(sut.dequeue.Data, Is.EqualTo(42));
}
Listing 13
Ein Element entnehmen.
[Test]
public void Liefert_bei_Dequeue_das_zuvor_mit_Enqueue_übergebene_Element() {
sut.Enqueue(42);
Assert.That(sut.Dequeue(), Is.EqualTo(42));
}
[Test]
public void Ist_nach_Entnahme_eines_zuvor_übergebenen_Elements_wieder_leer() {
sut.Enqueue(42);
sut.Dequeue();
Assert.That(sut.enqueue, Is.Null);
Assert.That(sut.dequeue, Is.Null);
}
Listing 14
Ein weiteres Element hinzufügen.
[Test]
public void
Nach_Aufnahme_eines_weiteren_Elements_zeigt_dequeue_immer_noch_auf_das_erste_Element() {
sut.Enqueue("b");
Assert.That(sut.dequeue.Data, Is.EqualTo("a"));
}
[Test]
public void Nach_Aufnahme_eines_weiteren_Elements_zeigt_enqueue_auf_das_neue_Element() {
sut.Enqueue("b");
Assert.That(sut.enqueue.Data, Is.EqualTo("b"));
}
[Test]
public void Nach_Aufnahme_eines_weiteren_Elements_zeigt_dequeue_prev_auf_das_neue_Element() {
sut.Enqueue("b");
Assert.That(sut.dequeue.Prev.Data, Is.EqualTo("b"));
}
Eine leere Queue testen.
[TestFixture]
public class Eine_leere_Queue {
private Queue<int> sut;
[SetUp]
public void Setup() {
sut = new Queue<int>();
}
[Test]
public void Ist_leer() {
Assert.That(sut.enqueue, Is.Null);
Assert.That(sut.dequeue, Is.Null);
}
}
42
schlange dann wieder leer, siehe Listing 13.
Die Implementierung war bis hierher trivial.
Als Nächstes ging es wieder um eine Warteschlange, die bereits ein Element enthält.
Wenn nämlich ein weiteres Element in die
Warteschlange gegeben wird, unterscheiden sich enqueue- und dequeue-Zeiger.
Ferner muss der Prev-Zeiger des schon
enthaltenen Elements gesetzt werden, siehe Listing 14.
Im Anschluss habe ich Tests ergänzt, welche nur die öffentliche Schnittstelle verwen-
den und demonstrieren, wie sich eineWarteschlange verhält, siehe Listing 15. Listing 16
zeigt zu guter Letzt die Implementierung.
Auch hier war, ähnlich wie beim Stack,
die Implementierung keine große Sache.
Die Skizze sowie die Vorüberlegungen zu
den Prev-/Next-Zeigern haben sich gelohnt, da die Implementierung dadurch
leicht von der Hand ging.
Im Anschluss habe ich noch das Umkehren der Elementreihenfolge implementiert.
Dabei zeigte sich wieder die große Stärke
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 15
Listing 16
Listing 17
Die öffentliche Schnittstelle
verwenden.
Die Queue implementieren.
Die Umkehr der Reihenfolge
testen.
[Test]
public void FIFO_verschachtelt() {
sut.Enqueue(1);
Assert.That(sut.Dequeue(),
Is.EqualTo(1));
sut.Enqueue(2);
Assert.That(sut.Dequeue(),
Is.EqualTo(2));
sut.Enqueue(3);
sut.Enqueue(4);
Assert.That(sut.Dequeue(),
Is.EqualTo(3));
Assert.That(sut.Dequeue(),
Is.EqualTo(4));
}
von automatisierten Tests. Die Skizze einer
Warteschlange im Vorher-Nachher-Vergleich war schnell erstellt, siehe Abbildung 4.
Doch bis das Umdrehen der Zeiger korrekt lief, mussten die Tests einige Male
durchlaufen. Ich habe wirklich keine Ahnung, wie man so etwas ohne automatisierte Tests hinkriegen will. Na ja, ich habe
irgendwann auch mal ohne automatisierte
Tests entwickelt. Aber das ist glücklicherweise schon lange her. Listing 17 zeigt den
Test für die Umkehr der Reihenfolge.
Bei der Implementierung habe ich mit
zwei Zeigern gearbeitet, die jeweils auf das
aktuelle in Arbeit befindliche Element (current) sowie das nächste Element (next) verweisen. Da die Elemente jeweils mit Prev
auf ihren Vorgänger verweisen, wird die
Warteschlange von hinten nach vorne abgearbeitet. Daher ist jeweils das Element, welches im zurückliegenden Schleifendurch-
public class Queue<TElement>
{
internal Element<TElement> enqueue;
internal Element<TElement> dequeue;
public void Enqueue(TElement element) {
var newElement = new
Element<TElement> {Data = element};
if (enqueue != null) {
enqueue.Prev = newElement;
}
enqueue = newElement;
if (dequeue == null) {
dequeue = newElement;
}
}
public TElement Dequeue() {
if (dequeue == null) {
throw new
InvalidOperationException();
}
var result = dequeue.Data;
dequeue = dequeue.Prev;
if (dequeue == null) {
enqueue = null;
}
return result;
}
}
[TestFixture]
public class ReverseTests {
private Queue<int> sut;
[SetUp]
public void Setup() {
sut = new Queue<int>();
}
[Test]
public void
Eine_Queue_mit_drei_Elementen_kann
_umgekehrt_werden() {
sut.Enqueue(1);
sut.Enqueue(2);
sut.Enqueue(3);
sut.Reverse();
Assert.That(sut.Dequeue(),
Is.EqualTo(3));
Assert.That(sut.Dequeue(),
Is.EqualTo(2));
Assert.That(sut.Dequeue(),
Is.EqualTo(1));
}
}
Listing 18
Die Reihenfolge umkehren.
lauf bearbeitet wurde, das nächste Element
im Sinne der normalen Vorwärts-Reihenfolge. Am Ende sind noch enqueue und dequeue zu vertauschen, siehe Listing 18.
Die Methode habe ich über die öffentliche Schnittstelle getestet. Man hätte sicherlich auch hier die interne Repräsentation
heranziehen können, ich glaube aber, dass
die Tests dadurch schlecht lesbar geworden
wären. Daher wird die Beispielwarteschlange mit enqueue aufgebaut, anschließend
public void Reverse() {
var current = dequeue;
Element<TElement> next = null;
while(current != null) {
var prev = current.Prev;
current.Prev = next;
next = current;
current = prev;
}
var dummy = enqueue;
enqueue = dequeue;
dequeue = dummy;
}
[Abb. 3] Prev-Zeiger bei der Queue.
umgedreht und dann mit dequeue überprüft, ob die Elemente in der richtigen, umgekehrten Reihenfolge geliefert werden.
Fazit
[Abb. 4] Die Reihenfolge der
Elemente umkehren.
www.dotnetpro.de dotnetpro.dojos.2011
Bei der testgetriebenen Entwicklung hat
sich für mich bestätigt, wie nützlich die
Planung auf Papier ist. Mir ist deutlich geworden, dass die Auswahl der Reihenfolge
der Tests bei TDD wichtig ist. Daher sollte
man in schwierigeren Szenarien immer
erst Testfälle sammeln und diese dann in
eine sinnvolle Reihenfolge bringen, bevor
[ml]
man mit dem ersten Test beginnt.
43
AUFGABE
Infrastruktur
Wie zähmt man den Dämon?
In der Unix-Welt heißen sie Dämonen: die Dienste, die im Hintergrund ihre Arbeit verrichten.
Stefan, stell doch mal eine Aufgabe, die in die Unterwelt der Windows-Dienste führt.
Wer übt, gewinnt
dnpCode: A1010DojoAufgabe
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
44
E
in Windows-Dienst ist aus .NET-Sicht
eine Konsolenanwendung. Da Dienste
im System im Hintergrund laufen,
auch wenn kein Benutzer am System
angemeldet ist, gelten einige Besonderheiten. So
kann ein Dienst logischerweise nicht über eine
grafische Benutzerschnittstelle verfügen. Kein Benutzer, keine Interaktion, so einfach ist das.
Damit der Dienst vom Betriebssystem ohne Zutun eines Benutzers gestartet werden kann, müssen in der Registry einige Angaben zum Dienst
hinterlegt werden. Die wichtigste Information ist,
unter welchem Benutzer der Dienst laufen soll,
weil sich daraus die Rechte ableiten, die dem
Dienst zur Verfügung stehen. Weitere Einstellungen betreffen das Startverhalten: Soll der Dienst
beim Systemstart automatisch mit gestartet werden? Ist der Dienst von anderen Diensten abhängig, die dann zuerst gestartet werden müssen?
Bevor ein Windows-Dienst gestartet werden
kann, muss er installiert werden. Dazu gibt es im
.NET Framework eine entsprechende Infrastruktur. Die Details sind nicht kompliziert, dennoch
ist es lästig, für jeden Dienst erneut den Installationsvorgang entwickeln zu müssen. Daher geht
es in diesem Monat darum, eine wiederverwendbare Infrastruktur zu entwickeln, mit der Windows-Dienste erstellt werden können.
Mit „wiederverwendbar“ und „Infrastruktur“
stecken in dieser Übung gleich zwei Fallen, die
man im Blick behalten sollte. Das Ziel der Wiederverwendbarkeit zieht sich zwar durch die Literatur zur objektorientierten Programmierung.
Es birgt jedoch die Gefahr, zu viel tun zu wollen.
Denn es droht das Risiko, maximal flexibel zu
sein, dadurch aber auch maximalen Aufwand
zu betreiben.
Der Infrastrukturaspekt birgt das Risiko, „von
unten“ zu beginnen. Statt also Anforderungen
„von oben“ aus Sicht des Anwenders zu definieren, wird beim Fokus auf Infrastruktur oft der
Fehler gemacht, Dinge vorzusehen, die am Ende
niemand braucht. Ohne klare Anforderungen
bleibt nur der Blick in die Glaskugel. Die Herausforderung lautet daher, Wiederverwendbarkeit
und Infrastruktur besonders kritisch im Blick zu
behalten, um nicht in diese Fallen zu laufen.
Die Anforderungen für die Übung lauten: Erstellen Sie eine Komponente, mit der ein Windows-Dienst realisiert werden kann. Als Anwender möchte ich den Dienst an der Konsole folgendermaßen bedienen können :
❚ Mit myService.exe /install und myService.exe
/uninstall kann der Dienst installiert beziehungsweise aus dem System entfernt werden.
❚ myService.exe /run führt den Dienst als normale
Konsolenanwendung aus, ohne dass er zuvor als
Windows-Dienst registriert werden muss.
Dies ist für Test und Fehlersuche sehr hilfreich.
Das Starten und Stoppen des Dienstes kann zunächst mit den Windows-Bordmitteln bewerkstelligt werden :
❚ net start myService
❚ net start myService
Eine denkbare Erweiterung wäre, den Dienst
auch mit myService.exe /start starten zu können.
Aber dies ist ein Feature, welches erst umgesetzt
werden soll, wenn die anderen Anforderungen
implementiert sind. Nicht zu viel auf einmal tun,
lautet die Devise.
Als Entwickler möchte ich möglichst wenig mit
der Windows-Infrastruktur konfrontiert werden.
Ich möchte den Namen des Dienstes angeben
sowie zwei Methoden, die beim Starten und
Stoppen des Dienstes aufgerufen werden. Dabei
sollte sich die Infrastruktur nicht in meine Klassen drängeln. Die Klasse, welche die Logik des
Dienstes implementiert, sollte nicht von einer
vorgegebenen Basisklasse ableiten müssen.
Die Herausforderung der Übung liegt in zwei
Bereichen : Zum einen geht es um die Technologie von Windows-Diensten. Wer sich damit noch
nicht befasst hat, kann sich mit einem Spike mit
der Technologie vertraut machen. Der andere
Bereich ist der Entwurf der Lösung. Hier geht es
darum, die Balance zu finden zwischen zu viel
und zu wenig. Zu viel wäre beispielsweise, wenn
im ersten Entwurf schon überlegt wird, wie man
den Dienst zur Laufzeit kontrollieren kann. Zu
wenig wäre, wenn die Anforderungen an die Bedienung der Kommandozeile nicht umgesetzt
[ml]
wären oder alles in einer Klasse landet.
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Windows-Dienste implementieren
So beherrschen Sie den Dienst
Ein Windows-Dienst ist eng in die Infrastruktur des Betriebssystems integriert.
Das erschwert automatisierte Tests. Wenn Sie den eigentlichen Kern des Dienstes unabhängig von
der Infrastruktur halten, ist er dennoch für automatisierte Tests zugänglich.
I
n der Aufgabenstellung zu dieser
Übung habe ich auf zwei Risiken
hingewiesen, die bei Infrastrukturprojekten oftmals auftreten. Zum
einen bergen sie das Risiko, den Aspekt der
Wiederverwendbarkeit zu stark zu berücksichtigen. Zum anderen ist die Versuchung
groß, „von unten nach oben“ zu entwickeln. Beides führt in der Tendenz dazu,
dass man zu viel tut. Nun mögen Sie sich
vielleicht fragen, was denn so schlimm daran ist, mehr zu tun als gefordert. Sicher, es
wäre schlimmer, weniger zu tun als gefordert. Jedoch wird beim „mehr tun“ Aufwand getrieben, den am Ende niemand
bezahlen möchte. Möglicherweise wird sogar der geplante Termin nicht gehalten,
weil unterwegs hier und da noch zusätzliche „Schmankerl“ eingebaut wurden. Aus
diesem Grund sollten Anforderungen möglichst exakt umgesetzt werden.
Und damit sind wir bei einem weiteren
Knackpunkt: Was sind denn eigentlich die
Anforderungen? Solange die nicht klar sind,
kann ein Entwickler bei der Implementierung eigentlich nur falschliegen. Folglich
sollte er so oft wie nötig nachfragen, um
unklare Anforderungen zu präzisieren.
Nun stand ich Ihnen während der Übung
nicht als Kunde unmittelbar zur Verfügung,
aber in der Aufgabenstellung war ein Feature explizit als „denkbare Erweiterung“
aufgeführt, nämlich das Starten und Stoppen des Windows-Dienstes. Folglich sollte
die Implementierung so voranschreiten,
dass dieses Feature nicht sofort von Anfang
an umgesetzt wird. Andererseits darf es
auch nicht aufwendiger sein, das Feature
nachträglich zu ergänzen, statt es von vornherein vorzusehen. Hilfreich ist es deshalb,
die möglichen Features zunächst zu sammeln. Dann können Kunde und Entwickler
die Features priorisieren und in der „richtigen“ Reihenfolge abarbeiten.
Beim Sammeln von Features muss eine
Kundensicht eingenommen werden. Alle
Features sollen dem Kunden Nutzen bringen. Am Ende muss schließlich der Kunde
das Feature als „fertig“ akzeptieren und ab-
www.dotnetpro.de dotnetpro.dojos.2011
nehmen. Das ist nur möglich, wenn das
Feature für den Kunden tatsächlich relevant ist. Ein Feature wie etwa „Das Programm von 32 Bit auf 64 Bit umstellen“
spiegelt nicht den unmittelbaren Kundennutzen wider. Lautet das Feature jedoch
„Das Programm kann mit sehr großen Datenmengen umgehen“, liegt der Fokus auf
dem Kundennutzen statt auf einem technischen Detail.
Featureliste
Für die Aufgabenstellung„Windows-Dienst“
könnten die Features wie folgt aussehen:
z F1: Ein Windows-Dienst kann installiert
und deinstalliert werden.
z F2: Der Windows-Dienst kann auch als
Konsolenanwendung gestartet werden,
ohne dass man ihn vorher als Dienst installieren muss.
z F3: Der Windows-Dienst kann gestartet
und gestoppt werden.
Abnahmekriterien
Um die Anforderungen zu präzisieren, sollten Abnahmekriterien definiert werden.
Dadurch weiß der Entwickler, wann er mit
der Arbeit fertig ist − eben dann, wenn alle
Abnahmekriterien erfüllt sind. Die Abnahmekriterien für Feature F1 lauten:
z Wenn der Dienst mit myservice.exe /install
installiert wird, ist er unter Systemsteuerung/Services sichtbar.
z Der Dienst kann nach der Installation
über Systemsteuerung/Services oder net
start myservice gestartet werden (und
natürlich auch gestoppt werden).
z Wenn der Dienst mit myservice.exe/uninstall deinstalliert wird, taucht er unter
Systemsteuerung/Services nicht mehr auf.
Spätestens an dieser Stelle beschleicht
mich der Verdacht, dass hier mit automatisierten Tests nicht viel auszurichten ist. Am
Ende hilft es nichts, der Dienst muss mit
dem Betriebssystem korrekt zusammenarbeiten. Das lässt sich nur durch einen Integrationstest wirklich sicherstellen. Das bedeutet nun nicht, dass wir gar keine auto-
matisierten Tests sehen werden, aber es
werden wenige sein.
Entwurf
Für Feature F1 kann nun eine Architektur
entworfen werden. Dabei ist einerseits zu
berücksichtigen, dass möglicherweise nicht
alle Features sofort implementiert werden.
Ein Feature wird nach dem anderen implementiert, niedrig priorisierte möglicherweise gar nicht. Wird ein Feature nach dem
anderen implementiert, bietet das für den
Kunden sehr viel Flexibilität: Er kann die
Priorisierung von Features jederzeit ändern.
Und er kann Features streichen oder auch
neue hinzunehmen. Logischerweise gilt dies
nicht für Features, die bereits in Arbeit sind.
Aber alle noch nicht begonnenen Features
stehen zur Disposition. Folglich sollte ein
Architekturentwurf nur die Features im
Detail berücksichtigen, die konkret zur Implementierung anstehen. Da weitere mögliche Features schon bekannt sind, kann
und sollte man diese beim Architekturentwurf im Blick behalten. Diese Features sollten jedoch keinen nennenswerten Einfluss
auf die Architektur nehmen, denn es droht
jederzeit das Risiko, dass sie doch nicht benötigt werden. Gefragt ist also ein Blick über
den Tellerrand der anstehenden Features,
ohne dass man dabei gleich zu viel tut. Orientierung liefert die Fokussierung auf den
Kundennutzen. Für den Kunden ist es weitaus angenehmer, das wichtigste Feature
einsatzbereit geliefert zu bekommen, als
viele unvollendete Features, die nicht einsatzbereit sind.
Um einen möglichst flexiblen Architekturentwurf zu erreichen, müssen die beteiligten Funktionseinheiten lose gekoppelt
sein. So ist die Wahrscheinlichkeit groß,
dass zusätzliche Features leicht integriert
werden können. Es liegt also auf der Hand,
hier Event-Based Components (EBC) zu
verwenden. Doch bevor es so weit ist, müssen die Anforderungen weiter präzisiert
werden, denn noch ist nicht klar, wie die
geforderte Dienstinfrastruktur eingesetzt
werden soll.
45
LÖSUNG
Listing 1
Ein Interface für den Dienst.
public interface IService
{
string Name { get; }
string DisplayName { get; }
string Description { get; }
void OnStart();
void OnStop();
}
Als Entwickler, der einen WindowsDienst implementieren soll, möchte ich es
so einfach wie möglich haben. Das bedeutet für mich unter anderem: geringe Abhängigkeiten. Denkbar wäre die Realisierung über ein Interface, das wie in Listing 1
zu implementieren ist.
Durch dieses Interface würden alle Informationen bereitgestellt, die relevant
sind, damit man einen Windows-Dienst installieren und starten kann. Das könnte so
wie in Listing 2 aussehen.
Das Anlegen einer Klasse, die IService
implementiert, geht zwar schnell von der
Hand. Noch schneller bin ich aber, wenn
ich nicht für jeden Dienst eine Klasse implementieren muss. Stattdessen könnte ich
eine generische Klasse verwenden, von der
Instanzen angelegt werden, siehe Listing 3.
Ich habe die Klasse EasyService genannt,
weil es damit so schön einfach ist, die erforderlichen Angaben für einen WindowsDienst zusammenzustellen. Es bleibt allerdings die Frage, wozu eine Instanz von
EasyService angelegt werden muss. Das
Objekt würde nur dazu dienen, die Dienstbeschreibung zu transportieren. Eigentlich
wird aber kein Zustand benötigt, also genügt auch eine statische Methode Run innerhalb der Klasse, siehe Listing 4.
Nachdem klar ist, wie der Kunde seine
Software bedienen will, können Sie einen
Architekturentwurf für Feature F1 angehen.
Von ferne betrachtet, ist das Feature eine
Funktionseinheit, die als Parameter die
Kommandozeilenargumente sowie eine
Dienstbeschreibung erhält. Die Dienstbeschreibung besteht vor allem aus dem Namen des Dienstes. Ferner sind zwei Lambda-Ausdrücke nötig, die beim Starten und
Stoppen des Dienstes ausgeführt werden
sollen. Abbildung 1 zeigt den Entwurf.
Listing 2
Grundlagen eines Dienstes.
public class MyService : IService
{
public string Name { get { return "myService"; }
}
public string DisplayName { get { return "Mein Service"; }
}
public string Description { get { return "Ein Service, der nichts tut."; }
}
public void OnStart() { Trace.WriteLine("OnStart aufgerufen");
}
public void OnStop() { Trace.WriteLine("OnStop aufgerufen");
}
}
Listing 3
Eine generische Klasse verwenden.
var myService = new EasyService {
Name = "myService",
DisplayName = "Mein Service",
Description = "Ein Service, der nichts tut.",
OnStart = () => Trace.WriteLine("OnStart aufgerufen"),
OnStop = () => Trace.WriteLine("OnStop aufgerufen")
};
myService.Run(args);
46
[Abb. 1] Erster Entwurf.
Der nächste Schritt besteht darin, diesen
Entwurf zu verfeinern. Dazu wird die Funktionseinheit zerlegt. Ohne mir schon zu
viele Gedanken um die Details der Dienstinstallation machen zu müssen, fällt es mir
leicht, vier Funktionseinheiten zu identifizieren:
z Argumente auswerten,
z Dienst installieren,
z Dienst deinstallieren,
z Dienst ausführen.
Diese Einheiten bilden die Verfeinerung
meines Entwurfs, siehe Abbildung 2.
Damit bin ich bereits auf einem Abstraktionsniveau angekommen, mit dem ich zufrieden bin. In die technischen Details der
Dienstinstallation will ich bei diesem Entwurf nicht weiter hineinzoomen. Sicherlich werden da noch ein paar technische
Details stecken, doch in der Rolle des Architekten gehe ich davon aus, dass der Entwickler diese Details in der Funktionseinheit „Dienst installieren“ sinnvoll unterbringen kann. Sollte sich während der Implementierung herausstellen, dass dem
nicht so ist, muss der Entwurf möglicherweise weiter verfeinert werden. Aus dem
Entwurf ergeben sich folgende Kontrakte :
z Ein Datenmodell für die Dienstbeschreibung.
z Ein Kontrakt für das Auswerten der Kommandozeilenargumente.
z Je ein Kontrakt für die Installation, die
Deinstallation und die Ausführung des
Dienstes.
Das Datenmodell wird anstelle von einfachen Typen wie string oder Ähnlichem
verwendet, weil dadurch ein höheres Abstraktionsniveau erreicht wird. Im Entwurf
werden bei den Input- und Outputpins
ebenfalls Bezeichner verwendet, die aus
der sogenannten allgegenwärtigen Sprache (Ubiquitous Language) stammen. Da
es sich bei Begriffen wie „Dienstbeschreibung“ um einen Begriff aus der Problemdomäne handelt, ist es gut, diese auch im
Code wiederzufinden. Das erleichtert das
Verständnis, da keine gedankliche Übersetzung erforderlich ist. Würde eine Me-
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
thode mehrere Parameter von einfachen
Typen erwarten, müsste jemand, der den
Code liest, daraus gedanklich die Dienstbeschreibung erst wieder zusammensetzen. Das Datenmodell für die Dienstbeschreibung sieht aus wie in Listing 5.
Die Kontrakte für die Funktionseinheiten sind in EBC-Manier erstellt. Das bedeutet, dass sie über sogenannte Inputund Outputpins verfügen. Inputpins werden in Form von Methoden modelliert,
Outputpins sind Events. Zu weiteren Details über EBCs lesen Sie am besten die
Artikelserie von Ralf Westphal, zu finden
unter [1] [2] [3]. Der Kontrakt für das Auswerten der Argumente sieht aus wie in Listing 6. Auf dem Inputpin In_Process der
Funktionseinheit werden die Kommando-
Listing 4
Eine statische Methode
„Run” verwenden.
EasyService.Run(
args,
"myService",
"Mein Service",
"Ein Service, der nichts tut.",
() => Trace.WriteLine
("OnStart aufgerufen"),
() => Trace.WriteLine
("OnStop aufgerufen")
);
Listing 5
Datenmodell für die Dienstbeschreibung.
public class ServiceBeschreibung
{
public string Name { get; set; }
public string DisplayName { get; set; }
public string Description { get; set; }
}
[Abb. 2] Verfeinerung
des Entwurfs.
zeilenparameter in Form eines string-Arrays übergeben. Je nachdem, welcher Parameter übergeben wurde, wird daraufhin
der korrespondierende Outputpin ausgelöst.
Die beiden Kontrakte für die Installation
und Deinstallation sind noch einfacher, da
sie nicht über Outputpins verfügen, siehe
Listing 7.
Und zu guter Letzt ist da noch der Kontrakt für die Ausführung des Service, siehe
Listing 8.
Damit haben Sie nun alle Kontrakte zusammen und können mit der Implementierung beginnen. Wie schon angedeutet,
sind automatisierte Tests an den Stellen
schwierig, an denen die Windows-Infrastruktur relevant ist. Dies betrifft das Installieren und Deinstallieren des Dienstes. Ferner sollte der Kontrakt IServiceAusführen
mit der abstrakten Klasse ServiceBase aus
dem .NET Framework kombiniert werden.
Diese stellt die erforderliche Infrastruktur
für die Dienstausführung zur Verfügung.
Damit die Dienste nicht von ServiceBase
ableiten müssen und tatsächlich keine Abhängigkeiten zur Windows-Infrastruktur
haben, verwende ich einen Dienstproxy.
Diese Klasse leitet von ServiceBase ab und
bietet zwei Events, die beim Starten bzw.
Stoppen des Dienstes ausgeführt werden.
Dadurch kann ein Lambda-Ausdruck verwendet werden, und der Dienst ist infra-
strukturunabhängig. Der Dienstproxy sieht
aus, wie in Listing 9 gezeigt wird.
Diese Klasse ist so simpel, dass ich auf
Tests verzichtet habe. Sie zu ergänzen würde im Übrigen auch erfordern, die protected Methoden OnStart und OnStop im
Test aufzurufen. Aufgrund der Vererbung
kann die Sichtbarkeit nicht zu internal geändert werden. Aufwand und Nutzen stünden daher in einem sehr ungünstigen Verhältnis. Da das gesamte Projekt ohnehin
einen Integrationstest erfordert, wird der
ServiceProxy dort mitgetestet.
Sehr gut automatisiert zu testen ist die
Implementierung der Argumentauswertung. Ich habe die Klasse ArgumenteAuswerten testgetrieben entwickelt. Listing 10
zeigt zwei der Tests als Beispiele.
Der erste Test prüft, ob beim Aufruf ohne
Parameter der Outputpin Out_RunAsService ausgelöst wird. Dies ist wichtig, da
Windows den Dienst so startet. Der zweite
Test prüft, ob bei Aufruf mit /install der
Event Out_Install ausgelöst wird.
Die zugehörige Implementierung ist einfach gehalten. Zunächst wird geprüft, ob
kein Argument übergeben wurde. In dem
Fall wird der Event Out_RunAsService ausgeführt. Andernfalls wird über ein switchStatement in die jeweiligen Events verzweigt. Diese Form der Argumentauswertung ist zwar zu einfach, um damit etwa zusätzliche Parameter zu den Optionen parsen
Listing 7
Listing 6
Argumente auswerten.
public interface IArgumenteAuswerten
{
void In_Process(params string[] args);
event Action Out_Install;
event Action Out_Uninstall;
event Action Out_RunAsService;
}
www.dotnetpro.de dotnetpro.dojos.2011
Installation und Deinstallation.
public interface IServiceInstallieren
{
void In_Installieren(ServiceBeschreibung beschreibung);
}
public interface IServiceDeinstallieren
{
void In_Deinstallieren(ServiceBeschreibung beschreibung);
}
47
LÖSUNG
Listing 8
Den Dienst ausführen und
stoppen.
public interface IServiceAusführen
{
void In_Start();
void In_Stop();
}
zu können. Kommandos mit Parametern
wie etwa /install myService2 erfordern etwas mehr Aufwand beim Parsen. Für Feature F1 genügt ein solch einfacher Parser
aber völlig, warum also mehr tun. Tatsächlich ist sogar die Groß-/Kleinschreibung
signifikant, das heißt /INSTALL würde zu
einem Fehler führen. Aber auch diese Einschränkung ist in Ordnung, es sei denn, der
Kunde würde explizit fordern, dass Groß-/
Kleinschreibung zu ignorieren sei.
Als Nächstes kommt das Installieren des
Dienstes an die Reihe. Obwohl ich die
Dienstinstallation schon mal implementiert habe, musste ich die Suchmaschine
meiner Wahl bedienen, um verschiedene
Details zusammenzusammeln. Auch automatisierte Tests schieden aus. So erinnerte
mich die Implementierung dieser Funktionseinheit eher an einen Spike, und leichtes Unbehagen ließ sich nicht vermeiden.
Um überhaupt etwas testen zu können,
musste ich einen kleinen Minidienst implementieren. Der kann nun gleichzeitig
als Beispiel dienen, wie man Dienste mithilfe der geschaffenen Infrastruktur imple-
mentiert. Dennoch war mir nicht wohl bei
der Sache, da ich diese Arbeitsweise dank
testgetriebener Entwicklung gar nicht
mehr gewohnt bin. Aber es hilft nichts −
die korrekte Installation eines Dienstes innerhalb des Betriebssystems lässt sich nun
mal nicht anders überprüfen.
Neben den Details der Dienstinstallation
ging es auch um andere Details. Denn natürlich darf nicht jeder Benutzer einen
Dienst im Betriebssystem registrieren, es
sei denn, man arbeitet immer noch als Administrator und schaltet die User Account
Control (UAC) aus. Folglich musste ich
mich damit befassen, wie man dem Betriebssystem mitteilen kann, dass ein Programm Administratorberechtigungen benötigt. Das geht ganz einfach: Man fügt
dem Projekt, mit dem man das Programm
erstellt, also dem EXE-Projekt, eine Manifestdatei hinzu. In dieser XML-Datei kann
die Administratorberechtigung angefordert werden, sodass Windows die sogenannte Elevation beim Benutzer anfordern
kann. Der relevante Ausschnitt aus der Manifestdatei app.manifest sieht aus, wie in
Listing 11 gezeigt wird.
Für die Installation eines Dienstes stehen im .NET Framework die Klassen TransactedInstaller, ServiceProcessInstaller und
ServiceInstaller zur Verfügung. Der TransactedInstaller ist dafür zuständig, eine Installation transaktional auszuführen. Das
bedeutet, die angeforderte Installation
wird entweder vollständig ausgeführt oder
bei einem Fehler komplett wieder rückgängig gemacht. ServiceProcessInstaller und
ServiceInstaller werden konfiguriert und
zum TransactedInstaller hinzugefügt; die-
Listing 9
Ein Proxy für den Dienst.
public class ServiceProxy : ServiceBase, IServiceAusführen
{
public event Action Out_Start = delegate { };
public event Action Out_Stop = delegate { };
public void In_Start() {
Out_Start();
}
public void In_Stop() {
Out_Stop();
}
protected override void OnStart(string[] args) {
Out_Start();
}
protected override void OnStop() {
Out_Stop();
}
}
48
Listing 10
Argumente auswerten.
[TestFixture]
public class ArgumenteAuswertenTests
{
private IArgumenteAuswerten sut;
[SetUp]
public void Setup() {
sut = new ArgumenteAuswerten();
}
[Test]
public void Ohne_Argumente() {
var count = 0;
sut.Out_RunAsService += () => count++;
sut.In_Process();
Assert.That(count, Is.EqualTo(1));
}
[Test]
public void Install_als_Argument() {
var count = 0;
sut.Out_Install += () => count++;
sut.In_Process("/install");
Assert.That(count, Is.EqualTo(1));
}
}
ser wird anschließend angewiesen, die Installation durchzuführen. Da die Schritte
für die Deinstallation alle gleich sind, liegt
es auf der Hand, die beiden Kontrakte IServiceInstallieren und IServiceDeinstallieren
in einer Klasse zusammenzufassen, wie
Listing 12 zeigt.
Nachdem die einzelnen Prozessschritte
implementiert sind, müssen sie nur noch
in EBC-Manier miteinander verbunden
werden. Diese Arbeit übernimmt die statische Run-Methode in der Klasse EasyService. Ich habe mich auch hier dagegen entschieden, die „Verdrahtung“ der Bausteine
automatisiert zu testen. Technisch wäre
das natürlich möglich. Dazu müssten die
einzelnen Bausteine durch ein Mock-Framework instanziert und in die Klasse EasyService injiziert werden. Anschließend
könnte automatisiert geprüft werden, ob
die Zuordnung von Methoden zu Events
korrekt ist. Im Ergebnis ist der Nutzen
abermals recht gering, da die Verdrahtung
aufgrund der Event- und Methodensignaturen kaum falsch gemacht werden kann.
Um nun tatsächlich feststellen zu können, ob Feature F1 fertiggestellt ist, müssen
die Abnahmekriterien überprüft werden.
Dazu muss ein exemplarischer Dienst implementiert werden, um so zu prüfen, ob
dieser tatsächlich in der Systemsteuerung
sichtbar ist und gestartet werden kann.
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 11
Die Administratorberechtigung anfordern.
[Abb. 3] DebugView zeigt die Trace-Ausgaben.
Hier stellte sich mir die Frage, wie man am
einfachsten überprüft, ob der Dienst tatsächlich gestartet und gestoppt werden
kann. Eine Ausgabe auf der Konsole scheidet aus, denn schließlich handelt es sich
um einen Dienst ohne Benutzerinteraktion. Ich entschied mich für eine Ausgabe
mittels System.Diagnostics.Trace aus dem
.NET Framework. Diese kann nämlich mit
dem Programm DebugView aus der SysInternals-Sammlung [4] angezeigt werden.
Damit Trace-Ausgaben von Diensten angezeigt werden, muss im DebugView die Einstellung „Capture Global Win32“ aktiviert
werden. Voraussetzung dafür ist wiederum,
dass das Programm mit Administratorrechten gestartet wird. Abbildung 3 zeigt
die Ausgabe von DebugView.
Die Features F2 und F3 zu ergänzen ist
dank der EBCs ganz leicht. Dazu musste
ich lediglich die Auswertung der Argumente so ergänzen, dass weitere Kommandozeilenparameter erkannt werden. Das Ausführen des Dienstes von der Konsole aus,
also ohne Installation im Betriebssystem,
war ganz leicht und benötigte keine weitere Funktionseinheit. Für das Starten und
Stoppen des Dienstes habe ich die Klasse
ServiceStarter ergänzt. Anschließend konnte ich in der Klasse EasyService die Verdrahtung ergänzen, und das neue Feature war
fertig.
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
Listing 12
Den Service installieren und entfernen.
public class ServiceInstallation : IServiceInstallieren, IServiceDeinstallieren
{
public void In_Installieren(ServiceBeschreibung name) {
var transactedInstaller = CreateTransactedInstaller(name, "Install.log");
transactedInstaller.Install(new Hashtable());
}
public void In_Deinstallieren(ServiceBeschreibung name) {
var transactedInstaller = CreateTransactedInstaller
(name, "UnInstall.log");
transactedInstaller.Uninstall(null);
}
private static TransactedInstaller CreateTransactedInstaller
(ServiceBeschreibung name, string logFilePath) {
var serviceProcessInstaller = new ServiceProcessInstaller {
Account = ServiceAccount.LocalSystem
};
var transactedInstaller = new TransactedInstaller();
transactedInstaller.Installers.Add(serviceProcessInstaller);
var path = string.Format("/assemblypath={0}",
Assembly.GetEntryAssembly().Location);
var installContext = new InstallContext(logFilePath, new[] {path});
transactedInstaller.Context = installContext;
var serviceInstaller = new ServiceInstaller {
ServiceName = name.Name,
DisplayName = name.DisplayName,
Description = name.Description
};
transactedInstaller.Installers.Add(serviceInstaller);
return transactedInstaller;
Fazit
Bei dieser Übung ist die Testabdeckung
recht gering ausgefallen. Dies hat mich natürlich nicht kaltgelassen. Allerdings gibt es
zwei Dinge zu berücksichtigen: Zum einen
wären automatisierte Tests aufgrund des
sehr hohen Infrastrukturanteils sehr aufwendig, darauf habe ich weiter oben an
den entsprechenden Stellen bereits hingewiesen. Zum anderen sorgt die hier vorgestellte Dienstinfrastruktur aber dafür, dass
der eigentliche Kern des zu implementierenden Dienstes völlig befreit ist von Infrastrukturabhängigkeiten. Dadurch ist der
Kern des Dienstes besser zu testen. Insofern kann ich akzeptieren, dass der Infrastrukturanteil mittels manueller Integra-
www.dotnetpro.de dotnetpro.dojos.2011
}
}
tionstests überprüft wird. Die Vorgehensweise, ausgehend von einer Featureliste
über die EBC-Architektur hin zur Implementierung, hat sich bewährt. Wie sah das
bei Ihnen aus? Schreiben Sie doch einmal
einen Leserbrief zu Ihren Erfahrungen
beim dotnetpro dojo!
[ml]
[1] Ralf Westphal, Zusammenstecken – funktioniert,
Event-Based Components, dotnetpro 6/2010,
S. 132ff., www.dotnetpro.de/
A1006ArchitekturKolumne
[2] Ralf Westphal, Stecker mit System,
dotnetpro 7/2010, S. 126 ff.,
www.dotnetpro.de/A1007ArchitekturKolumne
[3] Ralf Westphal, Nicht nur außen schön,
dotnetpro 8/2010, S. 126ff.,
www.dotnetpro.de/A1008ArchitekturKolumne
[4] DebugView for Windows v4.76,
www.dotnetpro.de/SL1011dojoLoesung1
49
AUFGABE
Event-Based Components
Wie baue ich einen Legostein?
Softwarekomponenten so einfach wie Legosteine zusammenstecken zu können – mit diesem Versprechen
tritt das Konzept der Event-Based Components an. Stefan, kannst du dazu eine Übung stellen?
Wer übt, gewinnt
dnpCode: A1011DojoAufgabe
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
z Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
z Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
50
I
n Ergänzung zur Artikelserie von Ralf
Westphal [1] [2] [3] [4] über Event-Based
Components (EBC) lautet die Aufgabe in
diesem Monat: Entwickeln Sie eine Textumbruchkomponente. Für die Silbentrennung
können Sie die Komponente NHunspell [5] verwenden. Abbildung 1 zeigt, wie eine kleine Testanwendung aussehen könnte, die den Textumbruch als Komponente verwendet. Die Komponente soll über folgenden Kontrakt verfügen :
public interface ITextumbruch {
string Umbrechen(string text,
int breiteInZeichen);
}
Der Text sowie die gewünschte Breite werden
in die Methode gegeben, diese liefert den umbrochenen Text zurück. Die Breite des Textes
wird der Einfachheit halber als Anzahl der Zeichen angegeben. Eine Angabe in Millimetern
würde es erfordern, dass man die Laufweiten der
jeweiligen Zeichen berücksichtigt. Das wäre für
die Übung dann doch zu viel des Guten.
So weit zum gewünschten API der Komponente. Diese ist damit eine Komponente im klassischen Sinne, also eine binäre Funktionseinheit
mit separatem Kontrakt. Intern soll sie jedoch
durch EBCs realisiert werden. Überlegen Sie sich
dazu, welche Bearbeitungsschritte nötig sind,
um den Text zu umbrechen. Entwerfen Sie dabei
nicht gleich in Verantwortlichkeiten, sondern in
Prozessschritten oder Aktionen. Die Verantwortlichkeiten ergeben sich daraus ganz von allein.
Für die Silbentrennung mag es auf der Hand liegen, dass NHunspell die Verantwortlichkeit für
diesen Prozessschritt übernimmt. Für alle anderen Schritte dürfte es nicht so offenkundig sein.
Die Anforderungen an die Komponente sollten Sie sich vorher notieren. Auch mögliche Testfälle sollten Sie sammeln. In einem realen Projekt
würden Sie solche Testfälle mit dem Kunden diskutieren. Bei dieser Übung treffen Sie selber
sinnvolle Annahmen. So könnten Sie beispielsweise entscheiden, dass ein Wort, welches selbst
nach der Silbentrennung zu lang ist, einfach
übersteht. „Spielen“ Sie ein wenig mit verschiedenen Texten, es werden Ihnen sicher zahlreiche
[Abb. 1] Testanwendung für den Textumbruch.
interessante Szenarien auffallen. Dieses Mal sollen automatisierte Tests eine größere Rolle spielen als bei der vorherigen Übung zum WindowsDienst.
Die einzelnen Funktionseinheiten sollen möglichst isoliert getestet werden. Und natürlich dürfen ein paar Integrationstests nicht fehlen. Für
das explorative Testen ist die Testanwendung gedacht. Damit können Sie ausprobieren, wie sich
die Komponente bei bestimmten Konstellationen verhält. Ich wünsche viel Spaß und großen
Erkenntnisgewinn!
[ml]
[1] Ralf Westphal, Zusammenstecken – funktioniert,
Event-Based Components, dotnetpro 6/2010, S. 132ff.,
www.dotnetpro.de/A1006ArchitekturKolumne
[2] Ralf Westphal, Stecker mit System, Event-Based
Components, dotnetpro 7/2010, S. 126 ff.,
www.dotnetpro.de/A1007ArchitekturKolumne
[3] Ralf Westphal, Nicht nur außen schön, Event-Based
Components, dotnetpro 8/2010, S. 126 ff.,
www.dotnetpro.de/A1008ArchitekturKolumne
[4] Ralf Westphal, Staffel-Ende mit Happy End,
Event-Based Components, dotnetpro 9/2010, S. 132ff.,
www.dotnetpro.de/A1009ArchitekturKolumne
[5] http://nhunspell.sourceforge.net/
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Event-Based Components
So trennt man Feu-er-wehr
Das Konzept der Event-Based Components einzuüben – das war das Ziel dieses dojos. Die konkrete Aufgabe bestand
darin, eine Komponente für den Textumbruch mit Silbentrennung zu entwickeln. Zum Glück hat Stefan Lieser ein eigenes
Test-GUI entwickelt, denn damit konnte er viele Fehler entdecken und beseitigen.
E
inen Textumbruch mit Silbentrennung zu implementieren,
hört sich schwierig an. Und das
ist es auch, wenn man an eine
Textverarbeitung wie Word denkt. Allerdings gab die Aufgabenstellung mit dem
Hinweis auf NHunspell [1] einen Tipp für
das Problem der Silbentrennung.
Als Erstes sind die Anforderungen zu klären. Im Falle des Textumbruchs können die
Anforderungen sehr gut anhand von Beispielen dargestellt werden. Der folgende
Satz soll etwa auf eine Breite von zehn Zeichen umbrochen werden:
Bauer Klaus erntet Kartoffeln.
Dann soll das Ergebnis folgendermaßen
aussehen:
Bauer
Klaus erntet Kartoffeln.
Das Beispiel enthält keine besonderen
Schwierigkeiten oder Spezialfälle. Aber genau die gilt es natürlich ebenfalls in den
Blick zu nehmen. So stellt sich beispielsweise die Frage, wie mit Zeilenumbrüchen
verfahren werden soll, die im Eingangstext
schon vorhanden sein können:
Bauer Klaus
erntet
Kartoffeln.
Das Ergebnis soll das gleiche sein wie
oben. Bereits vorhandene Zeilenumbrüche
werden also ignoriert. Das soll auch für
mehrere hintereinander stehende Zeilenumbrüche gelten. Auch Absätze werden
damit ignoriert. Diese Vereinfachung ist
der Tatsache geschuldet, dass es hier nur
um eine Übung geht.
Als Nächstes ist Leerraum zu betrachten.
Wenn im Satz zusätzliche Leerzeichen stehen, sollen diese erhalten bleiben, es soll
also keine Normalisierung stattfinden. Allerdings sollen Leerzeichen am Anfang einer Zeile entfernt werden, weil das Ergebnis sonst doch sehr fragwürdig aussieht.
www.dotnetpro.de dotnetpro.dojos.2011
Dazu ein Beispiel, in dem die Leerzeichen
durch einen Punkt ersetzt sind, damit sie
besser zu erkennen sind :
Leerzeichen am Zeilenanfang werden also entfernt, innerhalb des Satzes oder auch
am Ende bleiben sie erhalten. Um die Anforderungen für die Übung möglichst einfach zu halten, lassen wir es dabei zunächst
bewenden.
Denn zwischen den Wörtern steht Leerraum, der erhalten bleiben muss. Als Oberbegriff für Wort und Leerraum habe ich
Zeichenfolge gewählt. Der Text wird also
zunächst in Zeichenfolgen zerlegt. Das
lässt auch Spielraum für mögliche Erweiterungen. Schließlich können im Text auch
Zahlen als Zeichenfolgen auftreten, die
möglicherweise besonders behandelt werden müssen. Ein weiteres Beispiel sind
Interpunktionszeichen, auch diese kann
man unter den Überbegriff Zeichenfolgen
stellen.
Nachdem Zeichenfolgen in Silben zerlegt
sind, müssen die Silben so zu Zeilen zusammengefasst werden, dass die einzelnen
Zeilen höchstens die maximale Länge haben. Dabei stehen die einzelnen Silben natürlich nicht für sich. Denn Silben können
nur innerhalb der Zeile einfach so aneinandergereiht werden. Am Zeilenende muss
ein Trennstrich ergänzt werden, wenn die
letzte Silbe der Zeile zum selben Wort gehört wie die erste Silbe der Folgezeile. Daher muss der Zusammenhang zwischen Silben und Wörtern erhalten bleiben. Offensichtlich genügt es daher nicht, das Zusammenfassen zu Zeilen auf Basis eines Stroms
von Silben zu implementieren.
Algorithmus
Entwurf
Nachdem die Anforderungen präzisiert
sind, müssen Sie eine Idee für einen Algorithmus entwickeln. Dabei steht die Frage
im Vordergrund, wie das Problem algorithmisch gelöst werden kann. Es geht noch
nicht darum, wie der Algorithmus konkret
zu implementieren ist und welche Funktionseinheiten dabei eine Rolle spielen.
Die erste Idee für den Textumbruch sah
bei mir folgendermaßen aus :
❚ Zerlege den Text in Wörter.
❚ Zerlege die Wörter in Silben.
❚ Fasse die Silben neu zu Zeilen zusammen.
Aus diesen Vorüberlegungen entstand mein
Entwurf für eine EBC-Architektur (EventBased Components). Abbildung 1 zeigt die
folgenden vier Aktionen:
❚ Zerlegen in Zeichenfolgen,
❚ Zeichenfolgen in Silben trennen,
❚ Zusammenfassen zu Zeilen,
❚ Zusammenfassen zu Text.
Bauer•••••••Klaus••erntet•Kartoffeln.
Wenn dieser Satz auf eine Breite von
zehn Zeichen unter Beibehaltung der Leerzeichen umbrochen wird, ergibt sich zunächst folgendes Ergebnis:
Bauer•••••
••Klaus••
erntet•
Kartoffeln.
Dabei sind die Leerzeichen in der zweiten Zeile vor dem Wort „Klaus“ jedoch störend. Folglich sollen sie entfernt werden,
mit folgendem Ergebnis:
Bauer•••••
Klaus••erntet•Kartoffeln.
Dabei bemerkte ich schnell, dass die
Zerlegung des Textes in „Wörter“ nicht
wirklich präzise beschreibt, was zu tun ist.
Diese vier Aktionen sind zu einer EBCAktivität zusammengefasst, welche die Aktionen umschließt. Aufgabe der Aktivität ist
es, die Input- und Outputpins der beteiligten Aktionen zu verbinden. Die Aktivität
selbst verfügt über je einen Input- und Outputpin und verbirgt die internen Details der
Realisierung. Sollten später Aktionen hin-
51
LÖSUNG
zukommen, können diese Änderungen lokal innerhalb der Aktivität gehalten werden.
In der Abbildung verwende ich an den
Pfeilen, welche einen Datenstrom von einem Output- zu einem Inputpin darstellen, die Sternnotation. Die Bezeichnung
Zeile* bedeutet daher „mehrere Zeilen“. Ob
dies am Ende durch ein Array, eine Liste
oder ein IEnumerable realisiert wird, ist auf
der Ebene des Architekturentwurfs nicht
entscheidend. Wichtig ist, dass sich die
nachfolgende Implementation an die Kardinalität hält. Wenn also beispielsweise die
Aktion Zusammenfassen zu Zeilen im Entwurf mehrere Zeilen liefert, darf nicht in
der Implementation ein einzelner string
zurückkommen. Dies ergibt sich aus dem
Prinzip „Implementation spiegelt Entwurf“
[2], welches dafür sorgt, dass die Implementation besser verständlich ist. Würde
man in der Implementation vom Entwurf
abweichen, wäre für einen Entwickler, der
später in den Code einsteigt, ein Übersetzungsaufwand erforderlich. Ein Blick in
den Entwurf würde ihm dann nicht viel
helfen, wenn die Implementation immer
wieder davon abweicht.
Um das in den Anforderungen beschriebene API bereitzustellen, kommt noch eine
Klasse hinzu, in der die Aktivität verwendet
wird. So ist die Realisierung als EBC außen
nicht mehr sichtbar.
Wo beginnen?
Bei vier Aktionen, einer Aktivität und der
API-Klasse stellt sich die Frage, wo man anfangen soll. Vereinfacht gesagt stehen Topdown oder Bottom-up-Vorgehensweisen
zur Auswahl. Bei einer Top-down-Vorgehensweise beginnt man bei der Benutzerschnittstelle oder in diesem Fall beim API.
Von dort arbeitet man sich „nach unten“
durch. Andersherum beim Bottom-upVorgehen: Hier beginnt man mit den Aktionen und arbeitet sich langsam nach oben
[Abb. 1] Der Entwurf in EBC-Architektur.
zur Integration vor. Mein Favorit ist die
Top-down-Vorgehensweise. Diese bietet
den Vorteil, dass die Implementation jeweils aus der Sicht eines Verwenders erfolgt.
Zu jeder Komponente, Klasse oder Methode, die so entsteht, gibt es dann bereits einen Verwender. Dieser stellt ganz konkrete
Anforderungen. So wird die Gefahr minimiert, sich mögliche Anforderungen aus
den Fingern zu saugen. Bei einer Bottomup-Vorgehensweise ist diese Gefahr nicht
zu unterschätzen. Sie führt häufig dazu,
dass Funktionalität implementiert wird,
von der niemand weiß, ob sie benötigt wird.
Um zu überprüfen, ob der Architekturentwurf der Problemstellung angemessen
ist und funktioniert, ist es ganz wichtig, als
Erstes einen Durchstich zu realisieren, bei
dem alle entworfenen Funktionseinheiten
beteiligt sind. Dieses sogenannte Tracer
Bullet Feature soll keine echte Funktionalität erzeugen, sondern nur zeigen, dass die
Integration der Funktionseinheiten funktioniert. Hier kann daher auch auf automatisierte Tests verzichtet werden. Im Falle eines APIs ist in Ermangelung einer anderen
Benutzerschnittstelle lediglich ein Test erforderlich, der das API bedient.
Das Tracer Bullet Feature sorgt dafür,
dass die Entwurfsskizzen quasi in Code gegossen werden. Dadurch werden vor allem
die Schnittstellen zwischen den Funktions-
Listing 1
Aktionen verdrahten.
var
var
var
var
zerlegenInZeichenfolgen = new ZerlegenInZeichenfolgen();
zeichenfolgenInSilbenTrennen = new ZeichenfolgenInSilbenTrennen();
zusammenfassenZuZeilen = new ZusammenfassenZuZeilen();
zusammenfassenZuText = new ZusammenfassenZuText();
zerlegenInZeichenfolgen.Out_Result += zeichenfolgenInSilbenTrennen.In_Process;
zeichenfolgenInSilbenTrennen.Out_Result += zusammenfassenZuZeilen.In_Process;
zusammenfassenZuZeilen.Out_Result += zusammenfassenZuText.In_Process;
zusammenfassenZuText.Out_Result += text => Out_Result(text);
52
einheiten viel rigoroser unter die Lupe genommen, als dies am Whiteboard möglich
ist. So werden Ungereimtheiten frühzeitig
aufgedeckt.
Im konkreten Fall des Textumbruchs habe ich also zunächst die API-Klasse Textumbruch sowie die Aktivität TextumbruchAktivität erstellt. Anschließend habe ich die
einzelnen Aktionen mit ihren Input- und
Outputpins erstellt und innerhalb der Aktivität verdrahtet. Um die Aktionen nicht einzeln ausimplementieren zu müssen, habe
ich lediglich die Daten der Inputpins auf die
Outputpins übertragen. Dabei müssen natürlich nach Bedarf entsprechende Datenobjekte erzeugt werden, um den Signaturen
von Input- und Outputpins gerecht zu werden. Nach der Verdrahtung der Aktionen in
der Aktivität konnte ich dann sehen, dass
ein Text, der über den Inputpin in die Aktivität hineingegeben wird, tatsächlich am
Outputpin wieder herauskommt. Wunderbar! Commit nicht vergessen!
Zusätzlich kann man bei der Implementation des Tracer Bullet Features auch
Trace-Ausgaben ergänzen. Dadurch lässt
sich mit Tools wie DebugView [3] verfolgen, ob der Ablauf der einzelnen Aktionen
korrekt erfolgt. Listing 1 zeigt die Verdrahtung der Aktionen in der Aktivität.
Zunächst werden von den benötigten
Aktionen Instanzen erstellt. Anschließend
werden Input- und Outputpins gemäß dem
Entwurf verbunden. Die letzte Zeile zeigt die
Verbindung zum Outputpin der Aktivität.
Doch wie erfolgt dieVerbindung zum Inputpin der Aktivität? Dazu wird im Konstruktor
der Aktivität eine Action<string, int> erstellt,
die zur Signatur des Inputpins passt:
process = (text, breiteInZeichen) => {
zusammenfassenZuZeilen.
In_SetzeBreite(breiteInZeichen);
zerlegenInZeichenfolgen.In_Process(text);
};
Diese Action ist als Feld der Klasse deklariert und kann daher im Inputpin aufgerufen werden :
public void In_Process(string text, int
breiteInZeichen) {
process(text, breiteInZeichen);
}
Dank dieses Kniffs müssen in der Klasse
keine anderen Felder definiert werden, um
auf die Aktionen zugreifen zu können.
Und Action!
Nach der Aktivität ging es an die Implementation der einzelnen Aktionen. Auch dabei
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
bin ich immer in Durchstichen vorgegangen. Statt also die Zerlegung in Zeichenfolgen komplett fertigzustellen und erst dann
mit der Silbentrennung zu beginnen, habe
ich die Zerlegung erst nur ganz simpel realisiert und dann mit der Silbentrennung begonnen. Hier gilt es im realen Projekt abzuwägen, welche Teilfunktionalität dem Kunden den jeweils größten Nutzen bringt. Das
kann bedeuten, eine Funktion komplett zu
realisieren und andere nur rudimentär. Genauso gut kann es aber nützlich sein, an allen Stellen einen Teil der geforderten Leistung zu erbringen. Im Zweifel gilt: Reden
hilft! Eine Rückfrage beim Kunden oder Product Owner sollte den Sachverhalt klären.
Doch zurück zu den Aktionen. Das Aufteilen des Textes in Zeichenfolgen basiert
im Wesentlichen auf der Anwendung von
string.Split. Dadurch bleiben zwar nicht alle Leerzeichen erhalten, aber dies habe ich
für die erste Version in Kauf genommen.
Der erste Test befasst sich mit einem
Text, der nur aus einem Wort besteht. Dieser Test diente mir dazu, den Testrahmen
zu erstellen. Man beachte, dass es hier um
den Test einer EBC-Aktion geht, bei der das
Ergebnis über einen Event geliefert wird.
Den Rahmen für den Test zeigt Listing 2.
Die Rückgabe des Ergebnisses erfolgt bei
EBCs über einen Outputpin. Outputpins
werden über Events realisiert. Daher muss
im Test geprüft werden, ob der Event das
richtige Ergebnis liefert. Dazu erstelle ich
im Setup der Testklasse eine Instanz des
Prüflings und binde einen kleinen Lambda-Ausdruck an den Event. Der LambdaAusdruck kopiert das Argument des Events
in das Feld result der Testklasse. So kann in
den Testmethoden auf das Ergebnis des
Events zugegriffen werden.
Die Klasse Zeichenfolge ist trivial. Sie enthält lediglich eine Eigenschaft für den
string, siehe Listing 3.
Ferner ist eine Equals-Methode implementiert, damit Zeichenfolgen im Test verglichen werden können. Das Erzeugen von
Equals und GetHashCode übernimmt für
mich das ReSharper-Add-in.
Im Prinzip wäre eine Implementation ohne die Datenklasse Zeichenfolge denkbar.
Schließlich kapselt diese lediglich eine
string-Eigenschaft. Durch die Einführung
dieser Datenklasse ist die Typisierung der
Input- und Outputpins jedoch strenger, da
so nur Input- und Outputpins verbunden
werden können, die eine Zeichenfolge als
Argument erwarten. Das fördert nicht nur
die Verständlichkeit, sondern vereinfacht
auch ein automatisiertesVerdrahten der Ak-
www.dotnetpro.de dotnetpro.dojos.2011
tionen, auch wenn das hier nicht verwendet
wird. Ferner verwendet die Implementation
so auch die Ubiquitous Language, die allgegenwärtige Sprache des Projektes, was
ebenfalls zur Verständlichkeit beiträgt.
Die Implementation der Textzerlegung
sieht am Ende so aus wie in Listing 4.
Was mich daran stört, ist der Umgang
mit den Zeilenumbrüchen. Diese werden
nämlich hier ebenfalls behandelt, obwohl
die Aufgabe der Klasse das Zerlegen des
Textes in Zeichenfolgen ist. Damit kümmert sich die Klasse um zwei Dinge und
verstößt so gegen das Single Responsibility
Principle [4]. In der nächsten Iteration würde ich das ändern und die Behandlung der
Zeilenumbrüche herausziehen.
Als Nächstes kam die Silbentrennung an
die Reihe. Durch den Einsatz von NHunspell steht die Funktionalität bereits zur
Verfügung. Es geht also lediglich darum,
das NHunspell-API an unsere Bedürfnisse
anzupassen. Die Implementation ist einfach, bedarf aufgrund der Verwendung von
LINQ aber einer kurzen Erklärung, siehe
Listing 5.
Der Inputpin erhält eine Aufzählung von
Zeichenfolgen. Für jede Zeichenfolge muss
die Silbentrennung aufgerufen werden. Anschließend muss jeweils eine Instanz vom
Typ GetrennteZeichenfolge erstellt werden.
Das riecht nach einer Schleife, ist aber mit
LINQ viel eleganter realisierbar. Doch zuvor
habe ich mich auf die eigentliche Kernfunktionalität konzentriert, das Trennen einer
einzelnen Zeichenfolge. Daher habe ich eine internal-Methode erstellt, welche eine
einzelne Zeichenfolge in Silben trennt. Diese Methode habe ich isoliert getestet. Damit
die Tests möglichst wenig Rauschen enthalten und dadurch gut verständlich sind, arbeitet die Methode mit strings statt mit Zeichenfolge und GetrennteZeichenfolge. Listing 6 zeigt einen der Tests.
Diese Vorgehensweise bietet den großen
Vorteil, dass die beiden Concerns „Silbentrennung“ und „Iterieren“ sauber getrennt
sind. Das vereinfacht die Tests und schafft
Übersichtlichkeit in der Implementation.
Das Iterieren erledigt dann LINQ. Durch
Einsatz von Select wird über die Aufzählung
der Zeichenfolgen iteriert und jeweils eine
Instanz von GetrennteZeichenfolge erzeugt.
Zusammensetzen
Die Silben müssen nun wieder zu Zeilen zusammengefasst werden. Dabei muss zum einen die gewünschte Breite der Zeilen berücksichtigt werden. Zum anderen müssen
gegebenenfalls Trennstriche am Zeilenende
Listing 2
Ein erster Test.
private ZerlegenInZeichenfolgen sut;
private IEnumerable<Zeichenfolge> result;
[SetUp]
public void Setup() {
sut = new ZerlegenInZeichenfolgen();
sut.Out_Result += wörter => result =
wörter;
}
[Test]
public void Einzelnes_Wort() {
sut.In_Process("A");
Assert.That(result, Is.EqualTo(new[] {
new Zeichenfolge("A")
}));
}
Listing 3
Klasse für die Zeichenfolge.
public class Zeichenfolge {
public Zeichenfolge(string text) {
Text = text;
}
public string Text { get; private set; }
}
ergänzt werden. Dazu muss der Zusammenhang von Silben und Wörtern bekannt sein.
Am Ende stellte sich heraus, dass das Zusammenfassen der Silben zu Zeilen die
schwierigste Funktionseinheit darstellt. Hier
hatte ich mal wieder das Gefühl, dass ich ohne automatisierte Tests völlig aufgeschmissen wäre. Während der Implementation
enthielt die Schleife sogar zeitweise ein goto. Doch die Community konnte mich über
Twitter [5] überzeugen, dass dies keine gute Idee ist. Und am Ende ging es tatsächlich
auch ganz leicht ohne dieses Konstrukt.
Um aber zu dem hier in Listing 7 gezeigten
Ergebnis zu kommen, waren einige Refaktorisierungen notwendig.
Ich glaube, dass diese Methode auch ohne
den Abdruck aller verwendeten privaten
Methoden verständlich ist. Die Details können Sie sich wie gewohnt im Quellcode auf
der Heft-DVD anschauen. Die hier gezeigte
Methode realisiert das Zusammenfassen der
Silben zu Zeilen auf relativ hohem Abstraktionsniveau. Die Bedingungen für die zahlreichen if-Statements sind konsequent als
Methoden herausgezogen. Das bietet den
Vorteil, einen sprechenden Namen verwen-
53
LÖSUNG
Listing 4
Texte zerlegen.
public class ZerlegenInZeichenfolgen {
public void In_Process(string text) {
Out_Result(TrenneInZeichenfolgen(text));
}
private static IEnumerable<Zeichenfolge> TrenneInZeichenfolgen(string text) {
var textOhneZeilenumbruch = text.Replace(Environment.NewLine, " ");
var zeichenfolgen = textOhneZeilenumbruch.Split(' ');
for (var i = 0; i < zeichenfolgen.Length; i++) {
var zeichenfolge = zeichenfolgen[i];
yield return new Zeichenfolge(zeichenfolge);
if (IstNichtDieLetzteZeichenfolge(i, zeichenfolgen.Length)) {
yield return new Zeichenfolge(" ");
}
}
}
public event Action<IEnumerable<Zeichenfolge>> Out_Result;
private static bool IstNichtDieLetzteZeichenfolge(int i, int anzahlZeichenfolgen) {
return i + 1 < anzahlZeichenfolgen;
}
}
Listing 5
Die Silbentrennung implementieren.
public class ZeichenfolgenInSilbenTrennen {
private readonly Hyphen hyphen;
public ZeichenfolgenInSilbenTrennen() {
hyphen = new Hyphen("hyph_de_DE.dic");
}
public void In_Process(IEnumerable<Zeichenfolge> zeichenfolgen) {
Out_Result(zeichenfolgen.Select(
x => new GetrennteZeichenfolge(x.Text) {
Silben = Silben(x.Text)
}));
}
public event Action<IEnumerable<GetrennteZeichenfolge>> Out_Result;
internal IEnumerable<string> Silben(string zeichenfolge) {
var result = hyphen.Hyphenate(zeichenfolge);
return (result == null) ? new[]{""} : result.HyphenatedWord.Split('=');
}
}
den zu können. So muss man beim Lesen
nicht interpretieren, was die Bedingung eigentlich testet, sondern kann die Bedeutung
aus dem Namen ableiten. Bei dieser Implementation wäre es interessant auszuprobieren, wie schwierig es ist, die Zeilenbreite nicht in Zeichen, sondern in Millimetern
zu definieren. Dazu müsste die Schriftart
des Textes herangezogen werden, um die
tatsächliche Breite ermitteln zu können.
Gerade bei proportionalen Schriftarten ist
dies wichtig, da hier jedes Zeichen seine
eigene Breite hat. Ferner kann man nicht
einfach die Zeichenbreiten addieren, da
bestimmte Zeichenkombinationen enger
aneinandergestellt werden als andere.
54
Die Entscheidung, ob eine Silbe noch in
die Zeile passt oder die nächste Zeile begonnen wird, steht an genau einer Stelle in der
Methode SilbePasstNochInDieZeile. Hier
müsste man also mit einer entsprechenden
Erweiterung ansetzen. Richtig spannend
wird es bei solchen Erweiterungen ja immer
dann, wenn nicht der ursprüngliche Autor
des Codes diese Erweiterung vornimmt,
sondern jemand, der den Code bislang noch
nicht kennt.
Ein Test-GUI
Bei konsequentem Einsatz von automatisierten Unit-Tests verliert man schon mal die
Integration aus den Augen. Aber auch diese
muss getestet werden. Also habe ich Tests ergänzt, welche „ganz oben“ auf dem öffentlichen API aufsetzen. So ist sichergestellt, dass
die Integration von Aktivität und Aktionen
korrekt funktioniert. Bei dieser einfachen
Aufgabenstellung hat mir das Tracer Bullet
Feature schon die Sicherheit gegeben, dass
die Integration der einzelnen Funktionseinheiten korrekt ist. In komplexeren Szenarien
sind dazu häufig mehrere automatisierte Integrationstests erforderlich.
Aber selbst Unit-Tests plus Integrationstests genügen nicht. Man kommt nicht umhin, auch den Bereich der explorativen Tests
abzudecken. Je leichter es fällt, die Funktionalität „mal eben auf die Schnelle“ auszuprobieren, desto größer ist die Wahrscheinlichkeit, Fehler zu finden. Aus gutem Grund
sind Softwaretester nicht plötzlich überflüssig geworden, nur weil die Entwickler ihren
Code endlich selber automatisiert testen.
Ich habe also ein Test-GUI erstellt, ganz
in Anlehnung an den Entwurf, der in der
Aufgabenstellung abgedruckt war. Und
schon der erste Versuch mit dem Test-GUI
förderte einen Fehler zutage: Ich habe einfach mal auf den Umbrechen-Schalter geklickt, um einen leeren Text zu umbrechen.
Dabei zeigte sich, dass die Silbentrennung
NHunspell bei einem leeren Eingabetext
null als Ergebnis liefert. Sehr unschön,
aber so ist es nun mal. Was tun?
Klar ist: Man muss null abfangen und eine leere Silbenliste zurückliefern. Aber vorher sollte das Problem durch einen automatisierten Test reproduziert werden. Dieser sollte so nah wie möglich an der für das
Problem verantwortlichen Funktionseinheit ansetzen. Also bei der Aktion ZeichenfolgenInSilbenTrennen. So ist sichergestellt,
dass der Test fokussiert und überschaubar
bleibt. Hier zeigt sich übrigens, wie wichtig
es ist, Funktionalität zu kapseln.
Im Entwurf ist eine Schnittstelle für die
Silbentrennung entstanden. Dabei habe
ich keine Rücksicht auf NHunspell genommen (mir war dieses fragwürdige Verhalten vorher gar nicht bekannt). Hätte ich
NHunspell direkt verwendet, ohne eine
eigene Klasse drumherum zu legen, wäre
es möglicherweise schwieriger, dieses unschöne Verhalten an einer Stelle zu beseitigen. Dies gilt übrigens auch für den Rückgabewert. NHunspell liefert als Ergebnis
nicht etwa eine Liste der Silben, sondern
einen string, in dem die Silben durch ein
Gleichheitszeichen abgetrennt sind. Für
Feuerwehrauto wird Feu=er=wehr=au=to
geliefert. Dies muss dem Entwurf gemäß
umgesetzt werden, in eine Liste von Silben.
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Die zweite Erkenntnis aus den Versuchen
mit dem Test-GUI: Zeilenumbrüche wurden nicht berücksichtigt. In den Anforderungen habe ich diese zwar aufgeführt, aber
nicht sofort implementiert. Das war schnell
nachgeholt, allerdings mit den bereits weiter oben erwähnten Einschränkungen.
Dritte Erkenntnis: Nicht trennbare Wörter können länger als die maximale Zeilenlänge sein. Ferner können in trennbaren
Wörtern Silben auftreten, die länger als die
maximale Zeilenlänge sind. Dadurch drehte sich eine Schleife beim Zusammenfassen von Silben zu Zeilen im Kreis. Auch
hier habe ich erst zwei Tests ergänzt, um
den Fehler automatisiert reproduzieren zu
können. Erst danach habe ich das Problem
behoben. Die Vorgehensweise ist hierbei
pragmatisch: Zu lange Wörter oder Silben
werden nicht umbrochen.
Das nächste Problem, das ich durch Ausprobieren mit dem Test-GUI identifiziert
habe, hängt damit zusammen, wie NHunspell auf Interpunktionszeichen reagiert.
Beim Zerlegen des Textes in Zeichenfolgen
werden Interpunktionszeichen nicht gesondert betrachtet, sondern einfach an die
Wörter mit angehängt. Dadurch entstanden
aber merkwürdige Trennungen. So wurde
„Welt“ in „Wel-t“ getrennt.
Auch hier konnten automatisierte Tests
das Verhalten von NHunspell reproduzieren. Hängt nämlich am Wort „Welt“ noch
ein Punkt, also „Welt.“, trennt NHunspell
es zu „Wel-t.“. Ich habe dieses Verhalten lediglich durch entsprechende Tests dokumentiert, an der Implementierung jedoch
nichts geändert. Die Berücksichtigung von
Interpunktionszeichen würde eine ganze
Reihe von Änderungen nach sich ziehen,
die den Umfang dieser Übung sprengen
würden.
Listing 6
Die Silbentrennung testen.
[Test]
public void Feuerwehrauto_wird_getrennt() {
Assert.That(sut.Silben("Feuerwehrauto"),
Is.EqualTo(new[] {"Feu", "er", "wehr", "au", "to"}));
}
Listing 7
Silben zu Zeilen zusammensetzen.
private IEnumerable<string> ErzeugeZeilen(IEnumerable<GetrennteZeichenfolge> zeichenfolgen) {
var zeile = "";
foreach (var zeichenfolge in zeichenfolgen) {
foreach (var silbe in zeichenfolge.Silben) {
if (SilbeIstLeerraumAmZeilenanfang(zeile, silbe)) {
continue;
}
if (SilbePasstNochInDieZeile(zeile, zeichenfolge, silbe) || IstLeerraum(zeile)) {
zeile += silbe;
continue;
}
if (TrennstrichErforderlich(silbe, zeichenfolge)) {
zeile += "-";
}
yield return zeile;
zeile = IstLeerraum(silbe) ? "" : silbe;
}
}
if (ZeileIstNichtLeer(zeile)) {
yield return zeile;
}
}
fallen Absätze natürlich ebenfalls unter
den Tisch. Hier müssten also zwei hintereinander stehende Zeilenumbrüche anders
behandelt werden. Auch das wäre mit einem endlichen Automaten realisierbar.
Fazit und Nachtrag
Erweiterungen
Einige Erweiterungsmöglichkeiten sind mir
durch „Spielen“ mit dem Test-GUI aufgefallen. Der Umgang mit mehreren aufeinanderfolgenden Leerzeichen ist in meiner
Implementation stark vereinfacht. Da zum
Trennen des Textes in Zeichenfolgen die Methode string.Split verwendet wird, bleiben
die Leerzeichen nicht ordnungsgemäß erhalten. Die Implementation müsste also erweitert werden, da string.Split doch zu simpel für die Aufgabe ist. Vermutlich wäre hier
ein endlicher Automat besser geeignet.
Eine weitere Vereinfachung betrifft Absätze. Zeilenumbrüche werden derzeit
komplett entfernt, bevor mit der Trennung
in Zeichenfolgen begonnen wird. Dadurch
www.dotnetpro.de dotnetpro.dojos.2011
Einerseits bin ich überrascht, dass sich mit
vergleichsweise wenig Aufwand doch eine
recht leistungsfähige Textumbruchkomponente realisieren lässt. Andererseits
zeigt sich, dass man für ein reales Projekt
weitaus mehr Aufwand in die Analyse der
Anforderungen und den Entwurf stecken
müsste. Das wird beispielsweise beim Umgang mit Leerraum und Zeilenumbrüchen
deutlich.
Ich bin, nachdem dieser Artikel fertig
war, noch der Frage nachgegangen, ob die
Lösung tatsächlich so evolvierbar ist, dass
eine Umstellung von Breite in Zeichen auf
Breite in Millimetern leicht zu bewerkstelligen ist. Um ein aussagekräftigeres Ergebnis
zu erreichen, habe ich Ralf Westphal gebe-
ten, diese Änderung ebenfalls vorzunehmen. Dazu habe ich ihm lediglich den
Code und eine Entwurfskizze zur Verfügung gestellt. Mein Ergebnis: Nach 50 Minuten war die Änderung fertig.
Und erfreulicherweise hat auch Ralf es in
dieser Zeit geschafft. Das lag zum einen daran, dass der Entwurf 1:1 in der Implementation zu finden ist. So konnte er anhand
des Entwurfs verorten, wo Änderungen vorzunehmen sind. Zum anderen hat die konsequente Einhaltung der EBC-Konventionen geholfen. Ein schönes Ergebnis. [ml]
[1] NHunspell, http://nhunspell.sourceforge.net
[2] Blauer 5. Grad der Clean Code Developer,
http://clean-code-developer.de/wiki/
CcdBlauerGrad
[3] DebugView for Windows v4.76,
www.dotnetpro.de/SL1012dojoLoesung1
[4] Oranger 2. Grad der Clean Code Developer,
http://clean-code-developer.de/wiki/
CcdOrangerGrad
[5] http://twitter.com/stefanlieser
55
AUFGABE
Algorithmen und Datenstrukturen
Wie viele Blätter hat der Baum?
Baumstrukturen sind in der Informatik allgegenwärtig. Wer selbst Bäume implementiert, lernt dabei
viel über ihre Arbeitsweise. Stefan, kannst du dazu eine Übung stellen?
Wer übt, gewinnt
dnpCode: A1012DojoAufgabe
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
W
enn man den sprichwörtlichen
Wald vor lauter Bäumen nicht
mehr sehen kann, mag es helfen, einmal über die Implementierung von Bäumen nachzudenken. Im
.NET Framework gibt es dazu zwar keine generische Implementation, dennoch werden Bäume
auch dort verwendet, nämlich an so prominenter Stelle wie LINQ. Genauer gesagt übersetzt der
Compiler Lambda-Ausdrücke in sogenannte Expression Trees. Auf diese Weise ist es möglich, aus
LINQ-Ausdrücken SQL-Code zu erzeugen. Mit
den WCF-RIA-Services können LINQ-Ausdrücke
sogar übers Netz übertragen werden.
Doch wie implementiert man eine solche Datenstruktur? Dazu soll hier nur das API vorgegeben werden. Die Realisierung ist Ihre Aufgabe für
diesen Monat. Ein Baum hat immer genau einen
Wurzelknoten. Dieser und alle anderen Knoten
können beliebig viele untergeordnete Knoten,
sogenannte Kinder, haben.
Das API für Bäume besteht aus zwei Interfaces,
einem für den Baum, genannt ITree<T>, sowie
einem für die Knoten, genannt INode<T>, siehe
Listing 1.
Das Interface für den Baum ist simpel, es enthält lediglich den Wurzelknoten des Baums.
Beim Knoten sieht das schon anders aus. Jeder
Listing 1
Interfaces für die Baumstruktur.
public interface ITree<T>
{
INode<T> Root { get; }
}
public interface INode<T>
{
T Value { get; }
Node<T> Add(T nodeValue);
IEnumerable<Node<T>> Children { get; }
IEnumerable<T> ChildValues { get; }
IEnumerable<T> PreOrderValues();
IEnumerable<T> PostOrderValues();
}
56
[Abb. 1] Eine einfache Baumstruktur.
Knoten hat einen Wert. Dieser ist vom generischen Typ T. Der Wert eines Knotens kann über
die Eigenschaft Value gelesen werden. Um einem
Knoten einen Kindknoten hinzuzufügen, rufen
Sie die Add-Methode auf und übergeben den
Wert des neuen Knotens. Für den Wert müssen
Sie intern einen Knoten anlegen und in die Children-Liste aufnehmen. Mit der Eigenschaft
ChildValues können Sie die Werte aller Kindknoten eines Knotens abrufen.
Nun geht es an das Traversieren des Baums. Im
Gegensatz zum Traversieren von Listen ist das
Traversieren von Bäumen auf unterschiedliche
Weise möglich. Bei Listen wird ein Element nach
dem anderen geliefert. Bei Bäumen ist aber die
Frage, ob zuerst der Knoten und dann seine Kinder geliefert werden sollen (Pre-Order) oder umgekehrt (Post-Order).
Am einfachsten wird das an einem Beispiel
deutlich. Abbildung 1 zeigt einen Baum. Bei der sogenannten Pre-Order-Traversierung wird der Knoten vor seinen Kindern geliefert. Für den Baum aus
Abbildung 1 ergibt das folgende Reihenfolge:
1, 2, 5, 6, 3, 7, 8, 4, 9, 10
Bei der Post-Order-Traversierung werden zuerst die Kinder geliefert, dann der Knoten selbst.
Für den Beispielbaum ergibt sich daher folgendes Ergebnis :
5, 6, 2, 7, 8, 3, 9, 10, 4, 1
Damit sind die Anforderungen klar. Implementieren Sie die Datenstruktur und die zugehörigen
Traversierungsalgorithmen, natürlich inklusive
automatisierter Unit-Tests. Happy Learning! [ml]
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Algorithmen und Datenstrukturen
So bauen Sie Bäume
Im .NET Framework gibt es keine vordefinierte Datenstruktur für Bäume. Wer seine Daten in einer Baumstruktur
ablegen will, muss sich diese Struktur selbst implementieren. Eine ideale Aufgabe für das dotnetpro.dojo!
A
lgorithmen und Datenstrukturen
stellen auch heute noch eine
wichtige Grundlage der Softwareentwicklung dar. Zwar sind viele der Datenstrukturen im Laufe der Zeit in die Frameworks gewandert, sodass man als Entwickler heute nur noch selten eine der
klassischen Strukturen wie Listen oder
Stacks selbst implementieren muss. Andererseits ist es für einen Entwickler wichtig,
eine Vorstellung davon zu erlangen, was
hinter den Kulissen geschieht.
Nicht zuletzt eignen sich Datenstrukturen sehr gut dafür, die testgetriebene Entwicklung einzuüben. Und da die Datenstruktur „Baum“ im .NET Framework nicht
zur Verfügung steht, lohnt es sich tatsächlich, eine solche Implementation vorzunehmen.
Die Aufgabenstellung hat das API für
Bäume vorgegeben, siehe Listing 1. Sie besteht aus zwei Interfaces: einem für den
Baum, genannt ITree<T>, und einem für
die Knoten, genannt INode<T>.
Ein Baum besteht aus einem Wurzelknoten, Root genannt. Dieser hat selbst einen
Wert (Value) sowie Kindknoten (Children).
Dabei bezieht sich der Baum auf das Interface INode. Der Baum selbst fügt eigentlich
keine Funktionalität hinzu, sondern bezieht diese aus den Knoten.
Bei der Implementation habe ich mit
einem einzelnen Knoten Node<T> begonnen. Der erste Test betrifft die Value-Eigen-
Listing 1
Das API für die Bäume.
public interface ITree<T> {
INode<T> Root { get; }
}
public interface INode<T> {
T Value { get; }
Node<T> Add(T nodeValue);
IEnumerable<Node<T>> Children {get;}
IEnumerable<T> ChildValues { get; }
IEnumerable<T> PreOrderValues();
IEnumerable<T> PostOrderValues();
}
www.dotnetpro.de dotnetpro.dojos.2011
Listing 2
Ein erster Test.
[Test]
public void Node_liefert_den_Konstruktorwert_als_Value() {
var node = new Node<int>(42);
Assert.That(node.Value, Is.EqualTo(42));
}
Listing 3
Die Children-Eigenschaft testen.
[Test]
public void Node_hat_nach_dem_Instanzieren_keine_Nachkommen() {
var node = new Node<string>("");
Assert.That(node.Children, Is.Empty);
}
schaft für den Wert des Knotens. Da das Interface lediglich einen Getter erwartet,
müssen Sie sich überlegen, wie ein Knoten
zu seinem Wert kommt. Eine Möglichkeit
wäre, die Eigenschaft zusätzlich mit einem
Setter auszustatten. Allerdings wären die
Knoten damit veränderbar. Solange Sie
dies nicht benötigen, genügt ein privater
Setter, um den Wert einmalig setzen zu
können. Der Wert muss dann im Konstruktor zugewiesen werden, da private Setter
nur innerhalb der Klasse verwendet werden können. Solche nicht änderbaren Objekte, sogenannte Immutable Objects, bieten den Vorteil, dass man damit den Problemen des parallelen Zugriffs bei Multithreading aus dem Weg geht.
Mein erster Test prüft also, ob die ValueEigenschaft den Wert liefert, der dem Knoten im Konstruktor übergeben wurde, siehe Listing 2. Dies ist nur ein Minischritt,
und es ist fraglich, ob dieser Test von hohem Wert ist. Andererseits muss für diesen
ersten Test die Klasse Node<T> erzeugt
werden. Es entsteht also das grobe Codegerüst der Klasse, welches erforderlich ist, um
das Interface zu implementieren. Gerade
Anfängern der testgetriebenen Entwick-
lung sei empfohlen, auch solche Minischritte zu gehen. Das flexible Anpassen
der „Schrittweite“ beim testgetriebenen
Entwickeln hat viel mit Erfahrung zu tun
und muss daher geübt werden.
Der nächste Test betrifft die ChildrenEigenschaft. Nach dem Instanzieren eines
neuen Knotens soll diese Liste leer sein.
Ganz wichtig an dieser Stelle: Vergessen Sie
null in dem Zusammenhang. Es ist keine
gute Idee, hier null zu liefern anstelle einer
leeren Liste, weil dann der Verwender der
Klasse vor jedem Zugriff eine null-Prüfung
vornehmen müsste. Die Tatsache, dass der
Knoten keine Nachfolger hat, wird perfekt
repräsentiert durch eine leere Liste. Diese
kann beispielsweise auch ohne vorherige
Prüfung mit foreach durchlaufen werden.
Wenn die Liste leer ist, wird die Schleife
halt nicht ausgeführt. Eine zusätzliche
null-Prüfung würde den Code nur unnötig
aufblähen. Listing 3 zeigt den Test für die
Children-Eigenschaft.
Die Implementation ist einfach, der Test
treibt also auch hier noch nicht viel voran.
Sie definieren damit jedoch den Initialzustand eines Knotens. Sich darüber klarzuwerden ist nicht ganz unwichtig.
57
LÖSUNG
Listing 4
Listing 6
ChildValues testen.
Einen neuen Knoten
hinzufügen.
[Test]
public void Node_hat_nach_dem_Instanzieren_keine_Nachkommenwerte() {
var node = new Node<int>(1);
Assert.That(node.ChildValues, Is.Empty);
}
public Node<T> Add(T nodeValue) {
var node = new Node<T>(nodeValue);
children.Add(node);
return node;
}
Listing 5
Einen neuen Knoten testen.
[Test]
public void Für_einen_hinzugefügten_Wert_wird_ein_Knoten_angelegt() {
var node = new Node<int>(1);
var child = node.Add(2);
Assert.That(node.Children, Is.EquivalentTo(new[] {child}));
}
Weiter geht es mit der Eigenschaft ChildValues, siehe Listing 4.
Auch hier ist die Implementation für einen Knoten ohne Nachkommen trivial.
Immerhin haben Sie jetzt die Initialwerte
aller Eigenschaften definiert und können
sich einer neuen Aufgabe zuwenden.
Die einzige ändernde Operation, die ein
Knoten anbietet, ist das Hinzufügen eines
weiteren Knotens. Dabei habe ich mich in
der API-Definition dafür entschieden, dem
Knoten einen weiteren Wert hinzuzufügen.
Nehmen wir an, die Knoten hätten Zeichenketten als Werte. Dann gäbe es für das
API die beiden folgenden Möglichkeiten :
❚ Hinzufügen eines Knotens vom Typ
Node<string>.
❚ Hinzufügen einesWertes vom Typ string.
Ferner muss der Wert des Knotens in der
Liste der Nachkommenswerte (ChildValues)
auftauchen. Dass der neu erzeugte Knoten
von Add als Ergebnis zurückgeliefert wird,
ist übrigens nicht der Testbarkeit geschuldet. Beim Aufbauen eines Baumes ist es
handlich, auf die jeweils erzeugten Knoten
zugreifen zu können, weil auf diesen dann
weitere Methoden aufgerufen werden können. Damit wird das API für den Anwender
komfortabel in der Benutzung.
Übrigens habe ich diesen Test in einer
weiteren Testklasse angelegt. Die ersten
drei Tests befassen sich mit dem Initialzustand von Knoten, daher habe ich die Testklasse Node_Instanzieren_Tests genannt.
Das Hinzufügen von Knoten mit der AddMethode teste ich in der Klasse Node_ Add_
Tests. Die Implementation der Add-Methode verwendet eine interne Liste aller Nachkommen des Knotens. Dieser Liste wird bei
Add ein neues Element hinzugefügt, siehe
Listing 6.
Der nächste Test prüft, ob der hinzugefügte Wert in der Eigenschaft ChildValues
vertreten ist, siehe Listing 7. Mithilfe von
LINQ ist die Implementation der ChildValues-Eigenschaft simpel, siehe Listing 8.
Die Select-Methode aus dem Namespace
System.Linq ist eine Extension Method auf
IEnumerable<T>. Dadurch steht sie auf allen Aufzählungen zur Verfügung. Ergebnis
der Select-Methode ist wieder eine Aufzählung. Der übergebene Lambda-Ausdruck
gibt an, wie die Werte für die Ergebnisaufzählung gebildet werden sollen. Im vorliegenden Fall sollen aus der Aufzählung von
Knoten alle Werte extrahiert werden. Folglich gibt der Lambda-Ausdruck an, dass
node.Value geliefert werden soll.
Wenn die Knoten implementiert sind,
können Sie sich dem Baum zuwenden. Da
Listing 7
Das Hinzufügen eines Knotens sähe in
der Anwendung wie folgt aus :
Das erfolgreiche Hinzufügen eines Knotens testen.
node.Add(new Node("a");
[Test]
public void Der_hinzugefügte_Wert_wird_in_die_ChildValues_aufgenommen() {
var node = new Node<char>('x');
node.Add('y');
Assert.That(node.ChildValues, Is.EquivalentTo(new[]{'y'}));
}
Der erforderliche Aufruf des Konstruktors kann entfallen, wenn das API die Möglichkeit bietet, direkt einen Wert hinzuzufügen :
node.Add("a");
Die Add-Methode muss folglich einen
neuen Knoten anlegen und mit dem übergebenen Wert initialisieren, siehe Listing 5.
Als Ergebnis liefert die Add-Methode den
neu eingefügten Knoten als Rückgabewert
zurück. So können Sie nach dem Hinzufügen beobachten, dass der neue Knoten in
der Liste der Nachkommen vorhanden ist.
58
Listing 8
LINQ nutzen.
public IEnumerable<T> ChildValues {
get { return Children.Select(node => node.Value); }
}
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 9
Einen neu angelegten Baum prüfen.
[Test]
public void Tree_mit_einem_Wurzelknoten_initialisieren() {
var tree = new Tree<string>("Wurzel");
Assert.That(tree.Root.Value, Is.EqualTo("Wurzel"));
}
dieser nur die Eigenschaft des Wurzelknotens Root hat, sollte die Implementation
leicht von der Hand gehen. Ähnlich wie
beim Wert eines Knotens hat auch die
Root-Eigenschaft nur einen Getter. Auch
hier habe ich mich entschieden, den Konstruktor des Baumes für die Initialisierung
zu verwenden. Da beim Knoten im Konstruktor der Wert des Knotens übergeben
wird, ist es konsequent, beim Baum ebenso zu verfahren. Der Konstruktor des Baumes legt also den Wurzelknoten an und initialisiert diesen mit dem übergebenen Wert.
Listing 9 zeigt den zugehörigen Test. Auch
hier bietet es sich an, den Test in eine eigene Testklasse zu schreiben. Schließlich geht
es nicht um Knoten, sondern um Bäume.
Ich habe die Testklasse Tree_Tests genannt.
Traversieren
Nun geht es an das Traversieren der Bäume. Dazu sind im API die Methoden PreOr-
Listing 10
Pre-Order-Traversierung testen.
[Test]
public void Ein_Knoten_mit_Nachkommen() {
var node = new Node<int>(1);
node.Add(2);
node.Add(3);
Assert.That(node.PreOrderValues(),
Is.EqualTo(new[]{1, 2, 3}));
}
Listing 11
Pre-Order-Traversierung
durchführen.
public IEnumerable<T> PreOrderValues(){
yield return Value;
foreach (var child in Children) {
yield return child.Value;
}
}
www.dotnetpro.de dotnetpro.dojos.2011
derValues und PostOrderValues zu implementieren. Die beiden Methoden müssen
jeweils den Baum durchlaufen und alle
Knotenwerte in Form einer Aufzählung
IEnumerable<T> liefern. Auch hier stand
die Entscheidung an, ob die Knoten oder
deren jeweilige Werte geliefert werden sollen. Alternativ wäre das Ergebnis also vom
Typ IEnumerable<INode<T>>. Sollte sich
später zeigen, dass eine solche Aufzählung
benötigt wird, können die zugehörigen
Methoden leicht ergänzt werden.
Zunächst steht wieder die Frage an, wie
der erste Test aussehen soll. Einen „leeren“
Baum zu traversieren ist nicht wirklich
spannend. Vor allem würde durch diesen
Test die Implementation nicht wirklich vorangetrieben. Schließlich besteht ein neu
initialisierter Baum lediglich aus einem
Knoten, der keine Nachkommen hat. Am
anderen Ende stehen Tests, für die gleich
die komplette Traversierung implementiert werden muss. Ein Test „dazwischen“
wäre gut: mehr als nur ein einzelner Knoten, aber weniger als gleich ein ganzer
Baum. Ein einzelner Knoten mit Nachkommen dürfte das Kriterium erfüllen. Damit
ergeben sich folgende Testszenarien :
❚ Traversieren eines Knotens, der keine
Nachkommen hat;
❚ Traversieren eines Knotens, der Nachkommen hat;
❚ Traversieren eines Knotens, dessen Nachkommen ebenfalls Nachkommen haben.
Ob man nun mit dem ersten oder dem
zweiten Szenario beginnt, ist Geschmacksache. Das Traversieren eines einzelnen
Knotens ohne Nachkommen ist eigentlich
so trivial, dass man es nicht gesondert testen muss. Spürt man aber Unsicherheit bei
der Implementation, hilft es möglicherweise, in solch kleinen Minischritten voranzuschreiten. Wichtig ist jedoch festzuhalten,
dass man sich vor dem ersten Test Gedanken über die Testdaten machen muss. Dabei hilft es, Beispiele zu sammeln und diese anschließend in Äquivalenzklassen zu-
sammenzufassen. So liegt beispielsweise
das Traversieren eines Knotens mit drei
Nachkommen in der gleichen Äquivalenzklasse wie das Traversieren von fünf Nachkommen. Haben die Nachkommen jedoch
selbst wieder Nachkommen, so ergibt sich
eine andere Äquivalenzklasse, denn hier
muss plötzlich rekursiv vorgegangen werden. Würde man gleich mit einem ersten
Test beginnen, ohne vorher über die Testdaten und eine sinnvolle Reihenfolge der
Implementation nachzudenken, wäre der
anstehende Implementationsaufwand für
den nächsten Schritt möglicherweise zu
groß. Nachdenken hilft!
Die Tests zur Traversierung habe ich wieder in eine eigene Testklasse abgelegt. Sie
heißt Node_Pre_Order_Traversieren_Tests.
Für die Implementation dieses Tests von
Listing 10 ist es lediglich notwendig, erst
den eigenen Knotenwert und anschließend die Werte der Nachkommen zu liefern. Unter tatkräftiger Mithilfe von yield
return sieht die Implementation dazu wie
in Listing 11 aus.
Ergänzt man nun einen Test für das erste Szenario, bei dem ein Knoten keine
Nachkommen hat, wird man feststellen,
dass dieser sofort erfolgreich verläuft, ohne
dass an der Implementation etwas geändert werden muss.
Doch nun geht es ans Eingemachte. Der
letzte Test befasst sich mit dem Szenario
eines Knotens, dessen Nachkommen ebenfalls Nachkommen haben, siehe Listing 12.
Das riecht doch sehr nach Rekursion!
Die Implementation könnte folgendermaßen aussehen: Gib den eigenen Knotenwert aus, und rufe anschließend die Traversierung für jeden Nachkommen auf. Dazu
müssen Sie allerdings die bisherige Implementation zunächst in eine Methode auslagern, die einen Knoten als Parameter erhält.
Die Methode ist dann dafür verantwortlich,
Listing 12
Einen Baum testen.
[Test]
public void Ein_Knoten_deren_Nachkommen_auch_Nachkommen_haben() {
var node1 = new Node<int>(1);
var node2 = node1.Add(2);
var node3 = node2.Add(3);
var node4 = node2.Add(4);
Assert.That(node1.PreOrderValues(),
Is.EqualTo(new[] { 1, 2, 3, 4 }));
}
59
LÖSUNG
Listing 13
Listing 14
PreOrderValues refaktorisieren.
Rekursion einführen.
public IEnumerable<T> PreOrderValues() {
return TraversePreOrder(this);
}
private static IEnumerable<T>
TraversePreOrder(INode<T> node) {
yield return node.Value;
foreach (var child in node.Children) {
yield return child.Value;
}
}
private static IEnumerable<T> TraversePreOrder(INode<T> rootNode) {
yield return rootNode.Value;
foreach (var child in rootNode.Children) {
foreach (var childValue in TraversePreOrder(child)) {
yield return childValue;
}
}
}
Listing 15
Post-Order-Traversierung implementieren.
einen einzelnen Knoten zu traversieren und
kann sich dabei rekursiv aufrufen. Der erste
Schritt ist also eine Refaktorisierung, bei der
eine Methode mit einem Knoten als Parameter eingeführt wird, siehe Listing 13.
Nach dieser Refaktorisierung können Sie
die Rekursion in der Methode TraversePreOrder einführen, siehe Listing 14.
Durch Verwendung von yield return
kann hier das Ergebnis des rekursiven Aufrufs nicht direkt als Resultat zurückgegeben werden. Schließlich ist das Ergebnis
vom Typ IEnumerable<T>. yield return erwartet aber einzelne Elemente vom Typ T.
Daher muss das Ergebnis in einer Schleife
durchlaufen werden und Element für Element an yield return übergeben werden.
Und jetzt andersrum
Um die Post-Order-Traversierung zu implementieren, ist nur eine kleine Änderung
notwendig. Sie müssen lediglich die Reihenfolge der Bearbeitung umstellen. Statt
zuerst den Knotenwert auszugeben und
dann die Nachkommen zu bearbeiten,
werden erst die Nachkommen bearbeitet,
und danach wird der Knotenwert ausgegeben, siehe Listing 15.
Vergessen Sie bei diesem Copy-PasteVorgang nicht, den rekursiven Aufruf anzupassen. Hier muss TraversePostOrder rekursiv aufgerufen werden. Bei der Kontrolle
kann mal wieder JetBrains ReSharper behilflich sein. In Abbildung 1 sehen Sie einen
Ausschnitt aus der Methode. ReSharper erkennt den rekursiven Aufruf und markiert
diesen am linken Rand durch den kreisför-
private static IEnumerable<T> TraversePostOrder(INode<T> rootNode) {
foreach (var child in rootNode.Children) {
foreach (var childValue in TraversePostOrder(child)) {
yield return childValue;
}
}
yield return rootNode.Value;
}
migen Pfeil. Die Tests für die Post-OrderTraversierung befinden sich in der Klasse
Node_Post_Order_Traversieren_Tests.
Zuletzt habe ich die Beispiele aus der Aufgabenstellung noch in einer Testklasse mit
dem Namen Integrationstests überprüft.
Alternativ hätte ich sie auch Akzeptanztests
nennen können, da es sich um die Beispiele handelt, die quasi zwischen Kunde und
Auftragnehmer besprochen wurden. Wenn
man als Entwickler mit dem Kunden Beispielfälle durchgeht mit dem Ziel, die Anforderungen zu verstehen, dann sollte man
diese Beispiele als Akzeptanztests festhalten. So ist sichergestellt, dass man nach der
Implementation das Gespräch mit dem
Kunden leicht wieder aufnehmen kann.
Zeigt man ihm dabei anhand der automatisierten Tests, dass die besprochenen Beispiele erfolgreich implementiert sind, so
schafft dies eine gute Vertrauensbasis.
Fazit
Datenstrukturen machen Spaß! Das Schöne an der Implementation von Datenstrukturen ist, dass sich diese meist gut automa-
[Abb. 1] Rekursion mit ReSharper
visualisieren.
60
tisiert testen lassen. Dadurch kann man
sich bei solchen Übungen darauf konzentrieren, eine geeignete Reihenfolge für die
Tests zu finden. Voraussetzung ist, dass
man vor dem ersten Test Testfälle sammelt.
Beim Zusammenstellen der Testfälle sollte
man möglichst darauf achten, ob diese in
dieselbe Äquivalenzklasse fallen. Schließlich genügt es, jeweils einen Repräsentanten der Äquivalenzklasse für einen Test herauszugreifen. Bezüglich der Addition sind
etwa die beiden zu addierenden Zahlen 2
und 3 sowie 4 und 5 in derselben Äquivalenzklasse. Es genügt daher, für eines der
beiden Zahlenpaare die Addition zu testen.
Ein zusätzlicher Test mit einem weiteren
Repräsentanten derselben Äquivalenzklasse würde keinen weiteren Erkenntnisgewinn bringen.
Beim Traversieren genügt es, einen Knoten zu testen, dessen Nachkommen ebenfalls einen Nachkommen haben. Es ist
nicht nötig, einen Baum mit vielen Ebenen
zu testen. Durch die rekursive Implementation ist dies auch ganz offensichtlich. In
anderen Fällen mag es nicht so offensichtlich sein, wie die Äquivalenzklassen der
Testdaten aussehen. Dann muss man möglicherweise länger darüber nachdenken
und mehr Testdaten sammeln. Einfach
drauflos mit den Tests zu beginnen ist aber
so oder so wenig hilfreich, da man dann
Gefahr läuft, Testfälle zu übersehen. [ml]
dotnetpro.dojos.2011 www.dotnetpro.de
AUFGABE
.NET Framework Grundlagen
Wie funktioniert LINQ?
Manche Grundlagen versteht man besser, wenn man sie einmal selbst implementiert hat.
Stefan, kannst du dazu eine Übung stellen?
L
INQ ist nun schon seit einiger Zeit Bestandteil des .NET Frameworks sowie
der C#- und VB.NET-Compiler [1]–[4].
Dennoch geht dazu bei Entwicklern
noch oft genug einiges durcheinander. Angefangen bei der Frage, wie LINQ ausgesprochen wird,
bis zur Verwechslung von LINQ mit SQL oder einem Object Relational Mapper. Da lohnt es sich
doch mal aufzuräumen. Beginnen wir mit der
Aussprache: LINQ wird gesprochen wie „Link“.
So einfach ist das. Und obwohl LINQ in Verbindung mit Object Relational Mappern verwendet
wird, ist es nicht selbst ein Mapper. Mit LINQ to
Objects arbeitet LINQ auf allem, was aufzählbar
ist, sprich IEnumerable<T> implementiert.
LINQ besteht aus zwei Teilen:
❚ der sogenannten Query Comprehension Syntax, die so sehr an SQL erinnert;
❚ einer Reihe von Extension Methods im .NET
Framework aus dem Namespace System.Linq.
Die Query Comprehension Syntax sorgt dafür,
dass Sie im Quellcode Abfragen schreiben können wie beispielsweise diese:
var query = from kunde in kunden where
kunde.Ort == "Köln" select kunde;
Kurz und knackig und für die meisten Entwickler gut zu lesen. Aber schon nachdem der
Compiler sein Werk verrichtet hat, ist von der
Query Comprehension nichts mehr übrig: Der
Compiler übersetzt diese Query nämlich in
äquivalente Aufrufe der Extension Methods aus
dem Namespace System.Linq. Das sieht dann etwa so aus:
var query = kunden
.Where(kunde => kunde.Ort == "Köln")
.Select(kunde => kunde);
Diese Compilermagie ist dem C#- sowie dem
VB.NET-Compiler spendiert worden. Die CLR
hat also von LINQ keine Ahnung und musste dazu nicht verändert werden. Die eigentliche Funktionalität von LINQ steckt folglich in den Extension Methods. Diese haben nämlich die Aufgabe,
den jeweiligen Teil der Query auszuführen. So
wird die Where-Klausel einer Query in einen Auf-
www.dotnetpro.de dotnetpro.dojos.2011
ruf der Where-Methode übersetzt. Gleiches geschieht für Select, Order, Group by etc. Um dabei
möglichst flexibel zu bleiben, ist LINQ auf dem
Interface IEnumerable<T> definiert.
Die Signatur der Where-Methode sieht wie
folgt aus:
static IEnumerable<T> Where<T>(this
IEnumerable<T> values, Predicate<T>
predicate);
Where arbeitet also auf einer Aufzählung und
liefert eine solche zurück. Damit stellt die WhereMethode eine Selektion oder Filterung dar. Für
jedes Element der Aufzählung wird nämlich mithilfe des Prädikats geprüft, ob die Bedingung für
das Element zutrifft. Und ausschließlich Elemente, für die das Prädikat true zurückgibt, landen im
Ergebnis.
Um sich mit der Funktionsweise von LINQ
auseinanderzusetzen, lautet die Aufgabe dieses
Mal daher: Implementieren Sie LINQ Extension
Methods. Beginnen Sie mit Where und Select,
beide sind recht einfach. Etwas kniffliger wird es
beim Gruppieren. Schauen Sie sich dazu die Signatur der GroupBy-Methode im Framework an,
und überlegen Sie, wie Sie eine Gruppierung implementieren können. Anschließend gehen Sie
testgetrieben vor.
Weitere interessante Herausforderungen finden Sie in den Methoden Distinct, Union, Intersect und Except. Oder versuchen Sie sich an
Count, Min, Max und Average. Langeweile dürfte so schnell nicht aufkommen. Viel Spaß! [ml]
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
[1] Mirko Matytschak, Was steckt hinter LINQ?
Language Integrated Queries: Neue Sprachmerkmale
für C# und VB. dotnetpro 2/2006, Seite 97ff.
www.dotnetpro.de/A0602Linq
[2] Ralf Westphal, dotnetpro.tv: LINQ – Language
Integrated Query, dotnetpro 11/2006, Seite 46,
www.dotnetpro.de/A0611dotnetpro.tv
[3] Patrick A. Lorenz, Kochen mit Patrick, zum Thema
LINQ, dotnetpro 8/2008, Seite 116 ff.,
www.dotnetpro.de/A0808Kochstudio
[4] Christian Liensberger, LINQ to Foo, Ein LINQ-Provider
für den eigenen Datenspeicher, dotnetpro 8/2008,
Seite 72ff., www.dotnetpro.de/A0808LINQ2X
61
Wer übt, gewinnt
dnpCode: A1101DojoAufgabe
LÖSUNG
LINQ im Eigenbau
So geLINQt es
Grundlagen muss man gut verstanden haben. Wer sie besonders gut verstehen will, sollte sie nachbauen. Bei dem
Versuch, LINQ selbst zu implementieren, hat auch Stefan wieder etwas dazugelernt.
O
b man eine Technologie wirklich
versteht und beherrscht, merkt
man, sobald man versucht, sie
selbst zu implementieren. Im Fall von LINQ
liegt der Schwerpunkt auf der reinen Funktionalität. Unterschätzen Sie nicht, wie viel
Sie lernen können, wenn Sie vorhandene
Funktionalität nachbauen. Wissen Sie beispielsweise, wie generische Methoden definiert werden?
Die Reise durch LINQ soll bei der WhereMethode starten. Der erste Schritt ist die
Signatur. Where ist eine Extension Method
auf dem Typ IEnumerable<T>. Die Anforderungen an eine Extension Method sind
überschaubar :
❚ Die Methode muss in einer statischen
Klasse deklariert sein. Dadurch ist sie
selbst ebenfalls statisch.
❚ Der erste Parameter der Methode muss
zusätzlich mit dem Schlüsselwort this
gekennzeichnet werden.
Für die Where-Methode kommt hinzu,
dass sie generisch sein muss. Das bedeutet,
dass der Typ der Aufzählung nicht fix ist,
sondern als generischer Typparameter angegeben werden kann. Lässt man die Be-
dingung der Where-Methode fürs Erste
einmal weg, ergibt sich folgende Signatur :
Damit haben Sie alle Zutaten für die Signatur der Where-Methode zusammen :
IEnumerable<T> Where<T>(this
IEnumerable<T> values);
public static IEnumerable<T>
Where<T>(this IEnumerable<T> values,
Predicate<T> predicate);
Die Methode arbeitet also auf einer Aufzählung vom Typ T und liefert eine ebensolche zurück. Der generische Typparameter T muss syntaktisch beim Methodennamen deklariert werden.
Doch es fehlt noch das Prädikat. In der
Logik liefert ein Prädikat für ein Element
einen booleschen Wert. Den erforderlichen
Typ gibt es natürlich im .NET Framework,
aber das gilt ja auch für die Where-Methode.
Daher hier die Definition von Predicate<T>:
public delegate bool Predicate<T>(T t);
Die delegate-Deklaration definiert den
Typ einer Methode. Der Name dieses Methodentyps lautet Predicate. Methoden
dieses Typs sind generisch, der Typparameter T ist daher beim Methodentyp definiert. Des Weiteren definiert diese delegateDeklaration, dass der Rückgabewert der
Methode vom Typ bool ist und dass der
Methode ein Parameter vom generischen
Typ T übergeben werden muss.
Listing 1
Seid ihr alle da?
[Test]
public void Prädikat_liefert_immer_true() {
var values = new[] {1, 2, 3}.Where(x => true);
Assert.That(values, Is.EqualTo(new[] {1, 2, 3}));
}
Listing 2
Daten selektieren.
[Test]
public void Prädikat_liefert_nur_für_gerade_Werte_true() {
var values = new[] {1, 2, 3, 4}.Where(x => x % 2 == 0);
Assert.That(values, Is.EqualTo(new[] {2, 4}));
}
62
Mein erster Test für die Implementation
prüft, ob alle Elemente geliefert werden,
wenn das Prädikat immer true liefert, siehe
Listing 1. Die zugehörige Implementation
ist leicht: Die Eingabe wird einfach zurückgeliefert und das Prädikat ignoriert. Der
nächste Test erfordert dann bereits das Iterieren und elementweise Auswerten des
Prädikats. Der in Listing 2 gezeigte Test filtert die Aufzählung nach geraden Werten.
Die Implementation ist dank des Operators yield return sehr überschaubar, siehe
Listing 3.
Listing 3
yield return nutzen.
public static IEnumerable<T> Where<T>(this
IEnumerable<T> values, Predicate<T>
predicate) {
foreach (var value in values) {
if (predicate(value)) {
yield return value;
}
}
}
Wer yield return bislang nicht kannte,
wird die Werte, für die das Prädikat true liefert, vermutlich in einer List<T> gesammelt
haben. Doch Vorsicht, neben dem eleganteren, weil kürzeren Äußeren unterscheiden sich die beiden Lösungen deutlich in
der Semantik. Verwendet man eine Liste, in
der das Ergebnis zusammengestellt wird,
erfolgt das Zusammenstellen komplett innerhalb der Where-Methode. Das bedeutet
vor allem, dass alle Elemente gleichzeitig
im Speicher Platz finden müssen. Bei kleinen Datenmengen ist das sicher kein Problem – aber überlegen Sie, was passiert,
wenn Sie auf diesem Weg eine etwas größe-
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 4
Listing 5
Berechnen mit Select.
Select anwenden.
[Test]
public void String_Länge_wird_ermittelt() {
var values = new[] {"abc", "a",
"ab"}.Select(s => s.Length);
Assert.That(values, Is.EqualTo(new[]{3,
1, 2}));
}
public delegate TOutput Func<TInput, TOutput>(TInput input);
re Datei einlesen und dann filtern. Bei Verwendung von yield return sorgt der Compiler für etwas Magie. Denn er erzeugt einen
endlichen Automaten für das Zusammenstellen der Aufzählung. Dieser sorgt dafür,
dass die Methode immer nur dann aufgerufen wird, wenn wieder ein Element benötigt wird. Es muss quasi erst jemand an
der Aufzählung „ziehen“, damit ein Element durch das Prädikat überprüft wird.
Die Auswertung des Prädikats erfolgt somit
Element für Element statt für die gesamte
Eingabe auf einmal. Somit können Sie mit
yield return potenziell unendlich große
Datenmengen bearbeiten.
Select
Danach steht die Select-Methode an. Sie
dient dazu, den Elementtyp der Aufzählung
zu transformieren. Enthält die ursprüngliche Aufzählung beispielsweise Adressen,
können Sie mit Select eine einzelne Eigenschaft selektieren. Dabei können natürlich
auch Berechnungen erfolgen, wie der Test
in Listing 4 zeigt.
Der Test liefert zu jedem Eingangsstring
dessen Länge zurück. Dazu müssen Sie die
Aufzählung durchlaufen und die SelectFunktion auf jedes Element anwenden. Mit
yield return ist das ebenfalls keine Hexerei,
siehe Listing 5. Für die Select-Methode benötigen Sie wiederum eine delegate-Deklaration. Diesmal definieren Sie einen Methodentyp mit einem Eingangsparameter
und einem Rückgabewert. Beide sind von
generischem Typ. Es handelt sich damit
um eine Funktion, die ein Element vom
Eingabetyp TInput in den Ausgabetyp
TOutput transformiert. In der Select-Methode wird diese Funktion innerhalb einer
Schleife auf jedes Element der Aufzählung
angewandt. Das Ergebnis wird mit yield return an den Aufrufer geliefert.
GroupBy
Kommen wir nun zu den etwas kniffligeren
Methoden. Die GroupBy-Methode grup-
www.dotnetpro.de dotnetpro.dojos.2011
public static IEnumerable<TOutput> Select<TInput, TOutput>(this IEnumerable<TInput>
values, Func<TInput, TOutput> selector) {
foreach (var value in values) {
yield return selector(value);
}
}
Listing 6
IGrouping implementiert IEnumerable.
public interface IGrouping<TKey, TElement> : IEnumerable<TElement> {
TKey Key { get; }
}
Listing 7
Gruppierung ermöglichen.
public class Grouping<TKey, TElement> : IGrouping<TKey, TElement> {
private readonly TKey key;
private readonly IEnumerable<TElement> values;
public Grouping(TKey key, IEnumerable<TElement> values) {
this.key = key;
this.values = values;
}
public IEnumerator<TElement> GetEnumerator() {
return values.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() {
return GetEnumerator();
}
public TKey Key {
get { return key; }
}
}
piert die Elemente einer Aufzählung nach
einem Schlüsselwert und liefert eine neue
Aufzählung zurück. Die Ergebnisaufzählung enthält für jeden Schlüsselwert der
Eingabe ein Element. Ein Beispiel: Sie möchten die Zahlen von 1 bis 10 danach gruppieren, ob sie gerade oder ungerade sind.
Das Ergebnis von GroupBy(x => x % 2 == 0)
würde dann folgendermaßen aussehen :
new[]{
new[] { 1, 3, 5, 7, 9 },
new[] { 2, 4, 6, 8, 10}
}
Das Ergebnis ist also eine Aufzählung,
die wiederum zwei Aufzählungen enthält.
Der Elementtyp dieser Aufzählung lautet
IGrouping<bool, int>. Der Trick an der Stelle ist: IGrouping<TKey, TElement> implementiert IEnumerable<TElement>. Dadurch sind die einzelnen Elemente der
Aufzählung ebenfalls aufzählbar. Aber
IGrouping hat noch eine weitere Eigenschaft, wie das Interface in Listing 6 zeigt.
Über die Eigenschaft Key kann der Schlüsselwert ermittelt werden, der zu diesem
Element der Gruppierung gehört.
Listing 7 zeigt das Interface. Im Konstruktor werden Schlüssel und zugehörige Werte
übergeben und in Feldern abgelegt. Doch
wie erfolgt nun die Gruppierung der Eingangsdaten?
Schauen Sie sich dazu zunächst die Signatur der GroupBy-Methode an:
63
LÖSUNG
Listing 8
Listing 10
Zeichenketten gruppieren.
Wer ist der Erste?
[Test]
public void GroupBy_Länge_des_Wortes() {
var values = new[] {"abc", "a", "ab", "a", "abc"};
var groups = values.GroupBy(x => x.Length);
Assert.That(groups, Is.EqualTo(new[]{new[]{"abc", "abc"}, new []{"a", "a"},
new[]{"ab"}}));
}
[Test]
public void Drei_Elemente() {
Assert.That(new[] {1, 2, 3}.First(),
Is.EqualTo(1));
}
Listing 11
Listing 9
Mit MoveNext auf den 1.Platz.
GroupBy, selbst gebaut.
public static IEnumerable<IGrouping<TKey, TElement>> GroupBy<TKey, TElement>
(this IEnumerable<TElement> values, Func<TElement, TKey> keyFunction) {
var dictionary = new Dictionary<TKey, IList<TElement>>();
foreach (var value in values) {
if (!dictionary.ContainsKey(keyFunction(value))) {
dictionary[keyFunction(value)] = new List<TElement>();
}
dictionary[keyFunction(value)].Add(value);
}
foreach (var d in dictionary) {
yield return new Grouping<TKey, TElement>(d.Key, d.Value);
}
}
public static
IEnumerable<IGrouping<TKey, TElement>>
GroupBy<TKey, TElement>(this
IEnumerable<TElement> values,
Func<TElement, TKey> keyFunction)
Die Methode erhält neben den Eingangsdaten eine Funktion, die zu einem
Element den zugehörigen Schlüsselwert
liefert. Aufgabe der GroupBy-Methode ist
es nun, die Eingangsdaten Element für
Element zu durchlaufen und jeweils den
Schlüssel des Elements zu ermitteln. Anschließend muss das Element in die zu
seinem Schlüssel gehörige Aufzählung eingereiht werden. Gruppiert man beispielsweise Zeichenketten nach ihrer Länge,
muss das Element „a“ in die Aufzählung
zum Schlüsselwert 1 eingereiht werden.
Listing 8 zeigt einen Test, der Zeichenketten nach ihrer Länge gruppiert.
Wenn man nun überlegt, wie man diese
Funktionalität implementieren kann, wird
klar, dass die Eingangsdaten innerhalb der
GroupBy-Methode vollständig behandelt
werden müssen, ehe das Ergebnis geliefert
werden kann. Das Ergebnis kann nicht Element für Element gebildet werden, weil die
Elemente des Ergebnisses selbst wieder
64
Aufzählungen sind. Um die erste gruppierte Liste herausgeben zu können, müssen
die Schlüssel aller Eingangselemente geprüft worden sein. Also ist es für diese Methode angemessen, eine Variable zu verwenden, in der das Ergebnis erst vollständig gebildet wird.
Die GroupBy-Methode ist übrigens nicht
die einzige, bei der das Ergebnis vollständig
gebildet werden muss, ehe es als Rückgabewert herausgegeben werden kann. Das Sortieren der Elemente ist ein weiteres Beispiel.
Für die Gruppierung bietet es sich an,
mit einem Dictionary zu arbeiten. Darin
können Sie die Schlüsselwerte der Elemente als Schlüssel im Dictionary verwenden.
Der zugehörige Wert der Dictionary-Einträge ist dann jeweils eine Liste von Elementen. Das Dictionary ist daher von folgendem Typ :
var dictionary = new Dictionary<TKey,
IList<TElement>>();
Damit sieht die Implementation der
GroupBy-Methode wie in Listing 9 aus.
Die Methode besteht aus zwei Teilen. Im
ersten Teil werden die Elemente in die zu ihrem Schlüssel gehörige Liste eingereiht. Dabei ist jeweils zu prüfen, ob die Liste bereits
public static T First<T>
(this IEnumerable<T> values) {
var enumerator =
values.GetEnumerator();
enumerator.MoveNext();
return enumerator.Current;
}
existiert. Immer wenn ein Schlüssel zum
ersten Mal auftritt, wird das Element im
Dictionary angelegt. Im zweiten Teil wird
das fertige Dictionary durchlaufen und für
jedes Element ein Grouping zurückgegeben.
First
Beim Ausprobieren der GroupBy-Methode
aus dem .NET Framework habe ich in einem Test die First-Methode verwendet.
Diese liefert das erste Element einer Aufzählung, siehe Listing 10.
Für die Implementation ist es wichtig zu
wissen, wie ein Enumerator funktioniert.
Er muss nämlich vor dem ersten Zugriff auf
das aktuelle Element durch einen Aufruf
von MoveNext initialisiert werden. Mit dieser Kenntnis ist die Implementation einfach, siehe Listing 11.
Ganz wichtig ist hier übrigens die lokale
Variable für den Enumerator. Die Methode
GetEnumerator liefert nämlich bei jedem
Aufruf einen neuen Enumerator. Da MoveNext und Current jedoch auf demselben
Enumerator aufgerufen werden müssen,
ist das Zwischenspeichern in einer Variablen notwendig.
Distinct
Weiter geht’s mit der Methode Distinct. Sie
liefert jedes Element einer Aufzählung nur
genau einmal. Enthält die Aufzählung ein
Element mehrfach, wird es nur einmal weitergeleitet. Dazu ist es erforderlich, dass
sich die Methode merkt, welche Elemente
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 12
Distinct testen.
[Test]
public void Zwei_mal_das_gleiche_Element() {
Assert.That(new[] {1, 1}.Distinct(), Is.EqualTo(new[] {1}));
}
[Test]
public void
Mehrere_mehrfach_auftretende_Elemente() {
Assert.That(new[] {1, 2, 1, 2,
3}.Distinct(),
Is.EqualTo(new[] {1, 2, 3}));
}
sie bereits geliefert hat. Mithilfe dieses
„Merkzettels“ ist es möglich, jedes Element
einzeln zu behandeln. Im Gegensatz zu
GroupBy oder Sort muss also nicht die gesamte Aufzählung auf einmal bearbeitet
werden. Listing 12 zeigt die ersten Tests.
Den „Merkzettel“ habe ich über eine
List<T> realisiert. Der eine oder andere
Leser wird vermutlich sofort zusammenzucken und sich über die Performance Gedanken machen. Doch Obacht! Keep it
Simple Stupid (KISS) lautet die Devise. Sollte sich später wirklich ein PerformanceEngpass zeigen, kann immer noch eine effizientere Implementation gesucht werden.
Listing 13 zeigt meine Implementation.
Min
Fortgesetzt habe ich meinen kleinen LINQAusflug mit der Min-Methode. Denn dabei
stellt sich eine weitere Herausforderung:
Wie kann man Elemente eines beliebigen
generischen Typs miteinander vergleichen?
Für die bislang gezeigten Methoden genügte es, dass zwei Elemente auf Gleichheit überprüft werden konnten. Da die
Equals-Methode zum Bestandteil der Basisklasse object gehört, war dies bislang
kein Problem. Doch um das kleinste Element einer Aufzählung zu ermitteln, müssen die Elemente verglichen werden können. Listing 14 zeigt dazu einen Test.
Für den Vergleich zweier Elemente haben Sie mehrere Möglichkeiten :
❚ Sie können mehrere Überladungen der
Min-Methode anbieten. Die dabei verwendeten Elementtypen müssen einen
Vergleichsoperator definieren.
❚ Sie können den generischen Elementtyp
T mit einem Constraint versehen, welches
dafür sorgt, dass der Typ T das Interface
IComparable implementieren muss.
❚ Sie können die Klasse Comparer aus dem
.NET Framework verwenden.
Die erste Variante wird im .NET Framework zwar verwendet, doch das hat sicherlich seinen Grund in besserer Performance.
Ich habe daher zunächst nach Variante
zwei implementiert und den Elementtyp
mit einem Constraint versehen, siehe Listing 15. Das Constraint where T : IComparable sorgt dafür, dass der Compiler überprüft, ob der Typ T das Interface IComparable implementiert. Damit wird zur Kompilierzeit sichergestellt, dass CompareTo
aufgerufen werden kann.
Etwas unschön am generischen Typ ist,
dass man ohne ein weiteres Constraint
nicht davon ausgehen kann, dass es sich
um einen Referenztyp handelt. Damit kann
die Variable minimum, die das bislang
kleinste gefundene Element hält, nicht mit
null initialisiert werden, um anzuzeigen,
dass es noch kein Minimum gibt. Den Typ
T mit einem Constraint auf Referenztypen
einzuschränken wäre auch nicht sinnvoll,
denn dann könnte kein Minimum einer Integer-Aufzählung ermittelt werden. Daher
habe ich eine boolescheVariable minimumGefunden eingeführt, in der festgehalten
wird, ob bereits ein Minimum gefunden
wurde. Nur dann darf das aktuelle Element
gegen das bislang gefundene kleinste Ele-
Listing 13
Distinct realisieren.
public static IEnumerable<T> Distinct<T>(this IEnumerable<T> elements) {
var distinctElements = new List<T>();
foreach (var element in elements) {
if (!distinctElements.Contains(element)) {
distinctElements.Add(element);
yield return element;
}
}
}
www.dotnetpro.de dotnetpro.dojos.2011
Listing 14
Die kleinste Zahl finden.
[Test]
public void Mehrere_ints() {
Assert.That(new[] {4, 2, 3,
1}.Min(), Is.EqualTo(1));
}
Listing 15
Daten vergleichen.
public static T Min<T>(this
IEnumerable<T> elements) where T :
IComparable {
var minimumGefunden = false;
var minimum = default(T);
foreach (var element in elements) {
if (!minimumGefunden) {
minimumGefunden = true;
minimum = element;
}
else if (element.CompareTo(minimum) < 0) {
minimum = element;
}
}
if (!minimumGefunden) {
throw new InvalidOperationException();
}
return minimum;
}
ment verglichen werden. Zu Variante drei
sei verraten, dass ich darauf auch erst
durch einen Blick in den .NET-FrameworkQuellcode gekommen bin. Bislang wusste
ich nicht, dass es die statische Klasse Comparer gibt. Mit ihrer Hilfe kann man sich zu
einem Typ einen Comparer liefern lassen:
var comparer = Comparer<T>.Default;
Dadurch kann das Typconstraint entfallen. Wieder was dazugelernt!
Fazit
Ich habe bei dieser Übung zwei Dinge gelernt: Obwohl ich GroupBy schon oft verwendet habe, waren mir die Details von
IGrouping nicht klar. Und dass man sich
beim .NET Framework einfach so einen
Comparer abholen kann, war mir auch neu.
Also hat sich die kleine Übung gelohnt! [ml]
[1] Patrick A. Lorenz, Kochen mit Patrick zum
Thema LINQ, dotnetpro 8/2008, Seite 116 ff.,
www.dotnetpro.de/A0808Kochstudio
65
AUFGABE
Einen Twitterticker realisieren
Was pfeifen die Spatzen?
Gute Übungsaufgaben müssen cool sein. Sonst macht das Herumtüfteln keinen Spaß. Also, Stefan: Kannst du
eine Aufgabe stellen, bei der ein cooles Programm entsteht, das zugleich technisch herausfordernd ist?
Wer übt, gewinnt
dnpCode: A1102DojoAufgabe
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
T
witterwalls erfreuen sich auf Veranstaltungen wachsender Beliebtheit.
Eine Twitterwall zeigt regelmäßig aktualisiert Tweets, die ein vordefiniertes Hashtag enthalten. Die gefundenen Tweets
werden an eine Wand projiziert. Auf diese Weise
können Teilnehmer der Veranstaltung Tweets zur
Veranstaltung absetzen und die Twitterwall wie
ein Schwarzes Brett nutzen.
Doch in diesem dotnetpro.dojo soll es nicht
um eine Twitterwall gehen, sondern um ein Twitterband. Auch das Twitterband soll Tweets mit einem definierten Hashtag suchen und darstellen.
Allerdings sollen die Tweets wie ein visuelles
Laufband dargestellt werden, ähnlich den Börsentickern bei einschlägigen Nachrichtensendern.
Der Suchbegriff soll dem Programm über die
Kommandozeile übergeben werden. Anschließend soll das Programm die Tweets abrufen und
darstellen. Dabei sollen die üblichen Angaben
visualisiert werden:
❚ Text des Tweets,
❚ Benutzername,
❚ Profilfoto,
❚ Zeitstempel,
❚ Client, mit dem der Tweet abgesetzt wurde.
Zu Anfang können Sie natürlich den Funktionsumfang reduzieren und zunächst nur den
Text des Tweets anzeigen.
In regelmäßigen Abständen muss das Programm die Tweets aktualisieren. Dazu muss erneut eine Anfrage an Twitter abgesetzt werden.
Die Benutzerschnittstelle soll während der Abfrage nicht einfrieren. Hier kommt also Multithreading ins Spiel. Ein Timer, der in regelmäßigen Abständen ein Ereignis auslöst, kann hier
zum Einsatz kommen. Doch Obacht! Die beiden
Threads müssen dann synchronisiert werden,
damit die Aktualisierung der Benutzerschnittstelle auf dem UI-Thread erfolgt.
Den Zugriff auf das Twitter-API könnten Sie
natürlich selbst entwickeln. Hier empfehle ich
jedoch, eines der vorhandenen Open-SourceFrameworks einzusetzen. Andernfalls wird Sie
diese Übung längere Zeit beschäftigen. Ich habe
gute Erfahrungen gemacht mit Twitterizer [1].
Die Benutzerschnittstelle können Sie mit Windows Forms oder WPF angehen. Auch eine Silverlight-Anwendung wäre denkbar. Abbildung 1 zeigt
einen groben Entwurf der Benutzerschnittstelle.
Die einzelnen Tweets sollen nebeneinander angezeigt werden und von rechts nach links durchs
Fenster laufen. Zusammengenommen besteht
die Herausforderung dieser Übung darin, ein Modell für die Implementation zu entwickeln und
dieses umzusetzen. Bei der Umsetzung geht es
vor allem um Multithreading und die damit verbundene Synchronisation. Aber auch in der Benutzerschnittstelle stecken Herausforderungen.
Zur Modellierung und Umsetzung empfehle ich,
Event-Based Components einzusetzen. Ralf Westphal hat dazu in zurückliegenden Heften einiges
veröffentlicht [2]–[4]. Probieren Sie es doch mal
aus! Im nächsten Heft gibt’s meine Lösung. [ml]
[1] http://www.twitterizer.net/
[2] Ralf Westphal, Zusammenstecken – funktioniert,
Event-Based Components, dotnetpro 6/2010, S. 132ff.,
www.dotnetpro.de/A1006ArchitekturKolumne
[3] Ralf Westphal, Stecker mit System, Event-Based
Components, dotnetpro 7/2010, S. 126 ff.,
www.dotnetpro.de/A1007ArchitekturKolumne
[4] Ralf Westphal, Nicht nur außen schön, Event-Based
Components, dotnetpro 8/2010, S. 126 ff.,
www.dotnetpro.de/A1008ArchitekturKolumne
[Abb. 1]
Ein Twitterticker.
66
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Einen Twitter-Ticker realisieren
Der Zwitscherfinder
Hat da jemand 'piep' gesagt? Der Zwitscherfinder weiß die Antwort. Alle paar Minuten checkt er die Twitter-Website
nach dem gesuchten Schlüsselwort und präsentiert das Ergebnis. Übrigens lässt sich auch ein Zwitscherfinder
vorteilhaft über Event-Based Components realisieren.
A
m Anfang dieser kleinen Anwendung stand für mich ein Spike. Ich
hatte nämlich keine klare Vorstellung davon, wie das Open-Source-Framework Twitterizer [1] zu verwenden ist. Ferner wusste ich nicht so ganz genau, wie das
Ergebnis der Twitter-Suche, eine Liste von
Tweets, mit WPF visualisiert werden kann.
Dass das irgendwie mit Databinding und
einem Item-Template in einer ListView gehen würde, war mir klar. Aber wie genau?
Also habe ich eine Spike-Solution erstellt.
In der ist sozusagen alles erlaubt. Logik im
UI, keine Tests, alles in Ordnung. Solange
das Ergebnis am Ende lediglich dazu verwendet wird, Erkenntnisse zu gewinnen.
Das bedeutet nicht zwangsläufig wegwerfen. Häufig dient eine Spike-Solution auch
später noch mal dazu, wieder in ein Thema
reinzukommen. Oder ein Kollege möchte
sich mit der Technik vertraut machen. Legen Sie daher Ihre Spikes ruhig im Versionskontrollsystem ab, natürlich gut gekennzeichnet in einem separaten Verzeichnis.
Nachdem ich durch den Spike herausgefunden hatte, wie das Twitter-API zu bedienen ist, habe ich begonnen, die Lösung des
Problems zu modellieren. Dabei bin ich in
zwei Iterationen vorgegangen: In einem
ersten Modell habe ich das periodische Aktualisieren der Tweets weggelassen. Statt
also nach einer gewissen Zeit erneut bei
Twitter nach Tweets zu suchen, wird dort
nur einmal gesucht und die so gefundenen
Tweets werden angezeigt. Dadurch wurde
das Modell einfacher und ich konnte mich
auf den Kern der Anwendung konzentrieren: Ausgehend von einem Suchbegriff
werden die gefundenen Tweets in einem
Laufband visualisiert.
Das vereinfachte Modell und dessen
Umsetzung bietet schon einen großen
Nutzen für den potenziellen Kunden: Die
Anwendung kann in dieser abgespeckten
Form sicherlich schneller entwickelt werden als in der Komplettversion. Damit
steht das Feedback des Kunden auch
schneller zur Verfügung. Ich komme auf
diesen Aspekt später zurück.
www.dotnetpro.de dotnetpro.dojos.2011
[Abb. 1] Erster Schritt mit zwei Funktionseinheiten.
Die Modellierung habe ich in Form von
Datenflüssen vorgenommen. Pfeile bedeuten hier den Fluss von Daten, Kreise stehen
für Funktionseinheiten. Eine Funktionseinheit kann atomar oder zusammengesetzt
sein. In Anlehnung an die Elektrotechnik
und die Umsetzung des Datenflussmodells
mittels Event-Based Components werden
die atomaren Funktionseinheiten Bauteile
(engl. Parts) genannt, die zusammengesetzten heißen Platinen (engl. Boards) [2]. Ein
Bauteil enthält Logik, während eine Platine
dafür zuständig ist, Funktionseinheiten zu
verdrahten. Das können wieder Bauteile
oder Platinen sein. Im Modell sieht man
den Funktionseinheiten nicht an, ob sie
Bauteil oder Platine sind. Das ist gut so,
denn es ermöglicht die spätere Verfeinerung durch Hierarchisierung. Eine ursprünglich als Bauteil modellierte Funktionseinheit kann später zu einer Platine
verfeinert werden. Dadurch ist am ursprünglichen Diagramm nichts zu ändern,
sondern es entsteht ein weiteres Diagramm, welches das Innenleben der Platine zeigt. Es handelt sich dabei also um ein
hierarchisches Modell. Die Schachtelung
kann beliebig tief erfolgen.
Im Modell der Twitterband-Anwendung
habe ich zunächst mit zwei Funktionseinheiten begonnen, wie Abbildung 1 zeigt:
❚ Die Funktionseinheit UI steht für die Benutzerschnittstelle, also alle Elemente,
über die der Benutzer mit der Anwendung interagiert. Ob dabei alles in einer
Form realisiert wird oder zusätzlich User
Controls eingesetzt werden, spielt bei der
Modellierung noch keine Rolle, denn es
ist ein Implementationsdetail.
❚ Die Funktionseinheit Tweets suchen enthält die Logik der Anwendung. Ausge-
hend von einem Suchbegriff als Eingabe
produziert diese Funktionseinheit mehrere Tweets als Ausgabe.
Im Modell bedeutet der Datenfluss
(Tweet*), dass mehrere Tweets geliefert werden. Dies wird durch den Stern angezeigt.
Ob dies später als Array, List oder IEnumerable realisiert wird, spielt im Modell
keine Rolle. Wichtig ist hier lediglich auszudrücken, dass es mehrere Tweets sind. Die
Daten sind in Klammern notiert, um deutlich zu machen, dass dies die Daten der
Nachricht sind. Eine Benennung der Nachricht fehlt, weil sich diese aus dem Namen
der Funktionseinheit Tweets suchen ergibt.
Würde die Funktionseinheit beispielsweise
Twitter heißen, wäre es notwendig, den
Datenfluss zu benennen. So könnte die
Eingabe dann beispielsweise Tweets suchen (Suchbegriff) heißen, während die
Ausgabe Ergebnis liefern (Tweet*) heißen
könnte.
Hier wird der Unterschied deutlich zwischen einer Modellierung mit Aktivitäten
wie Tweets suchen und Akteuren wie Twitter. Bei der Modellierung mit Aktivitäten
kann die Benennung der Nachricht meist
entfallen, während sie bei Modellierung
mit Akteuren für das Verständnis notwendig ist. In der Praxis hat sich gezeigt, dass
die konsequente Modellierung in Aktivitäten natürlicher ist und nach einer kurzen
Gewöhnungsphase leichter fällt. Die Gewöhnungsphase ist vor allem für Entwickler erforderlich, die sehr in der Objektorientierung verhaftet sind. Sie sind eher gewohnt, die Substantive zu suchen, um daraus Akteure zu machen.
Für die Tweets, die von der Funktionseinheit Tweets suchen zum UI geliefert wer-
67
LÖSUNG
[Abb. 2] Die Funktionseinheit „Tweets suchen“ weiter zerlegen.
den, habe ich einen eigenen Datentyp
Tweet vorgesehen. Alternativ hätte ich den
Datentyp Tweet aus dem Twitterizer-Framework verwenden können. Dann wäre das
UI jedoch von dieser Infrastruktur abhängig. Im UI wäre dann eine Referenz auf die
Twitterizer-Assembly erforderlich gewesen. Das wollte ich in jedem Fall vermeiden, schließlich sind Benutzerschnittstelle
und Ressourcenzugriffe völlig unterschiedliche Concerns und sollten daher getrennt
werden. Ferner bietet ein eigener Datentyp
die Möglichkeit, die Daten bereits so aufzubereiten, dass sie vom UI direkt verwendet werden können. Das führt dazu, dass
das UI die Daten nicht deuten muss. Damit
bleibt das UI extrem dünn und enthält keinerlei Logik. Ein automatisiertes Testen des
UIs kann entfallen.
Aus der Tatsache, dass Tweets suchen die
gefundenen Tweets in einem Datenmodell
ablegt, folgt, dass sich Tweets suchen um
zwei Belange kümmert: Zum einen findet
hier der Zugriff auf Twitter über das Twitterizer-API statt. Zum anderen ist die Funktionseinheit dafür zuständig, die Daten aus
dem Twitterizer-Datentyp in meinen eigenen Datentyp zu mappen und dabei gegebenenfalls aufzubereiten.
Da diese Erkenntnis bereits während des
Modellierens zutage trat, habe ich die Funktionseinheit Tweets suchen weiter zerlegt.
Um auf dem gleichen Abstraktionsniveau
zu bleiben und das bisherige Modell nicht
mit Details zu verwässern, die auf der Ebene nicht relevant sind, habe ich die Verfeinerung in einem weiteren Diagramm modelliert. Die Funktionseinheit Tweets suchen zerfällt dadurch intern in weitere
Funktionseinheiten, ist demnach also eine
Platine und kein Bauteil. Diese hierarchische Zerlegung ist durch die Modellierung in Datenflüssen und die Umsetzung
mit Event-Based Components auf einfache
Weise möglich. Vor allem kann die Schachtelung in beliebiger Tiefe erfolgen, ohne
dass dadurch bei der späteren Implementation Probleme auftreten.
Abbildung 2 zeigt die Zerlegung von
Tweets suchen. Die äußere Schnittstelle ist
68
logischerweise gleich geblieben, andernfalls würde die Funktionseinheit nicht
mehr in das sie umgebende Modell passen.
Der Suchbegriff wird an die Funktionseinheit Twitter abfragen übergeben. Diese liefert daraufhin eine Liste der gefundenen
Tweets im Datentyp des Twitterizer-Frameworks. Die Tweets werden von der Funktionseinheit Tweets mappen in das eigene
Datenmodell übersetzt. Dabei werden unter anderem Eigenschaften aus dem Originaltweet zu Zeichenketten zusammengefasst, damit das UI diese Informationen
nicht deuten muss, sondern sie direkt per
Databinding anzeigen kann. Nun liegen in
unserem Modell zwei verschiedene Arten
von Funktionseinheiten vor :
❚ Bauteile, die nicht weiter verfeinert sind
und Logik enthalten.
❚ Platinen, die weitere Funktionseinheiten enthalten und für deren Verbindungen zuständig sind.
Die Funktionseinheiten UI, Twitter abfragen und Tweets mappen sind Bauteile.
Dagegen ist die Funktionseinheit Tweets
suchen durch die Verfeinerung jetzt eine
Platine. Sie ist dafür zuständig, die beiden
enthaltenen Bauteile zu verdrahten, und
enthält selbst keine Logik.
In dieser ersten Modellierung habe ich
das regelmäßige Aktualisieren der Tweets
bewusst weggelassen. Das hat mehrere
Vorteile. Zum einen wird dadurch die Modellierung vereinfacht. Das gilt natürlich
nur unter der Prämisse, dass spätere Ergänzungen einfach möglich sind. Durch
die Modellierung mit Datenflüssen ist das
gegeben. Wäre eine spätere Ergänzung des
Modells mit hohem Änderungsaufwand
verbunden, wäre die vorläufige Vereinfachung teuer eingekauft.
Der zweite Vorteil ist darin zu sehen,
dass nun dieses Modell bereits implementiert werden kann. Dabei wird zwar noch
nicht die gesamte geforderte Funktionalität umgesetzt, aber auch hier gilt, dass man
diese später ergänzen kann, ohne dabei die
bereits implementierten Funktionseinheiten ändern zu müssen. Das liegt maßgeblich daran, dass ich zur Implementation
des Modells Event-Based Components verwende. Somit bietet die Vorgehensweise
den Vorteil der iterativen Entwicklung mit
sehr kurzer Iterationsdauer. Dadurch kann
der Kunde oder Product Owner sehr früh
Feedback geben.
Und so wie das gesamte Modell iterativ
entwickelt werden kann, kann man auch
bei der Implementation iterativ vorgehen:
❚ Die Übergabe des Suchbegriffs als Kommandozeilenparameter kann zunächst
weggelassen werden. Stattdessen wird
ein fester Suchbegriff verwendet, der in
der Anwendung hart codiert ist.
❚ Statt direkt auf das Twitter-API zuzugreifen und die eingehenden Tweets zu
mappen, kann zunächst eine hart codierte Liste von Tweets zurückgegeben
werden. Dadurch lässt sich die Entwicklung des Controls zur Anzeige der Tweets
vorantreiben.
❚ Umgekehrt kann auch zunächst auf das
Control verzichtet werden. Stattdessen
Listing 1
Die Bauteile verdrahten.
public class TwitterSearch {
private readonly Action<string> search;
public TwitterSearch() {
var twitter = new Twitter();
var mapper = new Mapper();
twitter.Out_Result += mapper.In_Map;
mapper.Out_Result += tweets => Out_Update(tweets);
search = query => twitter.In_Search(query);
}
public event Action<IEnumerable<Tweet>> Out_Update;
public void In_Search(string query) {
search(query);
}
}
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
werden die Tweets ganz simpel in einem
Label als Text angezeigt.
Listing 2
Die Anwendung testen.
Es bieten sich also zahlreiche Möglichkeiten, das Feature Tweets suchen und anzeigen
in kleinen Schritten umzusetzen. Ganz
wichtig dabei: Es handelt sich trotzdem immer um Längsschnitte durch alle Funktionseinheiten. Dadurch kann man die Anwendung bereits sehr früh an den Kunden übergeben, um Feedback einzuholen.
Für die Implementation von Tweets suchen müssen zwei Bauteile und eine Platine implementiert werden. Das eine Bauteil
ermittelt mithilfe des Twitter-APIs die Liste
der Tweets. Das andere bringt die gefundenen Tweets in eine Form, die vom UI unmittelbar verwendet werden kann. Um beide Bauteile herum liegt eine Platine, die für
die Verdrahtung der Bauteile zuständig ist.
Dazu muss der eingehende Methodenaufruf an das erste Bauteil der Platine weitergereicht werden. Das Ergebnis der Suche
muss zum Mapper weitergeleitet werden.
Und schließlich muss das Ergebnis des
Mappers als Endergebnis der Platine zurückgegeben werden. Diese Verdrahtung
zeigt Listing 1.
Hier werden die Bauteile im Konstruktor
unmittelbar instanziert, statt sie per Parameter zu injizieren. Da das Board lediglich
für die Verdrahtung zuständig ist, verzichte ich auf einen automatisierten Test dieser
Verdrahtung. Dieser wäre relativ aufwendig, weil dazu mittels Attrappen überprüft
werden müsste, ob die Verdrahtung korrekt
erfolgt ist. Ebenso verzichte ich auf einen
automatisierten Test der Twitter-Suche,
weil das Bauteil lediglich einen Aufruf des
Twitter-APIs kapselt.
Ich habe allerdings je einen Test ergänzt,
der explizit gestartet werden muss, um damit zu überprüfen, ob das Twitter-API prinzipiell korrekt verwendet wird und irgendein Ergebnis zurückgeliefert wird. Einen
vergleichbaren Test habe ich für die Platine
ebenfalls erstellt. So kann überprüft werden, ob die Verdrahtung korrekt erfolgt ist,
ohne dass dazu die komplette Anwendung
gestartet werden muss. Allerdings handelt
es sich hierbei nicht um Unit-, sondern um
Integrationstests, die dazu noch vom realen
Twitter-API abhängig sind. Da jedoch Platine und Twitter-Bauteil praktisch keine Logik enthalten, halte ich das Vorgehen für
angemessen. Das Explicit-Attribut an den
Testmethoden sorgt dafür, dass diese Tests
nur ausgeführt werden, wenn dies explizit
angefordert wird, siehe Listing 2. So kann
man weiterhin alle Tests der Assembly aus-
www.dotnetpro.de dotnetpro.dojos.2011
[TestFixture]
public class TwitterTests {
private Twitter sut;
private TwitterSearchResultCollection
result;
[SetUp]
public void Setup() {
sut = new Twitter();
sut.Out_Result += x => result = x;
}
[Test, Explicit]
public void
Suche_nach_einem_Hashtag() {
sut.In_Search("#ccd");
Assert.That(result.Count,
Is.GreaterThan(0));
}
}
führen, ohne dabei jedesmal lange auf die
Antwort von Twitter warten zu müssen.
Für das Mappen der Daten vom Twitterizer-Datentyp in den eigenen Datentyp
können natürlich Tests geschrieben werden, da diese Operation ja lediglich auf Daten arbeitet.
Für das Laufband-Control habe ich die
Suchmaschine meiner Wahl befragt. Dies
führte in der Tat zu einem WPF-Treffer.
Den gefundenen Quellcode habe ich dahingehend angepasst, dass die Breite des
durchlaufenden Contents mit in die Laufzeit der Animation eingeht. Ohne diese
Modifikation liefen Suchergebnisse mit
vielen Treffern sehr schnell durch das Fenster, während Treffer mit nur einem Tweet
sehr langsam angezeigt wurden. Die gewählte Lösung der Animation scheint mir
relativ viel Prozessorzeit zu verbraten. Ich
gestehe erneut, dass ich kein WPF-Spezialist bin. Wenn einem Leser eine bessere
Lösung für das Marquee-Control einfällt,
möge er sich bitte melden.
Nachdem das Feature Tweets suchen und
anzeigen umgesetzt ist, muss das nächste
Feature modelliert werden: Periodisches
Aktualisieren der Suche. Dabei will ich natürlich auf dem schon vorhandenen Modell aufsetzen und dieses erweitern. Durch
die Modellierung mit Datenflüssen ist das
einfach möglich, da zusätzliche Funktionseinheiten leicht in einen schon vorhandenen Datenfluss eingesetzt werden können.
Um die Suche periodisch zu aktualisieren, habe ich zwischen UI und Tweets suchen eine weitere Funktionseinheit gesetzt,
siehe Abbildung 3. Dieser Periodic Dispenser hat die Aufgabe, beim Eintreffen von
Daten einen Timer zu starten und die erhaltenen Daten periodisch herauszugeben. So wird aus einem einmaligen Datenfluss ein sich periodisch wiederholender.
Das Schöne dabei: An den vorhandenen
Funktionseinheiten müssen keine Änderungen vorgenommen werden. Einzige
Ausnahme stellt die Platine dar, die für die
Verdrahtung der Funktionseinheiten zuständig ist. Diese erhält zusätzliche Funktionseinheiten, und die Verdrahtung muss
abgeändert werden.
Ein weiterer schöner Effekt: Der Timer
hat nichts mit einer Twittersuche zu tun.
Das Bauteil ist also völlig generisch und
kann auch in einem anderen Kontext eingesetzt werden.
Wenn man den Periodic Dispenser in die
Anwendung integriert hat, stellt man fest,
dass nun die aktualisierten Tweets auf einem anderen Thread beim UI eintreffen als
bislang. Vor dem Einsatz des Timers gab es
[Abb. 3] Einen Timer ergänzen.
69
LÖSUNG
Listing 3
Periodische Aktualisierung ermöglichen.
public class PeriodicDispenser<T> {
private T t;
private readonly int timerIntervalInSeconds;
public PeriodicDispenser()
: this(60) {
}
internal PeriodicDispenser(int timerIntervalInSeconds) {
this.timerIntervalInSeconds = timerIntervalInSeconds;
Timer_konfigurieren();
}
public void In_Event(T t) {
this.t = t;
Out_Event(t);
}
public event Action<T> Out_Event;
private void Timer_konfigurieren() {
var timer = new Timer {
Interval = timerIntervalInSeconds * 1000
};
timer.Elapsed += (sender, e) => Out_Event(t);
timer.Start();
}
}
Listing 4
Die Synchronisierung durchführen.
public class Synchronizer<T> {
private readonly SynchronizationContext synchronizationContext;
public Synchronizer() {
synchronizationContext = SynchronizationContext.Current ?? new
SynchronizationContext();
}
public void In_Event(T t) {
synchronizationContext.Send(state => Out_Event(t), null);
}
public event Action<T> Out_Event;
}
in der Anwendung nur einen einzigen
Thread, nun sind es zwei. Damit das UI
nicht meckert, müssen die Aktualisierungen der UI-Controls auf dem UI-Thread
vorgenommen werden.
Nun könnte man dazu Anpassungen im
UI vornehmen und dort das Wechseln des
Threads implementieren. Einfacher ist es
allerdings, auch hier wieder eine zusätzliche Funktionseinheit in den Datenfluss
einzusetzen, die den Threadwechsel vornimmt. Diese Synchronization-Funktionseinheit lässt sich mit einem SynchronizationContext aus dem .NET Framework einfach realisieren.
Diese Vorgehensweise führt dazu, dass
die unterschiedlichen Belange sowohl im
70
Modell als auch in der Implementation
sauber getrennt bleiben.
Ferner taucht der Aspekt der Synchronisation im Modell explizit auf und sorgt damit für bessere Verständlichkeit. Würde
man die Synchronisation im UI implementieren, indem dort das übliche Muster von
InvokeRequired-Abfrage und Invoke-Aufruf
angewandt würde, wäre der Aspekt in der
Implementation verborgen. Dadurch würde das Verständnis der Implementation erschwert.
Hinzu kommt, dass nun die Funktionseinheiten Periodic Dispenser und Synchronization zu Standardbauteilen werden, die
auch in anderen Anwendungen zum Einsatz kommen können.
Damit ist das Thema Timer und Threadsynchronisation abgehakt. Und das in einer Form, die es erlaubt, den Aspekt im
Modell zu visualisieren. Damit dient das
Modell wirklich dem Verständnis der Implementation. Wäre die Synchronisation
im UI auf die übliche Art und Weise realisiert worden, würde die Implementation
den Entwurf nicht widerspiegeln. Einziger
Ausweg: den Aspekt im Modell weglassen.
Damit wäre aber niemandem gedient.
Der PeriodicDispenser ist als generische
Klasse implementiert, siehe Listing 3. Dabei gibt der generische Typ an, von welchem Typ der Datenfluss ist. Durch den parameterlosen Defaultkonstruktor wird der
Timer fix auf 60 Sekunden eingestellt. Für
einen automatisierten Test habe ich einen
weiteren Konstruktor ergänzt, der allerdings nur internal sichtbar ist. Durch das
Attribut InternalsVisibleTo ist der Konstruktor auch in der Testassembly sichtbar.
Beim eingehenden Pin In_Event werden
die Daten in einem Feld abgelegt. Dadurch
stehen sie in der Timerroutine zur Verfügung, um periodisch mit dem Out_Event
wieder ausgeliefert zu werden. Die Synchronisation erfolgt mithilfe des SynchronizationContext aus dem .NET Framework,
siehe Listing 4. Dieser wird im Konstruktor
angelegt. Daher muss das Bauteil auf dem
Thread erzeugt werden, auf den später die
eingehenden Ereignisse synchronisiert
werden sollen. Auch dieses Bauteil ist generisch, um den Typ des ein- und ausgehenden Datenflusses angeben zu können.
Fazit
Durch die Zerlegung der Gesamtanwendung in Features und deren getrennte Modellierung konnte die Gesamtaufgabe in
Iterationen aufgeteilt werden. Die weitere
Zerlegung der Features in Featurescheiben
bot weitere Möglichkeiten, iterativ vorzugehen. Das gibt beim Entwickeln ein gutes
Gefühl, weil man den Fortschritt sieht und
immer wieder etwas wirklich fertig wird.
Durch den Einsatz von Funktionseinheiten für die periodische Wiederholung und
die Synchronisation der Threads sind diese
beiden wichtigen Aspekte im Modell sichtbar. Das fördert das Verständnis und erhöht den Nutzen der Modellierung als Dokumentation der Implementation.
[ml]
[1] www.twitterizer.net
[2] Ralf Westphal, Zusammenstecken –
funktioniert, Event-Based Components,
dotnetpro 6/2010, S. 132 ff.,
www.dotnetpro.de/A1006ArchitekturKolumne
dotnetpro.dojos.2011 www.dotnetpro.de
AUFGABE
Algorithmen und Datenstrukturen zu Graphen
Wie hängt alles zusammen?
Mit einem Graphen kann man darstellen, wie die Dinge miteinander zusammenhängen. Weil aber alles mit allem
irgendwie zusammenhängt, kann man mit Graphen eigentlich alles darstellen. Das ist interessant, und deswegen
gibt es hier dazu eine Übung.
G
raphen sind eine Datenstruktur, mit
der sich viele Probleme auf einfache
und elegante Art lösen lassen. Nehmen wir als Beispiel das Referenzieren von Projekten und Assemblies in Visual-Studio-Projekten. Dabei ergibt sich die Frage, in welcher Reihenfolge die Projekte einer Solution
übersetzt werden müssen. Die Fragestellung ist
leicht zu lösen, wenn man von einer Baumstruktur ausgeht. Solange also zirkuläre Referenzen
unterbunden werden, genügt es, alle referenzierten Projekte in einer Baumstruktur abzulegen
und anschließend den Baum zu traversieren. Allerdings muss die Projektstruktur nicht zwingend
einen einzigen Baum ergeben, sondern es können mehrere Bäume sein. Ferner stellt sich die
Frage, wie man erkennt, ob es bei den Referenzen
zu Kreisen kommt.
Hier kommt die Datenstruktur Graph ins Spiel.
Ein Graph besteht ganz allgemein gesagt aus
Knoten und Kanten. Eine Kante setzt zwei Knoten in eine Beziehung. Dabei ist zu unterscheiden, ob die Kanten gerichtet oder ungerichtet
sind. Bei ungerichteten Kanten werden einfach
zwei Knoten in Beziehung gesetzt, ohne dabei eine Traversierungsrichtung mit abzulegen. Bei gerichteten Graphen hat jede Kante eine Richtung,
zeigt also von einem Quellknoten auf einen Zielknoten. Hierbei ist eine Traversierung in Kantenrichtung oder auch gegen die Kantenrichtung
möglich. Enthält ein Graph nur ungerichtete
Kanten, spricht man von einem ungerichteten
Graphen. Enthält er gerichtete Kanten, spricht
man von einem gerichteten Graphen.
Das Beispiel der Projektreferenzen lässt sich
mit einem gerichteten Graphen abbilden. Die
Richtung der Kante definiert, wer wen referenziert. Eine Kante von A nach B bedeutet in dem
Fall, dass Projekt A das Projekt B referenziert.
Normalerweise geht man bei Graphen davon
aus, dass es maximal eine Kante zwischen denselben Knoten geben kann. Sollen mehrere Kanten möglich sein, so spricht man von einem Multigraphen.
www.dotnetpro.de dotnetpro.dojos.2011
Das Schöne an Graphen ist, dass in der Literatur zahlreiche Algorithmen zu finden sind, um
beispielsweise herauszufinden, ob zwei Knoten
miteinander verbunden sind. Im Falle der Projektreferenzen würde das eine Abhängigkeit der
betroffenen Projekte bedeuten. Durch eine topologische Sortierung lässt sich die Reihenfolge der
Übersetzung von abhängigen Projekten herausfinden. Und auch für die Frage nach Kreisen gibt
es Algorithmen, die herausfinden, ob ein Graph
kreisfrei ist.
In vorangegangenen dotnetpro-dojos war beim
Thema Datenstruktur jeweils ein API vorgegeben.
Diesmal ist es ein Bestandteil der Übung: Entwerfen Sie ein API für den Umgang mit gerichteten
Graphen. Anschließend implementieren Sie die
Datenstruktur. Dazu gibt es in der Literatur genügend Vorschläge.
Im Anschluss sollten Sie sich einen der Algorithmen vornehmen und implementieren. Dabei
geht es nicht darum, einen Graphenalgorithmus
selbst zu „erfinden“, sondern es geht lediglich um
die Umsetzung. Beginnen Sie beispielweise mit
der topologischen Sortierung. Da die Algorithmen meist von einer bestimmten Art und Weise
der Implementation der Datenstruktur ausgehen,
beispielsweise einer Adjazenzmatrix, sollten Sie
sich mit dem Algorithmus befassen, bevor Sie die
Datenstruktur umsetzen.
Eine spannende Ergänzung zur Implementation der Datenstruktur ist natürlich die Visualisierung eines Graphen. Auch dabei muss man
das Rad nicht neu erfinden, sondern kann Bibliotheken wie das freie GraphViz [1] oder das lizenzpflichtige MSAGL [2] verwenden. MSAGL ist
inzwischen in der MSDN Subscription enthalten.
Im Downloadbereich findet man es unter Automatic Graph Layout.
Genügend Stoff, um Neues zu lernen. Oder wie
sagte der Professor in derVorlesung über Graphentheorie? „Das ganze Leben ist ein Graph.“
[ml]
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
[1] www.graphviz.org/
[2] http://research.microsoft.com/en-us/projects/msagl/
71
Wer übt, gewinnt
dnpCode: A1103DojoAufgabe
LÖSUNG
Algorithmen und Datenstrukturen zu Graphen
Wie die Welt zusammenhält
„Adjazenz“ bezeichnet keinen geistlichen Würdenträger und ist auch kein militärischer Dienstgrad, sondern steht für
die Beziehung zwischen Knoten und Kanten. Über adjazente, also miteinander verbundene Knoten kann man
Zusammenhänge modellieren und erforschen. dotnetpro macht einen Ausflug in die Graphentheorie, für die es viele
praktische Anwendungen gibt.
dnpCode: A1104DojoLoesung
Stefan Lieser ist Softwareentwickler aus Leidenschaft. Nach
seinem Informatikstudium mit
Schwerpunkt auf Softwaretechnik hat er sich intensiv mit
Patterns und Principles auseinandergesetzt. Er arbeitet als
Berater und Trainer, hält zahlreiche Vorträge und hat gemeinsam mit Ralf Westphal die Clean
Code Developer Initiative ins
Leben gerufen. Sie erreichen ihn
unter [email protected]
oder lieser-online.de/blog.
L
iteratur zu Graphenalgorithmen zu finden ist nicht schwer. Ich habe Robert
Sedgewicks „Algorithms in Java“ [1] herangezogen. Die Tatsache, dass die Beispiele in
Java vorliegen, sollte Sie nicht abschrecken. Java
ist nicht so weit weg von C#, dass man die Beispiele nicht problemlos übernehmen könnte.
Bevor es an die Algorithmen geht, muss man
sich natürlich Gedanken über die zugrunde liegende Datenstruktur machen. Auch hierbei hilft
die Literatur. Sie enthält Vorschläge, wie Graphen
implementiert werden können. Ich habe mit der
Repräsentation in Form einer sogenannten Adjazenzmatrix begonnen. Der Begriff Adjazenz steht
für die Beziehung zwischen Knoten oder Kanten.
Zwei Knoten sind adjazent, wenn sie durch eine
Kante verbunden sind. Zwei Kanten sind adjazent,
wenn sie einen gemeinsamen Knoten haben.
Die Idee einer Adjazenzmatrix ist ganz einfach:
In Form einer Matrix wird festgehalten, ob zwei
Knoten durch eine Kante verbunden sind. Die
Adjazenzmatrix enthält also die Information über
die Kanten. Repräsentiert wird sie durch ein zweidimensionales Array von booleschen Werten:
private readonly bool[,] adjacency =
new bool[VertexMaxCount,VertexMaxCount];
[Abb. 1] Ein Graph mit zugehöriger
Adjazenzmatrix.
72
Eine Kante zwischen den Knoten 5 und 7 wird
also dargestellt, indem im Array an Position [5, 7]
der Wert true steht. Knoten werden durch Integerzahlen repräsentiert und können daher als Index in der Adjazenzmatrix verwendet werden.
Ein Beispiel für einen Graphen und die zugehörige Adjazenzmatrix zeigt Abbildung 1.
Die Adjazenzmatrix mag dem ein oder anderen
Leser etwas verschwenderisch mit dem Speicherplatz umgehen. Allemal, wenn ein Graph nur wenige Kanten enthält und dadurch die meisten
Einträge in der Matrix auf false stehen. Hier sei
der Hinweis auf das Prinzip „Vorsicht vor Optimierungen“ erlaubt [2]. Wenn nicht gerade Graphen mit Tausenden von Knoten bearbeitet werden sollen, ist der Speicherbedarf vernachlässigbar. Im Vordergrund steht die Verständlichkeit der
Implementation, und die ist hier definitiv gegeben. Um beispielsweise zu ermitteln, ob eine
Kante vom Knoten v zum Knoten w existiert, genügt es, in der Adjazenzmatrix nachzuschauen:
if(adjacency[v, w]) {
...
}
Wer sich dennoch Sorgen um den Speicherplatz
macht, kann auch die Klasse BitArray aus dem
.NET Framework verwenden. Allerdings muss
man sich die Matrix dann selbst zusammenbasteln, da BitArrays nur eindimensional sind.
In meiner Implementation habe ich die Adjazenzmatrix mit einer fixen Größe von willkürlich
50 Knoten angelegt. Es ist ein Leichtes, dies durch
einen zusätzlichen Konstruktor konfigurierbar
zu machen. Und natürlich könnte man das Array
bei Bedarf auch in der Größe anpassen. Dazu
muss lediglich geprüft werden, ob noch Platz für
den zu ergänzenden Knoten ist. Da dann allerdings ein Umkopieren der Werte erforderlich ist,
sollte man gut überlegen, ob es nicht besser ist,
gleich die „richtige“ Größe zu verwenden.
API und Implementation des Graphen habe
ich aus dem erwähnten Buch übernommen. Allerdings habe ich dabei einige der Bezeichner geändert, weil mir diese im Original zu stark abgekürzt waren. Das erschwert für mich die Lesbarkeit, daher bevorzuge ich ausgeschriebene Begriffe. Lediglich bei den Eigenschaften für die
Anzahl der Knoten und Kanten habe ich es bei V
und E in Großbuchstaben belassen, weil dies die
in der Literatur allgemein verwendeten Symbole
sind (abgeleitet von Vertex und Edge). Aber auch
über diese Bezeichner ließe sich natürlich reden.
Listing 1 zeigt die Implementation der Datenstruktur. Die beiden Properties V und E liefern jeweils die Anzahl von Knoten und Kanten zurück.
Zu beachten ist dabei, dass V jeweils die maximale Anzahl möglicher Knoten im Graph liefert.
Das liegt daran, dass in der Adjazenzmatrix lediglich die Kanten verwaltet werden. Diese werden beim Einfügen und Löschen zusätzlich noch
gezählt, damit für die Ermittlung ihrer Anzahl E
kein Zählen in der Matrix erforderlich ist.
Für das Hinzufügen und Löschen von Kanten
wird die Datenklasse Edge verwendet. Diese hat
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 1
Listing 2
Listing 3
Die Datenstruktur für einen
Graphen.
Graphen verändern.
Benachbarte Knoten
traversieren.
public class Graph {
private const int VertexMaxCount = 50;
private readonly bool[,] adjacency =
new bool[VertexMaxCount,VertexMaxCount];
private int edgeCount;
public int V {
get { return VertexMaxCount; }
}
public int E {
get { return edgeCount; }
}
public void Insert(Edge e) {
if (adjacency[e.v, e.w]) {
return;
}
adjacency[e.v, e.w] = true;
edgeCount++;
}
public void Remove(Edge e) {
if (!adjacency[e.v, e.w]) {
return;
}
adjacency[e.v, e.w] = false;
edgeCount--;
}
public bool Edge(int v, int w) {
return adjacency[v, w];
}
public IEnumerable<int>
AdjacentVertices(int v) {
for (var i = 0; i <
VertexMaxCount; i++) {
if (Edge(v, i)) {
yield return i;
}
}
}
}
public class Edge {
public int v { get; set; }
public int w { get; set; }
}
lediglich zwei Properties für Start- und
Zielknoten. Auch hier habe ich es bei den
Kleinbuchstaben v und w belassen, da diese in der Literatur sehr oft als Symbole für
Knoten verwendet werden.
Neben den Methoden zum Verändern
des Graphen stehen die beiden Methoden
Edge und AdjacentVertices zur Verfügung.
Die Methode Edge liefert für zwei Knoten
die Information, ob diese durch eine Kante verbunden sind. Dazu ist lediglich ein
Zugriff auf die Adjazenzmatrix erforderlich,
bei dem die beiden Knoten als Indizes verwendet werden. Der Laufzeitaufwand ist
somit konstant O(1). Details zur sogenann-
www.dotnetpro.de dotnetpro.dojos.2011
public class GraphTests {
private Graph g;
[SetUp]
public void Setup() {
g = new Graph();
}
[Test]
public void Edge_is_retrievable() {
g.Insert(new Edge {v = 0, w = 1});
Assert.That(g.Edge(0, 1), Is.True);
Assert.That(g.E, Is.EqualTo(1));
}
[Test]
public void Edge_can_be_removed() {
g.Insert(new Edge {v = 0, w = 1});
g.Remove(new Edge {v = 0, w = 1});
Assert.That(g.Edge(0, 1), Is.False);
Assert.That(g.E, Is.EqualTo(0));
}
}
ten O-Notation – auch Landau-Symbol genannt – finden Sie unter [3].
Mit AdjacentVertices können zu einem
gegebenen Knoten v sämtliche adjazenten
Knoten ermittelt werden. Das sind Knoten,
die durch eine von v ausgehende Kante mit
v verbunden sind sind. Dabei kommt ein
Iterator zum Einsatz, den ich mit yield return implementiert habe. Der Laufzeitaufwand für diese Methode beträgt O(n), ist
also linear. Das bedeutet, dass die Laufzeit
linear mit der Größe der Adjazenzmatrix
ansteigt.
Natürlich habe ich zu dieser Datenstruktur einige automatisierte Tests erstellt. Diese können gleichzeitig auch als Beispiele
für die Verwendung des APIs dienen. Listing 2 zeigt ein Beispiel für die Veränderung
eines Graphen.
Listing 3 zeigt einen Test zur Traversierung der benachbarten Knoten.
Der erste Test zeigt, dass die Aufzählung
der adjazenten Knoten leer ist, wenn keine
Kanten hinzugefügt wurden. Im zweiten
Test sieht man, dass die über Kanten unmittelbar erreichbaren Zielknoten aufgelistet werden.
Im Rausch der Tiefe
Dieses Beispiel führt uns wieder zur Frage
aus der Aufgabenstellung zurück: Wie kann
man ermitteln, ob es eine Verbindung zwischen zwei Knoten im Graph gibt, die über
mehrere Kanten verläuft? Da hilft die Tiefensuche: Man besucht, ausgehend vom
Startknoten, so lange alle erreichbaren
[Test]
public void
Adjacent_vertices_without_edge() {
Assert.That(g.AdjacentVertices(0),
Is.Empty);
}
[Test]
public void Adjacent_vertices() {
g.Insert(new Edge {v = 1, w = 1});
g.Insert(new Edge {v = 1, w = 3});
g.Insert(new Edge {v = 1, w = 7});
Assert.That(g.AdjacentVertices(1),
Is.EqualTo(new[] {1, 3, 7}));
}
Knoten, bis man entweder den Zielknoten
gefunden hat oder es nicht mehr weitergeht. Um sich dabei nicht im Kreis zu drehen, werden alle Knoten, die einmal besucht wurden, markiert. Wird ein solcher
Knoten während der weiteren Suche erneut erreicht, wird dieser Pfad nicht weiter
verfolgt. Listing 4 zeigt den in der Klasse
PathSearch umgesetzten Algorithmus.
Die eigentliche Suche nach einem Pfad
von v nach w übernimmt die rekursive Methode SearchRecursive. Ihr Abbruchkriterium ist erreicht, wenn die beiden Knoten
v und w dieselben sind, denn ein Weg von
einem Knoten zu sich existiert natürlich immer. Danach wird der Startknoten dieser
Suche markiert, damit er später nicht erneut
berücksichtigt wird. Dann werden in einer
Schleife alle adjazenten Knoten besucht,
falls das nicht bereits zuvor geschehen ist.
Beim Markieren der schon besuchten Knoten ist es wieder nützlich, dass die Knoten
als Zahlen repräsentiert werden. Dadurch
kann nämlich der Knoten selbst als Index
in ein boolesches Array verwendet werden.
Die Implementation des Algorithmus ist
getrennt von der Implementation der Datenstruktur. Im Konstruktor werden die nötigen Angaben übergeben: der Graph, in
dem gesucht werden soll, und die beiden
Knoten, zwischen denen ein Pfad gesucht
werden soll. Das Ergebnis der Suche wird
in einem Feld abgelegt und kann über die
Eigenschaft Exists abgefragt werden, wie
der Test in Listing 5 zeigt.
Auf diese Weise ist es übrigens auch
leicht, den Algorithmus so zu erweitern,
dass der gefundene Pfad auch ermittelt
wird statt nur seine Existenz zu überprü-
73
LÖSUNG
Listing 4
In Verzweigungen abtauchen.
public class PathSearch {
private readonly Graph g;
private readonly bool found;
private readonly bool[] visited;
public PathSearch(Graph g, int v, int w) {
this.g = g;
visited = new bool[g.V];
found = SearchRecursive(v, w);
}
private bool SearchRecursive(int v, int w) {
if (v == w) {
return true;
}
visited[v] = true;
foreach (var t in
g.AdjacentVertices(v)) {
if (visited[t]) {
continue;
}
if (SearchRecursive(t, w)) {
return true;
}
}
return false;
}
public bool Exists {
get { return found; }
}
}
Listing 5
Ergebnis der Suche ablegen.
[Test]
public void Existing_path() {
g.Insert(new Edge{v = 0, w = 1});
g.Insert(new Edge{v = 1, w = 2});
g.Insert(new Edge{v = 2, w = 3});
var path = new PathSearch(g, 0, 3);
Assert.That(path.Exists, Is.True);
}
fen. Die Klasse kann dazu einfach mit einer
weiteren Eigenschaft versehen werden, die
während der Suche die traversierten Knoten aufsammelt.
Natürlich wäre es auch denkbar, den Algorithmus nicht direkt von der Klasse Graph
abhängig zu machen. Dazu müsste Graph
nur mit einem Interface versehen werden.
So könnten unterschiedliche Implementationen der Datenstruktur vom selben Algorithmus verwendet werden. Im Sinne von
KISS, Keep it simple, stupid [2], habe ich
darauf aber verzichtet, denn zurzeit habe
ich nur eine einzige Datenstruktur Graph
implementiert. Bei Bedarf ist die Umstellung auf ein Interface keine große Tat.
74
Visualisierung
Ich wollte dann noch prüfen, ob der Algorithmus auch mit Kreisen (Zyklen) umgehen kann. Darunter versteht man einen
Graphen, in dem zwei Knoten so über Kanten verbunden sind, dass man bei der Traversierung wieder am Ursprungsknoten
ankommt. Natürlich dürfen dabei mehrere
andere Knoten traversiert werden.
Der Test dazu war schnell erstellt. Doch
kam der Wunsch auf, den zu testenden
Graphen visualisieren zu können: So ist sichergestellt, dass die Testdaten tatsächlich
den gewünschten Graphen repräsentieren.
Dies visuell zu prüfen ist eben viel einfacher,
als eine Liste von Kanten zu interpretieren.
Also habe ich mir die MSAGL-Bibliotheken
aus den MSDN Subscription Downloads
besorgt und in ein neues Projekt eingebunden [4]. MSAGL verwendet zur Visualisierung eine eigene Graph-Klasse. Also galt es
zunächst, meine Repräsentation eines Graphen in die Repräsentation aus dem
MSAGL-Framework zu überführen Dies
habe ich als Extension-Methode implementiert. Dadurch bleibt meine GraphKlasse weiterhin frei von unnötigen Abhängigkeiten, siehe Listing 6.
In einer Schleife werden alle Knoten des
Graphen durchlaufen. Für jeden Knoten
werden dann die adjazenten Knoten ermittelt. Für je zwei adjazente Knoten wird anschließend eine Kante im MSAGL-Graphen
angelegt. Ein Knoten wird durch MSAGL
standardmäßig als Kästchen visualisiert;
deshalb ändere ich hier auch gleich den
Shape zu einem Kreis.
Danach habe ich ein Windows-FormsProjekt erstellt und darin eine neue Form
angelegt. In die Form habe ich ein MSAGLGViewer-Control eingefügt und eine Methode ergänzt, mit welcher der zu visualisierende Graph an die Form übergeben
[Abb. 2]
Zyklische
Bezüge
visualisieren.
wird. Listing 7 zeigt, wie ein Graph nun angezeigt werden kann.
Auch diese Methode habe ich als Extension-Methode implementiert. Somit kann
ich nun den nicht kreisfreien Graphen im
Test visualisieren, siehe Listing 8.
Das Ergebnis der Visualisierung zeigt Abbildung 2. Natürlich sollte die Visualisierung nicht in einem automatisierten Test
aufgerufen werden. Nach der visuellen
Kontrolle meiner Testdaten habe ich den
Aufruf g.ShowGraph() daher auskommentiert. Wer es ganz richtig machen möchte,
implementiert den Viewer als Visual Studio
Debugger Extension. Dann kann jeder
Graph im Debugger zur Kontrolle angezeigt werden. Eine solche Debugger Extension zu realisieren ist nicht schwer.
Topologische Sortierung
Nun sind wir mithilfe der Pfadsuche in der
Lage zu ermitteln, ob ein Pfad von einem
Startknoten zu einem Zielknoten existiert.
Damit können wir beispielsweise ermitteln, ob eine Assembly von einer anderen
Assembly abhängig ist, auch wenn sich die-
Listing 6
Die MSAGL-Bibliothek einbeziehen.
public static class GraphExtensions {
public static Microsoft.Msagl.Drawing.Graph ToMsaglGraph(this Graph g) {
var msaglGraph = new Microsoft.Msagl.Drawing.Graph();
for (var v = 0; v < g.V; v++) {
foreach (var w in g.AdjacentVertices(v)) {
var e = msaglGraph.AddEdge(v.ToString(), w.ToString());
e.SourceNode.Attr.Shape = Shape.Circle;
e.TargetNode.Attr.Shape = Shape.Circle;
}
}
return msaglGraph;
}
}
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Listing 7
Listing 9
Listing 10
Einen Graphen visualisieren.
Umgekehrte topologische
Sortierung.
Graphen testen.
public static void ShowGraph(this
Graph g) {
var viewer = new Viewer();
viewer.SetGraph(g);
viewer.ShowDialog();
}
Listing 8
Zyklische Bezüge visualisieren.
[Test]
public void Path_with_circles() {
g.Insert(new Edge{v = 0, w = 1});
g.Insert(new Edge{v = 1, w = 2});
g.Insert(new Edge{v = 2, w = 3});
g.Insert(new Edge{v = 1, w = 0});
g.Insert(new Edge{v = 2, w = 0});
g.Insert(new Edge{v = 3, w = 0});
g.ShowGraph();
var path = new PathSearch(g, 0, 3);
Assert.That(path.Exists, Is.True);
}
se Abhängigkeit über mehrere Assemblies
hinweg ergibt. Doch wie können wir die
Build-Reihenfolge mehrerer Projekte ermitteln, die untereinander Abhängigkeiten haben? Die Lösung liegt in der topologischen
Sortierung eines entsprechenden Graphen.
Unter der topologischen Sortierung eines
Graphen versteht man eine Knotenreihenfolge, bei der jeder Knoten vor allen Knoten
angeordnet ist, auf die er mittels Kanten
verweist. Für das Bestimmen der BuildReihenfolge benötigen wir die umgekehrte
topologische Sortierung: Projekte, die von
anderen benötigt werden, müssen vor diesen übersetzt werden.
Der Algorithmus zur umgekehrten topologischen Sortierung basiert, wie schon
die Pfadermittlung, auf einer Tiefensuche.
Auch diesen Algorithmus habe ich wieder
als eigenständige Klasse implementiert,
siehe Listing 9.
Auch hier ist die Tiefensuche wieder als
Rekursion implementiert. Damit Knoten
nicht mehrfach besucht werden, wird das
Array pre mit -1 initialisiert. Nur Knoten,
für die der Initialwert noch im Array steht,
werden besucht. Die beiden Arrays order
und relabel nehmen das Ergebnis der Sortierung auf.
❚ Order liefert für einen gegebenen Index
die Knotennummer.
www.dotnetpro.de dotnetpro.dojos.2011
public class ReverseTopologicalSort {
private readonly Graph g;
private int cnt;
private int tcnt;
private readonly int[] pre;
private readonly int[] relabel;
private readonly int[] order;
public ReverseTopologicalSort(Graph g) {
this.g = g;
pre = new int[g.V];
relabel = new int[g.V];
order = new int[g.V];
for (var i = 0; i < g.V; i++) {
pre[i] = -1;
relabel[i] = -1;
order[i] = -1;
}
for (var i = 0; i < g.V; i++) {
if (pre[i] == -1) {
SortReverse(i);
}
}
}
private void SortReverse(int v) {
pre[v] = cnt++;
foreach (var w in
g.AdjacentVertices(v)) {
if (pre[w] == -1) {
SortReverse(w);
}
}
relabel[v] = tcnt;
order[tcnt++] = v;
}
public int Order(int v) {
return order[v];
}
public int Relabel(int v) {
return relabel[v];
}
}
❚ Relabel liefert zu einer gegebenen Knotennummer den Index des Knotens.
Listing 10 zeigt einen Test des Algorithmus für einen Graphen, bei dem drei Knoten hintereinander angeordnet sind. Man
sieht bereits, dass die beiden Methoden
Order und Relabel invers zueinander sind.
Natürlich gibt es für manche Graphen
mehrere mögliche Ergebnisse. Schon bei
einem Knoten mit zwei Nachfolgern stellt
sich die Frage, welcher Nachfolger zuerst
besucht werden soll. Hier gibt es also zwei
mögliche Ergebnisse bei der topologischen
Sortierung. Für die Reihenfolge beim Übersetzen von Visual-Studio-Projekten spielt
das natürlich keine Rolle.
[Test]
public void Graph_with_two_edges_in_line() {
g.Insert(new Edge {v = 0, w = 1});
g.Insert(new Edge {v = 1, w = 2});
var sut = new ReverseTopologicalSort(g);
Assert.That(sut.Order(0), Is.EqualTo(2));
Assert.That(sut.Order(1), Is.EqualTo(1));
Assert.That(sut.Order(2), Is.EqualTo(0));
Assert.That(sut.Relabel(0), Is.EqualTo(2));
Assert.That(sut.Relabel(1), Is.EqualTo(1));
Assert.That(sut.Relabel(2), Is.EqualTo(0));
}
Sich im Kreis drehen
Beim Ermitteln der Build-Reihenfolge bleibt
abschließend noch die Frage, ob zyklische
Abhängigkeiten existieren. Wenn A von B
abhängt und B von A, gibt es keine BuildReihenfolge, bei der jedes Projekt lediglich
einmal übersetzt wird. Solche Situationen
sollten erkannt werden, um Referenzen zu
verhindern, die zu Kreisen führen würden.
Die Vorgehensweise ist demnach wie
folgt: Zunächst wird ein Graph mit den bereits vorhandenen Projekten und Referenzen erzeugt. Danach wird die neu hinzuzufügende Referenz in den Graphen eingefügt. Bevor die Referenz tatsächlich in das
Visual-Studio-Projekt aufgenommen wird,
muss der Graph auf Kreisfreiheit überprüft
werden. Würde durch die zusätzliche Referenz ein Kreis entstehen, muss diese Referenz logischerweise abgelehnt werden.
Die Lösung des Problems kann man zurückführen auf die Frage, ob zwischen zwei
Knoten eine sogenannte starke Verbindung
existiert.Wenn für zwei Knoten v und w eine
starke Verbindung existiert, bedeutet das,
dass auch eine Verbindung zwischen w
und v existiert. Ist das der Fall, liegt ein Zyklus (oder auch Kreis) vor. Die Menge von
zusammenhängenden Knoten in einem
Graph, die über starke Verbindungen erreichbar sind, werden starke Komponenten
genannt. Und das führt uns zu einem Algorithmus für die Frage nach der Kreisfreiheit
eines Graphen: Wenn die Anzahl starker
Komponenten in einem Graph der Anzahl
seiner Knoten entspricht, ist keiner der
Knoten mit einem anderen stark verbunden. Das heißt, es liegen keine Kreise vor.
Um herauszufinden, ob ein Graph Kreise
enthält, ermittelt man also die Anzahl der
starken Komponenten. Ist diese gleich der
Anzahl der Knoten, ist der Graph kreisfrei.
75
LÖSUNG
Listing 11
Auf kreisfreie Graphen testen.
[Test]
public void Graph_without_cycles() {
g.Insert(new Edge{v = 0, w = 1});
g.Insert(new Edge{v = 1, w = 2});
g.Insert(new Edge{v = 2, w = 3});
var sc = new StrongComponents(g);
Assert.That(sc.Count, Is.EqualTo(g.V));
Assert.That(sc.StronglyReachable(0, 3),
Is.False);
}
[Test]
public void Graph_with_cycle_over_2_vertices() {
g.Insert(new Edge{v = 0, w = 1});
g.Insert(new Edge{v = 1, w = 0});
var sc = new StrongComponents(g);
Assert.That(sc.Count, Is.EqualTo(g.V - 1));
Assert.That(sc.StronglyReachable(0, 1),
Is.True);
}
Im Beispielcode auf der Heft-CD ist der
Algorithmus von Tarjan in der Klasse
StrongComponents implementiert. Er ist
ebenfalls aus Sedgewick [1] entnommen.
Zwei Tests sollen das API demonstrieren,
siehe Listing 11.
Das erste Beispiel betrifft einen kreisfreien Graphen. In diesem Fall ist die Anzahl
der starken Komponenten sc.Count gleich
der Anzahl der Knoten g.V. Zwischen den
beiden Knoten besteht keine starke Verbindung, da sie nur in einer Richtung verbunden sind. Im zweiten Beispiel enthält der
Graph zwei Knoten, die in beiden Richtungen miteinander verbunden sind. Somit
entspricht die Anzahl der starken Komponenten nicht der Anzahl der Knoten. Der
Algorithmus liefert uns also die Aussage,
dass der Graph nicht kreisfrei ist.
mit negativen Gefühlen verbindet, sei getröstet: Die Algorithmen konnte ich unverändert bei Sedgewick „abschreiben“. Die
Übersetzung von Java nach C# war leicht.
Das hat für mich wieder bestätigt, dass
man als .NET-Entwickler bei der Suche
nach Literatur durchaus Java-Literatur in
den Blick nehmen sollte, von Java-spezifischen Themen vielleicht abgesehen.
Graphenalgorithmen sind grundlegend
für viele Problemstellungen. Daher sollte
man zumindest die wichtigsten Begriffe
und Konzepte kennen. Bei der Umsetzung
eines vorhandenen Algorithmus geschieht
dies ganz praxisnah nebenbei. Befasst man
sich mit Algorithmen und Datenstrukturen
regelmäßig im Rahmen der persönlichen
Weiterbildung, ist man für zukünftige Herausforderungen gerüstet.
[ml]
Fazit
[1] Robert Sedgewick, Algorithms in Java, Part 5,
Graph Algorithms, ISBN 0-201-36121-3
[2] http://www.clean-code-developer.de/
Roter-Grad.ashx
[3] http://de.wikipedia.org/wiki/Landau-Symbole
[4] http://research.microsoft.com/
en-us/projects/msagl/
Ich habe mich während dieser Übung an
mein Studium erinnert. Die Vorlesungen
zu Graphenalgorithmen waren immer sehr
lehrreich, weil grundlegende Dinge wie Rekursion, Tiefensuche etc. erklärt wurden.
Wer solche gedanklichen „Zeitreisen“ eher
n
e
h
c
u
s
?
r
e
e
l
Si
k
c
i
w
t
n
E
.NE T
Mit uns finden Sie Ihren
Wunschkandidaten!
Neue Mediengesellschaft
Ulm mbH
Ihre Ansprechpartner für
den Stellenmarkt
Angelika Hochmuth
Anzeigenleitung
Tel: 089 / 74 11 7 - 1 25
[email protected]
76
dotnetpro.dojos.2011 www.dotnetpro.de
AUFGABE
Eine interaktive Anwendung mit Datenflüssen modellieren
Wie fließen die Daten?
Software modellieren: Ja, dem gehört die Zukunft. Aber was soll man eigentlich genau modellieren?
Datenflüsse oder Abhängigkeiten von Funktionseinheiten? Stefan, kannst du dazu eine Übung stellen?
B
ei einer algorithmischen Fragestellung liegt es nahe, Datenflüsse zu modellieren.Wer Datenflüsse modelliert,
verdeutlicht den Ablauf der Anwendung. Bei der Modellierung von Abhängigkeiten
ist das hingegen meistens nicht der Fall. Zwar
sieht man bei einem Abhängigkeitsdiagramm,
dass eine Funktionseinheit die Dienste anderer
Funktionseinheiten in Anspruch nimmt und damit von diesen Funktionseinheiten abhängig
wird. Wie die Interaktion der Funktionseinheiten
aber genau aussieht, lässt sich meist nur erahnen.
Für Aufgabenstellungen ohne Benutzerinteraktion sind Datenflussdiagramme naheliegend.
Das Zerlegen einer Zeichenkette in Konfigurationswerte, die in ein Dictionary übernommen
werden, wäre ein Beispiel. Doch kann man auch
eine interaktive Anwendung auf diese Weise modellieren? Um diese Frage zu beantworten, nehmen wir diesen Monat eine kleine To-do-Listenverwaltung auf der To-do-Liste. Im Vordergrund
stehen die Modellierung und Implementation der
Benutzerinteraktionen. Ziel ist, die GUI möglichst
frei von Logik zu halten. Somit müssen alle Entscheidungen, die sich auf die GUI auswirken,
außerhalb der GUI getroffen werden. Diese Form
der Interaktion lässt sich mit Datenflüssen sehr
gut modellieren. Doch diese Denkweise fällt vor
allem eingefleischten OOP-Verfechtern schwer.
Wie sehen die Anforderungen an die Anwendung aus? Ich habe dazu eine Featureliste erstellt.
Die Features können bei einer konsequenten Datenflussmodellierung einzeln modelliert werden.
Das Zusammenfügen der dabei entstandenen
Modelle ist bei Datenflussdesigns kein Problem.
Das liegt daran, dass die beteiligten Funktionseinheiten keine Abhängigkeiten mehr haben.
F3: Vorhandenes To-do zum Bearbeiten öffnen.
Interaktion: Doppelklick auf ein To-do.
Feedback: To-do wird im Bearbeiten-Modus angezeigt und kann geändert werden.
F4: To-dos persistieren.
Interaktion: Beenden und Starten der App.
Feedback: Nach Starten der Anwendung werden
die To-dos aus dem vorhergehenden Lauf wieder
angezeigt.
Ressource: Datei todoliste.daten.
Featureliste
Da die einzelnen Features möglicherweise recht
umfangreich sind, sollte man sich Gedanken machen, ob man sie weiter zerlegen kann. Bei dieser
Zerlegung in sogenannte Featurescheiben oder
Slices ist es wichtig, weiterhin bei Längsschnitten
zu bleiben, also vertikal zu zerlegen. Würde man
Feature F1 beispielsweise in den GUI-Anteil und
den „Rest“ zerlegen, so wäre der GUI-Anteil kein
eigenständiger Längsschnitt. Besser ist es, die Zerlegung so zu wählen, dass sie durch alle Funktionseinheiten hindurch verläuft. Dann wird ein Teil
des GUIs implementiert, aber auch ein Teil des
„Rests“, sodass eine voll funktionsfähige Teilfunktionalität zur Verfügung steht. Weitere Informationen zum Entwicklungsprozess siehe [1]. Abbildung 1 zeigt eine mögliche Benutzerschnittstelle.
Die Aufgabe für diesen Monat besteht darin,
die Features zu modellieren und anschließend zu
implementieren. Bei der Implementation mag
eine Beschränkung auf Featurescheiben sinnvoll
sein, damit man nicht zu viel Zeit in ein einzelnes
Feature investiert. Am Ende ist es spannender,
von mehreren Features jeweils einen kleinen Ausschnitt zu implementieren, anstatt nur ein einziges Feature komplett zu implementieren. Auch in
der Praxis empfiehlt sich diese Vorgehensweise,
um möglichst früh Feedback vom Kunden zu erhalten. Happy modeling!
[ml]
F1: Ein neues To-do hinzufügen.
Interaktion: Button Neu wird angeklickt.
Feedback: Das neue To-do wird in der Liste der
To-dos angezeigt und ist im Bearbeiten-Modus.
F2: Bearbeiten eines To-dos beenden.
Interaktion: Eingabetaste oder Anklicken eines
anderen To-dos.
Feedback: Das To-do wird im normalen Modus
angezeigt.
[1] Ralf Westphal, Elf Schritte bis zum Code, Von den
Anforderungen zum fertigen Programm,
Teil 1, dotnetpro 10/2010, Seite 126 ff.,
www.dotnetpro.de/A1010ArchitekturKolumne
Teil 2, dotnetpro 11/2010, Seite 130 ff.,
www.dotnetpro.de/A1011Architektur
Teil 3, dotnetpro 12/2010, Seite 130 ff.,
www.dotnetpro.de/A1012Architektur
www.dotnetpro.de dotnetpro.dojos.2011
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
[Abb. 1] So könnte die To-do-Liste
aussehen.
77
Wer übt, gewinnt
dnpCode: A1104DojoAufgabe
LÖSUNG
Eine interaktive Anwendung mit Datenflüssen modellieren
Wissen, was zu tun ist
MVVM-Pattern? Kennt man. Flow Design? Schon mal gehört. Event-Based Components? Klar, das ist die Spezialität
von Ralf und Stefan. Aber alles zusammen auf einmal? Ist noch nicht da gewesen. Geht aber, auch wenn Stefan
bei der Umsetzung ins Schwitzen kam.
dnpCode: A1105DojoLoesung
Stefan Lieser ist Softwareentwickler aus Leidenschaft. Nach
seinem Informatikstudium mit
Schwerpunkt auf Softwaretechnik hat er sich intensiv mit
Patterns und Principles auseinandergesetzt. Er arbeitet als
Berater und Trainer, hält zahlreiche Vorträge und hat gemeinsam mit Ralf Westphal die
Initiative Clean Code Developer
ins Leben gerufen. Sie erreichen
ihn unter [email protected]
oder lieser-online.de/blog.
[Abb. 1] ViewModel im Datenfluss.
78
D
as Modellieren der ToDo-Listenanwendung in Form eines Flow Designs ist
eine nützliche Übung. Es stellt sich die
herausfordernde Aufgabe, das MVVM-Pattern
mit Flow Design und Event-Based Components
(EBC) unter einen Hut zu bringen. Hier sind viele
Buzzwords vereint, da tut Aufklärung not.
Das Kürzel MVVM steht für Model-ViewViewModel. Das Pattern dient der Implementation grafischer Benutzerschnittstellen. Die Grundidee besteht darin, ein ViewModel zu definieren,
welches die View optimal bedient. Als View wird
hierbei die Benutzerschnittstelle bezeichnet. Das
ViewModel enthält alle Daten, die von der View
anzuzeigen sind. Das Ziel dabei ist es, die View so
dünn wie möglich zu halten. Sie soll die Daten
des ViewModels nicht deuten müssen. Der
Grund liegt zum einen in der Testbarkeit. Views
sind typischerweise schwierig automatisiert zu
testen, daher sollte dort möglichst kein Code untergebracht werden. Zum anderen darf die View
keine Domänenlogik enthalten. Es ist Aufgabe
der Domänenlogik, die Daten im ViewModel so
aufzubereiten, wie die View sie benötigt.
Das ViewModel wird an die View übergeben.
Es gibt also eine Abhängigkeit der View vom
ViewModel. Für WPF- und Silverlight-Anwendungen bedeutet dies vor allem, dass das
ViewModel optimal für Data Binding geeignet
sein sollte. Dazu sollten beispielsweise die Eigenschaften des ViewModels die INotifyPropertyChanged-Schnittstelle bedienen. Statt also wie
beim Model-View-Controller-(MVC)- oder Model-View-Presenter-(MVP)-Pattern der View jeweils mitzuteilen, welche Änderungen durchgeführt werden sollen, wird bei MVVM das ViewModel so an die View gebunden, dass direkt das
ViewModel manipuliert werden kann. Durch das
Data Binding werden bei Änderungen am
ViewModel die notwendigen Aktualisierungen
der View durch die Infrastruktur übernommen.
Auf der anderen Seite des ViewModels steht das
Model. Das Model steht für die eigentliche Geschäftslogik. Die Geschäftslogik soll frei sein von
Abhängigkeiten zur View und ihren technischen
Details. Als Mittler zwischen Model undView steht
das ViewModel. Das Model kann das ViewModel
erzeugen bzw. verändern, worauf die View mit
einer Aktualisierung der Darstellung reagiert.
Hochzeit von MVVM und Flow Design
Die Herausforderung beim „Verheiraten“ von
MVVM mit Flow Design liegt in der Frage, wie
Änderungen am ViewModel modelliert werden.
Wird in der View vom Benutzer eine Interaktion
gestartet, sind für die Abarbeitung der zugehörigen Logik in der Regel Informationen aus dem
ViewModel erforderlich. Umgekehrt führt die Abarbeitung der Geschäftslogik meist zu Änderungen, die in der View visualisiert werden müssen.
Eine Möglichkeit wäre, das ViewModel als Ergebnis einer Änderung als Datenfluss zur View zu
übertragen, so wie es Abbildung 1 zeigt.
Damit würde die View allerdings jedes Mal
eine neue Instanz des ViewModels erhalten, was
das Data Binding ad absurdum führen würde.
Sendet man immer dieselbe Instanz des
ViewModels, stellt sich die Frage, wie man dies
im Modell deutlich macht.
Abbildung 2 zeigt dazu ein Beispiel. Die Aktion
Neues ToDo erzeugen liefert an die View ein geändertes ViewModel, in das ein zusätzliches ToDo
eingefügt wurde.
Daraus ergeben sich nun gleich zwei Fragen:
❚ Woher kennt die Aktion Neues ToDo erzeugen
den vorhergehenden Zustand desViewModels?
❚ Wie stellt dieView sicher, dass das Data Binding
funktioniert?
[Abb. 2] Neues ToDo einfügen.
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
Die Frage nach dem Zustand führt zur
Lösung: Das ViewModel ist ein Zustand,
der von der View und der Aktion gemeinsam verwendet wird. Beide sind von diesem Zustand abhängig. Wenn dem so ist,
stellt sich erneut die Frage, ob es sinnvoll
ist, das ViewModel als Datenfluss zur View
zu übertragen. Wenn nämlich View und
Aktion ein gemeinsames ViewModel verwenden, kann die Aktion das ViewModel
manipulieren und muss der View keine
Daten mehr in Form eines Datenflusses
liefern. Schließlich erfolgt die Aktualisierung der View durch das Data Binding.
Für solche Abhängigkeiten haben Ralf
Westphal und ich die Notation des Flow
Designs um eine weitere Pfeilart ergänzt.
Ein Pfeil mit einem Punkt am Ende steht
für eine Abhängigkeit. Abbildung 3 zeigt,
wie man das ViewModel als gemeinsame
Abhängigkeit zwischen die View und eine
Aktion stellen kann. Abbildung 4 zeigt erneut das Feature Neues ToDo erzeugen,
diesmal jedoch mit einer Abhängigkeit anstelle eines Datenflusses.
Auf diese Weise ist es nun bequem möglich, die Abhängigkeit von einem Zustand
zu modellieren. Durch die Modellierung
des gemeinsamen Zustands als Abhängigkeit gehen die Datenflüsse in den folgenden Abbildungen jeweils vom GUI aus zu
den einzelnen Aktionen. Die Aktionen ändern bei Bedarf das ViewModel, und per
Data Binding gelangen diese Änderungen
zum GUI.
Features realisieren
Beginnen wir mit dem ersten Feature : F1:
Ein neues ToDo hinzufügen. Das Modell
dazu ist in Abbildung 4 zu sehen. Das Feature ist im Prinzip recht simpel zu modellieren: Ausgehend von einem Datenfluss
vom GUI zur Aktion Neues ToDo erzeugen
wird das ViewModel um ein neues ToDo
ergänzt, fertig. Durch die gemeinsame Abhängigkeit von View und Aktion vom ViewModel wird die Änderung im ViewModel
per Data Binding in der View visualisiert.
Bleibt noch die Frage zu klären, auf welchem Weg GUI und Aktion das ViewModel
initial erhalten. Es muss einmal instanziert
und in die beiden Funktionseinheiten hineingereicht werden. Ich habe mich entschieden, dies jeweils im Konstruktor von
GUI und Aktion zu realisieren. Dazu erhalten die Konstruktoren einen Parameter
vom Typ SharedState<T>. Dass beide Funktionseinheiten vom ViewModel abhängig
sind, geht aus dem Flow-Design-Modell
hervor. Wie diese Abhängigkeit initialisiert
www.dotnetpro.de dotnetpro.dojos.2011
[Abb. 3] View und Aktion sind vom
ViewModel abhängig.
[Abb. 4] Neues ToDo erzeugen.
[Abb. 5] Geöffnetes und geschlossenes ToDo.
wird, ist ein Implementationsdetail. Nach
der Modellierung habe ich begonnen, das
Feature zu implementieren. Mir war klar,
dass ich am GUI die meiste Zeit zubringen
werde. Allerdings habe ich mich zunächst
konsequent mit einer Minimalimplementation des GUIs zufriedengegeben und die
restlichen Funktionseinheiten realisiert,
damit am Ende ein Längsschnitt fertig wird
statt nur ein schickes GUI.
Nach den Minimalimplementationen
standen App-Projekt und Buildskript an.
Das App-Projekt ist dafür zuständig, die
benötigten Funktionseinheiten zu instanzieren und alles zusammenzustecken
(Build und Bind). Und da es bei komponentenorientierter Vorgehensweise nicht
eine einzige Solution gibt, die das gesamte
Programm ausspuckt, muss ein Buildskript
her, welches alle Solutions in der richtigen
Reihenfolge übersetzt.
Das hört sich aufwendiger an, als es in
der Praxis ist. Denn das Buildskript besteht
aus den immer gleichen vier Schritten :
1. Übersetzen der Kontrakte,
2. Übersetzen aller Komponenten in beliebiger Reihenfolge,
3. Ausführen der Tests,
4. Übersetzen der App.
Buildskripte erstelle ich nach wie vor mit
FinalBuilder [1]. Das Übersetzen der Komponenten erfolgt in einer Schleife, in der
einfach alle Komponentenwerkbänke aufgesammelt werden. Da die Buildreihenfolge der Komponenten egal ist, muss dieser
Teil beim Hinzufügen weiterer Komponen-
ten nicht angepasst werden. Dadurch ist
das Erstellen des Buildskripts ein einmaliger Vorgang zu Beginn des Projekts.
Das App-Projekt kann erst erstellt werden, wenn von allen Komponenten zumindest eine Minimalimplementation vorhanden ist. Das liegt daran, dass die App alle
Komponenten binär referenzieren muss,
um sie instanzieren zu können. Aus diesem
Grund erstelle ich von allen Komponenten
zunächst eine sogenannte Tracer-BulletImplementation. Die so implementierten
Funktionseinheiten machen noch nicht
wirklich etwas, außer Traceausgaben zu
produzieren. So sind zwei Fliegen mit einer
Klappe geschlagen: Zum einen kann das
App-Projekt erstellt werden, zum anderen
kann man anhand der Traceausgaben bereits feststellen, ob die einzelnen Funktionseinheiten korrekt zusammenspielen.
Nachdem ich die einzelnen Funktionseinheiten implementiert hatte, habe ich
begonnen, das GUI aufzumotzen. Ich
möchte erreichen, dass ein ToDo in der
Liste in zwei verschiedenen Zuständen angezeigt werden kann :
❚ geöffnet zur Bearbeitung,
❚ geschlossen zum Anzeigen.
Abbildung 5 zeigt die Form mit je einem
geöffneten und einem geschlossenen ToDo.
Im geöffneten Zustand soll der Text des
Eintrags editiert werden können. Ferner
soll man das ToDo mit einer Checkbox als
erledigt kennzeichnen können. In einem
späteren Schritt soll auch die Eingabe von
Tags möglich sein. Somit stand schnell die
79
LÖSUNG
Listing 1
Einen Data Trigger verwenden.
<Style TargetType="{x:Type ListViewItem}">
<Setter Property="IsSelected" Value="{Binding Mode=TwoWay, Path=IsSelected}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding InBearbeitung}" Value="true">
<Setter Property="ContentTemplate" Value="{StaticResource OpenedItem}" />
</DataTrigger>
<DataTrigger Binding="{Binding InBearbeitung}" Value="false">
<Setter Property="ContentTemplate" Value="{StaticResource ClosedItem}" />
</DataTrigger>
</Style.Triggers>
</Style>
Idee im Raum, dazu eine ListView mit zwei
unterschiedlichen DataTemplates zu verwenden. Die Frage war nur, wie man es erreicht, dass das DataTemplate in Abhängigkeit von einer Eigenschaft im ViewModel ausgewählt wird. Denn meine Idee war,
dass jedes einzelne ToDo eine Eigenschaft
InBearbeitung erhalten soll. Abhängig davon, ob diese Eigenschaft auf true oder
false gesetzt ist, soll das passende DataTemplate ausgewählt werden.
Die Lösung liegt in der Verwendung eines Data Triggers, der an die Eigenschaft
im ViewModel gebunden wird. Listing 1
zeigt den relevanten XAML-Ausschnitt.
Um das Tüfteln an der Form zu beschleunigen, braucht man einen Testrahmen.
Müsste man erst die gesamte Anwendung
vollständig übersetzen, um die Form in Aktion sehen zu können, wäre die Entwicklung stark ausgebremst. Die Komponentenorientierung will ja gerade erreichen, dass
die einzelnen Komponenten isoliert entwi-
Listing 2
Das GUI testen.
[SetUp]
public void Setup() {
toDoListe = new ToDoListe();
toDoListe.ToDos.Add(new ToDo {
Text = "ToDo Nummer 1",
IsSelected = true});
// ...
state = new SharedState<ToDoListe>();
state.Write(toDoListe);
sut = new Main(state);
sut.ToDo_schliessen += delegate { };
}
[Test, RequiresSTA, Explicit]
public void Liste_mit_ToDos_anzeigen() {
sut.ShowDialog();
}
80
ckelt werden können. Das soll auch für das
GUI möglich sein. Folglich habe ich in der
GUI-Solution neben dem Projekt mit der
Implementation noch ein Testprojekt angelegt. In einem normalen NUnit-Test instanziere ich die Form und fülle sie mit entsprechenden Testdaten. So ist ein zügiges
Arbeiten möglich, weil zur visuellen Kontrolle lediglich ein Test gestartet werden
muss. Listing 2 zeigt einen der GUI-Tests.
Da dieser Test mit ShowDialog eine WPFForm öffnet und erst weiterläuft, wenn die
Form wieder geschlossen ist, habe ich das
NUnit-Attribut Explicit ergänzt. Es sorgt dafür, dass der Test nur ausgeführt wird, wenn
er explizit gestartet wird. Beim Ausführen
aller Tests der Assembly, beispielsweise auf
dem Continuous-Integration-Server, werden die expliziten Tests ignoriert. Des Weiteren habe ich das Attribut RequiresSTA ergänzt, damit den Anforderungen von Windows Forms bzw. WPF entsprochen wird.
Das Austüfteln des XAML-Codes hat am
Ende doch einige Zeit in Anspruch genommen. Durch die klare Trennung des GUIs
vom Rest der Anwendung, durch Einsatz
der Komponentenorientierung, wäre es jedoch leicht möglich gewesen, diese Details
von einem erfahrenen WPF-Entwickler
vornehmen zu lassen. Dazu hätte dieser
nicht die gesamte Anwendung benötigt,
sondern lediglich die Visual-Studio-Solution mit der WPF-Form und dem zugehörigen Testrahmen.
ToDos bearbeiten
Wenn ein neues ToDo in die Liste aufgenommen wird, befindet es sich im Modus
Bearbeiten. Dazu ist die Eigenschaft InBearbeitung im ViewModel auf true gesetzt.
Damit immer nur ein einzelnes ToDo geöffnet dargestellt wird, muss die View einen Event auslösen, sobald ein anderes To-
do selektiert wird. Daraufhin muss die InBearbeitung-Eigenschaft des selektierten
Elements auf false geändert werden. Durch
Data Binding sorgt WPF dann dafür, dass
das Listenelement mit dem anderen DataTemplate im geschlossenen Zustand visualisiert wird.
Umgekehrt muss es möglich sein, ein
vorhandenes ToDo zur Bearbeitung zu öffnen. Laut Feature F3 soll dazu ein Doppelklick auf ein ToDo-Element als Interaktion
verwendet werden. Auch hier ist also in der
View ein Event auszulösen, wenn ein Eintrag der Liste per Doppelklick ausgewählt
wird. Die Umsetzung der Logik ist trivial:
Es muss lediglich die InBearbeitung-Eigenschaft angepasst werden.
Persistenz
Das nächste Feature hat es in sich: Die ToDo-Einträge sollen von einem zum anderen Programmstart erhalten bleiben. Dazu
habe ich zunächst identifiziert, bei welchen bereits umgesetzten Features die ToDo-Liste so geändert wird, dass die Daten
erneut persistiert werden müssen. Das ist
bei folgenden Interaktionen der Fall:
❚ Ein neues ToDo wird angelegt.
❚ Der Text eines vorhandenen ToDos wird
geändert.
❚ Die Erledigt-Eigenschaft eines vorhandenen ToDos wird geändert.
Anschließend habe ich im vorhandenen
Modell nachgesehen, ob diese drei Interaktionen dort bereits sichtbar sind. Bei zweien ist das der Fall, lediglich das Ändern der
Erledigt-Eigenschaft über die Checkbox ist
nicht im Modell sichtbar, da dies vollständig mittels Data Binding gelöst ist. Daher
habe ich im GUI einen ausgehenden Datenfluss ergänzt, der mitteilt, dass ein ToDo
geändert wurde.
Ausgehend von den Aktivitäten ToDo geändert, ToDo-Bearbeitung beenden und
Neues ToDo ergänzen wird die Aktivität ViewModel in ToDo-Liste übersetzen gestartet.
Sie mappt das ViewModel auf ein Datenmodell für die Persistenz. Beide Modelle sind
strikt zu trennen, damit es nicht zu Abhängigkeiten zwischen View und Persistenz
kommt. Zum Mappen des ViewModels auf
das Datamodell habe ich das Open-SourceFramework AutoMapper [2] verwendet.
Als letzter Schritt muss das Datenmodell
persistiert werden. Dazu habe ich es nach
XML serialisiert. Weil der XML Serializer
aus dem .NET Framework nur mit konkreten Typen umgehen kann und beispielsweise nicht mit IEnumerable<T> klarkommt,
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
[Abb. 6] Die ToDoListenlogik.
habe ich dazu das Open-Source-Framework sharpSerializer verwendet [3]. Wenn
der XML Serializer aus dem .NET Framework Sie mal wieder ärgert, sollten Sie
sharpSerializer ausprobieren.
Zum Speichern gehört auch das Laden
der ToDo-Liste. Die Implementation dieser Funktionseinheit ging schnell von der
Hand. Eingesetzt wird sie im Mainboard
der Anwendung. Das Mainboard bietet eine
Run-Methode, die beim Starten der App
aufgerufen wird. Auch dieser Schritt lässt
sich im Rahmen von Flow Design generalisieren. Nach Build, Bind und Inject folgt
Init. In der Beispielanwendung, die Sie auf
der beiliegenden Heft-DVD finden, wird
die Run-Methode des Mainboards allerdings direkt aufgerufen.
Spaßeshalber habe ich noch einen weiteren Persistenzmechanismus implementiert. Statt die ToDo-Liste in eine Datei zu
schreiben, kann man sie auch in einer
Amazon-SimpleDB-Datenbank ablegen.
Das bringt beim Speichern allerdings Latenzzeiten mit sich. Damit entstand der
Wunsch, das Speichern in den Hintergrund
zu verlagern. Zusätzlich sollten mehrere
Speicheraufträge, die in kurzer zeitlicher
Folge eintreffen, zu einem einzigen zusammengefasst werden. Das Speichern soll so
lange verzögert werden, bis für eine gewisse Zeit keine Änderungen mehr eintreffen.
Die Umsetzung dieser Anforderung ist
mit Flow Design und EBC ein Leichtes. Zunächst habe ich die Funktionseinheit ToDoListe speichern von einem Bauteil in eine
Platine geändert. Anschließend habe ich in
der Platine vor das eigentliche Speichern
einen Standardbaustein Throttle eingesetzt. Dieser verzögert einen eingehenden
Datenfluss für fünf Sekunden. Trifft das Ereignis in diesem Zeitraum mehrfach ein,
wird der interne Timer dadurch wieder zurückgesetzt. Diesen Standardbaustein sowie einige andere finden Sie im OpenSource-Projekt ebclang [4].
www.dotnetpro.de dotnetpro.dojos.2011
Um die Benutzerschnittstelle über das
Speichern zu informieren, liefert die Platine
drei Datenflüsse, die anzeigen, dass mit dem
Speichern begonnen wurde, dass es beendet
ist bzw. dass ein Fehler aufgetreten ist. Damit
diese Datenflüsse im GUI verwendet werden
können, um eine Meldung in der Statusleiste auszugeben, müssen sie auf den GUIThread synchronisiert werden. Auch das
übernehmen Standardbausteine innerhalb
der Platine. Abbildung 6 zeigt den Kern der
Anwendung, die ToDo-Listenlogik.
Das in der ToDo-App eingesetzte Tooling
ist Open Source und unter [4] zu finden.
Die Platinen sind im Contracts-Projekt jeweils in XML-Dateien beschrieben. Aus
diesen ebc.xml-Dateien wird durch den
ebc.compiler C#-Code generiert. Ein Vorteil
dieser Vorgehensweise liegt darin, dass die
ebc.xml-Dateien visualisiert werden können. Dadurch ist eine visuelle Kontrolle
möglich, die sicherstellt, dass die Modellierung korrekt in die ebc.xml-Dateien übernommen wurde. Zurzeit sehen die generierten Graphen noch nicht so richtig
schick aus. Aber Ralf Westphal und ich arbeiten bereits an einem Nachfolger.
ILmerge
Als die Anwendung in Grundzügen fertig
war, dachte ich mir, es sei eine gute Idee,
alle DLLs der App mithilfe von ILmerge zu
einer einzigen EXE-Datei zusammenzufassen. Das habe ich schon öfter gemacht und
bin dabei nie auf Probleme gestoßen. Diesmal aber schon. Der Grund: ILmerge versagt seinen Dienst, sobald WPF-Assemblies mit im Spiel sind. Das liegt nicht an
ILmerge, sondern daran, dass WPF beim
Laden von Ressourcen vollqualifizierte Assemblynamen verwendet. Diese ändern
sich dummerweise durch ILmerge.
Aber da es ja darum ging, etwas zu lernen, habe ich mich nicht damit abgefunden, sondern nach Abhilfe gesucht. Die Lösung findet sich im Hinweistext zu ILmerge,
in dem dasWPF-Problem beschrieben wird.
Hier steht auch gleich der Link zu einer Lösung [5]. Allerdings funktionierte die dort
beschriebene Lösung nicht auf Anhieb. Das
lag daran, dass bei mehrfachem Laden derselben Assembly mehrere Instanzen der Assembly geliefert wurden. Ein kleiner Cache,
implementiert mit einem Dictionary, schafft
Abhilfe. Die ILmerge-Alternative basiert darauf, dass alle erforderlichen DLLs als Ressource in das EXE-Projekt eingebettet werden. Ein Assembly-Loader, der die Assemblies aus der Ressource holt, sorgt dafür,
dass die Anwendung komplett in der EXEDatei enthalten ist. Wer sich das Verfahren
genauer anschauen mag, sollte die Solution im Verzeichnis source.app öffnen.
Fazit
Die Umsetzung der kompletten ToDo-App
hat länger gedauert als geplant. Das lag an
drei Bereichen:
❚ Das Modellieren mit MVVM und Abhängigkeiten brauchte noch etwas Feinschliff.
❚ Der XAML-Code des GUIs ist recht aufwendig geraten.
❚ Es gab Probleme mit ILmerge.
Trotz der Herausforderungen hatte ich
zwischendurch immer wieder Versionen,
die ich hätte liefern können. Konsequentes
Fokussieren auf Längsschnitte sowie schrittweises Verfeinern sind Schlüssel zum entspannten Arbeiten.
[ml]
[1] Best of Breed, Die Lieblingstools der dotnetpro-Autoren, dotnetpro 3/2011, S. 16,
www.dotnetpro.de/A1103LieblingsTools
[2] AutoMapper, http://automapper.codeplex.com/
[3] sharpSerializer,
http://www.sharpserializer.com/
[4] Event-Based Components Tooling,
http://ebclang.codeplex.com/
[5] Microsoft Research, ILMerge,
http://research.microsoft.com/enus/people/mbarnett/ilmerge.aspx
81
GRUNDLAGEN
MVVM und Flow-Design kombinieren
Alles unter einem Hut
Das Data Binding von WPF bietet beeindruckende Möglichkeiten. MVVM ist für WPF-Anwendungen das geeignete
Konzept. Flow-Design erlaubt eine sehr natürliche Art der Modellierung. Und Event-Based Components stellen
ein universales Konzept für Modellierung und Implementierung. dotnetpro zeigt, wie Sie diese Konzepte gemeinsam
nutzen können.
Auf einen Blick
Stefan Lieser ist Berater und
Trainer und hat mit Ralf Westphal die Initiative „Clean Code
Developer“ ins Leben gerufen.
Sie erreichen ihn unter
[email protected] oder
unter lieser-online.de/blog.
Inhalt
➡ Flow-Design für den Entwurf
einer grafischen Oberfläche
verwenden.
➡ Model und View in Abhängigkeit von einer einzelnen
Instanz des ViewModels
modellieren.
➡ Die Phasen des Konzepts der
Event-Based Components bei
der Implementierung umsetzen.
dnpCode
A1105MVVM
82
Alles ist im Fluss
ür Anwendungen mit
An dieser Stelle kommt das
einer visuellen BenutFlow-Design ins Spiel. Flowzerschnittstelle stellt
Design ist eine Art der Modelsich die zentrale Frage, wie die
lierung, bei der Datenflüsse die
Daten in die View gelangen.
zentrale Rolle spielen. Die DaDie gleiche Frage stellt sich auf
ten fließen hier zwischen
dem Rückweg: Wie gelangen
Funktionseinheiten, die in der
die vom Benutzer eingegebeRegel in Form einer Aktion benen Daten von der View zur
schrieben sind. Wenn also beiProgrammlogik? Ferner: Wie
spielsweise in einer Anwenwerden Kommandos des Bedung zur Pflege von Kundennutzers mitgeteilt? Seit das Dadaten eine Änderung an einer
ta Binding mit der Einführung
Kundenadresse vorgenommen
von WPF deutlich an Leiswerden soll, fließen die geäntungsfähigkeit gewonnen hat,
derten Adressdaten von der
liegt es auf der Hand, diese
Benutzerschnittstelle zu einer
Leistungsfähigkeit auch auszuFunktionseinheit Kundenadresnutzen. Zwar verfügte auch
se ändern. Von dort fließen die
schon Windows Forms über
Daten weiter zu einer Aktion
Data-Binding-Funktionalität, [Abb. 1] Ein Datenfluss mit GUI.
Geänderte Kundendaten perdoch hat sich der Einsatz nicht
sistieren, um in einer Datenwirklich durchgesetzt.
bank
abgelegt
zu
werden. Abbildung 1 zeigt das
Wer bei WPF oder Silverlight die Möglichkeiten
Modell
dazu.
des Data Bindings einmal gesehen hat, wird auf
Dieser Fluss von Daten und das Aneinanderihren Einsatz nicht verzichten wollen. So kann
reihen
von Aktionen ist eine sehr natürliche Art
beispielsweise eine Textbox mit einer Dropder
Modellierung.
Das liegt daran, dass es einem
downliste verbunden werden, und der DatenDenken
in
Prozessschritten
sehr nahe kommt.
austausch erfolgt in beiden Richtungen. Wer das
Denn
wenn
wir
uns
als
Entwickler
eine Anfordeohne Data Binding selbst implementieren müssrung
wie
das
skizzierte
Ändern
einer
Kundente, wäre längere Zeit beschäftigt.
adresse
anschauen,
analysieren
wir
meist
geAkzeptieren wir also für den Moment, dass das
danklich,
aus
welchen
Prozessschritten
das
Model-View-ViewModel-Pattern (MVVM) im ZuFeature besteht. Diese Prozessschritte sofort als
sammenhang mit WPF und Silverlight gesetzt ist,
Modell zu verwenden, liegt nahe.
auch wenn eine detailliertere Betrachtung des
Die Umsetzung solcher Flow-Designs in eine
Patterns erst später folgt [1] [2]. Trotz MVVM
Implementation
ist mithilfe von Event-Based
bleibt aber die Frage offen, wie der Rest der AnComponents
(EBC)
möglich. Dabei liegt der growendung strukturiert wird. Schließlich muss irße
Vorteil
in
der
Tatsache,
dass das Modell 1:1 in
gendwer das ViewModel mit Daten befüllen beCode
übersetzt
werden
kann.
Man findet also alziehungsweise vom Benutzer eingegebene Dale
Artefakte
des
Modells
leicht
im Code wieder.
ten verarbeiten. Zu glauben, mit MVVM wäre der
Und
umgekehrt
gilt
auch,
dass
alle
Artefakte der
gesamte Aufbau einer Anwendung bereits vorgeImplementation
leicht
im
Modell
zu verorten
geben, ist ein Trugschluss, denn die Anwensind.
Damit
kann
ein
Flow-Design-Modell
tatdungslogik dürfte den Hauptteil jeder nicht trisächlich
die
vornehmste
Aufgabe
eines
Modells
vialen Anwendung ausmachen. MVVM ist nur
übernehmen, die darin besteht, zu abstrahieren.
ein Pattern für bestimmte Teile der Anwendung
Das Modell ist eine abstrakte Darstellung der Imund gibt noch nicht das Modell für den wesentplementation. Damit dient das Modell einerseits
lich umfangreicheren Rest der Anwendung vor.
F
dotnetpro.dojos.2011 www.dotnetpro.de
GRUNDLAGEN
als Vorlage für die Implementation, andererseits auch als Dokumentation derselben. Ohne den Einsatz von MVVM ist das
Einbeziehen der Benutzerschnittstelle in
das Flow-Design ganz leicht: Daten fließen
von der Benutzerschnittstelle zur Logik
und werden dort verarbeitet. Und in vielen
Fällen fließt aus der Logik ein Resultat zurück zur Benutzerschnittstelle, um dort visualisiert zu werden. Doch wie bezieht
man nun Data Binding und ViewModels in
das Flow-Design ein?
ViewModels
Dazu muss zunächst geklärt werden, was
es mit dem ViewModel auf sich hat. Ein
ViewModel enthält alle Daten, die eine
View darstellen soll. Das ViewModel hat
keine Abhängigkeit zu irgendeiner Infrastruktur wie etwa WPF oder Silverlight. Es
sind einfache Klassen, die sich gut automatisiert testen lassen. Weil die Testbarkeit ein
unverzichtbares Kriterium bei der modernen Softwareentwicklung darstellt, ist es
wichtig, diesen Vorteil deutlich herauszustellen. Wenn das Ergebnis einer Logikoperation direkt in einer WPF-View angezeigt
wird, ist es ungleich schwerer, dies automatisiert zu testen. Ist das Ergebnis der
Operation jedoch ein ViewModel, das quasi aus der Operation hinausfließt, sind diese Tests sehr leicht zu automatisieren. Daraus folgt eine wichtige Erkenntnis: Views
sollen die Daten nicht deuten müssen.
Dann wäre nämlich in der View Code enthalten, der getestet werden muss. Liegen
die Daten aber bereits im ViewModel fix
und fertig aufbereitet vor, sodass die View
diese Daten lediglich anzeigt, ohne sie erst
deuten zu müssen, ist das Testen einfach.
Dazu ein Beispiel: Häufig sind Controls in
einer View nur unter bestimmten Umständen eingeschaltet. Es kann sein, dass ein
Control zwar immer sichtbar ist, die Eingabe jedoch deaktiviert ist. Es kann aber auch
sein, dass eine Gruppe von Controls nur in
einem bestimmten Kontext angezeigt wird.
Hier stellt sich also die Frage, wer dafür verantwortlich ist, Controls abhängig von einem Zustand auszublenden. Würde dies
durch die View bewerkstelligt, indem dort
eine entsprechende Prüfung des Zustands
mit anschließendem Deaktivieren der Controls programmiert ist, wäre dies problematisch in Bezug auf die Testbarkeit. Viel leichter zu testen ist ein solches Szenario, wenn
die zu testenden Daten bereits im ViewModel vorhanden sind. Dort sollte also für dieses Szenario für jedes Control eine Eigenschaft vorhanden sein, die anzeigt, ob das
www.dotnetpro.de dotnetpro.dojos.2011
Listing 1
Ein Control ein- und ausblenden.
public class ViewModel : INotifyPropertyChanged {
private bool kontonummerEnabled;
public bool KontonummerEnabled {
get { return kontonummerEnabled; }
set {
kontonummerEnabled = value;
PropertyChanged(this, new PropertyChangedEventArgs("KontonummerEnabled"));
}
}
public event PropertyChangedEventHandler PropertyChanged = delegate { };
}
[Abb. 2] ViewModel als Datenfluss austauschen.
Control deaktiviert werden muss beziehungsweise gar nicht angezeigt werden soll.
Listing 1 zeigt dazu ein Beispiel.
Mittels ViewModel und Data Binding
lässt sich in der View der Effekt erzielen,
dass das Control nur aktiviert ist, wenn die
Eigenschaft im ViewModel true ist.
<TextBox IsEnabled="{Binding
Path=KontonummerEnabled}" />
Der Vorteil dieser Vorgehensweise liegt
darin, dass die Deutung des Zustands bereits durch die Logikeinheit erfolgt. Ferner
lässt sich das Ergebnis im ViewModel ablesen und damit leicht automatisiert testen.
Auch die View lässt sich auf diese Weise
leicht testen. Im Test können einfach
ViewModels mit unterschiedlichen Daten
instanziert und an die View übergeben werden. Anschließend wird die View mit ShowDialog angezeigt und visuell überprüft. So
ist sichergestellt, dass auch die in XAML formulierten Data Bindings schnell überprüft
werden können. Es ist nicht mehr notwendig, die komplette Anwendung zu starten
und mit Testdaten zu füttern, um die unterschiedlichen visuellen Effekte der Views zu
überprüfen.
View und Logik verbinden
Das ViewModel bildet die Verbindung zwischen View und Logik. Im MVVM-Pattern
wird die Logik als Modell bezeichnet. Das
finde ich irreführend, weil im Rahmen des
Entwurfs das Ergebnis der Modellierung
ebenfalls Modell heißt. Daher spreche ich
lieber von Logik. Eigentlich müsste es dann
also Logik-View-ViewModel heißen.
View und Logik sind beide vom ViewModel abhängig. Dadurch wird eine saubere
Trennung zwischen View und Logik erreicht, denn zwischen ihnen gibt es keine
direkte Abhängigkeit. Allerdings bleibt
noch die Frage zu klären, wie beim FlowDesign das ViewModel konkret von View
und Logik zu verwenden ist.
Eine Möglichkeit wäre, das ViewModel
als einen Datenfluss zwischen View und
Logik aufzufassen. Das bedingt allerdings,
dass View und Logik entweder jeweils ein
neues ViewModel instanzieren oder einer
von beiden dieses als Zustand hält. In beiden Fällen wird dies aus einem reinen Datenflussdiagramm nicht deutlich. Abbildung 2 zeigt das an einem Beispiel.
Hier ist nicht ersichtlich, ob es sich beim
Datenfluss um ein und dasselbe ViewModel handelt oder ob es jeweils eine neue Instanz ist. Abhilfe schafft eine Erweiterung
der Flow-Design-Diagramme. Das ViewModel stellt eine Abhängigkeit dar. SowohlView
als auch Logik sind vom ViewModel abhängig. Und wenn sich beide auf dieselbe Instanz beziehen würden, wäre es nicht mehr
nötig, das ViewModel in Form eines Datenflusses zwischen beiden zu transportieren.
Abbildung 3 zeigt das im Beispiel. Der Datenfluss von View zu Logik dient nun lediglich dazu, die Logik über die Interaktion zu
informieren, damit dort die entsprechende
83
GRUNDLAGEN
[Abb. 3] View und Logik sind vom ViewModel abhängig.
[Abb. 4] MVVM mit Flow verheiratet.
Funktionalität ausgeführt wird. Der Datenfluss trägt allerdings keine Daten mehr, was
durch das leere Klammerpaar ausgedrückt
wird. Stattdessen sind nun View und Logik
vom ViewModel abhängig, was durch die
„Pfeile“ mit Kringel am Ende angezeigt wird.
Ein weiterer Vorteil dieser Vorgehensweise liegt darin, dass das ViewModel leicht eine Instanz sein kann, die nur einmalig beim
Öffnen der View erzeugt wird. Damit wird
das Data Binding erleichtert, da das Binden
nur einmalig erfolgen muss. Fließt jeweils
eine neue Instanz des ViewModels von der
Logik zur View, muss dieses jeweils neu gebunden werden. Dieser Nachteil wird beseitigt, wenn View und Logik die ganze Zeit
auf demselben ViewModel arbeiten.
Um zu sehen, wie das ViewModel zu den
beiden davon abhängigen Funktionseinheiten, View und Logik, gelangt, ist es notwendig, darzustellen, wie Funktionseinheiten instanziert und verbunden werden. Das Konzept der Event-Based Components sieht
mehrere Phasen vor, die durchlaufen werden, bevor Daten zwischen den Funktionseinheiten fließen können:
❚ Build,
❚ Bind,
❚ Inject,
❚ Config,
❚ Run.
Die Build-Phase ist dafür verantwortlich,
Funktionseinheiten zu instanzieren. Für
Bauteile ist dies ganz einfach, da die Konstruktoren von Bauteilen keine Parameter
haben. Das liegt daran, dass Bauteile keine
Abhängigkeiten aufweisen. Bei Platinen ist
es jedoch erforderlich, die Funktionseinheiten, die von der Platine verbunden werden,
vor der Platine zu instanzieren, da diese im
Konstruktor an die Platine übergeben werden. Die Build-Phase kann entweder manuell ausprogrammiert werden oder von einem Dependency-Injection-Container wie
StructureMap übernommen werden.
84
Aufgabe der Platinen ist es, Funktionseinheiten zu verbinden, indem Input- und
Output-Pins zusammengesteckt werden.
Dazu müssen Input-Pin-Methoden an
Output-Pin-Events gebunden werden. Insofern erfolgt die Bind-Phase innerhalb der
Build-Phase in den jeweiligen Konstruktoren der Platinen.
In der Inject-Phase geht es darum, die
Abhängigkeiten in die betroffenen Funktionseinheiten zu injizieren. Hier ist es
wichtig, zu differenzieren. Es geht nicht
um die Abhängigkeiten von Platinen zu
den in ihnen enthaltenen Funktionseinheiten. Diese sind ja bereits durch die
Build-Phase abgehandelt und wurden
durch Konstruktorinjektion aufgelöst. In
der Inject-Phase geht es um Abhängigkeiten, die explizit als solche modelliert wurden. Diese werden nun durch Aufruf einer
Methode in die abhängigen Funktionseinheiten hineingereicht.
In der vorletzten Phase, der Config-Phase, sind alle Funktionseinheiten bereits betriebsbereit. Sie sind verdrahtet und ihre
Abhängigkeiten sind erfüllt. Bevor die Anwendung am sogenannten Entry Point
startet, mögen manche Funktionseinheiten noch eine Konfiguration oder Initialisierung benötigen. So könnte es beispielsweise notwendig sein, die Daten der Anwendung durch einen Persistenzmechanismus zu laden.
Zum Schluss ist eine Funktionseinheit dafür zuständig, die Ausführung der Anwendung zu beginnen. Meist ist dies das Hauptformular der Anwendung. Diese Funktionseinheit stellt eine Run-Methode bereit.
Für die Phasen Inject, Config und Run
sollten Interfaces verwendet werden, um
diese Aspekte bei beliebigen Funktionseinheiten definieren zu können. Listing 2 zeigt
das Interface für die Inject-Phase. In der Inject-Phase müssen alle Funktionseinheiten
aufgesucht werden, die IDependsOn<T>
implementieren. Diesen muss dann die er-
forderliche Abhängigkeit durch Aufruf der
Inject-Methode übergeben werden.
Nun haben wir für die gemeinsame Verwendung eines ViewModels alles zusammen. Beim Programmstart werden alle
Funktionseinheiten instanziert und die Datenflüsse verbunden. Anschließend werden
die ViewModels an den entsprechenden
Stellen injiziert und die Anwendung kann
loslaufen.
Die View weist das ViewModel dem DataContext zu. Dadurch werden die im
XAML-Code definierten Bindings wirksam.
Ändert eine Funktionseinheit Daten im
ViewModel, werden diese Änderungen
durch das Data Binding visualisiert. Ändert
der Benutzer gebundene Daten über die
Listing 2
Interface für die Inject-Phase.
public interface IDependsOn<T> {
void Inject(T independent);
}
Listing 3
Interface für Commands.
public interface ICommand {
event EventHandler CanExecuteChanged;
bool CanExecute(object parameter);
void Execute(object parameter);
}
Listing 4
Interface eines Kommandos.
public interface IFlowCommand {
event Action ExecuteAction;
void SetCanExecute(bool newValue);
}
dotnetpro.dojos.2011 www.dotnetpro.de
GRUNDLAGEN
entsprechenden Controls, landen die Änderungen im ViewModel. Doch genau an
dieser Stelle fehlt noch ein kleiner Baustein : Wie signalisiert eine View der zugehörigen Logik eine Interaktion? Wie sind
Buttons, Toolbars und Menüs an die Logik
anzubinden?
Kommandos
WPF verwendet das Konzept der Commands. Dahinter steht das Interface ICommand, siehe Listing 3. Ein Kommando
muss zunächst eine Execute-Methode bereitstellen, die von WPF aufgerufen wird,
um das Kommando auszuführen. Natürlich sollte der Code, der für das Kommando
auszuführen ist, auf keinen Fall im Kommando abgelegt werden. Das wäre nicht
viel besser, als würde die Logik direkt in der
View untergebracht. Um den IsEnabledZustand eines Buttons oder Menüpunkts
passend setzen zu können, ruft WPF die
CanExecute-Funktion auf. Diese muss true
liefern, wenn das Kommando ausgeführt
werden kann. Ändert sich der Zustand von
CanExecute, muss dies durch CanExecuteChanged signalisiert werden.
Ein Kommando, welches ICommand implementiert, kann in WPF per Data Binding
gebunden werden. Für einen Button sieht
das beispielsweise so aus:
Listing 5
ICommand und IFlowCommand nutzen.
public class FlowCommand : ICommand, IFlowCommand
{
private bool canExecute = true;
public void Execute(object parameter) {
ExecuteAction();
}
public bool CanExecute(object parameter) {
return canExecute;
}
public event EventHandler CanExecuteChanged = delegate { };
public event Action ExecuteAction = delegate { };
public void SetCanExecute(bool newValue) {
canExecute = newValue;
CanExecuteChanged(this, EventArgs.Empty);
}
}
Listing 6
Kommandos mit im ViewModel ablegen.
public class ViewModel : INotifyPropertyChanged
{
private bool kontonummerEnabled;
private string kontonummer;
public ViewModel() {
ÜberweisungCmd = new FlowCommand();
}
public FlowCommand ÜberweisungCmd { get; private set; }
public bool KontonummerEnabled {
get { return kontonummerEnabled; }
set {
kontonummerEnabled = value;
PropertyChanged(this, new PropertyChangedEventArgs("KontonummerEnabled"));
}
}
public string Kontonummer {
get { return kontonummer; }
set {
kontonummer = value;
PropertyChanged(this, new PropertyChangedEventArgs("Kontonummer"));
}
}
<Button Command="{Binding
Path=ÜberweisungCmd}">Überweisung
</Button>
Doch wie bringt man ein Kommando mit
einem Flow zusammen? Dazu muss das
Kommando einen Ausgang haben, an dem
ein Datenfluss beginnen kann. Ausgehende
Flüsse werden bei EBC mit Events implementiert. Folglich muss das Kommando bei
Aufruf der Execute-Methode einen Event
auslösen, der dann einen Datenfluss startet.
Umgekehrt muss es möglich sein, durch einen eingehenden Datenfluss zu bestimmen,
ob ein Kommando weiterhin eingeschaltet
ist. Eingehende Datenflüsse werden bei EBC
durch Methoden realisiert. Somit sieht das
Interface eines Kommandos aus EBC-Sicht
so aus, wie es Listing 4 zeigt.
ExecuteAction ist der vom Kommando
ausgehende Datenfluss, der initiiert werden
muss, wenn WPF die Execute-Methode aufruft. SetCanExecute ist der eingehende Datenfluss, mit dem ein Logikbauteil das Kommando ein- oder ausschalten kann. Eine Implementation der beiden Interfaces ICommand und IFlowCommand zeigt Listing 5.
Damit kann das Kommando nun sowohl
WPF-konform verwendet werden als auch
www.dotnetpro.de dotnetpro.dojos.2011
}
Listing 7
Datenflüsse verdrahten.
public class Mainboard
{
public Mainboard(MainWindow mainWindow, Logik logik, FlowCommand überweisungCmd) {
überweisungCmd.ExecuteAction += logik.ÜberweisungAktivieren;
logik.ÜberweisungAktiviert += überweisungCmd.SetCanExecute;
}
}
85
GRUNDLAGEN
in einem Datenfluss stehen. Für die WPFSeite ist ICommand zuständig, für die EBCSeite IFlowCommand.
Für das Data Binding ist es sinnvoll, die
Kommandos mit im ViewModel abzulegen.
So kann der DataContext der Form für alle
Bindings verwendet werden. Das ViewModel für ein einfaches Beispiel zeigt Listing 6.
Auf diese Weise können in der XAMLDatei die Eigenschaften ÜberweisungCmd,
Kontonummer und KontonummerEnabled
gebunden werden.
Listing 8
Build, Bind und Inject.
var mainWindow = new MainWindow();
var logik = new Logik();
var viewModel = new ViewModel();
var mainBoard = new Mainboard(mainWindow,
logik, viewModel.ÜberweisungCmd);
mainWindow.Inject(viewModel);
logik.Inject(viewModel);
Auf der EBC-Seite werden View und Logik sowie das Kommando in eine Platine injiziert. Die Platine kann dann die Datenflüsse verdrahten, siehe Listing 7. Hier wird das
Kommando so verdrahtet, dass es mit dem
Eingang ÜberweisungAktivieren der Logik
verbunden ist. Die Logik-Funktionseinheit
kann daraufhin die notwendige Funktionalität ausführen. Durch die Verbindung
von ÜberweisungAktiviert zu SetCanExecute
wird nach Ausführung des Kommandos
durch die Logik bestimmt, ob das Kommando nach wie vor eingeschaltet bleibt.
Nach Build und Bind wird das ViewModel in die betroffenen Bauteile injiziert.
Build, Bind und Inject sehen damit so aus
wie in Listing 8. Abbildung 4 zeigt, wie die
Funktionseinheiten zusammenspielen.
Die View ist abhängig vom ViewModel.
Dies ist notwendig, damit die View per Data Binding auf das ViewModel zugreifen
kann. Auch die Logik ist vom ViewModel
abhängig, damit sie Daten zur Visualisierung im ViewModel ablegen und Benutzereingaben von dort entnehmen kann.
Zusätzlich zu den Abhängigkeiten steht
ein Teil des ViewModels, nämlich die darin
enthaltenen Kommandos, im Flow. Dabei
können Datenflüsse zum einen von Kommandos ausgehen, um eine Benutzerinteraktion zu signalisieren. Zum anderen können sie im Kommando enden, um ein
Kommando ein- oder auszuschalten.
Fazit
Durch die „Hochzeit“ zwischen MVVM
und Flows steht einer WPF-konformen
Umsetzung von Anwendungen, die auf Datenflüssen basieren, nichts mehr im Weg.
So können Sie die tollen Möglichkeiten des
Data Bindings voll ausschöpfen. Und natürlich können so auch die tollen Möglichkeiten des Flow-Designs und der Umsetzung mit Event-Based Components zum
Einsatz kommen.
[ml]
[1] Torsten Zimmermann, Ein Rahmen für Feinheiten, Die Funktionsweise von MVVM-Frameworks für WPF, dotnetpro 1/2011, Seite 14 ff.,
www.dotnetpro.de/A1101FrameworkTest
[2] Torsten Zimmermann, Geschickt verbunden,
Microsofts Framework für das Entwurfsmuster
MVVM, dotnetpro 1/2011, Seite 40 ff.,
www.dotnetpro.de/A1101WAF
AUFGABE
Synchronisation über die Cloud
Was steht in den Wolken?
Über die Cloud wurde genügend spekuliert. Es wird Zeit, sie konkret anzuwenden. Stefan, kannst du zu diesem
wolkigen Thema eine möglichst handfeste Übung stellen?
D
ie Idee zu dieser Übung hängt mit
der Aufgabe im vorhergehenden
Heft zusammen, deren Lösung auf
den folgenden Seiten präsentiert
wird. Dort geht es um eine To-do-Listenanwendung, die ihre Daten lokal in einer Datei persistiert. Dass die Daten lokal persistiert werden, hat
den großen Vorteil, dass sie auch dann zur Verfügung stehen, wenn das entsprechende Gerät
mal gerade nicht mit dem Internet verbunden
ist. Dennoch besteht oft der Wunsch, die Daten
über die Cloud mit einem anderen Gerät zu synchronisieren.
Diese Möglichkeit fehlt mir beispielsweise bei
Things [1], einer To-do-Listenanwendung für
Mac und iPhone/iPad. Leider kommt der Hersteller dieser Software schon seit mehreren Monaten nicht dem Versprechen nach, eine Synchronisation über die Cloud anzubieten. Zwar
kann man die Daten per WLAN synchronisieren.
Viel einfacher wäre aber eine Synchronisation
über einen Service in der Cloud: Dann könnte
die Software im Hintergrund selbstständig synchronisieren, und zwar unabhängig davon, ob
die betreffenden Geräte gerade eingeschaltet
sind oder nicht.
Durch die Wolke stechen
Doch wie schwierig ist die Realisierung einer solchen Synchronisation? Das herauszufinden ist
die Übung für diesen Monat. Es geht dabei um
einen sogenannten Spike. Ein Spike hat den Erkenntnisgewinn zum Ziel. Es geht also nicht darum, einen produktionsreifen Synchronisationsdienst zu entwickeln, sondern darum herauszufinden, wie ein solcher technisch realisierbar wäre.
Um Daten über die Cloud zu synchronisieren,
benötigt man einen Datenspeicher in der Cloud.
Dieser ist von allen Clients aus erreichbar und
kann somit verwendet werden, um darüber Daten auszutauschen. Auf der einen Seite schreibt
ein Client seine Änderungen in diesen Cloudspeicher. Auf der anderen Seite holt sich ein anderer Client aus dem Cloudspeicher die Änderungen des ersten Clients. Beide Clients arbeiten
also mit denselben Daten. Am Ende darf allerdings kein Client die Änderungen des anderen
www.dotnetpro.de dotnetpro.dojos.2011
überschreiben. Findet ein Client in der Cloud
neue oder geänderte Daten, müssen diese in den
lokalen Speicher eingepflegt werden. Ferner
muss der Client seine lokalen Änderungen an
den Cloudspeicher melden.
Natürlich können beim Synchronisieren Konflikte entstehen. Das passiert, wenn beide Clients
dieselben Daten ändern und dann synchronisieren. Solche Konflikte sollten mindestens erkannt
werden. Die Lösung des Konflikts könnte darin
bestehen, den Anwender entscheiden zu lassen,
welchen Stand er als aktuell akzeptieren möchte.
Treten solche Konflikte häufig auf, könnte die Lösung darin bestehen, die Änderungen zusammenzufassen. Dies ist allerdings relativ aufwendig und vor allem nicht generisch lösbar. Es wird
hier nicht weiter betrachtet.
Als Cloudspeicher kann beispielsweise Amazon SimpleDB [2] zum Einsatz kommen. Dieser
Service bietet ausreichend kostenlose Kapazitäten, um damit zu experimentieren. Mit dem
Open-Source-Framework Simple Savant [3] steht
ferner ein einfach zu bedienendes API zur Verfügung. Wer mag, kann auch eine Lösung mit
Windows Azure versuchen [4].
Bleibt am Ende die Frage, wie man die Synchronisation algorithmisch löst. Klar ist, dass jeder Datensatz über eine eindeutige ID verfügen
muss. Nimmt man dazu einen GUID, ist sichergestellt, dass die IDs auch über mehrere Clients
hinweg eindeutig sind. So ist es schon mal einfach zu erkennen, ob ein Datensatz in der Cloud
und/oder lokal vorhanden ist.
Für die Synchronisation der Änderungen sind
Fälle interessant, in denen sowohl in der Cloud
als auch lokal eine Version der Daten vorliegt. Um
dann zu erkennen, in welcher Richtung ein Update erfolgen muss, benötigt man eine Versionsnummer der Daten. Viel Spaß beim Tüfteln! [ml]
In jeder dotnetpro finden Sie
eine Übungsaufgabe von
Stefan Lieser, die in maximal
drei Stunden zu lösen sein
sollte. Wer die Zeit investiert,
gewinnt in jedem Fall – wenn
auch keine materiellen Dinge,
so doch Erfahrung und Wissen.
Es gilt :
❚ Falsche Lösungen gibt es
nicht. Es gibt möglicherweise elegantere, kürzere
oder schnellere Lösungen,
aber keine falschen.
❚ Wichtig ist, dass Sie reflektieren, was Sie gemacht
haben. Das können Sie,
indem Sie Ihre Lösung mit
der vergleichen, die Sie
eine Ausgabe später in
dotnetpro finden.
Übung macht den Meister.
Also − los geht’s. Aber Sie
wollten doch nicht etwa
sofort Visual Studio starten…
[1] Cultured Code, Things,
http://culturedcode.com/things/
[2] Amazon SimpleDB,
http://aws.amazon.com/de/simpledb/
[3] Simple Savant, http://simol.codeplex.com/
[4] Windows Azure, http://www.microsoft.com/
germany/net/WindowsAzure/
87
Wer übt, gewinnt
dnpCode: A1105DojoAufgabe
LÖSUNG
Synchronisation über die Cloud
Nicht ohne meine Wolke
Seine Daten will man am liebsten überall von verschiedenen Geräten aus verfügbar haben. Kein Problem, wenn man
sie über die Cloud synchronisiert. Und das ist gar nicht so schwer.
dnpCode: A1106DojoLoesung
Stefan Lieser ist Softwareentwickler aus Leidenschaft. Nach
seinem Informatikstudium mit
Schwerpunkt auf Softwaretechnik
hat er sich intensiv mit Patterns
und Principles auseinandergesetzt. Er arbeitet als Berater und
Trainer, hält zahlreiche Vorträge
und hat gemeinsam mit
Ralf Westphal die Initiative Clean
Code Developer ins Leben
gerufen. Sie erreichen ihn unter
[email protected] oder
lieser-online.de/blog.
D
ie Synchronisierung von Daten wird
immer wichtiger. Immer mehr Anwender nutzen Smartphones und Tablets,
die ihrerseits immer leistungsfähiger werden.
Damit wächst der Wunsch, alle relevanten Daten
auch offline, also ohne Verbindung zum Internet,
auf dem Gerät zur Verfügung zu haben. Aber natürlich müssen die Daten mit anderen Geräten
synchronisiert werden, da die meisten Nutzer
von Smartphones wohl zusätzlich einen Arbeitsplatzrechner und/oder ein Notebook verwenden.
Ein zweiter Trend verstärkt die Nachfrage nach
Synchronisationslösungen: die Cloud. Dienste in
der Cloud werden ebenfalls immer leistungsfähiger, gleichzeitig sinken die Preise. Was liegt also
näher, als die Synchronisation der Daten über die
Cloud auszuführen? Die Idee dabei: Alle beteiligten Geräte synchronisieren sich mit einem Dienst,
der in der Cloud läuft, siehe Abbildung 1.
Dadurch muss man nicht mehr zwei Geräte
miteinander verbinden, um Daten zu synchronisieren. Denn das ist lästig. Wenn ich mit dem
Notebook arbeite, möchte ich alle Änderungen
von dort in die Cloud synchronisieren. Anschließend „Deckel zu“ und Smartphone raus: Die Daten sollen nun von der Cloud zum Smartphone
übertragen werden. Dabei kann zwischen dem
Wechsel von einem zum anderen Gerät auch mal
ein längerer Zeitraum vergehen. Das ist komfortabler, als wenn man zum Synchronisieren beide
Geräte gleichzeitig verfügbar haben muss.
Damit sich die Geräte synchronisieren können, müssen die Daten über die Cloud erreich-
[Abb. 1] Daten über einen Cloud-Service synchronisieren.
88
bar sein. Das bedingt noch nicht, dass sie in der
Cloud gespeichert werden. Ein Webservice, der
über das Internet erreichbar ist und der seine
Daten auf einem firmeneigenen Server ablegt,
kann den Zweck ebenso erfüllen. Damit die Lösung hier nicht zu umfangreich ausfällt, habe ich
mich jedoch dazu entschieden, direkt auf einen
Datenspeicher in der Cloud zu setzen. Dadurch
ist es nicht erforderlich, eine eigene Infrastruktur
in der Cloud aufzubauen. Der Datenspeicher
muss lediglich über das Internet erreichbar sein.
Ein zusätzlicher Webservice entfällt. Die Clients
greifen zum Synchronisieren direkt auf den Speicherservice in der Cloud zu.
Amazon SimpleDB
Wie in der Aufgabenstellung angedeutet, setzte
ich bei meiner Lösung auf Amazon SimpleDB.
Amazon bietet kostenlos ein ausreichend großes
monatliches Kontingent an, sodass bei den Experimenten keine Kosten anfallen. Man muss
sich lediglich für die Nutzung von SimpleDB bei
Amazon anmelden [1]. Als API habe ich das
Open-Source-Framework Simple Savant [2] verwendet. Es vereinfacht das Speichern und Laden
von Objekten im SimpleDB Storage.
Synchronisation
Doch ehe wir zu den Details des Cloud-Speichers
kommen, muss eine Strategie entwickelt werden,
nach der die Synchronisation erfolgen soll. Es ist
naheliegend, dass die einzelnen Datensätze einen
eindeutigen Identifier benötigen. Und natürlich ist
[Abb. 2] Synchronisieren als Flow.
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
es naheliegend, dazu den .NET-Datentyp
Guid zu verwenden. Der Vorteil: Das Erzeugen einer ID für einen neuen Datensatz
kann dezentral erfolgen, da der Guid-Algorithmus sicherstellt, dass die generierten
IDs eindeutig sind.
Für den Synchronisationsvorgang habe
ich die einzelnen zu synchronisierenden
Datensätze zusätzlich mit einer Versionsnummer versehen. Durch diese Nummer
kann erkannt werden, ob Daten zwischenzeitlich auf einem anderen Client aktualisiert wurden: Ist die lokal vorhandene Version kleiner als die in der Cloud, muss offensichtlich eine Übertragung von der
Cloud in den lokalen Speicher erfolgen.
Damit das Verfahren funktioniert, muss sichergestellt werden, dass die Versionsnummer beim Speichern einer Änderung in der
Cloud jeweils erhöht wird. Nur beim Speichern in der Cloud darf die Versionsnummer verändert werden! So können Clients
leicht feststellen, ob in der Cloud eine
neuere Version eines Datensatzes existiert.
Allerdings genügt die Versionsnummer
noch nicht, um alle möglichen Fälle abzudecken. Zusätzlich muss jeder Client an
den Datensätzen festhalten, ob sie lokal geändert wurden. Sind die Versionsnummern
nämlich gleich und liegen lokale Änderungen vor, müssen diese zur Cloud übertragen werden. Alle Szenarien sind in der Tabelle 1 abgebildet.
Die beiden ersten Fälle sind einfach:
Wenn Daten lokal, aber nicht entfernt vorhanden sind, muss der Datensatz in die
Cloud übertragen und dort eingefügt werden. Sind die Daten umgekehrt nur in der
Cloud, aber nicht lokal vorhanden, müssen
sie lokal eingefügt werden.
Der dritte Fall liegt vor, wenn Daten lokal
geändert wurden. Beide Versionsnummern
sind gleich, das heißt, lokal lag vor den Änderungen der Stand vor, der derzeit in der
Cloud liegt. Daher müssen die lokalen Änderungen in die Cloud übertragen werden.
Wurden Daten auf einem anderen Client
geändert und in die Cloud übertragen, liegt
der vierte Fall vor: Die entfernte Versionsnummer ist höher als die lokale, und es
gibt lokal keine Änderungen. In diesem Fall
werden die Änderungen aus der Cloud lokal übernommen.
Im fünften Fall sind die Versionsnummern gleich, und es liegen lokal keine Änderungen vor. In diesem Fall ist nichts zu tun.
Beim sechsten Fall kommt es zu einem
Konflikt: Hier liegen Änderungen sowohl
lokal wie entfernt vor. Die lokalen Änderungen werden am Changed Flag erkannt,
www.dotnetpro.de dotnetpro.dojos.2011
[Tabelle 1] Mögliche Fälle
bei der Synchronisation.
die entfernten daran, dass entfernt eine
höhere Versionsnummer als lokal vorliegt.
In diesem Konfliktfall müssen die Änderungen zusammengeführt werden, sofern
dies möglich ist. Im einfachsten Fall wird
der Anwender informiert und um eine Entscheidung gebeten, welche Daten herangezogen werden sollen.
Zuletzt bleiben noch zwei Fälle in der Tabelle übrig, die nicht eintreten können,
wenn alles richtig implementiert ist: Die lokale Versionsnummer ist größer als die entfernte. Dieser Fall kann nicht eintreten, sofern die Versionsnummer nur beim Spei-
chern von Daten auf dem entfernten System erhöht wird.
Löschen
Die Tabelle sieht einfach und übersichtlich
aus, die Implementation dürfte eigentlich
keine Schwierigkeiten bereiten. Doch der
Teufel steckt im Detail: Das Verfahren eignet sich nicht für das Synchronisieren von
Löschungen. Überhaupt ist das Löschen
von Daten die größte Herausforderung
beim Synchronisieren.
Dazu ein Beispiel: Zunächst wird auf
dem Client ein Datensatz angelegt und in
Listing 1
Definition der Platine.
<?xml version="1.0" encoding="utf-8"?>
<board name="ToDo_Liste_synchronisieren" implements="IToDo_Liste_synchronisieren">
<using namespace="ebcpatterns"/>
<using namespace="ebcpatterns.infrastructure"/>
<using namespace="todo.contracts.datamodel"/>
<external name="IToDo_Liste_in_ViewModel_uebersetzen"/>
<external name="ILokale_ToDo_Liste_laden"/>
<external name="IToDo_Liste_speichern"/>
<wire from="this" type="" to="Lokale_ToDo_Liste_laden"/>
<wire from="this" type="" to="Remote_ToDo_Liste_laden"/>
<wire from="Lokale_ToDo_Liste_laden" type="ToDoListe"
to="(ResetJoin{ToDoListe,ToDoListe,Tuple{ToDoListe,ToDoListe}})join.Input1"/>
<wire from="Remote_ToDo_Liste_laden" type="ToDoListe"
to="(ResetJoin{ToDoListe,ToDoListe,Tuple{ToDoListe,ToDoListe}})join.Input2"/>
<wire from="(ResetJoin{ToDoListe,ToDoListe,Tuple{ToDoListe,ToDoListe}})join.Output"
type="Tuple{ToDoListe,ToDoListe}" to="ToDo_Listen_synchronisieren"/>
<wire from="ToDo_Listen_synchronisieren" type="ToDoListe"
to="Remote_ToDo_Liste_speichern"/>
<wire from="ToDo_Listen_synchronisieren" type="ToDoListe"
to="ToDo_Liste_in_ViewModel_uebersetzen"/>
<wire from="ToDo_Listen_synchronisieren" type="ToDoListe"
to="ToDo_Liste_speichern"/>
<dependency from="ToDo_Listen_synchronisieren" to="SharedState{ToDoListe}" />
</board>
89
LÖSUNG
Listing 2
chronisieren entfernt werden, wenn sichergestellt ist, dass diese Daten beim Synchronisieren von der Cloud zum Client nicht
wieder auf dem Client angelegt werden.
Key und Passwort auslesen.
public class Secrets {
public string AwsAccessKeyId { get; set; }
public string AwsSecretAccessKey { get; set; }
public static Secrets LoadFromFile(string filename) {
var serializer = new SharpSerializer();
return (Secrets)serializer.Deserialize(filename);
}
public void SaveToFile(string filename) {
var serializer = new SharpSerializer();
serializer.Serialize(this, filename);
}
}
Listing 3
Das Simple-Savant-API initialisieren.
var secrets = Secrets.LoadFromFile("aws.secrets");
var config = new SavantConfig {
ReadConsistency = ConsistencyBehavior.Immediate
};
Savant = new SimpleSavant(secrets.AwsAccessKeyId, secrets.AwsSecretAccessKey, config);
Listing 4
Festlegen der zu speichernden Attribute.
var itemNameMapping = AttributeMapping.Create("Id", typeof(Guid));
itemMapping = ItemMapping.Create(domainName, itemNameMapping);
itemMapping.AttributeMappings.Add(AttributeMapping.Create("Text", typeof(string)));
itemMapping.AttributeMappings.Add(AttributeMapping.Create("Erledigt", typeof(bool)));
itemMapping.AttributeMappings.Add(AttributeMapping.Create("Version", typeof(int)));
itemMapping.AttributeMappings.Add(AttributeMapping.Create("Deleted", typeof(bool)));
die Cloud synchronisiert. Damit steht der
Datensatz nun mit der gleichen Versionsnummer lokal und entfernt zur Verfügung.
Wird er nun lokal gelöscht und anschließend synchronisiert, landen wir bei Fall
zwei der Tabelle: Der Datensatz wird aus
der Cloud wieder zum Client übertragen,
das Löschen damit rückgängig gemacht.
Ich habe die Synchronisation in die ToDo-Listenanwendung integriert, die ich für
die Übung aus dem vorhergehenden Heft
erstellt habe. Für das Synchronisieren der
Löschungen habe ich die Anwendung zunächst so umgestellt, dass das Löschen
nicht hart auf den Daten ausgeführt wird,
sondern nur soft, indem ein Marker in den
Daten gesetzt wird. BeimVisualisieren ignoriert der Client alle als gelöscht markierten
Daten. Allerdings werden die gelöschten
Daten weiterhin in der Datendatei abge-
90
legt. Damit ist das Problem der Löschsynchronisation auf das normale Synchronisieren von Änderungen zurückgeführt.
Auf dem Client führt beim Synchronisieren von Löschungen ohnehin kein Weg daran vorbei, über die Löschungen bis zum
nächsten Synchronisieren Buch zu führen.
Insofern ist das softe Löschen eine gute Vorbereitung für eine leistungsfähigere Implementation. Schließlich würden sich im Laufe der Zeit einige Daten ansammeln, wenn
die als gelöscht markierten Daten nie entfernt würden. In der vorliegenden Implementation werden einfach alle als gelöscht
markierten Daten dauerhaft gespeichert
und synchronisiert. In einem späteren
Schritt kann man dies so erweitern, dass die
als gelöscht markierten Daten nur in der
Cloud gehalten werden. Auf den Clients
könnten die Löschungen nach dem Syn-
Show me your Code!
Doch nun zur Implementation. Zunächst
habe ich die Synchronisation der ToDoListe auf relativ hohem Abstraktionsniveau
durch einen Flow abgebildet. In diesem
Flow werden die lokalen sowie die entfernten Daten gelesen und dann zu einer synchronisierten Liste zusammengefasst. Diese zusammengefasste Liste wird anschließend lokal und in der Cloud gespeichert.
Ferner wird sie in das ViewModel gemappt,
um im GUI visualisiert zu werden.
Abbildung 2 zeigt den Flow. Alle Pfeile in
der Abbildung repräsentieren Datenflüsse,
Abhängigkeiten sind durch eine Verbindungslinie mit einem Punkt am Ende dargestellt. Die Abbildung ist aus einer XMLDatei generiert. Gleichzeitig werden aus
dieser Datei die Interfaces für alle Funktionseinheiten und die Implementation der
Platinen generiert. Das dabei verwendete
Tooling ist im Open-Source-Projekt ebclang unter [3] zu finden. Listing 1 zeigt die
Definition der Platine.
Das Synchronisieren beginnt, indem die
lokale und die entfernte ToDo-Liste geladen werden. Die beiden Ergebnisse der Ladevorgänge werden durch einen Join-Baustein zu einem Tuple zusammengefasst.
Ganz wichtig bei diesem Join: Erst wenn
beide Eingänge über Daten verfügen, wird
der Datenstrom am Ausgang in Gang gesetzt. Bei einem erneuten Durchlaufen der
Synchronisation müssen wieder beide Eingänge des Joins anliegen. Daher wird hier
ein ResetJoin-Baustein aus dem ebclangProjekt verwendet. Bei diesem müssen jedes Mal beide Eingänge anliegen, bevor
der Ausgang freigeschaltet wird. So ist sichergestellt, dass bei jedem Synchronisationsvorgang sowohl die lokalen als auch die
entfernten Daten ermittelt werden.
Der zweite wichtige Aspekt des Flows
liegt in der Abhängigkeit des Bauteils ToDo_Listen_synchronisieren von SharedState<ToDoListe>. Diese Abhängigkeit ist im
Kontext der ToDo-Listenanwendung erforderlich, damit das Modell der ToDo-Liste
im Speicher aktualisiert wird. Dieses Modell repräsentiert den gesamten Zustand
der Anwendung.
Bauteile implementieren
Nachdem das Flow-Design der Synchronisation erstellt war, habe ich begonnen, die
6. 2011 www.dotnetpro.de
LÖSUNG
Listing 5
Die ToDo-Liste aus dem Cloud-Speicher lesen.
private IEnumerable<ToDo> LoadToDos() {
using (new ConsistentReadScope()) {
var selectStatement = new SelectCommand(simpleDb.ItemMapping, string.Format("select * from {0}", simpleDb.DomainName));
var results = simpleDb.Savant.SelectAttributes(selectStatement);
foreach (var propertyValues in results) {
var toDo = (ToDo)PropertyValues.CreateItem(simpleDb.ItemMapping, typeof(ToDo), propertyValues);
Trace.TraceInformation(" Id: {0}", toDo.Id);
yield return toDo;
}
}
}
einzelnen Bauteile zu implementieren. Das
Laden der lokalen ToDo-Liste aus einer Datei war schon fertig und konnte wiederverwendet werden. Auch das lokale Speichern
und das Übersetzen in das ViewModel hatte ich bereits im Rahmen der ToDo-Anwendung implementiert. Wer die Lösung
der ToDo-Listenanwendung im vorhergehenden Heft studiert hat, sollte sich die
aktuelle Version der Anwendung noch mal
anschauen − es hat sich einiges geändert.
Zur Implementation standen also an:
❚ Laden und Speichern der entfernten
ToDo-Liste in einer SimpleDB-Datenbank in der Cloud.
❚ Das eigentliche Synchronisieren zweier
Listen.
Über den Wolken
Beginnen wir beim Laden und Speichern
in der Cloud. Um Amazons SimpleDB nutzen zu können, muss man bei jedem APIAufruf einen Key und ein Passwort übergeben. Simple Savant erwartet beide als Konstruktorparameter, sodass man die Angaben nur an einer Stelle hinterlegen muss.
Natürlich haben solch vertrauliche Daten
nichts im Quellcode zu suchen, sondern
müssen in eine Konfigurationsdatei ausgelagert werden. Und tun Sie sich gleich den
Gefallen, den Dateinamen in die IgnoreListe der Versionsverwaltung aufzunehmen. Sonst erhöht sich das Risiko, dass Ihre Zugangsdaten plötzlich nicht mehr so
geheim sind, wie sie es verdient haben.
Für das Lesen der Zugangsdaten habe
ich eine Klasse implementiert, die einen
XML-Serialisierer verwendet, um die Daten aus der Datei zu lesen, siehe Listing 2.
Damit ist die Initialisierung der Simple
Savant API ganz einfach, siehe Listing 3.
Danach wird definiert, welche Attribute in
den SimpleDB-Datensätzen abgelegt wer-
www.dotnetpro.de 6. 2011
den sollen, siehe Listing 4. Anschließend
können Sie bereits Objekte im Cloud-Speicher ablegen. Das Lesen der ToDo-Liste
aus dem Cloud-Speicher zeigt Listing 5.
Das Schreiben der Daten in den CloudSpeicher sieht ähnlich aus. Den kompletten
Quellcode für den Cloud-Speicherzugriff
finden Sie auf der beiliegenden Heft-DVD. Er
befindet sich innerhalb des Projektverzeichnisses in der Solution source\todo.simpledbadapter\todo.simpledbadapter.sln.
Ferner wird in der kommenden dotnetpro ein Artikel zum Einsatz von SimpleDB
erscheinen.
Listing 6
ToDo-Listen synchronisieren.
public void Process(Tuple<ToDoListe, ToDoListe> message) {
Trace.TraceInformation("ToDo_Listen_synchronisieren.Process");
Trace.TraceInformation(" Local count: {0}", message.Item1.ToDos.Count());
Trace.TraceInformation(" Remote count: {0}", message.Item2.ToDos.Count());
var distinctIds = GetDistinctIds(message.Item1.ToDos, message.Item2.ToDos);
var pairs = GetPairs(distinctIds, message.Item1.ToDos, message.Item2.ToDos);
var actions = GetSyncActions(pairs);
var result = GetResult(actions);
sharedState.Write(result);
Result(result);
}
Listing 7
LINQ nutzen.
internal static IEnumerable<Guid> GetDistinctIds(IEnumerable<ToDo> localToDos,
IEnumerable<ToDo> remoteToDos) {
return (from l in localToDos select l.Id)
.Union(from r in remoteToDos select r.Id)
.Distinct()
.ToList();
}
internal static IEnumerable<Tuple<ToDo, ToDo>> GetPairs(IEnumerable<Guid>
distinctIds, IEnumerable<ToDo> localToDos, IEnumerable<ToDo> remoteToDos) {
return (from id in distinctIds
let local = localToDos.FirstOrDefault(x => x.Id == id)
let remote = remoteToDos.FirstOrDefault(x => x.Id == id)
select new Tuple<ToDo, ToDo>(local, remote))
.ToList();
}
91
LÖSUNG
Listing 8
Das Ermitteln der Synchronisationsaktion testen.
[TestFixture]
public class SyncResultTests {
private readonly Guid guid_1 =
new Guid("11111111-1111-1111-1111-111111111111");
private readonly Guid guid_2 =
new Guid("22222222-2222-2222-2222-222222222222");
[Test]
public void Only_local_data_found() {
var syncResult = Syncing.GetSyncResult(
new ToDo(),
null);
Assert.That(syncResult, Is.EqualTo(SyncResult.InsertRemote));
}
[Test]
public void Only_remote_data_found() {
var syncResult = Syncing.GetSyncResult(
null,
new ToDo());
Assert.That(syncResult, Is.EqualTo(SyncResult.InsertLocal));
}
[Test]
public void Local_changes() {
var syncResult = Syncing.GetSyncResult(
new ToDo { Id = guid_1, Version = 1, Changes = true},
new ToDo { Id = guid_1, Version = 1 });
Assert.That(syncResult, Is.EqualTo(SyncResult.UpdateRemote));
}
[Test]
public void No_local_changes() {
var syncResult = Syncing.GetSyncResult(
new ToDo { Id = guid_1, Version = 1 },
new ToDo { Id = guid_1, Version = 1 });
Assert.That(syncResult, Is.EqualTo(SyncResult.Nothing));
}
[Test]
public void Local_and_remote_changes() {
Synchronisieren
Das Synchronisieren arbeitet auf zwei Listen.
Eine enthält den lokalen Stand der Daten, die
andere den Stand der Daten in der Cloud.
Um die Listen zu synchronisieren, müssen
immer je zwei zusammengehörige Einträge verglichen werden. Dabei sind zunächst
drei Fälle zu unterscheiden:
❚ ein Datensatz ist nur lokal vorhanden,
❚ ein Datensatz ist nur entfernt vorhanden,
❚ ein Datensatz ist sowohl lokal als auch
entfernt vorhanden.
Schon bei dieser ersten Überlegung wird
klar, dass der komplette Vorgang der Synchronisation der beiden Listen nicht sinnvoll in einer einzigen Methode unterzubringen ist. Ferner ist die Aufgabenstellung zu
kompliziert, um sofort draufloszucodieren.
Nachdenken hilft bekanntlich, also habe
ich vor der Implementation einen weiteren
Flow entworfen. Abbildung 3 zeigt, wie die
beiden Listen synchronisiert werden.
92
var syncResult = Syncing.GetSyncResult(
new ToDo {Id = guid_1, Version = 1, Changes = true},
new ToDo {Id = guid_1, Version = 2});
Assert.That(syncResult, Is.EqualTo(SyncResult.Conflict));
}
[Test]
public void Remote_changes() {
var syncResult = Syncing.GetSyncResult(
new ToDo { Id = guid_1, Version = 1 },
new ToDo { Id = guid_1, Version = 2 });
Assert.That(syncResult, Is.EqualTo(SyncResult.UpdateLocal));
}
[Test]
public void Invalid_local_version_with_local_changes() {
Assert.Throws<InvalidOperationException>(() =>
Syncing.GetSyncResult(
new ToDo {Version = 2, Changes = true},
new ToDo {Version = 1}));
}
[Test]
public void Invalid_local_version_without_local_changes() {
Assert.Throws<InvalidOperationException>(() =>
Syncing.GetSyncResult(
new ToDo {Version = 2},
new ToDo {Version = 1}));
}
[Test]
public void Different_ids() {
Assert.Throws<InvalidOperationException>(() =>
Syncing.GetSyncResult(
new ToDo {Id = guid_1},
new ToDo {Id = guid_2}
));
}
}
Im ersten Schritt (GetDistinctIds) wird eine Liste aller IDs gebildet, die in den beiden Datenlisten auftreten. Die Liste der IDs
wird im zweiten Schritt (GetPairs) verwendet, um jeweils Paare von Datensätzen zu
bilden. Dabei werden jeweils die Datensätze aus der lokalen und entfernten Liste mit
gleicher ID zusammengestellt.
Der dritte Schritt (GetSyncActions) ermittelt zu jedem der Paare die Aktion, die auszuführen ist. Dabei werden die in der Tabelle
gezeigten Regeln umgesetzt. Zuletzt (GetRe-
sult) werden die ermittelten Aktionen auf
das zugehörige Paar angewandt, und es entsteht eine synchronisierte Liste. Der dargestellte Datenfluss ist kein astreiner Flow,
das sei hier zugestanden. Die Umsetzung habe ich mit Methoden gelöst, siehe Listing 6.
Der Vorteil dieser Aufteilung auf Methoden liegt in jedem Fall in der Testbarkeit.
Die einzelnen Schritte der Synchronisation
lassen sich isoliert testen. Am Ende genügen dann wenige Integrationstests, die den
Datenfluss vollständig durchlaufen.
[Abb. 3] Zwei Listen
synchronisieren.
dotnetpro.dojos.2011 www.dotnetpro.de
LÖSUNG
In den einzelnen Methoden habe ich
LINQ wieder einmal schätzen gelernt. GetDistinctIds und GetPairs sind mit LINQ
schnell implementiert, siehe Listing 7.
Die Musik spielt dann am Ende an zwei
Stellen: beim Ermitteln der Synchronisationsaktion und beim Zusammenstellen der
Ergebnisliste. Die Synchronisationsaktion
habe ich testgetrieben stur nach der Tabelle
umgesetzt: ersten Fall der Tabelle genommen, in einem Test abgebildet, implementiert; zweiten Fall abgebildet, implementiert.
Und so fort. Das ging leicht von der Hand.
Und wie sich später zeigte, war dieser Teil
auch von Anfang an korrekt. Auch die anderen Einzelteile waren sofort korrekt. Lediglich beim Join habe ich anfangs den falschen verwendet, weshalb mehrfaches
Synchronisieren nicht auf Anhieb funktionierte. Listing 8 zeigt die Tests für das Ermitteln der Synchronisationsaktion.
Die Umsetzung besteht dann nur aus ein
paar Bedingungen, die in der richtigen Reihenfolge überprüft werden müssen, siehe
Listing 9. Für das Zusammenstellen der Ergebnisliste müssen nun die Aktionen jeweils pro Datenpaar ausgeführt werden,
siehe Listing 10.
Je nach ermittelter Aktion wird der lokale
oder der entfernte Datensatz in die Ergebnisliste übernommen. Synchronisationskonflikte habe ich hier nicht berücksichtigt,
da dazu eine Interaktion mit dem Benutzer
erforderlich ist. Die Konflikte müssten also
vor dem Bilden der Ergebnisliste behandelt
werden. Doch die Übung ist ohnehin wieder etwas länglich geraten, daher habe ich
die Konfliktbehandlung weggelassen.
Fazit
Das Synchronisieren von Datenbeständen
mehrerer Clients mittels eines Cloud-Speichers ist kein Hexenwerk. Die Lösung hat
wenige Stunden in Anspruch genommen.
Sie ist sicher noch nicht robust genug, um in
ein Produkt aufgenommen zu werden. Doch
der Schritt dahin ist nicht wirklich aufwendig. Für den Einsatz einer Synchronisation
in einem Produkt will man dem Benutzer
sicher auch nicht zumuten, seine AmazonSimpleDB-Credentials zurVerfügung zu stellen. Das heißt, dass man einen Webservice
ergänzen müsste, über den die Synchronisation erfolgt. Auch das ist nicht mit Zauberei verbunden. Ich frage mich also am
Ende weiterhin, wieso einzelne Produkte
trotz lange zurückliegender Ankündigung
immer noch keine Cloud-Synchronisation
anbieten. An der technischen Herausforderung kann es jedenfalls nicht liegen.
www.dotnetpro.de dotnetpro.dojos.2011
Listing 9
Datensätze synchronisieren.
public class Syncing {
public static SyncResult GetSyncResult(ToDo local, ToDo remote) {
if (remote == null) {
return SyncResult.InsertRemote;
}
if (local == null) {
return SyncResult.InsertLocal;
}
if (local.Id != remote.Id) {
throw new InvalidOperationException("Ids must be the same");
}
if (local.Version == remote.Version) {
if (local.Changes) {
return SyncResult.UpdateRemote;
}
return SyncResult.Nothing;
}
if (local.Version < remote.Version) {
if (local.Changes) {
return SyncResult.Conflict;
}
return SyncResult.UpdateLocal;
}
throw new InvalidOperationException("Version numbers are corrupted");
}
}
Listing 10
Ergebnisliste zusammenstellen.
private static IEnumerable<ToDo> GetResultList(IEnumerable<Tuple<SyncResult, Tuple<ToDo,
ToDo>>> tuples) {
foreach (var tuple in tuples) {
if (tuple.Item1 == SyncResult.InsertLocal || tuple.Item1 == SyncResult.UpdateLocal) {
Trace.TraceInformation(" Local
Id: {0}, Sync: {1}", tuple.Item2.Item2.Id,
tuple.Item1);
tuple.Item2.Item2.Changes = false;
yield return tuple.Item2.Item2;
}
else if (tuple.Item1 == SyncResult.InsertRemote || tuple.Item1 ==
SyncResult.UpdateRemote) {
Trace.TraceInformation(" Remote Id: {0}, Sync: {1}", tuple.Item2.Item1.Id,
tuple.Item1);
tuple.Item2.Item1.Version++;
tuple.Item2.Item1.Changes = false;
yield return tuple.Item2.Item1;
}
else if (tuple.Item1 == SyncResult.Nothing) {
Trace.TraceInformation(
" Nothing Id: {0}, Sync: {1}", tuple.Item2.Item1.Id, tuple.Item1);
yield return tuple.Item2.Item1;
}
}
}
Ein weiterer Aspekt ist mir bei der
Übung erneut aufgefallen: Durch die Modellierung und Umsetzung mit Flows lässt
sich die Anwendung einfach erweitern. Die
Wiederverwendung einzelner Bausteine
war problemlos möglich, und die Integra-
tion der zusätzlichen Funktionalität war
leicht.
[ml]
[1] http://aws.amazon.com/de/simpledb
[2] http://simol.codeplex.com
[3] http://ebclang.codeplex.com
93
Impressum
Imprint
Dojos für Entwickler
Stefan Lieser, Tilman Börner
published by: epubli GmbH, Berlin,
www.epubli.de
Copyright: © 2012 Stefan Lieser, Tilman Börner
GRUNDLAGEN
Coding Dojo : Mit Spaß lernen
Lass uns einen lernen gehen
Auch nach der Ausbildung gehört Lernen zum Berufsbild des Softwareentwicklers. Aber Lernen kann auch richtig
Spaß machen: In lockerer Runde eine Programmieraufgabe lösen macht Laune.
W
as fällt Ihnen bei folgenden zwei Begriffen ein: Übung und Meister? Genau, das ist der Spruch, den wir suchen. Auch bei der Programmierung gilt, dass stetes Trainieren wichtig ist. Ja, man kann sogar sagen,
dass Lernen wie in keiner anderem Branche überlebenswichtig ist. In kaum einem anderen Gebiet
dreht sich das Rad mit neuen Technologien so
schnell wie in der heiligen Softwareentwicklung.
„Ja, das stimmt, aber ich übe jeden Tag, wenn ich
für unsere Kunden Software schreibe“, mag Ihr
Kommentar lauten. Das ist aber nicht richtig. Wer
lernen will, muss spielen und ausprobieren.
Spielen heißt im Fall von Programmieren
schlicht, sich auch mal an etwas anderem versuchen, was nicht zur täglichen Arbeit gehört.
Benötigen Sie in Ihrer täglichen Arbeit je Asynchronizität? Oder wie viele Male haben Sie schon
die Rx [1] benutzt? Sind Sie fit mit Event-Based
Components, oder wissen Sie, wie Sie Code tatsächlich clean machen? Mit TDD alles klar?
Projekte, mit denen Sie im weitesten Sinne Ihr
Einkommen verdienen, sind meist viel zu groß,
um einen sinnvollen Anreiz für das Spielen zu geben. Eine Lernübung hingegen sollte vom Umfang überschaubar sein und nicht mehr als ein,
zwei oder drei Stunden in Anspruch nehmen.
Aufgaben stellen ist nicht einfach
Der gravierendste und am häufigsten von Softwareentwicklern begangene Fehler liegt in folgenden Sätzen: „Das sind nur ein paar Zeilen
Code“ oder „Das haben wir gleich“. Meist werden Aufwände komplett unterschätzt.
Mit dem Ausdenken von Übungsaufgaben verhält es sich da nicht anders. „Aufgaben zu finden
ist doch nicht schwer.“ Dieser Satz ist falsch,
denn es müssen so viele Randbedingungen eingehalten werden: Idee, Machbarkeit, Dauer und
so weiter. Finden Sie eine Aufgabe, die in etwa
zwei bis drei Stunden lösbar ist, haben Sie eine
sogenannte Kata erfunden.
Wollen Sie sich die Mühe aber nicht machen,
eigene Katas zu erfinden, können Sie auch welche aus dem Internet verwenden [2], [3]. Weiter
unten listen wir die bekanntesten Katas auf.
Kata und Dojo
Der Begriff Kata ist der fernöstlichen Kampfkunst
entlehnt und bedeutet so viel wie Übung. Wer
www.dotnetpro.de dotnetpro.dojos.2011
Kampfsportarten lernt, läuft Katas. Das sind
strikt vorgegebene Bewegungsfolgen, die Koordination, Bewegung und Exaktheit trainieren sollen. Insofern passt der Begriff eigentlich nicht
hundertprozentig, denn es geht bei einer Coding
Kata nicht darum, die gleiche Aufgabe immer
und immer wieder zu lösen, sondern um das
Eruieren von Neuem.
Auch der Begriff Dojo kommt aus Fernost. Er
bezeichnet den Platz, an dem Kampfsportarten
geübt werden. In der Softwareentwicklung ist mit
Coding Dojo nicht der Raum an sich gemeint,
sondern das Event des Zusammenkommens und
Lösens der Aufgabe.
Es geht los – fast
Am Anfang eines Coding Dojos bestimmt die
Gruppe, welche Aufgabe gelöst werden soll. Sowohl Moderator als auch Teilnehmer können
Vorschläge unterbreiten. Die Anwesenden einigen sich auf eine Kata, und los geht’s.
Na ja, noch nicht ganz. Was gern vergessen
wird, ist, zu prüfen, ob die Kata auch komplett
von allen verstanden wurde. Was sind die Anforderungen? Wie sieht beispielsweise ein Use Case
aus? Welche Nebenbedingungen sind zu erfüllen? Was ist eigentlich zu erzeugen? Ist das Ergebnis eine Klasse oder eine Methode?
Erst wenn die Aufgabenstellung klar ist, sollte
es losgehen. Und das heißt: programmieren nach
Test-Driven Development. Das wiederum bedeutet, dass es eine Schnittstelle gibt, deren Funktionalität über Unit-Tests getestet wird. Es muss somit keine Kompilierung laufen, nur ein Testframework und ein Testrunner sind nötig. Auch das
ist wieder so eine Randbedingung der Aufgabenstellung: Die Kata darf keine Abhängigkeiten haben. Sie muss losgelöst getestet werden können.
Auf einen Blick
Tilman Börner ist Diplomphysiker und Chefredakteur der
dotnetpro. Programmieren ist
für ihn ein kreativer Akt, für den
er leider viel zu wenig Zeit hat.
Inhalt
➡ In Coding Dojos löst man
gemeinsam eine Programmieraufgabe per TDD.
➡ Die populärsten Übungsaufgaben vorgestellt.
➡ Spaß und Spiel gehören
unbedingt mit zum Lernen.
dnpCode
A11DOJOKatas
Die Spielarten
Es gibt verschiedene Arten von Coding Dojos,
über deren Sinn oder Unsinn schon trefflich gestritten wurde. Oft läuft ein Dojo aber so ab: Die
Aufgabe wird an einem Rechner gelöst, dessen
Bild per Beamer an die Wand projiziert wird. Es
gibt einen Code Monkey, der im Prinzip das in
die Tasten klopft, was die Runde der Anwesenden wünscht.
In einer anderen Art des Coding Dojos kann jeder, der möchte, für eine gewisse Zeit am Dojo-
95
GRUNDLAGEN _Coding Dojo : Mit Spaß programmieren lernen
[Abb. 1] Coding
Dojo auf der
.NET DevCon 2011
in Nürnberg mit
Ilker Cetinkaya
(stehend).
Rechner sitzen. Damit ist er in der Lage, die
Aufgabe so zu lösen, wie er möchte. Meist
sorgt eine Zeitbegrenzung dafür, dass möglichst viele mal ihre Ideen zeigen können.
Der Nachteil: Der Nachfolgende kann den
Code des Vorgängers löschen und durch eigenen ersetzen.
In beiden Formen ist es von Vorteil,
wenn es einen Moderator gibt, der versucht, einen gewissen Konsens im einen
Fall und eine gewisse Stringenz im anderen
Fall durchzusetzen. Vor allem muss der
Moderator auf die Zeit achten, denn Diskussionen unter Programmierern ufern
bekanntlich schnell aus.
Die Teilnehmer denken sich nun einen
ersten Test aus, der die Schnittstelle gemäß
einer Anforderung überprüft. Eine erste Implementierung des System Under Test (SUT)
muss dann den ersten Test grün machen.
Weitere Anforderungen werden in Form
von Unit-Tests beschrieben und das SUT
so implementiert, dass die Tests alle grün
werden.
Ein möglicher Streitpunkt ist, wann eine
Refaktorisierung erfolgen soll und ob das
überhaupt zu den Aufgaben des Coding
Dojos gehört. Wenn Code nach TDD
wächst, empfiehlt es sich nach gewissen
Schritten, den Code so umzustrukturieren,
dass er wieder lesbarer wird. Aber ist das
tatsächlich eine Anforderung, die an die
Software gestellt wird, oder soll der Code
96
nur die funktionale Ebene erfüllen, und es
ist egal, wie er aussieht?
Coding Dojo = Spaß + Lernen
Dialog und Diskussion gehören zum Coding Dojo. Aber Vorsicht vor zu langen Diskussionen. Meistens entzünden sie sich an
kleinen Dingen wie Benennungen von Methoden. Hier muss der Moderator rechtzeitig eingreifen – obwohl auch die richtige
Benennung von Methoden und Variablen
durchaus geübt werden muss.
Schließlich ist die vorgegebene Zeit um,
und die Aufgabe ist nicht gelöst. War das
Coding Dojo deshalb ein Misserfolg?
Diese Frage kann nur jeder für sich beantworten.
Schließlich und endlich geht es ja darum, etwas mitzunehmen und etwas mit
Spaß zu lernen (siehe auch Abbildung 2,
Das Code Kata Manifesto). Und das kann
schlicht die Erkenntnis sein, dass ein
Coding Dojo manchmal einfach zu chaotisch verläuft.
Die Klassiker : Ausgewählte Katas
Kata BankOCR
In dieser Kata geht es um das Lesen von
Zahlen: Gesucht ist ein Algorithmus, der
aus einer Eingabe eine Zahl erzeugt. Die
Eingabe sind in dem Fall Strings, die Ausgabe soll ein Ganzzahlenformat sein. Wie
der Name schon sagt, soll die Zahl per Optical Character Recognition (OCR) erkannt
werden. Die Eingabe ist die Darstellung der
Zahl in Form einer sogenannten Siebensegmentanzeige. Die Ziffern von 0 bis 9
würden dann so aussehen:
_
_ _
_ _ _ _ _
| | | _| _||_||_ |_ ||_||_|
|_| ||_ _| | _||_| ||_| _|
Diese Zahl lässt sich durch drei Zeilen
Text darstellen. Darin markieren Unterstriche, vertikale Striche und Leerzeichen die
einzelnen Segmente einer Ziffer. Nach den
drei Zeilen kommt eine Leerzeile, die eine
Zahl von der nächsten trennt. Jede Ziffer
besteht damit aus drei Zeilen und ist drei
Zeichen breit. Die Ziffern sind innerhalb
des Rechtecks aus drei Zeilen und drei Zeichen rechtsbündig ausgerichtet. Das bedeutet, die 1, die ja nur ein Zeichen breit
ist, erhält links noch zwei Leerzeichen.
Jede Zahl ist 9 Ziffern breit.
Damit ist jede Zahl 27 Zeichen breit und
drei Zeilen hoch.
dotnetpro.dojos.2011 www.dotnetpro.de
GRUNDLAGEN
Aufgabe: Schreiben Sie einen Algorithmus, der aus einer Eingabe, die aus Strings
besteht, Zahlen erzeugt. Die Menge der zu
erkennenden Zahlen ist nicht besonders
groß. Gehen Sie von 500 Zahlen aus. Die
Zahlen sind alle fehlerfrei. Eine Fehlererkennung ist im ersten Schritt nicht nötig.
Diese Kata lässt sich noch erweitern, etwa
indem über eine Checksumme geprüft wird,
ob die Zahl in Ordnung ist. Meist reicht aber
für den ersten Teil die Zeit gerade so.
Kata FizzBuzz
Diese Kata geht auf ein Spiel zurück, bei
dem die Konzentration eine wichtige Rolle
spielt. Eine Gruppe Menschen steht zusammen und zählt der Reihe nach oben. 1,
2, 3 … und so weiter. So weit ist das noch
einfach. Jetzt kommt aber die Verschärfung: Ist eine Zahl durch drei teilbar, ruft
der Mensch statt des Zahlenwerts „Fizz“,
ist sie durch fünf teilbar, dann „Buzz“, und
ist sie durch drei und fünf teilbar, dann
muss derjenige „FizzBuzz“ rufen. Sie dürfen sich selbst ausmalen, was derjenige
machen muss, der nicht richtig Zahl, Fizz,
Buzz oder FizzBuzz ruft.
Aufgabe : Schreiben Sie einen Algorithmus, der jedes Mal, wenn er aufgerufen
wird, entweder eine Zahl, Fizz, Buzz oder
FizzBuzz zurückgibt. Startpunkt soll bei 1
liegen.
Eine Folge von Aufrufen gibt also das
Folgende zurück:
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz …
Kata Potter
In dieser Kata geht es um den Verkauf von
Büchern. Genauer gesagt: von Harry-Potter-Büchern. Eine Buchhandlung möchte
als Werbeaktion besondere Buchbundles
anbieten. Frei nach dem Motto: Kauf zwei,
bekomm das dritte geschenkt. Aber so einfach geht es hier nicht zu.
Ein Buch aus der Harry-Potter-Reihe soll
acht Euro kosten. So weit wäre die Kata
zwar sehr einfach, die Werbeaktion aber
ein Fiasko. Also kommt Rabatt ins Spiel.
Wer zwei verschiedene Bücher aus der Reihe erwirbt, erhält 5 Prozent Rabatt auf die
beiden Bücher. Wer drei verschiedene Bücher kauft, erhält 10 Prozent Rabatt. Bei
vier verschiedenen Büchern sind es schon
20 Prozent Rabatt. Kauft ein Kunde fünf
verschiedene Bücher aus der Reihe, bekommt er 25 Prozent Rabatt.
Knifflig wird es, wenn die Bücher nicht
unterschiedlich sind. Wer beispielsweise
drei Exemplare kauft, wobei es sich aber
www.dotnetpro.de dotnetpro.dojos.2011
[Abb. 2] Das Code Kata Manifesto fasst alle wichtigen Regeln für eine Kata zusammen.
nur um zwei verschiedene handelt (also
ein Buch kauft er zweimal), erhält er nur
auf die zwei verschiedenen 5 Prozent Rabatt. Das doppelte Buch kostet weiterhin 8
Euro.
Aufgabe : Schreiben Sie einen Algorithmus, der für beliebig viele Exemplare den
Preis berechnet, wobei der den maximalen
Rabatt geben soll. Bei dieser Aufgabe ist es
extrem wichtig, sich durch einige Fälle mit
der Problematik vertraut zu machen. Die
Optimierung bezüglich geringstem Preis
ist nicht so einfach, wie das auf den ersten
Blick scheint.
Kata Tennis
Tennisspieler sind anders. Sie spielen auf
Sand oder Rasen, zu zweit oder zu viert
und hauen sich mit Schlägern den Ball gegenseitig um die Ohren. Am seltsamsten ist
aber die Zählweise, die den Punktestand in
einem Tennismatch festhält.
0, 15, 30, 40, Spiel lautet die Zählweise. Erreichen die Gegner beide den Punktestand
40, entsteht ein sogenannter Einstand. Wer
nach dem Einstand einen Ballwechsel für
sich entscheiden kann, erhält den „Vorteil“.
Aber er hat das Spiel noch nicht gewonnen.
Erst mit einem erneuten gewonnenen Ballwechsel gewinnt er das Match. Verliert er
den Ballwechsel, herrscht wieder Einstand.
Wie gesagt: Das Tennisspiel ist seltsam.
Nichtsdestotrotz gibt das eine schöne Aufgabe für einen Coding Dojo.
Aufgabe : Schreiben Sie einen Algorithmus, der die Zählweise des Tennis nachahmt. Dabei bekommt Spieler A oder Spieler B den Gewinn des Ballwechsels zugesprochen. Der Algorithmus soll den aktuellen Spielstand zurückgeben.
Kata Römische Zahlen
Das darf nicht fehlen: Wenn es seltsame
Zahlensysteme gibt, dann gehört das römische mit dazu. Gesehen haben Sie solche
Zahlen sicher schon, vor allem als Jahreszahlangabe auf Gräbern oder Häusern.
MCMLI steht etwa für 1951. Die Zahlen sind
so aufgebaut: Es gibt Zeichen, mit denen
sich alle Zahlen darstellen lassen. Diese Zeichen sind: I, V, X, L, C, D, M gemäß 1, 5, 10,
50, 100, 500, 1000.
Eine Zahl setzt sich nun aus mehreren
solcher Zeichen zusammen:
I steht für 1
II steht für 2
III steht für 3
IV steht für 4
V steht für 5
VI steht für 6
VII steht für 7
VIII steht für 8
IX steht für 9
X für 10.
Aufgabe : Schreiben Sie einen Algorithmus, der eine dezimale Zahl als String einer römischen Zahl zurückgibt. Achten Sie
darauf, dass die römische Zahl auch valide
97
GRUNDLAGEN _Coding Dojo : Mit Spaß programmieren lernen
ist. IM beispielsweise für 999 ist falsch.
Richtig wäre CMXCIX.
Kata Taschenrechner Römische
Zahlen
Wer konvertieren kann, kann auch rechnen. Somit kann man die Kata Römische
Zahlen so erweitern, dass daraus ein Taschenrechner wird.
Aber Vorsicht : Ist das Konvertieren überhaupt nötig ? Oder lässt sich das ganze
auch ohne Konversion erledigen. Es gibt
nämlich die folgende Erleichterung : Der
Taschenrechner soll nur addieren. Subtraktion, Division und Multiplikation sind
nicht zu implementieren.
Aufgabe : Schreiben Sie einen Algorithmus, der zwei römische Zahlen addiert. Also:
VIII + LXX = LXXVIII oder
CXI + XII = CXXIII
Kata Spiel des Lebens
Das „Spiel des Lebens“ ist wohl ein Klassiker, den John Horton Conway schon 1970
erfunden hat. Auf Basis einer Verteilung
von Zellen auf einem zweidimensionalen
Raster wird die nächste Generation an Zellen berechnet. Welche Zelle in der nächsten Generation weiterlebt, stirbt oder von
den Toten aufgeweckt wird, richtet sich danach, wie viele Zellen sie als Nachbarn hat.
Nach folgenden Bedingungen berechnet
sich die nächste Generation:
1. Jede Zelle, die weniger als zwei Nachbarn
hat, stirbt wegen Vereinsamung.
2. Jede Zelle, die mehr als drei Nachbarn hat,
stirbt wegen Überbevölkerung.
3. Jede Zelle, die zwei oder drei Nachbarn
hat, lebt auch in der nächsten Generation
weiter.
4. Jede tote Zelle mit genau drei Nachbarn
wird reanimiert und lebt in der nächsten
Generaton wieder.
Besonders zu beachten ist die Bedingung 4 : Wir werden zu Frankensteins, die
Leben erschaffen können.
Hier ein Beispiel für drei aufeinanderfolgende Generationen. Drei Zellen in einer
Reihe werden zu einem Oszillator, der von
einer Generation zur nächsten die Ausrichtung von vertikal zu horizontal und wieder
zurück bildet.
Generation 1
........
....*...
....*...
....*...
98
[Abb. 3] Eine Kata von Ilker Cetincayas Website [4]. Ilker hat die Coding Dojos in Deutschland in die
.NET-Community gebracht.
Generation 2
........
........
...***..
........
Generation 3
........
....*...
....*...
....*...
Aufgabe : Schreiben Sie einen Algorithmus, der aus einem zweidimensionalen Array mit lebenden und toten Zellen auf Basis
der vier Regeln die nächste Generation berechnet. Der Algorithmus soll ein zweidimensionales Array zurückgeben, das dann
wieder zur Berechnung der nächsten Generation verwendet werden kann.
Hinweis : Teilen Sie die Randbereiche ab.
Diese bedürfen einer eigenen Behandlung.
Kata Anagram
Gegeben sei ein Wort, das der Schlüssel
zum Schloss eines sagenhaften Schatzes
ist. Nur leider sind die Buchstaben dieses
Wortes durcheinandergeraten. Sie müssen
so lange probieren, bis Sie die richtige
Kombination gefunden haben. Wäre das
Wort etwa ei, dann ist das schnell erledigt,
denn es gibt nur noch ie als Permutation.
Bei „ein“ sind es schon sechs mögliche :
ein
eni
ine
ien
nei
nie
Aufgabe : Schreiben Sie einen Algorithmus, der von einem eingegebenen Wort alle Buchstabenpermutationen erzeugt. Eingabe ist ein String, Ausgabe ist eine Liste
von Strings. Aber aufpassen: Die Zahl der
Permutationen wächst mit der Fakultät der
Zahl der Buchstaben. Also sind es bei einem Wort mit vier Buchstaben schon 24
mögliche Wörter.
[tib]
[1] http://msdn.microsoft.com/enus/data/gg577609
[2] http://codingkata.org/
[3] http://codingdojo.org/
[4] ilker.de/code-kata-pickakin
dotnetpro.dojos.2011 www.dotnetpro.de
#1
dotnetpro.de
facebook.de/dotnetpro
twitter.com/dotnetpro_mag
gplus.to/dotnetpro

Documents pareils