Battleship Drucken
Benutzerbewertung: / 106
SchwachPerfekt 
Sonntag, den 10. Mai 2009 um 00:00 Uhr

Einführung

In diesem umfangreichen Artikel werden Sie viele Aspekte der Softwareentwicklung kennenlernen. Das Tutorial richtet sich an fortgeschrittene Programmierer, die mit der Programmiersprache Java vertraut sind und sich nun mit dem komplexen Prozess der Anwendungsentwicklung vertraut machen wollen. Sie werden mit der GUI-Entwicklung, Multithreading, Netzwerkprogrammierung, UML-Diagrammen, Javadoc, Anwendungsprotokollen, Algorithmen und Entwurfsmustern konfrontiert werden, um anschließend mit diesem Know-How sukzessive ihr Ziel zu erreichen.

Um den komplexen Prozess der Softwareentwicklung kennenzulernen, ist es von Vorteil sich einem der Königsdisziplinen in diesem Bereich zu widmen, nämlich der Entwicklung von Spielen. Spiele vereinen zahlreiche wichtige Aspekte der Softwareentwicklung in sich. Neben der gewissenhaften Auswahl von Entwurfsmustern, sind unter anderem Algorithmen und Datenstrukturen von entscheidender Bedeutung. Schlußendlich weisen viele Spiele heutzutage auch die Möglichkeit auf, über Netzwerk miteinander zu kommunizieren und dafür ist in der Regel ein Anwendungsprotokoll erforderlich.

Wir haben uns auf CodePlanet entschieden ein triviales und beliebtes Spiel zu entwickeln, allgemein bekannt unter dem Namen Schiffe versenken oder auch Seeschlacht genannt. Lassen Sie sich allerdings nicht davon täuschen. Unser sogenanntes triviales Spiel wird am Ende aus annähernd 100 Java-Klassen und über 20000 Zeilen Code (SLOC) bestehen. Neben einer komplexen Netzwerkbibliothek, die Sie später problemlos in Ihren anderen Anwendungen verwenden können, wird das Spiel auch eine eigene KI besitzen und einen Chat bereitstellen.

Für die Anwendung Battleship wurde eine detaillierte Dokumentation (Javadoc) generiert. Sie finden diese Dokumentation mit der Auflistung aller Klassen und Methoden auf der Seite http://codeplanet.eu/files/battleship/javadoc/. Im nachfolgenden Abschnitt sind einige Screenshots der fertigen Applikation zu sehen.

Highslide JS Highslide JS Highslide JS
Highslide JS Highslide JS Highslide JS

Willkommen auf der Softwarebaustelle

Der Begriff »Baustelle« wird Ihnen im Zusammenhang mit der Software wahrscheinlich etwas ungewöhnlich vorkommen. »Baustelle« - das sind doch Bauarbeiter, Zement, Wasserwagen? »Bauen« umfasst Begriffe wie Planung, Entwicklung, Überprüfung der Arbeit, doch größtenteils ist mit »Bauen« die eigentliche manuelle Arbeit gemeint: Ärmel hochgekrempelt, und los! Und genau darum geht es in diesem Tutorial.

Bevor wir allerdings mit dem Bau beginnen, werden wir uns die einzelnen Teilschritte der Softwareentwicklung näher ansehen. Worauf kommt es in der Planung an und was ist vor der eigentlichen Programmierarbeit tatsächlich zu tun? Viele elementare Fragen müssen vor dem Beginn beantwortet werden. Auf diese Weise vermeiden Sie schwerwiegende Fehler und optimieren die Zeit, in der Sie den Code schreiben.

Was bedeutet »Software bauen«?

Die Entwicklung von Computersoftware kann ein komplizierter Prozess sein. In den letzten 25 Jahren haben Forscher mehrere unterschiedliche Tätigkeiten identifiziert, die in den Bau von Software einfließen. Hierzu zählen:

  • Problemdefinition
  • Analyse der Anforderungen
  • Planung der Implementierung
  • Softwarearchitektur (Entwurf auf höherer Ebene)
  • Detailentwurf
  • Bau und Debugging
  • Testen von Einheiten
  • Testen des Gesamtsystems
  • Pflege

Wenn Sie bislang nur an kleineren, informellen Projekten gearbeitet haben, wird Ihnen diese Liste sicher wie Bürokratie vorkommen. Wenn Sie an Projekten mitgewirkt haben, die zu formell organisiert waren, dann wissen Sie, dass Sie den Amtsschimmel hier zu Recht wiehern hören. Es ist gar nicht so einfach, einen vernünftigen Mittelweg zwischen zu viel und zu wenig Formalismus zu finden - doch dazu später mehr.

Wenn Sie sich das Programmieren selbst beigebracht haben oder hauptsächlich an informellen Projekten gearbeitet haben, werden Sie sich bisher vermutlich kaum um die Unterschiede zwischen den einzelnen Tätigkeiten gekümmert haben, aus denen sich ein Softwareprojekt zusammensetzt. Wahrscheinlich haben Sie all diese Tätigkeiten unter dem Oberbegriff »Programmierung« zusammengefasst. Und Sie sind dann sicher auch der Meinung, dass die Entwicklung von Software in erster Linie aus dem Schreiben von Quelltext besteht. Wissenschaftler verwenden für diese Phase den Begriff »Bauen« (engl. construction).

»Bauen« verkörpert ziemlich genau, worum es beim Schreiben von Quelltext geht, greift jedoch ein wenig zu kurz. Darum soll diese Phase in den Kontext der gesamten Programmentwicklung gesetzt werden. Abbildung 1 verdeutlicht die Bedeutung des »Bauens« und seinen Platz innerhalb der Hierarchie der Softwareentwicklung.

softwarebau
Abbildung 1: Die Tätigkeiten, die mit dem Bau von Software zusammenhängen

Wie die Abbildung zeigt, handelt es sich beim Bauen hauptsächlich um das Schreiben von Quelltext und das Debuggen (die Fehlerbeseitigung), aber auch Detailentwurf, Testen von Einheiten, Integrationstests, Test des Gesamtsystems und andere Tätigkeiten gehören dazu.

Der Bau von Software läuft auch häufig unter den Begriffen »Code schreiben«, »Quelltext schreiben« oder schlicht »Programmieren«. Diese Tätigkeit ist aber alles andere als mechanisch-stumpfsinnig; sie erfordert Augenmaß und Kreativität.

Warum ist der Bauabschnitt wichtig?

Als Leser dieses Tutorials stimmen Sie sicher der Aussage zu, dass die Erhöhung der Softwarequalität und der Arbeitsproduktivität des Entwicklers wichtig sind. Viele interessante Projekte unserer Zeit setzen in hohem Maß auf Software: Internet, Special Effects in der Filmindustrie, Medizintechnik, Luft- und Raumfahrt, blitzschnelle Finanzanalyse, wissenschaftliche Forschung, um nur einige Beispiele zu nennen. Diese Projekte können genauso wie bodenständigere Projekte von verbesserten Verfahren profitieren, weil viele Grundlagen identisch sind.

Der Bau von Software ist ein großer und wichtiger Abschnitt der Softwareentwicklung. Abhängig vom Umfang des Projekts werden zwischen 30 und 80 Prozent der gesamten Arbeitszeit für den Bau der Software benötigt. Jede Tätigkeit, die anteilig so viel Zeit beansprucht, beeinflusst den Erfolg des Projekts entscheidend.

Der Bau von Software ist der Dreh- und Angelpunkt der Softwareentwicklung. Damit die Bauausführung gut läuft, kommen vorher Anforderungsanalyse und Entwurf. Das fertige System wird anschließend unabhängig geprüft, um sicherzustellen, dass der Bau erfolgreich war. Die Bautätigkeit ist der Kern des Entwicklungsprozesses.

Gerade im Bauabschnitt gibt es enorme Möglichkeiten, die Produktivität eines Programmierers zu erhöhen. In einer klassischen Studie (1968) zeigten Sackman, Erikson und Grant, dass die Produktivität einzelner Programmierer in der Bauphase stark voneinander abweicht - um das Zehn- bis Zwanzigfache! Diese Werte wurden seither durch zahlreiche andere Untersuchungen bestätigt (Curtis 1981, Mills 1983, Curtis u.a. 1986, Valett und McGarry 1989, DeMarco und Lister 1999, Boehm u.a. 200). Dises Tutorial zeigt, wie Sie einige Techniken der Spitzenkönner übernehmen können.

Das Ergebnis der Bauphase, der Quelltext, ist oft die einzige exakte Dokumentation der Software. Bei vielen Projekten ist der Quelltext die einzige vernünftige Dokumentation, die den Programmierern zur Verfügung steht. Die Analyse der Anforderungen und die Entwurfsunterlagen können schnell veralten, doch der Quelltext ist stets auf dem neuesten Stand. Daher versteht es sich von selbst, dass der Quelltext von der bestmöglichen Qualität sein muss. Die konsequente Anwendung von qualitätssteigernden Techniken macht den Unterschied zwischen einem mysteriösen Wunderwerk der Technik und einem detaillierten, korrekten und daher informativen Programm aus. Und diese Techniken lassen sich mit dem meisten Erfolg eben während der Bauphase anwenden.

Das Bauen ist die einzige Arbeit, die garantiert erledigt wird. Das ideale Softwareprojekt beginnt mit einer genauen Analyse der Anforderungen und dem Entwurf der Architektur, bevor die Bauphase einsetzt. Anschließend wird es sorgfältig und statistisch zuverlässig geprüft. Aber: Das wirkliche Leben sieht oft ein wenig anders aus. Oft wird der ganze Vorbereitungsquatsch beiseite geschoben und ruck, zuck mit dem Bau der Software begonnen. Auch gründliche Tests sind oft ein Fremdwort - sei es, weil nicht mehr genug Zeit vorhanden ist, sei es, weil einfach zu viele Fehler im Programm stecken. Doch egal wie schlecht geplant oder wie hastig Sie ihr Projekt durchziehen: Um den Bau der Software führt kein Weg herum. Daher wirkt sich höhere Qualität beim Bau ausnahmslos auf alle Projekte aus.

Zusammenfassung

  • Der Softwarebau ist die zentrale Tätigkeit bei der Softwareentwicklung. Der Bau ist die einzige Tätigkeit, die bei jedem Projekt garantiert durchgeführt wird.
  • Die wichtigsten Arbeiten beim Bau sind. Detailentwurf, Schreiben des Quelltext, Debuggen und Testen (Testen und Integrationstests)
  • Andere Begriffe für »Bau« sind »Schreiben von Code« und »Programmierung«.
  • Die Bauqualität bestimmt entscheidend die Qualität der Software.
  • Letzten Endes stellt sich in der Bauphase heraus, ob Sie ein guter Programmierer sind.

Metaphern als Hilfsmittel für die Softwareentwicklung

Die Sprache der Informatik ist ausgesprochen farbig und bildhaft. Wo sonst treffen Sie in einem sterilen, klimatisierten Raum auf Viren, trojanische Pferde, Würmer, Briefbomben, Abstürze oder fatale Fehler. Die sogenannten Bugs verdanken ihre Bezeichnung dem englischen Begriff Bug, was soviel wie Wanze, Kakerlake oder Floh bedeutet. Demenstrechend nennt man Tools zum Aufspüren und Beseitigen von Bugs auch Debugger.

Diese vielen sehr plastischen Metaphern beschreiben Softwarephänomene und sollen die Hintergründe der Softwareentwicklung erhellen. Sie können Ihr Verständnis für den Prozess der Softwareentwicklung verbessern. So ist auch der Bau einer Software an die Praxis angelehnt, viele denken hierbei an den Hausbau und damit liegt man in der Tat goldrichtig. Auch bei einem Hausbau gilt es Planungsschritte zu beachten und sich gut vorzubereiten, damit das Haus am Ende auf einem soliden Fundament steht und nicht in sich zusammenfällt.

Vorbereitung beim Softwarebau

Bevor ein Maurer mit seiner Arbeit beginnt, studiert er die Pläne. Er überprüft, ob alle Genehmigungen erteilt worden sind, und sieht sich das Fundament an. Er bereitet sich auf einen Wolkenkratzer anders vor als auf ein Gartenhäuschen, und auf eine Hundehütte wieder ganz anders. Die Vorbereitung findet also vor dem beginn der Arbeiten statt und bezieht sich immer auf das konkrete, anstehende Projekt.

In diesem Abschnitt erfahren Sie, wie Sie sich auf den Bau von Software vorbereiten sollten. Genau wie beim Bau eines Hauses hängt der Erfolg des Unternehmens in großem Maße von der Vorbereitung ab. Fehler in der Planung oder am Fundament lassen sich später kaum noch ausbügeln.

Das Motto des Schreiners lautet: »Zweimal messen, einmal sägen.« Dieses Motto gilt auch für die Bauphase der Softwareentwicklung, die bis zu 65 Prozent der gesamten Projektkosten verschlingen kann. Die schlimmsten Softwareprojekte müssen zwei- oder dreimal gebaut werden. Das teuerste Element des Projekts zweimal durchführen zu müssen, bedeutet bei Software genauso eine Katastrophe wie bei jeder anderen Tätigkeit.

Generell unterscheidet man zwischen zwei verschiedenen Ansätzen in der Vorbereitung, dem sequentiellen und iterativen Ansatz. Der iterative Ansatz eignet sich besonders für Projekte, in denen die Anforderungen nicht von Beginn an feststehen, er ist flexibler aber auch nicht so geradlinig, wie der sequentielle Ansatz. Werfen wir nun zunächst einen Blick auf die Pyramide der Softwareentwicklung.

Pyramide

Die Problemdefinition

Der Bau einer guten Software beginnt mit der Problemdefinition, nämlich was soll die Software überhaupt leisten. Die Problemdefinition steckt das Problem ab - ohne jegliche Verweise auf Lösungsmöglichkeiten. Es handelt sich hierbei um einfache Feststellungen mit einem Umfang von vielleicht ein oder zwei Seiten, die das zu lösende Problem wirklich beim Namen nennen. Das Problem sollte in normaler Sprache formuliert sein.

Die Anforderungen

Anforderungen beschreiben detailliert, was ein Programm tun soll; sie sind der erste Lösungsschritt. Diese Phase der Softwareentwicklung läuft unter den Bezeichnungen »Forderungskatalog«, »Pflichtenheft«, »Analyse«, oder »Spezifikation«. Dem geht eine grobe Spezifikation voraus, diese wird auch Lastenheft genannt. Hier werden unter anderem die Ziele grob festgelegt.

Die Architektur

Bei der Softwarearchitektur handelt es sich um die höchste Ebene der Softwareentwicklung, um das gerüst, das die detaillierten Teile des Entwurfs trägt. Vielleicht ist Ihnen dieser Begriff auch schon einmal als »Entwurf«, »Top-Level-Design« oder »Hogh-Level-Design« begegnet. Üblicherweise wird die Architektur in einem einzigen Dokument festgelegt, das den Namen »Architekturspezifikation« oder »Top-Level-Design« trägt.

Die Qualität der Architektur bestimmt die Integrität der Systemkonzeption, die wiederrum für die Gesamtqualität des Systems ausschlaggebend ist. Mit einer schlechten Architektur ist es kaum möglich, ein gutes System zu bauen. Die Architektur gibt den Programmieren die Richtung vor - auf einer Stufe, die den Fähigkeiten der Programmierer und dem Projekt angemessen sind. Sie teilt die Arbeit auf, so dass mehrere Entwickler oder Teams parallel arbeiten können. Eine gute Architektur erleichtert den Bau. Schlechte Architektur macht den Bau praktisch unmöglich.

Zu jeder Systemarchitektur gehören einige elementare Komponenten.

  • Programmorganisation
  • Wichtige Klassen
  • Datenentwurf
  • Geschäftsregeln
  • Entwurf der Benutzeroberfläche (GUI)
  • Ressourcenverwaltung
  • Sicherheit
  • Leistung
  • Skalierbarkeit
  • Interoperabilität
  • Internationalisierung/Lokalisierung
  • Ein-/Ausgabe
  • Fehlerbehandlung
  • Qualität des Codes
  • Wartung

Schlüsselentscheidungen für den Bau

Nachdem Sie sicher sind, dass alle erforderlichen Vorarbeiten für den Bau abgeschlossen sind, wendet sich die Vorbereitung eher bauspezifischen Entscheidungen zu. Übertragen auf den Hausbau geht es um die geeignete Ausrüstung für Ihren Werkzeuggürtel und um die Beladung des Lieferwagens.

Auswahl der Programmiersprache

Zu den Werkzeugen eines Programmieres gehört natürlich die Programmiersprache selbst. Die Programmiersprache, in der das System implementiert wird, ist von großer Bedeutung. Sie werden von Anfang bis Ende des Baus der Software mit ihr zu tun haben. Entscheidend für die Auswahl der richtigen Programmiersprache sind die umzusetzenden Ziele, die zur Verfügung stehenden Programmierer und die Zeit. Programmierer sind produktiver, wenn sie eine vertraute Sprache verwenden. Hochsprachenprogrammierer arbeiten produktiver und erzielen in der Regel eine höhere Qualität als ihre Kollegen, die mit maschinennahen Sprachen arbeiten (müssen). Sprachen, wie C++, Java, C# oder Delphi erhöhen gegenüber Sprachen, wie Assembler oder C die Produktivität, Zuverlässigkeit, Unkompliziertheit und Verständlichkeit um den Faktor 5 bis 15. Hochsprachen sind auch ausdrucksstärker als maschinennahe Sprachen, d.h. Sie erzielen mit weniger Zeilen Code denselben Effekt.

Heutige Anwendungen werden nur noch selten in maschinennahen Sprachen geschrieben. Assembler und reines C kommen zumeist nur noch in performancekritischen Anwendungen zum Einsatz bzw. in den untersten Schichten von Systemen. Betriebbssystemkerne, wie der Linux Kernel, und rechenintensive Algorithmen werden maschinnennah implementiert, unter anderem um das Maximum an Leistung aus der Anwendung zu holen. Dementsprechend fällt die Wartung dieser Programme sehr aufwendig aus. Allein der Linux Kernel umfasst über 10 Millionen Zeilen Code, der Großteil in C und Assembler implementiert.

Fragen Sie sich, ob die verwendeten Programmiertechniken eine Antwort auf die eingesetzte Programmiersprache sind oder ob die Sprache die Techniken diktiert. Programmieren sie in eine Sprache, nicht in einer Sprache.

Konventionen für die Programmierung

Software hoher Qualität zeichnet sich durch einen Zusammenhang zwischen dem durchgängigen Konzept der Architektur und ihrer Implementierung auf Maschinenebene, sprich ihrer Umsetzung in Quelltext aus. Die Implementierung muss der Architektur entsprechen und einen inneren Zusammenhang aufweisen. Hierauf zielen all die Richtlinien für Variablennamen, Klassennamen, Namen von Routinen, Format- und Kommentarkonventionen ab.

In einem komplexen Programm sorgen also die architektonischen Richtlinien für die strukturelle Ausgewogenheit und Implementierungsrichtlinien für die Harmonie des Quelltextes, indem jede Klasse im Zusammenhang des gesamten Systems gesehen wird. Jedes große Programm benötigt eine ordnende Hand, eine Strukturierung, die die Details der Programmiersprache systematisiert. Die Schönheit eines großen Systems offenbart sich unter anderem in seinen Details, die den leuchtenden, übergeordneten Gedanken wiederspiegeln. Ohne diese disziplinierende, ordnende Hand haben Sie es sehr schnell mit wild wuchernden, kaum koordinierten Klassen und dissonanten Stilbrüchen zu tun. Solche Stilbrüche erschweren das Verständnis, weil Sie Unterschiede im Programmierstil verarbeiten müssen, die eigentlich völlig willkürlich sind. Man sollte versuchen die Komplexität an allen Stellen zu zähmen.

Was würden Sie zu einem Gemälde sagen, das zwar einem großartigen Gedanken entspringt, jedoch von Künstlern mit verschiedenen Vorlieben ausgeführt wurde, so dass es teils impressionistisch aussieht, teils kubistisch und teils klassisch? Das Bild vermittelt kein einheitliches Konzept, keine konzeptionelle Integrität.

Halten Sie die Programmierkonventionen fest, bevor Sie mit dem Bau der Software beginnen. Diese Konventionen sind naturgemäß so detailreich, dass sie sich auf vorhandenen Quelltext kaum noch anwenden lassen. Hinterher ist es meist zu spät.

Der Entwurf beim Bau

Bei kleinen informellen Projekten entwickelt der Programmierer den Entwurf, während er die Tatstatur bearbeitet. Der »Entwurf« könnte dann so aussehen, dass eine Klassenschnittstelle zunächst in Pseudocode geschrieben wird, bevor sie vollständig programmiert wird. Der Programmierer könnte ein paar Diagramme von den Beziehungen zwischen den Klassen zeichnen, bevor er ans Schreiben geht.

Entwurf ist ein sehr weites Feld. Die Qualität eines Klassen- oder Routinenentwurfs wird zum großen Teil durch die Systemarchitektur bestimmt. Vergewissern Sie sich daher, ob die aufgestellten Anforderungen an die Architektur bei Ihrem Projekt erfüllt sind.

Ein Schlüsselkonzept für den Entwurf ist das Zähmen der Komplexität. Wenn Analysen zu gescheiterten Softwareprojekten die Ursachen aufzählen, werden nur selten technische Probleme als Hauptgrund für das Fehlschlagen genannt. Projekte scheitern meist wegen schlechter Anforderungskataloge, schlechter Planung oder schlechter Verwaltung. Sind dagegen primär technische Probleme für das Scheitern eines Projekts verantwortlich, ist die Ursache oft eine außer Kontrolle geratene Komplexität. Es wurde der Software erlaubt, so komplex zu werden, dass niemand mehr weiß, was sie eigentlich tut und wie sie es tut. Wenn ein Projekt einen Punkt erreicht hat, an dem niemand mehr sagen kann, welche Auswirkungen Änderungen an einem Codeteil auf andere Bereiche haben werden, kommt die Entwicklung mit quietschenden Bremsen zum Stillstand.

Komplexität ist keineswegs eine neue Erscheinung bei der Softwareentwicklung. Der Computerpionier und Turing Award Gewinner Edsger Wybe Dijkstra wies darauf hin, dass die Arbeit mit Computern die einzige Tätigkeit ist, bei der ein einzelner Geist die gesamte Strecke von einem Bit bis zu einigen hundert Megabytes überschauen muss, eine Distanz von 1 bis 109. Dieses gigantische Verhältnis ist Ehrfurcht gebietend. Dijkstra drückte es so aus: »Im Vergleich zu dieser Zahl semantischer Ebenen ist die übliche mathematische Theorie fast schon oberfächlich. Weil beim Umgang mit dem automatischen Computer tiefe konzeptionelle Hierarchien erforderlich sind, konfrontiert uns der Computer mit einer völlig neuen intellektuellen Herausforderung, für die es in unserer Geschichte kein Vorbild gibt.« Und Software wurde seit 1989 noch komplexer, Dijkstras Verhältnis von 1 bis 109 wäre heute eher bei 1 bis 1015.

Dijkstra wies darauf hin, dass niemand ein Gehirn besitzt, das tatsächlich ein modernes Computerprogramm erfassen könnte und dass wir als Softwareentwickler daher überhaupt nicht versuchen sollten uns komplette Programme vollständig zu verinnerlichen. Wir sollte stattdessen unsere Programme so organisieren, dass wir uns problemlos immer auf ein bestimmtes Element konzentrieren können.

Erstrebenswerte Merkmale eines guten Entwurfs

Ein guter Entwurf zeichnet sich durch einige charakteristische Merkmale aus. Es wäre nur zu schön, wenn Sie alle Ziele erreichen könnten - nur widersprechen einige Ziele einander. Doch hierin liegt ja gerade die Herausforderung, die dem Entwurfsprozess innewohnt: Entwurf bedeutet nichts anderes, als gute Kompromisse zu schließen.

Nachfolgend werden die wesentlichen Merkmale des Entwurfsprozesses aufgelistet und kurz erklärt.

Ein bereits genanntes Merkmal ist die Minimierung der Komplexität. Vermeiden sie allzu "pfiffige" Entwürfe, die nur Sie überhaupt verstehen. Streben Sie einen eleganten und »simplen« Entwurf an. Falls ihr Entwurf nicht zulässt, beim Bearbeiten eines bestimmten Teils problemlos die meisten anderen Teile des Programms zu ignorieren, taugt der Entwurf nichts.

Neben der Komplexität spielt die Pflegeleichtigkeit eine wichtige Rolle. Bauen Sie die Verbindungen und Abhängigkeiten zwischen unterschiedlichen Programmteilen so weit wie möglich ab. Halten Sie sich bei Klassenschnittstellen, Kapselung und beim Verbergen von Informationen an die Prinzipien guter Abstraktion.

Die Erweiterbarkeit ist ebenso essentiell. Ihr System soll sich verbessern lassen, ohne dass seine grundlegende Struktur in Mitleidenschaft gezogen wird und ohne das große Teile vollständig abgeändert werden müssen.

Auch die Wiederverwendbarkeit ist ein Merkmal eines guten Entwurfs. Wenn Teile ihres Systems problemlos woanders integriert werden können, ist ihr Entwurf gut.

Ein möglichst geringer Fan-in. Der Fan-in bezeichnet die Zahl der Klassen, die eine einzelne Klasse aufruft. Ein hoher Fan-in weist darauf hin, dass eine Klasse eine große Anzahl weiterer Klassen verwendet und daher unter Umständen unnötig komplex ist. Das trifft selbstverständlich nicht auf Schnittstellen zu.

Die Portierbarkeit stellt sicher, dass ihr System leicht auf eine andere Umgebung übertragen werden kann.

Für den Entwurf ist auch die Schlankheit vorteilhaft. Überflüssige Teile gehören nicht in das System. Voltaire sagte einst, dass ein Buch nicht dann fertig ist, wenn nichts mehr hinzugefügt werden kann, sondern dann, wenn sich nichts mehr wegstreichen lässt.

Zuletzt zeichnet sich ein guter Entwurf durch den Gebrauch von Schichten aus. Versuchen sie, die Untergliederungsebenen in Schichten zu halten, so dass sich auf jeder Schicht eine zusammenhängende Sicht auf das System ergibt. Sie müssen das System durchgehend auf einer Ebene betrachten können, ohne andere Ebenen einbeziehen zu müssen.

Auch zu diesem abschließenden Punkt »Entwurf beim Bau« finden Sie eine Checkliste im PDF-Format.


Beginn des Projekts

Im letzten Kapitel haben wir einige wichtige Informationen zusammengefasst, die einem dabei helfen eine Software zu planen, zu entwerfen und zu bauen. Unser Projekt soll das Spiel »Schiffe versenken« werden. Die Zielbestimmung im Pflichtenheft beschreibt nach einer exzessiven Brainstorming-Runde eine Applikation mit den folgenden Anforderungen.

Anforderungen

Die genannten Anforderungen für das Spiel »Schiffe versenken« sind grob, lassen aber eine erste Entwicklungsrichtung erkennen. Wir beginnen damit eine triviale Struktur auszuarbeiten. Die Checkliste für die zentralen Bautechniken eignet sich hierfür besonders gut.

Wir legen zunächst einmal fest in welchem Ausmaß der Entwurf im Vorfeld erstellt und in welchem Maß er beim Schreiben des Codes - an der Tastatur - entwickelt wird. Da es sich um ein relativ kleines Open-Source Projekt handelt, werden wir die iterative Vorgehensweise wählen und uns nicht allzu lange mit dem konzeptionellen Entwurf aufhalten. Neben dem Entwurf des Codes mithilfe von Klassendiagrammen in der Unified Modeling Language, kurz UML, ist die Festlegung der Programmierkonventionen von großer Bedeutung. Zu den Schlüsselentscheidungen für den Bau zählt die Wahl der Programmiersprache. Eines der Anforderungen war die Tatsache, dass unser Spiel auf verschiedenen Plattformen lauffähig sein soll. Aus diesem Grunde werden wir auf die Programmiersprache Java zurückgreifen.

Java ist eine objektorientierte Programmiersprache, entwickelt von Sun Microsystems in den Neunzigern. Java-Programme werden in Bytecode übersetzt und dann in einer speziellen Umgebung ausgeführt, die als Java-Laufzeitumgebung oder Java-Plattform bezeichnet wird. Diese Umgebung steht für viele Plattformen zur Verfügung, so dass Java-Programme problemlos auf vielen Plattformen ausgeführt werden können. Java bietet viele Vorteile bei der Anwendungsentwicklung, sie hat eine automatische Speicher- und Heap-Verwaltung, ist robust, unterstützt Multi-Threading, ist einfach - weil strukturiert und stellt standardmäßig eine große Klassenbibliothek, samt einheitlichen Grafikfunktionen für verschiedene Windows-Systeme, zur Verfügung.

Unter dem Namen »Code Conventions for the JavaTM Programming Language« wurden von Sun Microsystems Richtlinien für die Programmierung mit Java veröffentlicht. Wir werden uns in diesem Projekt an diese Richtlinien halten und eins zu eins umsetzen.

Die Planung der Architektur

Die Planung der Architektur muss wohl durchdacht erfolgen. Der Grundaufbau, also das Fundament der Anwendung, kann später kaum mehr verändert werden, so dass schlecht entworfene Systeme anschließend nicht mehr korrigiert werden können.

Architekturmuster

Im Bereich der Softwareentwicklung sind Architekturmuster in den Arten von Mustern auf oberster Ebene einzuordnen. Im Gegensatz zu Idiomen oder Entwurfsmustern bestimmen sie nicht ein konkretes (meist kleines oder lokales) Teilproblem, sondern den Grundaufbau, das Fundament der Anwendung.

Umfangreiche Software von Grund auf neu zu entwickeln findet heutzutage nur noch selten statt. Dies wäre im Zusammenhang mit der heutigen Schnelllebigkeit und den ständig wachsenden Anforderungen nicht mehr wirtschaftlich. Daher werden Muster und ihre Beschreibungen eingesetzt, um ein erprobtes Lösungsschema als Vorgabe zu nutzen und entlang dieses Rahmens entwickeln zu können. Muster bestehen aus Beschreibungen von Problem-Lösungspaaren, welche über die Zeit von Experten zusammengetragen worden sind und ermöglichen so auch Neueinsteigern ohne langjährige Erfahrungen im Softwareentwicklungsbereich, häufig auftretende Entwurfsprobleme schneller zu lösen. Um komplexe Softwaresysteme entwickeln zu können sind Erfahrungswerte notwendig. Diese lassen sich gerade von Neulingen nicht im Hand umdrehen erlernen, sondern nehmen viel Zeit in Anspruch. Die allgemein zugänglichen Muster stellen einen mentalen Baustein dar, der diese Erfahrungslücke schließen kann.

Softwarearchitektur entscheidet mit über die Qualität des Systems. Eine gute Architektur ist gekennzeichnet durch Flexibilität. Gerade in der heutigen Zeit spielt Anpassbarkeit an ständige Änderungen und neue Ansprüche eine große Rolle. So kann es in einem bestimmten Kontext beispielsweise sinnvoll sein, ein großes System in mehrere, voneinander unabhängige Komponenten zu zerlegen um die Wartbarkeit der einzelnen Komponenten möglichst zur Laufzeit gewährleisten zu können, ohne alle Schnittstellen der zu wartenden Komponente beachten zu müssen. Die Wartbar- bzw. Anpassbarkeit des Systems würde in diesem Fall die „Kraft“ oder auch gewünschte Eigenschaft des Systems darstellen. Häufig stehen Muster nicht isoliert voneinander dar, sondern treten gemeinsam auf, da beispielsweise die Anwendung eines bestimmten Musters zu neuen Problemen führen kann, denen mit der Beschreibung anderer Muster begegnet wird. Desweiteren kann der Grund für die Anwendung von mehreren Mustern für einen Kontext darin liegen, dass ein Muster nicht alle „Kräfte“/ Anforderungen zufrieden stellend ausgleichen kann, sondern nur einige. Ein anderes Muster kann dann eingesetzt werden, um die unberücksichtigten Kräfte zu behandeln.

Im Zusammenhang mit der Softwareentwicklung von komplexen Systemen werden von den Entwicklern häufig Architekturmuster zur Komplexitätsbewältigung eingesetzt. Sie zeigen ein erprobtes Lösungsschema auf und beschreiben die beteiligten Komponenten mit ihren Beziehungen und Zuständigkeiten.

Während „Idiome“ Muster auf Programmiersprachenebene liefern, befassen sich „Entwurfsmuster“ mit der Verfeinerung von Subsystemen und Architekturmuster schließlich mit der Strukturierung von diesen. Die Muster befinden sich somit auf drei verschiedenen Abstraktionsebenen, wobei die Architekturmuster auf der höchsten Abstraktionsebene liegen. Sie liefern die grundsätzliche Strukturierung von Softwaresystemen und definieren des weiteren Subsysteme, deren jeweilige Zuständigkeiten, sowie ihre Interaktionen mit Hilfe von Regeln.

Model View Controller

Ein wichtiger Aspekt der modernen Softwareentwicklung ist das sogenannte Model-View-Controller Schema, ein bedeutendes Architekturmuster welches die wesentlichen Aufgabengebiete in seperate und miteinander in Wechselwirkung stehende Komponenten unterteilt. Nämlich das Model selbst, die Ansicht und den Controller. Während sich der Controller um die Interaktion mit dem Benutzer kümmert, liefert das Model z.B. Daten und teilt der Ansicht eventuelle Änderungen im Abauf mit. Die Ansicht kümmert sich um die Darstellung der Daten.

Wir werden in diesem Projekt auf das bewährte Architekturmuster MVC zurückgreifen. Vielmehr soll MVC den Eckpfeiler und das Fundament der gesamten Applikation bilden. Unser Spiel wird darauf aufbauen und alle Facetten dieses Muster ausreizen.

Falls Sie sich bisher mit MVC noch nicht beschäftigt haben oder Ihnen gewisse Teilaspekte ein Mysterium geblieben sind, werden wir diese in den nachfolgenden Kapiteln auflösen.

Bei MVC handelt es sich prinzipiell um ein leistungsfähiges zusammengesetztes Architekturmuster. Es erspart Ihnen viel Code-Schreiberei und trennt die teile Ihrer Applikation in logische Einheiten auf. Kurzum, sie reduziert die Komplexität, ein wichtiges Merkmal eines guten Entwurfs.
Um MVC zu verstehen, muss man die Entwurfsmuster verstehen, die das MVC ausmachen. Dazu zählt insbesondere das Observer Entwurfsmuster, das Strategy Entwurfsmuster und das Composite Entwurfsmuster.

MVC-Musterbrille

Das Model enthält die darzustellenden Daten und gegebenenfalls auch die Geschäftslogik. Es ist von Präsentation und Steuerung unabhängig. Die Bekanntgabe von Änderungen an relevanten Daten im Model geschieht nach dem Entwurfsmuster „Beobachter“. Das Model ist das zu beobachtende Subjekt, auch Publisher, also „Veröffentlicher“, genannt. Es kennt die Views nicht und agiert vollständig autonom.

Die View ist die Präsentationsschicht und für die Darstellung der benötigten Daten aus dem Model, sowie für die Entgegennahme von Benutzerinteraktionen zuständig. Es handelt sich praktisch um Ihre Sicht auf das Model. Sie kennt sowohl ihre Steuerung als auch das Model, dessen Daten sie präsentiert, ist aber nicht für die Weiterverarbeitung der vom Benutzer übergebenen Daten zuständig.
Im Regelfall wird die Präsentation über Änderungen von Daten im Model mithilfe des Entwurfsmusters „Beobachter“ unterrichtet und kann sich daraufhin die aktualisierten Daten besorgen. Die Präsentation verwendet das Entwurfsmuster „Kompositum“.

Der Controller verwaltet eine oder mehrere Präsentationen, nimmt von ihnen Benutzeraktionen entgegen, wertet diese aus und agiert entsprechend. Es ist nicht die Aufgabe der Steuerung, Daten zu manipulieren. Die Steuerung entscheidet aufgrund der Benutzeraktion in der Präsentation, welche Daten im Model geändert werden müssen. Sie enthält weiterhin Mechanismen, um die Benutzerinteraktionen der Präsentation einzuschränken.

Präsentation und Steuerung verwenden zusammen das Entwurfsmuster „Strategie“, wobei die Steuerung der Strategie entspricht. Der Controller kann in manchen Implementierungen ebenfalls zu einem „Beobachter“ des Models werden, um bei Änderungen der Daten die View direkt zu manipulieren.

Was ist die eigentliche Aufgabe des Controllers? Die View könnte theoretisch auch die Benutzereingaben vollständig verwalten und an das Model weiterleiten. MVC separiert die Aufgabenbereiche und hält die View dadurch sauber, gleichzeitig entkoppelt der Controller die View vom Model. Auf diese Weise lässt sich das Model leicht mit einer anderen View verwenden. Der Controller trennt die Steuerungslogik vom View und gestaltet das Design dadurch flexibel, so dass es später leicht erweitert werden kann.

Das Grundgerüst

Eine wesentliche Entscheidung wurde nun getroffen. Mit dem Model View Controller haben wir uns für ein leistungsstarkes Architekturmuster für unser Spiel entschieden.

Wir werden nun ein erstes Klassendiagramm entwerfen. Das Model repräsentiert die Daten des Spieles »Battleship«. Zu einem Spiel gehören zwei Spieler, die abwechselnd auf Koordinaten feuern. Die Spielfelder bestehen aus x×y Kästchen, die man an den Seiten und an den oberen Rändern mit Zahlen versieht. Diese stellen einmal das eigene und dann das gegnerische Meer oder Kampfgebiet dar. In das eigene Meer trägt man nun, ohne dass der Mitspieler dies sieht, seine Flotte ein. Dies geschieht, indem man Gebilde von unterschiedlicher Kästchenlänge einzeichnet. Über die Anzahl und Größe der Schiffe und über die Art der Platzierung sollten vor Spielbeginn Einigkeit herrschen.

Folgende Spielregeln müssen eingehalten werden:

  • Die Schiffe dürfen nicht aneinander stoßen.
  • Die Schiffe dürfen nicht über Eck gebaut sein oder Ausbuchtungen besitzen.
  • Die Schiffe dürfen auch am Rand liegen.
  • Die Schiffe dürfen nicht diagonal aufgestellt werden.
  • Jeder verfügt über insgesamt neun Schiffe, abhängig von der Spielfeldgröße:
    • Ein Battleship (5 Kästchen)
    • Zwei Cruiser (je 4 K.)
    • Ein Tankship (3 Kästchen)
    • Zwei Minesweeper (je 2 K.)
    • Drei Speedboat (je 1 K.)

Die Positionen der Schiffe werden zum Spielanfang durch die Spieler oder automatisch durch den Computer bestimmt.

Schiffeversenken

Die Definition der Daten im Model

Das Model sollte mehrere Datentypen verwalten können. Neben den Spielern, muss es pro Spieler mindestens einen Datensatz von jeweils zwei Spielfeldern verwalten. Ein Spielfeld besteht aus Feldern, die unterschiedliche Zustände einnehmen können. Wir definieren daher zunächst eine separate Klasse vom Typ enum, die die möglichen Zustände eines Feldes definiert.

public enum FieldState implements Serializable
{
    /** Marks a destroyed ship */
    DESTROYED_SHIP,
 
    /** Marks a field which was hit */
    HIT, 
 
    /** Marks a field */
    MARKED,
 
    /** Marks a field which has a ship */
    SHIP,
 
    /** Marks an unknown field */
    UNKNOWN, 
 
    /** Marks a water field */
    WATER;
 
    /**
     * Determines if a de-serialized file is compatible with this class.
     *
     * Maintainers must change this value if and only if the new version
     * of this class is not compatible with old versions. See Sun docs
     * for <a href=http://java.sun.com/products/jdk/1.1/docs/guide
     * /serialization/spec/version.doc.html> details. </a>
     *
     * Not necessary to include in first version of the class, but
     * included here as a reminder of its importance.
     */
    private static final long serialVersionUID = 3103200979172470511L;
}

Wie Sie vielleicht auf den ersten Blick erkennen können, implementieren wir das Interface Serializable. Dies wird es uns später ermöglichen das vollständige Model mitsamt seinen Daten als Objekt zu serialisieren um es anschließend persistent abzuspeichern. Dazu wird der komplette Zustand des Objektes, inklusive aller referenzierten Objekte, in einen Datenstrom umgewandelt, der anschließend auf ein Speichermedium geschrieben wird.

Die Schiffe

Es existieren unterschiedliche Arten von Schiffstypen. Unser Spiel soll mit den folgenden Typen auf einem 10x10 Feld arbeiten:

Typ Engl. Bezeichnung Länge Breite Anzahl
Flugzeugträger Aircraftcarrier 6 1 0
Schlachtschiff Battleship 5 1 1
Kreuzer Cruiser 4 1 2
Tanker Tankship 3 1 1
Minensuchboot Minesweeper 2 1 2
Schnellboot Speedboat 1 1 3

Alle Schiffe haben bestimmte Eigenschaften, weshalb sich an dieser Stelle die Generalisierung aufdrängt. Eine Generalisierung in der UML ist eine gerichtete Beziehung zwischen einem generelleren und einem spezielleren Classifier.

Alle Schiffe erweitern die abstrakte Klasse Ship.

public abstract class Ship implements Serializable, Cloneable
{
    /**
     * Default Constructor.
     * 
     * @param type  the ship type
     * @param width the width of the ship
     * @param length    the length of the ship
     */
    public Ship(String type, int width, int length) 
    {
        this(type, width, length, 0, 0);
    }
 
    /**
     * Inits a new ship.
     * 
     * @param type  the ship type
     * @param width the width of the ship
     * @param length    the length of the ship
     * @param score the score for destroying this ship
     * @param damage    an initial damage
     */
    public Ship(String type, int width, int length, int score, int damage) 
    {
        type_ = type;
        length_ = length;
        width_ = width;
        score_ = score;
        damage_ = damage;
        coordinates_ = new ArrayList<Coordinate>();
    } 
 
    /**
     * Allows to clone an object.
     * 
     * @return  an object copy
     * @throws java.lang.CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException 
    {
        // Get initial bit-by-bit copy, which handles all immutable fields
        return super.clone();
    }
 
    /**
     * Get the ship length.
     * 
     * @return  the length
     */
    public int getLength()
    {
        return length_;
    }
 
    /**
     * Get the ship width.
     * 
     * @return  the width
     */
    public int getWidth()
    {
        return width_;
    }
 
    // Rest siehe Quellcode...
}

Ein wesentlicher Teil der Rahmendaten wurde nun definiert. Bevor wir uns den Spielfelder widmen generieren wir eine weitere Klasse, die sich später als nützlich erweisen wird. Jeder Spieler besitzt einen Satz von Schiffen. Diesen Satz kann man unter einer Schiffsflotte vereinen.

public final class Fleet implements Serializable, Cloneable
{
    /**
     * Standard-Constructor.
     */
    public Fleet()
    {
        fleet_ = new ArrayList<Ship>();
    }
 
    /**
     * Constructs a new fleet, with a given fleet list. Since a fleet array
     * consists of ship objects, this method will just add a shallow reference
     * to the old fleet array. 
     * 
     * If you want to copy a whole fleet, use clone().
     * 
     * @param fleet the fleet
     */
    public Fleet(ArrayList<Ship> fleet)
    {
        fleet_ = fleet;
    }  
 
    /**
     * Implement clone as follows:
     * <ul>
     * <li>the class declaration "implements Cloneable" (not needed if already
     * declared in superclass)
     * <li>declare clone method as public
     * <li>if the class is final, clone does not need to throw CloneNotSupportedException
     * <li>call super.clone and cast to this class
     * <li>as in defensive copying, ensure each mutable field has an independent copy
     * constructed, to avoid sharing internal state between objects
     * </ul>
     * 
     * @return  an object copy
     * @throws java.lang.CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException 
    {
        // Get initial bit-by-bit copy, which handles all immutable fields
        Fleet result = (Fleet)super.clone();
 
        // Mutable fields need to be made independent of this object, for reasons
        // similar to those for defensive copies - to prevent unwanted access to
        // this object's internal state. Strings are always immutable.
        result.fleet_ = getShipArrayCopy();
 
        return result;
    }   
 
    /**
     * Returns a *deep* copy of the ship array.
     * 
     * @return  a ship array list
     */
    public ArrayList<Ship> getShipArrayCopy()
    { 
        ArrayList<Ship> fleet = new ArrayList<Ship>();  
 
        try {
            // We need to copy and add every single ship object
            for(Ship s : fleet_) {
                fleet.add( (Ship)s.clone() );
            }
            return fleet;
        } catch(CloneNotSupportedException e){            
        }
 
        return null;
    }   
 
     /**
     * Return the fleet ship array.
     *
     * @return  a ship array list
     */
    public ArrayList<Ship> getShipArray()
    {
        return fleet_;
    }
 
    /**
     * Appends the specified ship to the end of this fleet.
     * 
     * @param ship  ship to be appended to this list 
     * @return  true (as per the general contract of Collection.add)
     */
    public boolean addShip(Ship ship)
    {
        if( ship == null )
            return false;
 
        return fleet_.add(ship);
    }
 
    // Rest siehe Quellcode...
}
Die Spielfelder

Wir haben bereits festgestellt, dass zu einem Spieler zwei Spielfelder gehören. Das eigene Spielfeld mit der Draufsicht auf die eigenen Schiffe, sowie das Spielfeld des Gegners, dessen positionierte Schiffe wir nicht sehen können. Wir wählen einen Ansatz mit zwei unterschiedlichen Klassen, die jeweils die abstrakte Klasse Board.java erweitern, nämlich HomeBoard.java und EnemyBoard.java.

Die Klasse EnemyBoard.java beinhaltet kaum Funktionalitäten mit Ausnahme von Feldanzeigen, da wir auf dem Spielfeld des Gegners keine nennenswerten Operation ausführen können. Die Klasse HomeBoard.java hingegen implementiert diverse Methoden, darunter die Methode removeShips(), die alle Schiffe von einem Spielfeld entfernt.

/**
 * Removes all ships from a game board. This is an internal method
 * to clean a board completely from ships.
 * 
 * @param state the state to set
 */
public void removeShips(FieldState state)
{
    Field f = null;
 
    // Call all fields and release them from ships.
    for(int row = 0; row < height_; row++) {
        for(int column = 0; column < width_; column++) {                
            f = getField( column, row );
            if(f != null) {
                // Delete the ship from the field, if there is one
                if( f.hasShip() ) {
                    // First delete coords of the ship
                    f.getShip().clearCoords();
                    // Now remove it from the field
                    f.deleteShip( state );
                }
            }
        }   
    }
}

Zur Lokalisierung eines Feldes wird eine seperate Klasse namens Coordinate.java verwendet, welche Koordinatenpunkte in einem zweidimensionalen Raum speichert.

Die beiden genannten Spielfelder werden abschließend zu einem Set verknüpft. Dieses Set nennen wir BoardSet.java. Jeder Spieler besitzt genau einen solchen Satz.

Die Spieler

Neben den Spielfeldern muss das Model auch die Daten eines Spielers verwalten. Ein Spieler trägt eine eindeutige Identifikationsnummer (Id) und kann weitere Personenmerkmale tragen. Dazu zählt der Name, der Geburtstag, das Geschlecht der Person und auch die Zahl der abgegebenen Schüsse. Weitere Merkmale sollten zu einem späteren Zeitpunkt definiert werden können.

Die Klasse PlayerInfo.java beinhaltet alle wesentlichen Datensätze zu einer Person.

public class PlayerInfo implements Serializable
{
    /**
     * Constructor. Creates an empty player profile, with an id.
     * 
     * @param id  the player id
     */
    public PlayerInfo(String id)
    {
        this( id, "" );
    }
 
    /**
     * Constructor. Inits all variables, and creates a new player profile.
     * 
     * @param id    the player id
     * @param name  the player name
     */
    public PlayerInfo(String id, String name)
    {
        id_ = id;
        name_ = name;
        idHash_ = Cryptography.getSha256( id.getBytes() );
    }
 
    /**
     * Set the players name.
     * 
     * @param name  the new name
     */
    public void setName(String name)
    {
        name_ = name;
    }
 
    // Rest siehe Quellcode...
}

Die Identifikationsnummer ist von entscheidender Bedeutung. Sie identifiziert einen Spieler im Model und erlaubt es ihm, auf Ressourcen zuzugreifen. Verliert ein Spieler seine Identifikationsnummer, so ist er anschließend nicht mehr in der Lage seine Daten im Model abzufragen. Gleichzeitig verhindert die Nummer den unerlaubten Zugriff auf fremde Daten im Model. So kann eine Person die Daten einer anderen Person nicht abfragen, wodurch das Geheimnisprinzip gewahrt bleibt.
Die Identifikationsnummer verhält sich analog zur neunstelligen »Social Security Number« in den Vereinigten Staaten, wo sie die Funktion eines allgemeinen Personenkennzeichens erfüllt und unter anderem von diversen Bundesbehörden genutzt wird. In den USA existiert eine Person ohne diese Nummer nicht und so verhält es sich auch in Battleship.


Model

Wir haben die wesentlichen Daten für das Model definiert. Nun gilt es mithilfe dieser Daten eine Geschäftslogik zu entwickeln, die es der Applikation erlaubt diese Daten konsistent zu manipulieren. Zum gegenwärtigen Zeitpunkt sind unsere Datenklassen nicht viel mehr, als ein loser Verbund von zwecklosen Klassen ohne jeden Kontext.

Die Implementierung eines vernünftigen Models erfordert die Einhaltung von strengen Regeln. Mit dem Model steht und fällt die Anwendung. Umso leistungsfähiger das Model ist, umso mehr Möglichkeiten geben sie Ihrer Applikation diese Leistung später auch voll auszureizen. Sollte das Model unzureichend sein, werden Sie zu einem späteren Zeitpunkt auf massive Probleme stoßen, die sich negativ auf ihr Design niederschlagen.

Zu den Grundkonzepten von MVC gehört das Observer Entwurfsmuster. Beobachter erhalten die Möglichkeit sich beim Model zu registrieren. Das Model stellt hierfür Schnittstellen und Methoden bereit. Es geht nicht näher darauf ein, wer sich bei ihm registriert. Java bietet eine fertige Observer-Schnittstelle und eine Observable-Klasse an, die der Entwickler verwenden kann.

Die Klasse Observable muss von allen Klassen erweitert werden, die das zu beobachtende Objekt darstellen. Das Model repräsentiert die Daten, so dass Beobachter sich beim Model registrieren müssen um über Datenänderungen informiert zu werden. Als analoges Beispiel in der Praxis sei das Zeitungsabonnement genannt. Abonnenten registrieren sich bei einem Verlag und werden bei Neuerscheinungen informiert bzw. erhalten ein Exemplar zugesandt.

Verwendete Softwaretechniken

Neben dem Entwurfsmuster Observer, verwendet das Model noch einige weitere Softwaretechniken, die in den folgenden Abschnitten kurz erläutert werden sollen.

Das Singleton Muster

Das Singleton (auch Einzelstück genannt) ist ein in der Softwareentwicklung eingesetztes Entwurfsmuster und gehört zur Kategorie der Erzeugungsmuster (engl. Creational Patterns). Es verhindert, dass von einer Klasse mehr als ein Objekt erzeugt werden kann. Dieses Einzelstück ist darüber hinaus üblicherweise global verfügbar. Das Muster ist eines der von der so genannten Viererbande (GoF) publizierten Muster.

Auch unser Model soll einzigartig sein. Es sollen keine weiteren Instanzen kreiert werden können. Dadurch kann eine Zugriffskontrolle realisiert werden.

Die Schnittstelle

Wann immer Sie Code schreiben, sollten Sie versuchen gegen eine Schnittstelle zu programmieren um die Implementierung austauschbar zu machen. Das Model bietet der Außenwelt eine Reihe von Methoden, so dass die Definition einer Schnittstelle Sinn macht. Das ModelInterface beinhaltet alle Schnittstellen, die von der Anwendung benötigt werden.

Nachdem einige wichtige Regeln bei der Programmierung eines Models genannt wurden, lässt sich ein grober Rumpf implementieren.

public final class BattleshipModel extends Observable 
        implements ModelInterface, Observer, Serializable
{   
    /**
     * Get a Singleton instance of the model class using double-checked
     * locking with a volatile field.
     */
    public static BattleshipModel getInstance() 
    {
        if( modelInstance_ == null ) {
            synchronized(BattleshipModel.class) {
                if( modelInstance_ == null ) {
                    modelInstance_ = new BattleshipModel();
                }
            }
        }
 
        return modelInstance_;
    }
 
    /**
     * This model is an observable as well as an observer of another object.
     * In our case, this model will observe the network node. Any changes of the
     * node will be instantly dispatched to our observers.
     * 
     * @param o the observable object
     * @param arg   arguments, send by the observable
     */
    @Override
    public void update(Observable o, Object arg)
    {        
    }
 
    /**
     * @serial  BattleshipModel Singleton instance
     */
    private static volatile BattleshipModel modelInstance_;
 
    /**
     * Determines if a de-serialized file is compatible with this class.
     *
     * Maintainers must change this value if and only if the new version
     * of this class is not compatible with old versions. See Sun docs
     * for <a href=http://java.sun.com/products/jdk/1.1/docs/guide
     * /serialization/spec/version.doc.html> details. </a>
     *
     * Not necessary to include in first version of the class, but
     * included here as a reminder of its importance.
     */
    private static final long serialVersionUID = 3103200964227944224L;
}

Wie Sie erkennen können, implementiert das Model zusätzlich das Observer Interface. Den Grund dafür werden Sie später erfahren.

Neben den trivialen Spieldaten muss ein komplexes Model auch weitere Applikationsdaten verwalten. In einer echten Anwendung sind das Umgebungsdaten, Daten aus Konfigurationsdateien oder Sprachdateien. Letzteres führt uns zu einer wesentlichen Komponente in jeder Systemarchitektur - der Internationalisierung/Lokalisierung.

Internationalisierung/Lokalisierung

Ziel ist, alle Aspekte bereits bei dem Design und der Entwicklung des Produktes zu berücksichtigen, die einen weltweiten Einsatz, Verkauf, Wartung und Weiterentwicklung des Produktes ermöglichen. Zu Beginn des Produktentwicklungszyklus müssen hier detaillierte technische Überlegungen zu den Entwicklungsanforderungen eines Produkts für bestimmte lokale Märkte oder generell für den internationalen Markt im Vordergrund stehen, wie zum Beispiel Einsatz von Internationalisierungsstandards und -normen wie Unicode und andere Multibyte Zeichensatz-Technologien, um überhaupt die Benutzung und Verarbeitung der vielfältigen Buchstaben- und Zeichendarstellungen der Sprachen in den Software-Produkten zu ermöglichen.

Die Software-Lokalisierung ist der eigentliche Prozess der Anpassung und Übersetzung von internationalisierten Software-Anwendungen in die gewünschten Sprachen und deren lokalen, kulturellen und geographischen Standards.

Java bietet dem Programmierer einen angenehmen Weg, diese Herausforderungen in der Praxis zu meistern und die eigene Anwendung zu internationalisieren. Ein Vorteil besteht in der Tatsache das Java von vornherein Unicode zu 100 Prozent unterstützt. Bei allen Strings handelt es sich um Unicode, so dass keine besonderen Richtlinien bei der Internationalisierung beachtet werden müssen.

Mithilfe von Properties ist es möglich in Java Sprachdateien zu entwerfen. Die Sprachdateien folgen einer festgelegten Namenskonvention, welche in unserem Fall, wie folgt aussieht:

Battleship.properties
Battleship_de_DE.properties
Battleship_en_US.properties

Je nach Einstellung oder Umgebung kann mit der folgenden Methode die korrekte Datei ausgelesen werden.

ResourceBundle language = ResourceBundle.getBundle("Battleship", currentLocale);

Für die Verwaltung der Sprache in der Anwendung Battleship wurde eine zusätzliche Klasse definiert. Die Klasse Language.java kapselt intern den Zugriff auf Properties und verwaltet Lokalitäten. Sie stellt Methoden zur Verfügung, die entsprechende Schlüssel in Werte mappen. Spezifische Schlüssel werden je nach Laufzeitumgebung automatisch in den richtigen Sprachstring transformiert. Die Datei Battleship_de_DE.properties enthält unter anderen folgende Schlüssel/Wert-Paare.

BATTLESHIP.BLUE=Blau
BATTLESHIP.RED=Rot
BATTLESHIP.YELLOW=Gelb
BATTLESHIP.BLACK=Schwarz
BATTLESHIP.ORANGE=Orange
BATTLESHIP.GREEN=Grün
BATTLESHIP.GRAY=Grau

Nachfolgend können Sie eine Methode der Klasse Language.java begutachten.

/**
 * Gets a string for the given key from this resource bundle or one of its
 * parents.
 *
 * @param key   the hashtable key
 * @return  the string for the given key
 */
public String getString(String key)
{
    String string = "";
 
    try {
        string = bundle_.getString( key );
    } catch( NullPointerException e ) {
        logger_.warning( e.getMessage() );
    } catch( MissingResourceException e ) {
        logger_.warning( e.getMessage() );
    }
 
    return string;
}

Alle Strings in einer internationalen Anwendung müssen auf diese Weise abstrahiert werden. Die Anwendung transformiert die Schlüssel anschließend in die korrekte Sprache. Das Spiel Battleship erlaubt es problemlos die Anwendung mit weiteren Sprachen auszustatten, ohne den Quelltext antasten zu müssen. Dazu muss lediglich die neue Sprachdatei mit der Übersetzung zur Verfügung gestellt werden.

Ressourcenverwaltung

Jede Anwendung muss diverse Ressourcen verwalten können. Die Konfigurationsdatei ist eine wichtige Komponente dieser Ressourcen. Es handelt sich dabei um eine Datei, in der bestimmte Einstellungen (die Konfiguration) von Computerprogrammen oder Hardwarebestandteilen gespeichert sind.

Die Singleton Klasse Environment.java ist für die Verwaltung der Umgebungsdaten und Ressourcen in der Anwendung Battleship zuständig. Environment beinhaltet auch die Klasse Language zur Sprachenverwaltung. Darüberhinaus speichert diese Klasse elementare Informationen über die Laufzeitumgebung, darunter die Java-Version und das Betriebssystem auf dem die Anwendung läuft.

/**
 * Determines what operating system this system is using.
 */
private void initializeOS()
{
    // Get the operating system
    String os = System.getProperty("os.name").toLowerCase();
 
    if( os.startsWith("mac os") ) {
        isMacOSX_ = os.endsWith("x");
        return;
    } else if( os.indexOf( "windows" ) != -1 ) {
        isWindowsOS_ = true;
        if ( os.indexOf( "windows 2000" ) != -1 ||
            os.indexOf( "windows xp" ) != -1 ) {
            isWin2000orXpOS_ = true;
        }
        return;
    }
}
Konfiguration

Daten, die während der Programmausführung im flüchtigen Arbeitsspeicher liegen, müssen nach Beendigung der Anwendung auf der Festplatte hinterlegt werden. Zu diesen Daten gehören elementare Einstellungen, darunter die eingestellte Sprache oder die verwendete Portnummer der Applikation. Diese Daten werden in der Regel vom Benutzer verändert und müssen auch nach dem Programmstart wieder eingelesen werden können.

Eine Speicherform der Konfigurationsdatei ist die bekannte Initialisierungsdatei (.ini). Sie speichert Schlüssel/Wert-Paare und ermöglicht auf diese Weise das einfache Auslesen von Daten. In Java lässt sich diese Funktionalität mit den bereits genannten Properties nachahmen.

JAXB (Java Architecture for XML Binding)

Eine wesentlich angenehmere Vorgehensweise Konfigurationsdateien zu generieren und wieder auszulesen bietet die Programmschnittstelle JAXB. Sie ermöglicht es, Daten aus einer XML-Schema-Instanz heraus automatisch an Java-Klassen zu binden, und diese Java-Klassen aus einem XML-Schema heraus zu generieren.
Somit ist ein Arbeiten mit XML-Dokumenten möglich, ohne dass der Programmierer direkt Schnittstellen zur Verarbeitung von XML wie SAX oder DOM verwenden muss.

jaxb

Die Klasse Environment verwaltet die Konfiguration der Anwendung. Vor dem Programmstart wird diese Klasse generiert und liest alle wichtigen Daten aus der Konfigurationsdatei ein. Die Klasse stellt hierfür eine Methode zur Verfügung, die den Namen getConfiguration() trägt.

/**
 * Returns the Configuration representing the complete structure.
 *
 * @return a Config object with the complete configuration
 */
public synchronized Config getConfiguration()
{
    if( configRoot_ == null ) {
        try {
            setupConfigRoot( null );
        } catch( IOException e ) {
            // initialize the file name.
            System.exit( - 1 );
        }
    }
 
    return configInstance_;
}

Diese Methode liefert eine Instanz der Klasse Config zurück, die die zu Java-Klassen gebundenen Daten beherbergt. Dazu ruft die synchronisierte Methode die private Methode setupConfigRoot() auf. Diese Methode generiert die JAXB Marshaller und liest die Daten aus einer XML-Datei ein.

/**
 * Sets the directory into which Battleship adds its configuration files
 * and other data. When configRoot is null the directory is set to:
 * {user.home}/data on windows systems and {user.home}/.data on unix
 * and mac systems.
 *
 * @param configRoot the directory of the configuration files or null
 * @return  true if cofiguration setup was successful
 */
private boolean setupConfigRoot(File configRoot)
        throws IOException
{
    if( configRoot == null ) {
        StringBuffer path = new StringBuffer( 25 );
        // Get the working directory. This is the location in the file system
        // from where the java command was invoked.
        path.append( System.getProperty( "user.home" ) );
        path.append( File.separator );
 
        // Since there are many UNIX like operation systems with Java
        // support out there, we can not recognize the OS through it's name.
        // Thus we check if the root of the filesystem starts with "/"
        // since only UNIX uses such filesystem conventions.
        if( File.separatorChar == '/' ) {
            path.append ('.');
        }
 
        path.append( "Battleship" );
        configRoot_ = new File( path.toString() );
    } else {
        configRoot_ = configRoot;
    }
 
    // Create JAXB variables
    JAXBContext context = null;
    Marshaller marshaller = null;
    Unmarshaller unmarshaller = null;
    Config config = null;
 
    File configuration = new File( configRoot_ + File.separator + configFileName_ );
    File loggingPath = new File( configRoot_ + File.separator + logDir_ );
 
    try {
        // Get JAXB context and instantiate marshaller
        context = JAXBContext.newInstance( Config.class );
        marshaller = context.createMarshaller();
        marshaller.setProperty( Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE );
 
        // Check for file and directory
        if( configuration.exists() ) {
            unmarshaller = context.createUnmarshaller();
            FileInputStream in = new FileInputStream( configRoot_ + 
                    File.separator + configFileName_ );
            Reader reader = new InputStreamReader( in, "UTF-8" );
            configInstance_ = (Config) unmarshaller.unmarshal( reader );
        } else {
 
            // Check if we have a root directory
            if( !configRoot_.isDirectory() ) {
                boolean success = configRoot_.mkdirs();
                if( !success ) {
                    System.err.println( "Unable to create directory." );
                    return false;   // Fatal error
                }
            }
 
            // Generate a new config file
            Preferences pref = new Preferences();
            pref.setDatadir( configRoot_.getPath() + File.separator );
            pref.setLocale( "en_US" );
            pref.setPort( "8734" );
            pref.setLogFilePath( loggingPath.getPath() + File.separator );
 
            config = new Config();
            config.setWebsite( Version.getURL() );
            config.setVersion( Version.getShortVersion() );
            config.setPublisher( Version.getCopyright() );
            config.setProduct( Version.getProduct() );
            config.setLastUpdate( "" );
            config.setPreferences( pref );
 
            // Create JAXB context and instantiate marshaller
            context = JAXBContext.newInstance( Config.class );
            marshaller = context.createMarshaller();
            marshaller.setProperty( Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE );
            File file = new File( configRoot_ + File.separator + configFileName_ );
            marshaller.marshal( config, new FileOutputStream( file ) );
 
            // Set the config instance of the model
            configInstance_ = config;
        }
    } catch( JAXBException e ) {
        e.printStackTrace();
        return false;   // Fatal error
    } catch( FileNotFoundException e ) {
        e.printStackTrace();
        return false;   // Fatal error
    } catch( IOException e) {
        e.printStackTrace();
        return false;   // Fatal error
    }
 
    // Now check for language directory, too
    if( !loggingPath.isDirectory() ) {
        boolean success = loggingPath.mkdir();
 
        if( success == false ) {
            System.err.println( "Unable to create directory." );
            return false;   // Fatal error
        }
    }
 
    // At this point, we should have a valid Config instance
    if( configInstance_ == null ) {
        // Todo: logger
        System.err.println( "Couldn't create Config instance." );
        return false;
    }
 
    String resources = "eu/codeplanet/battleship/resources/lang/Battleship";
 
    // Perform some integrity checks
    if( configInstance_.getPreferences() == null ||
            configInstance_.getPreferences().getLocale().isEmpty() ||
            configInstance_.getPreferences().getLocale().length() != 5 ) {
        // Get the JVM Locale
        String country = Locale.getDefault().getCountry();
        String language = Locale.getDefault().getLanguage();
        // Set the config file
        configInstance_.getPreferences().setLocale( language + "_" + country );
        ResourceBundle bundle = ResourceBundle.getBundle( resources );
        try {
            language_ = new Language( bundle ); // default
        } catch( MissingResourceException e) {
            e.printStackTrace();
            return false;
        }
    } else {
        // Load the locale from the config
        String tmp = configInstance_.getPreferences().getLocale();
        String language = tmp.split("_")[0];
        String country = tmp.split("_")[1];
        locale_ = new Locale( language, country );
        try {
            ResourceBundle bundle = ResourceBundle.getBundle( resources, locale_ );
            language_ = new Language( bundle );
        } catch( MissingResourceException e) {
            e.printStackTrace();
            return false;
        }
    }
 
    // Everything was fine, we are ready to proceed
    return true;
}

Die Methode lokalisiert zunächst das Stammverzeichnis des Benutzers. In diesem Verzeichnis werden anschließend die Programmdateien hinterlegt. Dazu wird im Stammverzeichnis bei Bedarf ein separater Ordner namens "Battleship" erstellt. Nachdem der Ordner erfolgreich generiert wurde, überprüft die Methode das Vorhandensein der Konfigurationsdatei Settings.Config. Ist diese vorhanden, werden alle Daten nacheinander mithilfe des Unmarshaller in die Klasse Config eingelesen. Die Klasse Config enthält unter anderen eine Unterklasse Preferences mit den Einstellungen und weiteren Programmdaten. Alle Objekte dieser Klasse werden bei Programmende automatisch in eine XML-Datei geschrieben. Diese XML-Datei hat gegenwärtig den folgenden Inhalt:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<ns2:config xmlns:ns2="eu.codeplanet.battleship">
    <lastUpdate></lastUpdate>
    <preferences>
        <datadir>C:\Users\Christian\Battleship\</datadir>
        <locale>en_US</locale>
        <logFilePath>C:\Users\Christian\Battleship\log\</logFilePath>
        <port>8734</port>
    </preferences>
    <product>Battleship</product>
    <publisher>Copyright © 2005 - 2009 CodePlanet. All rights reserved.</publisher>
    <version>1.0.0</version>
    <website>http://www.codeplanet.eu/</website>
</ns2:config>

Die Methode setupConfigRoot() generiert eine Standarddatei, falls keine Konfigurationsdatei existiert. Die Klasse Config wird mit Default-Werten gefüllt und anschließend in eine Datei geschrieben. Das Spiel Battleship bezieht alle seine Programmdaten aus dieser Konfigurationsdatei. Die Klassen lassen sich bei Bedarf problemlos erweitern

Die Klasse Environment stellt eine Basisklasse in der Anwendung dar, ohne die das Programm nicht starten kann. Als Verwalter der Umgebungsdaten und Ressourcen, leitet sie alle wichtigen Informationen an das Model weiter. Das Model besitzt eine Instanz dieser wichtigen Klasse. Nach dem Programmstart muss das Model initialisiert werden. In der synchronisierten Initialisierungsphase generiert das Model die Umgebung und liest alle notwendigen Informationen ein.

/**
 * Initializes the model. Call this method immediately after
 * constructing the model object.
 *
 * @return  true if model was successfully initialized
 */
@Override
public synchronized boolean initialize()
{
    // Check if the environment was already initialized
    if( environment_ != null ) {
        return false;
    }
 
    // Get the system environment
    environment_ = Environment.getInstance();
 
    // Check if environment setup was successful
    if( environment_ == null ||
            environment_.getConfiguration() == null ||
            environment_.getLanguage() == null) {
        return false;
    }
 
    on_ = true; // Set model active
 
    return true;
}

Die Methode initialize() des Models muss unmittelbar nach Programmstart aufgerufen werden.


Netzwerkprogrammierung in Java

Viele moderne Anwendungen in der Softwareindustrie kommen heute kaum noch ohne Netzwerkfunktionalitäten aus. Kenntnisse zur Netzwerkprogrammierung gehören heutzutage zu den Grundlagen eines ambitionierten Software-Entwicklers. Dank guter Bibliotheken ist es kein allzu kompliziertes Unterfangen mehr, Ihre Software mit einer Netzwerkfunktionalität auszustatten.

Die meisten Daten laufen heute weltweit durch Netzwerke auf Basis des Internet Protokolls (bekannter ist die Protocol-Suite TCP/IP). Das IP teilt die zu versendenden Daten in Pakete auf: Im sogenannten Header stehen Quelle und Ziel des Pakets (IP-Adresse und Port). Der Körper enthält die eigentlichen Daten. Diese Pakete werden dann an einen Router im Netz geschickt. Der Router bestimmt dann anhand der Daten im Header den momentan kürzesten Weg zum Empfänger und sendet das Paket weiter an den nächsten Router entlang diese Weges. Dies wiederholt sich solange, bis ein letzter Router eine direkte Verbindung zum Zielrechner hat und ihm die Daten zustellt.

Die hohen Anforderungen, die die Benutzung eines komplizierten Protokoll-Verbunds wie TCP/IP an den Programmierer einer Netzwerk- Anwendung stellt, führte schließlich, zunächst unter Unix, zur Entwicklung des Socket-Konzepts. Dabei kapseln die Sockets alle TCP- und IP-Verwaltungsaufgaben, so dass der Nutzer nur seine Daten an den Socket übergibt und dieser sie versendet und auf der anderen Seite ein weiterer Socket die Pakete empfängt und verarbeitet und nur die rohen Daten an den Nutzer weitergibt. Ein solches Konzept findet sich heute auf praktisch jedem TCP/IP-fähigen System.

Die Programmiersprache Java unterstützt sowohl TCP- als auch UDP-Sockets, andere vom Internet Protocol vorgesehene Paketformate, wie etwa die für Ping benutzten ICMP-Pakete, kann man mit reinem Java (derzeit) nicht erzeugen.

Eine Netzwerkbibliothek entwerfen

Obwohl Java viele fertige Klassen für den Umgang mit Sockets zur Verfügung stellt, bleibt dem ambitionierten Softwareentwickler eine intensivere Einarbeitung in die Netzwerkprogrammierung nicht erspart. Moderne Anwendungen nutzen umfangreiche Bibliotheken und Protokolle, die es der Anwendung erlauben mit anderen Programmen über Netzwerk zu kommunizieren.

Auch unser Spiel soll in der Lage sein mit anderen Programmen im Netzwerk zu kommunizieren, schließlich war eine Anforderung die Implementierung eines Chat. Eventuell möchten wir unsere Anwendung zu einem späteren Zeitpunkt auch erweitern, so dass Spieler online gegeneinander spielen können. Dafür brauchen wir ein solides Fundament in unserer Softwarearchitektur. Dieses Fundament basiert auf einer soliden Netzwerkbibliothek, die als Bestandteil des Models Daten aus dem Netzwerk verarbeitet.

Die Gestaltung einer Netzwerkbibliothek erfordert gewisse Kenntnisse. Dem Entwickler müssen die Grundlagen von TCP/IP bekannt sein. Darüberhinaus sollte man ein wenig Erfahrung mit Netzwerkprotokollen haben. Eine sehr bekannte Kommunikationsstruktur im Internet ist P2P.

Peer-to-Peer (P2P)

Peer-to-Peer (P2P) bezeichnet eine Kommunikation unter gleichen Computern. In einem reinen Peer-to-Peer-Netz sind alle Computer gleichberechtigt und können sowohl Dienste in Anspruch nehmen als auch Dienste zur Verfügung stellen. Man spricht deshalb auch von einem Client-Server-Modell, da ein Peer beide Aufgaben zugleich übernimmt. Die Computer in diesem Netzwerk können als Arbeitsstationen genutzt werden, aber auch Aufgaben im Netz übernehmen. Mittels der Such-Operation können die Peers nach Objekten im Netzwerk suchen, die gewisse Kriterien erfüllen. Alle Peers bilden zusammen ein engmaschiges Netz, so dass Daten direkt untereinander, zwischen den Peers, ausgetauscht werden können.

p2p

In einem Peer-to-Peer Netz weisen alle Klienten eine eindeutige Identifikationsnummer auf, zumeist setzt sich diese Nummer aus der IP-Adresse und der Port-Nummer zusammen.

Sockets

Sockets sind die elementaren Bausteine einer Netzwerkanwendung. Ein Socket ist ein Endpunkt einer bi-direktionalen Software-Schnittstelle zur Interprozess- (IPC) oder Netzwerk-Kommunikation zwischen zwei Programmen. Ein Socket ist gebunden an eine Port-Nummer, so dass die TCP Schicht die Anwendung identifizieren kann für die die Informationen bestimmt sind.

Ein Socket kann man sich als "Steckdose" vorstellen. Maschinen können über diese Steckdose Zugang zum Netz erhalten. Im Prinzip ist ein Socket eine Abstraktion die es einer Anwendung ermöglicht sich in ein bestehendes Netzwerk einzuklinken um mit anderen Applikationen zu kommunizieren.

Auch unsere Anwendung basiert auf Sockets und deshalb beginnen wir auch bei der Implementierung geeigneter Socketklassen. Java stellt eine Standardsocketklasse, namens Socket, zur Verfügung. Wir wollen diese Klasse zunächst kapseln, so dass wir später in der Lage sind eigene Erweiterungen hinzuzufügen. Die Klasse StandardSocket implementiert ein einfaches Interface, welches elementare Methoden definiert.

public class StandardSocket implements SocketInterface 
{  
    /**
     * Creates a stream socket and connects it to the specified port number on 
     * the named host.
     * 
     * @param host  the host name, or null for the loopback address
     * @param port  the port number
     * @throws java.io.IOException  if an I/O error occurs when creating the socket
     * @throws java.net.UnknownHostException    if the IP address of the host could not be determined
     */
    public StandardSocket(String host, int port) 
            throws IOException, UnknownHostException 
    {
        this( new Socket( host, port ) );
    }
 
    /**
     * Encapsulates a normal Java API Socket object.
     * 
     * @param socket an already-open socket connection
     * @throws IOException
     */
    public StandardSocket(Socket socket) throws IOException
    {
        s_ = socket;
        is_ = s_.getInputStream();
        os_ = s_.getOutputStream();        
    }   
 
    /**
     * Creates a stream socket and connects it to the specified port number on 
     * the named host.
     * 
     * @param address  the IP address
     * @param port  the port number
     * @throws java.io.IOException  if an I/O error occurs when creating the socket
     * @throws java.net.UnknownHostException    if the IP address of the host could not be determined
     */
    public StandardSocket(InetAddress address, int port)
            throws IOException, UnknownHostException
    {
        this( new Socket( address, port ) );
    }
 
    /**
     * Closes all streams of this socket.
     * 
     * @throws java.io.IOException
     */
    @Override
    public void close() throws IOException 
    {
        is_.close();
        os_.close();
        s_.close();
    }
 
    /**
     * Reads the next byte of data from the input stream of this socket.
     *
     * @return  the next byte of data, or -1 if the end of the stream is reached.
     * @throws java.io.IOException
     */
    @Override
    public int read() throws IOException
    {
        return is_.read();
    }
 
    /**
     * Reads some number of bytes from the input stream of this socket
     * and stores them into the buffer array b.
     * 
     * @param b the buffer into which the data is read.
     * @return  the total number of bytes read into the buffer, or -1 is there 
     *          is no more data because the end of the stream has been reached.
     * @throws java.io.IOException
     */
    @Override
    public int read(byte[] b) throws IOException
    {
        return is_.read( b );
    }
 
    /**
     * Writes b.length bytes from the specified byte array to the
     * output stream of this socket.
     * 
     * @param b
     * @throws java.io.IOException
     */
    @Override
    public void write(byte[] b) throws IOException
    {
        os_.write( b );
        os_.flush();
    }
 
    /** The socket object */
    private Socket s_;
 
    /** The input stream */
    private InputStream is_;
 
    /** The output stream */
    private OutputStream os_;
}

Da wir die Sockets dynamisch generieren wollen, entwerfen wir eine entsprechende Fabrikmethode. Der Begriff Fabrikmethode oder Factory Method bezeichnet ein Entwurfsmuster aus dem Bereich der Softwareentwicklung. Das Muster beschreibt, wie ein Objekt durch Aufruf einer Methode anstatt durch direkten Aufruf eines Konstruktors erzeugt wird. Es gehört somit zur Kategorie der Erzeugungsmuster.

Auch wir wollen über eine Methode einen eigenen Socket erzeugen und definieren dazu die Klasse StandardSocketFactory, die sich von der abstrakten Klasse SocketFactory ableitet.

public class StandardSocketFactory extends SocketFactory 
{
    /**
     * Generates a new socket object.
     * 
     * @param host  a host nameor ip-address
     * @param port  the port number
     * @return  a new standard socket object
     * @throws java.io.IOException
     * @throws java.net.UnknownHostException
     */
    @Override
    public SocketInterface makeSocket(String host, int port)
            throws IOException, UnknownHostException
    {
        return new StandardSocket( host, port );
    }
 
    /**
     * Generates a new socket object from an existing socket.
     * 
     * @param socket    the initial socket
     * @return  a new standard socket object
     * @throws java.io.IOException
     */
    @Override
    public SocketInterface makeSocket(Socket socket) throws IOException 
    {
        return new StandardSocket( socket );
    }
 
    /**
     * Generates a new socket object.
     *
     * @param address  the IP address
     * @param port  the port number
     * @return  a new standard socket object
     * @throws java.io.IOException
     * @throws java.net.UnknownHostException
     */
    @Override
    public SocketInterface makeSocket(InetAddress address, int port)
            throws IOException, UnknownHostException
    {
        return new StandardSocket( address, port );
    }
}

Die Klasse StandardSocket kapselt lediglich die Java Socket-Klasse. Mit der Methode makeSocket() aus der Klasse SocketFactory lässt sich ein neues Socketobjekt erzeugen.

Wir wollen unserer Anwendung zusätzlich eine etwas ausgefeiltere Klasse zur Verfügung stellen. Daten die über ein Netzwerk übertragen werden verbrauchen natürlich eine bestimmte Bandbreite. Werden viele Daten übertragen, kann sich das spürbar auf die Performance auswirken. Darüberhinaus verfügt nicht jeder Benutzer über eine starke Internetanbindung, so dass es an dieser Stelle ebenfalls zu Problemen kommen kann.

Komprimierte Sockets

Idealerweise werden Daten vor der Übertragung komprimiert. Auf diese Weise sparen Sie Bandbreite und können mehr Daten übertragen.

Das Paket java.util.zip, eingeführt mit JDK 1.3, stellt ein Java Interface für den weltweit genutzen ZLIB Kompressionsalgorithmus zur Verfügung. Die Kernklassen dieses Packages sind die beiden Klassen Inflator und Deflator, die eine native Bibliothek kapseln, welche Datenblöcke komprimiert und dekomprimiert. Zwei Paare von Stream-Klassen, ZipInputStream/ZipOutputStream und GZIPInputStream/GZIPOutputStream, verwenden den Inflator und Deflator um komprimierte Daten im bekannten ZIP- und GZIP-Format zu schreiben.

Um Daten über Sockets zu komprimieren, müssen entsprechende Stream-Klassen implementiert werden. Die beiden Klassen CompressedBlockOutputStream und CompressedBlockInputStream sind dafür zuständig.

Bei der Konzeption dieser Klassen sollten bestimmte Aspekte berücksichtigt werden.

  • Die Kompression sollte für die Anwendung unsichtbar sein. Mit anderen Worten, es sollte möglich sein sie um die Input- und Outputstreams des Socket zu wrappen, anschließend die Methoden read() und write() aufzurufen, ohne sich Gedanken über die Kompression selbst machen zu müssen.
  • Ein Datenblock sollte nach einer festgelegten Zahl von Bytes versendet werden, so dass Speicherprobleme vermieden werden.
  • Die flush() Methode sollte den gepufferten Input umgehend komprimieren und versenden, auch wenn die festgelegte Datenblockgröße noch nicht erreicht wurde.
  • Die erforderlichen Metadaten im Header sollten sich auf ein Minimum beschränken. In unserem Beispiel wird ein 8-Byte großer Kopfbereich verwendet, bei dem die ersten 4 Bytes die Länge des komprimierten Blocks identifizieren und die restlichen 4 Bytes die Größe des Datenblocks.

Um einen Überblick über die Kompressionsrate und Wirkungsweise zu bekommen, können wir die Versendung der Daten über einen Packetsniffer, z.B. Wireshark nachvollziehen. Wireshark ist ein leistungsfähiges Programm zur Analyse von Netzwerk- Kommunikationsverbindungen.

Wir versenden zunächst einen Text mithilfe der Klasse StandardSocket. Wie Sie im Screenshot erkennen können, wurden insgesamt 1120 Bytes versendet. Die Daten werden unkomprimiert im Klartext versendet und sind sehr gut lesbar.

Nun verwenden wir statt der Klasse StandardSocket, die Klasse CompressedSocket, die die Daten vor dem Senden komprimiert. Bereits auf den ersten Blick zeigt sich, dass die Datenmenge beinahe halbiert wurde. Im Datenbereich sind die Daten nun nicht mehr lesbar, da sie im komprimierten Format vorliegen.

Bei der Verwendung von komprimierten Sockets ist zu beachten, dass die Daten auch wieder dekomprimiert werden müssen. Die beiden Applikationen im Netzwerk müssen daher dieselbe Methode verwenden und das Protokoll mit den Metadaten verstehen. Darüberhinaus darf die zu komprimierende Blockgröße nicht zu klein gewählt werden, da sonst der Vorteil der Komprimierung verloren geht. Ein Kompressionsalgorithmus arbeitet bei großen Datenmengen deutlich effizienter, da mehr Muster identifiziert werden können. Die Blockgröße sollte aber auch nicht zu groß gewählt werden, weil sich dies auf die Speicherauslastung auswirkt. Das Programm Battleship verwendet eine Blockgröße von 0xFFFF bzw. 65535 Byte. Die Anwendung stellt neben der Klasse StandardSocket die Klasse CompressedSocket zur Verfügung, die Daten vor dem Senden komprimiert.

public class CompressedSocket implements SocketInterface
{
    /**
     * Creates a stream socket and connects it to the specified port number on
     * the named host.
     *
     * @param host  the host name, or null for the loopback address
     * @param port  the port number
     * @throws java.io.IOException  if an I/O error occurs when creating the socket
     * @throws java.net.UnknownHostException    if the IP address of the host could not be determined
     */
    public CompressedSocket(String host, int port)
            throws IOException, UnknownHostException
    {
        this( new Socket( host, port ) );
    }
 
    /**
     * Encapsulates a normal Java API Socket object.
     *
     * @param socket an already-open socket connection
     * @throws IOException
     */
    public CompressedSocket(Socket socket) throws IOException
    {
        s_ = socket;
        cbos_ = new CompressedBlockOutputStream( s_.getOutputStream(),
                DEFAULT_SYNC_SIZE_GZIP_BYTES );
        cbis_ = new CompressedBlockInputStream( s_.getInputStream() );
    }
 
    /**
     * Creates a stream socket and connects it to the specified port number on
     * the named host.
     *
     * @param address  the IP address
     * @param port  the port number
     * @throws java.io.IOException  if an I/O error occurs when creating the socket
     * @throws java.net.UnknownHostException    if the IP address of the host could not be determined
     */
    public CompressedSocket(InetAddress address, int port)
            throws IOException, UnknownHostException
    {
        this( new Socket( address, port ) );
    }
 
    /**
     * Closes all streams of this socket.
     *
     * @throws java.io.IOException
     */
    @Override
    public void close() throws IOException
    {
        s_.close();
        cbos_.close();
        cbis_.close();
    }
 
    /**
     * Reads the next byte of data from the input stream of this socket.
     *
     * @return  the next byte of data, or -1 if the end of the stream is reached.
     * @throws java.io.IOException
     */
    @Override
    public int read() throws IOException
    {
        return cbis_.read();
    }
 
    /**
     * Reads some number of bytes from the input stream of this socket
     * and stores them into the buffer array b.
     *
     * @param b the buffer into which the data is read.
     * @return  the total number of bytes read into the buffer, or -1 is there
     *          is no more data because the end of the stream has been reached.
     * @throws java.io.IOException
     */
    @Override
    public int read(byte[] b) throws IOException
    {
        return cbis_.read( b, 0, b.length );
    }
 
    /**
     * Writes b.length bytes from the specified byte array to the
     * output stream of this socket.
     *
     * @param b byte buffer to write
     * @throws java.io.IOException
     */
    @Override
    public void write(byte[] b) throws IOException
    {
        cbos_.write( b, 0, b.length );
        // Note that if we don't flush the stream, the last
        // block of data may not be sent across the connection. We
        // could also force the last block to be sent by calling
        // close(), which would close the socket connection.
        cbos_.flush();
    }
 
    /** A compressed output stream */
    private CompressedBlockOutputStream cbos_;
 
    /** A compressed input stream */
    private CompressedBlockInputStream cbis_;
 
    /** The socket object */
    private Socket s_;
 
    /** Default size of a compressed block */
    public static int DEFAULT_SYNC_SIZE_GZIP_BYTES = 65535;
}

Standardmäßig verwendet Battleship die Klasse CompressedSocket. Sie können alternativ auf die gewöhnliche Socketklasse StandardSocket zurückgreifen. In diesem Fall müssen Sie natürlich in Ihrem kompletten Netzwerk diese Klasse verwenden oder eine Protokollvereinbarung über den Sockettyp implementieren.

Die untersten Socketschichten sind mit den beiden Socketklassen und den zugehörigen Fabrikklassen implementiert.

Der Server

Jeder Peer in einem Peer-to-Peer-Netz muss zugleich auch Anfragen aus dem Netz bearbeiten, d.h. er muss als Server laufen um eingehende Verbindungen von Klienten (Peers) entgegen zu nehmen. Bei der Programmierung einer P2P-Netzwerkbibliothek ist der Entwurf der Serverkomponente ein erster Anhaltspunkt. Dazu implementieren wir zunächst einen Service, der im Dauerbetrieb läuft und Verbindungen von anderen Klienten akzeptieren soll. Die Java-Klasse ServerSocket lauscht auf eingehende Verbindungen an einer Portnummer und generiert ggf. eine neue TCP-Verbindung.

Der Service soll in einem separaten Thread in einer Endlosschleife laufen, so dass die Hauptanwendung nicht blockiert wird. Zunächst wird eine abstrakte Klasse Node.java generiert. Diese Klasse stellt die Basisklasse der gesamten P2P-Netzwerkbibliothek dar. Sie repräsentiert einen Knotenpunkt im Netzwerk. In dieser Klasse definieren wir auch unseren Service als innere Klasse, die sich von der Java Thread-Klasse ableitet.

/**
 * Starts the loop which is the primary operation of the Node.
 * The main loop opens a server socket, listens for incoming connections,
 * and dispatches them to registered and threaded handlers appropriately.
 */
public class NodeService extends Thread implements Runnable
{
    public void requestStop()
    {
        stop_ = true;
    }
 
    @Override
    public void run()
    {
        // Creates a thread pool that creates new threads as needed, but
        // will reuse previously constructed threads when they are available.
        final ExecutorService pool = Executors.newCachedThreadPool();
 
        try {
            service_ = true;
            sock = makeServerSocket( myInfo_.getPort() );
            sock.setSoTimeout( SOCKETTIMEOUT );
            while( !stop_ ) {
                try {
                    logger_.finest( "Listening..." );
                    Socket clientsock = sock.accept();
                    clientsock.setSoTimeout( 0 );
 
                    // Execute handler
                    pool.execute( new PeerHandler( clientsock ) );
 
                } catch( SocketTimeoutException e ) {
                    logger_.finer( e.getMessage() );
                    continue;
                } catch( Exception e ) {
                }
            }
            sock.close();
        } catch( SocketException e ) {
            logger_.severe( "SocketException NodeService: " + e );
        } catch( IOException e ) {
            logger_.severe( "IOException NodeService: " + e );
        } finally {
            pool.shutdown();
            service_ = false;   // We are no longer servicing
        }
    }
 
    private volatile boolean stop_ = false;
    private ServerSocket sock;
}

Es ist relativ aufwendig, einen neuen Thread zu konstruieren, da das Betriebssystem beteiligt ist. Wenn unser Programm eine große Anzahl von kurzlebigen Threads erzeugt, sollte es stattdessen einen Thread-Pool verwenden. Ein Thread-Pool enthält eine Anzahl von ausführungsbereiten Leerlauf-Threads. Man gibt Runnable an den Pool und einer der Threads ruft die Methode run auf. Wenn die Methode run endet, stirbt der Thread nicht, sondern bleibt erhalten, um die nächste Anforderung zu bedienen.

Einen Thread-Pool verwendet man auch aus einem anderen Grund: um die Anzahl der konkurrierenden Threads zu drosseln. Wenn eine riesige Anzahl von Threads erzeugt wird, geht die Systemleistung spürbar zurück und die virtuelle Maschine kann sogar abstürzen. Sobald viele Threads generiert werden, sollten Sie auf einen »festen« Thread-Pool zurückgreifen, der die Anzahl der konkurrierenden Threads beschränkt.

Die Klasse Executors besitzt eine Reihe von statischen Fabrikmethoden für die Konstruktion von Thread-Pools. Die Methode newChachedThreadPool in NodeService konstruiert einen Thread-Pool, der jede Task unverzüglich ausführt. Hierfür wird ein Leerlauf-Thread verwendet, falls einer verfügbar ist, andernfalls wird ein neuer Thread generiert.

Der Server läuft in einer Endlosschleife, solange bis mit requestStop() der Thread explizit angehalten wird oder eine SocketException auftritt. Der Thread-Pool wird daraufhin mit pool.shutdown() heruntergefahren.

Wie man anhand des Quelltextes erkennen kann, wird dem Pool die Klasse PeerHandler zusammen mit dem Socketobjekt übergeben. Diese Klasse PeerHandler implementiert Runnable und kümmert sich um einen neuen Klienten.

/**
 * This class is used to respond to and handle incoming connections
 * in a separate thread.
 * 
 * <pre>
 * 
 *      ------
 *     | MAIN |     ---------
 *      ------     | HANDLER |
 *         |        ---------
 *         |            |
 *         |<>-----------
 *         |
 *     ---------
 *    | EXECUTE |
 *     ---------  
 *  
 * </pre>
 */
private class PeerHandler implements Runnable
{   
    public PeerHandler(Socket socket) throws IOException 
    {
        s_ = SocketFactory.getSocketFactory().makeSocket( socket );
    }
 
    @Override
    public void run() 
    {
        logger_.finest( "New PeerHandler: " + s_ );
 
        // Note, there is no peerinfo when receiving data from stream
        PeerConnection peerconn = new PeerConnection( null, s_ );
        Message peermsg = peerconn.recvData();
 
        // Check if the message exists in the handler list
        if( !handlers_.containsKey( peermsg.getType() ) ) {
            logger_.finest( "Not handled: " + peermsg );
        } else {
            logger_.info( "Handling: " + peermsg );
            // Execute handler
            handlers_.get( peermsg.getType() ).handleMessage( peerconn, peermsg );
        }
 
        // NOTE: log message should indicate null peerconnection host
        logger_.finest( "Disconnecting incoming: " + peerconn );
 
        peerconn.close();
    }
 
    private SocketInterface s_;  
}

Zunächst wird im Konstruktor mit der von uns zuvor definierten Fabrikmethode ein neuer Socket generiert. In run finden nun eine Reihe entscheidender Ereignisse statt. Eine wichtige Klasse in der P2P-Bibliothek ist die Klasse PeerConnection. Diese Klasse stellt eine Verbindung zu einem anderen Peer her und verwaltet Sockets. Zwei elementare Methoden aus dieser Klasse sind die Methoden sendData und recvData.

/**
 * Sends a Message to the connected peer.
 * 
 * @param msg the message object to send
 */
public void sendData(Message msg) 
{
    try {
        s_.write( msg.toBytes() );
    } catch( MessageException e ) {
        logger_.warning( "Error sending message: " + e.getMessage() );
    } catch( IOException e ) {
        logger_.warning( "Error sending message: " + e.getMessage() );
    }
}
/**
 * Receives a PeerMessage from the connected peer.
 * 
 * @return the message object received, or null if error
 */
public Message recvData() 
{
    try {
        Message msg = new PeerMessage( s_ );    // Read from socket
        return msg;
    } catch( IOException e ) {
        // It's normal for EOF to occur if there is no more replies coming
        // back from this connection.
        if( e.getMessage().equals( "PeerMessage IOException: Undefined message type." ) ) {
            // Lowest priority
            logger_.finest( "Error receiving message: " + e.getMessage() );
        } else {
            logger_.warning( "Error receiving message: " + e.getMessage() );
        }
        return null;
    }
}

Die Methode recvData liest und konstruiert eine neue P2P-Nachricht von einem Socket. Auf den Entwurf von Nachrichten wird im nächsten Kapitel detailliert eingegangen.

Nachdem die Konstruktion einer neuen Netzwerknachricht abgeschlossen wurde, wird ein Handler aufgerufen. Mithilfe des Nachrichtentyps wird die Nachricht automatisch an die zugehörige Handlerroutine weitergeleitet. Auch zu den Handlern werden wir später mehr erfahren.


Netzwerkprotokolle

Jede nicht triviale Netzwerkbibliothek nutzt Netzwerk- bzw. Anwendungsprotokolle. Sobald Sie mit Sockets arbeiten, müssen Sie in der Regel auch ein höheres Protokoll entwerfen. Ein Netzwerkprotokoll ist eine exakte Vereinbarung, nach der Daten zwischen Computern bzw. Prozessen ausgetauscht werden, die durch ein Netz miteinander verbunden sind. Auch in einem P2P-Netz muss eine Vereinbarung darüber herrschen, wie die gesendeten Daten interpretiert werden sollen, wie Daten kodiert werden, wer welche Daten sendet und wie eine Kommunikation abgebrochen wird.

Das Internet-Protokoll definiert bereits Regeln, wie Pakete innerhalb des Internets versendet und empfangen werden. Aufbauend auf dem IP befindet sich das Transmission Control Protocol (TCP) (zu dt. Übertragungssteuerungsprotokoll). Es befindet sich in der Transportschicht im OSI-Schichtenmodell.

Das TCP/IP-Protokoll transportiert Daten, ohne diese zu verändern oder zu prüfen. Dies stattet Anwendungen mit einer großen Flexibilität aus, wie sie ihre Informationen für die Übertragung kodieren. Viele Anwendungen nutzen Anwendungsprotokolle, die sich aus diskreten Nachrichten, kodiert in sequentiellen Feldern, zusammensetzen. Jedes Feld beinhaltet spezifische Informationen, kodiert als Bitsequenz. An diese Stelle tritt das Anwendungsprotokoll. Es spezifiziert wie die Bitsequenzen formatiert sind, wie sie interpretiert und ggf. geparsed werden müssen, so dass der Empfänger die Nachricht lesen kann.

Das TCP verlangt lediglich das die zu übertragenden Daten Vielfache von 8-Bit sein müssen. Auf Netzwerkebene werden alle Daten in Sequenzen von Bytes übertragen. Ein Byte sind 8-Bit und liegen in einem Bereich zwischen 0 und 255. Unabhängig von der Kodierung werden die Daten stets in Bytes übertragen.

Anwendungen, die einen TCP-Transport verwenden, senden einen fortlaufenden Bytestrom (Stream) über die aufgebaute Datenverbindung und nicht aufeinanderfolgende Einzelnachrichten. Nachrichtengrenzen werden daher auch bei einer Ende-zu-Ende-Verbindung nicht bewahrt.

Ein Problem in TCP ist, das TCP keine Nachrichtengrenzen kennt. Das Framing beschreibt das Problem des Empfängers, den Beginn und das Ende der diskreten Nachrichten innerhalb des Streams im TCP-Buffer zu lokalisieren. Ob die Information als Text in ASCII kodiert ist, als Multibyte in Unicode oder in einer Kombination aus beidem - das Anwendungsprotokoll muss den Empfänger der Nachricht in die Lage versetzen zu entscheiden wann die komplette Nachricht empfangen wurde und wie diese zurück in ihre Felder geparsed werden kann.

TCP-Buffer

Falls die Felder innerhalb einer Nachricht stets eine feste Größe haben und die Nachricht ebenfalls immer aus einer festgelegten Anzahl von Feldern besteht, dann ist die Größe der Nachricht bekannt und der Empfänger muss nur noch die erwartete Zahl an Bytes in den byte[] Puffer laden. Sobald aber die Nachrichtengröße variiert, muss die Nachricht Framing-Informationen beinhalten. Es gibt in der Regel zwei unterschiedliche Ansätze dies zu bewerkstelligen.

  • Delimiter: Das Ende der Nachricht, mit variabler Länge, wird durch eine einzigartige Zeichenfolge oder ein einzigartiges Trennzeichen beschrieben.
  • Explizite Länge: Die Länge der Nachricht wird zu Beginn der Transmission in einer zuvor definierten Weise übermittelt.

Der Ansatz mithilfe eines Delimiters wird oft bei textbasierten Nachrichten gewählt. In dem Bild oben, wurde ein mit ASCII kodierter Text versendet. Als Delimiter wurde ein einzelnes Zeichen, nämlich die Raute gewählt. Sobald komplexe Datenströme übermittelt werden, ist der Ansatz mit einem Delimiter allerdings ineffizient und unzureichend. Wir werden in unserem Protokoll deshalb den zweiten Ansatz wählen. Dazu wird zunächst eine sogenannte Message (zu dt. Nachricht) entworfen.

Nachrichten

Netzwerkbibliotheken müssen Nachrichten übertragen können. Viele Anwendungen, wie der Bittorrent Client Azureus oder ICQ verwenden Nachrichten. Das Instant-Messaging-Programm ICQ basiert auf einem komplexen Anwendungsprotokoll, namens OSCAR. Das OSCAR-Protokoll ist sehr umfassend und wurde über Jahre hinweg entwickelt. Die offizielle Dokumentation des OSCAR-Protokolls ist auf http://dev.aol.com/aim/oscar/ zu finden. Falls Sie an komplexen Anwendungsprotokollen von großen Programmen interessiert sind, können Sie sich dort mit dem Aufbau eines solchen Protokolls vertraut machen.

Auch das OSCAR-Protokoll definiert Nachrichten und Nachrichtentypen. So definiert beispielsweise die Konstante MTYPE_FILEREQ im Feld 0x03 einen File Request in ICQ. Die hier vorgestellte Netzwerkbibliothek wird ebenfalls auf Nachrichten aufbauen. Das konkrete Anwendungsprotokoll soll zu einem späteren Zeitpunkt definiert werden können. Zunächst muss ein Peer im Netzwerk in die Lage versetzt werden Nachrichten zu senden und wieder zu empfangen. Wir definieren zuerst ein Interface, das den Namen Message trägt und alle Methoden definiert, die eine reale Nachricht in unserem Netzwerk bereitstellen muss.

public interface Message
{   
    /**
     * Get message type.
     *
     * @return type the message type
     */
    String getType();
 
    /**
     * Get textual data of this particular message.
     *
     * @return  the message data
     */
    String getData();
 
    /**
     * Returns a list with tokens. If there are more messages in the message
     * data buffer, this method will tokenize the buffer and return the
     * tokens in a list.
     *
     * @return  a list of byte[] tokens
     */
    List<byte[]> getByteTokens();
 
    /**
     * Allows to clone an object.
     *
     * @return  an object copy
     * @throws java.lang.CloneNotSupportedException
     */
    Object clone() throws CloneNotSupportedException;
 
    /**
     * Returns the total number of data tokens stored in the data buffer.
     *
     * @return  num of tokens
     */
    int countTokens();
 
    /**
     * Returns a packed representation of this message as an
     * array of bytes.
     *
     * @return byte array of message data
     */
    byte[] toBytes() throws MessageException;
 
    /**
     * Returns a list with String tokens from the message data buffer.
     *
     * @return  the message data tokens
     */
    List<String> getStringTokens();
 
    /**
     * Returns the bytes of the message data.
     *
     * @return the message data bytes
     */
    byte[] getDataBytes();
 
    /**
     * Returns the message type bytes.
     *
     * @return the message type (4-byte array)
     */
    byte[] getTypeBytes();
 
    /**
     * General delimiter for all messages. Do not choose a regex metacharacter.
     *
     * @return the delimiter
     */
    String getDelimiter();
 
    /**
     * Overrides toString().
     *
     * @return  the string
     */
    @Override
    String toString();
 
    /**
     * Gets the protocol payload of this message in bytes.
     *
     * @return  total protocol payload
     */
    int getProtocolPayload();
 
    /**
     * Gets the data payload of this message in bytes.
     *
     * @return  total data payload
     */
    int getDataPayload();  
}

Sie können an den ersten beiden Methoden im Interface zwei wichtige Funktionen erkennen, die eine Nachricht besitzen muss. Eine Nachricht im Battleship-Netzwerk besteht aus einem Typ und aus Daten. Gleichzeitig muss eine Nachricht die Methode toBytes implementieren, so dass die Nachricht als Bytefolge versendet werden kann.

Die konkrete Nachricht hat den folgenden Aufbau in unserem System.

0 15 31 47 63
Type Length
Data

Der Kopfbereich (Header) besteht aus 64-Bit. Die ersten 32-Bit beschreiben den Nachrichtentyp. Die nächsten 32-Bit stehen für die Nachrichtenlänge. Darauf folgen die eigentlichen Daten der Nachricht. Sie können theoretisch eine Länge von 32-Bit haben. Das entspricht einem Integer in Java. Eine Nachricht kann also maximal 4 Gigabyte an Daten fassen.

Die Implementierung einer konkreten Nachrichtenklasse gestaltet sich sehr einfach, aufbauend auf dem zuvor definierten Interface. Die Klasse PeerMessage implementiert das Interface Message und stellt alle Methoden bereit, die für das Versenden von Nachrichten erforderlich sind.

public final class PeerMessage implements Message, Cloneable
{
    /**
     * Constructs a new PeerMessage object.
     *
     * @param type the message type (4 ascii characters)
     * @param pid  the peer identification of the sender
     * @param data the message data
     */
    public PeerMessage(String type, String pid, byte[] data)
    {
        this( type.getBytes(), pid, data );
    }
 
 
    /**
     * Constructs a new PeerMessage object by reading data
     * from the given socket connection. Automatically decodes the
     * message data.
     * 
     * @param s a socket connection object
     * @throws IOException if I/O error occurs
     */
    public PeerMessage(SocketInterface s) throws IOException 
    {
        type_ = new byte[4];
        byte[] length = new byte[4];    // for reading length of message data
 
        // Check if it is a valid message type
        if( s.read( type_ ) != 4 )
            throw new IOException( "PeerMessage IOException: Undefined message type."  );
 
        // Check if we now can read 4 data bytes, holding the *size* 
        // of the data in the message
        if( s.read( length ) != 4 )
            throw new IOException( "PeerMessage IOException: Undefined data length." );
 
        // Convert the 4 bytes to an integer
        int size = Bytes.byteArrayToInt( length );
 
        // Reserve space for the message
        data_ = new byte[size];
 
        // Read it...
        if( s.read( data_ ) != size )
            throw new IOException( "PeerMessage IOException: Unexpected message data length." );
    }  
 
    /** 
     * Returns the message type as a String.
     * 
     * @return the message type (4-character String)
     */
    @Override
    public String getType()
    {
        return new String( type_ );
    }    
 
    /**
     * Returns a copy of the message type buffer.
     * 
     * @return the message type (4-byte array)
     */
    @Override
    public byte[] getTypeBytes()
    {
        return (byte[])type_.clone();
    }
 
    /**
     * <p>Retrieves the <b>maximum allowed size</b> of a message. This is necessary
     * if a block compressing algorithm is used at the socket layer,
     * transmitting a PeerMessage by wire.</p>
     *
     * <p>Since a message is an atomic object, the message is not allowed to be
     * bigger than the compressed block size. Otherwise the message length
     * would not match the received length, causing an invalid message
     * length exception.</p>
     *
     * @return  maximum message size
     */
    public static int getMaxSize()
    {
        return CompressedSocket.DEFAULT_SYNC_SIZE_GZIP_BYTES;
    }
 
    /** The type buffer (header composed of a 4-byte identifier) */
    private byte[] type_;
 
    /** The data buffer */
    private byte[] data_;
 
    // Rest siehe Quellcode...
}

Die Klasse hat diverse Konstruktoren, einige sind im Code oben gelistet. Ein wichtiger Konstruktor nimmt als Argument das SocketInterface entgegen. Aus dem Socket wird anschließend mit read die Nachricht geparsed. Zunächst werden die ersten 4-Byte eingelesen, dann die zweiten 4-Byte. Anschließend wird die in Big-Endian vorliegende Länge mithilfe der statischen Methode byteArrayToInt aus der Klasse Bytes in einen Integer transformiert, so dass anschließend n Bytes aus dem restlichen Stream gelesen werden können. Die Nachricht wurde erfolgreich aus dem TCP-Buffer konstruiert.

Handler

Bei der Implementierung des Servers wurde eine innere Klasse mit dem Namen PeerHandler definiert. Die Klasse PeerHandler hat Nachrichten in Abhängigkeit des Nachrichtentyps an bestimmte Handler weitergeleitet. Bei einem Handler handelt es sich um eine Aufrufroutine, bei der Nachrichten automatisch an die richtigen Methoden weitergeleitet werden.

In der Basisklasse Node, in welcher auch die Klasse PeerHandler implementiert ist, wurde für die Verwaltung der Handler eine spezielle HashMap verwendet.

    /** 
     * A hash table supporting full concurrency containing key-value pairs
     * of unique commands and handlers
     */
    private final ConcurrentMap<String, MessageHandlerInterface> handlers_ =
            new ConcurrentHashMap<String, MessageHandlerInterface>();

Bei der ConcurrentHashMap handelt es sich um eine Thread-sichere Collection. Diese besonderen Collections sind im Package java.util.concurrent versammelt. In Multi-Threaded Applikationen ist es von entscheidender Bedeutung Zugriffe von Threads zu koordinieren. Geschieht das nicht, erhält man früher oder später mit Sicherheit eine ConcurrentModificationException sobald mehr als ein Thread versucht auf die Collection schreibend zuzugreifen. Im schlimmsten Fall erhalten Sie keine ConcurrentModificationException und Ihre Anwendung produziert mysteriöse Fehler.

Die ausgeklügelten Algorithmen dieser speziellen Collections blockieren niemals die gesamte Tabelle und minimieren Konkurrenzsituationen, indem sie gleichzeitigen Zugriff auf verschiedene Teile der Datenstruktur zulassen. Da unser Server mithilfe von Thread-Pools automatisch beliebig viele neue Threads produziert, muss sichergestellt sein, dass sich diese Threads nicht in die Quere kommen. Die ConcurrentHashMap hat nützliche Methoden für atomares Einfügen und Entfernen von Zuordnungen.

Der PeerHandler ruft je nach Typ der Nachricht das MessageHandlerInterface auf. Dieses Interface hat nur eine einzige Methode, die von allen Handlern implementiert werden muss, nämlich handleMessage. Die Methode nimmt die aktuelle Verbindung, sowie die Nachricht entgegen.

public interface MessageHandlerInterface
{
    /**
     * Invoked when a peer connects and sends a message to this node.
     * 
     * @param peerconn  the peer connection
     * @param msg   the message
     */
    public void handleMessage(PeerConnection peerconn, Message msg);
}

In der Klasse Node sind weitere wichtige Methoden und innere Klassen definiert. Neben einer Stabilisierungsroutine, die kontinuierlich Teilnehmer im Netzwerk anpingt und bei gescheiterter Verbindung aus der eigenen Peerliste entfernt, sind auch Methoden zum Versenden von Daten in dieser abstrakten Klasse implementiert. Die Klasse verfügt über eine Startmethode, namens start. Diese Methode startet die wesentlichen Threads der Klasse Node und muss nach der Instanzierung der Subklasse aufgerufen werden.

Die Klasse Node ist bekanntlich abstrakt. Sie erweitert, wie das Model, die Java-Klasse Observable. Von Node leiten wir nun eine konkrete Klasse für unsere Anwendung ab. Diese Klasse nennen wir BattleshipNode.

public final class BattleshipNode extends Node
{
    /**
     * Constructs a new battleship node.
     * 
     * @param maxPeers  the max. allowed number of peers
     * @param myInfo    the own peer info (host, port)
     */
    public BattleshipNode(int maxPeers, PeerInfo myInfo) 
    {
        super( myInfo );
 
        maxPeers_ = maxPeers;
 
        addRouter( new SimpleRouter( this ) );
 
        // Add protocol handlers to the message types
        addHandler( MessageType.ERROR.toString(), new ErrorHandler( this ) );
        addHandler( MessageType.SHOT.toString(), new ShotHandler( this ) );
        addHandler( MessageType.JOIN.toString(), new JoinHandler( this ) );
        addHandler( MessageType.PEERINFO.toString(), new PeerInfoHandler( this ) );
        addHandler( MessageType.LISTPEERS.toString(), new ListPeersHandler( this ) );
        addHandler( MessageType.TEXTMESSAGE.toString(), new TextMessageHandler( this ) );
        addHandler( MessageType.NEWGAME.toString(), new NewGameHandler( this ) );
        addHandler( MessageType.FLEET.toString(), new FleetHandler( this ) );
        addHandler( MessageType.NEWGAMEACCEPT.toString(), new NewGameAcceptedHandler( this ) );
        addHandler( MessageType.STYLEDDOCUMENT.toString(), new StyledDocumentHandler( this ) );
        addHandler( MessageType.QUIT.toString(), new QuitHandler( this ) );
    }
 
    /**
     * <p>This enum handles the common 4-byte message types, used in the
     * peer-to-peer protocol. This types can be viewed as a string. Each type
     * identifies the various messages exchanged in the system. When the 
     * NodeService of a peer receives a message, it dispatches it to the
     * appropriate handler based on the message type.</p>
     */    
    public enum MessageType
    {
        /** Indicates an error message */
        ERROR             ("ERRO"),
        /** A fleet message with information about all ships */
        FLEET             ("FLET"),        
        /** Indicates a welcome hello packet */
        HELLO             ("HELO"),
        /** Indicates a joining peer */
        JOIN              ("JOIN"),
        /** Indicates the peer listing */
        LISTPEERS         ("LIST"),
        /** Indicates that a peer wants to start a new game with us */
        NEWGAME           ("NEWG"),      
        /** Indicates that we accept a new game */
        NEWGAMEACCEPT     ("NWGA"),         
        /** Indicates a peer info */
        PEERINFO          ("PIFO"),
        /** Indicates that this peer wants to quit */
        QUIT              ("QUIT"),
        /** Indicates a reply message */
        REPLY             ("REPL"),
        /** Indicates a shot message */
        SHOT              ("SHOT"),
        /** Indicates a serialized styled document */
        STYLEDDOCUMENT    ("SDOC"),
        /** Indicates a simple text message */
        TEXTMESSAGE       ("TMSG");
 
        MessageType(String type) 
        {
            if( type.length() == 4 ) {
                type_ = type;
            } else {
                type_ = "####";
            }
        }
 
        @Override
        public String toString()
        { 
            return type_; 
        }
 
        /** The message type */
        private final String type_;
    }
 
    // ...

Die Klasse BattleshipNode implementiert alle wichtigen Handler und definiert auch alle Nachrichtentypen, die von unserem Anwendungsprotokoll gebraucht werden. Mithilfe einer Enumeration namens MessageType werden die jeweiligen Typen definiert. Es handelt sich entsprechend der Vereinbarung um 4-Byte große Strings. Die Enumeration kann beliebig um weitere Nachrichtentypen erweitert werden.

Im Konstruktor der Klasse BattleshipNode werden für alle Nachrichtentypen die Handler definiert. Ein Handler wird als innere Klasse von BattleshipNode definiert. Eingehende Nachrichten werden auf diese Weise, abhängig vom Typ, direkt an die jeweilige Handlerroutine weitergeleitet. An dieser Stelle können Sie einen Blick auf einen Handler werfen. Dieser wird aufgerufen, sobald eine Nachricht vom Typ »TEXTMESSAGE« empfangen wird.

/**
 * Handles simple text messages. If a peer sends a message
 * to us, this handler will be called.
 * 
 * Message syntax: TYPE pid message
 */
private class TextMessageHandler implements MessageHandlerInterface
{     
    public TextMessageHandler(Node peer)
    { 
        peer_ = peer; 
    }
 
    @Override
    public void handleMessage(PeerConnection peerconn, Message msg) 
    {
        // Check for correct number of arguments 
        List<String> data = msg.getStringTokens();
 
        if( data.size() != 2 ) {
            Message reply = new PeerMessage( MessageType.ERROR.toString(), peer_.getMyInfo().getId(),
                    msg.getType() + ": " + "Incorrect arguments." );
            peerconn.sendData( reply );
            return;
        }            
 
        // Put the message in the queue
        addMessage( msg );
    }
 
    private Node peer_;
}

Sie können bereits jetzt erkennen, dass das System sehr flexibel konzipiert ist. Die Netzwerkbibliothek erlaubt es beliebige Nachrichtentypen zu definieren, so dass ein ganz individuelles Anwendungsprotokoll entworfen werden kann.

Peers

Die Klasse BattleshipNode implementiert einige Methoden der abstrakten Superklasse Node. Neben den Handlern verwaltet die Klasse auch eine Tabelle mit den Peers. Alle Netzwerkteilnehmer werden anhand ihrer IP-Adresse und Portnummer identifiziert. Nimmt ein neuer Netzwerkteilnehmer Kontakt mit uns auf, wird seine Adresse in einer gewöhnlichen Hashtabelle gespeichert.

In P2P-Systemen kommen oft sogenannte verteilte Hashtabellen (engl. distributed hash table) zum Einsatz. Es handelt sich dabei um eine Datenstruktur, die versucht, das allgemeine Problem in P2P-Systemen – das Finden des Speicherorts einer gesuchten Datei – mit möglichst geringem Aufwand effizient zu lösen und zu dezentralisieren, um es von Ausfall, zum Beispiel durch Abschalten eines Trackers unabhängig zu gestalten. Kademlia ist ein bekanntes Beispiel für ein Protokoll das auf einer DHT basiert.

Die Datenobjekte sollen in einer DHT möglichst gleichmäßig über die Knotenmenge verteilt sein und ein von jedem beliebigen Einstiegsort ortsunabhängiges Routing zum verantwortlichen Knoten ermöglichen. Jeder Knoten ist dabei analog zu einem Behälter einer Hashtabelle. Die Datenstruktur muss ständige Anpassungen durch Ausfall, Beitritt und Austritt von Knoten überstehen, sich selbst organisieren und skalierbar sein. Die Grundlage für verteilte Hashtabellen bilden konsistente Hash-Funktionen, die für alle gesuchten Werte bzw. Dateien in einem P2P-System einen eindeutigen Hash abbilden.

DHT

Für die Verwaltung der Peers und der empfangenen Nachrichten in Battleship, werden zwei Thread-sichere Collections benötigt. Während die ConcurrentHashMap alle Peerdaten speichert, werden Nachrichten in einer ConcurrentLinkedQueue hinterlegt. Es handelt sich dabei um eine Warteschlange, die auf dem FIFO-Prinzip beruht.

/**
 * A hash table supporting full concurrency containing key-value
 * pairs of unique id's and the PeerInfo's.
 */
private final ConcurrentMap<String, PeerInfo> peers_= new ConcurrentHashMap<String, PeerInfo>();
 
/**
 * An efficient scalable thread-safe non-blocking FIFO queue. This
 * queue will hold all messages send by other p2p network members.
 */
private final ConcurrentLinkedQueue<Message> queue_ = new ConcurrentLinkedQueue<Message>();

Alle Peers im Netzwerk werden in der speziellen HashMap gespeichert. Der Schüssel ist eine eindeutige ID, der Wert ein Objekt vom Typ PeerInfo. Die Klasse PeerInfo speichert relevante Daten von einem Peer. Dazu zählt neben der IP-Adresse auch die Portnummer und der Name. In der Klasse sind weitere Felder definiert. Auch diese Klasse kann bei Bedarf erweitert werden. In BattleshipNode sind entsprechende Methoden implementiert, die es der Anwendung gestatten Peers in das eigene Netzwerk aufzunehmen. Eine Methode trägt den Namen addPeer.

/**
 * Add new peer information to the peer list, indexed by the given
 * key.
 *
 * @param key the key associated with the peer
 * @param peerInfo the peer information
 * @return true if successful; false if maxPeers is reached, or if the
 * peer list already contains the given key, or if peer is corrupt.
 */
@Override
public boolean addPeer(String key, PeerInfo peerInfo)
{
    // Before we add a new peer, sanitize variables. Call the class check.
    if( !peerInfo.sanitize() ) return false;
 
    if( (maxPeers_ == 0 || peers_.size() < maxPeers_) && !peers_.containsKey( key ) ) {
        peers_.put( key, peerInfo );
        setChanged();   // New peers, tell observers
        notifyObservers();
        return true;
    }
 
    return false;
}

Die Methode fügt einen neuen Teilnehmer in die Map ein. Sie können hier erstmals zwei Methoden der Klasse Observable erkennen. Sobald ein neuer Peer in die Liste eingetragen wurde, werden alle Beobachter der Klasse davon unterrichtet. Dies erreicht man durch den Aufruf von setChanged und dem anschließenden Aufruf von notifyObservers.

Neben den Peers, stehen auch etliche Methoden für den Zugriff auf die empfangenen Nachrichten zur Verfügung. Die Methode getMsgOfType ist eine davon. Sie liefert die neueste Nachricht eines bestimmten Typs zurück. Bei Bedarf wird die Nachricht aus der Warteschlange endgültig entfernt.

/**
 * Gets the first message of a given type from the message queue. Removes
 * this message from the queue, if <code>remove</code> is true.
 *
 * @param mt    the message type
 * @param remove    if true, the message will be removed from the queue
 * @return  the last message of the given type; null if no such message
 */
public Message getMsgOfType(MessageType mt, boolean remove)
{
    Message message = null;
 
    for( Message msg : queue_ ) {
        if( msg.getType().equals( mt.toString() ) ) {
            message = msg;
            if( remove ) {
                queue_.remove( msg );   // Iterator.remove is safe
            }
            break;
        }
    }
 
    return message;
}

Routing

Routing bezeichnet in der Telekommunikation das Festlegen von Wegen für Nachrichtenströme bei der Nachrichtenübermittlung über vermaschte Nachrichtennetze bzw. Rechnernetze. Die Routing-Protokolle sorgen für den Austausch von Routing-Informationen zwischen den Netzen und erlauben es den Routern, ihre Routing-Tabellen dynamisch aufzubauen.

Auch Battleship erlaubt es Ihnen ein eigenes Routing zu implementieren, beispielsweise unter Nutzung der bereits erwähnten verteilten Hashtabelle. In unserer Netzwerkbibliothek wird als Einstieg eine sehr einfache Routerklasse mit dem Namen SimpleRouter definiert.

public class SimpleRouter implements RouterInterface
{
    /**
     * The constructor.
     *
     * @param peer
     */
    public SimpleRouter(Node peer)
    {
        peer_ = peer;
    }
 
    /**
     * Returns the peer info object of a given peer id.
     * 
     * @param peerid    a peer id
     * @return  returns a PeerInfo object or null if unknown
     */
    @Override
    public PeerInfo route(String peerid) 
    {
        if( peer_.getPeerKeys().contains( peerid ) ) {
            return peer_.getPeer( peerid );
        } else {
            return null;
        }
    }
 
    /** The peer node */
    private Node peer_;
}

Der SimpleRouter leitet Anfragen direkt an den aus der Hashtabelle ermittelten Peer weiter. Hierzu werden keine nennenswerten intelligenten Algorithmen verwendet. Das System kann allerdings zu einem beliebigen Zeitpunkt in der Zukunft um eine neue Routerklasse ergänzt werden.

/**
 * This method attempts to route and send a message to the specified peer,
 * optionally waiting and returning any replies. It uses the Node's routing function
 * to decide the next immediate peer to actually send the message to, based on
 * the peer identifier of the final destination. If no router function (object)
 * has been registered, it will not work.
 *
 * @param pid   the destination peer identifier
 * @param msg   the message
 * @param waitReply whether to wait for reply(ies)
 * @return list of replies (may be empty if error occurred)
 */
public List<Message> sendToPeer(String pid, Message msg, boolean waitReply)
{
    PeerInfo peerInfo = null;
 
    if( getRouter() != null ) {
        peerInfo = getRouter().route( pid );
    } else {
        logger_.severe( String.format( "Unable to route %s to %s", msg.getType(), pid ) );
        return new ArrayList<Message>();
    }
 
    return connectAndSend( peerInfo, msg, waitReply );
}

Man unterscheidet zwischen vier wesentlichen Routingschemen.

  • Unicast liefert eine Nachricht an einen spezifizierten Knoten;
  • Broadcast liefert eine Nachricht an alle Knoten im Netzwerk;
  • Multicast liefert eine Nachricht an eine bestimmte Gruppe von Knoten im Netzwerk, die an dieser Nachricht interessiert sind;
  • Anycast liefert eine Nachricht an jeden Knoten innerhalb einer Gruppe von Knoten, typischerweise den an der Quelle nächstgelegenen.

Highslide JS Highslide JS Highslide JS
Highslide JS Highslide JS

Kleine Netzwerke verwenden oft manuell konfigurierte Routingtabellen (Static Routing), während größere Netzwerke komplexe Topologien abbilden und sich schnell wandeln, was eine manuelle Anpassung der Routingtabellen erschwert. Battleship verwendet mit dem aktuellen SimpleRouter Unicast um Nachrichten an Knoten im Netzwerk auszuliefern.

Bei allen Verbindungen handelt es sich um bidirektionale, byte-orientierte, zuverlässige Datenströme zwischen zwei Endpunkten. Die Netzwerkbibliothek basiert vollständig auf dem Transmission Control Protocol (TCP) und verzichtet auf das UDP. In TCP sind Multicast und Broadcast auf Internet-Protokoll-Ebene nicht möglich, da es es sich bei TCP stets um eine verbindungsorientierte Punkt-zu-Punkt-Verbindung (Unicast) handelt. Im Anwendungsprotokoll lassen sich allerdings Mechanismen implementieren, die eine effizientere Ausnutzung der individuellen Bandbreite gestatten. Das übergeordnete Protokoll definiert Routingschemen, z.B. baumbasierte Multicast-Algorithmen, die Nachrichten gezielt an Gruppen ausliefern und diese effizient über das P2P-Netz routen. Schlußendlich ist es auch möglich die Bibliothek zu erweitern, so dass UDP-Pakete gesendet und empfangen werden können.

Es ist nur UDP-Sockets gestattet Broadcast und Multicast zu nutzen. Da die herkömmlichen, im Internet eingesetzten Router in aller Regel nicht Broadcast- und Multicast-fähig sind, bleiben Multicast-fähige Teilnetze im Internet isoliert und bilden so genannte Multicasting-Inseln. Diese Inseln können mittels Tunneling, also durch Kapseln eines Multicast-Pakets in ein Unicast-Paket, überbrückt werden und bilden so ein weltumspannendes Multicast-Netz, den MBONE.

Bootstrapping

In einer P2P-Anwendung muss ein neuer Peer zunächst die Adresse von einem zweiten Peer kennen, der bereits mit dem Netzwerk verbunden ist. Ein Netzwerk ist vergleichbar mit einem Spinnennetz. Viele Peers bilden ein gemeinsames Netzwerk. Netze können auch fragmentieren, so dass sich Subnetze bilden. Dies kann gewollt oder nicht gewollt sein.

Bevor ein Peer online gehen kann und zu einem Bestandteil eines bestehenden Netzwerks wird, muss das sogenannte „Bootstrapping“ erfolgen. Unter „Bootstrapping“ versteht man den Vorgang, den ein Knoten durchläuft, wenn er sich in ein bestehendes Netzwerk einklinkt. Es bezeichnet in der Informatik einen Prozess, der aus einem einfachen System ein komplizierteres System aktiviert. Für diesen Prozess wird eine Startadresse benötigt, ähnlich wie Sie bei einer Telefonkonferenz die Telefonnummer von mindestens einem weiteren Teilnehmer kennen müssen.

Bekannte P2P-Anwendungen, wie eMule, nutzen spezielle Techniken, wie Kademlia. Dabei handelt es sich um eine besondere Technik für Peer-to-Peer-Netze, welche eine verteilte Hashtabelle implementiert. Kademlia legt Art und Aufbau des Netzes fest. Auch in Kademlia muss ein Knoten, der dem Netz beitreten möchte, zuerst den mit „Bootstrapping“ benannten Prozess durchlaufen: In dieser Phase erhält der Algorithmus von einem Server oder Benutzer im Netzwerk die IP-Adresse einiger Knoten, die bereits im Kademlia-Netz bekannt sind. Von diesen ersten Knoten erhält er nun auch die IP-Adressen weiterer Knoten, so dass keine Abhängigkeit mehr von einzelnen Knoten besteht.

Viele Anwendungen, wie auch ICQ, nutzen zentrale Server mit einer statischen IP-Adresse, um das Problem zu umgehen. Der Benutzer muss sich nicht weiter darum kümmern und betätigt lediglich einen Connect-Button. Alle Netzwerkteilnehmer registrieren sich bei diesem zentralen Server, so dass Adressen problemlos ausgetauscht werden können.

Die Klasse BattleshipNode stellt eine spezielle Methode für die Konstruktion eine Netzwerks beim „Bootstrapping“ zur Verfügung. Diese Methode baut in einem rekursiven Aufruf das vollständige Netzwerk für den Peer auf, sobald einer erste Verbindung mit einem Netzwerkteilnehmer etabliert wurde.

/**
 * Builds the p2p network for this node bootstrapping the new peer,
 * recursively.
 * 
 * @param host  the host
 * @param port  the port number
 * @param hops  number of hops
 * @return  returns false if peer couldn't be added
 */
public boolean buildNetwork(String host, int port, int hops)
{
    logger_.fine( "Building network." );
 
    if( maxPeersReached() || hops <= 0 )
        return false;
 
    PeerInfo peerInfo = new PeerInfo( host, port );
 
    // Send a PEERINFO message to get detailled peer infos, wait for reply
    Message msg = new PeerMessage( MessageType.PEERINFO.toString(), getMyInfo().getId(), "" );
    List<Message> resplist = connectAndSend( peerInfo, msg, true );
 
    // No reply, return
    if( resplist == null || resplist.size() == 0 )
        return false;
 
    // We have the peer info data, proceed...
    List<String> infos = resplist.get( 0 ).getStringTokens();
 
    // Abort recursive search, when the pid is already known
    if( infos.size() != 6  || getPeerKeys().contains( infos.get( 0 ) ) )
        return false;
 
    logger_.fine( "Contacted " + infos.get( 0 ) );
 
    // Now parse received infos into our existing PeerInfo object
    peerInfo.setId( infos.get( 0 ) );
    // infos.get( 1 ), infos.get( 2 ) ... already stored
    peerInfo.setName( infos.get( 3 ) );
    peerInfo.setGender( infos.get( 4 ) );
    peerInfo.setBirthday( infos.get( 5 ) );
 
    // We try to join to the peer, too.
    msg = new PeerMessage( MessageType.JOIN.toString(), getMyInfo().getId(),
        new String[] { getMyInfo().getHost(),
                       Integer.toString( getMyInfo().getPort() ),
                       getMyInfo().getName(),
                       getMyInfo().getGender(),
                       getMyInfo().getBirthday() } );
 
    // Connect and send stuff
    String resp = connectAndSend( peerInfo, msg, true ).get( 0 ).getType();
 
    // If no answer, joining not possible
    if( !resp.equals( MessageType.REPLY.toString() ) )
        return false;        
 
    addPeer( peerInfo );    // Add the peer to our list
 
    // Check the web knowlegde of the remote peer, ask for his list
    resplist = connectAndSend( peerInfo, new PeerMessage( MessageType.LISTPEERS.toString(), getMyInfo().getId(), "" ), true );
 
    // Do a recursive depth call, first search to add more peers
    if( resplist.size() > 1 ) {
        resplist.remove( 0 );   // Number of peers, remove this
        for( Message pm : resplist ) {
            try {
                List<String> data = pm.getStringTokens();
                String nextpid = data.get( 1 ); // 0 is the senders id
                String nexthost = data.get( 2 );
                int nextport = Integer.parseInt( data.get( 3 ) );
                if( !nextpid.equals( getMyInfo().getId() ) ) {  // Not our pid
                    buildNetwork( nexthost, nextport, hops - 1 );
                }
            } catch( NumberFormatException e ) {
                logger_.info( "Can't parse port number! " + e.getMessage() );
            }
        }
    }
 
    return true;
}

Mithilfe der ersten Adresse wird der entsprechende Peer kontaktiert. Das System stellt diverse Handler und Nachrichtentypen, wie z.B. »JOIN« bereit, um den Austausch von Kontaktdaten zu erleichtern. Sobald ein Knoten mit einem anderen Knoten Kontakt aufgenommen hat, tauschen beide Knoten Informationen untereinander aus. So werden Kontaktadressen zu weiteren Knoten übermittelt. In einem rekursiven Aufruf werden diese neuen Adressen anschließend kontaktiert. So kann überprüft werden ob die Adressangaben korrekt sind. Darüberhinaus können spezifische Informationen von den Peers abgefragt werden.

Die Methode buildNetwork arbeitet sehr effektiv und konstruiert das vollständige Netzwerk. In der Regel wird diese Methode nur zu Beginn, beim „Bootstrapping“ aufgerufen.

Mit der Klasse BattleshipNode schließt sich der Kreis der Netzwerkprogrammierung in diesem Tutorial. Im Anhang finden Sie weitere Quelldateien, die zu dieser P2P-Bibliothek gehören, hier aber nicht angesprochen wurden. Sie können die entworfene Bibliothek problemlos in ihren eigenen Anwendungen weiterverwenden und neue Handler und Nachrichtentypen für ihr eigenes Anwendungsprotokoll definieren. So lassen sich auch Dateien übertragen oder Topologieinformationen austauschen. Den Möglichkeiten sind kaum Grenzen gesetzt. Wir beenden das umfangreiche Kapitel mit einem Diagramm, welches den prinzipiellen Aufbau und die Funktionsweise der Bibliothek demonstriert.

p2p-diagram.png
Abbildung 2: Interaktion zwischen zwei Peers

Das Model vervollständigen

Nachdem mit der Netzwerkkomponente ein wichtiger Bestandteil der Geschäftslogik implementiert wurde, ist es an der Zeit das Model zu vervollständigen. Das Model stellt eine Methode zur Generierung der Identifikationsnummer bereit. Diese Nummer wird an Spieler vergeben, die anschließend in der Lage sind ihre eigenen Daten im Model mithilfe dieser individuellen ID abzufragen.

/**
 * This is a utility function of the model. It generates an unique id
 * for all players. The id's are of the following format:
 * 
 * <code>object_classname:randombytes</code>
 *
 * @param object    the class object
 * @return          the unique id
 */
@Override
public String generateUniqueId(Object object)
{
    // Lets first generate a random string
    Random random = new Random();
    StringBuilder sb = new StringBuilder();
    String str = new String("QAa0bcLdUK2eHfJgTP8XhiFj61DOklNm9nBoI5pGqYVrs3CtSuMZvwWx4yE7zR");
    for( int pos = 0, i = 0; i < 100; i++ ) {
        pos = random.nextInt( str.length() );
        sb.append( str.charAt( pos ) );
    }
 
    String seed = new Date().getTime() + sb.toString();
 
    StringBuilder hexString = new StringBuilder();
    int numberToGenerate = 64;  // Total random bytes to generate
 
    try {
        // Generate pseudo-numbers using SHA1PRNG algorithm
        SecureRandom rng = SecureRandom.getInstance( "SHA1PRNG" );
        rng.setSeed( seed.getBytes() );
 
        byte randNumbers[] = new byte[numberToGenerate];
 
        rng.nextBytes( randNumbers );
 
        // Convert to hex
        for( int i = 0; i < randNumbers.length; i++ ) {
            String hex = Integer.toHexString( 0xFF & randNumbers[i] );
            if( hex.length() == 1 )
                hexString.append('0');
 
            hexString.append( hex );
        }
    } catch( NoSuchAlgorithmException e ) {
        e.printStackTrace();
    }
 
    // Concat class name with random bytes
    String id = object.getClass().getName() + ":" + hexString.toString();
 
    return id;
}

Die ID wird für jeden Spieler zu Beginn generiert. Im Model sind zahlreiche Methoden zur Datenabfrage definiert. Neben der Methode getString, die Zeichenketten in der aktuell eingestellten Sprache zurückliefert, kann der Spieler mit seiner ID auch sein eigenes Profil im Model abfragen, Schiffe auf seinem Spielfeld platzieren oder Felder markieren.

/**
 * Returns the name of this player.
 * 
 * @param id    the player id
 * @return  the players name
 */
@Override
public String getPlayerName(String id)
{
    // First check if the player is alive, has a profile
    if( playerExists( id ) == false ) {
        throw new IllegalArgumentException("Invalid player argument.");
    }
 
    return playerProfiles_.get( id ).getName();
}

Die Methode mark ist für die Markierung eines Feldes zuständig. Als ersten Parameter nimmt sie die ID des Spielers entgegen. Der zweite Parameter stellt einen Koordinatenpunkt auf dem Spielfeld dar. Das Model informiert über das Beobachter Entwurfsmuster alle beim Model registrierten Beobachter über Änderungen im Datenbestand.

/**
 * Marks a field.
 *
 * @param id        the player id
 * @param coord     the coords, which indicates where to mark
 * @return          true, if successful, false if not
 * @throws          eu.codeplanet.battleship.core.FieldOperationException
 */
@Override
public synchronized boolean mark(String id, Coordinate coord)
        throws FieldOperationException
{
    if( playerExists( id ) == false || gameExists( id ) == false ) {
        throw new IllegalArgumentException("Invalid player argument.");
    }
 
    // Get the specified field
    FieldState fieldstate = getEnemyBoardFieldState( id, coord );
 
    // Last but not at least, look if this field is unknown
    if( fieldstate == FieldState.UNKNOWN ) {
        // Now get the enemy board of this player
        EnemyBoard eBoard = boardSet_.get( id ).getEnemyBoard();
        // Set the mark
        eBoard.getField( coord ).setFieldState( FieldState.MARKED );
        // We have changed the data, notify observers.
        setChanged();
        notifyObservers( new ModelMessage( ModelMessage.MessageType.UPDATE ) );
        return true;
    }
 
    return false;
}

Im speziellen versendet das Model spezifizierte Nachrichten an seine Observer. Diese Nachrichten werden in der Klasse ModelMessage definiert. Das Model ist Thread-sicher und stellt synchronisierte Methoden bereit, die von den Spielern auch parallel aufgerufen werden können.

Die Klasse BattleshipModel hält desweiteren eine Instanz der Klasse BattleshipNode und leitet Änderungen im Knoten automatisch an seine Observer (zu dt. Beobachter) weiter. Das zu beobachtende Model beobachtet seinerseits die Klasse BattleshipNode. Dies ist auch der Grund, warum das Model zusätzlich das Interface Observer implementiert. Alle Netzwerkaktivitäten werden vom Model registriert. Dazu zählt auch der Empfang von neuen Nachrichten, die Aufnahme von neuen Peers oder die Entfernung von Teilnehmern aus dem Netzwerk.

/**
 * This model is an observable as well as an observer of another object.
 * In our case, this model will observe the network node. Any changes of the
 * node will be instantly dispatched to our observers.
 * 
 * @param o the observable object
 * @param arg   arguments, send by the observable
 */
@Override
public void update(Observable o, Object arg)
{
    setChanged();
    notifyObservers( new ModelMessage( ModelMessage.MessageType.NETWORKUPDATE ) );
}

Das Model stellt über das ModelInterface der Applikation alle wesentlichen Schnittstellen zur Verfügung, die für das Spiel »Schiffe versenken« benötigt werden. Dazu zählen auch Methoden der Netzwerkbibliothek, die vom Model gekapselt werden. Eine elementare Methode nennt sich connect und ist für die Verbindung zu anderen Knoten zuständig. Die Methode erzeugt einen neuen Netzwerkknoten, startet die Threads der Netzwerkbibliothek und registriert das Model als Beobachter bei der Netzwerkschicht.

/**
 * Connects to a battleship p2p network.
 *
 * @param id    the player id
 * @param host  destination host for bootstrapping
 * @param port  destination port for bootstrapping
 * @param bootstrap if true, client will try to bootstrap
 * @return  true, if connection was established
 */
@Override
public boolean connect(String id, String host, int port, boolean bootstrap)
{
    if( playerExists( id ) == false ) {
        throw new IllegalArgumentException("Invalid player argument.");
    }
 
    if( node_ != null && node_.connected() && !bootstrap )
        return true;
 
    if( node_ != null && node_.running() && bootstrap ) {
        return node_.buildNetwork( host, port, 30 );
    }
 
    boolean success = true;
 
    // Create the peer info
    PeerInfo info = new PeerInfo( "localhost", Integer.parseInt( getConfig().getPreferences().getPort() ) );
 
    info.setName( getPlayerName( id ) );
 
    int maxPeers = 99;  // This is a fixed size.
 
    node_ = new BattleshipNode( maxPeers, info );
 
    // Register our model as an Observer of the node
    node_.addObserver( this );
 
    // Init node
    node_.start();
 
    // Set the level for the network loggers
    Logging.setHandlersLevel( Logging.Type.NETWORK.getLoggerName(), Level.INFO );
 
    if( bootstrap ) {
        success = node_.buildNetwork( host, port, 30 );
    }
 
    return success;
}

Das Model stellt noch viele weitere Methoden zur Verfügung, die den Zugriff und die Manipulation der eigenen Daten kontrollieren. Es ist die zentrale Anlaufstelle für die gesamten Applikationsdaten.


View

Die Präsentationsschicht (engl. View) ist für die Darstellung der benötigten Daten aus dem Model und die Entgegennahme von Benutzerinteraktionen zuständig. Ein Vorteil von MVC ist die strenge Trennung der Präsentationsschicht von der Geschäftlogik mitsamt den Daten. Dieser Vorteil ermöglicht es problemlos eine textbasierten Benutzeroberfläche, als auch eine grafische Benutzeroberfläche, kurz GUI, zu implementieren ohne Änderungen an der Geschäftslogik durchführen zu müssen.

Die View erhält den Zustand, den es anzeigt, direkt vom Model. Wird es beispielsweise vom Model benachrichtigt, dass sich Daten geändert haben, kann die View das Model nach den neuen Daten abfragen um die Oberfläche zu aktualisieren. Die View kann das Model auch dann nach dessen Zustand fragen, wenn es vom Controller aufgefordert wurde, seine Ansicht zu verändern.

Die Präsentationsschicht ist ein Kompositum aus GUI-Komponenten (Labels, Buttons, Textfeldern usw.). Die oberste Komponente enthält andere Komponenten, die wiederum weitere Komponenten enthalten usw., bis man bei den Blattknoten angelangt ist. In einer textbasierten Anwendung, auch Konsolenanwendung genannt, unterscheidet sich die View von einer GUI in einem ganz wesentlichen Punkt. Nicht nur sind keine GUI-Komponenten vorhanden, eine Konsolenanwendung ist im Gegensatz zu einer GUI nicht ereignisgesteuert (event-driven). Dies muss bei der Konzeption zusätzlich berücksichtigt werden.

Spieler und Views

In einem Spiel, wie »Schiffe versenken«, repräsentiert eine View die Ansicht des Spielers auf das Model. Die View reagiert auf Benutzereingaben und leitet die Informationen über den Controller weiter. Der Spieler kann über seine View Einfluss auf die Daten nehmen, sie lesen und auch manipulieren. Kurzum, der Spieler ist für die Interaktion zuständig und entscheidet was zu tun ist.

Es liegt nahe das die View mit einem Spieler gleichsetzbar ist. Dies ist eine wichtige Kernerkenntnis für die Systemarchitektur. Ein Spieler, egal welcher Art, wird durch eine separate View dargestellt. Auf dieser Erkenntnis aufbauend, konzipieren wir sowohl die textbasierten Views, als auch die Views mit grafischer Oberfläche.

Schnittstellen entwerfen

Alle Views haben gemeinsame Eigenschaften. Die Anwendung unterscheidet zwei wesentliche Arten, die es zu unterteilen gilt. Neben den bereits genannten Views für die Spieler gibt es noch einen weiteren Typ von View, der beim Start der Anwendung zu sehen ist. Es handelt sich um das Hauptmenü. Das Hauptmenü gehört keinem Spieler, es repräsentiert allgemein die Anwendung und kann daher nicht in die Kategorie "Spieler" eingeordnet werden. Im Hauptmenü können Informationen geladen, Spiele gestartet und Lizeninformationen angezeigt werden.

Es ist sinnvoll mit der Schnittstelle für diesen elementaren Typ von View zu beginnen. In Battleship wird dazu das Interface ViewInterface definiert. Das Interface implementiert, wie es sich für eine View in MVC gehört, das Interface Observer. Es stellt alle wesentlichen Informationen zur Verfügung, die von einer Hauptpräsentation benötigt werden.

public interface ViewInterface extends Observer
{
    /**
     * Sets the model for this controller.
     *
     * @param model the model
     */
    void setModel(ModelInterface model);
 
    /**
     * Returns the model for this controller.
     *
     * @return the model
     */
    ModelInterface getModel();
 
    /**
     * Set the controller.
     *
     * @param controller    the controller
     */
    void setController(ControllerInterface controller);
 
    /**
     * Get the controller.
     *
     * @return  the controller
     */
    ControllerInterface getController();
 
    /**
     * Generates the view.
     *
     * @param time  expected creation time for the splash bar
     */
    void generateView(int time);
 
    /**
     * Simple abstraction method for displaying messages.
     *
     * @param message
     */
    void showMessage(String message);
 
    /**
     * Shows a message in a message dialog.
     *
     * @param parentComponent   determines the Frame in which the dialog is displayed
     * @param message   the Object to display
     * @param title the title string for the dialog
     * @param messageType   the type of message to be displayed
     * @param icon  an icon to display in the dialog
     */
    void showMessageDialog(Component parentComponent, Object message, String title,
            int messageType, Icon icon);
 
    /**
     * Shows a dialog requesting input from the user.
     *
     * @param parentComponent   the parent Component for the dialog
     * @param message   the Object to display
     * @param title the String to display in the dialog title bar
     * @param messageType   the type of message that is to be displayed
     * @param pattern   a regex pattern
     * @return  user input
     */
    String showInputDialog(Component parentComponent, Object message,
            String title, int messageType, String pattern);
 
    /**
     * Prompts a dialog and asks the user to choose one string.
     *
     * @param parentComponent   the parent Component for the dialog
     * @param message   the Object to display
     * @param title the String to display in the dialog title bar
     * @param messageType   the type of message that is to be displayed
     * @param icon  the Icon image to display
     * @param selectionValues   an array of Objects that gives the possible selections
     * @param initialSelectionValue   the value used to initialize the input field
     * @return  the entered string
     */
    Object showInputDialog(Component parentComponent, Object message, String title,
            int messageType, Icon icon, Object[] selectionValues, Object initialSelectionValue);
 
    /**
     * Brings up a dialog with a specified icon, where the number of choices is
     * determined by the optionType parameter.
     *
     * @param parentComponent   determines the Frame in which the dialog is displayed
     * @param message   the Object to display
     * @param title the title string for the dialog
     * @param optionType    an int designating the options available on the dialog
     * @param messageType   an int designating the kind of message this is
     * @param icon  the icon to display in the dialog
     * @return  an int indicating the option selected by the user
     */
    int showConfirmDialog(Component parentComponent, Object message, String title,
            int optionType, int messageType, Icon icon);
 
    /**
     * Brings up a dialog with a specified icon, where the initial choice is
     * determined by the initialValue parameter and the number of choices is
     * determined by the optionType parameter.
     *
     * @param parentComponent   determines the Frame  in which the dialog is displayed
     * @param message   the Object to display
     * @param title the title string for the dialog
     * @param optionType    an integer designating the options available on the dialog
     * @param messageType   an integer designating the kind of message this is
     * @param icon  the icon to display in the dialog
     * @param options   an array of objects indicating the possible choices the user can make
     * @param initialValue  methods; if null, options are determined by the Look and Feel
     * @return  an integer indicating the option chosen by user, or CLOSED_OPTION
     */
    int showOptionDialog(Component parentComponent, Object message, String title,
            int optionType, int messageType, Icon icon, Object[] options, Object initialValue);
 
    /**
     * Enables or disables this component, depending on the value of the
     * parameter b. An enabled component can respond to user input and
     * generate events. Components are enabled initially by default.
     *
     * @param b If true, this component is enabled; otherwise it is disabled
     */
    void setEnabled(boolean b);
 
    /**
     * Shows or hides this component depending on the value of parameter b.
     *
     * @param b if true, shows this component; otherwise, hides this component
     */
    void setVisible(boolean b);
 
    /**
     * Repaint the complete view.
     *
     * @param both  if true, redraw both boards
     */
    void repaint(boolean both);
 
    /**
     * Generate the first player.
     */
    void getLocalViews();
 
    /**
     * Generate remote views.
     */
    void getRemoteViews();
 
    /**
     * Get the board sizes.
     */
    Coordinate getBoardSize();
 
    /**
     * Shows the game license.
     */
    void showLicense();
 
    /**
     * About the game.
     */
    void showAbout();
 
    /**
     * Shows a manual with rules for the game.
     */
    void showHelp();
 
    /**
     * Exit game.
     */
    void showExit();
 
    /**
     * Load a game.
     */
    void loadGame();
 
    /**
     * Show game statistics.
     */
    void showStatistics();
}

Sie können einige wichtige Eigenschaften erkennen, die eine derartige View besitzen muss. Sie sollte in der Lage sein Texte auszugeben, eine Option zum Starten von neuen Spielen bereitstellen oder auch eine Option, sich die Lizenzdaten anzeigen zu lassen.

Mit der Definition der ersten Schnittstelle kann nun auch die zweite Schnittstelle definiert werden. Alle Spieler implementieren das sogenannte PlayerInterface. Dieses Interface stellt Methoden, wie manualSet zum automatischen Platzieren von Schiffen oder move zum Ausführen von Aktionen zur Verfügung.

public interface PlayerInterface extends Observer
{
    /**
     * Sets the model for this controller.
     *
     * @param model the model
     */
    void setModel(ModelInterface model);
 
    /**
     * Returns the model for this controller.
     *
     * @return the model
     */
    ModelInterface getModel();
 
    /**
     * Set the controller.
     *
     * @param controller    the controller
     */
    void setController(ControllerInterface controller);
 
    /**
     * Get the controller.
     *
     * @return  the controller
     */
    ControllerInterface getController();
 
    /**
     * Get the player id.
     *
     * @return  the player id
     */
    String getId();
 
    /**
     * Set the player id.
     *
     * @param id  the new player id
     */
    void setId(String id);
 
    /**
     * Enables or disables this component, depending on the value of the
     * parameter b. An enabled component can respond to user input and
     * generate events. Components are enabled initially by default.
     *
     * @param b If true, this component is enabled; otherwise it is disabled
     */
    void setEnabled(boolean b);
 
    /**
     * Shows or hides this component depending on the value of parameter b.
     *
     * @param b if true, shows this component; otherwise, hides this component
     */
    void setVisible(boolean b);
 
    /**
     * Set all ships manually.
     */
    void manualSet();
 
    /**
     * Set a ship.
     *
     * @param type      the ship type
     * @param coord     the left, highest coord of the ship
     * @param vertical  if true, the ship lies vertically
     * @return          true if successfull
     */
    boolean setShip(String type, Coordinate coord, boolean vertical);
 
    /**
     * Return the node id of the remote player.
     * 
     * @return  an unique network identifier
     */
    String getPid();
 
    /**
     * Set all ships on a board.
     */
    void setShips();
 
    /**
     * Set all ships on the board.
     * 
     * @param fleet a network stream with all ships
     * @return true, if successful 
     */    
    boolean setShips(String fleet);
 
    /**
     * Generates the view.
     */
    void generateView();
 
    /**
     * Simple abstraction method for displaying messages.
     *
     * @param message
     */
    void showMessage(String message);
 
    /**
     * Shows a message in a message dialog.
     *
     * @param parentComponent   determines the Frame in which the dialog is displayed
     * @param message   the Object to display
     * @param title the title string for the dialog
     * @param messageType   the type of message to be displayed
     * @param icon  an icon to display in the dialog
     */
    void showMessageDialog(Component parentComponent, Object message, String title,
            int messageType, Icon icon);
 
    /**
     * Shows a dialog requesting input from the user.
     *
     * @param parentComponent   the parent Component for the dialog
     * @param message   the Object to display
     * @param title the String to display in the dialog title bar
     * @param messageType   the type of message that is to be displayed
     * @param pattern   a regex pattern
     * @return  user input
     */
    String showInputDialog(Component parentComponent, Object message,
            String title, int messageType, String pattern);
 
    /**
     * Prompts a dialog and asks the user to choose one string.
     *
     * @param parentComponent   the parent Component for the dialog
     * @param message   the Object to display
     * @param title the String to display in the dialog title bar
     * @param messageType   the type of message that is to be displayed
     * @param icon  the Icon image to display
     * @param selectionValues   an array of Objects that gives the possible selections
     * @param initialSelectionValue   the value used to initialize the input field
     * @return  the entered string
     */
    Object showInputDialog(Component parentComponent, Object message, String title,
            int messageType, Icon icon, Object[] selectionValues, Object initialSelectionValue);
 
    /**
     * Brings up a dialog with a specified icon, where the number of choices is
     * determined by the optionType parameter.
     *
     * @param parentComponent   determines the Frame in which the dialog is displayed
     * @param message   the Object to display
     * @param title the title string for the dialog
     * @param optionType    an int designating the options available on the dialog
     * @param messageType   an int designating the kind of message this is
     * @param icon  the icon to display in the dialog
     * @return  an int indicating the option selected by the user
     */
    int showConfirmDialog(Component parentComponent, Object message, String title,
            int optionType, int messageType, Icon icon);
 
    /**
     * Brings up a dialog with a specified icon, where the initial choice is
     * determined by the initialValue parameter and the number of choices is
     * determined by the optionType parameter.
     *
     * @param parentComponent   determines the Frame  in which the dialog is displayed
     * @param message   the Object to display
     * @param title the title string for the dialog
     * @param optionType    an integer designating the options available on the dialog
     * @param messageType   an integer designating the kind of message this is
     * @param icon  the icon to display in the dialog
     * @param options   an array of objects indicating the possible choices the user can make
     * @param initialValue  methods; if null, options are determined by the Look and Feel
     * @return  an integer indicating the option chosen by user, or CLOSED_OPTION
     */
    int showOptionDialog(Component parentComponent, Object message, String title,
            int optionType, int messageType, Icon icon, Object[] options, Object initialValue);
 
    /**
     * Repaint view. If Coordinate is null, the complete board will be
     * redrawed.
     *
     * @param both  an internal flag
     */
    void repaint(boolean both);
 
    /**
     * Start game. Inform the user about it.
     */
    void start();
 
    /**
     * Performs a move.
     */
    void move();
 
    /**
     * Alywas called, when the player wins.
     */
    void won();
 
    /**
     * Called, when the player looses a game.
     */
    void lost();
 
    /**
     * Close view.
     */
    void close();
}

Mit diesen beiden Schnittstellen wurden die zwei Arten von Views hinreichend definiert. Als nächstes folgt die Implementierung der Views.

Konsole

Die Kommandozeile, Befehlszeile oder aus dem Englischen command-line interface, kurz CLI, oft auch als Konsole oder Terminal bezeichnet, ist ein Eingabebereich für die Steuerung einer Software, der typischerweise im Textmodus abläuft. Die Kommandos oder Befehle werden als Wörter eingegeben. Die Ausführung der Befehle wird meist direkt aus der Zeile durch zusätzlich angegebene Parameter gesteuert. Programme, die den Benutzer interaktiv befragen, sind auf dieser Ebene eher unüblich.

Ein Spiel in der Kommandozeile ist nicht ereignisgesteuert. Das bedeutet das Aktionen nicht vom Benutzer initiiert werden können, sondern vom Programm ausgehen. Das Programm fordert den Benutzer zu bestimmten Zeitpunkten auf, Eingaben zu tätigen, ganz im Gegensatz zu einer grafischen Oberfläche, bei der Aktionen beliebig vom Benutzer angestoßen werden können, z.B. durch einen Knopfdruck.

Bereits jetzt ist ersichtlich, dass ein Kommandozeilenprogramm auf einen Programmfluss angewiesen ist. Die Anwendung muss das Spiel lenken und den Benutzer explizit abfragen. Bei der Programmierung ist dies zu berücksichtigen.

Hauptoberfläche

Wir beginnen mit der View für die Hauptoberfläche und implementieren alle wichtigen Methoden. Im folgenden Quelltext ist die Klasse teilweise dargestellt.

public class MainConsoleView implements ViewInterface
{
    /**
     *
     * @param model the corresponding model
     * @param controller    the controller
     */
    public MainConsoleView(ModelInterface model, ControllerInterface controller)
    {
        // Set the model.
        setModel( model );
 
        // If a controller was supplied, use it. Otherwise let the first
        // call to getController() create the default controller.
        if( controller != null ) {
            setController( controller );
        }
 
        model.addObserver( this );    // We are the observer
    }
 
    /**
     * This method is called whenever the observed object is changed. The
     * observed object is the model.
     *
     * @param obj   the observable
     * @param arg   the retrieved arguments
     */
    @Override
    public void update(Observable obj, Object arg)
    {                
    }
 
    /**
     * Returns the default controller for this view.
     *
     * @param model the model
     * @return  the controller interface
     */
    public ControllerInterface defaultController(ModelInterface model)
    {
        return null;
    }
 
    /**
     * Sets the model this view is observing.
     *
     * @param model
     */
    @Override
    public void setModel(ModelInterface model)
    {
        model_ = model;
    }
 
    /**
     * Returns the model this view is observing.
     *
     * @return  the model
     */
    @Override
    public ModelInterface getModel()
    {
        return model_;
    }
 
    /**
     * Sets the controller for this view.
     *
     * @param controller
     */
    @Override
    public void setController(ControllerInterface controller)
    {
        controller_ = controller;
 
        // Tell the controller this object is its view.
        getController().setView( this );
    }
 
    /**
     * Returns this view's controller.
     *
     * @return  the controller interface
     */
    @Override
    public ControllerInterface getController()
    {
        // If a controller hasn't been defined yet...
        if (controller_ == null) {
            // ...make one. Note that defaultController() is normally overridden
            // by the AbstractView subclass so that it returns the appropriate
            // controller for the view.
            setController( defaultController( getModel() ) );
        }
 
        return controller_;
    }
}

Im Konstruktor wird die View mit den Schnittstellen vom Model und dem Controller instanziert. Mithilfe der Methoden setModel und setController werden das Model der View und der Controller gesetzt.
Abschließend registriert sich diese View beim Model als Beobachter. Dieses Verfahren gehört zu den Standardverfahren in MVC. Die Methode update bleibt von der MainConsoleView allerdings unbelegt.

Zu der Klasse MainConsoleView gehört die Methode generateView. Diese Methode generiert das Hauptmenü, das nach dem Start der Anwendung zu sehen ist.

/**
 * This method generates the main view when a game starts. It will
 * create the welcome screen.
 *
 * @param time  expected creation time for splash (not used in console mode)
 */
@Override
public void generateView(int time)
{
    showMessage( "\n" + model_.getString( "BATTLESHIP.INITIALIZE_GAME" ) + "\n\n" );
 
    // Create main menu
    Object[] possibleValues = {
        model_.getString( "BATTLESHIP.START_LOCAL_GAME" ),
        model_.getString( "BATTLESHIP.LOAD_GAME" ),
        model_.getString( "BATTLESHIP.START_REMOTE_GAME" ),
        model_.getString( "BATTLESHIP.LICENSE" ),
        model_.getString( "BATTLESHIP.AUTHOR" ),
        model_.getString( "BATTLESHIP.CHECK_FOR_UPDATES" ),
        model_.getString( "BATTLESHIP.END_GAME" )
    };
 
    // Loop until the player exits the game
    while( true ) {
 
        // Print ascii art
        showMessage( model_.getString( "BATTLESHIP.ASCII_LOGO" ) );
 
        Object input = showInputDialog( null,
                model_.getString( "BATTLESHIP.CHOOSE_OPTION" ),
                model_.getString( "BATTLESHIP.CHOOSE" ),
                0, null, possibleValues, possibleValues[0] );
 
        if( input.equals( possibleValues[0] ) ) {
            // Start new local game
            controller_.localGame();
        } else if( input.equals( possibleValues[1] ) ) {
            // Load an old game
            controller_.load();
        } else if( input.equals( possibleValues[2] ) ) {
            // Start new remote game
            controller_.remoteGame();
        } else if( input.equals( possibleValues[3] ) ) {
            // Print copyright notice
            controller_.license();
        } else if( input.equals( possibleValues[4] ) ) {
            // Print help
            controller_.about();
        } else if( input.equals( possibleValues[5] ) ) {
            // Check for updates
            controller_.checkForUpdates( this );
        } else if( input.equals( possibleValues[6] ) ) {
            // Exits the menu, leaves the game
            controller_.exit();
        }
    }
}

Darüberhinaus implementiert die Klasse alle weiteren Methoden aus dem Interface ViewInterface. Eine davon ist showLicense, welche die Lizenzinformationen für das Spiel auf die Kommandozeile druckt.

/**
 * Shows the game license.
 *
 * @see <a href="www.gnu.org/copyleft/gpl.html">GNU General Public License</a>
 */
@Override
public void showLicense()
{
    // Path to the license file
    String license = model_.getString( "BATTLESHIP.PATH_TO_LICENSE_FILE" );
 
    // Display the copyright license
    showMessage( IOUtil.resToStrBuilder( license ).toString() );
}
Spieleroberfläche

Jeder gewöhnliche Spieler in Battleship muss das PlayerInterface implementieren. Das gilt auch für die Kommandozeilenansicht eines menschlichen Spielers. Die Klasse HumanConsoleView repräsentiert einen menschlichen Konsolenspieler. Ein Konsolenspieler muss in der Lage sein Befehle über die Kommandozeile einzugeben. Desweiteren kann ein menschlicher Spieler seine Spielfelder textbasiert einsehen. Die Klasse HumanConsoleView formatiert alle Daten aus dem Model und stellt sie in der Kommandozeile dem Benutzer visuell dar.

Der Konstruktor in HumanConsoleView gleicht dem bereits bekannten Konstruktor der Klasse MainConsoleView mit Ausnahme von einer Zeile. Unmittelbar nach dem Setzen des Models wird auch die Identifikationsnummer für den Spieler generiert. Dazu wird die Methode getIdentification über den Controller aufgerufen. Die ID wird einmalig zugewiesen und verändert sich nicht mehr. Sie ist der Schlüssel, mit dem der Spieler (View) seine Daten im Model abrufen kann.

/**
 * Constructs the human player.
 * 
 * @param model the corresponding model
 * @param controller    the controller
 */
public HumanConsoleView(ModelInterface model, ControllerInterface controller)
{
    // Set the model.
    setModel( model );
 
    // On startup create profile and get an id
    setId( controller.getIdentification( this ) );
 
    // If a controller was supplied, use it. Otherwise let the first
    // call to getController() create the default controller.
    if( controller != null ) {
        setController( controller );
    }
 
    // Let us add as an observer to the model
    model.addObserver( this );
}

Mithilfe des Observer Entwurfsmusters wird ein Spieler bei Änderungen, z.B. Schüssen auf das eigene oder gegnerische Spielfeld vom Model über die Methode update umgehend unterrichtet.

/**
 * This method is called whenever the observed object is changed. The
 * observed object is the model.
 * 
 * @param obj   the observable object
 * @param arg   arguments
 */
@Override
public void update(Observable obj, Object arg)
{
    // Check if this view is active, if not return
    if( !enabled_ )
        return;
 
    // Check if the model tells us something. If not than we should
    // probably just redraw our boards.
    if( arg instanceof String) {
        showMessage( (String)arg ); // Display the message
    } else {        
        repaint( true );
    }       
}

Die Methode update ruft unmittelbar die Methode repaint auf. Diese Methode zeichnet die Spielfelder in der Kommandozeile neu, indem sie die aktuellen Daten aus dem Model liest.

Ein menschlicher Spieler muss ebenfalls die Methode move implementieren. Diese Methode wird vom Controller aufgerufen und teilt dem Benutzer mit, seinen nächsten Zug durchzuführen. In der Konsole wird dazu eine Eingabe abverlangt. Diese Eingabe wird anschließend ausgewertet und an einen bestimmten Handler übergeben, der vom Controller abgearbeitet wird. Die Eingabe ist mithilfe von regulären Ausdrücken (Regex) explizit eingegrenzt, so dass fehlerhafte Eingaben automatisch unterbunden werden.

/**
 * Let the player move.
 */
@Override
public void move()
{
    if( model_.isActivePlayer( getId() ) ) {
 
        showMessage( "\n" + getModel().getString( "BATTLESHIP.YOUR_TURN" ) + "\n" );
 
        showMessage( "\n" + getModel().getString( "BATTLESHIP.ENTER_COMMAND" ) );
 
        // Get a specified pattern. If you change this pattern, you probably
        // also have to change the parsing of the command line string.
        responseHandler( getInput("(([alehqrv]{1,1})?|([msu]{1,1}[0-9]{4,4})?|([@]{1,1}(.*))?)") );
    }
}

Die Klasse HumanConsoleView implementiert darüberhinaus auch noch die Methoden setShips zum Platzieren der eigenen Schiffe, sowie won. Diese Methode wird vom Controller aufgerufen, sobald der Spieler sein Spiel gewonnen hat.

Graphical User Interface (GUI)

Ein Spiel über die Kommandozeile zu spielen mag kurzfristig eine gewisse Abwechslung mit sich bringen. Sofern das aber nicht zwingend notwendig ist, z.B. weil das eigene System nur textbasiert arbeitet, ist es weitaus unterhaltsamer, wenn eine grafische Oberfläche zu sehen ist. Eine grafische Benutzeroberfläche ist eine Software-Komponente, die dem Benutzer eines Computers die Interaktion mit der Maschine über grafische Symbole erlaubt. Die Darstellungen und Elemente können unter Verwendung einer Maus gesteuert werden.

Das Model-View-Controller Architekturmuster gestattet es die View, also die Präsentationsschicht, völlig frei zu konzipieren. Neben den bereits implementierten Views für die Kommandozeile können wir nun zusätzlich weitere Views entwerfen, diesmal mit einer grafischen Benutzeroberfläche (GUI), so wie Sie es von anderen Anwendungen in der Regel gewohnt sind.

Hauptoberfläche

Wir beginnen an dieser Stelle ebenfalls bei der Hauptoberfläche. Die Klasse MainGuiView repräsentiert ein Hauptmenü mit klassischer grafischer Oberfläche. Die Klasse implementiert ebenfalls das Interface ViewInterface. Bei der Klasse MainGuiView handelt es sich um eine Subklasse von javax.swing.JFrame. Alle Methoden von JFrame werden mittels Vererbung an die Klasse MainGuiView weitergegeben.

// Teilauszug aus der Klasse MainGuiView
public class MainGuiView extends JFrame implements ViewInterface
{
    /** 
     * Creates new form GuiApplication.
     *
     * @param model the corresponding model
     * @param controller    the controller
     */
    public MainGuiView(ModelInterface model, ControllerInterface controller)
    {
        // Set the model.
        setModel( model );
 
        // If a controller was supplied, use it. Otherwise let the first
        // call to getController() create the default controller.
        if( controller != null ) {
            setController( controller );
        }
 
        // Add observer to the model
        model_.addObserver( this );
 
        // Set java's look & feel
        LAFSettings.setNativeLookAndFeel();
    }
 
    /**
     * This is the main build method for the view. Add your code here.
     *
     * @param time  expected creation time for the splash bar
     */
    @Override
    public void generateView(int time)
    {
        // Create the splash screen
        Splash splash = new Splash( time );
 
        // 1. Initialize gui
        splash.drawSplashOperation( model_.getString( "BATTLESHIP.INITIALIZE_GUI" ) );
 
        initComponents();
        initMyComponents();
 
        try {
            Thread.sleep( 500 );
        } catch(InterruptedException ex) {
        }
 
        splash.drawSplashProgress();
 
        // 2. Generate directories
        splash.drawSplashOperation( model_.getString( "BATTLESHIP.INITIALIZE_ENVIRONMENT" ) );
 
        try {
            Thread.sleep( 1000 );
        } catch(InterruptedException ex) {
        }
 
        splash.drawSplashProgress();
 
        // 3. Initialize game
        splash.drawSplashOperation( model_.getString( "BATTLESHIP.INITIALIZE_GAME" ) );
 
        try {
            Thread.sleep( 500 );
        } catch(InterruptedException ex) {
        }
 
        splash.drawSplashProgress();
 
        splash.closeSplash();
 
        // Set the window position
        Point pos = new Point();
        pos.x = 100;
        pos.y = 100;
 
        setLocation(pos);
 
        // Make the view visible. Do not mess with Swing components in the
        // main thread after this call or pack() has been called.
        setVisible( true );
    }
}

Auch die Klasse MainGuiView implementiert, wie ihr Kollege in der Kommandozeile, die Methode generateView. Diese Methode erzeugt allerdings einen SplashScreen, generiert mit den Methoden initComponents() und initMyComponents() alle Buttons, Labels und andere grafischen Komponenten. Im Konstruktor wird mithilfe der Hilfsklasse LAFSettings das native „Look and Feel“ gewählt. Look and Feel (LAF; dt. Aussehen und Handhabung, „Anfühlen“, Anmutung) bezeichnet meist durch Hersteller oder Konsortien standardisierte Design-Aspekte einer Software, wie zum Beispiel Farben, Layout, Fontgröße und die Benutzung von grafischen Elementen (widgets).

Spieleroberfläche

Ein menschlicher Spieler hat bei einer grafischen Oberfläche in der Regel deutlich mehr Möglichkeiten. Der Hauptbestandteil des Quelltextes in einer grafischen View geht auf die Generierung der Schaltoberflächen und Menüs zurück. Auch die Ereignishandler müssen alle geschrieben werden.

Die Klasse HumanGuiView implementiert, wie alle anderen Views auch, die Methode update. Bei Änderungen von Daten benachrichtigt das Model alle Views.

/**
 * This method is called whenever the observed object is changed. The
 * observed object is the model.
 * 
 * @param obj   the observable object
 * @param arg   the argument
 */
@Override
public void update(Observable obj, Object arg)
{
    if( arg instanceof ModelMessage ) {
        ModelMessage msg = (ModelMessage)arg;
        if( msg.getType().equals( ModelMessage.MessageType.UPDATE ) ) {
            repaint( true );
        } else if( msg.getType().equals( ModelMessage.MessageType.NETWORKUPDATE ) ) {
            // State changes in the model, update all relevant gui data
            EventQueue.invokeLater( new Runnable() {
                @Override
                public void run()
                {
                    // Update user list
                    updateUserList();
                }
            });
 
            // Check for relevant messages
            checkForNewGames();
            checkForAcceptedGames();
        }
    }
}

An dieser Stelle wird ein weiterer elementarer Vorteil von MVC ersichtlich. Sobald sich neue User im Netzwerk einloggen, benachrichtigt das Model die View. Diese kann anschließend über EventQueue.invokeLater die Oberfläche mit der Methode updateUserList aktualisieren und zeigt neue Netzwerkteilnehmer umgehend in der Nutzerliste an. Dies geschieht vollkommen automatisch und beinahe in Echtzeit. Die View muss das Model nicht mühselig in einem Zyklus abfragen, ob sich die Daten geändert haben, um anschließend über einen Swing-Timer die Oberfläche zu aktualisieren.

Diese Ansatz, auch Polling (zyklische Abfrage) genannt, verbraucht unnötig Leistung und verlangsamt das Programm. Sehr bald schon hätten Sie darüberhinaus etliche Timer, die in zahlreichen Threads das Model mit Anfragen bombardieren. Das Observer Entwurfsmuster erspart uns dieses ineffiziente pollen. Die View wird nur dann aktualisiert, wenn sich tatsächlich auch Daten geändert haben. Das Model, das die Daten verwaltet, benachrichtigt uns sobald das der Fall ist.

In der Methode repaint werden, wie in der Klasse HumanConsoleView auch, die beiden Spielfelder bei Änderungen neu gezeichnet. In der grafischen Oberfläche werden dazu einfach die einzelnen Felder aktualisiert und neue Icons geladen.

/**
 * Repaint view. If Coordinate is null, the complete board will be
 * redrawed.
 *
 * @param both  if true, the home field will be repainted, if false the enemy
 */
@Override
public void repaint(boolean both)
{
    // This can only mean that the model has updated the data.
    // Draw the new board.
    Coordinate size = getModel().getBoardSize( getId() );
 
    for(int y = 0; y <= size.getY(); y++) {
        for(int x = 0; x <= size.getX(); x++) {
            jLabelHomeBoard_[x + (y * 10)].setIcon(
                    interpretFieldState(
                    getModel().getHomeBoardFieldState( getId(), new Coordinate( x, y ) ) ));
            try {
                jLabelEnemyBoard_[x + (y * 10)].setIcon(
                        interpretFieldState(
                        getModel().getEnemyBoardFieldState( getId(), new Coordinate( x, y ) ) ));
            } catch(FieldOperationException e) {
                showMessage( e.getMessage() );
            }
        }
    }
}

In dem Package eu.codeplanet.battleship.view.gui sind noch weitere View-Klassen für die GUI definiert. Dazu zählen triviale Fenster, wie die AboutView oder auch die ShipSetView. Letztere stellt ein Menü zur Schiffsplatzierung zur Verfügung. Auch das Chatfenster ist in diesem Unterpaket zu finden.


Computerspieler

Bisher wurde sowohl für die Konsole, als auch für die grafische Benutzeroberfläche eine View vom Typ Spieler implementiert. Es handelte sich aber stets um denselben Typus, nämlich um einen menschlichen Spieler. In einem Spiel, wie »Schiffe versenken«, möchte man aber auch gegen eine künstliche Intelligenz (KI) spielen können. Der Begriff künstliche Intelligenz ist an dieser Stelle metaphorisch zu verstehen, tatsächlich sind die strategischen Anforderungen an einen Computerspieler in dem Spiel »Schiffe versenken« eher gering anzusiedeln.

Nichtsdestotrotz kann die Konzeption eines guten Computergegners eine Herausforderung darstellen. Ein guter Computerspieler muss ein Spielfeld bewerten können, er sollte seine Schüsse von einigen wichtigen Faktoren abhängig machen. Dazu zählt die Einsicht das Schiffe nicht unmittelbar nebeneinander oder diagonal liegen können.

Ein Problem von Computerspielern ist, dass der Computer so wie ein Mensch, Benutzereingaben machen muss. Er muss auf Spielfelder des Gegners schießen und Felder markieren können. Das Interface PlayerInterface definiert die Methode move. Diese Methode bietet sich als zentrale Anlaufstelle für die Züge des Computers an. Während der menschliche Spieler in dieser Methode zur Eingabe von Befehlen aufgefordert wird, sollte ein Computerspieler an dieser Stelle seine Eingaben ebenfalls durchführen können.

Log

Alle Computerspieler haben eines gemeinsam. Sie besitzen keine Benutzeroberfläche mit einer Ansicht. Sie benötigen auch keine optische Ansicht, schließlich kennt der Computer die internen Daten. Eine View, die einen Computerspieler repräsentiert, muss seine Daten weder auf die Konsole, noch über eine GUI ausgeben. Die View eines Computerspielers loggt allerdings Informationen in eine Datei. Das Logging ist eine wesentliche Eigenschaft von Computer-Views.

Die Logik eines Computerspielers kann vollständig in der View implementiert werden, schließlich repräsentiert die View einen Spieler. Wir wollen in den nächsten Kapiteln drei verschiedene Views, also drei unterschiedliche Computerspieler implementieren.

Dummer Computerspieler

Bei dem „dummen“ Computerspieler handelt es sich um den primitivsten Computergegner. Er berücksichtigt weder die Tatsache das Schiffe nicht diagonal positioniert sein können, noch das Schiffe niemals direkt nebeneinander liegen. Der „dumme“ Computerspieler schießt vollkommen willkührlich auf das Spielfeld des Gegners. Seine einzige „Fähigkeit“ ist das er niemals auf dasselbe Feld schießt. Dies wird vom Model unterbunden.

Die Methode move des „dummen“ Computerspielers ist wie folgt definiert:

/**
 * Performs a computer move.
 */
@Override
public void move()
{
    // Get the board size
    Coordinate size = getModel().getBoardSize( getId() );
 
    // Get a random coord instance
    Coordinate c = Coordinate.getRandom( size.getX(), size.getY() );
 
    // Return the shot
    getController().shoot( this, c );
}

Es ist zu erkennen das zufällige Koordinaten ausgewählt werden um anschließend mithilfe des Controllers den Schuss auszuführen.

Kluger Computerspieler

Der „kluge“ Computerspieler agiert deutlich fortschrittlicher. Er berücksichtigt das ein Schiff nicht diagonal positioniert sein kann. Außerdem kann er Felder markieren, sobald ein Schiff versenkt wurde. Auf diese Weise schließt er Felder aus, die unmittelbar neben einem versenkten Schiffen liegen. Dadurch wird eine elementare Regel beachtet, nämlich das Schiffe niemals unmittelbar nebeneinander liegen.

Die Methode move des „klugen“ Computerspielers ist wie folgt definiert:

/**
 * Performs a computer move.
 */
@Override
public void move()
{
    // Get the board size
    Coordinate size = getModel().getBoardSize( getId() );
    Coordinate c = null;
 
    // First check the game board and search for destroyed ships.
    // Mark the surrounding of the ship if this wasn't already done.
    for(int y = 0; y <= size.getY(); y++) {
        for(int x = 0; x <= size.getX(); x++) {
            try {
                // Get and check this field if it's a destroyed ship
                if( getModel().getEnemyBoardFieldState( getId(), new Coordinate(x, y) ).equals(FieldState.DESTROYED_SHIP) ) {
                    // Return a mark command if we found an unknown coord around this destroyed ship
                    if( (c = checkFieldPeriphery( new Coordinate(x, y), size, FieldState.UNKNOWN )) != null )
                        getController().mark( this, c );
                }
            } catch( FieldOperationException e ) {
                showMessage( e.getMessage() );
            }
        }
    } 
 
    // Evaluate the current board of the enemy
    Evaluation eval = new Evaluation( getModel().getEnemyBoard( getId() ) );
 
    // Get a possible coord from this evaluation
    c = eval.getProbableCoordinate();
 
    // Return the shot
    getController().shoot( this, c );
}

Auf den ersten Blick ist zu erkennen, dass die Methode bereits deutlich komplexer ist. Der „kluge“ Computerspieler markiert mithilfe der Methode checkFieldPeriphery Felder um ein zerstörtes Schiff herum. Die Methode ermittelt alle unbekannten Felder und gibt die Koordinaten zurück.

Der „kluge“ Computerspieler verwendet darüberhinaus eine separate Klasse namens Evaluation. Die Klasse Evaluation bewertet ein Spielfeld. So werden beispielsweise Felder, die unmittelbar neben einem getroffenen Feld liegen in ihrer Prioriät heraufgesetzt. Felder die markiert wurden, erhalten die Priorität 0. Die Klasse stellt eine Methode namens getProbableCoordinate zur Verfügung. Diese Methode liefert die am höchsten bewertete Koordinate aus dem Spielfeld.

In dem unten illustrierten Quelltext ist die Methode generatePriorityField der Klasse Evaluation gelistet. Diese Methode generiert die Bewertung für ein Spielfeld.

/**
 * Generates a priority field for the next shot command. Such a field can
 * look like this one:
 * 
 * <code>
 * UUUUU                20102
 * *UUU*   Priorities   02120
 * UUUUU       =>       20103
 * --UU*                00100
 * X-UU*                00100
 * </code>
 * 
 * @param enemyBoard the enemy board
 */
private void generatePriorityField(EnemyBoard enemyBoard)
{        
    int height = enemyBoard.getHeight();
    int width = enemyBoard.getWidth();
 
    // We use an existent field for performance issues. Initialize only one time.
    priorityBoard_ = new Priority[height][width];
 
    // First initialize all fields with a low priority
    for( int y = 0; y < height; y++ ) {
        for( int x = 0; x < width; x++ ) {
            priorityBoard_[y][x] = Priority.LOW;
        }
    }        
 
    // Search for all *non* UNKNOWN fields. This fields have a priority of 
    // ZERO (0) and therefore we do *not* shot on them.
    for(int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            if(enemyBoard.getField( x, y ).getFieldState() != FieldState.UNKNOWN)
                priorityBoard_[y][x] = Priority.ZERO;
        }
    }
 
    // Now comes the second step. We search for all HIT fields and
    // set the field above, below, right and left of this field to MEDIUM if
    // it is not ZERO or to HIGH if it is already MEDIUM.
    // There can't be a ship diagonal of a HIT field, therefore
    // we set this field to ZERO. This automatically excludes long
    // ships and elimates them.
    for( int y = 0; y < height; y++ ) {
        for( int x = 0; x < width; x++ ) {
            // Search for a HIT field
            if( enemyBoard.getField( x, y ).getFieldState() == FieldState.HIT ) {
 
                // ------------------------------------------------                  
                // First look at the vertical and horizontal fields
 
                // Above
                if( (y - 1) >= 0 ) {
                    // Check if not ZERO
                    if( priorityBoard_[y - 1][x] != Priority.ZERO ) {
                        // Check if already MEDIUM
                        if (priorityBoard_[y - 1][x] == Priority.MEDIUM) {
                            priorityBoard_[y - 1][x] = Priority.HIGH;
                        } else {
                            priorityBoard_[y - 1][x] = Priority.MEDIUM;
                        }
                    }
                }
 
                // Below
                if( (y + 1) < height ) {
                    // Check if not ZERO
                    if (priorityBoard_[y + 1][x] != Priority.ZERO) {
                        // Check if already MEDIUM
                        if (priorityBoard_[y + 1][x] == Priority.MEDIUM) {
                            priorityBoard_[y + 1][x] = Priority.HIGH;
                        } else {
                            priorityBoard_[y + 1][x] = Priority.MEDIUM;
                        }
                    }
                }
 
                // Right side
                if( (x + 1) < width ) {
                    // Check if not ZERO
                    if (priorityBoard_[y][x + 1] != Priority.ZERO) {
                        // Check if already MEDIUM
                        if (priorityBoard_[y][x + 1] == Priority.MEDIUM) {
                            priorityBoard_[y][x + 1] = Priority.HIGH;
                        } else {
                            priorityBoard_[y][x + 1] = Priority.MEDIUM;
                        }
                    }
                }
 
                // Left side
                if( (x - 1) >= 0 ) {
                    // Check if not ZERO
                    if (priorityBoard_[y][x - 1] != Priority.ZERO) {
                        // Check if already MEDIUM
                        if (priorityBoard_[y][x - 1] == Priority.MEDIUM) {
                            priorityBoard_[y][x - 1] = Priority.HIGH;
                        } else {
                            priorityBoard_[y][x - 1] = Priority.MEDIUM;
                        }
                    }
                }                                 
 
                // -----------------------------------                   
                // Now set all diagonal fields to ZERO      
 
                // Right upper corner
                if( (y - 1) >= 0 && (x + 1) < width ) {
                    priorityBoard_[y - 1][x + 1] = Priority.ZERO;
                }
 
                // Left upper corner
                if( (y - 1) >= 0 && (x - 1) >= 0 ) {
                    priorityBoard_[y - 1][x - 1] = Priority.ZERO;
                }
 
                // Right lower corner
                if( (x + 1) < width && (y + 1) < height ) {
                    priorityBoard_[y + 1][x + 1] = Priority.ZERO;
                }
 
                // Left lower corner
                if( (x - 1) >= 0 && (y + 1) < height ) {
                    priorityBoard_[y + 1][x - 1] = Priority.ZERO;
                }                                         
            }   
        }
    }   
}
Genialer Computerspieler

Die Krönung des Computergegners ist der „geniale“ Computerspieler. Der „geniale“ Computerspieler vereint alle Eigenschaften des „klugen“ Computerspielers und geht sogar noch einen Schritt weiter.

/**
 * Performs a computer move.
 */
@Override
public void move()
{  
    // Get the board size
    Coordinate size = getModel().getBoardSize( getId() );
    Coordinate c = null;
 
    // First check the game board and search for destroyed ships.
    // Mark the surrounding of the ship if this wasn't already done.
    for( int y = 0; y <= size.getY(); y++ ) {
        for( int x = 0; x <= size.getX(); x++ ) {
            try {
                // Get and check this field if it's a destroyed ship
                if( getModel().getEnemyBoardFieldState( getId(), new Coordinate(x, y) ).equals(FieldState.DESTROYED_SHIP)) {
                    // Return a mark command if we found an unknown coord around this destroyed ship
                    if( (c = checkFieldPeriphery( new Coordinate(x, y), size, FieldState.UNKNOWN )) != null)
                        getController().mark( this, c );
                }
            } catch(FieldOperationException e) {
                showMessage( e.getMessage() );
            }
        }
    } 
 
    // Evaluate the current board of the enemy
    Evaluation eval = new Evaluation( getModel().getEnemyBoard( getId() ) );
 
    // Get a possible coord from this evaluation
    c = eval.getProbableCoordinate();        
 
    // Make sure that we didn't found a coord where a ship was hit in the
    // surrounding. Only then use backtracking.
    if( checkFieldPeriphery( c, size, FieldState.HIT ) == null ) {  
 
        Fleet eFleet = getModel().getAliveEnemyFleet( getId() );
 
        // Use recursive backtracking if less than 4 ships in the enemys fleet 
        // are alive. Otherwise we will use the evaluation coord (Performance)
        if( eFleet.getTotalShips() < 4 ) {                
            showMessage( getModel().getString( "BATTLESHIP.START_BACKTRACKING" ) );
 
            // Create a new backtracking instance
            Backtracking bt = new Backtracking( getModel().getEnemyBoard( getId() ), eFleet );
 
            if( bt.calcProbabilities() ) {
                c = bt.getProbableCoordinate();
            }      
        }
    }
 
    // Return the shot
    getController().shoot( this, c );
}

Ein „genialer“ Computerspieler nutzt neben der Evalution eines Spielfeldes auch einen bekannten Algorithmus - das sogenannte Backtracking. Der Begriff Backtracking (engl. Rückverfolgung) bezeichnet eine Problemlösungsmethode innerhalb der Algorithmik.

Backtracking geht nach dem Versuch-und-Irrtum-Prinzip vor, d. h. es wird versucht, eine erreichte Teillösung schrittweise zu einer Gesamtlösung auszubauen. Wenn absehbar ist, dass eine Teillösung nicht zu einer endgültigen Lösung führen kann, wird der letzte Schritt bzw. die letzten Schritte zurückgenommen, und es werden stattdessen alternative Wege probiert. Auf diese Weise ist sichergestellt, dass alle in Frage kommenden Lösungswege ausprobiert werden können. Mit Backtracking-Algorithmen wird eine vorhandene Lösung entweder gefunden, oder es kann definitiv ausgesagt werden, dass keine Lösung existiert. Backtracking wird meistens am einfachsten rekursiv implementiert und ist ein prototypischer Anwendungsfall von Rekursion.

Die Klasse Backtracking sucht mithilfe des gleichnamigen Algorithmus nach Schiffen. Sobald dem Gegner weniger als 4 Schiffe verbleiben, wird der Algorithmus aktiv. Dabei versucht der Algorithmus alle möglichen Schiffspositionierungen in Erfahrung zu bringen. Der Computer weiß welche Schiffe dem Gegner verblieben sind. Mit diesen Informationen geht er alle möglichen Kombinationen durch, indem er versucht die restlichen Schiffe nacheinander auf dem Spielfeld zu positionieren.
Gelingt ihm das, inkrementiert er auf einem Feld mit Wahrscheinlichkeiten auf den entsprechenden Koordinaten die Zahlen. Diese Operation wird für alle möglichen Schiffspositionierungen durchgeführt. Am Ende entsteht dadurch ein zweidimensionales Zahlenfeld, wobei die erfolgversprechendsten Felder besonders hohe Zahlen aufweisen, weil die Wahrscheinlichkeit das ein Schiff diese Koordinaten besitzt hoch ist.

Der Backtracking Algorithmus arbeitet rekursiv. Dabei ensteht ein Suchbaum der folgenden Art.

backtracking

Alle „Zweige“ die nicht abgeschlossen werden, d.h. bei denen die Positionierung aller verbliebenen Schiffe fehlschlägt werden nicht weiter berücksichtigt. Die Klasse Backtracking implementiert den Suchalgorithmus in der Methode placeShip.

/** 
 * This is the recursive function for the backtracking algorithm that 
 * tests all possible ship constellations. It will build a search tree 
 * in depth, like this one:
 * 
 * <pre>
 * 
 *           1
 *         / | \
 *        2  7  8
 *      / |     | \
 *     3  6     9  12
 *   / |        |   \
 *  4  5       10   11
 * 
 * </pre>
 * 
 * @param shipNumber Number of the ship that is currently to be set
 */
private void placeShip(int shipNumber)
{
    // Get the current ship
    Ship ship = backtrackingBoard_.getFleet().getShipAt(shipNumber);
 
    // Cycle through the whole field and put the ship whereever possible.
    // Then recursively call this method with the next ship if this one 
    // is not the last one.
    for( int y = 0; y < backtrackingBoard_.getHeight(); y++ ) {
        for( int x = 0; x < backtrackingBoard_.getWidth(); x++ ) {
 
            ALIGNMENT:
            for( int align = 0; align < 2; align++ ) {   // If align 0 -> horizontally              
 
                int xpos = x;
                int ypos = y;
                int endpos = 0;
 
                // If ship cannot be positioned on the current coords then continue.
                if (align == 0) {
                    endpos = xpos + ship.getLength() - 1;
 
                    if (endpos >= backtrackingBoard_.getWidth())
                        continue ALIGNMENT;
 
                    for (int t = xpos; t <= endpos; t++)
                        if (backtrackingBoard_.getField(t, ypos).getFieldState() == FieldState.MARKED)
                            continue ALIGNMENT;
 
                } else {
                    // Test vertical ship placing
                    endpos = ypos + ship.getLength() - 1;
 
                    if (endpos >= backtrackingBoard_.getHeight())
                        continue ALIGNMENT;
 
                    for (int t = ypos; t <= endpos; t++)
                        if (backtrackingBoard_.getField(xpos, t).getFieldState() == FieldState.MARKED)
                            continue ALIGNMENT;
                } 
 
                // Was it possible to put the ship on the coordinates with alignment
                if( backtrackingBoard_.placeShip( ship.getShipType(), new Coordinate( x, y ), (align != 0) ) ) {
 
                    // Is the currently ship the last ship to put on the field?
                    if( shipNumber >= backtrackingBoard_.getFleet().getTotalShips() - 1 ) {
                        // We have an Endnode
 
                        // If yes, evaluate the generated ship-constellation
                        evalProbabilityBoard();
 
                        // Now delete this ship again, so we can move back in the 
                        // search tree and try to place this ship at other coords
                        backtrackingBoard_.removeShip( ship, FieldState.UNKNOWN );
                    } else {
 
                        // If there are more ships to be put on the board,
                        // go deeper in the tree.
 
                        // Call the placeShip method recursively with the next ship in the fleet
                        placeShip( shipNumber + 1 );
 
                        backtrackingBoard_.removeShip( ship, FieldState.UNKNOWN );
                    }
                }
            }
        }
    }
}

Bei der Tiefensuche werden bei maximal z möglichen Verzweigungen von jeder Teillösung aus und einem Lösungsbaum mit maximaler Tiefe von N im schlechtesten Fall 1 + z + z2 + z3 + ... + zN Knoten erweitert.

Die Tiefensuche und somit auch Backtracking haben im schlechtesten Fall nach der O-Notation mit O(zN) eine exponentielle Laufzeit. Das ist eine katastrophale Zeitkomplexität. Bei großer Suchtiefe n und Verzweigungsgrad z > 1 dauert die Suche somit oft sehr lange. Daher ist das Backtracking primär für Probleme mit einem kleinen Lösungsbaum geeignet, weshalb der Algorithmus erst bei vier verbliebenen Schiffen zum Einsatz kommt.

Mit dem „genialen“ Computerspieler endet die Programmierung der Views. Im Quellcodepaket sind alle implementierten Klassen für die Computerspieler, die Konsole und die GUI zu finden. Das Package eu.codeplanet.battleship.view enthält alle Views, auch die Klassen und Methoden, die in dem Artikel nicht explizit angesprochen wurden. Der letzte Schritt bei dem Entwurf von Battleship betrifft den Controller. Diesem wollen wir uns im nächsten Kapitel widmen.


Controller

Die View und der Controller implementieren das klassische Strategy-Muster: Die View ist ein Objekt, das mit einer Strategie konfiguriert ist; der Controller liefert diese Strategie. Die View ist nur für die visuellen Aspekte der Anwendung zuständig; alle Entscheidungen über das Verhalten der Schnittstelle delegiert es an den Controller. Durch die Verwendung des Strategy-Musters bleibt die View außerhalb entkoppelt vom Model, denn es ist der Controller, der bei der Bearbeitung von Benutzereingaben für die Interaktion mit dem Model zuständig ist. Die View weiß nichts darüber, wie das vor sich geht.

In dem Spiel Battleship gibt es nur einen Controller. Die Klasse BattleshipController erweitert die abstrakte Klasse AbstractController und stellt alle Methoden über das Interface ControllerInterface bereit.
Der Controller steuert den kompletten Spielfluss und vermittelt zwischen Model und View.

Neben den Methoden localGame und connect, finden sich im Controller viele weitere Methoden zur Steuerung des Spiels. Eine bekannte Methode, die Sie bereits in den Views sehen konnten ist die Methode shoot. Diese Methode führt bei einem Schuss alle wesentlichen Flußsteuerungen aus und ruft anschließend die entsprechende View wieder auf.

/**
 * Shoots on a fields on the enemy board.
 *
 * @param player
 * @param c
 */
@Override
public void shoot(PlayerInterface player, Coordinate c)
{
    try {
        // Call model
        if( getModel().shoot( player.getId(), c ) ) {
            // If successful, check if it was the last ship and if
            // the game is over and inform the player
            player.showMessage( getModel().getString( "BATTLESHIP.STRIKE" ) );
            if( getModel().isGameOver( player.getId() ) ) {
                // Proceed with the victory steps
                processVictory( player );
            } else {
                // Game not over, move on
                player.move();
            }
        } else {
            // If not successful, toggle player and let him move
            getActivePlayer().move();
        }
    } catch(FieldOperationException e) {
        // Coords are not a valid field
        player.showMessage( e.getMessage() );
        // Try again
        player.move();
    } catch(InvalidShotException e) {
        // Shot wasn't valid
        player.showMessage( e.getMessage() );
        // Try again
        player.move();
    }
}

Der Controller generiert auch die Hauptoberfläche und hält einen Verweis auf ihre Instanz. Eine weitere Methode im Controller ist die Methode checkForUpdates. Diese Methode ruft eine Instanz der Klasse VersionManager auf.

/**
 * Checks if updates are available.
 *
 * @param view  the view called
 */
@Override
public void checkForUpdates(ViewInterface view)
{
    VersionManager manager = new VersionManager( getModel(), view );
    manager.checkUpdateCheck( 3 ); // Perform update
}

Der VersionManager stellt eine Onlineverbindung mithilfe der Java-Klasse URLConnection her und überprüft ob es eine aktuellere Version von Battleship gibt, als die vom Benutzer gerade verwendete. Dazu wird eine Seite von Codeplanet kontaktiert, die mithilfe eines PHP-Skriptes die aktuelle Version übermittelt.

Die Seite kann testweise über http://www.codeplanet.eu/files/battleship/version.php?version=1.0.0&language=de_DE aufgerufen werden.

Findet das Programm eine aktuellere Version, wird der Nutzer über den installierten Systembrowser automatisch zum Installer der neuen Version weitergeleitet. Das Datum der letzten Aktualisierung wird in der Konfigurationsdatei gespeichert. Das Programm gestattet Aktualisierungen nur in bestimmten Zeitintervallen um ein Flooding der angegebenen Seite zu verhindern.

/**
 * Checks for a new battleship version. This method will open
 * an URLConnection to a remote server to check for program updates.
 * If any updates are available, it will redirect the user browser
 * to the installer package.
 *
 * @param notifyNoUpdate    if true, the user gets detailled informations
 * @return  true, if successful
 */
public boolean checkForNewVersion(boolean notifyNoUpdate)
{
    URL url = null;
 
    try {
        String base = "http://www.codeplanet.eu/files/battleship/version.php";
        String version = "?version=" + Version.getShortVersion();
        String language = "&language=" + model_.getConfig().getPreferences().getLocale();
        String address = base + version + language;
        url = new URL( address );
    } catch( MalformedURLException e ) {
        logger_.warning( model_.getString( "BATTLESHIP.UNABLE_TO_CHECK_FOR_UPDATED_VERSION" ) +
                "\n" + e.getMessage() );
 
        if( notifyNoUpdate ) {
            view_.showMessageDialog( null, e.getMessage(),
                    model_.getString( "BATTLESHIP.UNABLE_TO_CHECK_FOR_UPDATED_VERSION" ),
                    0, null );
        }
 
        return false;
    }
 
    InputStream inputStream = null;
    InputStreamReader inputStreamReader = null;
    BufferedReader reader = null;
 
    // Try to get connection
    try {
        URLConnection urlConnection = url.openConnection();
 
        urlConnection.setConnectTimeout( 5000 );
 
        if( urlConnection instanceof HttpURLConnection ) {
            logger_.info( model_.getString( "BATTLESHIP.UPDATE_CHECK_WITH_HTTP" ) );
            HttpURLConnection conn = (HttpURLConnection)urlConnection;
 
            if( conn.usingProxy() ) {
                logger_.info( model_.getString( "BATTLESHIP.HTTP_USING_PROXY" ) );
            }
        }
 
        inputStream = urlConnection.getInputStream();
        inputStreamReader = new InputStreamReader( inputStream );
        reader = new BufferedReader( inputStreamReader );
 
        String newVersLine = reader.readLine();
        String curVersLine = Version.getShortVersion();
 
        boolean newVersionAvailable = false;
 
        if( newVersLine != null && curVersLine != null ) {
 
            String newVersion = newVersLine;
 
            if( newVersion.compareTo(curVersLine) > 0 ) {
                newVersionAvailable = true;
 
                String[] filler = { newVersion };
 
                if ( view_.showConfirmDialog( null,
                        model_.getString( "BATTLESHIP.NEW_VERSION_AVAILABLE_DO_DOWNLOAD", filler ),
                        model_.getString( "BATTLESHIP.NEW_VERSION_AVAILABLE", filler ),
                        JOptionPane.YES_NO_OPTION, JOptionPane.PLAIN_MESSAGE, null)
                    == JOptionPane.YES_OPTION ) {
 
                    // Try to download installer
                    try {
                        BrowserManager.openURL( INSTALL_URL );
                    } catch( IOException e ) {
                        view_.showMessageDialog( null, e.getMessage(),
                            model_.getString( "BATTLESHIP.UNABLE_TO_OPEN_BROWSER" ), 0, null );
                    }
                }
            }
        }
 
        if( !newVersionAvailable && notifyNoUpdate ) {
 
            String[] filler = { Version.getShortVersion() };
 
            view_.showMessageDialog( null,
                    model_.getString( "BATTLESHIP.LATEST_VERSION_INSTALLED", filler ),
                    model_.getString( "BATTLESHIP.NO_UPDATE_AVAILABLE" ),
                    JOptionPane.INFORMATION_MESSAGE, null );
        }
 
    } catch( IOException e ) {
        logger_.warning(  model_.getString( "BATTLESHIP.UNABLE_TO_CHECK_FOR_UPDATED_VERSION" ) +
                " " + e.getMessage() );
 
        if( notifyNoUpdate ) {
            view_.showMessageDialog( null, e.getMessage(),
                    model_.getString( "BATTLESHIP.UNABLE_TO_CHECK_FOR_UPDATED_VERSION" ),
                    JOptionPane.INFORMATION_MESSAGE, null );
        }
 
        return false;
 
    } finally {
        if( inputStream != null ) {
            try {
                inputStream.close();
            } catch( IOException e ) {
            }
        }
 
        if( inputStreamReader != null ) {
            try {
                inputStreamReader.close();
            } catch( IOException e ) {
            }
        }
 
        if( reader != null ) {
            try {
                reader.close();
            } catch( IOException e ) {
            }
        }
    }
 
    return true;
}

Das Spiel

Zum Abschluß wollen wir einen Blick auf die Konstruktion des Spiels werfen. In der Klasse Main wird über Reflection die Basisklasse Game aufgerufen.

public class Main 
{
    /**
     * For now, fire off the Main class.
     * In the future, this class may do some logic to find out available
     * startup classes and pick one (like the uis one does).
     * 
     * @param args  arguments
     */
    public static void main(String[] args) throws IOException
    {
        try {
 
            Class startupClass = null;
 
            startupClass = Class.forName("eu.codeplanet.battleship.core.Game");
 
            final Constructor constructor = startupClass.getConstructor(new Class[] {
                String[].class
            });
 
            constructor.newInstance(new Object[] { args });
 
        } catch (Exception e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

Die Instanzierung beginnt mit der Konstruktion des Models. Unmittelbar nach der Konstruktion wird die essentielle Methode initialize aufgerufen, die das Model initialisiert. Schlägt die Initialisierung aus irgendeinem Grund fehl, wird sofort abgebrochen.

Nachdem das Model erfolgreich initialisiert wurde, wird der Controller instanziert. Diesem werden die Kommandozeilenargumente übergeben.

/**
 * This class will represent the start point for the console application.
 * We are using the MVC-Pattern and first generate a singleton model and then
 * pass it to the main controller.
 * 
 * <pre>
 *               +------------+
 *               |   Model    |
 *               +------------+
 *              /\ .          /\
 *              / .            \
 *             / .              \
 *            / .                \
 *           / \/                 \
 *    +------------+ <------ +------------+
 *    |    View    |         | Controller |
 *    +------------+ ......> +------------+
 * </pre>
 * 
 * @author  CodePlanet
 * @version 1.0.0, 04/06/2009
 * @see     <a href="http://www.codeplanet.eu/">http://www.codeplanet.eu/</a> 
 */
public class Game
{
    public Game(final String args[])
    {
        // The preferred way to transfer control and begin working with Swing
        // is to use invokeLater.
        Runnable init = new Runnable() {
            @Override
            public void run()
            {
                // Instantiate the model.
                final BattleshipModel model = BattleshipModel.getInstance();
 
                // Initialize model.
                if( model.initialize() == false ) {
                    System.exit( -1 );  // Major failure, shutdown
                }
 
                // In this line, the view gets instantiated by the controller, too.
                final ControllerInterface controller = new BattleshipController( model, args );
            }
        };
 
        java.awt.EventQueue.invokeLater( init );
    }
}

Mithilfe der Kommandozeilenargumente kann das Spiel über die Konsole gestartet werden. Dazu wird die folgende Syntax verwendet.

java -splash:null -Dfile.encoding=cp850 -jar "C:\Battleship.jar" console

Das Spiel kann durch den Doppelkick auf das Jar-Archiv auch automatisch gestartet werden. Allerdings startet es in diesem Fall direkt im Modus GUI mit grafischer Benutzeroberfläche.

Javadoc

Für das gesamte Projekt Battleship steht eine ausführliche Dokumentation zur Verfügung, die mit Javadoc generiert wurde. Javadoc ist ein Software-Dokumentationswerkzeug, das aus Java-Quelltexten automatisch HTML-Dokumentationsdateien erstellt. Die Daten für Battleship stehen unter http://codeplanet.eu/files/battleship/javadoc/ zur Ansicht bereit.

Sie können sich die HTML-Dokumentationsdateien näher ansehen. Dort erfahren Sie mehr über den Aufbau der Pakete und Klassen. Sie erhalten einen Überblick über die Methoden einer Klasse und ihren Zweck.

In der nachfolgenden Abbildung wurde das komplette Projekt mit einem Tool gemessen. Es zeigt die totale Anzahl an Klassen, Kommentarzeilen und Quelltextzeilen.

SLOC
Abbildung 3: Messung der »Source lines of code (SLOC)«

Schluss

Wir sind am Ende unseres Artikels angekommen. Sie haben Methoden und Wege kennengelernt, wie sich kleine Softwareprojekte planen und durchführen lassen. Sie haben wichtige Entwurfs- und Architekturmuster kennengelernt, eine Netzwerkbibliothek programmiert und sich mit einigen interessanten Algorithmen vertraut gemacht.

Dieser Artikel wurde fertiggestellt, als mit Version 1.0.0 die erste Betaversion von Battleship herausgegeben wurde. Die Betaversion ist bereits in großen Teilen einsetzbar. Einige Funktionen wurden in dieser Version allerdings noch nicht eingeführt und sind für spätere Versionen geplant. Dies betrifft nicht alle in diesem Tutorial gestellten Anforderungen. Diese sind bereits vollständig implementiert und funktionsfähig.

Das Projekt Battleship enthält noch viele weitere interessante Klassen, die Sie sich im Anhang näher ansehen können. Im Quelltext erfahren Sie wie Dokumente für den Chat serialisiert werden, bevor man sie über ein Netzwerk überträgt, wie sich Popupfenster in Java erstellen lassen, wie man seine eigenen Renderer programmiert und wie Ressourcen verwaltet werden. Darüberhinaus finden Sie viele nützliche Funktionen, die Sie in ihren Java-Programmen weiterverwenden können. Neben der sehr flexiblem Netzwerkbibliothek sind das auch kurze Codesnippets, z.B. zur Erzeugung von MD5- oder SHA-Hashwerten oder Methoden zur Manipulation von Bits und Bytes.

Wir hoffen Sie hatten Spaß beim Lesen dieses Artikels, haben einige neue Informationen gewinnen können und wünschen Ihnen viel Spass beim Nachprogrammieren.

Ihr CodePlanet Team.

Zuletzt aktualisiert am Montag, den 23. April 2012 um 12:59 Uhr
 
AUSWAHLMENÜ