Weitere aktuelle Java-Titel finden Sie bei dpunkt.
 Inhaltsverzeichnis   Auf Ebene Zurück   Seite Zurück   Seite Vor   Auf Ebene Vor   Eine Ebene höher   Index


13.2.4

Client/Server-Anwendung



In den bisherigen Beispielen werden Verbindungen zu Diensten benutzt, die schon auf einem Rechner zur Verfügung stehen. Die Daten werden dabei von einem Server bereitgestellt.

Ein Server stellt anderen Programmen einen Dienst zur Verfügung. Er wartet auf Anfragen (lokal oder innerhalb eines Netzes) und antwortet mit der gewünschten Aktion. Ein http-Server z. B. wartet auf Anfragen eines Browsers. Trifft eine Anfrage ein, schickt der Server dem Browser das entsprechende Dokument. Er »bedient« sozusagen den Client (in diesem Fall den Browser). Die meisten Server können gleichzeitig mit mehreren Programmen kommunizieren.

Ein Client hingegen ist ein Programm, das den Dienst, den der Server bietet, benutzt (z. B. Browser). Der Browser z. B. fordert vom Server ein Dokument an.

Wie bereits für Clients im letzten Abschnitt beschrieben wurde, können auch Server die Kommunikation über Datagramm-Sockets abwickeln. Die Server-Programmierung ist in diesem Fall nicht allzu schwierig, da ja in jedem ankommenden Paket der Absender vermerkt ist. Der Server bearbeitet das Paket und schickt anschließend die Antwort an den Rechner, von dem das Datagramm stammt, zurück.

Deshalb werden im folgenden Abschnitt Streamsockets für den Datenaustausch benutzt.

Bei den folgenden Ausführungen wird jeweils das Standard-Sicherheitsmodell vorausgesetzt, das in Abschnitt 15.1.3 beschrieben wird. Erweiterte Rechte, die man z. B. durch Editieren der System-Policies vergeben kann, werden nicht berücksichtigt. Mit dem Einsatz eines Servers ist es in Java möglich, zwischen zwei Applets zu kommunizieren, die in verschiedenen Browsern gestartet wurden. Auf dem Applet-Host muss hierzu ein Server-Programm laufen, mit dem ein Applet Kontakt aufnehmen kann. Ist dasselbe Applet gleichzeitig auf zwei verschiedenen Browsern aktiv, so können beide Applets mit dem Server Kontakt aufnehmen und über diesen Daten austauschen.

So ist es möglich, Chat-Programme und andere Netzwerkanwendungen, wie z. B. Datenbankanbindungen oder Netzwerkspiele, in Java zu programmieren. Ein Datenbank-Server z. B. würde mit einer Datenbank verbunden sein und die Möglichkeit besitzen, Queries zu formulieren. Ein Applet müsste nun Kontakt zu diesem Server aufnehmen und Daten aus der Datenbank von ihm anfordern. Der Server stellt die Query an die Datenbank und schickt anschließend die angeforderten Daten an das Applet. Sind die Daten beim Applet angekommen, können sie aufbereitet und präsentiert werden.

Abbildung 13.4: Kommunikation zwischen zwei Applets über einen Server
Abbildung 13.4

Server werden als Applikationen implementiert, da sie uneingeschränkten Netzwerkzugriff besitzen müssen und üblicherweise längere Zeit am Stück aktiv sind. Beides könnte mit Applets nicht gewährleistet werden, da diese im Normalfall nur bedingt Netzwerkzugriff besitzen.

An dieser Stelle wird die Implementierung eines Echo-Servers erläutert. Der Server ist recht einfach gehalten und somit leicht überschaubar. Anschließend kann der Echo-Server mit Hilfe des Echo-Clients aus Abschnitt 13.2.2 getestet werden.

Prinzipiell gibt es zwei verschiedene Techniken, wie man einen Server realisieren kann. Bei beiden Techniken wird vorausgesetzt, dass ein Server gleichzeitig mehrere Clients bedienen kann: Das Beispiel des Echo-Servers wird im folgenden auf beide oben genannten Arten implementiert.

Der Echo-Server soll maximal zehn Verbindungen gleichzeitig aufnehmen können. Erhält er drei Minuten lang von einem Client, mit dem er verbunden ist, keine Nachricht, so bricht der Server die Verbindung ab und hat somit wieder Kapazität zur Kontaktaufnahme mit einem neuen Client.

Iterativer Server

Für die Implementierung eines Servers mit Streamsockets stellt Java die Klasse ServerSocket zur Verfügung:
  public static void main(String args[]) {
    try {
      serversocket = new ServerSocket(PORT);
      cons = new Connection[MAX_CONNECTIONS];
      listener = new Listen(new EchoServerIter());
      listener.start();
    }
    catch(IOException e) {
      e.printStackTrace();
    }
  }

Ein neues Exemplar wird mit
  serversocket = new ServerSocket(PORT);
erzeugt. Dem Konstruktor wird die Port-Nummer, an der sich der Server befindet, übergeben.

Tritt ein Fehler beim Erzeugen dieses Exemplars auf, wird eine IOException ausgelöst. Dies kann z. B. der Fall sein, wenn der angegebene Port schon durch einen anderen Dienst belegt ist.

Um eine Verbindung zu einem anderen Host zu repräsentieren, wird hier die Klasse Connection definiert. Pro Verbindung wird ein Exemplar erzeugt. Sie besitzt folgenden Aufbau:
  class Connection {
    public Socket socket;      // Socket der Verbindung
    public BufferedReader in;  // Eingabestream
    public PrintWriter out;    // Ausgabestream
    public long time;       // Zeit der letzten Aktivität
    public boolean ok = true;  // Verbindungsstatus
    private InputStream stream;
  
    public Connection(Socket socket) {
      // Socket speichern
      this.socket = socket;
      try {
        // Streams Erzeugen
        stream = socket.getInputStream();
        in = new BufferedReader(
        	new InputStreamReader(stream, "latin1"));
        out = new PrintWriter(
        		new OutputStreamWriter(socket.getOutputStream(),
        			                     "latin1"), true);
        // Zeit des Verbindungsaufbaus merken
        time = new Date().getTime();
      }
      catch (IOException e) { // Bei Fehler
        ok = false;           // Verbindung nicht 'ok'
      }
    }
  
    // liefert true, wenn Daten Verfügbar sind
    public boolean available() throws IOException {
      return stream.available() != 0;
    }
  
    public void finalize() {
      // Schließen der Streams und des Sockets, wenn
      // die Verbindung gelöscht wird wird
      try {
        in.close();      // Eingabestream schließen
        out.close();     // Ausgabestream schließen
        socket.close();  // Socket schließen
      }
      catch(IOException e) {
        e.printStackTrace();
      }
    }
  }
In ihr sind alle Daten, die für eine Verbindung relevant sind, gespeichert: der Socket, die Ein- und Ausgabe-Streams, die Zeiten, zu denen die Verbindungen aufgebaut wurde, und ein boolesches Feld, das anzeigt, ob eine Verbindung richtig initialisiert wurde. Wenn ein Exemplar dieser Klasse vom Garbage Collector aus dem Speicher entfernt wird, werden durch Implementierung der Methode finalize() automatisch alle Streams und der Socket geschlossen.

Der Konstruktor bekommt den Socket übergeben, über den die Kommunikation mit dem zugehörigen Client stattfindet. Doch wie kommt man an diesen Socket?

Des Rätsels Lösung liegt in der Klasse Listen, von der in der Methode main() ein neues Exemplar kreiert wird. Ohne diese Klasse wäre das Beispielprogramm nicht in der Lage, mit einem anderen Programm zu kommunizieren:
  class Listen extends Thread {
    EchoServerIter server;
  
    public Listen(EchoServerIter server) {
      this.server = server;
    }
  
    public void run() {
      Socket socket;    // Socket einer neuen Verbindung
      Connection con;   // Daten einer neuen Verbindung
      while(true) {
        try {
          // Auf neue Verbindungen warten
          socket = server.serversocket.accept();
          // Neue Verbindung anlegen
          con = new Connection(socket);
          // Wenn die Verbindung 'ok' ist,
          // Verbindung hinzufügen
          if (con.ok)
            server.addConnection(con);
        }
        catch (IOException e) {
          e.printStackTrace();
        }
      }
    }
  }
Wie oben zu sehen ist, erwartet der Konstruktor von Listen ein Exemplar von EchoServer. Damit ist die Hauptklasse der Applikation gemeint. Dieses Objekt wird gebraucht, um auf das ServerSocket zuzugreifen und neue Verbindungen zuzufügen. Das Zufügen geschieht durch Aufruf der Methode addConnection(). Listen ist ein einfacher Thread, der nichts anderes tut, als auf neue Verbindungen zu warten.

Die Methode, die hierbei die Hauptaufgabe übernimmt, heißt accept():
  socket = server.serversocket.accept();
accept() blockiert den weiteren Ablauf der Methode so lange, bis eine Verbindung am ServerSocket aufgebaut ist.

Versucht ein anderer Host, am ServerSocket eine Verbindung aufzubauen, liefert accept() ein Exemplar von Socket, über das mit dem Client kommuniziert werden kann.

Der Server nimmt zunächst alle Anfragen am selben Port entgegen. Wenn eine Anfrage eines Clients vorhanden ist, legt der Server die Kommunikation mit dem Client auf einen anderen Port, um wieder für neue Anfragen zur Verfügung zu stehen. Der von accept() gelieferte Socket ist mit dem neuen Port verbunden.

Dies ist notwendig, um mehrere Verbindungen aufbauen zu können. Würde der Port des ServerSockets nicht freigegeben werden, könnte der Server nur mit einem Rechner gleichzeitig kommunizieren.

Mit dem neuen Socket wird anschließend ein Exemplar von Connection erzeugt und dem Array cons hinzugefügt, in dem alle Verbindungen verwaltet werden. Dies geschieht durch die Methode addConnection() der Klasse EchoServerIter :
  public void addConnection(Connection con) {
    // Kann noch eine Verbindung zugefügt werden?
    if (actConnections < MAX_CONNECTIONS) {
      // Hinzufügen der Verbindung
      cons[actConnections] = con;
      actConnections++;
    }
    else {
      // Sonst schicke Meldung zurück
      con.out.println
          ("Max. amount of connections is reached");
      con = null;
    }
  }

Ist die maximale Anzahl von Verbindungen noch nicht überschritten, wird die neue Verbindung dem Array cons hinzugefügt und actConnections inkrementiert. Wie die Verwaltung der Verbindungen genau realisiert ist, wird später erklärt.

Für die Ausgabe der Nachrichten ist die run()-Methode des Servers zuständig:
  public void run() {
    String text;
    while (true) { // Endlosschleife
      try {
        // Ist mindestens eine Verbindung vorhanden?
        if (actConnections != 0)
          for (int i = 0;i<actConnections;i++)
            // Liegt ein Timeout vor?
            if (! isTimeout(i))
              readData(i);
            else delConnection(i);
        t.sleep(100);
      }
      catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }

Nach dem Polling-Verfahren wird hier in Abständen von 100 Millisekunden nacheinander jede Verbindung zu einem Client nach folgenden Kriterien überprüft: Für die Überprüfung des Time-out besitzt der Server die Methode isTimeout():
  public boolean isTimeout(int index) {
    // Überprüfung der Verbindung nach Timeout
    if (new Date().getTime() >
                  (cons[index].time + NOOP_TIMEOUT)) {
      send("Timeout", index);
      return true;
    }
    return false;
  }

isTimeout() vergleicht die aktuelle Zeit mit der Zeit, zu der die letzte Nachricht vom Client angekommen ist.

Liegt dies länger als NOOP_TIMEOUT zurück, wird von run() aus delConnection() aufgerufen.
  public void delConnection(int index) {
    // aktuelle Verbindungsanzahl erniedrigen
    actConnections--;
    try {
      cons[index].socket.close();
    }
    catch (IOException e) {
      e.printStackTrace();
    }
    // Wenn nicht die Verbindung am Ende des Arrays
    // geschlossen wurde, Lücke im Array schließen
    if (actConnections != index)
      cons[index] = cons[actConnections];
    // Verbindung dereferenzieren
    cons[actConnections] = null;
  }

In delConnection() wird die Anzahl der aktuellen Verbindungen erniedrigt und der Socket anschließend geschlossen.

Die offenen Verbindungen werden innerhalb des Arrays cons in einem zusammenhängenden Bereich verwaltet (von Index 0 bis actConnections). Wird jetzt nicht die aktuelle Verbindung geschlossen, sondern eine andere, entsteht eine Lücke innerhalb dieses Bereichs. Um den Bereich wieder lückenlos zu gestalten, wird die Verbindung mit dem größten Index in diese Lücke geschrieben:
  cons[index] = cons[actConnections];
Dies ist notwendig, um zu gewährleisten, dass die offenen Verbindungen immer einen zusammenhängenden Bereich innerhalb des Arrays bilden.

Unterliegt die Verbindung keinem Time-out, wird überprüft, ob Daten vom Client angekommen sind. Hierzu ist der Server mit der Methode readData() ausgestattet:
  public void readData(int index) {
    String text;  // Datenpuffer
    try {
      // Sind Daten angekommen?
      if (cons[index].available())
        // Wenn ja, lies Daten
        if ((text = cons[index].in.readLine()) != null) {
          // Zeit neu setzen
          cons[index].time = new Date().getTime();
          // Daten an Absender zurückschicken
          send(text, index);
        }
    }
    catch (IOException e) {
      e.printStackTrace();
    }
  }

readData() bekommt ebenfalls einen Index übergeben, mit dessen Hilfe auf die jeweils benötigte Connection innerhalb des Arrays zugegriffen werden kann. Durch den Aufruf von available() wird geprüft, ob Daten angekommen sind. Ist dies der Fall, wird zuerst das time-Feld der betreffenden Connection aktualisiert. Anschließend wird der Text durch Aufruf der Methode send() an den Client zurückgeschickt:
  public void send(String message, int index) {
    // Nachricht zu einem anderen Host schicken
    cons[index].out.println(message);
  }

Bei diesem Beispiel wird davon ausgegangen, dass der Server ständig aktiv ist. Deswegen ist keine Terminierung des Programms vorgesehen. Will man einen Server schreiben, der nur eine bestimmte Zeit ausgeführt wird, sollte dieser vor seiner Beendigung alle noch offenen Sockets, einschließlich des ServerSockets, schließen. Sowohl die Klasse Socket als auch ServerSocket besitzt hierzu die Methode close().

Als Austauschformat zwischen Client und Server wird in diesem Fall iso-latin-1 verwendet. Zeichen werden vom Server sowohl in iso-latin-1 gelesen als auch geschrieben.

Der Server kann mit dem Beispiel EchoDemo aus dem vorigen Abschnitt getestet werden. Hierzu muss lediglich die Port-Nummer durch die Port-Nummer des eigenen Servers ersetzt werden.

Ändert man send() nun so ab, dass der ankommende Text nicht nur an den Client geschickt wird, der diesen sendet, sondern an alle Clients, die gerade mit dem Server verbunden sind, wird aus dem EchoServer ein ChatServer .

Konkurrierender Server

In allen Fällen, in denen die Anfrage eines Clients lange dauern kann, ist ein konkurrierender (multithreaded) Server einem iterativen Server vorzuziehen. Das liegt daran, dass ein konkurrierender Server Clients nebenläufig bedienen kann. Besonders einfach ist die Architektur eines konkurrierenden Servers, wenn zwischen den Clients, die gleichzeitig mit dem Server verbunden sind, keine Daten ausgetauscht werden müssen. Das ist z. B. bei Diensten wie ftp der Fall. Findet ein Datenaustausch zwischen Clients statt, muss eine Synchronisation zwischen den Clients stattfinden. Im folgenden wird zunächst ein konkurrierender Server ohne Datenaustausch zwischen den Clients am Beispiel des Echo-Servers erläutert. Die Klasse ConcurrentEchoServer stellt die eigentliche Server-Anwendung dar. In ihr ist die main()-Methode enthalten. ConcurrentEchoServer implementiert das Interface Runnable, damit der Server in der Lage ist, neu eingehende Verbindungen anzunehmen und gleichzeitig Clients zu bedienen. Dieser Thread wird sofort nach Erzeugen eines neuen Exemplars gestartet:
  public ConcurrentEchoServer () {
    try {
      server = new ServerSocket(7);
      listener = new Thread(this);
      listener.start();
    }
    catch(Exception e) {
      e.printStackTrace();
    }
  }

Im Konstruktor wird ein ServerSocket erzeugt und im Datenelement listener gespeichert. Nach Starten des Thread wird automatisch die run()-Methode ausgeführt. Dort wird durch Aufruf von accept() der Klasse ServerSocket auf eingehende Verbindungen gewartet (analog zum iterativen Server):
  public void run() {
    try {
      while(true) {
        Socket client = server.accept();
        new EchoClient(client).start();
      }
    }
    catch(Exception e) {
      e.printStackTrace();
    }
  }

Wurde eine Verbindung aufgebaut, liefert accept() das Socket zur Kommunikation mit dem Client zurück. Im nächsten Schritt wird der Unterschied zum iterativen Server erkennbar: Für jede neue Verbindung wird ein neues Exemplar der Klasse EchoClient erzeugt. EchoClient ist von Thread abgeleitet und bearbeitet die Anfragen genau eines Clients. Nach Erzeugen des Exemplars wird der Thread durch Aufruf von start() gestartet. Zuvor wird das Client-Socket als Parameter übergeben. Die Implementierung des Client-Threads ist sehr einfach. Im Konstruktor werden zunächst die Streams über das Socket abgefragt und in Datenelementen gespeichert:
  public EchoClient(Socket s) throws IOException {
    this.s = s;
    out = s.getOutputStream();
    in = s.getInputStream();
  }

In run() werden anschließend Zeichen eingelesen und an den Client zurückgeschrieben, bis keine Daten mehr vorhanden sind:
  public void run() {
    byte[] buffer = new byte[1024];
    int num;
    try {
      while((num = in.read(buffer)) != -1) {
        out.write(buffer, 0, num);
      }
    }
    catch(IOException e) {
      e.printStackTrace();
    }
  }

Wie man unschwer erkennen kann, bietet die Implementierung des konkurrierenden Servers gegenüber dem iterativen folgende Vorteile: All diese Gründe sprechen für die Implementierung eines konkurrierenden Servers. Nachteile zeigen sich jedoch beim Datenaustausch zwischen den gleichzeitig verbundenen Clients.

Material zum Beispiel

Mit einigen wenigen Änderungen kann man auch aus diesem Echo-Server einen Chat-Server machen: Zunächst müssen in der Hauptklasse des Servers in einem Datenelement Verweise auf alle aktiven Verbindungen gespeichert werden. In diesem Beispiel wird hierfür ein Vector verwendet.

Baut ein Client eine Verbindung zum Server auf, so wird in diesem Vector der Verweis auf den ChatClient gespeichert:
  public void run() {
    try {
      while(true) {
        Socket client = server.accept();
        ChatClient c = new ChatClient(this, client);
        c.start();
        connections.addElement(c);
      }
    }
    catch(Exception e) {
      e.printStackTrace();
    }
  }

Außerdem bekommt der Konstruktor von ChatClient einen Verweis auf die Hauptklasse des Servers übergeben. Über diesen Verweis findet die Kommunikation mit den anderen Clients statt.

Da ein Chat-System üblicherweise textorientiert arbeitet, wird in einem Client-Thread auch zeilenorientiert gearbeitet. Der Thread empfängt und schreibt Text als Unicode in der UTF-8-Codierung:
  public void run() {
    String text;
    try {
      while((text = in.readUTF()) != null) {
        server.broadcast(text);
      }
    }
    catch(IOException e) {
      e.printStackTrace();
    }
    server.closeConnection(this);
  }

Trifft bei einem Client eine Zeichenkette ein, so muss sie an alle anderen Clients geschickt werden. Diese Aufgabe übernimmt die Hauptklasse ConcurrentChatServer mit der Methode broadcast():
  public synchronized void broadcast(String message) {
    for(int i=0; i < connections.size(); i++) {
      ((ChatClient)connections.elementAt(i)).send(message);
    }
  }

Diese Methode muss synchronized deklariert werden, da sie prinzipiell von mehreren Client-Threads nebenläufig aufgerufen werden kann. broadcast wiederum ruft die Methode send() der einzelnen Client-Threads auf, in der der angekommene Text letztendlich verschickt wird:
  public void send(String message) {
    try {
      out.writeUTF(message);
    }
    catch(IOException e) {
      e.printStackTrace();
    }
  }


Material zum Beispiel


 Inhaltsverzeichnis   Auf Ebene Zurück   Seite Zurück   Seite Vor   Auf Ebene Vor   Eine Ebene höher   Index

Copyright © 2002 dpunkt.Verlag, Heidelberg. Alle Rechte vorbehalten.