Als letzte Übung vor den Sommerferien haben wir im Unterricht ein eigenes Chat-System implementiert.
Hierfür musste sich ein eigenes Protokoll ausgedacht bzw. erweitert werden.

In diesem Blogpost wird meine Umsetzung sowohl des Servers vorgestellt und erläutert. Der Client wird ausgelassen, da seine Implementierung verglichen mit dem Server nur wenig neues enthält.

Protokoll

Das Chat-Protokoll setzt auf TCP auf und besteht aus zwei verschiedenen Befehlssätzen, Requests und Events.

Requests

Requests lösen Aktionen aus.
Sie werden vom Client an den Server gesendet, wenn der Nutzer sie auslöst.

Befehl Bedeutung
LOGIN <nickname> Anmelden
SEND <recipient> <message> Eine neue Nachricht versenden
SEND_PUBLIC <message> Eine neue öffentliche Nachricht versenden
BAN_USER <userId> Einen Nutzer bannen
NEW_GROUP <name> <memberId1> <memberIdN> Eine neue Gruppe erstellen
HELP Die Hilfe anzeigen
QUIT Sitzung schließen

Events

Events signalisieren Status-Änderungen.
Der Client sollte nach einem solchen Event seine Anzeige ändern.

Befehl Bedeutung
NEW_MESSAGE <sender> <recipient> <message> Es gibt eine neue Nachricht.
NEW_PUBLIC_MESSAGE <sender> <message> Es gibt eine neue öffentliche Nachricht.
RECIPIENTS_CHANGE <recipientId1> <recipientIdN> Die verfügbaren Empfänger wurden aktualisiert.

Server

Der Server besteht aus zwei Teilen:

  1. Verwaltung der Nutzer
  2. Entgegennehmen der Requests und entsprechendes Versenden der Events

Nutzerverwaltung

Nutzerverwaltung UML-Diagramm

Die Nutzerverwaltung besteht aus einigen Klassen und Interfaces, die größtenteils sehr trivial sind und durch das UML-Diagramm zur genüge beschrieben sind.

Genauer möchte ich auf das UserRepository eingehen:

private final Map<String, User> byAddress = new HashMap<>();
private final Map<String, User> byNickname = new HashMap<>();

Es speichert alle Nutzer in den zwei HashMaps byAddress und byNickname.

Besonders nach der letzten Unterrichtseinheit zu SQL klingeln sofort die Alarmglocken: Redundanz!
In diesem Fall ist sie allerdings das kleinere Übel, denn die Anwendung verlangt sowohl die Suche über den Nickname, als auch über die Addresse.

Verwendet man nur eine der beiden Maps als Index, hätte die Suche über das jeweils andere Attribut lineare Laufzeit. So tauschen wir diese Laufzeitkomplexität durch die Speicher-Komplexitäte eines zusätzlichen Indexes ein.

private final Set<String> bannedAddresses = new HashSet<>();

Die gebannten Addresses werden in einem HashSet abgelegt. Ein Set enthält jeden Wert nur einmal und kann schnell auf einen Werts überprüft werden.

private final Consumer<String> onChange;

Ein Consumer<T> ist ein Objekt mit der Methode .accept(T v). Dieses wird beim Erzeugen des UserRepository übergeben und löst bei jeder Nutzerveränderung aus. So wird das Versenden der Events von der Nutzerverwaltung abgekoppelt.

Requests

try {
  Request r = requestBuilder.build(pClientIP, pClientPort, pMessage);
  onRequest(r);
} catch (Command.CommandVerbUnknownException e) {
  send(pClientIP, pClientPort, "-CommandVerb unknown");
} catch (UserRepository.UserBannedException e) {
  send(pClientIP, pClientPort, "-You are banned.");
}

Erreicht den Server ein neuer Request, wird erst einmal ein Request-Objekt daraus erstellt. Dieses ist im folgenden UML-Diagramm beschrieben:

Request UML-Diagramm

Ist das Request-Objekt erstellt, so wird es an die zuständigen Request-Handler geleitet, die die Anfrage dann beantworten.

private void onRequest(Request r) {
  log(r);

  if (!r.userKnown) {
    switch (r.cmd.verb) {
      case LOGIN:
        login(r);
        break;
    }
    return;
  }

  switch (r.cmd.verb) {
    case SEND:
      sendHandler(r);
      break;
    ...
    default:
      r.error("Unknown Error");
  }
}

So ein RequestHandler führt dann die im Protokoll festgelegten Schritte durch. Am Beispiel des sendHandlers:

private void sendHandler(Request r) {
  String recipientIdentifier = r.cmd.args.get(0); // 1
  String msg = r.cmd.args.get(1); // 1

  Recipiable recipient = findRecipient(recipientIdentifier); // 2
  
  String cmd = Command.build( // 3
    CommandVerb.NEW_MESSAGE,
    r.user.nickname,
    recipient.getIdentifier(),
    msg
  );

  for (User u : recipient.getUsers()) { // 3
    send(u, cmd);
  }

  r.success("200 Success"); // 4
}

Zuerst werden die Argumente aus dem Request-Objekt abgerufen (1).
Dann wird nach einem passenden recipient gesucht (2).
Nun wird ein neues Event mit der Nachricht konstruiert und an alle Nutzer des recipients geschickt (3).
Zum Schluss wird der Request als Erfolgreich beantwortet.

Fazit

Steckt man ein wenig Vorarbeit in die Modellierung seines Codes und verwendet zum Beispiel Techniken wie Domain Driven Design, kann man komplexen Code auf einfache Klassen mit klarer Zuständigkeit herunterbrechen.
An den UML-Diagrammen kann man schon sehen, dass alle Klassen relativ klein sind, außerdem gibt es keine zyklischen Abhängigkeiten. So kann - besonders bei größeren Anwendungen - viel Kopfschmerz gespart werden.

Das Chatprotkoll ist sicherlich nicht vollständig ausgereift und kann an vielen Stellen noch erweitert oder verbessert werden.
Beispielsweise könnte speziell für mobile Clients das RECIPIENTS_CHANGE-Event zu viel Bandbreite benötigen, auf einem aktiven Server kommen schnell einige Nutzer zusammen. Möchte man das Protokoll für solche Anwendungen optimieren, könnten die Events USER_JOINED und USER_LEFT besser geeignet sein.

Der Quellcode für Server und Client findet sich unter https://github.com/Skn0tt/lkNetzwerke