Entwicklungsweisen dynamischer Webseiten

In einer privaten Diskussion zwischen @flupsi und mir ist das Thema aktueller Entwicklungsweisen für dynamische Webseiten aufgekommen. Nachdem wir mit Astro und einem auf Directus basierten GraphQL-Endpunkt gute Ergebnisse für die Erstellung einer statischen Webseite erlangen konnten, stellt sich nun die Frage, wie dynamische Inhalte mit dem Ansatz verwaltet werden können.

Stichworte hierfür sind Server-Side Rendering (SSR) und Hot Module Replacement (HMR), auch On-Demand Rendering, Hybrid Rendering, Progressive Enhancement oder Hydration genannt. Die Plattformen, welche wir hierfür in den Blick nehmen, sind obiges Astro, als auch Elm mit Elm-Pages, das die Unterstützung für SSR mitbringt. Die Idee dahinter ist für die Entwicklung von Server- als auch Client-Komponenten die gleiche Programmiersprache einzusetzen.

Dieser Ansatz wird benötigt, wenn User-Generated Content abgefragt wird, um Menschen die Möglichkeit zu geben eine Webseite nicht nur zu lesen, sondern auch dazu beizutragen (Kommentare, Beiträge), sich anzumelden (eigene Daten, interner Bereich) oder die Darstellung einer Seite dynamisch an die durch das Benutzen der Seite ausgedrückten Absichten und gezielten Abfragen der Nutzenden anzupassen. Die Vermischung statischer und dynamischer Inhalte ist hierbei eine Strategie, um den Ressourcenaufwand für die Darstellung der Seite klein zu halten und auf Anfrage wirklich nur die dynamischen Teile zu berechnen, welche nicht statisch vorgehalten werden können.

Hierfür sind viele Feinheiten zu betrachten und zu berachten, die abhängig von den gewählten Anforderungen zu unterschiedlichen Ergebnissen führen und unterschiedliche Aufwände mit sich bringen. Die technischen Möglichkeiten sind hierbei entsprechend den funktionalen Anforderungen zu wählen, um das Rad nicht neu zu erfinden und wo möglich bestehende Werkzeuge wiederzuverwenden.

Eine Möglichkeit sich diesen Erfahrungsraum zu strukturieren möchte ich im Folgenden vorstellen.

Glossar

Glossar

Um den Überblick zu behalten und sicher zu stellen, dass wir von den gleichen Dingen sprechen, ist es nützlich vorab einige Begriffe und Konzepte zu definieren und diese in einem Glossar zu bündeln. Übliche technische Begriffe aus der lingua franca der Programmierung, dem Englischen, werden übernommen, insofern es keine geläufige deutschsprachige Bezeichnung dafür gibt.

  • Browser
    Anwendung, welche Zugang zum Web bietet, Webseiten darstellt und Webanwendungen ausführt
  • Client
    Anwendung, welche mit Servern Daten austauscht
  • Datenstruktur (eng. Data Structure)
    Konkrete Implementierung von Daten in einer spezifischen Form
  • Datentyp (eng. Data Type)
    Abstrakte Implementierung von Daten in einer generalisierten Form
  • Design Patterns (dt. Designmuster)
    Muster, welche Anforderungen auf eine Implementierung abbilden
  • Dynamische Webseite
    Webseite, welche beim Abruf dynamisch aus Daten erzeugt wird
  • Entwicklerïn (generisches femininum, eng. developer)
    Person, welche Software produziert
  • Edge Function (keine nützliche dt. Übersetzung)
    Software, welche in einer generischen Ausführungsumgebung vorgehalten und nur beim Abruf ausgeführt wird
  • Graph
    Datenstruktur, welche Informationen als Knoten und Beziehungen als Kanten abbildet
  • Hydration (dt. Hydratisierung)
    Technik, um statische Webseiten mittels schrittweiser Verbesserung um dynamische Aspekte zu erweitern
  • Knowledge Graph (dt. Wissensgraph)
    Datenstruktur, welche Daten als Graph abbildet und den Kanten Datentypen zuweist
  • Nutzerïn (generisches femininum, eng. user)
    Person, welche eine Softwareanwendung zur Umsetzung eines Vorhabens einsetzt
  • On-Disk Format
    Format, in welchem Daten auf einem Speichergerät persistiert werden
  • OSI-Modell (eng. OSI model, Open Systems Interconnection)
    Modell, in welchem die Verbindung von offenen Systemen in Form gestapelter Ebenen idealisiert wird
  • Persistenz (eng. persistence)
    Vorhalten von Daten in einer dauerhaften Form
  • Plattform
    System, auf welchem andere Systeme aufbauen können
  • Programmiersprache
    Symbolsprache, welche imperativ, funktional oder deklarativ den Zustand eines System beschreibt.
    Symbole werden in Maschinensprache interpretiert, transpiliert oder kompiliert, um von einer logischen Maschine ausgeführt zu werden
  • Progressive Enhancement (dt. schrittweise Verbesserung)
    Technik, um die Interaktivität von Anwendungen in wechselnden Situationen graduell aufzubauen
  • Property Graph (dt. Eigenschaftsgraph)
    Datenstruktur, welche Daten als Graph abbildet und Eigenschaften an die Knoten bindet
  • Relationale(s) Datenbank(-management System) (eng. Relational Database Management System, RDBMS)
    Datenstruktur, welche Daten als Zeilen-basierte Tabellen ablegt und Zeilenweise abgefragt werden kann
  • Runtime Environment (dt. Ausführungsumgebung)
    System, welches die Ausführung von Software gestattet
  • Schema
    Datenstruktur, welche die Datentypen der Spalten einer relationale Datenbank oder die Eigenschaften eines Datums beschreibt
  • Schema Definition Language (SDL, dt. Schemadefinitionssprache)
    auch: Data Definition Language (DDL, dt. Datendefinitionssprache)
    Symbolische Sprache, welche Schemata abbildet
  • Schnittstelle (eng. interface)
    Interaktionspunkt zwischen Mensch und Maschine oder zwischen Maschine und Maschine
  • Serialisierung (eng. Serialisation)
    Abbildung komplexer Datenstrukturen in einer linearisierten Normalform. Nützlich für Übertragungsformate
  • Server
    System, welches Anwendungen ausführt und Daten vorhält, um von Clients abgefragt zu werden
  • Server-Side Rendering (dt. serverseitige Berechnung)
    Technik zur ad hoc Berechnung von Webseiten im Moment des Zugriffs
  • „Serverless“
    System, welches als Ausführungsumgebung für Edge Functions dient
  • Statische Webseite
    Dokument oder Anwendung auf der Web Plattform, welche in Form sich nicht-ändernder HTML, CSS und JavaScript Artefakte ausgeliefert werden
  • Speicher, nicht-flüchtiger (eng. Storage)
    System zur Persistenz und zum Abruf von textlichen oder binären Daten
  • Typisierung, strenge
    Technik, um Datenstrukturen exakt zu beschreiben, sodass unerwünschte Seiteneffekte bei der Verarbeitung ausgeschlossen werden können
  • Webseite
    Dokument oder Anwendung auf der Web Plattform
  • Wire Format (dt. Übertragungsformat)
    Bestimmt eine Datenstruktur und eine Serialisierung, um Daten zu übertragen
  • Wire Protocol (dt. Übertragungsprotokoll)
    Protokoll, um Daten über Schnittstellen von einem System zu einem anderen System zu übertragen

Die für uns praktischen technischen Umsetzungen davon heißen:

  • Astro
    JavaScript Meta-Framework zur Beschreibung von statischen und dynamischen Webseiten
  • CSS
    Deklarative Stilbeschreibungssprache zur visuellen Koordination von HTML Elementen im Browser und für druckbare Dokumente
  • Deno
    TypeScript Laufzeitumgebung; transpiliert intern nach JavaScript
  • Elm Deklarative Programmiersprache, welche nach JavaScript transpiliert, um ausgeführt zu werden.
  • Elm Pages System, um mit Elm statische und, mit Abhängigkeit zu einem prorietären Dienst, genannt Lamdera, dynamische Webseiten zu programmieren
  • Firefox
    Browser, welcher von einer unabhägigen Stiftung ohne Profitinteressen programmiert wird
  • GraphQL (eng. Graph Query Language, dt. Graphabfragesprache)
    Abfragesprache, um einen Property Graph zu beschreiben und über Schnittstellen zugänglich zu machen
  • HTML
    Beschreibungssprache für Hypertextdokumente, welche Referenzen auf andere Hypertextdokumente enthalten können
  • HTTP
    Übertragungsprotokoll des Webs
  • JavaScript Programmiersprache, welche von Browsern als auch von Servern interpretiert und ausgeführt werden kann
  • JSON Notation für Objekte in JavaScript
  • Node.js JavaScript Laufzeitumgebung auf dem Server
  • REST Repräsentative Zustandsübertragung zwischen einem Client und einem Server, die dynamische Webanwendungen ermöglicht
  • SQL (eng. Structured Query Language, dt. Strukturierte Abfragesprache)
    Abfragesprachhe für relationale Datenbanksysteme
  • TypeScript
    Typisierte Programmiersprache, welche Garantien über die Konsistenz von Datentypen und ihrer Schnittstellen bietet

Ferner nehmen wir an, dass Begriffe wie Betriebssystem, Computer, Internet oder (World Wide) Web hinlänglich bekannt sind.

Gestaltung

Für die Gestaltung eines Systems zur Mensch-Maschine-Interaktion ist es hilfreich sich geeignete Designmuster und Systeme zu vergegenwärtigen. Die verschiedenen Systeme und Abstraktionen, welche sich anbieten, bringen alle eine eigene Geschichte mit sich und haben unterschiedliche Vor- und Nachteile. Manche der Systeme lassen sich gut in konventionelle Designmuster integrieren, während andere mit vorherrschenden Paradigmen brechen und sich eigene Nischen erschließen.

Für uns als zivilgesellschaftlichem, gemeinnützigen Internetdienstanbieter ist es an dieser Stelle wichtig, dass wir darauf Wert legen, dass sich alle Systeme als freie Software ohne Abhängigkeiten zu kommerziellen Anbietern oder Angeboten Dritter, die nicht unserer Kontrolle unterliegen, selbst in eigener Infrastruktur betreiben lassen. Dies ist nicht immer gegeben oder sofort ersichtlich. Manchmal lässt sich eine freie Software identifizieren, die sich hinter einem (kostenlosen, eingeschränkten) Dienstangebot verbirgt.

Unabhängigkeit und selbstbestimmte Verwaltung der informationstechnischen Systeme sind unsere obersten Gebote, weshalb wir ggf. auch den längeren und langsameren Weg des Aufbaus eigener Infrastrukturen bevorzugen. Gleichzeitig müssen übliche Aspekte der Softwareentwicklung und des -betriebs gewährleistet sein, um eine datenschutzfreundliche und sichere Anwendung gewährleisten zu können.

Nähern wir uns nun deduktiv von allgemeineren Konzepten her konkreten Implementierungsmöglichkeiten.

Auswahl von Entwicklungs- und Betriebsumgebung

Wenn wir von Software sprechen, sprechen wir auch immer von Ausführungsumgebungen und Programmiersprachen, sowie deren jeweilige Art und Weise den Programmcode in Maschinencode zu übersetzen, welche sich stark unterscheiden können. Um eine niedrigschwellige Entwicklung zu ermöglichen, wählen wir eine interpretierte Sprache, die auch als Skriptsprache bezeichnet wird, da ihr Code ohne Umwege direkt („just in time“) in Bytecode übersetzt wird.

  • Als Ausführungsplattform wählen wir Node.js, welches JavaScript interpretiert und ausführt.
  • Als Programmiersprache wählen wir primär TypeScript, da es streng typisiert eingesetzt und verlustfrei nach JavaScript transpiliert werden kann. Alternativ kann Elm zum Einsatz kommen, was unter bestimmten Bedingungen möglich erscheint und weiter unten an entsprechender Stelle diskutiert wird.

Zur Entwicklung verwenden wir ein so genanntes Build Scaffolding mit einer Build Pipeline, welche, eine konventionelle Strukturierung des Codes vorausgesetzt, die Übertragung des TypeScript Codes nach JavaScript sowie weitere Optimierungen zur Laufzeit übernehmen, sodass Änderungen direkte Auswirkungen haben und sofort nachvollzogen werden können.

Zum Betrieb wählen wir zusätzlich zu einer statischen Webseite eine Laufzeitkomponente auf einem Server (-ähnlichen System), was bei entsprechender Abfrage die dynamische Generierung von Inhalten ermöglicht. Ausdrücklicher Wunsch ist es das SSR Pattern mit Hydration für Progressive Enhancement einzusetzen, um gezielt einzelne statische Komponenten der Webanwendung mit dynamischen Elementen auszutauschen (HMR).

Auswahl einer Persistenzschicht

In einer stark vereinfachten Sicht darauf wie Software funktioniert, gliedert sich diese in drei Schichten:

  1. Benutzerschnittstelle
  2. Anwendungslogik
  3. Datenpersistenz

Für die hier vorliegende Diskussion wollen wir uns darauf beschränken die Möglichkeiten abzuwägen, wie die Anwendungslogik Daten persistieren kann. Wir gehen davon aus, dass die Benutzerschnittstelle als auch die Anwendungslogik als gelöst angesehen werden.

Klassischerweise kommt zum Speichern von Daten in Dateien ein Dateisystem zum Einsatz, welches sich lokal auf einer Festplatte oder entfernt in einem S3 Bucket, remoteStorage, oder einem Solid Pod befinden kann. Dies eignet sich gut für unstrukturierte oder binäre Daten die sich selten ändern, wie Quellcode, statische Seitenvorlagen, Bilder oder Dateitypen, die serialisierte Datenstrukturen enthalten, wie bspw. JSON oder Parquet.

Ändern sich die Daten häufiger, oder sollen sie verschiedentlich abgefragt werden, bietet sich eine Datenbank an. Wir kennen verschiedene Datenbanktypen, die unterschiedliche Qualitäten aufweisen. Grundsätzlich unterscheiden sich diese darin, wie sie Daten im Dateisystem ablegen, welche Formen von Indizes sie bilden und welche Abfragen sie auf diesen zulassen. Gängige Datenbanktypen sind (1) Attribut-Wert Speicher (eng. Key-Value Store), (2) Dokumentenspeicher (eng. Document Store), (3) Zeilenspeicher (relationale Datenbanken, bspw. SQL) und (d) Graphdatenbanken (Property Graph, Knowledge Graph).

Allen diesen ist gemeinsam, dass sie die in ihnen enthaltenen Daten über eine programmierbare Anwendungsschnittstelle (eng. Application Programming Interface, API) zugänglich machen, was sich für die Weiterverarbeitung in verteilten Systemen eignet. Es gibt noch exotischere und hybride Datenbankformen, die hier keine Erwähnung finden können. Interessierten sei db-engines.com ans Herz gelegt.

Auswahl einer Schemadefinitionssprache und -implementierung

Wenn wir die weiteren Aspekte ausblenden, welche eine vollständige Implementierung einer Lese-Schreib-Webanwendung ausmachen, und uns weiter auf die Persistenzschicht konzentrieren, stehen wir vor der Qual der Wahl eines geeigneten Datenbanksystems. Unter Berücksichtigung der Zielvorgabe ein verteiltes System mit strukturierten Daten zu gestalten, die nach qualitativen Kriterien abgefragt werden können, entfallen noch die Dateisystem-basierten Ansätze, da sich diese nur mit größerer Mühe skalieren lassen, sollte das ein Thema werden.

Übrig bleiben für uns diejenigen Datenbanksysteme, welche gut als Webdienst von verschiedenen Ausführungsumgebungen aus abgerufen werden können. Als Ausführungsumgebungen stehen selbst-gehostete, lange laufende Daemons, bspw. als Container auf einem Server hinter einem Load Balancer, als auch selbst-hostbare, kurzlebige Edge Functions (in Worker Environments), wie bspw. als Deno Skripte in Supabase, zur Auswahl.

Für einfache Umsetzungen, welche keine strengen Konsistenzanforderungen haben, bieten sich „schemafreie“ Datenbanken für Attribut-Wert-Paare oder JSON-Dokumente an. Hier sei Deno KV hervorgehoben, welches sich explizit für kurzlebige Funktionen anbietet, als auch andere Attribut-Wert-Speicher wie Valkey oder Dokumentendatenbanken wie CouchDB.

Eine besondere Erwähnung finden an dieser Stelle Multi-Modell-Datenbanken, welche verschiedene Paradigmen der Definition und Abfrage von Datenbanken vereinen (eine Kombination von Attribut-Wert Paaren, Relationalen Tabellen, Dokumenten und Graphen) und über generalisierte als auch individuelle Schnittstellen zugänglich machen. Von diesen seien TerminusDB, ArrangoDB und Skytable erwähnt.

Was uns zu solchen Datenbanksystemen führt, welche im Alltag am häufigsten zum Einsatz kommen und „klassiche“, relationale Datenbankschemata abbilden und programmatisch über die Structured Query Language (SQL) oder Objekt-Relationale Mapper (ORM) angesprochen werden. Dies ist eine der ältesten, ausgereiftesten und am besten verstandenen Techniken, um strukturierte Daten in einem komputationalen System abzubilden.

Zum Ausdruck eines Schemas können hierbei unterschiedliche Ansätze zum Einsatz kommen, die sich grob in drei Gebiete einteilen lassen: SQL-native Ansätze, welche die Datenbank direkt provisionieren, die oben bereits genannten ORMs, welche eigene Schemadefinitionssprachen (engl. Schema Definition Languages, SDLs) als Domänenspezifische Sprache (engl. Domain-Specific Language, DSL) anbieten, um Datenbanktabellen, die darin zeilenweise abgelegten Datenobjekte, spaltenweise abgelegten Datentypen und ihre Relationen untereinander abzubilden. Zudem zeigt sich ein neues Muster, welches native Typen der Programmiersprache annotiert und daraus die Objekt-Relationalen Mappings sowie SQL Manifeste ableitet.

Gängige Werkzeuge für den Ausdruck und das Laden von SQL Schemata im Node.js Ökosystem sind dbmate, Supabase Migrations oder Hasura Migrations. Beispiele für programmatischen Ausdruck und Anwendung Objekt-Relationaler Mappings sind Sequelize, Directus Schema, Prisma und Drizzle, welche alle ihre jeweiligen SDLs in SQL konvertieren und dann anwenden. Ein TypeORM sticht aus dieser Liste hervor, da dessen SDL die Typendefinitionen in TypeScript selbst ausdrückt.

Zum krönenden Abschluss seien noch die Graph, Linked Data respektive Named Graph Datenmodelle erwähnt, die sich entweder direkt oder über Objekt-Graph Mapper (OGMs) ansprechen lassen, welche es erlauben Datentypen einer Graph-Datenbank direkt im Code zu verwenden. Für Property Graphs sei hier die GraphQL SDL erwähnt, welche bspw. von dgraph nativ genutzt wird. Für Knowledge Graphs ist RDF zu erwähnen, was sich in einer Anwendung über die OGMs LDkit, Soukai Solid, LDO oder LDflex ansprechen lässt und in Triple- oder Quad-Stores, Solid oder auch intern in TerminusDB zum Einsatz kommt. Der Clou an RDF, inkl. all seiner gängigen Serialisierungen (RDF/XML, RDFa, N3, Turtle, n-Quads, n-Triples, JSON-LD und HDT), ist, dass die Schemabeschreibungen im gleichen Datenformat ausgedrückt werden wie die davon beschriebenen Nutzdaten.

Auswahl einer Abfragesprache, die sich vom Browser aus nutzen lässt

Für die konkrete Umsetzung ist es nötig sich für eines dieser Paradigmen zu entscheiden und dieses konsequent zu verfolgen. Für unseren Zweck ist es hilfreich, aber nicht unbedingt erforderlich, wenn sich die Datenbank über das Web-native Protokoll HTTP vom Browser aus ansprechen lässt. Gegebenenfalls wird dies dem lang- oder kurzlebigen SSR Daemon überlassen, welcher dann als Relais wirkt.

Während es äußerst üblich ist einen lange laufenden Prozess, wie solch einen Daemon, direkt mit einer relationalen Datenbank in SQL sprechen zu lassen, gibt es Anwendungsfälle, in denen das u.U. nicht möglich oder nicht gewollt ist. Da wir sehr gerne mit der Webplatform arbeiten und bewusst auf ihr aufbauen, ziehen wir alle Implementierungen vor, welche sich über HTTP ansprechen lassen mit JSON arbeiten.

Zu diesen zählen OpenAPI, GraphQL, SPARQL und Solid, die hier kurz vorgestellt seien:

  • OpenAPI ist sozusagen die Großmutter aller neueren Web-basierten APIs. Es basiert auf dem REST Prinzip, welches zustandslose Übertragungen verwendet, bei welchen weder der Client noch der Server nachvollzieht, welche anderen Verbindungen und Übertragungen bereits getätigt wurden. Das „Session“-basierte Verfahren, welches davor zum Einsatz kam, gilt nicht mehr als sicher. Sensitive Anfragen bringen bei OpenAPI schlicht einen Schlüssel mit, welcher sich vom Client über eine dritte, hier nicht näher zu erläuternden Instanz erhalten lässt. Der Server überprüft dann unabhängig vom Client auch bei dieser dritten Instanz, ob der Schlüssel valide ist und entscheidet daran, ob eine postive Antwort gesendet wird.
    Das Besondere an Web-APIs, welche auf REST setzen, war nach deren Einführung zudem, dass auf Seiten des Servers und des Clients mit JSON gearbeitet wird, was die Weiterverarbeitung erleichtert. Frühere XML-basierte Schnittstellen litten an dem Problem, dass sich damit vom JavaScript-Interpreter im Browser nicht viel anfangen ließ. OpenAPI ist das Kind des XMLHttpRequests, welchen Google als erste für GMail mit JSON fütterten, et voilà: das Web 2.0 war geboren und JSON-via-HTTP wurde für interaktive Webanwendungen zum de facto Standard für Datenübertragungen zwischen Browser-Clients und Servern.
  • GraphQL verwendet für Antworten ebenso JSON, sodass sich diese im Browser direkt weiterverwenden lassen und erlaubt für Abfragen das eigene GraphQL Format, als auch eine JSON-Variante davon. Der Standard erweitert die Bequemlichkeiten von REST-basierten APIs um Aspekte, welche sich mit der Zeit als nützlich erwiesen haben: Echtzeitbenachrichtigungen vom Server an den Client, strenge Datentypisierung und das dem relationalen Modell überlegenen (Sternchen) Datenmodell des Graphen, welches auch mehrere Ebenen der Verschachtelung von Beziehungen besser abbilden kann. Sternchen, weil es als expressives Datenformat manchmal etwas ausufert und durch seine vielfältigen Anpassungsfähigkeiten für nicht-übliche Anwendungsfälle schwer zu standardisieren ist. Der Standard und die Implementierungen driften mit der Zeit auseinander. Auch wurden ursprünglich Funktionen vergessen, welche bspw. das stückweise Navigieren durch lange Antworten erleichtern, die von Facebook in Form von Cursorn in Relay nachgereicht wurden, aber nicht zum Kernstandard gehören.
  • SPARQL ist die Abfragesprache des Semantischen Webs, welche Linked Data zugänglich macht. Es verwendet eine Syntax, die jener von SQL visuell sehr ähnlich ist, implementiert dabei aber eine Semantik, welche auf die Eigenheiten von Web-scale Graphen eingeht. Es glänzt mit Funktionen zur föderierten Abfrage mehrerer Endpunkte gleichzeitig, leidet aber durch die Expressivität des RDF Datenformats auch darunter, dass die vielen kleinteiligen Informationen, die es zurückgibt, erst „in Form gebracht“ werden müssen, bevor sie nützlich weiterverarbeitet werden können, was ihr den Ruf eingebracht hat „akademisch“ zu sein und bis heute viele Leute abschreckt.
  • Solid greift diese Kritik auf und ändert das Zugriffsmuster: Es werden unter Ausnutzung der Linked Data Platform (LDP) wieder Dokumente statt APIs als Resourcen im Web angesprochen und es verhält sich „gefühlt“ wieder wie eine Dateisystem. Die Daten in diesem Dateisystem liegen jedoch nicht auf dem eigenen Rechner, sondern auf so genannten Pods. Ein Pod ist ein Server, auf welchem jedë Nutzerïn einen eigenen Speicherbereich bekommt, aus dem sie einzelne Dateien bequem mit anderen Solid Nuterïnnen als auch Anwendungen auf anderen Servern teilen kann. Hierzu bedient es sich des Cross-Origin Resource Sharing (CORS), womit Webanwendungen Daten auch aus anderen Quellen als der jeweils eigenen Domäne benutzen können. Das RDF Datenmodell verspricht zudem, dass sich durch die strengen Vokabularien (auch Ontologien genannt; quasi ein Schema für RDF Daten, ausgedrückt in RDF selbst), welche zum Einsatz kommen, verschiedene Anwendungen auf den gleichen Daten arbeiten können. Und all das nur mit explizitem Einverständnis der Nuterïn.

Vor allem im Graph-Umfeld finden wir häufig zudem noch nicht-standardisierte Abfragesprachen in Eigenentwicklung, welche spezifische Features der jeweiligen Datenbanksysteme ansprechen und daher schwer generalisierbar sind. Zu erwähnen wären hier BlueQL (Skytable), DQL (dgraph) und WQDL (TerminusDB). De facto Standards in diesem Feld, welche sich über die Häufigkeit ihrer Implementierung etabliert haben, sind zudem Cypher (Neo4j) und Gremlin (TinkerPop).

Für den Anwendungsfall einer dynamischen Webseite, den wir hier betrachten, bietet GraphQL das ausgewogenste Verhältnis von Eigenschaften, Zugänglichkeit und breiter Unterstützung.

Zwischenstück über Schema-first oder Code-first Entwicklungsmuster

Was uns einen Schritt näher an die Umsetzung heranführt. Zunächst aber noch ein paar Worte zu dem Verhältnis von Datenbankschemata und ihrer Repräsentationen im Code.

In der Praxis haben sich zwei Wege herauskristallisiert, wie sich die Datenstrukturen in einer relationalen Datenbank von Entwicklerïnnen verwalten lassen, wenn diese anschließend mit Hilfe von GraphQL zugänglich gemacht werden sollen. Native GraphQL Datenbanken, wie bspw. dgraph oder bald auch TerminusDB, kennen dieses Problem nicht. Zum Einstieg in die Problematik empfiehlt sich dieser Artikel:

Kurz zusammengefasst:

In der Entwicklung von Client-Server-Anwendungen, welche ihre Daten in einer relationalen Datenbank ablegen und diese über eine GraphQL-Schnittstelle zugänglich machen, haben wir die Wahl zwischen zwei Ansätzen, die Schema-first und Code-first genannt werden: Im ersten Fall legen wir zunächst das Ausgabeformat fest, in welchem wir die Daten an Konsumenten der Schnittstelle weiterreichen, bspw. der Anwendung im Browser, und leiten die zu verwendeten Datentypen in der Anwendung und in der Datenbank davon ab. Im zweiten Fall legen wir zunächst die Datentypen fest, mit welchen wir in der Anwendung arbeiten wollen und leiten davon die Schemata der GraphQL Schnittstelle und der Datenbank ab.

Die Wahl, welchen Ansatz wir bevorzugen, entscheidet darüber, wo wir die Single Source of Truth (SSoT, dt. einzige Quelle der Wahrheit) über den Zustand unserer Datenmodelle in Anwendung, Schnittstelle und Datenbank verankern. Damit entscheiden wir auch, an welcher Stelle wir Transformationen anbringen müssen, um diese Modelle ggf. in eine nachrangige Normalform umzuwandeln.

Diese Entscheidung hat Auswirkung darauf, in welchem Umfang wir die Datenmodelle an gegebener Stelle an unsere Vorstellung anpassen können und wo dieser Anpassungen Grenzen gesetzt sind. Implizit entscheiden wir damit auch darüber, mit welcher Syntax und Semantik wir uns auseinandersetzen müssen, wenn wir Änderungen anbringen wollen und diese (vorab) mit anderen Menschen kommunizieren.

In kleineren, übersichtlicheren Projekten mag es noch ausreichend sein das gesamte Modell der Anwendung in einer (expressiven, s.o.) GraphQL SDL Repräsentation vorzuhalten, um alle Eigenheiten an einer Stelle zu versammeln. Wenn die Anforderungen and Komplexität und Komponierbarkeit jedoch steigen, kann es nützlich sein das Modell im Code vorzuhalten, um einzelne Teilaspekte an unterschiedlichen Stellen zu verwalten und erst in einem späteren Schritt in ein Ganzes zusammenzufügen. Dieser Ansatz ermöglicht es auch leichter dynamische Anpassungen im Modell vorzunehmen, da die gesamte Ausdrucksstärke der gewählten Programmmiersprache zum Einsatz kommen kann.

Your GraphQL schema should be shaped according to the needs of the clients. Your DB schema should be shaped according to your data model.

Those are 2 very different goals. One should not be generated from the other, else it will perform its job miserably.

https://www.reddit.com/r/graphql/comments/zo6ber/comment/j0msbv4/

Es ist letztlich eine Frage der Anforderungen an den Entwicklungsprozess und der späteren Verwendung, als auch ein bisschen eine des Geschmacks, wo wir die Quelle unseres Datenmodells verankern und welche Derivate wir davon ableiten. Für einfache Anwendungen ist es mithin ausreichend ein großes Schema an einer einzigen Stelle vorzuhalten. Komplexere Umgebungen können ein feingliedrigeres Vorgehen erfordern, wofür es nützlicher ist die Quelle im Code zu verankern und Schnittstellendefinitionen als auch das Datenbankmodell hiervon abzuleiten.

Für unsere bescheidenen Zwecke reicht es zunächst aus von einem in der GraphQL SDL ausgedrückten Schema auszugehen und alle weiteren Repräsentationen als nachrangig zu betrachten.

Sehen wir uns unten einige Beispiele an.

Nutzereingaben in einer Benutzeroberfläche als SSoT

Beginnen wir mit einem Sonderfall, der in keine der beiden vorher vorgestellten Kategorien passt: Das Datenmodell wird von der Nutzerïn in einer Benutzeroberfläche zusammengeklickt, die eine eigene interne Repräsentation einer SDL verwendet, die für die Anwenderïn opak ist, leitet davon jedoch automatisch SQL und GraphQL ab. Letzteres kann dann auf Grund seiner strengen Typisierung als Quelle für das Anwendungsmodell genutzt werden.

Directus

graph LR
  dir[Directus Klickibunti] --export--> dirs[Directus Schema]
  dirs --import--> dir
  dir --> dirsql[SQL Schema]
  dir --> dirgql[GraphQL Schema]
  dirsql --> sb[Supabase DB Diff]
  dirgql --codegen--> b[TypeScript Types]
  sb --codegen--> sql[SQL Migrations]

SQL DDL

Klassische Webanwendungen und datenbanknahe Programmierungsweisen verwenden SQL selbst Ursprung der Wahrheit über das Datenmodell. Alle anderen Formen sind in diesem Fall nachrangig und werden programmatisch erzeugt.

dbmate, Hasura Migrations, Supabase Migrations

graph LR
  sql[SQL Migrations] --> a
  a[SQL Schema] --Directus / Supabase / Hasura--> c[GraphQL Schema]
  c --codegen--> b[TypeScript Types]

SQL ORMs

Ein ähnlicher Fall sind die ORMs, welche eigene DSLs als SDL verwenden und davon alle Derivate ableiten.

Prisma SDL

graph LR
  a[Prisma SDL] --codegen--> b[Prisma Client]
  b --includes--> e[TypeScript Types]
  a --> f[Prisma Migrate]
  f --> c[SQL Schema]
  a --codegen--> d[GraphQL Schema]

Pothos + Prisma + Relay

graph LR
  a[Prisma SDL] --> b[Pothos]
  b --codegen--> ts[TypeScript Types]
  b --codegen--> ry[Relay]
  b --codegen--> d[GraphQL Schema]
  a --> c[SQL Migrations]

Sequelize + Apollo

graph LR
  a[Sequelize SDL] --> b[Apollo]
  a --> c[SQL Migrations]
  b --codegen--> d[GraphQL Schema]
  b --codegen--> e[TypeScript Types]

TypeORM + TypeGraphQL

Data Mapper oder Active Record Patterns

graph LR
  a[TypeScript Classes] --TypeORM decorators + codegen--> b[TypeScript Types]
  a --TypeORM decorators + codegen--> c[SQL Schema]
  a --TypeGraphQL decorators + codegen--> d[GraphQL Schema]
Quellen

How to integrate TypeScript with GraphQL using TypeGraphQL - LogRocket Blog

Building GraphQL APIs with TypeGraphQL and TypeORM - LogRocket Blog

Chapter 2. TypeGraphQL

Database to client type safety with Typescript, TypeORM, type-graphql and Apollo - Alex Olivier | cloud native product manager in london

type-graphql/examples/typeorm-lazy-relations at master · MichalLytek/type-graphql · GitHub

„Astro DB“ = Drizzle + libSQL

Für Astro existiert ein „in der Cloud“ (= auf Computern anderer Leute) gehosteter Datenbankdienst, genannt Astro DB, welcher auf dem TypeScript ORM Drizzle in Kombination mit einem libSQL Server basiert. Da beide als Freie Software mit offenem Quellcode und offener Lizenz vorliegen, lässt sich das Angebot auch eigenhändig nachbauen. Dies umgeht die Abhängigkeit von einem Drittanbieter

Das „Push Muster“ in SQL ORMs

Das schöne an Drizzle und Supabase ist, dass sie das „Push Pattern“ der Schemaentwicklung umsetzen. Datenbankschemata können in diesem Fall transparent zwischen verschiedenen Instanzen ausgetauscht werden, einfach indem die Zustände der Datenbanken mit dem in SQL- oder ORM-SDL-Code ausgedrückten Schemata verglichen werden. Die Unterschiede (Diffs) zwischen beiden dienen dann als Grundlage für die Transformationen, welche angewandt werden. Migrationen zwischen verschiedenen Zustandsbeschreibungen des Datenbanksystems können in diesem Fall automatisch erzeugt werden.

GraphQL SDL

Wem die Schnittstellenübergreifende Typisierung der GraphQL SDL genügt, um das Anwendungsmodell im Browser-Client und auf dem Server auszudrücken, kann die Sprache direkt einsetzen, um daraus ein SQL-Datenbankmodell abzuleiten. Es ist hierbei auffällig, dass es wenige Implementierungen gibt, welche diesen Schritt ermöglichen, was darauf schließen lässt, wie verbreitet diese Herangehensweise ist.

Bei GraphQL-nativen Datenbanken entfällt dieser Schritt.

dgraph + GraphQL Codegen

graph LR
  a[GraphQL SDL] --> b[TypeScript Types]

graphql-to-sql + GraphQL Codegen / Apollo Codegen

graph LR
  a[GrapQL SDL] --> b[TypeScript Types]
  a --> c[SQL]

Modularität von GraphQL Schemata einsetzen

Der Code-first Ansatz erlaubt bei automatischer Generierung der Schemabeschreibung einen modulareren Ansatz, da alle Refactoring Werkzeuge der Programmiersprache eingesetzt werden können. Für GraphQL bedeutet dies, dass aus mehreren Quelldatein (1) ein großes Schema erzeugt, (2) mehrere kleinere Schemata per „Stitching“ zusammengefügt oder (3) über GraphQL Federation mehrere unabhängige Server generiert werden können. Dies wird, wie oben beschrieben, erst in komplexeren Fällen nützlich.

Entwicklungsstufen von JavaScript beachten

Viele alte Beispiele geistern im Internet herum. Darunter welche aus der Anfangszeit von GraphQL, in welcher Node.js nur Promises und den XMLHttpRequest kannte, aber die Schlüsselwörter async / await sowie fetch() noch nicht existierten.

Es ist daher beim Lernen aus Beispielen darauf zu achten, dass möglichst aktuelle Beiträge unter Verwendung aktueller Idiome studiert werden.

Zusammenfassung

Wir haben gesehen, dass es ein ganzes Potpurri an Möglichkeiten gibt, zwischen denen sich eine geneigte Entwicklerïn entscheiden kann, um ihre Vorstellung in ein Softwareartefakt zu gießen. Verschiedene pragmatische und theoretische Überlegungen unterstützen sie auf diesem Weg die richtigen Entscheidungen zu treffen. Wie so oft gibt es keinen Königsweg, aber viele mehr oder weniger ausgetretene Pfade. Je weiter wir diese Pfade verlassen, desto mehr Arbeit machen wir uns und desto weniger Vorarbeit finden wir, auf welche wir uns auf einem einmal eingeschlagenen Weg stützen können.

Convention is king.

Oder wie es so schön auf Französisch heißt:

Ça depend!

Ein schöner Artikel, der Property-Graphen und deren Verhältnis zu RDF bespricht:

https://jakobib.github.io/pgraphen2024/