Im Folgenden möchte ich gern meinen Ansatz für einen FullStack-Router erläutern. Es genügen wenige hundert Zeilen Code für die beiden Router. Das Backend schafft mit einem kleinen Xeon locker 10000 Anfragen/Sekunde und bleibt auch bei langen Pfaden mit <10ms sehr schnell. Das Frontend kommt ohne Gargabe-Collector aus, ist ebenso schlank und bietet Renderzeiten unter einer Sekunde für jede beliebige Route. Der einzige Flaschenhals, die Latenz zwischen Front- und Backend ist stets auf 1 Anfrage / Fetch zusammengefasst. Ein Pferdefuss bleibt: Sie müssen zur Erstellung von Code wissen wie es grundlegend funktioniert.
Aber wenn es bei Ihnen und später beim User Klick gemacht hat läuft Ihre Page in allen Sinne des Wortes in Lichtgeschwindigkeit.
Man kann sich die Zeit sparen React oder andere Frameworks zu erlernen, wenn man weiß, was man eigentlich von einem FullStack-Router erwartet.
Ein FullStack-Router verbindet das Endgerät des Benutzers mit dem Server und koordiniert Anfragen und den Aufbau der Seite.
Heruntergebrochen bedeutet das:
Beiden gemein ist nur der Adress-Pfad. Daher teilen wir Code immer für den gleichen Pfad für Front- und Backend auf. Das bleibt an Ihnen hängen, aber die grundlegende Struktur des zwischen Backend und Frontend geteilten Pfads leicht und schnell zu handhaben - das können Sie mit 2 Routern abstrahieren.
Um diesen Pfad aufzuschlüsseln bietet sich eine Struktur ähnlich Dateisystemen an, d.h. jeder Controller kann untergeordnete Controller enthalten. Der Hauptcontroller Main
wird als Standard gesetzt und erlaubt so auch mehrere Ordner, Sie sind also in Ihrer Software nicht auf einen Pfad festgelegt, können woanders einen 2. Router mit Präfix ExperimentalMain
starten oder so.
construct()
mit dem übergeordneten Controller (parent::construct()
)Das hat den Vorteil, dass wir recht schlank alle constructoren so verbinden können, dass sich Dinge wie Zugriffsbeschränkungen automatisch ergeben und Klasseneigenschaften überall wo nötig (und nur dort) genutzt werden können.
🖼️ Stellen Sie sich das wie einen abgeschlossenen Aktenschrank in einem Zimmer vor - beide können unterschiedliche Zugriffsrechte haben, aber sobald Sie nicht ins Zimmer kommen bleibt auch der Aktenschrank zu. Doch alle Eigenschaften des Zimmers (Licht an? Temperatur, etc.) sind auf den Aktenschrank "vererbt".
Controller-Zweig: MainController
--> UserController
--> EditUserController
Pfad: /user/edit
Hier kann man bereits im UserController auf einen eingeloggten Benutzer prüfen lassen und sichert so automatisch alle untergeordneten Controller ab. Schlanke constructoren haben auch den Vorteil, dass sich Aktionen mit Querverweisen in Controllern nutzen lassen die nicht am selben Zweig hängen. Sobald Sie innerhalb eines Controllers einen anderen instanzieren wird dieser seine eigenen Rechte prüfen.
Zu guter letzt: Wenn diese aktuelle Aktion im aktuellen Controller keinen oder diesen Parameter-Typ nicht kennt gibt man einen 404 aus - damit decken Sie alle nicht-existenten Pfade ab.
🖼️ Ähnlich wie in Platons Höhle besteht unsere Webseite aus geschichteten Elementen an einer Wand und diese Elemente werden als Schatten von mehreren Ebenen von Trägern geworfen.
Jede Träger-Ebene, also jeder verschachtelte Controller generiert dabei wie in alten DOSney-Filmen einen Teil der Internet-Seite. Alle zusammen ergeben das Layout der Seite für den angefragten Pfad.
Je nachdem was Sie auf der Wand sehen wollen rufen Sie also mehreren Ebenen von Trägern zu welche sich hinstellen sollen und was Sie vors Feuer hochhalten.
Die grundlegenden Main-Träger generieren dabei grundlegende Seitenelemente wie den Hintergrund oder Header, höher sitzende (aber tiefer verschachtelte) fügen jedoch als erste Elemente ins Licht hinzu. Da alle Träger prinzipiell faul sind generiert keiner unnötig Elemente für die ein anderer schon Dinge ins Licht gerückt hat.
Das ist natürlich effizienter als die antike Herangehensweise von nur 1 Ebene Träger, aber das Bild mit den Schatten ist super, denn es wäre sinnlos Dinge die schon Schatten werfen nochmals hochzuhalten.
In unseren FullStack-Routern geht das so: Der Pfad wird zunächst --> vorwärts in abgeleitete Controller und deren constructoren und Aktionen/Methoden übersetzt:
Pfad: /User/edit
Controller-Zweig: Main->construct()
--> User->construct()
--> User->edit()
Jetzt wird <-- rückwärts gestapelt:
User->edit()
füllt $elements['editForm'] auf.
Jeder Controller führt zudem als Standard oder zusätzlich zu angefragten Aktionen (das können mehrere verkettet sein) die index()-Aktion aus.
User->index()
füllt für die Benutzerverwaltung Seitenelemente wie eine kleine navbar $elements['navbar']
und steckt alle Elemente die der User-Controller baut zusammen in ein $elements['body']
.
Main->index()
füllt nun noch $elements['head'] und $elements['footer'] auf. Allerdings den Body nur wenn noch keiner gesetzt wurde, was einen Aufruf von Main->welcome()
erspart (diese setzt den Body als Willkommensseite).
Ein typischer Index für einen Main-Controller sieht also meist so aus:
if (!isset ($this->elements['head'])) $this->head();
if (!isset ($this->elements['body'])) $this->welcome();
if (!isset ($this->elements['foot'])) $this->foot();
mehr braucht der auch nicht. Da er immer aufgerufen wird ist für jeden Controller die Struktur sichergestellt, aber auch dass nach einer Aktion von sich selbst oder von tiefer verschachtelten Controllern Teile nicht doppelt berechnet werden.
Eine Backend-render()-Funktion läuft nun das fertig verschachtelte Elemente-Array ab und generiert für jedes Element einen HTML-Tag mit dessen Inhalt. Das ist ultra praktisch, denn Sie können mit custom-html-tags so jedes Seitenelement exakt platzieren.
Der fertige Elemente-Stapel wird im Main-Controller nur exakt 1x an eine grundlegende template()-Aktion übergeben. Denn nur wenn die komplette Seite neu gerendert wird erwartet HTML eine gewisse Struktur im Stamm die sie nicht oder nur umständlich aus einem Array ableiten können. Hier ist ein template() schneller.
Aber: Wird nur ein Zweig neu erstellt ist das nicht notwendig und Sie erhalten mit render() immer nur die Elemten für diesen Abzweig als Fragment. (dazu mehr im Frontend-Teil).
Wie Sie unschwer sehen sparen Sie sich mit dem Forward-Pass der constructoren und dem Backward-Pass aller gestapelten Elemente jede unnötige routine, eine template-Engine brauchen Sie auch nicht mehr und sowas wie route-annotations ist auch vollkommen überflüssig geworden.
Im Frontend wird es minimal komplizierter. Wir wissen welche Controller sich aus dem Pfad ergeben und spiegeln daher alle Controller im Frontend. Der Job des Frontends ist es nun zu wissen bei Klicks auf andere Pfade schon zu wissen welche Elemente bereits gerendert sind, welche wieder verworfen werden können und welche man vom Server erneut fetchen muss.
Pfad: User/Advanced/edit
ist gerendert, der User klickt nun auf z.B. auf die Aufgabenübersicht Tasks/
Daraus ergeben sich 3 Teile:
🪓 deconstruct-Teil des Altpfads. Hier werden alle Controller->destruct()-Routinen im Frontend aufgerufen um eventlistener, Animationen, etc. die unnötig geworden sind zu entfernen. In unserem Fall ist das der User-Controller und der Advanced-Controller.
🏗️ construct-Teil des Neupfads. Hier werden alle construct()-Routinen aufgerufen und render-funktionen gesetzt. In unserem Beispiel ist das der Tasks-Controller.
Branch / Zweig. Ab diesem Controller / dem gemeinsamen Pfadteil wird neu gerendert. In dem Fall Main.
Statt Elemente wie im Backend stapeln wir im Frontend renderer-Routinen und wie im Backend werden diese immer an den übergeordneten Controller übergeben - dieser kann so ein doppeltes Rendern vermeiden und spart auch alle Aufrufe zu Methoden/Aktionen die nicht gebraucht werden. Die Elemente für diese Render-Routinen kommen wieder aus dem Backend, aber wieder: Nur welche auch gebraucht werden.
Elemente die noch aus dem Backend zu holen sind kann man so mit einem einzelnen Fetch holen, denn wir wissen immer wie lang der Pfadanteil am neuen Pfad ist der noch keine Elemente aus dem Backend hat. Das ist immer der Zweig (der gemeinsame bereits instanzierte Teil) plus alle neuen Controller. In unserem Fall wäre das ein einfacher fetch: Tasks/?fetch=0 der angibt, dass nur Elemente die Tasks-Controller, Aktion index setzt geholt werden müssen.
Andersherum, also zurück zu User/Advanced/edit wäre User/Advanced/edit im neuen Pfad, der Zweig wäre User und der fetch wäre demnach User/Advanced/edit?fetch=1 - es werden 2 Ebenen Elemente vom Backend in einem Fetch geholt.
Es können beliebig viele Elemente in der Antwort stecken, egal wo Sie den Zweig ansetzen, ob direkt am Stamm oder höher: Sie erhalten eine Liste von Elementen zurück die sich nacheinander im document DOM überschreiben lassen.
Main
--> User
--> Advanced->edit
kann daher head und foot aus Main behalten
Main
--> Tasks
?fetch=0 holt nur das body-element und ruft aus dem Main-Controller nur das schlank construct() auf.
Main
--> User
--> Advanced->edit
?fetch=1 spart ebenso die Main-Controller-Routinen.
Komplexere DOM-Bäume in neuen Pfaden werden einzeln und hintereinander vom Stamm zum Blatt zurückgegeben, daher können wir erst größere Bereiche ersetzen (body z.B.) und darin dann kleinere Teile aus Subcontrollern (sub-menu z.B. was als Tag in body steckt, aber in der fetch-Antwort danach kommt). Das ist mit custom-tags in HTML überhaupt kein Problem und erleichtert das Strukturieren ungemein.
Nun sind alle Elemente neu gesetzt, aber Frontend-Dinge wie eventlistener oder kosmetische Anpassungen fehlen noch - diese stecken in den Frontend-Renderern.
Dafür müssen wir nach dem Stapeln der zusätzlich zu den bereits aufgerufenen Renderern im Frontend den Status der Renderer in den Controller-Instanzen selektiv zurücksetzen. Das ist notwendig, weil wir im Frontend die Seite im Speicher halten und daher einen Status über alle render-Routinen halten und resetten müssen.
Ein einfacher Aufbau:
renderer = [
]
erhält ein identisches Array mit dem Status:
rendererState = [
]
Wir können im Stamm- oder Zweig-Controller, der alle untergeordneten Controller rendert, nicht wissen welche Renderer dort von Subcontrollern zurückgesetzt wurden. Möglich, dass ein Subcontroller die header()-Routine statt nur dem body() anpasst. Ein neuer Renderer z.B. für ein Submenü hat einen neuen Namen und natürlich immer Status false zu Beginn. Wir müssen aber ab dem Abzweig alle gestapelten Renderer durchlaufen. Hier ist auch die destruct()-Methode wichtig, denn renderer müssen auch wieder entfernt werden (was den Speicher im Client schont).
🖼️ Stellen Sie sich den Ansatz einfach wie ein Restaurant vor: Sie bestellen Dinge von der Speisekarte (Controller mit Aktionen). Welche Zutaten (Elemente) da wie kombiniert und geschichtet werden ist Sache des Backends, das Frontend muss nur wissen wie es arrangiert wird (Renderer). Das einzige was beide wissen müssen ist was Sie in welchem Gang essen wollen (Pfad).
Der Job des Kellners (Ihr Browser) ist nun bei jeder Bestellung nur die Dinge vom Tisch zu räumen, die Sie nicht mehr brauchen und nur Teller mit Speisen aus der Küche (der Server) zu bringen die Sie bestellt haben. Nicht mehr und nicht weniger und ohne 10 Mal hin- und herzurennen, Sie bei jeder Bestellung erstmal rauszuschmeissen, eine neue Reservierung zu verlangen, das Restaurant dem Erdboden gleich zu machen und dann alles für ein stilles Wasser mit Kohlensäure neu aufzubauen.
Wer neben Leistung auch das Optische misst erkennt hier auch, dass sich Elemente beim neu rendern seltener verschieben, weil Front- und Backend die gleichen Stapel basteln.
Da jeder Controller den kompletten Elemente- und Renderer-Baum schreiben kann, können Sie auch in untergeordneten Controllern setzen, dass z.B. nach einem Login / Status-Änderung die Header-Bar neu gefetcht / gerendert werden soll. Das geht am einfachsten, indem Sie in der Methode des untergeordneten Controllers die übergeordnete Methode neu aufrufen (das funktioniert sowohl in Backend wie Frontend).
Das brauchen Sie zwar nicht immer, es ist also sparsamer in untergeordneten Controllern nur Teil-Elemente der Seite zu setzen, denn bei einem Fetch wird dann auch nur dieser Teil übertragen und wir wollen ja nicht mit jedem Click alle Routinen neu laufen lassen.
ABER: Da alle untergeordneten Controller auf übergeordneten aufbauen (wegen der schlanken constructoren allerdings sehr schnell) sind diese übergeordneten Controller immer schon instanziert. Der Geschwindigkeitsverlust ist also null, denn diese Controller muss man so oder so instanzieren.
Ich dachte erst, das geht nicht ohne Klimmzüge, aber die Baum-Architektur lässt es bereits "by-design" zu :)
Angenommen Main (/) rendert 3 Elemente: header, body, footer
die meisten Controller darunter (/User z.B.) rendern immer nur sub-Elemente in body, fetchen daher header und footer nicht neu.
Doch nun ... /User/Advanced/edit möchte im Header neue Werkzeuge / Status einfügen. DAS GEHT! Denn wenn dieser Subcontroller das element header neu setzt wird es bei einem fetch mitgeliefert, also nur dann überschrieben und bei einem Bookmark wird der header in main nicht extra neu berechnet, das das /User/Advanced/edit schon gesetzt hat.
Ein Vorteil dieser Architektur ist auch, dass alle Frontend-Controller in eigenen .js-Dateien in Unterordnern ausgegeben werden können und trotzdem alle npm-Bibliotheken oder Code ausserhalb der Controller in chunks ausgeliefert wird (einfach mal die Entwickler-Console anwerfen).
D.h. mit bundlern wie esbuild können Sie eine Struktur ausgeben die vom Backend-Server aus je nach Zugriffsbeschränkung Code nur an Nutzer verteilt, die diesen auch bekommen sollen dürfen.
Natürlich gibt es im Frontend keine absolute Sicherheit, aber trotzdem kann es ein Anwendungsfall erfordern nicht alle .js-Dateien öffentlich zu halten. Als unvorhersehbares Chunk geht das nicht - mit klaren Ordnern schon.
Ich habe das selbst nicht implementiert, weil ich Anfragen nach .js nicht vom Backend abfangen muss (aller Frontend-Code hier ist prinzipiell öffentlich), aber machbar ist es so mit wenigen Anpassungen.
Sie sehen im router.js Code auch, dass die Routinen Clicks abzufangen oder die History zu managen nur wenige Zeilen benötigen.