Redirect-Statistiken in Vaadin Flow: Vom Event bis zur Visualisierung
Ein URL-Shortener erfüllt auf den ersten Blick eine triviale Aufgabe: Er nimmt eine lange URL entgegen und liefert eine kurze zurück. Der eigentliche Wert entsteht jedoch erst durch das, was nach dem Erstellen passiert – nämlich die Nutzung. Wie oft wird ein Link aufgerufen? Zu welchen Tageszeiten? Über welchen Zeitraum verteilt? Diese Fragen sind nicht nur aus Neugier interessant, sondern bilden die Grundlage für fundierte Entscheidungen. Wer einen Link in einer Kampagne einsetzt, möchte wissen, ob die Kampagne funktioniert. Wer einen Link intern teilt, möchte verstehen, ob die Zielgruppe ihn überhaupt nutzt.
Table of Contents
- Architekturüberblick
- Das Datenmodell im Core-Modul
- Die Aggregat-Records: Verdichtete Statistiken
- Die Server-Seite: HTTP-Handler und Persistenz
- Die Handler-Architektur
- Der StatisticsCountHandler: Ein exemplarischer Handler
- Der StatisticsTimelineHandler: Daten für die UI
- Der RequestDataExtractor: Metadaten aus dem HTTP-Request
- Das StatisticsStore Interface: Abstraktion der Persistenz
- Die InMemory-Implementierung: Einfach und transparent
- Die EclipseStore-Implementierung: Persistenz ohne ORM
- Event-Aggregation und das Writer-Pattern
- Der StatisticsDebugHandler: Einblick für Entwickler
Statistiken machen aus einem simplen Weiterleitungsdienst ein Werkzeug mit echtem Mehrwert. Sie transformieren passive Infrastruktur in aktive Informationsquelle. Genau dieser Schritt steht im Mittelpunkt des vorliegenden Artikels: die Implementierung eines vollständigen Statistikmoduls für einen URL-Shortener, von der Erfassung einzelner Redirect-Events über die serverseitige Aggregation bis hin zur Visualisierung in einer Vaadin-Flow-Oberfläche.
Die Quelltexte findest Du auf GitHub unter https://3g3.eu/url

Das zugrunde liegende Projekt ist ein quelloffener URL-Shortener, der bewusst ohne die üblichen Framework-Schwergewichte auskommt. Kein Spring Boot, kein Jakarta EE – stattdessen pures Java mit dem JDK-eigenen HttpServer für die REST-Schnittstelle und Vaadin Flow für die Benutzeroberfläche. Diese Architekturentscheidung ist kein Selbstzweck, sondern verfolgt ein didaktisches Ziel: Sie macht die Zusammenhänge zwischen den Schichten sichtbar, die in größeren Frameworks oft hinter Annotationen und Magie verschwinden. Wer das Zusammenspiel zwischen UI und Backend verstehen will, findet hier eine Codebasis, in der jeder HTTP-Request und jede Komponenteninteraktion nachvollziehbar bleibt. Der vollständige Quellcode ist auf GitHub verfügbar, und in früheren Artikeln wurden bereits die theoretischen Grundlagen, die erste Implementierung sowie die Integration der Vaadin-Oberfläche behandelt.
Der Fokus dieses Artikels liegt auf dem Vaadin-Frontend und seiner Anbindung an das Backend. Die zentrale Frage lautet: Wie baut man eine Statistik-Oberfläche, die dem Nutzer ermöglicht, Zeiträume auszuwählen, Aggregationsstufen zu wechseln und die Ergebnisse übersichtlich zu betrachten – und das alles mit den Bordmitteln von Vaadin Flow, ohne zusätzliche JavaScript-Frameworks oder externe Charting-Bibliotheken? Die Antwort führt durch mehrere Schichten: von den Datenstrukturen im Core-Modul über die REST-Handler im Server bis hin zu den UI-Komponenten, die der Nutzer letztlich sieht und bedient.
Dabei geht es nicht nur um das Was, sondern auch um das Wie. Welche Vaadin-Komponenten eignen sich für die Steuerung von Datumsbereichsabfragen? Wie gestaltet man eine Toolbar, die intuitiv bedienbar ist und gleichzeitig die nötige Flexibilität bietet? Wie kapselt man die Kommunikation mit dem Backend, sodass alle Schichten testbar und wartbar bleiben? Die Statistik-Oberfläche ist letztlich ein Beispiel für ein wiederkehrendes Problem: Die Darstellung von zeitbasierten Daten mit nutzergesteuerter Filterung.
Architekturüberblick
Die Schichten des Systems
Die Architektur des URL-Shorteners folgt einem klassischen Schichtenprinzip, das sich auch im Statistikmodul konsequent durchzieht. Vier Module bilden das Rückgrat: Das Core-Modul definiert die gemeinsamen Datenstrukturen und Geschäftslogik. Das Server-Modul stellt die REST-Schnittstelle bereit und verwaltet die Persistenz. Das Client-Modul kapselt die HTTP-Kommunikation in einer Java-API. Und das UI-Modul – eine Vaadin-Flow-Anwendung – macht das Ganze für den Nutzer zugänglich.

Diese Aufteilung ist nicht zufällig. Sie spiegelt eine klare Verantwortungstrennung wider, die in größeren Projekten oft durch Framework-Konventionen erzwungen wird, hier aber explizit im Code sichtbar bleibt. Das Core-Modul kennt weder HTTP noch UI-Komponenten. Es definiert lediglich, was ein RedirectEvent ist, wie ein HourlyAggregate aussieht und welche Felder eine StatisticsConfig enthält. Diese Records und DTOs sind reine Datencontainer, die zwischen den Schichten wandern, ohne selbst Logik zu transportieren.
Das Server-Modul nimmt diese Strukturen entgegen und gibt sie zurück. Es implementiert die HTTP-Handler, die auf Anfragen wie /admin/statistics/count/{shortCode} reagieren, und orchestriert die Persistenzschicht, die wahlweise im Speicher oder über EclipseStore arbeitet. Entscheidend ist: Der Server weiß nichts von Vaadin. Er liefert JSON-Antworten an jeden Client, der die richtige URL aufruft – sei es ein Browser, ein Kommandozeilenwerkzeug oder eben die Vaadin-Oberfläche.

Das Client-Modul schließt die Lücke zwischen Server und UI. Es bietet eine typisierte Java-API, die die REST-Endpunkte hinter Methodenaufrufen verbirgt. Statt manuell HTTP-Verbindungen zu öffnen und JSON zu parsen, ruft die UI einfach statisticsClient.getHourlyStatistics(shortCode, date) auf und erhält ein Optional<HourlyStatisticsResponse> zurück. Diese Abstraktion hält die UI-Schicht schlank und testbar, weil sich der Client bei Bedarf durch ein Mock ersetzen lässt.
Pure Java ohne Framework-Magie
Die Entscheidung gegen Spring Boot und Jakarta EE prägt die gesamte Codebasis. Im Server-Modul bedeutet das: Der eingebaute com.sun.net.httpserver.HttpServer des JDK übernimmt die HTTP-Verarbeitung. Jeder Handler ist eine Klasse, die das HttpHandler-Interface implementiert und explizit am Server registriert wird. Es gibt keine annotationsbasierte Routenerkennung, keine automatische Dependency Injection, keine versteckten Proxies. Was im Code steht, ist das, was zur Laufzeit passiert.

Im Client-Modul setzt sich dieses Prinzip fort. Die Klasse StatisticsClient nutzt HttpURLConnection aus dem JDK, um Anfragen an den Server zu senden. Timeouts, Content-Type-Header, Fehlerbehandlung – alles ist explizit im Code sichtbar. Die Methode executeGet öffnet die Verbindung, liest die Antwort, prüft den Statuscode und deserialisiert das JSON. Keine Magie, keine Überraschungen.
Das Zusammenspiel von REST-API und Vaadin-Frontend
Vaadin Flow ist ein serverseitiges UI-Framework. Die Komponenten leben auf dem Server, und der Browser erhält lediglich eine Darstellung, die über WebSocket synchronisiert wird. Diese Architektur hat einen entscheidenden Vorteil für das Zusammenspiel mit dem Backend: Die UI-Logik läuft in derselben JVM, in der auch der Client-Code ausgeführt wird. Es gibt keinen Medienbruch zwischen Frontend und Backend-Aufruf, keine Notwendigkeit für JavaScript-basierte HTTP-Clients im Browser.
Das folgende Sequenzdiagramm zeigt den vollständigen Ablauf, wenn ein Nutzer in der Statistik-Oberfläche einen neuen Datumsbereich auswählt:

Dieser Ablauf verdeutlicht, warum Vaadin für datengetriebene Anwendungen so gut geeignet ist. Die gesamte Geschäftslogik bleibt auf dem Server. Der Browser ist ein reines Darstellungsmedium, das keine Kenntnis von REST-Endpunkten oder JSON-Strukturen benötigt. Für Entwickler bedeutet das: Sie arbeiten durchgängig in Java, mit denselben Datentypen, derselben IDE-Unterstützung und denselben Debugging-Werkzeugen wie im Backend.
Die REST-API des Servers ist dabei bewusst so gestaltet, dass sie auch ohne Vaadin nutzbar bleibt. Ein Administrator könnte die Statistiken ebenso über curl abfragen oder ein eigenes Dashboard in einer anderen Technologie bauen. Die Vaadin-Oberfläche ist ein Client unter vielen – wenn auch derjenige, der für Endnutzer gedacht ist. Diese Entkopplung hält die Architektur flexibel und ermöglicht es, einzelne Teile unabhängig voneinander weiterzuentwickeln oder auszutauschen.

Das Datenmodell im Core-Modul
Die Rolle des Core-Moduls
Das Core-Modul bildet das Fundament des gesamten Statistiksystems. Es definiert die Datenstrukturen, die zwischen allen Schichten wandern – vom Server, der die Events erfasst, über den Client, der die Daten abruft, bis zur UI, die sie darstellt. Diese zentrale Position bringt eine klare Anforderung mit sich: Das Core-Modul darf keine Abhängigkeiten zu HTTP, Persistenz oder UI-Frameworks haben. Es enthält ausschließlich reine Java-Typen, die sich problemlos serialisieren, testen und zwischen Modulen austauschen lassen.
Java Records sind für diese Aufgabe wie geschaffen. Sie bieten eine kompakte Syntax für unveränderliche Datencontainer, generieren automatisch Konstruktor, Getter, equals, hashCode und toString, und signalisieren durch ihre Natur, dass es sich um reine Datenträger ohne Verhalten handelt. Das Statistikmodul nutzt Records konsequent für alle DTOs und Aggregate.

RedirectEvent: Das Rohereignis
Jeder Klick auf einen Kurzlink erzeugt ein RedirectEvent. Dieses Record erfasst den Moment des Zugriffs zusammen mit den verfügbaren Metadaten aus dem HTTP-Request. Die Struktur ist bewusst schlank gehalten – sie enthält nur Informationen, die ohne zusätzliche externe Dienste verfügbar sind.
package com.svenruppert.urlshortener.core.statistics;
import java.time.Instant;
public record RedirectEvent(
String shortCode,
Instant timestamp,
String userAgent,
String referer,
String ipAddress,
String acceptLanguage
) {
public RedirectEvent {
if (shortCode == null || shortCode.isBlank()) {
throw new IllegalArgumentException("shortCode must not be null or blank");
}
if (timestamp == null) {
timestamp = Instant.now();
}
}
}
Das Record nutzt den kompakten Konstruktor von Java, um Validierung und Standardwerte zu implementieren. Der shortCode ist zwingend erforderlich – ohne ihn wäre das Event nicht zuordenbar. Der timestamp erhält einen Standardwert, falls er nicht explizit gesetzt wird. Die übrigen Felder sind optional und können null sein, etwa wenn der Browser keinen Referer sendet oder der User-Agent nicht auslesbar ist.
Der RedirectEventBuilder: Lesbare Event-Erstellung
Die direkte Instanziierung eines Records mit vielen Parametern wird schnell unübersichtlich, besonders wenn mehrere Felder optional sind. Der RedirectEventBuilder löst dieses Problem durch ein Fluent-API, das die Lesbarkeit erhöht und Fehler bei der Parameterreihenfolge vermeidet.
package com.svenruppert.urlshortener.core.statistics;
import java.time.Instant;
public class RedirectEventBuilder {
private String shortCode;
private Instant timestamp;
private String userAgent;
private String referer;
private String ipAddress;
private String acceptLanguage;
public static RedirectEventBuilder newBuilder() {
return new RedirectEventBuilder();
}
public RedirectEventBuilder shortCode(String shortCode) {
this.shortCode = shortCode;
return this;
}
public RedirectEventBuilder timestamp(Instant timestamp) {
this.timestamp = timestamp;
return this;
}
public RedirectEventBuilder userAgent(String userAgent) {
this.userAgent = userAgent;
return this;
}
public RedirectEventBuilder referer(String referer) {
this.referer = referer;
return this;
}
public RedirectEventBuilder ipAddress(String ipAddress) {
this.ipAddress = ipAddress;
return this;
}
public RedirectEventBuilder acceptLanguage(String acceptLanguage) {
this.acceptLanguage = acceptLanguage;
return this;
}
public RedirectEvent build() {
return new RedirectEvent(
shortCode,
timestamp,
userAgent,
referer,
ipAddress,
acceptLanguage
);
}
}
Die Verwendung im Server-Code wird dadurch selbstdokumentierend:
RedirectEvent event = RedirectEventBuilder.newBuilder()
.shortCode("abc123")
.timestamp(Instant.now())
.userAgent(request.getHeader("User-Agent"))
.referer(request.getHeader("Referer"))
.ipAddress(request.getRemoteAddr())
.acceptLanguage(request.getHeader("Accept-Language"))
.build();
Die Aggregat-Records: Verdichtete Statistiken
Rohe Events sind für die Analyse zu granular. Niemand möchte tausende Einzeleinträge durchscrollen, um zu verstehen, wie oft ein Link aufgerufen wurde. Die Aggregat-Records verdichten die Rohdaten auf sinnvolle Zeiteinheiten.
Das HourlyAggregate fasst alle Events eines ShortCodes für eine bestimmte Stunde eines Tages zusammen:
package com.svenruppert.urlshortener.core.statistics;
public record HourlyAggregate(
String shortCode,
LocalDate date,
int hour,
long count
) {
public HourlyAggregate {
if (shortCode == null || shortCode.isBlank()) {
throw new IllegalArgumentException("shortCode must not be null or blank");
}
if (date == null) {
throw new IllegalArgumentException("date must not be null");
}
if (hour < 0 || hour > 23) {
throw new IllegalArgumentException("hour must be between 0 and 23");
}
if (count < 0) {
throw new IllegalArgumentException("count must not be negative");
}
}
}
Das DailyAggregate geht einen Schritt weiter und summiert alle Zugriffe eines Tages:
package com.svenruppert.urlshortener.core.statistics;
public record DailyAggregate(
String shortCode,
LocalDate date,
long count
) {
public DailyAggregate {
if (shortCode == null || shortCode.isBlank()) {
throw new IllegalArgumentException("shortCode must not be null or blank");
}
if (date == null) {
throw new IllegalArgumentException("date must not be null");
}
if (count < 0) {
throw new IllegalArgumentException("count must not be negative");
}
}
}
Die Validierung im kompakten Konstruktor stellt sicher, dass nur gültige Aggregate entstehen können. Diese defensive Programmierung zahlt sich aus, wenn Daten aus verschiedenen Quellen zusammenfließen – etwa aus der Persistenz oder aus JSON-Deserialisierung.

StatisticsConfig: Konfigurierbare Parameter
Das Verhalten des Statistiksystems lässt sich über die StatisticsConfig steuern. Diese Konfiguration definiert, wie lange detaillierte Daten vorgehalten werden, wie Events gebündelt verarbeitet werden und ob die Statistikerfassung überhaupt aktiv ist.
package com.svenruppert.urlshortener.core.statistics;
public record StatisticsConfig(
int hotWindowDays,
int writerBatchSize,
int aggregatorIntervalSeconds,
boolean statisticsEnabled) {
public static final int DEFAULT_HOT_WINDOW_DAYS = 7;
public static final int DEFAULT_WRITER_BATCH_SIZE = 100;
public static final int DEFAULT_AGGREGATOR_INTERVAL_SECONDS = 300;
public StatisticsConfig {
if (hotWindowDays < 1) {
throw new IllegalArgumentException("hotWindowDays must be at least 1");
}
if (writerBatchSize < 1) {
throw new IllegalArgumentException("writerBatchSize must be at least 1");
}
if (aggregatorIntervalSeconds < 60) {
throw new IllegalArgumentException("aggregatorIntervalSeconds must be at least 60");
}
}
public static StatisticsConfig defaultConfig() {
return new StatisticsConfig(
DEFAULT_HOT_WINDOW_DAYS,
DEFAULT_WRITER_BATCH_SIZE,
DEFAULT_AGGREGATOR_INTERVAL_SECONDS,
true
);
}
}
Das Konzept des “Hot Window” verdient besondere Beachtung. Es definiert einen Zeitraum, für den stündliche Aggregate vorgehalten werden. Innerhalb dieses Fensters – standardmäßig sieben Tage – kann der Nutzer in der UI die Statistiken stundengenau analysieren. Ältere Daten werden nur noch als Tagessummen gespeichert, was Speicherplatz spart, ohne die langfristige Auswertbarkeit zu verlieren.

Response-DTOs für die API-Kommunikation
Die REST-API liefert nicht die internen Aggregate direkt aus, sondern verpackt sie in Response-Objekte, die zusätzlichen Kontext bieten. Diese Trennung ermöglicht es, die API-Struktur unabhängig von der internen Datenhaltung zu gestalten.
package com.svenruppert.urlshortener.core.statistics;
import java.time.LocalDate;
public record StatisticsCountResponse(
String shortCode,
long count,
LocalDate from,
LocalDate to
) {}
package com.svenruppert.urlshortener.core.statistics;
import java.time.LocalDate;
import java.util.List;
public record HourlyStatisticsResponse(
String shortCode,
LocalDate date,
List<HourlyAggregate> hourlyData
) {}
package com.svenruppert.urlshortener.core.statistics;
import java.time.LocalDate;
public record DailyStatisticsResponse(
String shortCode,
LocalDate date,
long count
) {}
package com.svenruppert.urlshortener.core.statistics;
import java.time.LocalDate;
import java.util.List;
public record StatisticsTimelineResponse(
String shortCode,
LocalDate from,
LocalDate to,
List<DailyAggregate> dailyCounts
) {}
package com.svenruppert.urlshortener.core.statistics;
public record StatisticsConfigResponse(
int hotWindowDays,
int writerBatchSize,
int aggregatorIntervalSeconds,
boolean statisticsEnabled
) {
public static StatisticsConfigResponse from(StatisticsConfig config) {
return new StatisticsConfigResponse(
config.hotWindowDays(),
config.writerBatchSize(),
config.aggregatorIntervalSeconds(),
config.statisticsEnabled()
);
}
}
Die StatisticsTimelineResponse ist besonders relevant für die Vaadin-UI. Sie liefert eine Liste von Tagesaggregaten für einen Zeitraum, die sich direkt in einem Chart oder einer Tabelle darstellen lassen. Die Felder from und to dokumentieren den angefragten Zeitraum, sodass die UI dem Nutzer anzeigen kann, welche Daten sie gerade betrachtet.

Die Bedeutung von Immutability
Alle Records im Core-Modul sind unveränderlich. Nach der Erstellung können ihre Werte nicht mehr geändert werden. Diese Eigenschaft ist kein Zufall, sondern eine bewusste Designentscheidung mit weitreichenden Konsequenzen.
Unveränderliche Objekte sind thread-safe ohne zusätzliche Synchronisation. Wenn ein RedirectEvent einmal erstellt ist, kann es bedenkenlos zwischen Threads geteilt werden – etwa vom HTTP-Handler-Thread zum Writer-Thread, der die Events persistiert. Es gibt keine Race Conditions, keine Lock-Contention, keine subtilen Bugs durch gleichzeitige Modifikation.
Für die Vaadin-UI bedeutet das: Die Response-Objekte, die vom StatisticsClient geliefert werden, können direkt in UI-Komponenten verwendet werden, ohne defensives Kopieren. Wenn die StatisticsToolbar eine Änderung auslöst und neue Daten geladen werden, entsteht ein neues Response-Objekt – das alte bleibt unverändert und kann bei Bedarf für Vergleiche oder Caching herangezogen werden.
Die Server-Seite: HTTP-Handler und Persistenz
Die Handler-Architektur
Das Server-Modul bildet das Rückgrat des Statistiksystems. Es empfängt die HTTP-Anfragen, verarbeitet sie und liefert die Ergebnisse als JSON zurück. Die Architektur folgt einem einfachen Prinzip: Jeder Endpunkt erhält seinen eigenen Handler, der genau eine Aufgabe erfüllt. Diese Aufteilung mag auf den ersten Blick nach mehr Code aussehen als eine zentrale Controller-Klasse, bringt aber entscheidende Vorteile: Jeder Handler ist isoliert testbar, die Verantwortlichkeiten sind klar getrennt, und Änderungen an einem Endpunkt können keine Seiteneffekte auf andere haben.

Der JDK-eigene HttpServer arbeitet mit dem Konzept von Contexts. Jeder Context bindet einen URL-Pfad an einen Handler. Die Registrierung erfolgt explizit im Code – keine Annotationen, keine Classpath-Scans, keine Magie:
package com.svenruppert.urlshortener.api;
public class ShortenerServer {
private final HttpServer server;
private final StatisticsStore statisticsStore;
public ShortenerServer(int port, StatisticsStore statisticsStore) throws Exception {
this.statisticsStore = statisticsStore;
this.server = HttpServer.create(new InetSocketAddress(port), 0);
registerStatisticsHandlers();
}
private void registerStatisticsHandlers() {
server.createContext(
"/admin/statistics/count",
new StatisticsCountHandler(statisticsStore)
);
server.createContext(
"/admin/statistics/hourly",
new StatisticsHourlyHandler(statisticsStore)
);
server.createContext(
"/admin/statistics/daily",
new StatisticsDailyHandler(statisticsStore)
);
server.createContext(
"/admin/statistics/timeline",
new StatisticsTimelineHandler(statisticsStore)
);
server.createContext(
"/admin/statistics/config",
new StatisticsConfigHandler(statisticsStore)
);
server.createContext(
"/admin/statistics/debug",
new StatisticsDebugHandler(statisticsStore)
);
}
public void start() {
server.setExecutor(null); // Default executor
server.start();
}
}
Der StatisticsCountHandler: Ein exemplarischer Handler
Der StatisticsCountHandler zeigt das grundlegende Muster, das alle Handler teilen. Er implementiert das HttpHandler-Interface, extrahiert die Parameter aus dem Request, delegiert die eigentliche Arbeit an den Store und serialisiert das Ergebnis als JSON.
package com.svenruppert.urlshortener.api.handler.statistics;
public class StatisticsCountHandler
implements HttpHandler, HasLogger {
private final StatisticsStore store;
public StatisticsCountHandler(StatisticsStore store) {
this.store = store;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
try {
String path = exchange.getRequestURI().getPath();
String shortCode = extractShortCode(path);
if (shortCode == null || shortCode.isBlank()) {
sendError(exchange, 400, "Missing shortCode");
return;
}
Map<String, String> queryParams = parseQueryParams(exchange);
LocalDate from = parseDate(queryParams.get("from"));
LocalDate to = parseDate(queryParams.get("to"));
LocalDate date = parseDate(queryParams.get("date"));
StatisticsCountResponse response = queryCount(shortCode, from, to, date);
JsonWriter.writeJsonResponse(exchange, 200, response);
} catch (Exception e) {
logger().error("Error handling count request", e);
sendError(exchange, 500, "Internal server error");
}
}
private StatisticsCountResponse queryCount(
String shortCode,
LocalDate from,
LocalDate to,
LocalDate date) {
if (date != null) {
// Single date query
long count = store.getCountForDate(shortCode, date);
return new StatisticsCountResponse(shortCode, count, date, date);
}
if (from != null && to != null) {
// Date range query
long count = store.getCountForDateRange(shortCode, from, to);
return new StatisticsCountResponse(shortCode, count, from, to);
}
// Total count (all time)
long count = store.getTotalCount(shortCode);
return new StatisticsCountResponse(shortCode, count, null, null);
}
private String extractShortCode(String path) {
// Path: /admin/statistics/count/{shortCode}
String prefix = PATH_ADMIN_STATISTICS_COUNT + "/";
if (path.startsWith(prefix) && path.length() > prefix.length()) {
return path.substring(prefix.length());
}
return null;
}
private Map<String, String> parseQueryParams(HttpExchange exchange) {
// Query parameter parsing implementation
// ...
}
private LocalDate parseDate(String value) {
if (value == null || value.isBlank()) {
return null;
}
return LocalDate.parse(value);
}
private void sendError(HttpExchange exchange, int code, String message)
throws IOException {
// Error response implementation
// ...
}
}
Das Muster wiederholt sich in allen Statistik-Handlern: Request-Methode prüfen, Parameter extrahieren, Store aufrufen, Response serialisieren. Die Unterschiede liegen in den spezifischen Query-Parametern und den aufgerufenen Store-Methoden.
Der StatisticsTimelineHandler: Daten für die UI
Für die Vaadin-UI ist der StatisticsTimelineHandler besonders relevant. Er liefert die Daten, die in einem Zeitverlaufs-Chart dargestellt werden – eine Liste von Tagesaggregaten für einen definierten Zeitraum.
package com.svenruppert.urlshortener.api.handler.statistics;
public class StatisticsTimelineHandler implements HttpHandler {
private final StatisticsStore store;
public StatisticsTimelineHandler(StatisticsStore store) {
this.store = store;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
try {
String shortCode = extractShortCode(exchange.getRequestURI().getPath());
Map<String, String> params = parseQueryParams(exchange);
LocalDate from = LocalDate.parse(params.get("from"));
LocalDate to = LocalDate.parse(params.get("to"));
List<DailyAggregate> dailyCounts = store.getDailyAggregates(
shortCode, from, to
);
StatisticsTimelineResponse response = new StatisticsTimelineResponse(
shortCode, from, to, dailyCounts
);
JsonWriter.writeJsonResponse(exchange, 200, response);
} catch (Exception e) {
sendError(exchange, 500, "Internal server error");
}
}
// Helper methods...
}
Die Response enthält nicht nur die Daten, sondern auch die Abfrageparameter from und to. Das ermöglicht der UI, den angezeigten Zeitraum zu verifizieren und dem Nutzer anzuzeigen – eine defensive Maßnahme, die Missverständnisse vermeidet, wenn etwa die UI-Eingabe und die tatsächliche Abfrage auseinanderlaufen sollten.
Der RequestDataExtractor: Metadaten aus dem HTTP-Request
Bevor Events gespeichert werden können, müssen sie erfasst werden. Der RequestDataExtractor zieht die relevanten Informationen aus dem HTTP-Request, wenn ein Redirect stattfindet.
package com.svenruppert.urlshortener.api.store.statistics;
public class RequestDataExtractor {
public static RedirectEvent extractEvent(HttpExchange exchange, String shortCode) {
return RedirectEventBuilder.newBuilder()
.shortCode(shortCode)
.timestamp(Instant.now())
.userAgent(getHeader(exchange, "User-Agent"))
.referer(getHeader(exchange, "Referer"))
.ipAddress(extractIpAddress(exchange))
.acceptLanguage(getHeader(exchange, "Accept-Language"))
.build();
}
private static String getHeader(HttpExchange exchange, String name) {
var headers = exchange.getRequestHeaders();
var values = headers.get(name);
return (values != null && !values.isEmpty()) ? values.getFirst() : null;
}
private static String extractIpAddress(HttpExchange exchange) {
// Check for X-Forwarded-For header (reverse proxy scenarios)
String forwarded = getHeader(exchange, "X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
// Take the first IP in the chain
return forwarded.split(",")[0].trim();
}
// Fall back to direct connection address
var remoteAddress = exchange.getRemoteAddress();
return remoteAddress != null ? remoteAddress.getAddress().getHostAddress() : null;
}
}
Die Extraktion der IP-Adresse verdient besondere Aufmerksamkeit. In Produktionsumgebungen steht der Server oft hinter einem Reverse Proxy oder Load Balancer. In diesem Fall enthält getRemoteAddress() die IP des Proxys, nicht die des eigentlichen Clients. Der X-Forwarded-For-Header transportiert die ursprüngliche Client-IP durch die Proxy-Kette. Der Extractor prüft diesen Header zuerst und fällt nur bei Abwesenheit auf die direkte Verbindungsadresse zurück.

Das StatisticsStore Interface: Abstraktion der Persistenz
Die Handler arbeiten nicht direkt mit einer konkreten Speicherimplementierung, sondern gegen ein Interface. Diese Abstraktion ermöglicht es, verschiedene Backends zu unterstützen – von einer einfachen InMemory-Lösung für Tests und Entwicklung bis hin zu persistenten Lösungen für den Produktionsbetrieb.
package com.svenruppert.urlshortener.api.store.statistics;
public interface StatisticsStore {
// Write operations
void recordEvent(RedirectEvent event);
void recordEvents(List<RedirectEvent> events);
// Count queries
long getTotalCount(String shortCode);
long getCountForDate(String shortCode, LocalDate date);
long getCountForDateRange(String shortCode, LocalDate from, LocalDate to);
// Aggregate queries
Optional<List<HourlyAggregate>> getHourlyAggregates(
String shortCode, LocalDate date);
List<DailyAggregate> getDailyAggregates(
String shortCode, LocalDate from, LocalDate to);
// Configuration
StatisticsConfig getConfig();
void updateConfig(StatisticsConfig config);
}
Die InMemory-Implementierung: Einfach und transparent
Für Entwicklung und Tests bietet die InMemory-Implementierung einen schnellen Einstieg. Sie hält alle Daten in Maps und Listen im Arbeitsspeicher – performant, aber flüchtig.
package com.svenruppert.urlshortener.api.store.provider.inmemory;
public class InMemoryStatisticsStore implements StatisticsStore {
private final List<RedirectEvent> events = new CopyOnWriteArrayList<>();
private final Map<String, Map<LocalDate, Map<Integer, Long>>> hourlyData =
new ConcurrentHashMap<>();
private final Map<String, Map<LocalDate, Long>> dailyData =
new ConcurrentHashMap<>();
private StatisticsConfig config = StatisticsConfig.defaultConfig();
@Override
public void recordEvent(RedirectEvent event) {
if (!config.statisticsEnabled()) {
return;
}
events.add(event);
updateAggregates(event);
}
@Override
public void recordEvents(List<RedirectEvent> eventList) {
if (!config.statisticsEnabled()) {
return;
}
for (RedirectEvent event : eventList) {
recordEvent(event);
}
}
private void updateAggregates(RedirectEvent event) {
LocalDate date = event.timestamp()
.atZone(ZoneId.systemDefault())
.toLocalDate();
int hour = event.timestamp()
.atZone(ZoneId.systemDefault())
.getHour();
// Update hourly aggregate
hourlyData
.computeIfAbsent(event.shortCode(), k -> new ConcurrentHashMap<>())
.computeIfAbsent(date, k -> new ConcurrentHashMap<>())
.merge(hour, 1L, Long::sum);
// Update daily aggregate
dailyData
.computeIfAbsent(event.shortCode(), k -> new ConcurrentHashMap<>())
.merge(date, 1L, Long::sum);
}
@Override
public long getTotalCount(String shortCode) {
Map<LocalDate, Long> byDate = dailyData.get(shortCode);
if (byDate == null) {
return 0;
}
return byDate.values().stream().mapToLong(Long::longValue).sum();
}
@Override
public long getCountForDate(String shortCode, LocalDate date) {
return dailyData
.getOrDefault(shortCode, Collections.emptyMap())
.getOrDefault(date, 0L);
}
@Override
public long getCountForDateRange(String shortCode, LocalDate from, LocalDate to) {
Map<LocalDate, Long> byDate = dailyData.get(shortCode);
if (byDate == null) {
return 0;
}
return byDate.entrySet().stream()
.filter(e -> !e.getKey().isBefore(from) && !e.getKey().isAfter(to))
.mapToLong(Map.Entry::getValue)
.sum();
}
@Override
public Optional<List<HourlyAggregate>> getHourlyAggregates(
String shortCode, LocalDate date) {
// Check if date is within hot window
LocalDate hotWindowStart = LocalDate.now().minusDays(config.hotWindowDays());
if (date.isBefore(hotWindowStart)) {
return Optional.empty();
}
Map<LocalDate, Map<Integer, Long>> byDate = hourlyData.get(shortCode);
if (byDate == null) {
return Optional.of(Collections.emptyList());
}
Map<Integer, Long> byHour = byDate.get(date);
if (byHour == null) {
return Optional.of(Collections.emptyList());
}
List<HourlyAggregate> result = byHour.entrySet().stream()
.map(e -> new HourlyAggregate(shortCode, date, e.getKey(), e.getValue()))
.sorted(Comparator.comparingInt(HourlyAggregate::hour))
.toList();
return Optional.of(result);
}
@Override
public List<DailyAggregate> getDailyAggregates(
String shortCode, LocalDate from, LocalDate to) {
Map<LocalDate, Long> byDate = dailyData.get(shortCode);
if (byDate == null) {
return Collections.emptyList();
}
return byDate.entrySet().stream()
.filter(e -> !e.getKey().isBefore(from) && !e.getKey().isAfter(to))
.map(e -> new DailyAggregate(shortCode, e.getKey(), e.getValue()))
.sorted(Comparator.comparing(DailyAggregate::date))
.toList();
}
@Override
public StatisticsConfig getConfig() {
return config;
}
@Override
public void updateConfig(StatisticsConfig config) {
this.config = config;
}
}
Die Verwendung von ConcurrentHashMap und CopyOnWriteArrayList stellt Thread-Sicherheit sicher. Mehrere HTTP-Handler-Threads können gleichzeitig Events schreiben und lesen, ohne dass explizite Synchronisation erforderlich wäre. Die computeIfAbsent- und merge-Methoden von ConcurrentHashMap sind atomar und vermeiden Race Conditions bei der Aggregation.
Die EclipseStore-Implementierung: Persistenz ohne ORM
Für den Produktionsbetrieb bietet EclipseStore eine elegante Lösung. Es persistiert Java-Objekte direkt, ohne den Umweg über ein relationales Schema oder ein ORM. Die Datenstrukturen im Code entsprechen exakt dem, was auf der Festplatte landet.
package com.svenruppert.urlshortener.api.store.provider.eclipsestore;
public class DataRoot {
// URL Mappings
private final Map<String, ShortUrlMapping> mappings = new ConcurrentHashMap<>();
// Statistics
private final Map<String, Map<LocalDate, Map<Integer, Long>>> hourlyStats =
new ConcurrentHashMap<>();
private final Map<String, Map<LocalDate, Long>> dailyStats =
new ConcurrentHashMap<>();
private StatisticsConfig statisticsConfig = StatisticsConfig.defaultConfig();
// Getters and setters...
public Map<String, Map<LocalDate, Map<Integer, Long>>> getHourlyStats() {
return hourlyStats;
}
public Map<String, Map<LocalDate, Long>> getDailyStats() {
return dailyStats;
}
public StatisticsConfig getStatisticsConfig() {
return statisticsConfig;
}
public void setStatisticsConfig(StatisticsConfig config) {
this.statisticsConfig = config;
}
}
package com.svenruppert.urlshortener.api.store.provider.eclipsestore.partitions;
public class EclipseStatisticsStore implements StatisticsStore {
private final EmbeddedStorageManager storage;
private final DataRoot root;
public EclipseStatisticsStore(Path storagePath) {
this.root = new DataRoot();
this.storage = EmbeddedStorage.start(root, storagePath);
}
@Override
public void recordEvent(RedirectEvent event) {
if (!root.getStatisticsConfig().statisticsEnabled()) {
return;
}
LocalDate date = event.timestamp()
.atZone(ZoneId.systemDefault())
.toLocalDate();
int hour = event.timestamp()
.atZone(ZoneId.systemDefault())
.getHour();
// Update hourly stats
root.getHourlyStats()
.computeIfAbsent(event.shortCode(), k -> new ConcurrentHashMap<>())
.computeIfAbsent(date, k -> new ConcurrentHashMap<>())
.merge(hour, 1L, Long::sum);
// Update daily stats
root.getDailyStats()
.computeIfAbsent(event.shortCode(), k -> new ConcurrentHashMap<>())
.merge(date, 1L, Long::sum);
// Persist changes
storage.store(root.getHourlyStats());
storage.store(root.getDailyStats());
}
@Override
public void updateConfig(StatisticsConfig config) {
root.setStatisticsConfig(config);
storage.store(root.getStatisticsConfig());
}
public void shutdown() {
storage.shutdown();
}
// Other methods similar to InMemoryStatisticsStore...
}
Der entscheidende Unterschied zur InMemory-Variante liegt in den storage.store()-Aufrufen. Sie signalisieren EclipseStore, welche Teile des Objektgraphen sich geändert haben und persistiert werden sollen. Nach einem Neustart lädt EclipseStore den DataRoot automatisch aus dem Speicherverzeichnis – die Anwendung arbeitet mit denselben Objekten weiter, als wäre sie nie beendet worden.

Event-Aggregation und das Writer-Pattern
In einem System mit hohem Durchsatz wäre es ineffizient, jedes einzelne Event sofort zu persistieren. Das Statistikmodul unterstützt daher ein Batch-Verfahren: Events werden zunächst gesammelt und dann in größeren Einheiten geschrieben.
package com.svenruppert.urlshortener.api.store.statistics;
public class StatisticsWriter implements HasLogger, Runnable {
private final BlockingQueue<RedirectEvent> eventQueue = new LinkedBlockingQueue<>();
private final StatisticsStore store;
private final int batchSize;
private volatile boolean running = true;
public StatisticsWriter(StatisticsStore store, int batchSize) {
this.store = store;
this.batchSize = batchSize;
}
public void submit(RedirectEvent event) {
eventQueue.offer(event);
}
@Override
public void run() {
List<RedirectEvent> batch = new ArrayList<>(batchSize);
while (running || !eventQueue.isEmpty()) {
try {
// Wait for first event with timeout
RedirectEvent event = eventQueue.poll(1, TimeUnit.SECONDS);
if (event != null) {
batch.add(event);
// Drain additional events up to batch size
eventQueue.drainTo(batch, batchSize - 1);
// Write batch to store
if (!batch.isEmpty()) {
store.recordEvents(batch);
logger().debug("Wrote batch of {} events", batch.size());
batch.clear();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
// Final flush
if (!batch.isEmpty()) {
store.recordEvents(batch);
}
}
public void shutdown() {
running = false;
}
}

Der StatisticsWriter läuft in einem eigenen Thread und entkoppelt damit die Event-Erfassung von der Persistenz. Der HTTP-Handler kann sofort nach dem submit()-Aufruf weitermachen und die Redirect-Response senden. Die eigentliche Speicherung erfolgt asynchron, was die Latenz für den Endnutzer minimiert.
Der StatisticsDebugHandler: Einblick für Entwickler
Für Debugging und Monitoring bietet der StatisticsDebugHandler einen Einblick in den internen Zustand des Systems. Er ist bewusst auf den Admin-Port beschränkt und sollte in Produktionsumgebungen zusätzlich abgesichert werden.
package com.svenruppert.urlshortener.api.handler.statistics;
public class StatisticsDebugHandler implements HttpHandler {
private final StatisticsStore store;
public StatisticsDebugHandler(StatisticsStore store) {
this.store = store;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
Map<String, Object> debugInfo = new HashMap<>();
debugInfo.put("config", store.getConfig());
debugInfo.put("storeType", store.getClass().getSimpleName());
debugInfo.put("timestamp", System.currentTimeMillis());
JsonWriter.writeJsonResponse(exchange, 200, debugInfo);
}
}
Im nächsten Teil geht es dann um die UI mittels Vaadin.. stay tuned..