Url-Shortener – Redirect-Statistiken – Teil 2

Sven Ruppert

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.

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 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

Total
0
Shares
Previous Post

Großes Kino für Java-Entwickler & Architekten

Next Post

Joyful web development with Hypermedia-Driven Applications and HTMX

Related Posts