Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme

2.2.2 Java Stream Sockets

Hinweis zur Programmierung

Im Folgenden wird die Verwendung von Stream Sockets in der Programmiersprache Java beschrieben. Und wie bereits bei den Ausführungen zum Programmieren mit Threads werden auch hier, wie in allen Abschnitten, die auf das Programmieren mit Java eingehen, lediglich elementare Vorgehensweisen beschrieben.

Client/Server-Verhalten

Eine Kommunikation zweier Anwendungen über Stream Sockets ist asymmetrisch, denn eine der beiden Anwendungen arbeitet als Server, die andere als Client. Wenn ein Client startet, dann erwartet er, dass ein Server für ihn bereits existiert und auf ihn wartet. Ist dies nicht der Fall, dann liegt eine Fehlersituation vor. Java-Anwendungen werfen in solchen Situationen nach einer Wartezeit (TimeOut) eine entsprechende Exception. Der Server ist also vor einem Client zu starten. Er wartet darauf, von einem Client angesprochen zu werden. Derartige Festlegungen zum Client/Server-Verhalten gehören bei den Netzwerkprotokollen zur Kommunikationssteuerung (vgl. Abschnitt 1.4.3), die im DoD-Modell Bestandteil der Anwendungsschicht ist, während im ISO/OSI-Modell dafür die Schicht 5 vorgesehen ist.

Erforderliche Java-Klassen

Ein Java-Programm, mit dem ein Server auf der Basis von Stream Sockets eingerichtet werden soll, benötigt die folgenden beiden Klassen aus dem java.net-Package:

Ein Client-Programm kommt mit der Klasse

aus. Die Art von Stream Socket, die bislang als Clientsocket bezeichnet worden ist, ist also eine Instanz der Java-Klasse Socket. Übrigens sind die Namen der beiden Klassen so gewählt worden, dass sie eindeutig den Stream Sockets zugeordnet werden können. Sie können allein schon deshalb nicht mit Klassen für Datagram Sockets verwechselt werden.

Serverseitige Programmierung

Zunächst werden die serverseitigen Abläufe und Vorgehensweisen bei der Programmentwicklung vorgestellt. Die Abarbeitung des Serverprogramms beginnt mit der Einrichtung eines sogenannten Serversockets. Es gibt einen Konstruktor der Java-Klasse ServerSocket, der mit einer Integerzahl parametrisiert werden kann. Diese Zahl ist die Portnummer, also die Adresse des Servers auf dem Host, auf dem er implementiert worden ist. Anschaulich soll der Server an diesem Port auf Anforderungen von Clients warten.

Ein Client, der diesen Server ansprechen will, muss dessen Portnummer kennen, und er muss auch die IP-Adresse des Hosts kennen, auf dem der Server implementiert worden ist. An dieser Stelle soll an die Socketinformation erinnert werden (vgl. Abschnitt 2.2.1), die zu jedem Socket gehört und aus den folgenden fünf Angaben besteht:

Protocol : Local Host : Local Port : Remote Host : Remote Port :

Zu einem Socket, der zum Senden benutzt werden soll, muss die vollständige Socketinformation vorliegen. Bei den folgenden Java-Beispielen ist jeweils angegeben, welchen Beitrag die einzelnen Befehle zum Zusammentragen dieser Information leisten.

Mit dem folgenden Befehl richtet eine Java-Anwendung einen Serversocket namens ss ein, der am Port 8761 auf Anforderungen durch Clients warten soll. Dazu sagt man kurz: Der Server richtet einen Serversocket ss am Port 8761 ein:

ServerSocket ss = new ServerSocket(8761);

Dass Portnummern in Gruppen eingeteilt worden sind und diese Einteilung Auswirkungen auf die Anwendungsprogramme hat, die mit ihnen arbeiten, soll hier ignoriert werden. Die Portnummer 8761 ist korrekt gewählt worden, und im Abschnitt 4.2 (Portnummern) wird auf die Gruppeneinteilung noch näher eingegangen.

Die Socketinformation des Serversockets im Beispiel ist nach der Ausführung des Java-Befehls nur teilweise vorhanden. Wenn der Konstruktor der Java-Klasse ServerSocket abgearbeitet ist, liegen lediglich die folgenden Angaben vor:

  1. Als Protocol-Wert konnte der Konstruktor TCP eintragen, denn der Konstruktorname ist eindeutig einem Stream Socket zuzuordnen.
  2. Die Angabe Local Host konnte durch das Auslesen einer lokalen Konfigurationsdatei mit der IP-Adresse des lokalen Hosts belegt werden.
  3. Der Local Port wurde aus dem Parameter des Konstruktoraufrufs übernommen.

Die Angaben Remote Host und Remote Port der Socketinformation sind vorerst nicht bekannt, das heißt, dass, falls der Server auf dem Host mit der IP-Adresse 141.64.89.65 implementiert worden ist, die Socketinformation zu dem Serversocket des Beispiels nach dem Konstruktoraufruf das folgende Aussehen hat:

Socketinformation zum Serversocket ss: Protocol : TCP Local Host : 141.64.89.65 Local Port : 8761 Remote Host : Remote Port :

Mit einer solchen Teilspezifizierung kann der Server an diesem Socket nicht senden, denn es ist kein Ziel vorhanden. Der Server kann an diesem Socket lediglich warten. Dieses Warten auf Clients erfolgt in der accept()-Methode der Java-Klasse ServerSocket:

Socket s = ss.accept();

Der accept()-Aufruf blockiert den Server und hält ihn blockiert, bis sich ein Client mit dem Server verbinden möchte. Das kann ein Client aber nur dann realisieren, wenn zu seinem Stream Socket, einer Instanz der Klasse Socket, alle fünf Angaben der Socketinformation vollständig vorliegen. Die Socketinformation des Sockets des Clients wird beim Verbindungsaufbau mit übertragen und vom Server übernommen, so dass auch dieser jetzt über die vollständige Socketinformation verfügt.

Verbindet sich ein Client mit dem Server, wird dieser aus seiner Blockierung im accept()-Aufruf befreit, und der Aufruf wird korrekt beendet. Er liefert als Rückgabewert eine Referenz auf einen Clientsocket, also auf eine Instanz der Socketklasse. Anschaulich verdoppelt der Server den Serversocket, an dem er gewartet hat, zu einem Clientsocket und ergänzt das Duplikat um die Angaben Remote Host und Remote Port, die der Client übertragen hat. Zu dem duplizierten Socket auf der Serverseite liegt die vollständige Socketinformation vor, zu dem auf der Clientseite lag sie schon vorher vor. Über diese beiden Clientsockets (Instanzen der Klasse Socket) kommunizieren jetzt Client und Server miteinander.

Clientseitige Programmierung

Ein Client richtet zunächst einen Clientsocket ein. Dazu bildet er eine Instanz der Java-Klasse Socket und benutzt dabei einen Konstruktor dieser Klasse, der zwei Parameter erwartet. Der erste ist ein String mit dem Domänennamen des Hosts, auf dem das Serverprogramm implementiert worden ist, während der zweite die Portnummer enthält, an dem der Server auf Clients wartet. Mit dem folgenden Beispiel richtet eine Java-Anwendung einen Socket ein, mit dem sie sich mit einem Server auf dem Host namens sun65.bht-berlin.de verbinden möchte, der am Port 8761 auf Clients wartet:

Socket cs = new Socket("sun65.bht-berlin.de", 8761);

Der in diesem Beispiel angegebene Rechner ist ein Laborrechner unserer Hochschule. Er hat den Domänennamen sun65.bht-berlin.de und die IP-Adresse 141.64.89.65. Der Konstruktor der Socketklasse trägt bei der im Beispiel gezeigten Parametrisierung sämtliche fünf Teile der Socketinformation zusammen:

  1. Der Konstruktor Socket() gehört eindeutig zu TCP. Er kann deshalb die Protocol-Angabe belegen.
  2. Einer lokalen Konfigurationsdatei entnimmt er die IP-Adresse des Hosts, auf dem er implementiert worden ist und kann so die Angabe Local Host anfügen.
  3. Zu jeder Implementierung der Internetprotokolle gehört ein Programm für die Verwaltung freier, lokaler Ports. Von dieser Portverwaltung lässt sich der Konstruktor einen freien Port geben und gelangt so zu der Angabe Local Port.
  4. Der erste Parameter des Konstruktoraufrufs ist ein String mit dem Domänennamen eines fernen Rechners. Mit diesem String wendet sich der Konstruktor an das Domain Name System und lässt sich von ihm die zugehörige IP-Adresse geben. Damit hat er auch die Angabe Remote Host.
  5. Der zweite Parameter des Konstruktoraufrufs ist direkt die Angabe des Remote Port.

Client/Server-Verbindungsaufnahme

Im Clientprozess liegt nach dem Konstruktoraufruf die vollständige Information für den Clientsocket vor. Der Client hat damit unter anderem eine Zielangabe und kann den Server ansprechen. Im Folgenden wird eine beispielhafte Verbindungsaufnahme gezeigt. Dabei soll dem Client von seiner Portverwaltungssoftware die Portnummer 1088 zugewiesen worden sein:

Auf dem Host sun65.bht-berlin.de wartet ein Server: ServerSocket ss = new ServerSocket(8761); Socket s = ss.accept(); Socketinformation zum Serversocket ss: Protocol : TCP Local Host : 141.64.89.65 Local Port : 8761 Remote Host : Remote Port : Auf dem Host sun70.bht-berlin.de mit der IP-Adresse 141.64.89.70 richtet ein Client den folgenden Clientsocket ein: Socket cs = new Socket("sun65.bht-berlin.de", 8761); Socketinformation zum Clientsocket cs: Protocol : TCP Local Host : 141.64.89.70 Local Port : 1088 Remote Host : 141.64.89.65 Remote Port : 8761 Der Aufruf des Konstruktors der Klasse Socket nimmt die Verbindung zum Server auf. Dieser verdoppelt seinen Socket ss und ergänzt das Duplikat s zu: Protocol : TCP Local Host : 141.64.89.65 Local Port : 8761 Remote Host : 141.64.89.70 Remote Port : 1088 Über die beiden Sockets cs und s erfolgt die Kommunikation zwischen Client und Server.

Lesen und Schreiben bei Stream Sockets

Stream Sockets folgen einem Client/Server-Kommunikationsmodell. Dabei beginnt immer der Client mit dem Verbindungsaufbau, während der Server passiv bleibt und auf Clients wartet. Dadurch ist jedoch nicht festgelegt, welcher der beiden Partner nach der Verbindungsaufnahme zuerst in den Socket schreibt oder aus dem Socket liest. Das wird von den beiden Anwendungen entschieden.

Für das Schreiben in bzw. Lesen aus einem Socket ist in Java der Mechanismus übertragen worden, der sich beim Umgang mit Dateien bewährt hat. Um die folgenden Beispiele programmiertechnisch möglichst einfach zu halten, wird mit einer Zeilenorientierung bei den Ein- bzw. Ausgaben gearbeitet. Dabei ist eine Zeile ein String, der genau ein NewLine-Zeichen, und zwar an seinem Ende, enthält. Das erlaubt die Verwendung der println()-Methode aus der Klasse PrintStream und der readLine()-Methode aus der Klasse BufferedReader. Beide Klassen gehören zum java.io-Package. In den folgenden Beispielen werden jeweils Zeilen in die Sockets geschrieben und Zeilen werden aus ihnen gelesen.

Angenommen, s ist ein Clientsocket, also eine Instanz der Socketklasse. Dann macht der folgende Aufruf diesen Socket als Stream zum Lesen von Zeilen verfügbar:

BufferedReader br = new BufferedReader( new InputStreamReader(s.getInputStream()));

Und mit

PrintWriter pw = new PrintWriter(s.getOutputStream());

können Zeilen in diesen Stream Socket geschrieben werden. Allerdings muss darauf geachtet werden, dass die Strings, die in den Socket geschrieben werden, auch wirklich Zeilen sind. Außerdem sollte beim Programmieren nach Schreibvorgängen in einen Socket stets mit einem flush()-Aufruf an den verwendeten PrintWriter das Ausschreiben der internen Puffer ausgelöst werden.

Beispiel 1: Echo-Serving

Als erstes geschlossenes Beispiel wird ein elementares Echo-Serving vorgestellt: Ein Client sendet einen String, den er von einem Benutzer über eine Konsoleneingabe angefordert hat, an den Server, der ihn, ohne ihn auch nur anzusehen, an den Client zurücksendet. Der Client beendet sich daraufhin, während der Server in einer Endlosschleife auf weitere Anforderungen durch Clients wartet. Sendet ein Client den String quit, dann beendet sich der Server.

In dem Beispiel wurde der Server auf dem Rechner sun65.bht-berlin.de der Berliner Hochschule für Technik am Port 9021 gestartet, der Client auf irgendeinem anderen Rechner der Hochschule. Es folgt ein Listing der Programmcodes von Server und Client:

EchoServer.java EchoClient.java

Sollen während der Programmentwicklung Client und Server auf ein und demselben Rechner implementiert werden, dann muss lediglich im Clientprogramm der Servername in der Stringvariablen serverName auf den Wert localhost gesetzt werden. Im Programmcode wird an der Stelle, an der das geschehen soll, darauf hingewiesen.

Beispiel 2: Kommunikation mit einem Web-Browser

In der zweiten Übungsaufgabe (siehe Abschnitt 7.2) wird verlangt, dass das Programm, das bei der ersten Übungsaufgabe (siehe Abschnitt 7.1) entstanden ist, als Benutzerschnittstelle einen handelsüblichen Web-Browser, hier kurz Browser genannt, verwenden muss. Als Vorbereitung zur zweiten Aufgabe wird im Folgenden beschrieben, wie ein Java-Programm mit einem Browser kommunizieren kann.

Jeder Browser ist ein Stream Socket Client. Er richtet einen Socket (einen Clientsocket) ein und versucht, sich mit Hilfe der Benutzereingaben mit einen Web-Server zu verbinden, von dem er dann, gesteuert durch die Benutzereingaben, beispielsweise eine Web-Seite anfordert. Er weiß, dass der Server, den er anspricht, in einem accept()-Aufruf an einem Serversocket wartet, falls er überhaupt gestartet worden ist. Kann er eine Verbindung aufbauen, dann kommuniziert er mit dem Server gemäß den Anforderungen des Anwendungsprotokolls HTTP.

Die folgenden Ausführungen müssen teils auf den Abschnitt 4.3 (World Wide Web) und teils auf den Abschnitt 5.3 (TCP) vorgreifen. Sie sind deshalb stellenweise etwas knapp gefasst. Angenommen, ein Benutzer an einem Browser auf einem Host in unserem Übungsraum gibt in die Eingabezeile für Web-Adressen im Browserkopf Folgendes ein:

http://sun65.bht-berlin.de:9876

Dann verlangt er damit von seinem Browser

  1. eine Verbindung zu dem Prozess am Port 9876 des Rechners sun65.bht-berlin.de aufzunehmen und
  2. von diesem Server die Leitseite anzufordern. (Hätte er eine andere als die Leitseite gewollt, dann hätte er seine Eingabe etwas anders fassen müssen!).

Wenn es dem Browser gelingt, Verbindung mit dem angegebenen Prozess (Port, Host) aufzunehmen, dann schreibt er einen HTTP-Request in Form von Textzeilen in seinen Socket. Von diesen Zeilen ist für das Beispiel lediglich die erste von Interesse. Sie lautet:

GET / HTTP/1.1

Die Zeile besteht aus drei Tokens, von denen

Als Anforderung des Clients ist im Beispiel ein Schrägstrich angegeben. Damit wird die Leitseite (welcome.html, ...) des Servers angesprochen, und das GET sagt, dass diese Seite übertragen werden soll.

Wird der Server nicht über die Browser-Eingabezeile, sondern über eine HTML-Form in einer HTML-Seite mit method=get angesprochen (vgl. Abschnitt 4.3), dann entsteht beim Browser durch seine Bearbeitung der HTML-Form zunächst eine Web-Adresse, die beispielhaft folgendermaßen aussieht:

http://sun65.bht-berlin.de:9876/?A=Meier&B=4711

Dabei sollen A und B die Namen zweier Eingabefelder der HTML-Form sein. Diese Adresse führt zu einem Request an den Server mit der folgenden ersten Zeile:

GET /?A=Meier&B=4711 HTTP/1.1

Der Server liest diese Zeile, isoliert die Feldwerte und macht eine eventuelle Kodierung von Sonderzeichen rückgängig (vgl. Abschnitt 4.3). Seine Antwort schreibt er zeilenweise in seinen Socket:

HTTP/1.1 200 OK Connection:close Content-Type:text/html <html> Hier beginnt der Datenteil. Gemäß Content-Type ist das eine HTML-Seite. Man beachte die Leerzeile am Kopfende. . . . </html>

Die erste Zeile der Antwort des Servers auf die Anforderung des Clients enthält nach der Protokollversionsangabe eine Erfolgs- bzw. Misserfolgsmeldung. Die Angabe 200 OK sagt aus, dass der Client-Request ordnungsgemäß bearbeitet werden konnte. Das Protokoll HTTP legt eine Liste solcher Spezifizierungszahlen fest. Und es legt auch fest, dass der Protokollkopf durch eine Leerzeile (zwei aufeinander folgende Newline-Zeichen) beendet werden muss.

Die Connection-Angabe in der zweiten Antwortzeile steuert den Frage/Antwort-Zyklus der Browser-Server-Kommunikation. Ein solcher Zyklus ist eine Einheit, bestehend aus einer Anfrage des Browsers, gefolgt von einer Antwort des Servers. Erst danach kann eine neuer Zyklus erfolgen. Die Voreinstellung der Connection-Angabe lautet deshalb connection: close. Sendet ein Browser dem Server den Hinweis connection: keep-alive, dann signalisiert er damit, dass er von dieser Voreinstellung abweichen möchte, um im Rahmen des aktuellen Dialogs weitere Daten auszutauschen ohne jedesmal eine neue Anfrage starten zu müssen. Der Server kann auf diesen Wunsch eingehen, ihn aber auch ablehnen. Im angegebenen Beispiel lehnt der Server den Wunsch des Clients ab. Er wird seinen Socket jetzt schließen. Das heißt, er erwartet einen neuen, vom ersten unabhängigen Request des gleichen oder eines anderen Clients und startet damit einen neuen Frage/Antwort-Zyklus.

In der dritten Zeile seiner Antwort, teilt der Server dem Client mit, dass er direkt im Anschluss an den HTTP-Protokollkopf die angeforderte HTML-Seite in seinen Socket schreiben wird. Man beachte die Leerzeile, die HTTP als Protokollkopfende fordert.

Ein accept()-Aufruf auf der Serverseite führt im Fall, dass eine Verbindung mit einem Client zustande kommt, zu einem neuen Socket, während der alte geschlossen wird. Ein Server, der nach seiner Antwort sofort wieder in einen accept()-Aufruf geht, muss einen eventuellen keep-alive-Wunsch eines Clients ablehen und seinerseits ein Connection: close verlangen.

Das zugehörige Java-Programm des Servers wurde auf dem Laborrechner sun65.bht-berlin.de implementiert und hat folgenden prinzipiellen Aufbau:

ServerSocket ss = new ServerSocket(9876); Socket cs = ss.accept(); BufferedReader br = new BufferedReader( new InputStreamReader(cs.getInputStream())); String zeile = br.readLine(); // ----------------------- // zeile verarbeiten // Antwortseite schreiben // ----------------------- PrintWriter pw = new PrintWriter(cs.getOutputStream()); pw.println("HTTP/1.1 200 OK"); pw.println("Connection:close"); pw.println("Content-Type:text/html"); pw.println(); pw.println("<html>"); ... pw.println("</html>"); pw.flush(); cs.close(); ss.close();

Favorite Icons

Die Ausführungen in diesem Abschnitt und die beiden Beispiele zur Kommunikation über Streamsockets sollten ausreichen, um die zweite Übungsaufgabe erfolgreich bearbeiten zu können. Jedoch soll noch auf einen sehr speziellen Aspekt bei Browseranfragen kurz eingegangen werden. Wendet sich ein Browser an einen Server, dann fordert er zunächst eine bevorzugte Ikone (favorite icon) an, die er direkt vor seiner URL-Eingabezeile ausgeben möchte. Es ist ein GET-Request, der folgende Form hat und mehrfach wiederholt auftreten kann:

GET /favicon HTTP/1.1

Da in der Übungsaufgabe keine bevorzugte Ikone übermittelt werden soll, sind diese Requests zu ignorieren. Das folgende Programm GetFilter.java zeigt, wie Clientanforderungen gefiltert werden können. Der zugehörige Prozess übernimmt dabei die Rolle eines Web-Servers und geht von einem einfachen Frage/Antwort-Zyklus aus. Er filtert favicon-Anforderungen und alle Requests, die keine GET-Requests sind, heraus und ignoriert sie. Wird das Programm beispielsweise in einer Konsole mit dem Java-Befehl

java GetFilter

aus dem Java Development Kit gestartet, dann wartet der Prozess in einem accept()-Aufruf auf Client-Requests. Wird auf dem selben Rechner dann ein Browser zum Beispiel über seine URL-Eingabezeile mit

http://localhost:9876

angewiesen, diesen Server anzusprechen, dann kann die Ablehnung unerwünschter Requests beobachtet werden. Ist ein GET-Request zulässig, dann wird er bearbeitet, wobei die Bearbeitung im Programmbeispiel lediglich im Zurückschicken der ersten Requestzeile in einer HTML-Seite besteht. Der Server arbeitet in einer Endlosschleife, aus der er nur betriebssystemabhängig, zum Beispiel unter Windows mit [ctr/c] oder durch das Schließen des Konsolenfensters, befreit werden kann. Eine kleine Erweiterung dieses Programms, die es erlaubt, den Server durch eine Benutzereingabe in der URL-Eingabezeile des Browser korrekt zu beenden, betrachte man als kleine Zusatzaufgabe.

GetFilter.java


Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme