OdoKeep ist jetzt im App Store. Kostenlos laden.

Alle Artikel

Geodaten

Geodaten-Abfragen durch JSON-Kacheln und Cloudflare R2 ersetzen

Ein gemeinsames Raster, statisches JSON auf Cloudflare R2 und ein Cache, der fehlende Daten von einer leeren Karte unterscheidet.

Aktualisiert am

In diesem Artikel

Alle, die in OdoKeep nach nahen Kraftstoffpreisen suchten, stellten unterschiedliche Fragen an denselben Datenbestand.

Standort, Radius und Kraftstoff wechselten. Die Stationsliste war für alle gleich und wurde täglich neu aufgebaut.

Das genügte, um den Ort der Abfrage zu überdenken.

OdoKeep ist ein Fahrzeugtagebuch mit React Native. Die Preisfunktion nutzt nationale Quellen aus Portugal, Spanien, Frankreich, Italien und Österreich. Das Kachelmodul dokumentiert ungefähr 46.000 Stationen; die Zahl schwankt mit den Quellen. Ursprünglich fragte die App das Backend nach nahen Stationen und der Anzahl je Marke und Land.

Heute liest sie JSON-Dateien aus Cloudflare R2 über eine öffentliche eigene Domain. Supabase verwaltet weiterhin die privaten Garagendaten. Der Veröffentlichungsprozess liest außerdem Quellenmetadaten und meldet seinen Zustand. Entfallen ist die Datenbankabfrage für jede einzelne Preissuche auf einem Telefon.

Eine Datei pro Land wäre leicht zu veröffentlichen und lokal zu filtern gewesen. Eine Suche in der Nähe sollte aber nicht alle Stationen eines Landes herunterladen. Der Datenbestand brauchte eine Aufteilung passend zur Nutzung.

Dasselbe Raster für Veröffentlichung und Telefon

Das Raster besteht aus Zellen von einem halben Grad. lib/fuel-prices/tiles.ts enthält die gemeinsame Berechnung:

const TILE_DEGREES = 0.5;

function tileKey(lat: number, lon: number): string {
  return `${Math.floor(lat / TILE_DEGREES)}_${Math.floor(lon / TILE_DEGREES)}`;
}

Lissabon bei 38.72, -9.14 landet in 77_-19. Für die negative Koordinate ist Math.floor entscheidend: Abschneiden würde Punkte westlich von Greenwich der falschen Zelle zuweisen.

Die veröffentlichte Struktur ist übersichtlich:

fuel/
  index.json
  sources.json
  brands/
    PT.json
    ES.json
    ...
  tiles/
    77_-19.json
    77_-18.json
    ...

Eine Kachel enthält Koordinaten, Identität, Adresse, Preise, gegebenenfalls Öffnungszeiten und Aktualisierungszeiten der Quellen. Sie enthält keine Entfernung zum Benutzer. Entfernung gehört zur Suche, nicht zur Station.

Ein Rechteck laden, dann nach Entfernung filtern

Der Client berechnet die Zellen des umschließenden Rechtecks um den Suchkreis. Die ungefähre Breitenspanne ergibt sich aus Radius geteilt durch Kilometer pro Grad. Die Längenspanne wird mithilfe des Breitengradkosinus erweitert, weil Längengrade nach Norden schmaler werden.

Das Rechteck ist absichtlich etwas ungenau. Eine Eckkachel kann geladen werden, obwohl keine Station darin innerhalb des Kreises liegt. Danach entfernt die Haversine-Distanz Stationen außerhalb des Radius und sortiert die übrigen. Der Erdradius entspricht der ersetzten Abfrage.

Die Dateianzahl begründet die Rastergröße. Ein halbes Grad ist etwa 56 km hoch und in den bedienten Breiten schmaler. Der Standardradius von 10 km hat 20 km Durchmesser. Tests prüfen Stichproben im dokumentierten Gebiet auf höchstens vier Zellen für diesen Radius und höchstens neun beim Maximum von 30 km.

Den Durchmesser vergisst man leicht. „Eine Suche über 30 km passt in eine 56-km-Zelle“ klingt plausibel, bis man den Kreis zeichnet.

Ein gröberes Raster würde die maximale Zahl an Anfragen reduzieren, aber größere Dateien für die übliche Suche verlangen. Das gewählte Raster investiert mehr Anfragen in große Radien, damit kleine Suchen kleinere Teile laden können.

Der Index macht leere Bereiche günstig. Er nennt nur Zellen mit Stationen sowie Rastergröße und Erstellungszeit. Eine Küstensuche kann bekannte leere Zellen vor dem Abruf ausschließen. Einen Index mit anderer Rastergröße lehnt der Client ab, statt Schlüssel falsch zu interpretieren.

Markenanzahlen werden ebenfalls einmal pro Land berechnet. Ein anderer Markenfilter benötigt keine landesweite Datenbankaggregation mehr.

Nach dem Laden können Kraftstoff-, Marken- und Radiusänderungen vorhandene Stationen wiederverwenden. Das Netzwerk muss nicht mehr jede Anpassung der Ansicht begleiten.

Leer und fehlgeschlagen unterscheiden

Der Cache vertraut Einträgen sechs Stunden lang. Er hält geparste Daten im Speicher, persistiert Kopien für weitere Starts und teilt ein laufendes Promise je Schlüssel. Zwei gleichzeitige Verbraucher teilen sich also eine Anfrage. Scheitert das Netzwerk, kann eine brauchbare ältere Kopie die Suche weiter versorgen.

Der wichtigste Schritt war, drei Ergebnisse zu unterscheiden:

type TileOutcome =
  | { status: "ok"; stations: TileStation[] }
  | { status: "empty" }
  | { status: "failed" };

Eine leere Zelle ist ein anderer Nachweis als eine nicht abrufbare Zelle.

Angenommen, die Suche betrifft zwei befüllte Zellen. Eine antwortet normal, die andere mit 503 und ohne brauchbare Cachekopie. Nur die erste zurückzugeben würde das Suchgebiet still verkleinern. Diese Liste könnte anschließend sortiert und als vollständige Antwort gespeichert werden.

stationsAround meldet einen Fehler, sobald eine erforderliche Kachel scheitert. Die höhere Schicht kann eine frühere Suche aus dem Cache nutzen oder erklären, dass Daten fehlen. Aus einem fehlenden Kartenteil entsteht kein neuer vollständiger Trefferbestand.

Ein 404 wird derzeit eingeschränkter behandelt: als leer, für fünf Minuten gespeichert. Diese kurze Dauer hilft während der Veröffentlichung, wenn eine Zelle vorübergehend fehlen kann. Der Index verhindert viele solche Anfragen. Trotzdem bietet ein 404 nicht die Sicherheit eines versionierten, in sich konsistenten Datenbestands.

Vollständige Bestände veröffentlichen, Kacheln zuerst

Der TypeScript-Generator verlangt alle konfigurierten Länder und veröffentlicht nichts, wenn ein Adapter scheitert. Sonst würde ein Quellenausfall zum neuen Bestand, aus dem ein Land verschwunden ist. Die letzte vollständige Version zu behalten ist besser.

Die geplante GitHub Action führt den Generator mit Bun aus. Er verwendet eingebaute Module und Projektquellen, ohne den Abhängigkeitsbaum der mobilen App zu installieren. Sein Ergebnis ist ein Verzeichnis; der Upload bildet einen eigenen Schritt.

Die JSON-Dateien werden vorab komprimiert und mit passendem Content-Encoding ausgeliefert. gzip -n entfernt den Zeitstempel aus dem Kompressionsheader. Identische Eingaben bekommen so nicht allein wegen eines anderen Kompressionstags andere Bytes. Die Objekte haben eine öffentliche Cachevorgabe von einer Stunde.

Kacheln werden vor dem Index hochgeladen. Der umgekehrte Ablauf würde neue Schlüssel ankündigen, die ein fehlgeschlagener Upload vielleicht nie bereitstellt.

Die Reihenfolge macht die Veröffentlichung nicht atomar. Dateien werden an Ort und Stelle ersetzt; ein Client kann verschiedene Generationen sehen. Die Bereinigung kann eine alte Kachel entfernen, während jemand noch den vorherigen Index hält. Für diese Preisansicht akzeptiert die Implementierung die begrenzte Konsistenz und kurzfristig gespeicherte Fehlstellen. Ein konsistenter Schnappschuss würde generationseigene Pfade und einen erst nach vollständigem Upload umgeschalteten Zeiger verlangen.

Wofür sich diese Architektur eignet

R2 passt hier, weil die veröffentlichte Preisliste keinen ausgehenden Internetverkehr nach Bandbreite berechnet. Speicher und Operationen gehören weiterhin zum R2-Preismodell, und die Cachekonfiguration bleibt wichtig. Eine eigene öffentliche Domain kann Cloudflare-Caching nutzen, wie die Dokumentation zu öffentlichen Buckets beschreibt. Eine Vorher-nachher-Rechnung habe ich nicht: Die konkrete Änderung ist, dass Preissuchen keine Datenbanklesevorgänge und keinen Datenbank-Ausgangsverkehr mehr verbrauchen.

Tägliche Veröffentlichung bedeutet auch keine minutengenauen Preise. Die Quellaktualisierung bleibt separat erhalten, und Quellenangaben begleiten den Bestand. Nationale Quellen unterscheiden sich bei Aktualisierung und Wiederverwendungsbedingungen. Eine Datei statt einer Datenbankzeile ändert daran nichts.

Der Ansatz passt zu öffentlichen, gemeinsamen, geografisch begrenzten Daten, die viel häufiger gelesen als geändert werden. Für private Daten, benutzerbezogene Berechtigungen oder transaktionsabhängige Echtzeitpreise würde ich anders entscheiden.

Das Telefon kannte Standort, Radius und Filter bereits. Ein wiederverwendbarer lokaler Ausschnitt lässt es die Suche abschließen, ohne den Server dieselben Stationen immer wieder finden zu lassen.

Die daraus entstandene Preissuche lässt sich in OdoKeep neben dem Fahrzeugtagebuch ausprobieren. Die iOS-App ist verfügbar, wenn du sie entlang deiner eigenen Strecken testen möchtest.