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:
- Verwaltung der Nutzer
- Entgegennehmen der Requests und entsprechendes Versenden der Events
Nutzerverwaltung
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:
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 sendHandler
s:
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 recipient
s 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