Vorlesungsinhalt: Entwicklung verteilter Systeme

⚠️
Dieser Post ist Teil einer vierteiligen Serie, die im Rahmen eines Universitätsprojekts entstanden ist. In ihr wird der Vorlesungsinhalt des Moduls „Entwicklung verteilter Systeme“ zusammengefasst und um drei Schwerpunkte erweitert, die den Aufbau dieses Blogs beschreiben sollen. Update: Diese Blog-Serie wurde mit 1.0 bewertet 🎉.

In diesem Beitrag wird der Vorlesungsinhalt des Moduls „Entwicklung verteilter Systeme“ der DHBW-Mannheim erklärt und veranschaulicht. Der Lehrinhalt beschäftigt sich mit der Entwicklung verteilter Systeme und Netzwerke, insbesondere im Kontext von Server-Client-Beziehungen.

Vorlesung 1

Historische Entwicklung von Netzwerken

In der ersten Vorlesung wurde zu Beginn die historische Entwicklung von Netzwerken und der Kommunikation zwischen Computern diskutiert.

Die Implementierung von Ethernet in lokalen Netzwerken begann in den 1980er-Jahren und wurde in den 1990er-Jahren für das Internet verbreitet. Kollisionen von Daten, die über Koaxialkabel gesendet und empfangen wurden, waren ein häufiges Problem. Um diese Kollisionen zu vermeiden, wurden Ethernet-Segmente durch den Einsatz von Bridges und später Switches aufgeteilt. Diese Geräte analysieren ankommende Datenframes und leiten sie entsprechend weiter, wobei Switches auch in der Lage sind, Datenframes parallel zu verarbeiten, wodurch der Durchsatz erhöht wird.

Clientseitige und serverseitige Technologien

Zwei grundlegende Ansätze prägen die Art und Weise, wie Webseiten entwickelt und erweitert werden: clientseitige und serverseitige Technologien. Bei clientseitigen Technologien liegt die Hauptlast der Verarbeitung auf dem Client (also dem Endgerät des Benutzers), während bei serverseitigen Technologien der Server die Hauptarbeit leistet. Die Auswahl des optimalen Ansatzes hängt von den spezifischen Anforderungen des Projekts ab, wobei oftmals eine Kombination aus beiden Ansätzen am sinnvollsten ist.

Clientseitige Technologien

JavaScript ist eine der prominentesten clientseitigen Technologien, oft verwendet in Kombination mit Frameworks wie jQuery, um dynamisches Verhalten auf Webseiten zu ermöglichen. JavaScript ermöglicht es Entwicklern, Webseiten zu erstellen, die auf Benutzerinteraktionen reagieren, indem sie etwa Elemente hinzufügen, entfernen oder ändern.

Für mobile Plattformen gibt es spezifische Technologien wie SWIFT für iOS (Apple) und Java für Android (Google). Mit diesen Technologien können native Apps entwickelt werden, die speziell für diese Betriebssysteme optimiert sind und auf den Geräten selbst ausgeführt werden.

Java Applets

Java Applets, eine weitere clientseitige Technologie, sind kleine Anwendungen, die in der Programmiersprache Java geschrieben und direkt im Webbrowser ausgeführt werden können. Ursprünglich wurden sie entwickelt, um interaktive Funktionen auf Websites zu ermöglichen, die sonst schwierig oder unmöglich zu implementieren wären.

Java Applet Schaubild

Das Problem ist jedoch, dass Java Applets auf dem Client-PC ausgeführt werden und dabei direkten Zugriff auf das Betriebssystem haben. Dies stellt eine Gefahr dar, da schädlicher Code, auch bekannt als Malware, leicht auf dem Client-PC installiert werden kann, wenn er in einem unsicheren Applet ist. Aufgrund dieser Sicherheitsprobleme haben die meisten aktuellen Webbrowser Java Applets deaktiviert oder komplett entfernt.

Serverseitige Technologien:

Es gibt zahlreiche Programmiersprachen und Tools zur Implementierung, darunter PHP und Java. Außerdem sind zusätzliche Anwendungen wie Node.js (JavaScript-basiert), Flask und Django (beide Python-basiert) sowie Spring (Java-basiert) verfügbar, die alle verschiedenen Aspekte der Entwicklung sowohl client- als auch serverseitig abdecken.

Common Gateway Interface (CGI)

Das Common Gateway Interface, auch CGI genannt, ist eine Technik, die es einem Webserver ermöglicht, mit Softwareanwendungen zu kommunizieren und Daten zwischen ihnen auszutauschen. In den Anfängen des Internets wurde CGI hauptsächlich verwendet, um Webseiten interaktiver und dynamischer zu machen. Über CGI konnten Webserver Daten empfangen, die über ein HTML-Formular vom Benutzer gesendet wurden, und diese an ein Serverprogramm weiterleiten. Dieses Programm verarbeitet die Daten und gibt eine Antwort zurück, die der Webserver dann dem Benutzer als Webseite darstellt. Aufgrund von Sicherheits- und Leistungsbedenken wird CGI heute als überholt betrachtet und von neueren Technologien wie APIs ersetzt.

PHP: Einfachheit und Offenheit in der Webentwicklung

PHP ist eine dynamische und leistungsstarke Skriptsprache, die sich durch ihre Einfachheit und Offenheit auszeichnet und insbesondere in der Webentwicklung Anwendung findet. Der größte Vorteil von PHP besteht darin, dass es direkt in HTML-Code eingebettet werden kann, was einen reibungslosen Ablauf von serverseitigen Aufgaben ermöglicht. Hier ist ein einfaches Beispiel, wie PHP in HTML eingebunden wird, sodass eine Ausgabe mit „Hallo Welt!“ und einer Überschrift mit „Meine erste PHP-Seite“ entsteht:

<!DOCTYPE html>
<html>
	<body>
		<h1>Meine erste PHP-Seite</h1>
		<?php
		echo "Hallo Welt!";
		?>
	</body>
</html>

PHP-Beispiel: Simple Website

Java Servlets: Serverseitige Programme mit Erweiterungsfunktionen

Java Servlets sind spezialisierte Programme, die auf Webservern ausgeführt werden und auf Anfragen von Webclients reagieren. Sie übernehmen die Funktion, Anfragen zu bearbeiten und daraus resultierende Antworten zu generieren. Ihre Anwendungsfelder umfassen unter anderem die Erstellung komplexer, auf Benutzeranfragen reagierender Webseiten sowie die Bereitstellung diverser webbasierter Dienste. Speziell bei dynamischem Inhalt, der Nutzereingaben benötigt, zeigen Servlets ihre Stärke.

Objektspeicherung: Persistente Speicherung durch Dateien

Die Verwendung von Dateien ist einer der häufigsten Methoden zur persistierenden Speicherung von Daten. Es gibt Werkzeuge für das Erstellen, Lesen, Schreiben und Löschen von Dateien in Programmiersprachen. Es werden spezielle Formate und Strukturen verwendet, um diese Dateien lesbar und für die Programmiersprachen interpretierbar zu machen.

Als objektorientierte Programmiersprache ermöglicht Java die Arbeit mit Objekten, die reale Entitäten darstellen. Da diese Objekte jedoch in einer Form gespeichert werden müssen, die von Maschinen verstanden werden kann, kann das Speichern und Abrufen dieser Objekte schwierig sein. Die Serialisierung ist der Prozess, bei dem ein Objekt in einen maschinenlesbaren Zustand umgewandelt wird. Der Zustand eines Objekts wird durch Serialisierung gespeichert und kann bei Bedarf wiederhergestellt werden, wodurch die Datenpersistenz gewährleistet wird.

Übersetzung von Objektstrukturen in speicherbare Daten

Die Serialisierung in Java transformiert sowohl die Struktur eines Objekts – dessen „Objektbauplan“ – als auch spezifische Instanzen dieses Objekts, sodass sie dauerhaft in einer Datei gespeichert werden können. Dies beinhaltet das gesamte Gerüst des Objekts: Attribute, Funktionen und Implementierungen. Allerdings werden nicht alle Objekte automatisch serialisiert, da dies die Leistung beeinträchtigen könnte. Um ein Objekt serialisierbar zu machen, müssen Entwickler:innen es explizit als solches markieren, indem das Serializable-Interface verwendet wird. Dieses muss in die Java-Klassendatei integriert werden, wie es im folgenden Code Ausschnitt gemacht wurde:

import java.io.*;

public class MeinObjekt implements Serializable {

} 

Definition Java-Klasse, die das „Serializable“-Interface implementiert

Der finale Schritt zur Speicherung der serialisierbaren Objekte in einer Datei erfolgt über sogenannte Streams.

Streams: Verwaltung von Datenströmen in Java

In Java werden Datenströme, auch Streams genannt, mittels Byte-Streams (8 Bit) und Character-Streams (16 Bit) gehandhabt. Jeder Stream verfügt über eine Quelle und ein Ziel, wobei Daten kontinuierlich von der Quelle zum Ziel transportiert werden. Ein Stream kann zusätzlich einen neuen Stream erzeugen, um beispielsweise eine Verkettung von Streams zu ermöglichen. Das ermöglicht es, ein Objekt zu serialisieren und in eine Datei zu schreiben. Es ist wichtig zu betonen, dass diese Art von Streams nicht mit denjenigen, der Java Stream API verwechselt werden sollte, die primär in der funktionalen Entwicklung Anwendung finden.

Schreiboperationen mit Objekten: Stream-Verkettungen und IOExceptions

Die Speicherung und Abrufung von Objekten in und aus Dateien kann durch den Einsatz verschiedener Datenströme (Streams) erreicht werden. Im Folgenden werden konkrete Verfahren zur Schaffung solcher Streams dargelegt.

Für die Erstellung eines Datenstroms zu einer Zieldatei wird ein Objekt des Typs FileOutputStream verwendet. Hierbei kann der überladene Konstruktor der Klasse zur Erzeugung dieses Datenstroms herangezogen werden. Die konkreten Codebeispiele sind:

FileOutputStream fileOutputStream = new FileOutputStream("meineDatei.ser");
// Eine Zieldatei wird mit dem im Konstruktor des Streams spezifizierten Namen erstellt

FileOutputStream fileOutputStream2 = new FileOutputStream(new File("meineDatei2.ser"));
// Die Zieldatei wird mithilfe eines File-Objekts aus dem java.io-Paket instanziiert

Stream Erstellung

Anschließend kann ein ObjectOutputStream hinzugefügt werden, das die Umwandlung eines Objekts in eine für den Stream passende Darstellung ermöglicht. Diese Umwandlung ist notwendig, um das Objekt in eine Datei schreiben zu können. Java erfordert dabei die Prüfung auf eine IOException, um potenzielle Fehler zu berücksichtigen.

import java.io.*;

public class MeinObjekt implements Serializable {
	public static void main(String[] args) {
		try {
			FileOutputStream fileOutputStream = new FileOutputStream("meineDatei.ser");
			ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
			objectOutputStream.writeObject(new MeinObjekt());
			objectOutputStream.close();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

ObjectOutputStream Write mit Try-Catch IO Exception

Es ist wichtig, den Datenstrom nach dem Schreibvorgang zu schließen, um die Operation vollständig abzuschließen.

Objektlesen: FileInputStream und ObjectInputStream

In ähnlicher Weise kann ein Objekt aus einer Datei gelesen werden, indem FileInputStream und ObjectInputStream verwendet werden. Der Begriff „Input“ weist darauf hin, dass Daten aus einer Datei in das Programm geladen werden.

import java.io.*;

public class MeinObjektTest {
	public static void main(String[] args) {
		try {
			FileInputStream fileInputStream = new FileInputStream("meineDatei.ser");
			ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
			Object inputObject = objectInputStream.readObject();
			MeinObjekt myObject = (MeinObjekt) inputObject;
			objectInputStream.close();
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}
}

ObjectInputStream Read

Übung: Speichern und Wiederherstellen von Spielfiguren

Das Hauptziel dieser Übung ist es, den Zustand von drei Spielfiguren zu speichern und später wiederherzustellen. Dazu werden die Spielobjekte serialisiert, was bedeutet, dass sie in eine Form umgewandelt werden, die in einer Datei gespeichert oder über ein Netzwerk übertragen werden kann. Später können diese serialisierten Objekte deserialisiert, also in ihren ursprünglichen Zustand zurückgebracht werden.

Zunächst werden die Spielfiguren erstellt und in einem Team-Objekt zusammengefasst. Anschließend wird dieses Team-Objekt serialisiert und in eine Datei geschrieben. Danach werden die Referenzen auf die Spielfiguren und das Team auf null gesetzt, um sicherzustellen, dass die nachfolgende Deserialisierung tatsächlich aus der Datei erfolgt und nicht etwa die ursprünglichen Objekte verwendet. Schließlich wird das Team-Objekt aus der Datei gelesen (deserialisiert) und in seine ursprüngliche Form zurückversetzt.

Die Klassen Spielfigur und Team repräsentieren die Spielcharaktere und ihre Zusammenstellung in Teams. Beide Klassen implementieren das Interface Serializable, was die Voraussetzung dafür ist, dass ihre Instanzen serialisiert und deserialisiert werden können.

package dhbw.semester4.verteiltesysteme.Aufgabe1;

import java.io.Serializable;

public class Team implements Serializable {
    Spielfigur Spielfigur1;
    Spielfigur Spielfigur2;
    Spielfigur Spielfigur3;

    public Team(Spielfigur f1, Spielfigur f2, Spielfigur f3){
        this.Spielfigur1 = f1;
        this.Spielfigur2 = f2;
        this.Spielfigur3 = f3;
    }
}

Team Klasse

package dhbw.semester4.verteiltesysteme.Aufgabe1;

import java.io.Serializable;
public class Spielfigur implements Serializable {
    int staerke;
    String typ;
    String[] waffen;

    public Spielfigur(int s, String t, String[] w){
        this.staerke= s;
        this.typ = t;
        this. waffen= w;
    }

    public int getStaerke(){
         return staerke;
    }

    public String getTyp() {
        return typ;
    }
    public String getWaffen() {
        String waffenListe = "";
        for (int i = 0; i < waffen.length; i++){
            waffenListe += waffen[i] + "";
        }
        return waffenListe;
    }
}

Spielfigur Klasse

Der Hauptteil des Programms (SpielspeicherungTest) erstellt zuerst drei Spielfiguren und ein Team und serialisiert dann das Team-Objekt in eine Datei (Spiel.ser). Danach setzt es alle Objektreferenzen auf null, um sicherzustellen, dass die nachfolgende Deserialisierung tatsächlich aus der Datei erfolgt. Schließlich liest es das Team-Objekt aus der Datei und stellt es wieder her. Das resultierende Team-Objekt wird verwendet, um die Typen der Spielfiguren auszugeben, die nun wiederhergestellt sind.

package dhbw.semester4.verteiltesysteme.Aufgabe1;

import java.io.*;

public class SpielspeicherungTest {
	public static void main(String[] args) {
		// Erstellen von Spielfiguren und einem Team
		Spielfigur elf = new Spielfigur(50, "Elb", new String[] {"Bogen", "Schwert", "Staub"});
		Spielfigur troll = new Spielfigur(200, "Troll",new String [] {"blosse Haende", "grosse Axt"});
		Spielfigur magier = new Spielfigur(120, "Zauberer",new String [] {"Zaubersprüche", "Unsichtbarkeit"});
		Team team = new Team(elf, troll, magier);

		try{
			// Serialisierung des Team-Objekts
			ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("Spiel.ser"));
			os.writeObject(team);
			os.close();
		} catch (IOException ex) {
			ex.printStackTrace();
		}

		// Setzen der Referenzen auf null
		elf = null;
		troll = null;
		magier = null;
		team = null;
		
		try{
			// Deserialisierung des Team-Objekts
			ObjectInputStream is = new ObjectInputStream(new FileInputStream("Spiel.ser"));
			Team Mitgliedschaft = (Team) is.readObject();
			is.close();

			// Ausgabe der Typen der wiederhergestellten Spielfiguren
			System.out.println("Spielfigur 1 ist im Team: " + Mitgliedschaft.Spielfigur1.getTyp());
			System.out.println("Spielfigur 2 ist im Team: " + Mitgliedschaft.Spielfigur2.getTyp());
			System.out.println("Spielfigur 3 ist im Team: " + Mitgliedschaft.Spielfigur3.getTyp());
		} catch (Exception ex)  {
			ex.printStackTrace();
		}
	}
}

SpielspeicherungTest Klasse

Insgesamt demonstriert der Code die grundlegenden Verfahren der Serialisierung und Deserialisierung in Java, die es ermöglichen, den Zustand von Objekten zwischen verschiedenen Ausführungen eines Programms oder sogar zwischen verschiedenen Programmen zu übertragen.

Vorlesung 2

Textdateien in Java bearbeiten

Byte-Streams und Character-Streams sind die beiden Haupttypen von Strömen, die in Java verwendet werden können, um eine Datei zu lesen oder zu schreiben. Character-Streams können Textdateien lesen und schreiben, während Byte-Streams binäre Daten lesen und schreiben können. Dieser Beitrag behandelt Charakterströme, die an der Reader/Writer-Endung identifiziert werden können.

Text in eine Datei schreiben

Um einen Text in eine Textdatei zu schreiben, muss ein Writer-Stream geöffnet werden. Es wird die Java-Klasse FileWriter genutzt. Wie dies in der Praxis aussieht, zeigt der folgende Codeblock:

public static void main(String[] args) {
	try {
		FileWriter writer = new FileWriter("testDatei.txt");
		writer.write("Das hier ist die erste Zeile");
		writer.close();
	} catch (Exception ex) {
		ex.printStackTrace();
	}
} 

FileWriter schreibt die Datei testDatei.txt

In diesem Code wird zuerst ein FileWriter-Objekt erzeugt und geben diesem dabei den Namen der Datei an, die erstellt werden soll. Dann wird die Methode write() des FileWriter-Objekts genutzt, um einen String in die Datei zu schreiben. Schließlich wird der Writer mit der Methode close() geschlossen. Sollte bei der Ausführung des Codes ein Fehler auftreten, wird die catch-Klausel ausgeführt und der Fehler ausgegeben.

Text aus einer Datei lesen

Um Text aus einer Datei zu lesen, wird die Java-Klasse FileReader genutzt. Hier ist ein Beispiel, wie das gemacht werden könnte:

public static void main(String[] args) {
	try {
		FileReader reader = new FileReader("testDatei.txt");
		char characterCode = (char) reader.read();
		reader.close();
	} catch (Exception ex) {
		ex.printStackTrace();
	}
} 

Filereader liest die Datei testDatei.txt

In diesem Code wird ein FileReader-Objekt erstellt, welches die ersten Zeichen aus der Datei liest. Die Methode read() gibt den ASCII-Code des gelesenen Zeichens als Integer zurück. Dieser Integer wird dann in ein char gecastet und in der Variable characterCode gespeichert.

Gepuffertes Lesen und Schreiben

Das Lesen oder Schreiben jedes einzelnen Characters kann bei großen Dateien ineffizient sein. Deshalb werden oft gepufferte Reader und Writer (BufferedReader und BufferedWriter) verwendet. Ein gepufferter Reader oder Writer nimmt einen normalen Reader oder Writer als Argument in seinem Konstruktor und ermöglicht das zeilenweise Lesen oder Schreiben. Hier ist ein Beispiel, wie ein BufferedReader umgesetzt werden kann:

public static void main(String[] args) {
	try {
		BufferedReader reader = new BufferedReader(new FileReader("testDatei.txt"));
		String zeile = reader.readLine();
		System.out.println(zeile);
		reader.close();
	} catch (Exception ex) {
		ex.printStackTrace();
	}
} 

BufferedReader liest gesamte Zeile von testDatei.txt

Um den Code effizienter zu gestalten, kann mithilfe der Methode readLine() eine ganze Zeile gelesen werden.

Übung: Erstellen und kopieren einer Textdatei

Die Übung zielt darauf ab, eine Textdatei mit mehreren Zeilen zu erstellen und diese dann zu kopieren. Es gibt zwei grundsätzliche Methoden, um dieses Ziel zu erreichen:

  1. Die Datei wird zeilenweise gelesen und diese Zeilen werden dann in eine neue Datei geschrieben.
  2. Die gesamte Datei wird auf einmal gelesen, in einen mehrzeiligen String umgewandelt und dieser String wird dann in eine neue Datei geschrieben.

Die gegebene Implementierung folgt dem ersten Ansatz. Der unten angegebene Code demonstriert diesen Prozess:

package dhbw.semester4.verteiltesysteme.Aufgabe2;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class FileRead {
    public static void main(String[] args) {
        Path dir = Paths.get("C:", "Users", "asauna9674", "Downloads", "verteilteSysteme", "src", "main", "java", "dhbw", "semester4", "verteiltesysteme");
        String fileName = "example.txt";
        Path file = dir.resolve(fileName);
        StringBuilder content = new StringBuilder();

Pfade für die Datei hinterlegen

Im obigen Codeabschnitt wird der vollständige Pfad zur Datei erzeugt. Die Methode Paths.get() erzeugt ein Path-Objekt, das den Pfad zur Datei darstellt, und dir.resolve(fileName) generiert den vollständigen Pfad zur Datei.

Weiter geht es mit dem Einlesen der Datei:

        // Reading
        try (BufferedReader reader = Files.newBufferedReader(file)) {
            String line;
            while ((line = reader.readLine()) != null) {
                content.append(line).append(System.lineSeparator());
            }
            // Printing content
            System.out.println("File content:");
            System.out.println(content);
        } catch (IOException e) {
            System.out.println("Error reading file: " + e.getMessage());
            return;
        }

Datei lesen

Die BufferedReader-Klasse wird verwendet, um die Datei zeilenweise zu lesen. Der Inhalt jeder Zeile wird dann an ein StringBuilder-Objekt angehängt, das verwendet wird, um einen einzigen String mit dem gesamten Inhalt der Datei zu erstellen.

Im nächsten Schritt wird eine Kopie der Datei erstellt:

        // Copying File
        String copiedFileName = "example_copy.txt";
        Path copiedFile = dir.resolve(copiedFileName);
        try (BufferedWriter writer = Files.newBufferedWriter(copiedFile)) {
            writer.write(content.toString());
            System.out.println("File copied to: " + copiedFileName);
        } catch (IOException e) {
            System.out.println("Error copying file: " + e.getMessage());
        }
    }
}

Datei-Kopie durchführen

Ein BufferedWriter-Objekt wird verwendet, um den Inhalt des StringBuilder-Objekts in die neue Datei zu schreiben. Falls beim Schreibvorgang eine IOException auftritt, wird eine Fehlermeldung ausgegeben.

Interaktion mit Netzwerken durch Sockets

Streams in Java besitzen nicht nur die Fähigkeit, Informationen in Dateien zu schreiben, sondern auch, um als Client mit Servern zu kommunizieren. Für die Kommunikation mit Netzwerken stellt Java verschiedene Arten von Sockets zur Verfügung, wobei jeder Socket eine IP-Adresse und einen Port besitzt.

Client-Socket

Ein Client-Socket dient dazu, eine Verbindung zu einem Server-Socket an einer bestimmten IP-Adresse und einem bestimmten Port zu initiieren. Ein solcher Socket kann in Java mit der Socket-Klasse erstellt werden. Hier ist ein Beispiel:

public static void main(String[] args) {
	Socket neuerClient = new Socket("127.0.0.1", 5000);
}

Client-Socket erstellen

In diesem Beispiel wird ein neuer Socket erstellt, der eine Verbindung zum lokalen Host (IP-Adresse: 127.0.0.1) auf Port 5000 initiieren wird.

Datenströme lesen mit InputStreamReader

Sobald der Socket erstellt wurde, können Daten von diesem Socket gelesen werden. Dazu wird in Java ein InputStreamReader verwendet. Dieser wandelt Bytes, die vom Socket empfangen werden, in Zeichen um.

public static void main(String[] args) {
	Socket neuerClient = new Socket("127.0.0.1", 5000);
	InputStreamReader inputStreamReader = new InputStreamReader(neuerClient.getInputStream());
}

Eingehende Daten über InputStreamReader lesen

In dem obigen Code erstellt der Aufruf von neuerClient.getInputStream() einen InputStream, der mit dem Socket verbunden ist. Der InputStreamReader wickelt diesen InputStream und konvertiert die darin enthaltenen Bytes in Zeichen.

Datenpufferung und -verarbeitung mit BufferedReader

Um das Lesen der Daten zu optimieren, wird oft ein BufferedReader verwendet. Dieser liest Text aus einem Zeichen-Eingabestream und puffert die Zeichen, um die Eingabe durch Zeichen, Arrays und Zeilen effizient zu ermöglichen.

public static void main(String[] args) {
	Socket neuerClient = new Socket("127.0.0.1", 5000);
	InputStreamReader inputStreamReader = new InputStreamReader(neuerClient.getInputStream());
	BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
	String zeile = bufferedReader.readLine();
}

BufferedReader wird hinzugefügt

Hier wird ein BufferedReader erstellt, der den InputStreamReader umhüllt. Dieser kann dann dazu verwendet werden, Daten aus dem InputStream zeilenweise zu lesen, was das Einlesen und Verarbeiten der Daten erheblich vereinfacht.

Server-Socket

Während ein Client-Socket dazu dient, eine Verbindung zu einem Server zu initiieren, dient ein Server-Socket dazu, eingehende Verbindungsanfragen zu akzeptieren. Ein Server-Socket kann in Java mit der ServerSocket-Klasse erstellt werden. Hier ist ein Beispiel:

public static void main(String[] args) {
	try {
		ServerSocket serverSocket = new ServerSocket(4242);
		while(true) {
			Socket sock = serverSocket.accept();
		}
	} catch(Exception ex) {
		ex.printStackTrace();
	}
}

ServerSocket Erstellung

In diesem Code wird ein ServerSocket erstellt, der auf Port 4242 reagiert. Die Methode serverSocket.accept() wartet, bis ein Client eine Verbindung anfordert und akzeptiert diese Verbindung, indem sie einen neuen Socket zurückgibt, der mit dem Client-Socket verbunden ist.

Vorlesung 3

Übung: Erstellung eines „Tipp des Tages“ Generators

In dieser Vorlesung wurde die Übung zur Erstellung eines „Tipp des Tages“ Generators, der eine Client-Server-Architektur nutzt. Der Server wählt zufällig einen Tipp aus einer vordefinierten Liste aus und der Client zeigt diesen Tipp an.

TippDesTagesClient

Zunächst wird der Client konstruiert. Die Aufgabe des Clients besteht darin, mehrere Zeilen zu lesen, die vom Server gesendet werden.

package dhbw.semester4.verteiltesysteme.Aufgabe3;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;

public class TippDesTagesClient {
    private static final String HOST = "localhost";
    private static final int PORT = 4242;

    public void startClient() {
        try (Socket socket = new Socket(HOST, PORT);
             BufferedReader reader  = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
            String tipp;
            while ((tipp = reader.readLine()) != null) {
                System.out.println("Tipp des Tages: " + tipp);
            }
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }

    public static void main(String[] args) {
        new TippDesTagesClient().startClient();
    }
}

TippDesTagesClient Klasse

In diesem Codeausschnitt wird ein Socket-Objekt erstellt, um eine Verbindung zum Server herzustellen. Ein BufferedReader-Objekt liest dann die Zeilen, die vom Server empfangen werden. Diese Zeilen stellen die Tipps dar, die dann auf dem Bildschirm ausgegeben werden.

TippDesTagesServer

package dhbw.semester4.verteiltesysteme.Aufgabe3;

import java.io.IOException;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class TippDesTagesServer {
    private static final int PORT = 4242;
    private static final String[] TIPP_LISTE = {
            "Geduld ist eine Tugend",
            "Schokolade macht glücklich",
            "Der Weg ist das Ziel",
            "Lachen ist die beste Medizin",
            "Ein Lächeln kann den Tag erhellen",
            "Jeder Tag ist eine neue Chance",
            "Der beste Moment ist jetzt"
    };
    
    public void startServer() {
        try (ServerSocket serverSock = new ServerSocket(PORT)) {
            while (true) {
                try (Socket sock = serverSock.accept();
                     PrintWriter writer = new PrintWriter(sock.getOutputStream())) {
                    for (String tipp : TIPP_LISTE) {
                        writer.println(tipp);
                    }
                    writer.flush();
                    System.out.println("Tipp-Liste gesendet");
                }
            }
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }

    public static void main(String[]args) {
        new TippDesTagesServer().startServer();
    }
}

TippDesTagesServer Klasse

Auf der Serverseite wird ein ServerSocket erstellt, der auf eingehende Verbindungen wartet. Sobald eine Verbindung angenommen wurde, verwendet das Programm ein PrintWriter-Objekt, um die vordefinierten Tipps an den Client zu senden.

Um das Programm korrekt zu testen, sollte, wie im Video gezeigt, zunächst der Server und anschließend der Client gestartet werden. Andernfalls wird der Client eine Exception erhalten, da keine Verbindung zum Stream hergestellt werden kann.

Was ist ein Thread?

In einfachen Worten ist ein Thread eine separate Einheit der Ausführung innerhalb eines Programms. Threads ermöglichen es, dass mehrere Operationen gleichzeitig ablaufen, was zur Leistungssteigerung und besserer Nutzbarkeit der Anwendung führt.

In Java kann ein Thread entweder durch Erweiterung der Thread-Klasse oder Implementierung des Runnable-Interfaces erstellt werden. Das Runnable-Interface hat eine einzige Methode namens run(), die die Aufgabe enthält, die der Thread ausführen soll.

Threads und ihr Zustand

In Bezug auf den Zustand besitzen Threads in Java drei Hauptzustände:

  1. Lauffähig (Runnable): Der Thread ist bereit zur Ausführung und wartet darauf, dass der Thread-Scheduler ihn zur Ausführung auswählt.
  2. Laufend (Running): Der Thread führt gerade den Code in seiner run()-Methode aus.
  3. Blockiert (Blocked/Not Runnable): Der Thread könnte schlafen, wartet auf I/O oder auf eine andere Ressource, oder er ist aus einem anderen Grund derzeit nicht zur Ausführung bereit.

Threads in Java programmieren

Um Threads in Java zu erstellen, wird zunächst eine Klasse erstellt, die das Runnable-Interface implementiert. Die run()-Methode dieser Klasse enthält den Code, der parallel ausgeführt werden soll.

Im folgenden Code-Snippet wird eine Klasse MeinRunnable erstellt, die das Interface Runnable implementiert:

public class MeinRunnable implements Runnable {
	public void run() {
		los();
	}

	public void los() {
		tuNochMehr();
	}

	public void tuNochMehr() {
		System.out.println("Thread: oben auf dem Stack");
	}
}

MeinRunnable Klasse

Um diesen Thread zu starten, wird eine Instanz der Klasse MeinRunnable kreiert und übergeben diese einem Thread-Objekt, das dann gestartet wird:

public class ThreadTestLauf {
	public static void main(String[] args) {
		Runnable threadJob = new MeinRunnable();
		Thread meinThread = new Thread(threadJob);
		meinThread.start();

		try {
			Thread.sleep(2000); //Der Thread wird für 2000ms schlafen gelegt
		} catch(Exception ex) {
			ex.printStackTrace();
		}
		System.out.println("wieder in main");
	}
}

ThreadTestLauf Klasse

In diesem Beispiel wird meinThread gestartet und die main-Methode schlafen gelegt, was bedeutet, dass der Thread für eine bestimmte Zeit (in diesem Fall 2000 Millisekunden) pausiert. Die Ausführung wird nach dieser Pause mit dem nächsten Befehl fortgesetzt.

Zwei Threads anlegen

Das folgende Beispiel zeigt, wie zwei Threads erstellt und gestartet werden können, die beide die gleiche Aufgabe ausführen:

public class ZweiThreads implements Runnable {
	public static void main(String[] args) {
		ZweiThreads aufgabe = new ZweiThreads();
		Thread alpha = new Thread(aufgabe);
		Thread beta = new Thread(aufgabe);
		alpha.setName("Alpha");
		beta.setName("Beta");
		alpha.start();
		beta.start();
	}

	public void run() {
		for(int i = 0; i < 25; i++) {
		System.out.println("Output von " + Thread.currentThread().getName());
			}
		}
}

ZweiThreads Klasse

In diesem Code-Snippet werden zwei Threads, alpha und beta erstellt, die beide die run()-Methode der Klasse ZweiThreads ausführen. Die Ausgabe wird variieren, da die Reihenfolge, in der die Threads ausgeführt werden, vom Thread-Scheduler des Betriebssystems bestimmt wird und nicht vorhersehbar ist.

Nebenläufigkeitsprobleme lösen

Bei der parallelen Ausführung von dem Code wurden in der Vorlesung zwei Nebenläufigkeitsprobleme behandelt, die auftreten können: Race Conditions und Deadlocks.

Race Conditions:
Wenn zwei oder mehr Threads gleichzeitig auf dieselben Daten zugreifen und diese ändern, führt dies zu inkonsistenten und unvorhersehbaren Ergebnissen. Das „Rainer und Monika“-Problem, in dem beide versuchen, die Anzahl ihrer gemeinsamen Bankkontoeinlagen zu erhöhen, ist ein bekanntes Beispiel für eine Race Condition. Es ist möglich, dass eine der Erhöhungen verloren geht, wenn beide gleichzeitig den Kontostand abrufen, einen neuen Wert berechnen und diesen dann speichern.

class Bankkonto {
    private int kontostand = 0;

    public void einzahlen(int betrag) {
        int neuerKontostand = kontostand + betrag;
        kontostand = neuerKontostand;
    }
}

Bankkonto Klasse

In diesem Fall könnte es sein, dass Rainer und Monika beide gleichzeitig den aktuellen Kontostand abrufen (zum Beispiel 100 €), dann jeweils ihren Einzahlungsbetrag darauf addieren (zum Beispiel 10 €), und dann diesen neuen Wert speichern. Da beide den ursprünglichen Kontostand von 100 € verwendet haben, um ihren neuen Wert zu berechnen, wird der Kontostand nur um 10 € erhöht, obwohl insgesamt 20 € eingezahlt wurden.

Das synchronized Schlüsselwort in Java kann dieses Problem lösen, indem es sicherstellt, dass nur ein Thread zu einem bestimmten Zeitpunkt auf den kritischen Code zugreifen kann:

class Bankkonto {
    private int kontostand = 0;

    public synchronized void einzahlen(int betrag) {
        int neuerKontostand = kontostand + betrag;
        kontostand = neuerKontostand;
    }
}

Verbesserte Bankkonto Klasse

Nun wird immer nur ein Thread (entweder Rainer oder Monika) die Methode einzahlen zur gleichen Zeit ausführen können, wodurch das Problem der Race Condition vermieden wird.

Deadlocks: ⁣
Deadlocks treten auf, wenn zwei oder mehr Threads auf Ressourcen warten, die von den anderen gehalten werden, was zu einem Zustand führt, in dem keiner der Threads fortfahren kann. Eine typische Deadlock-Situation ist, wenn zwei Threads, A und B, Ressourcen halten und auf Ressourcen warten, die von dem jeweils anderen Thread gehalten werden. Dies führt zu einer Sackgasse, da keiner der Threads fortfahren kann. Leider bietet Java keinen eingebauten Mechanismus zur Lösung von Deadlocks, daher ist vorsichtiges Design und gründliches Testen unerlässlich.

Erweiterte Synchronisierungstechniken
Ein weiterer Aspekt der Synchronisierung ist, wenn ein Thread eine Variable bearbeitet, die als Zwischenspeicher für einen Wert fungiert, während ein zweiter Thread möglicherweise mit dieser Zwischenspeicher-Variable arbeiten möchte. Dies kann ebenfalls zu einer Art von Rainer-und-Monika-Problem führen, allerdings indirekt.

Um dieses Problem zu lösen, kann ein spezieller Zustand in das Objekt eingeführt werden: verfügbar/nicht verfügbar. Basierend auf diesen Zuständen können die Threads entscheiden, ob sie ihre Arbeit fortsetzen oder pausieren sollten. Wenn ein Thread seine Arbeit beendet hat, kann er den Zustand auf „verfügbar“ setzen und damit den nächsten Thread zur Ausführung auffordern. Eine Methode wie notify() kann am Ende eines Thread-Prozesses eingesetzt werden, um andere Threads über den abgeschlossenen Status zu informieren und sie zur weiteren Ausführung aufzufordern. So können asynchrone Vorgänge und eine falsche Reihenfolge der logischen Schritte vermieden werden, die zu inkorrekten Ergebnissen führen können.

Vorlesung 4

Das Erzeuger-Verbraucher-Problem

Die Synchronisierung von Threads zwischen Erzeuger und Verbraucher ist eine Herausforderung, insbesondere wenn es um den nebenläufigen Zugriff auf gemeinsam genutzte Ressourcen geht. In diesem bestimmten Szenario erzeugt und speichert ein Thread Werte in einem Puffer-Objekt, während ein anderer Thread diese Werte ausliest. Es ist klar, dass Synchronisation allein das Problem der Abhängigkeit zwischen diesen Threads nicht lösen kann.

Zum Verdeutlichen dieses Problems wird das Szenario eines Erzeuger-Threads vorgestellt, der ganzzahlige Werte von 0 bis 4 erzeugt und in einem Vermittler-Objekt speichert. Ein Verbraucher-Thread ist dann für das Auslesen dieser Werte aus dem Vermittler-Objekt zuständig.

In der gegebenen Codebeispiel steht eine abstrakte Klasse Wert bereit, die ein Puffer für die Speicherung eines einzigen int-Wertes bereitstellt. Die Methode put setzt den Wert, während die Methode get den Wert ausliest.

abstract class Wert {
    protected int wert;
    abstract public int get();
    abstract public void put (int w);
}

Wert-Klasse

Implementierung von Erzeuger und Verbraucher

Der Erzeuger-Thread, repräsentiert durch die Klasse Erzeuger, generiert in seiner run-Methode in einer Schleife Werte von 0 bis 4 und speichert diese im Vermittler-Objekt.

class Erzeuger extends Thread {
    Wert w;
    public Erzeuger (Wert w) {
        this.w = w;
    }
    public void run() {
        for ( int i = 0; i < 5; i ++) {
            w.put(i);
            try {
                sleep( (int) (Math.random() * 100));
            }
            catch (InterruptedException e) {
            }
        }
    }
}

Erzeuger Klasse

Ebenso liest der Verbraucher-Thread, repräsentiert durch die Klasse Verbraucher, die im Vermittler-Objekt abgelegten Werte in seiner run-Methode aus.

class Verbraucher extends Thread {
    Wert w;
    public Verbraucher (Wert w) {
        this.w = w;
    }
    public void run() {
        int v;
        for ( int i = 0; i < 5; i ++) {
            v = w.get();
            try {
                sleep( (int) (Math.random() * 100));
            }
            catch (InterruptedException e) {
            }
        }
    }
}

Verbraucher Klasse

Sowohl der Erzeuger- als auch der Verbraucher-Thread pausieren jeweils nach den put beziehungsweise get-Aufrufen für eine kurze Zeitspanne zwischen 0 und 100 Millisekunden.

Testprogramm und das Erzeuger/Verbraucher-Problem

Ein Testprogramm wird vorgestellt, in dem Erzeuger und Verbraucher mit einem Wert-Objekt der Klasse SchlechterWert arbeiten, wobei die Methoden get und put als synchronisiert deklariert sind.

Trotz dieser Synchronisation ist jedoch festzustellen, dass die fünf erzeugten Werte nicht in der korrekten Reihenfolge verbraucht werden. Einige Werte werden mehrfach oder überhaupt nicht verbraucht. Dieses Problem kann nur gelöst werden, wenn eine Kommunikation zwischen den beiden Threads gewährleistet ist, sodass der Verbraucher nur dann aktiv wird, wenn der Erzeuger einen neuen Wert bereitgestellt hat.

Lösung des Erzeuger/Verbraucher-Problems

Um dieses Problem zu lösen, müssen die Methoden wait und notify geschickt eingesetzt werden. Hierfür wird eine neue Klasse GuterWert erstellt, die von der abstrakten Klasse Wert erbt.

class GuterWert extends Wert {
    private boolean verfuegbar = false;
    public synchronized int get() {
        if ( !verfuegbar )
            try {
                wait();
            }
            catch ( InterruptedException ie ) {
            }
        verfuegbar = false;
        notify();
        System.out.println("Verbraucher get: " + wert);
        return wert;
    }
    public synchronized void put(int w) {
        if (verfuegbar)
            try {
                wait();
            }
            catch ( InterruptedException ie ) {
            }
        wert = w;
        System.out.println("Erzeuger put: " + wert);
        verfuegbar = true;
        notify();
    }
}

GuterWert Klasse

In der neuen GuterWert-Klasse arbeitet das Vermittler-Objekt mit einer Flag verfuegbar, die angibt, ob bereits ein Wert vom Erzeuger produziert und somit für den Verbraucher bereitgestellt wurde. Daher lässt die get Methode den Verbraucher-Thread zunächst prüfen, ob ein Wert verfügbar ist. Ist das nicht der Fall, wartet er mittels wait auf ein notify vom Erzeuger. Wenn der Wartezustand aufgehoben wird, steht fest, dass ein neuer Wert in der Variable wert vorhanden ist und nun verbraucht werden kann. Nachdem der Wert verbraucht wurde, benachrichtigt der Verbraucher den Erzeuger mittels notify, dass er wieder aktiv werden soll.

In der put Methode lässt der Erzeuger-Thread zunächst prüfen, ob ein Wert verfügbar ist. Ist das der Fall, wartet er mittels wait auf ein notify vom Verbraucher. Wenn der Wartezustand aufgehoben wird, steht fest, dass ein neuer Wert in der Variable wert eingetragen werden kann. Nach Speicherung des neuen Werts wird das verfuegbar-Flag gesetzt und der Verbraucher mittels notify informiert, dass er wieder aktiv werden kann.

Das Hauptprogramm ist wie folgt definiert:

public class EVTest {
    public static void main(String args[]) {
        GuterWert w = new GuterWert();
        Erzeuger e = new Erzeuger(w);
        Verbraucher v = new Verbraucher(w);
        e.start();
        v.start();
    }
}

Wenn die beiden Erzeuger- und Verbraucher-Threads nun mit einem Objekt der neuen Klasse GuterWert arbeiten, werden die Werte in korrekter Reihenfolge erzeugt und verbraucht, wie folgende Ausgabe zeigt:

Erzeuger put: 0
Verbraucher get: 0
Erzeuger put: 1
Verbraucher get: 1
Erzeuger put: 2
Verbraucher get: 2
Erzeuger put: 3
Verbraucher get: 3
Erzeuger put: 4
Verbraucher get: 4

Ausgabe

Unter der Annahme, dass ein Objekt der Klasse Klemmwert verwendet wird, bei dem die Methoden put und get ohne das verfuegbar-Flag operieren, führt dies zu einem Deadlock-Szenario kurz nach dem Programmstart. Trotz der Ausgabe der Meldung „Wert erzeugt!“ kommt das Programm anschließend zum Stillstand. Der zugrundeliegende Grund liegt darin, dass obwohl der Verbraucher theoretisch auf ein Signal des Erzeugers wartet, das anzeigt, dass ein Wert generiert wurde und der Erzeuger wiederum darauf wartet, dass der Verbraucher signalisiert, dass der Wert verbraucht wurde, es an einem Mechanismus mangelt, der verhindert, dass sowohl Erzeuger als auch Verbraucher gleichzeitig in den wait-Zustand geraten. Dieses Phänomen führt zu der beobachteten Deadlock-Situation.

Im Großen und Ganzen stellt die Verwendung des Schlüsselworts synchronized zusammen mit den Methoden join, wait, notify und notifyAll in Kombination mit dem Monitor-Konzept eine sichere Strategie dar, um kritische Bereiche im Code zu schützen. Dennoch ist diese Technik nicht ohne Risiken – es besteht die Möglichkeit von Starvation- oder Deadlock-Situationen. Sorglosigkeit bei der Implementierung dieser Methoden kann zu erheblichen Schwierigkeiten führen.

Übung: Erstellung eines Chat-Programms

Die Aufgabe war es, mit dem Wissen über die Thread-Programmierung, ein einfaches Chat-Programm zu schreiben, das Nachrichten senden und empfangen kann. Die Architektur des Programms basiert auf dem Client-Server-Modell, und es ermöglicht eine synchrone Kommunikation.

ChatServer

Im Serverteil des Programms werden die Nachrichten von den Clients empfangen und an alle verbundenen Clients verteilt.

package dhbw.semester4.verteiltesysteme.Aufgabe4;

import java.io.*;
import java.net.*;
import java.util.*;

public class ChatServer {
    // Ein Array zum Speichern der Ausgabeströme aller verbundenen Clients
    ArrayList clientAusgabeStroeme;

    // Die innere Klasse ClientHandler implementiert das Runnable-Interface, damit sie auf einem separaten Thread laufen kann.
    // Ein ClientHandler-Objekt ist für das Lesen der eingehenden Nachrichten von einem einzelnen Client zuständig.
    public class ClientHandler implements Runnable {
        BufferedReader reader;
        Socket sock;

        public ClientHandler(Socket clientSocket) {
            try {
                sock = clientSocket;
                InputStreamReader isReader = new InputStreamReader(sock.getInputStream());
                reader = new BufferedReader(isReader);
            } catch (Exception ex) {ex.printStackTrace();}
        }

        // Im run()-Methode des Threads wird eine Schleife ausgeführt, die die eingehenden Nachrichten von einem Client liest und diese Nachrichten an alle Clients sendet.
        public void run() {
            String nachricht;
            try {
                while ((nachricht = reader.readLine()) != null) {
                    System.out.println("gelesen: " + nachricht);
                    Broadcast(nachricht);
                }
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }

    }

    public static void main (String[] args) {
        new ChatServer().los();
    }

    public void los() {
        clientAusgabeStroeme = new ArrayList();
        try {
            ServerSocket serverSock = new ServerSocket(5000);
            // Eine Schleife wartet auf eingehende Client-Verbindungen und erstellt für jeden Client einen neuen ClientHandler-Thread.
            while (true) {
                Socket clientSocket = serverSock.accept();
                PrintWriter writer = new PrintWriter(clientSocket.getOutputStream());
                clientAusgabeStroeme.add(writer);
                Thread t = new Thread(new ClientHandler(clientSocket));
                t.start();
                System.out.println("habe eine Verbindung");
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
    // Die Broadcast-Methode nimmt eine Nachricht als Parameter und sendet diese Nachricht an alle verbundenen Clients.
    public void Broadcast(String nachricht) {
        Iterator it = clientAusgabeStroeme.iterator();
        while(it.hasNext()) {
            try {
                PrintWriter writer = (PrintWriter) it.next();
                writer.println(nachricht);
                writer.flush();
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
    }
}

ChatServer Klasse

ChatClient

Es wird eine Benutzeroberfläche auf der Clientseite erstellt, die es ermöglicht, Nachrichten zu senden und eingehende Nachrichten anzuzeigen.

package dhbw.semester4.verteiltesysteme.Aufgabe4;

import java.io.*;
import java.net.*;
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;

public class ChatClient {
    JTextArea eingehend;
    JTextField ausgehend;
    BufferedReader reader;
    PrintWriter writer;
    Socket sock;

    public static void main(String[] args) {
        ChatClient client = new ChatClient();
        client.los();
    }

    public void los() {
        // Erstellen der Benutzeroberfläche für den Chat-Client.
        JFrame frame = new JFrame("Simpler Chat-Client");
        JPanel hauptPanel = new JPanel();
        eingehend = new JTextArea(15, 20);
        eingehend.setLineWrap(true);
        eingehend.setWrapStyleWord(true);
        eingehend.setEditable(false);
        JScrollPane fScroller = new JScrollPane(eingehend);
        fScroller.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
        fScroller.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS);
        ausgehend = new JTextField(20);
        JButton sendenButton = new JButton("Senden");
        sendenButton.addActionListener(new SendenButtonListener());
        ausgehend.addKeyListener(new EnterKeyListener());
        hauptPanel.add(fScroller);
        hauptPanel.add(ausgehend);
        hauptPanel.add(sendenButton);
        netzwerkEinrichten();
        // Starten des neuen Threads zum Lesen der eingehenden Nachrichten.
        Thread readerThread = new Thread(new EingehendReader());
        readerThread.start();
        frame.getContentPane().add(BorderLayout.CENTER, hauptPanel);
        frame.setSize(400, 500);
        frame.setVisible(true);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }

    // Die netzwerkEinrichten()-Methode verbindet sich mit dem Server und initialisiert die BufferedReader- und PrintWriter-Objekte für die Interaktion.
    public void netzwerkEinrichten() {
        try {
            sock = new Socket("127.0.0.1", 5000);
            InputStreamReader streamReader = new InputStreamReader(sock.getInputStream(), "UTF-8");
            reader = new BufferedReader(streamReader);
            writer = new PrintWriter(new OutputStreamWriter(sock.getOutputStream(), "UTF-8"));
            System.out.println("Connection established");
        }   catch (IOException ex) {
            ex.printStackTrace();
        }
    }

    // Die sendNachricht()-Methode liest die im Textfeld eingegebene Nachricht und sendet sie an den Server.
    private void sendNachricht() {
        try {
            String nachricht = ausgehend.getText();
            writer.println("Fabio: " + nachricht);
            writer.flush();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        ausgehend.setText("");
        ausgehend.requestFocus();
    }

    // Die SendenButtonListener und EnterKeyListener Klassen sind Event-Handler für das Senden-Button und das Enter-Key.
    private class SendenButtonListener implements ActionListener {
        public void actionPerformed(ActionEvent ev) {
            sendNachricht();
        }
    }

    private class EnterKeyListener extends KeyAdapter {
        public void keyPressed(KeyEvent ev) {
            if (ev.getKeyCode() == KeyEvent.VK_ENTER) {
                sendNachricht();
            }
        }
    }

    // Die innere Klasse EingehendReader implementiert das Runnable-Interface, sodass sie auf einem separaten Thread ausgeführt werden kann.
    // Diese Klasse ist dafür verantwortlich, die eingehenden Nachrichten vom Server zu lesen und diese Nachrichten in das JTextArea-Widget einzufügen.
    public class EingehendReader implements Runnable {
        public void run() {
            String nachricht;
            try {
                while ((nachricht = reader.readLine()) != null) {
                    System.out.println("gelesen: " + nachricht);
                    eingehend.append(nachricht + "\n");
                }
            } catch (Exception ex) {ex.printStackTrace();}
        }
    }
}

ChatClient Klasse

In dem folgenden Video wird die Funktionalität des erstellten Chat-Programms gezeigt. Das Programm kann in Echtzeit durch synchrone Kommunikation gleichzeitig das Senden und Empfangen von Nachrichten.

Vorlesung 5

HTML und HTTP

Um im Internet effektiv zu kommunizieren, werden bestimmte Sprachen und Protokolle verwendet. Das Hauptaugenmerk dieser Interaktion liegt auf dem Hypertext Transfer Protocol (HTTP) und der Hypertext Markup Language (HTML). Beide arbeiten zusammen, um den Informationsaustausch zwischen Clients und Servern zu ermöglichen und die Darstellung dieser Informationen für Endnutzer zu kontrollieren.

Hypertext Markup Language (HTML)

HTML ist eine Markup-Sprache, die dazu dient, Inhalte auf einer Website zu organisieren und zu formatieren. Sie gibt dem Webbrowser Anweisungen zur Darstellung des Inhalts für den Endbenutzer. Es ist wichtig zu beachten, dass das Alter und die technischen Fähigkeiten des Browsers die Interpretation und Darstellung von HTML-Code beeinflussen. Ältere Browser können bestimmte Seiten oder Seitenteile, die mit neueren HTML-Versionen geschrieben wurden, nicht korrekt anzeigen.

Hypertext Transfer Protocol (HTTP)

Im Gegensatz dazu ist HTTP das Protokoll, das die Interaktion zwischen Clients und Servern steuert. Es definiert die Regeln und Strukturen, die für den Austausch von Daten zwischen diesen Parteien verwendet werden. Bei einer Interaktion mit einem Webserver würde man HTTP als die gemeinsame Kommunikationssprache betrachten.

HTTP-Anfragen und -Antworten

Der Hauptmechanismus des HTTP-Protokolls besteht aus Anfragen und Antworten. Ein Client sendet eine HTTP-Anfrage an den Server, und dieser reagiert darauf mit einer HTTP-Antwort. Innerhalb dieser Kommunikation können verschiedene HTTP-Methoden verwendet werden, die auf verschiedene Arten von Aktionen hinweisen.

Methoden der HTTP-Anfragen

GET-Anfragen sind die einfachste Art von HTTP-Anfragen. Sie werden verwendet, um Daten von einem Server anzufordern. Die Parameter einer solchen Anfrage sind für den Nutzer in der URL sichtbar und auf 2.048 Zeichen beschränkt. Ebenso können sie nur ASCII-Zeichen übertragen. Ein Hauptnachteil der Verwendung von GET-Anfragen besteht darin, dass Daten offen und leicht zugänglich übertragen werden. Dies kann ein Sicherheitsrisiko darstellen, wenn sensible Daten wie Passwörter oder Session-IDs übertragen werden.

Im Gegensatz dazu werden POST-Anfragen verwendet, um Daten an einen Server zu senden. Im Unterschied zu GET-Anfragen sind die Parameter für den Nutzer nicht sichtbar und es gibt keine Beschränkungen hinsichtlich der Menge oder Art der zu übertragenden Daten. Dies macht POST-Anfragen zur bevorzugten Methode für die Übermittlung sensibler oder großer Datenmengen.

HTTP-Antwort und MIME-Typen

Eine HTTP-Antwort vom Server enthält einen Header und einen Rumpf. Der Header enthält Angaben zum Protokoll, dem Status der Anfrage und dem MIME-Typ der Antwort. Eine Möglichkeit, den Typ der Daten zu beschreiben, die in der Antwort übermittelt werden, sind MIME-Typen (Multipurpose Internet Mail Extensions). Sie erleichtern das Verständnis des Browsers, wie die empfangenen Daten behandelt werden sollen.

Der Rumpf der Antwort enthält den eigentlichen Inhalt, der vom Browser dargestellt werden soll. Dieser wäre der HTML-Quelltext, aber auch andere Datenformate wie Bilder oder JSON-Daten.

JavaServer Pages (JSP)

Sun Microsystems hat eine Technologie namens JavaServer Pages (JSP) entwickelt, die es ermöglicht, dynamische Webseiten zu erstellen, die auf der Java-Plattform laufen. Entwickler können Webseiten mit dynamischem Inhalt erstellen, indem sie Java-Code in HTML-, XML- oder andere Dokumenttypen einbetten.

JSP funktioniert, indem es Anfragen an den Server sendet, der dann den Java-Code innerhalb der JSP-Datei ausführt. Dieser Code generiert HTML, das an den Client zurückgesendet wird. Das Resultat ist eine dynamische Webseite, die basierend auf den serverseitigen Berechnungen aktualisiert werden kann.

Ein einfaches JSP-Beispiel könnte wie folgt aussehen:

<%@ page language="java" contentType="text/html; charset=ISO-8859-1" pageEncoding="ISO-8859-1"%>
<!DOCTYPE html>
<html>
    <body>
    	<%= "Hallo, Welt!" %>
    </body>
</html>

JSP-Beispiel

In diesem Beispiel wird ein einfacher String „Hallo, Welt!“ durch den Java-Code innerhalb der JSP-Datei erzeugt.

Apache Tomcat

Die Apache Software Foundation hat den Webserver und Servlet-Container Tomcat entwickelt, der Open-Source ist. Tomcat stellt Java Servlets und JavaServer Pages sowie eine „reine Java“-Umgebung zur Ausführung von Java-Code zur Verfügung.

Tomcat erhält HTTP-Anfragen vom Client, leitet sie an die entsprechenden Servlets weiter und sendet die Antworten der Servlets an den Client zurück. Servlets sind Java-Programme, die auf dem Server laufen und die Logik zur Bearbeitung von Client-Anfragen liefern.

Die Bereitstellung der JSP-Datei auf einem Tomcat-Server ist ein einfaches Beispiel für die Verwendung von Tomcat. Wenn der Server eine Anfrage für diese JSP-Datei erhält, wird der Java-Code darin ausgeführt und die resultierende HTML-Seite a den Client gesendet.

Container

In Java ist ein Container ein Komponentensystem, das APIs und Laufzeitumgebungen zur Komponentenausführung bereitstellt. Es gibt verschiedene Arten von Containern, darunter Servlet-Container und Enterprise JavaBeans (EJB)-Container.

Ein Container nimmt Anfragen entgegen, leitet diese an die entsprechenden Komponenten weiter und verwaltet den Lebenszyklus dieser Komponenten. Es stellt auch Dienste wie Sicherheit, Transaktionsverwaltung und Namensauflösung bereit.

Ein Servlet-Container ist Apache Tomcat. Tomcat verwaltet den Lebenszyklus des Servlets, einschließlich seiner Erstellung und Zerstörung, wenn eine Anfrage gesendet wird. Tomcat leitet dann die Anfrage a das entsprechende Servlet weiter.

Servlets

Servlets sind Java-Programme, die auf dem Server laufen und speziell für die Verwaltung von Anfragen in einem Webserver-Umfeld entwickelt wurden. Sie setzen die Java Servlet API ein, die eine standardisierte Kollektion von Klassen und Interfaces darstellt, die es ihnen ermöglicht, mit dem Webserver zu interagieren.

Ein Servlet verarbeitet eine Anfrage, führt die Logik aus (zum Beispiel das Abrufen von Daten aus einer Datenbank) und generiert eine Antwort, die an den Client zurückgesendet wird.

Ein einfaches Servlet könnte wie folgt aussehen:

import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class HelloWorldServlet extends HttpServlet {
  public void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
      PrintWriter out = response.getWriter();
      out.println("Hallo, Welt!");
  }
}

Dieses Servlet reagiert auf GET-Anfragen und sendet eine einfache Textnachricht „Hallo, Welt!“ an den Client.

Model-View-Controller (MVC)

Das Model-View-Controller-Prinzip (MVC) ist ein Designmuster, das häufig in der Webentwicklung verwendet wird. Es trennt die Anwendungsdaten (Model), die Benutzeroberfläche (View) und die Steuerungslogik (Controller) voneinander, um die Entwicklung und Wartung von Software zu erleichtern.

Im MVC-Muster ist das Model für die Daten und die damit verbundene Logik verantwortlich. Die View stellt die Daten dar und ist für die Interaktion mit dem Benutzer zuständig. Der Controller verarbeitet Benutzereingaben, interagiert mit dem Model und aktualisiert die View entsprechend.

In einer Webanwendung könnte ein MVC-Beispiel aus einem Servlet als Controller, einer JSP-Datei als View und einer Java-Klasse als Model bestehen.

Übung: Servlet erstellen

Die Aufgabe war es, ein einfaches Servlets zu erstellen, das eine HTML-Datei generiert. Dieses Servlet wird mithilfe von Java Server Pages (JSP) und dem Apache Tomcat-Server realisiert.

Notwendige Projektstruktur

Für die Ausführung des Servlets auf dem Tomcat-Server muss folgende Projektstruktur vorhanden sein, die sicherstellt, dass das Servlet korrekt ausgeführt wird:

Projektstruktur

Web-Konfiguration

Die Konfiguration des Servers wird mithilfe der web.xml-Datei vorgenommen. Diese XML-Datei ist ein grundlegender Aspekt von Java Servlets und enthält Informationen darüber, wie das Servlet auf dem Server konfiguriert wird. Im gegebenen Beispiel definiert die web.xml-Datei ein Servlet und weist es einer bestimmten URL zu.

<web-app xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd" 
version="2.4"> 
<servlet>
  <servlet-name>Kapitel 1 Servlet</servlet-name>
  <servlet-class>dhbw.semester4.verteiltesysteme.Aufgabe5.Kap01Servlet</servlet-class>
</servlet>
<servlet-mapping>
  <servlet-name>Kapitel 1 Servlet</servlet-name>
  <url-pattern>/Serv1</url-pattern>
</servlet-mapping>
</web-app>

web.xml

Der Servlet-Block enthält zwei Elemente: servlet-name und servlet-class. Das servlet-name-Element dient zur Identifizierung des Servlets, während das servlet-class-Element den vollständigen Klassennamen des Servlets angibt.

Im anschließenden servlet-mapping-Block wird der url-pattern definiert, der bestimmt, unter welcher URL das Servlet erreichbar ist.

Die Servlet-Klasse

Nach der Konfiguration des Servers wird das Servlet selbst erstellt. Im gegebenen Beispiel wird das Servlet „Kap01Servlet“ als Java-Datei geschrieben.

package dhbw.semester4.verteiltesysteme.Aufgabe5;

import jakarta.servlet.http.*;
import jakarta.servlet.annotation.*;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Date;

public class Kap01Servlet extends HttpServlet {
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        PrintWriter out = response.getWriter();
        Date now = new Date();
        out.println("<html>" +
                "<body>" +
                "<h1 align=center>VKBF Kapitel 1 Servlet</h1>" +
                "<br>" + now + "</body>" + "</html>");
    }
}

Servlet Klasse

Es erweitert die HttpServlet-Klasse und überschreibt die doGet-Methode, um eine HTML-Antwort zu generieren, wenn das Servlet mit einem GET-Request angefragt wird.

Es ist wichtig zu beachten, dass das Servlet noch kompiliert werden muss, um es auf dem Server auszuführen. Da das Servlet Klassen aus dem javax.servlet-Package verwendet, die vom Standard-Java-Compiler nicht erkannt werden, muss es mithilfe des Tomcat-Packages kompiliert werden. Dies erfolgt durch Ausführen eines javac-Befehls mit dem korrekten Klassenpfad und der -d classes-Option, um das generierte Klassenfile im Ordner „classes“ zu platzieren.

javac -classpath "C:\Program Files\Apache Software Foundation\Tomcat 10.0\lib\servlet-api.jar" -d classes "C:\Users\asauna9674\Downloads\verteilteSysteme\src\main\java\dhbw\semester4\verteiltesysteme\Aufgabe5\Servlet.java"

Kompilier-Befehl

Servlet-Test

Nachdem das Servlet kompiliert und auf dem Server bereitgestellt wurde, kann es getestet werden. Der Test besteht darin, den Tomcat-Server zu starten und die URL localhost:8080/Kap01/Serv1 in einem Webbrowser aufzurufen. Wenn alles korrekt konfiguriert und implementiert wurde, sollte, wie im folgenden Video demonstriert, der Browser eine HTML-Seite anzeigen, die vom Servlet generiert wurde.

Vorlesung 6

Node.js: Eine Einführung

Node.js ist eine leistungsstarke Plattform, die JavaScript-Code auf Serverebene ausführen kann. JavaScript war ursprünglich für die clientseitige Ausführung in Webbrowsern gedacht, aber Node.js kann JavaScript auf der Serverseite ausführen.

Die V8 JavaScript-Engine, die aus dem Google Chrome-Projekt stammt, ist eine wichtige Komponente von Node.js. Sie fungiert als Ausführungs- und Interpretationsmechanismus für JavaScript-Code in Node.js.

Um Node.js zu installieren, wird der Installationsassistent verwendet. Dieser Assistent vereinfacht den Installationsprozess und führt den Benutzer durch die erforderlichen Schritte.

Nach der Installation kann die Funktionalität von Node.js getestet werden. Eine Möglichkeit ist der Start der node.exe-Datei, in die JavaScript-Code direkt eingegeben werden kann. Alternativ können komplette JavaScript-Dateien ausgeführt werden, indem in der Kommandozeile der folgende Befehl verwendet wird:

node <Dateipfad>/<Dateiname>.js

Node Befehl

Native Apps mit JavaScript

JavaScript kann auch zur Erstellung nativer Apps für mobile Geräte wie Handys und Tablets verwendet werden. Hierzu wird das Ionic Framework genutzt. Dieses Framework ermöglicht es, eine Art mobilen Browser zu starten, in dem die App läuft. Dann bietet es Zugriff auf native Funktionen des Geräts, wie zum Beispiel die Kamera.

Eine Herausforderung bei der Entwicklung dieser Apps besteht darin, dass sie in der Regel nur über die offiziellen App-Stores der jeweiligen Betriebssysteme bezogen werden können. Außerdem müssen zwei separate Frontends erstellt werden: Eine für die Web-App und eine für die reale App.
Eine Lösung für progressive Web-Apps.

Progressive Web-Apps: Eine Lösung

Die Verwendung von Progressive Web-Apps (PWAs) ist eine Möglichkeit, einige der Herausforderungen bei der Entwicklung nativer Apps zu überwinden. PWAs müssen nur eine einzige Anwendung oder ein Frontend entwickeln, das sowohl als Webseite als auch als App zugänglich ist. Darüber hinaus ermöglichen sie den Zugriff auf die eigentlichen Funktionen des mobilen Geräts.

Es ist jedoch wichtig zu beachten, dass nicht alle Browser PWAs unterstützen. Ein prominentes Beispiel ist Apples Safari, der bestimmte Funktionen von PWAs blockiert. Dies liegt daran, dass Apple darauf besteht, dass mobile Apps nur über den offiziellen App-Store installiert werden.

Multi-Page- und Single-Page-Applications

Eine bedeutende Unterscheidung im Bereich der Webentwicklung besteht zwischen Multi-Page-Applications (MPA) und Single-Page-Applications (SPA).

MPAs basieren auf dem traditionellen Webdesign, bei dem jede Nutzerinteraktion, wie ein Klick auf einen Link oder Button, eine Anforderung an den Server sendet. Dieser erzeugt eine neue HTML-Seite und sendet sie an den Client, der diese darstellt. Bei dieser Vorgehensweise wird für jede Interaktion eine vollständige neue Webseite geladen, was zu deutlichem Overhead und potenziellen Leistungseinbußen führen kann.

Im Gegensatz dazu steht das Konzept der SPAs. Hierbei wird zu Beginn eine einzelne HTML-Seite vom Server geladen. Sämtliche Interaktionen des Nutzenden führen nun dazu, dass Inhalte dynamisch auf der Client-Seite geladen oder verändert werden, ohne dass eine vollständige neue Seite vom Server angefordert wird. Stattdessen werden kleinere Datenpakete über HTTP-Requests ausgetauscht. Sozialen Medien sind ein geläufiges Beispiel für SPAs, wo die Nutzenden durch ständige Aktualisierung und Ergänzung des Inhalts praktisch unendlich scrollen können.

React: Ein Framework für SPAs

React ist eine populäre JavaScript-Bibliothek, die speziell für den Bau von SPAs entwickelt wurde. Sie ermöglicht die Erstellung wiederverwendbarer Benutzeroberflächenkomponenten, die auf veränderliche Daten (durch sogenannte „States“) reagieren können. Mithilfe des „virtuellen DOMs“ von React werden Änderungen an der Benutzeroberfläche effizient verarbeitet, da nur die tatsächlich veränderten Elemente neu gerendert werden.

Full-Stack Webentwicklung: Beliebte Technologie-Stacks

In der Full-Stack-Webentwicklung hat sich eine Reihe von Technologie-Stacks etabliert, die auf bestimmten Konstanten und verschiedenen Frontend-Frameworks basieren. Dazu zählen:

  • MERN: MongoDB (Datenbank), Express (Server-Middleware), React (Frontend), Node.js (Server-Umgebung)
  • MEAN: MongoDB, Express, Angular (Frontend), Node.js
  • MEVN: MongoDB, Express, Vue (Frontend), Node.js

Die Wahl des Stacks hängt meist von den Anforderungen des Projekts und den Präferenzen des Entwicklerteams ab.

Express.js: Einfache API- und Webserver-Erstellung

Express.js ist eine Middleware-Bibliothek für Node.js und vereinfacht die Erstellung von APIs und Webservers. Sie ermöglicht es, statische Dateien wie Stylesheets an den Client zu senden, um eine einheitliche Darstellung von Inhalten zu gewährleisten.

Mit Express.js lassen sich Webservices mit wenigen Codezeilen aufbauen. Ein grundlegender Server könnte zum Beispiel so konfiguriert werden:

// Einrichten von Express.js
let express = require('express');
let app = express();
let path = require('path');

// Definition einer Route für GET-Anfragen
app.get("/login", (req, res) => {
	res.sendFile(path.join(__dirname + "/public/login.html"));
})

// Starten des Servers auf Port 3000
app.listen(3000, () => {
    console.log('Server läuft auf Port 3000');
});

Mit dem obigen Code wird ein Express-Server konfiguriert, der auf dem Port 3000 läuft und auf GET-Anfragen an die URL „/login“ reagiert.


Weitere Posts aus dieser Serie

Schwerpunkt 1: Content Management System (CMS) Ghost
Entdecke das alternative CMS Ghost. Ein benutzerfreundliches Open-Source-System, das ein leistungsstarkes Blogging ermöglicht.

Schwerpunkt 1: Content Management System (CMS) Ghost

Schwerpunkt 2: Reverse-Proxy Traefik
Entdecke die komplexe Welt von dem Reverse Proxys Traefik – Verstehe die Funktionsweise und wie du deine Netzwerk-Sicherheit verbessern kannst.

Schwerpunkt 2: Reverse-Proxy Traefik

Schwerpunkt 3: Identity and Access Management (IAM) Authelia
Authelia, ein Open-Source IAM, vereint Authentifizierung und Autorisierung. Lerne seine Funktionsweise und Einbindung kennen.

Schwerpunkt 3: Identity and Access Management (IAM) Authelia

Fabio Sauna

Fabio Sauna

Just an everyday, normal Systems Engineer living for the European spirit and maintaining a semi-professional homelab.
Heidelberg, Germany