Die Vaadin-UI-Komponenten im Detail
In diesem Teil geht es nun weiter mit der Implementierung von den Redirect-Statistiken in dem Open Source Tool URL-Shortener. Den ersten Teil findest Du hier: https://3g3.eu/Hfzsf3
Die Quelltexte findest Du auf GitHub unter https://3g3.eu/url

Die Komponentenarchitektur
Die Vaadin-UI für die Statistik-Funktionalität besteht aus mehreren eng verzahnten, aber bewusst entkoppelten Komponenten. Im Zentrum stehen zwei Views: die StatisticsView als Übersichtsseite und die StatisticsDetailView für die Analyse einzelner ShortCodes. Beide nutzen die wiederverwendbare StatisticsToolbar für Datumsauswahl und Granularitätssteuerung. Diese Trennung folgt dem Prinzip der Komposition über Vererbung – ein Muster, das sich in Vaadin-Anwendungen bewährt hat.

Table of Contents
- Die Vaadin-UI-Komponenten im Detail
- Die Komponentenarchitektur
- Die StatisticsView: Übersicht aller ShortCodes
- Die Konfiguration der SearchBar
- Die StatisticsToolbar: Datumsbereich und Granularität
- Das Hot-Window-Konzept in der UI
- Die Internationalisierung
- Das AggregationGranularity Enum
- Die Grid-Konfiguration in der Übersicht
- Das Anreichern mit Zählern
- Die StatisticsDetailView: Drill-Down zu einem ShortCode
- Das Laden der Daten je nach Granularität
- Stündliche Daten aus dem Hot Window
- Das AggregateRow Record
- Der Datenfluss in der Praxis
Die StatisticsView: Übersicht aller ShortCodes
Die StatisticsView ist die Einstiegsseite für Statistiken. Sie zeigt eine paginierte Liste aller ShortCodes mit ihren Redirect-Zählern. Der Aufbau folgt dem bewährten Muster einer Vaadin-View mit Grid, Suchleiste und Paginierung.
package com.svenruppert.urlshortener.ui.vaadin.views.statistics;
/**
* Statistics overview view showing all shortcodes with their redirect counts.
* Uses SearchBar for filtering and StatisticsToolbar for date range/granularity.
*/
@PageTitle("Statistics")
@Route(value = StatisticsView.PATH, layout = MainLayout.class)
@CssImport("./styles/statistics-view.css")
public class StatisticsView
extends VerticalLayout
implements HasLogger, I18nSupport {
public static final String PATH = "statistics";
private static final String C_ROOT = "statistics-view";
private static final String C_GRID = "statistics-view__grid";
private static final String C_URL_ANCHOR = "statistics-view__url";
private static final String C_SHORTCODE = "statistics-view__shortcode";
private static final String C_COUNT = "statistics-view__count";
private static final String C_PAGING_BAR = "statistics-view__pagingbar";
// i18n keys
private static final String K_TITLE = "statistics.title";
private static final String K_COL_SHORTCODE = "statistics.grid.column.shortcode";
private static final String K_COL_URL = "statistics.grid.column.url";
private static final String K_COL_COUNT = "statistics.grid.column.count";
private static final String K_COL_ACTIONS = "statistics.grid.column.actions";
private static final String K_PAGING_PREV = "statistics.paging.prev";
private static final String K_PAGING_NEXT = "statistics.paging.next";
private static final String K_DETAILS_TOOLTIP = "statistics.details.tooltip";
private final URLShortenerClient urlShortenerClient = UrlShortenerClientFactory.newInstance();
private final StatisticsClient statisticsClient = StatisticsClientFactory.newInstance();
private final SearchBar searchBar = new SearchBar();
private final StatisticsToolbar statisticsToolbar = new StatisticsToolbar();
private final Grid<ShortUrlMappingWithCount> grid = new Grid<>();
private final Button prevBtn = new Button();
private final Button nextBtn = new Button();
private final Text pageInfo = new Text("");
private int currentPage = 1;
private int totalCount = 0;
public StatisticsView() {
addClassName(C_ROOT);
setSizeFull();
setPadding(true);
setSpacing(true);
add(new H2(tr(K_TITLE, "Statistics")));
configureSearchBar();
configureStatisticsToolbar();
configureGrid();
configurePagingBar();
applyI18n();
add(searchBar, statisticsToolbar, grid, buildPagingBar());
// Initial data load
loadData();
}
// ...
}
Die View nutzt zwei Clients: den URLShortenerClient für die Mapping-Liste und den StatisticsClient für die Zähler. Diese werden über Factory-Klassen instanziiert, was die Konfiguration zentralisiert:
package com.svenruppert.urlshortener.ui.vaadin.tools;
public class StatisticsClientFactory {
private StatisticsClientFactory() {
}
public static StatisticsClient newInstance() {
return new StatisticsClient();
}
}
Die CSS-Klassen folgen der BEM-Konvention (Block-Element-Modifier). Das Präfix statistics-view__ gruppiert alle Elemente dieser View und verhindert Namenskollisionen mit anderen Komponenten.
Die Konfiguration der SearchBar
Die SearchBar ist eine wiederverwendbare Komponente, die in mehreren Views zum Einsatz kommt. Für die Statistik-View wird sie über Callbacks konfiguriert:
private void configureSearchBar() {
// Configure callbacks
searchBar.setOnFilterChange(_ -> {
currentPage = 1;
loadData();
});
searchBar.setOnPageSizeChange(_ -> {
currentPage = 1;
loadData();
});
searchBar.setOnReset(_ -> {
currentPage = 1;
statisticsToolbar.resetToDefaults();
loadData();
});
// Set default page size
searchBar.setPageSize(25);
}
Das Lambda-Muster mit Consumer<Void> ermöglicht lose Kopplung. Die SearchBar weiß nichts über Statistiken – sie signalisiert nur, dass sich Filter geändert haben. Die konkrete Reaktion liegt bei der View.
Die StatisticsToolbar: Datumsbereich und Granularität
Die StatisticsToolbar kapselt die statistikspezifischen Steuerelemente: Datumsbereich und Aggregationsgranularität. Sie ist als Composite implementiert, was den internen Aufbau vor der Außenwelt verbirgt.
package com.svenruppert.urlshortener.ui.vaadin.views.statistics;
/**
* Toolbar for statistics-specific controls: date range and aggregation granularity.
* Designed to be used below the SearchBar in statistics views.
*/
@CssImport("./styles/statistics-toolbar.css")
public class StatisticsToolbar
extends Composite<HorizontalLayout>
implements HasLogger, I18nSupport {
private static final String C_ROOT = "statistics-toolbar";
private static final String C_DATE_RANGE = "statistics-toolbar__date-range";
private static final String C_GRANULARITY = "statistics-toolbar__granularity";
private static final int DEFAULT_HOT_WINDOW_DAYS = 7;
// i18n keys
private static final String K_FROM_LABEL = "statistics.toolbar.from.label";
private static final String K_TO_LABEL = "statistics.toolbar.to.label";
private static final String K_GRANULARITY_LABEL = "statistics.toolbar.granularity.label";
private static final String K_GRANULARITY_HOUR = "statistics.toolbar.granularity.hour";
private static final String K_GRANULARITY_DAY = "statistics.toolbar.granularity.day";
private static final String K_GRANULARITY_WEEK = "statistics.toolbar.granularity.week";
private static final String K_GRANULARITY_MONTH = "statistics.toolbar.granularity.month";
private final DatePicker fromDate = new DatePicker();
private final DatePicker toDate = new DatePicker();
private final Select<AggregationGranularity> granularity = new Select<>();
private int hotWindowDays = DEFAULT_HOT_WINDOW_DAYS;
private Consumer<Void> onFilterChange;
public StatisticsToolbar() {
container().addClassName(C_ROOT);
container().setWidthFull();
container().setSpacing(true);
container().setAlignItems(FlexComponent.Alignment.END);
buildComponents();
applyI18n();
addListeners();
setDefaultValues();
}
private HorizontalLayout container() {
return getContent();
}
private void buildComponents() {
fromDate.addClassName(C_DATE_RANGE);
fromDate.setClearButtonVisible(true);
toDate.addClassName(C_DATE_RANGE);
toDate.setClearButtonVisible(true);
granularity.addClassName(C_GRANULARITY);
granularity.setItems(AggregationGranularity.values());
granularity.setItemLabelGenerator(this::translateGranularity);
granularity.setEmptySelectionAllowed(false);
container().add(fromDate, toDate, granularity);
}
// ...
}

Das Hot-Window-Konzept in der UI
Die Toolbar reagiert auf das Hot-Window-Konzept des Backends. Stündliche Daten sind nur für die letzten Tage verfügbar. Die UI spiegelt diese Einschränkung wider:
private void updateHourlyAvailability() {
LocalDate from = fromDate.getValue();
LocalDate to = toDate.getValue();
LocalDate hotWindowStart = LocalDate.now().minusDays(hotWindowDays);
// Hour granularity is only available if the entire range is within the hot window
final boolean hourlyAvailable =
(from == null || !from.isBefore(hotWindowStart))
&& (to == null || !to.isBefore(hotWindowStart));
// If hourly is currently selected but not available, switch to day
if (!hourlyAvailable && granularity.getValue() == AggregationGranularity.HOUR) {
granularity.setValue(AggregationGranularity.DAY);
}
granularity.setItemEnabledProvider(
g -> g != AggregationGranularity.HOUR || hourlyAvailable
);
}
Diese Logik sorgt dafür, dass der Nutzer keine ungültigen Kombinationen wählen kann. Wenn er ein Datum außerhalb des Hot Windows wählt, wird die stündliche Granularität automatisch deaktiviert. Das ist ein Beispiel für progressive Disclosure – die UI zeigt nur Optionen, die im aktuellen Kontext sinnvoll sind.

Die Internationalisierung
Alle sichtbaren Texte werden über das I18n-System von Vaadin übersetzt. Das Interface I18nSupport stellt Hilfsmethoden bereit:
package com.svenruppert.urlshortener.ui.vaadin.tools;
public interface I18nSupport {
default String tr(String key) {
return ((Component) this).getTranslation(key);
}
default String tr(String key, String fallback) {
final String translated = ((Component) this).getTranslation(key);
if (translated == null || translated.isBlank() || translated.equals(key)) {
return fallback;
}
return translated;
}
default String tr(String key, String fallback, Object... params) {
final String translated = ((Component) this).getTranslation(key, params);
if (translated == null || translated.isBlank() || translated.equals(key)) {
// fallback uses the same placeholder style as Vaadin (MessageFormat)
return java.text.MessageFormat.format(fallback, params);
}
return translated;
}
}
Die Übersetzungen liegen in Properties-Dateien:
# Statistics Toolbar
statistics.toolbar.from.label=Von
statistics.toolbar.to.label=Bis
statistics.toolbar.granularity.label=Granularität
statistics.toolbar.granularity.hour=Stunde
statistics.toolbar.granularity.day=Tag
statistics.toolbar.granularity.week=Woche
statistics.toolbar.granularity.month=Monat
Die applyI18n-Methode wendet diese Übersetzungen auf die Komponenten an:
private void applyI18n() {
fromDate.setLabel(tr(K_FROM_LABEL, "From"));
toDate.setLabel(tr(K_TO_LABEL, "To"));
granularity.setLabel(tr(K_GRANULARITY_LABEL, "Granularity"));
}
private String translateGranularity(AggregationGranularity g) {
return switch (g) {
case HOUR -> tr(K_GRANULARITY_HOUR, "Hour");
case DAY -> tr(K_GRANULARITY_DAY, "Day");
case WEEK -> tr(K_GRANULARITY_WEEK, "Week");
case MONTH -> tr(K_GRANULARITY_MONTH, "Month");
};
}
Das AggregationGranularity Enum
Die Granularitätsauswahl wird durch ein einfaches Enum modelliert:
package com.svenruppert.urlshortener.ui.vaadin.views.statistics;
/**
* Defines the granularity for statistics aggregation.
*/
public enum AggregationGranularity {
HOUR("hour"),
DAY("day"),
WEEK("week"),
MONTH("month");
private final String key;
AggregationGranularity(String key) {
this.key = key;
}
public String getKey() {
return key;
}
}
Das Enum ist bewusst minimal gehalten. Die Übersetzung erfolgt nicht im Enum selbst, sondern in der Komponente, die es anzeigt. Diese Trennung ermöglicht es, dasselbe Enum in verschiedenen Kontexten mit unterschiedlichen Labels zu verwenden.
Die Grid-Konfiguration in der Übersicht
Das Grid der StatisticsView zeigt ShortCode, URL und Redirect-Zähler. Die Spalten werden programmatisch definiert:
private void configureGrid() {
grid.addClassName(C_GRID);
grid.addThemeVariants(GridVariant.LUMO_ROW_STRIPES);
grid.setSelectionMode(Grid.SelectionMode.NONE);
// Shortcode column
grid.addComponentColumn(item -> {
Span code = new Span(item.mapping().shortCode());
code.addClassName(C_SHORTCODE);
return code;
}).setKey("shortcode").setAutoWidth(true).setFlexGrow(0).setSortable(true);
// URL column
grid.addComponentColumn(item -> {
Anchor a = new Anchor(item.mapping().originalUrl(), item.mapping().originalUrl());
a.addClassName(C_URL_ANCHOR);
a.setTarget("_blank");
a.getElement().setProperty("title", item.mapping().originalUrl());
return a;
}).setKey("url").setFlexGrow(1);
// Count column
grid.addComponentColumn(item -> {
Span count = new Span(String.valueOf(item.count()));
count.addClassName(C_COUNT);
return count;
}).setKey("count").setAutoWidth(true).setFlexGrow(0).setSortable(true);
// Actions column (detail link)
grid.addComponentColumn(item -> {
Button detailBtn = new Button(new Icon(VaadinIcon.CHART));
detailBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
detailBtn.getElement().setProperty("title", tr(K_DETAILS_TOOLTIP, "View details"));
detailBtn.addClickListener(_ ->
detailBtn.getUI()
.ifPresent(ui ->
ui.navigate(StatisticsDetailView.class,
item.mapping().shortCode())
)
);
return detailBtn;
}).setKey("actions").setAutoWidth(true).setFlexGrow(0);
}
Die addComponentColumn-Methode erlaubt es, beliebige Vaadin-Komponenten in Zellen darzustellen. Der Detail-Button navigiert zur StatisticsDetailView und übergibt den ShortCode als URL-Parameter.
Das Anreichern mit Zählern
Die Mapping-Daten werden mit Redirect-Zählern angereichert:
private List<ShortUrlMappingWithCount> enrichWithCounts(List<ShortUrlMapping> mappings) {
LocalDate from = statisticsToolbar.getFromDate();
LocalDate to = statisticsToolbar.getToDate();
return mappings.stream()
.map(mapping -> {
long count = 0;
try {
if (from != null && to != null) {
StatisticsCountResponse response = statisticsClient.getCountForDateRange(
mapping.shortCode(), from, to
);
count = response != null ? response.count() : 0;
} else {
StatisticsCountResponse response = statisticsClient.getTotalCount(mapping.shortCode());
count = response != null ? response.count() : 0;
}
} catch (IOException ex) {
logger().warn("Failed to get count for {}: {}", mapping.shortCode(), ex.getMessage());
}
return new ShortUrlMappingWithCount(mapping, count);
})
.toList();
}
/**
* Record combining a ShortUrlMapping with its redirect count.
*/
public record ShortUrlMappingWithCount(ShortUrlMapping mapping, long count) {
}
Das Record ShortUrlMappingWithCount kombiniert Mapping und Zähler in einem unveränderlichen Datenobjekt. Die Stream-Verarbeitung macht den Code deklarativ und leicht verständlich.
Die StatisticsDetailView: Drill-Down zu einem ShortCode
Die StatisticsDetailView zeigt detaillierte Statistiken für einen einzelnen ShortCode. Sie implementiert HasUrlParameter<String>, um den ShortCode aus der URL zu extrahieren:
package com.svenruppert.urlshortener.ui.vaadin.views.statistics;
/**
* Detail view showing statistics for a specific shortcode.
* Uses StatisticsToolbar for date range and granularity controls.
*/
@PageTitle("Statistics Detail")
@Route(value = StatisticsDetailView.PATH, layout = MainLayout.class)
@CssImport("./styles/statistics-detail-view.css")
public class StatisticsDetailView
extends VerticalLayout
implements HasUrlParameter<String>, HasLogger, I18nSupport {
public static final String PATH = "statisticDetails";
// ...
@Override
public void setParameter(BeforeEvent event, @OptionalParameter String parameter) {
if (parameter == null || parameter.isBlank()) {
Notifications.errorKey(K_NOT_FOUND, "No shortcode specified");
event.forwardTo(StatisticsView.class);
return;
}
this.shortCode = parameter;
loadMappingInfo();
loadData();
}
// ...
}

Das Laden der Daten je nach Granularität
Die loadData-Methode delegiert an granularitätsspezifische Methoden:
private void loadData() {
if (shortCode == null) return;
LocalDate from = statisticsToolbar.getFromDate();
LocalDate to = statisticsToolbar.getToDate();
AggregationGranularity granularity = statisticsToolbar.getGranularity();
// Load total count for the selected period
loadTotalCount(from, to);
// Load aggregated data based on granularity
List<AggregateRow> rows = switch (granularity) {
case HOUR -> loadHourlyData(from, to);
case DAY -> loadDailyData(from, to);
case WEEK -> loadWeeklyData(from, to);
case MONTH -> loadMonthlyData(from, to);
};
grid.setItems(rows);
}
Die täglichen Daten kommen direkt vom Server:
private List<AggregateRow> loadDailyData(LocalDate from, LocalDate to) {
List<AggregateRow> rows = new ArrayList<>();
try {
StatisticsTimelineResponse timeline = statisticsClient.getTimeline(
shortCode,
from != null ? from : LocalDate.now().minusDays(30),
to != null ? to : LocalDate.now()
);
if (timeline != null && timeline.dailyCounts() != null) {
for (var dailyCount : timeline.dailyCounts()) {
String label = dailyCount.date().format(DATE_FMT);
rows.add(new AggregateRow(label, dailyCount.count()));
}
}
} catch (IOException ex) {
logger().error("Failed to load daily data for {}", shortCode, ex);
}
return rows;
}
Wöchentliche und monatliche Aggregationen werden clientseitig berechnet:
private List<AggregateRow> loadWeeklyData(LocalDate from, LocalDate to) {
List<AggregateRow> dailyRows = loadDailyData(from, to);
Map<String, Long> weeklyAggregates = new LinkedHashMap<>();
WeekFields weekFields = WeekFields.of(Locale.getDefault());
for (AggregateRow row : dailyRows) {
LocalDate date = LocalDate.parse(row.periodLabel(), DATE_FMT);
int year = date.getYear();
int week = date.get(weekFields.weekOfWeekBasedYear());
String weekLabel = year + "-W" + String.format("%02d", week);
weeklyAggregates.merge(weekLabel, row.count(), Long::sum);
}
return weeklyAggregates.entrySet().stream()
.map(e -> new AggregateRow(e.getKey(), e.getValue()))
.toList();
}
private List<AggregateRow> loadMonthlyData(LocalDate from, LocalDate to) {
List<AggregateRow> dailyRows = loadDailyData(from, to);
Map<String, Long> monthlyAggregates = new LinkedHashMap<>();
for (AggregateRow row : dailyRows) {
LocalDate date = LocalDate.parse(row.periodLabel(), DATE_FMT);
String monthLabel = date.getYear() + "-" + String.format("%02d", date.getMonthValue());
monthlyAggregates.merge(monthLabel, row.count(), Long::sum);
}
return monthlyAggregates.entrySet().stream()
.map(e -> new AggregateRow(e.getKey(), e.getValue()))
.toList();
}
Die Verwendung von LinkedHashMap bewahrt die Einfügereihenfolge, sodass die Wochen und Monate chronologisch sortiert bleiben.
Stündliche Daten aus dem Hot Window
Die stündlichen Daten erfordern einen anderen Ansatz, da sie tageweise abgefragt werden müssen:
private List<AggregateRow> loadHourlyData(LocalDate from, LocalDate to) {
List<AggregateRow> rows = new ArrayList<>();
try {
LocalDate current = from != null ? from : LocalDate.now().minusDays(7);
LocalDate end = to != null ? to : LocalDate.now();
while (!current.isAfter(end)) {
Optional<HourlyStatisticsResponse> hourlyOpt =
statisticsClient.getHourlyStatistics(shortCode, current);
if (hourlyOpt.isPresent()) {
HourlyStatisticsResponse hourly = hourlyOpt.get();
long[] counts = hourly.hourlyCounts();
for (int hour = 0; hour < 24; hour++) {
if (counts[hour] > 0) {
String label = current.format(DATE_FMT) + " " + String.format("%02d:00", hour);
rows.add(new AggregateRow(label, counts[hour]));
}
}
}
current = current.plusDays(1);
}
} catch (IOException ex) {
logger().error("Failed to load hourly data for {}", shortCode, ex);
}
return rows;
}
Die Schleife iteriert über jeden Tag im Zeitraum und fragt die stündlichen Daten ab. Wenn das Optional leer ist (außerhalb des Hot Windows), wird der Tag übersprungen. Nur Stunden mit tatsächlichen Zugriffen werden angezeigt.
Das AggregateRow Record
Die Zeilen im Detail-Grid werden durch ein einfaches Record modelliert:
/**
* Record representing a single row in the aggregates table.
*/
public record AggregateRow(String periodLabel, long count) {
}
Das Record ist bewusst generisch gehalten. Das periodLabel kann ein Datum (“2024-03-15”), eine Stunde (“2024-03-15 14:00”), eine Woche (“2024-W12”) oder ein Monat (“2024-03”) sein. Die UI kümmert sich nicht um die Semantik – sie zeigt einfach Label und Zähler an.

Der Datenfluss in der Praxis
Vom Klick zur Statistik
In diesem Kapitel verfolgen wir den kompletten Lebenszyklus eines Redirect-Events: von dem Moment, in dem ein Nutzer auf einen Kurzlink klickt, über die Speicherung im Backend bis hin zur Anzeige in der Vaadin-Oberfläche. Dieser End-to-End-Blick verdeutlicht, wie die einzelnen Schichten zusammenspielen und welche Designentscheidungen den Datenfluss prägen.

Phase 1: Der Redirect und die Event-Erfassung
Alles beginnt im RedirectHandler. Wenn eine Anfrage für einen Kurzlink eingeht, prüft der Handler zunächst, ob das Mapping existiert, aktiv ist und nicht abgelaufen. Erst wenn all diese Bedingungen erfüllt sind und der Redirect tatsächlich stattfindet, wird das Statistik-Event erfasst.
@Override
public void handle(HttpExchange exchange)
throws IOException {
if (!RequestMethodUtils.requireGet(exchange)) return;
final String path = exchange.getRequestURI().getPath();
if (path == null || !path.startsWith(PATH_REDIRECT)) {
exchange.sendResponseHeaders(400, -1);
return;
}
final String code = path.substring((PATH_REDIRECT).length());
if (code.isBlank()) {
exchange.sendResponseHeaders(400, -1);
return;
}
logger().info("Looking for short code {}", code);
var mappingOpt = store.findByShortCode(code);
if (mappingOpt.isEmpty()) {
logger().info("No mapping found for short code {}", code);
exchange.sendResponseHeaders(404, -1);
return;
}
var mapping = mappingOpt.get();
if (isExpired(mapping)) {
logger().info("Short code {} is expired at {}", code, mapping.expiresAt().orElse(null));
exchange.sendResponseHeaders(410, -1);
return;
}
if (!mapping.active()) {
logger().info("Short code {} is inactive", code);
exchange.sendResponseHeaders(404, -1);
return;
}
// Record the redirect event (non-blocking)
recordRedirectEvent(exchange, code);
exchange.getResponseHeaders().add("Location", mapping.originalUrl());
exchange.sendResponseHeaders(302, -1);
}
Die Methode recordRedirectEvent ist bewusst defensiv implementiert. Ein Fehler bei der Statistik-Erfassung darf niemals den eigentlichen Redirect beeinträchtigen:
private void recordRedirectEvent(HttpExchange exchange, String shortCode) {
if (statisticsWriter == null) {
return;
}
try {
var event = requestDataExtractor.extractEvent(exchange, shortCode);
statisticsWriter.recordEvent(event);
logger().debug("Recorded redirect event for shortCode={}", shortCode);
} catch (Exception e) {
logger().warn("Failed to record redirect event for shortCode={}", shortCode, e);
}
}
Statistiken sind ein Nice-to-have, der Redirect ist das Kerngeschäft (vgl. Kapitel 8).

Der RequestDataExtractor
Der RequestDataExtractor extrahiert alle relevanten Informationen aus dem HTTP-Request und baut daraus ein RedirectEvent. Die Implementierung folgt dem in Kapitel 4 beschriebenen Single-Responsibility-Prinzip.
package com.svenruppert.urlshortener.api.store.statistics;
/**
* Extracts redirect event data from an HTTP request.
* Centralizs the logic for reading headers and extracting client information.
*/
public final class RequestDataExtractor {
private static final String HEADER_USER_AGENT = "User-Agent";
private static final String HEADER_REFERER = "Referer";
private static final String HEADER_ACCEPT_LANGUAGE = "Accept-Language";
private static final String HEADER_X_FORWARDED_FOR = "X-Forwarded-For";
private final Clock clock;
public RequestDataExtractor(Clock clock) {
this.clock = clock != null ? clock : Clock.systemUTC();
}
public RequestDataExtractor() {
this(Clock.systemUTC());
}
/**
* Creates a RedirectEvent from an HttpExchange.
*
* @param exchange the HTTP exchange
* @param shortCode the short code being resolved
* @return the redirect event
*/
public RedirectEvent extractEvent(HttpExchange exchange, String shortCode) {
var headers = exchange.getRequestHeaders();
return RedirectEventBuilder.forShortCode(shortCode, clock)
.userAgent(getFirstHeader(headers, HEADER_USER_AGENT))
.referer(getFirstHeader(headers, HEADER_REFERER))
.acceptLanguage(getFirstHeader(headers, HEADER_ACCEPT_LANGUAGE))
.ipAddress(extractClientIp(exchange))
.build();
}
/**
* Extracts the client IP address, considering proxy headers.
*/
private String extractClientIp(HttpExchange exchange) {
var headers = exchange.getRequestHeaders();
// Check X-Forwarded-For first (common proxy header)
String forwardedFor = getFirstHeader(headers, HEADER_X_FORWARDED_FOR);
if (forwardedFor != null && !forwardedFor.isBlank()) {
// X-Forwarded-For can contain multiple IPs, take the first one
int commaIndex = forwardedFor.indexOf(',');
if (commaIndex > 0) {
return forwardedFor.substring(0, commaIndex).trim();
}
return forwardedFor.trim();
}
// Fall back to remote address
var remoteAddress = exchange.getRemoteAddress();
if (remoteAddress != null && remoteAddress.getAddress() != null) {
return remoteAddress.getAddress().getHostAddress();
}
return null;
}
private String getFirstHeader(com.sun.net.httpserver.Headers headers, String name) {
List<String> values = headers.get(name);
if (values != null && !values.isEmpty()) {
return values.getFirst();
}
return null;
}
}
Die IP-Extraktion berücksichtigt den X-Forwarded-For-Header, wie bereits in Kapitel 4 ausführlich beschrieben.
Der RedirectEventBuilder und Datenschutz
Der Builder übernimmt nicht nur die Konstruktion des Events, sondern auch den Datenschutz: IP-Adressen werden gehasht, bevor sie gespeichert werden.
/**
* Sets the IP address and hashes it for privacy.
* The original IP is never stored.
*/
public RedirectEventBuilder ipAddress(String ipAddress) {
this.ipHash = hashIp(ipAddress);
return this;
}
/**
* Hashes an IP address using SHA-256.
* Returns null if the input is null or empty.
*/
private String hashIp(String ipAddress) {
if (ipAddress == null || ipAddress.isBlank()) {
return null;
}
try {
MessageDigest digest = MessageDigest.getInstance(HASH_ALGORITHM);
byte[] hash = digest.digest(ipAddress.getBytes(StandardCharsets.UTF_8));
// Return first 16 characters of the hex string for a shorter hash
return HEX_FORMAT.formatHex(hash).substring(0, 16);
} catch (NoSuchAlgorithmException e) {
// SHA-256 should always be available
throw new IllegalStateException("SHA-256 algorithm not available", e);
}
}
Der gekürzte Hash bietet einen guten Kompromiss für Statistik-Zwecke (vgl. Kapitel 3).

Phase 2: Die Speicherung im Store
Das Event landet im StatisticsWriter, der es an den StatisticsStore weiterleitet. In der InMemory-Implementierung geschieht die Verarbeitung synchron:
@Override
public void recordEvent(RedirectEvent event) {
if (event == null || !config.isStatisticsEnabled()) {
return;
}
String shortCode = event.shortCode();
LocalDate date = event.timestamp().atZone(ZoneOffset.UTC).toLocalDate();
int hour = event.timestamp().atZone(ZoneOffset.UTC).getHour();
// Store event
events.computeIfAbsent(shortCode, k -> new ConcurrentHashMap<>())
.computeIfAbsent(date, k -> new CopyOnWriteArrayList<>())
.add(event);
// Update hourly aggregate
hourlyAggregates.computeIfAbsent(shortCode, k -> new ConcurrentHashMap<>())
.computeIfAbsent(date, k -> new HourlyAggregate(date))
.increment(hour);
// Update daily aggregate
dailyAggregates.computeIfAbsent(shortCode, k -> new ConcurrentHashMap<>())
.computeIfAbsent(date, k -> new DailyAggregate(date))
.increment();
logger().debug("Recorded event for shortCode={} date={} hour={}", shortCode, date, hour);
}
Die dreifache Speicherung optimiert Abfragen durch vorberechnete Aggregate (vgl. Kapitel 4).

Die Verwendung von ConcurrentHashMap und CopyOnWriteArrayList gewährleistet Thread-Sicherheit (vgl. Kapitel 4).
Phase 3: Die Abfrage über die REST-API
Wenn die Vaadin-UI Statistiken anzeigen möchte, ruft sie den StatisticsClient auf. Dieser macht einen HTTP-Request an den Server:
// In StatisticsDetailView.loadDailyData()
StatisticsTimelineResponse timeline = statisticsClient.getTimeline(
shortCode,
from != null ? from : LocalDate.now().minusDays(30),
to != null ? to : LocalDate.now()
);
Der StatisticsTimelineHandler empfängt die Anfrage und delegiert an den StatisticsReader:
@Override
public void handle(HttpExchange exchange)
throws IOException {
if (!RequestMethodUtils.requireGet(exchange)) return;
String path = exchange.getRequestURI().getPath();
logger().info("StatisticsTimelineHandler: GET {}", path);
String shortCode = extractShortCode(path);
if (shortCode == null || shortCode.isBlank()) {
writeJson(exchange, fromCode(400), ERROR_MISSING_SHORT_CODE);
return;
}
var queryParams = QueryUtils.parseQueryParams(exchange.getRequestURI().getRawQuery());
String fromParam = QueryUtils.first(queryParams, "from");
String toParam = QueryUtils.first(queryParams, "to");
if (fromParam == null || toParam == null || fromParam.isBlank() || toParam.isBlank()) {
writeJson(exchange, fromCode(400), ERROR_MISSING_DATES);
return;
}
try {
LocalDate from = LocalDate.parse(fromParam);
LocalDate to = LocalDate.parse(toParam);
if (from.isAfter(to)) {
writeJson(exchange, fromCode(400), ERROR_INVALID_RANGE);
return;
}
List<DailyAggregate> aggregates = statisticsReader.getDailyAggregates(shortCode, from, to);
List<DailyCount> dailyCounts = new ArrayList<>();
for (DailyAggregate agg : aggregates) {
dailyCounts.add(new DailyCount(agg.date(), agg.totalCount()));
}
StatisticsTimelineResponse response = StatisticsTimelineResponse.create(
shortCode, from, to, dailyCounts
);
writeJson(exchange, fromCode(200), response);
} catch (DateTimeParseException e) {
logger().warn("Invalid date format: {}", e.getMessage());
writeJson(exchange, fromCode(400), ERROR_INVALID_DATE);
}
}
Der Store liefert die vorberechneten Aggregate:
@Override
public List<DailyAggregate> getDailyAggregates(String shortCode, LocalDate from, LocalDate to) {
var dailyMap = dailyAggregates.get(shortCode);
if (dailyMap == null) {
return Collections.emptyList();
}
return dailyMap.values().stream()
.filter(agg -> !agg.date().isBefore(from) && !agg.date().isAfter(to))
.sorted(Comparator.comparing(DailyAggregate::date))
.collect(Collectors.toList());
}

Der komplette Datenfluss als Übersicht

Erkenntnisse aus dem Datenfluss
Entkopplung durch Interfaces: Der RedirectHandler kennt nur das StatisticsWriter-Interface, nicht die konkrete Implementierung. Das ermöglicht es, zwischen InMemory- und EclipseStore-Implementierung zu wechseln, ohne den Handler anzupassen.
Fail-Safe Design: Die Statistik-Erfassung ist in einen try-catch-Block gehüllt. Selbst wenn die Speicherung fehlschlägt, wird der Redirect korrekt ausgeführt. Das Kerngeschäft hat Priorität.
Vorberechnete Aggregate: Die dreifache Speicherung (Events, Hourly, Daily) ist ein klassischer Space-Time-Tradeoff. Mehr Speicherplatz, aber schnellere Abfragen. Für eine Statistik-Anwendung ist das der richtige Kompromiss.
Schichtentrennung: Jede Schicht hat eine klar definierte Aufgabe:
- Handler: HTTP-Protokoll, Routing, Validierung
- Extractor: Datenaufbereitung aus Requests
- Builder: Objektkonstruktion mit Datenschutz
- Store: Persistenz und Abfragen
- Client: HTTP-Abstraktion für die UI
- View: Darstellung und Interaktion
UTC als kanonische Zeitzone: Alle Timestamps werden in UTC gespeichert. Die Umrechnung in lokale Zeit erfolgt erst in der UI. Das vermeidet Zeitzonenprobleme bei verteilten Systemen.
Happy Coding