Stabile Sessions für offline-fähige Web-Apps mit PouchDB
Oder: Der ewige Kreis – über zirkuläre Abhängigkeiten in AngularMit einer kleinen Gruppe von Studierenden entwickele ich im Rahmen von Aam Digital, einem Teilprojekt der Karlsruher Engineers Without Borders die Web-App NGO-DB, die es kleinen NGOs – vor allem unserem Partner H.E.L.G.O. – einfacher machen soll, ihre Daten zu verwalten.
Bei H.E.L.G.O. geht es dabei primär um das erfassen von Daten der Schüler, die das Projekt in Kalkutta fördert. Eine wichtige Anforderung ist hierbei die Offline-Fähigkeit der App, da Internet nicht immer verfügbar ist.
Als technische Basis wurden daher Angular, ein TypeScript-Framework mit Unterstützung für Progressive Web Apps, sowie PouchDB, ein JavaScript-Klon der dokumentenorientierten Datenbank CouchDB, die Features wie Offlinefähigkeit und Synchronisation zur Verfügung stellt, verwendet.
Dieser Blog-Post beschäftigt sich nun mit der Frage, wie eine User-Session von Login über Synchronisation der lokalen Datenbank-Replik bis hin zur Nutzung der Datenbank sinnvollerweise aussehen könnte. Dabei wird die ganz am Anfang des Projekts entwickelte, bestehende Session analysiert, um daraus für die neu zu gestaltende Session zu lernen.
Nebenbei wird es dabei auch in großer Tiefe um zirkuläre Abhängigkeiten in JavaScript-Modulen, bei Dependency Injection sowie zwischen Modulen in Angular gehen und um Strategien, wie diese Abhängigkeiten aufgelöst werden können.
Wer den Post nur für diesen Teil ließt, empfehle ich, direkt hinab zum Spaß zu springen: Zirkuläre Abhängigkeiten. Allen anderen empfehle ich auch gerne die nun folgende Einführung des Gesamtszenarios des Session-Handlings der Applikation.
Die bestehende Session
An der Session sind eine Reihe von Komponenten beteiligt, die in die folgenden Angular-Module aufgeteilt sind:
- Database: Enthält eine Abstraktion über den Datenbankzugriff, eine Implementierung für eine lokale PouchDB sowie eine Mock-Datenbank. Außerdem ist ein
DatabaseManagerService
abstrakt und für die beiden Implementierungen enthalten, der den Angular-Provider für die Datenbank selbst hält. Die PouchDB-Implementierung, derPouchDatabaseManagerService
hält intern Referenzen auf die lokale und entfernte PouchDB und stellt Methoden zum Login an der entfernten sowie zur Synchronisierung zwischen lokaler und entfernter Datenbank zur Verfügung. Aus prinzipiellen Gründen enthält die lokale Datenbank auch eine Kopie der User mit ihren gehashten Passwörtern.1 - Entity: Enthält einen simplen EntityMapper zur Benutzung mit unserer Datenbankabstraktion. Andere Module implementieren die abstrakte Klasse
Entity
um in der Datenbank gespeichert werden zu können. - User: Der User ist eine solche Entity, die zudem Methoden zur Prüfung des Passworts beinhaltet.
- Session: Die Session beinhaltet den
SessionService
, der alle Bestandteile zusammenhält und schlussendlich verwendet wird, um herauszufinden, welcher User angemeldet ist.
Services
Das folgende UML-Klassendiagramm stellt (etwas vereinfacht) die beteiligten Services und Klassen mit ihren wichtigsten Methoden dar. Gestrichelte Pfeile sind dabei Abhängigkeiten, durchgezogene Pfeile Injections durch Angular.
Design-Probleme
Leider kommt dieses Design mit einer Reihe von Problemen:
SessionService
undDatabaseManagerService
implementieren beidelogin
undlogout
. Dass sich die Session dabei primär um die lokale und der DatabaseManager sich ausschließlich um die entfernte Datenbank kümmert, ist nicht definiert.SessionService
enthält in den privaten Methoden bereits ein Konzept von lokaler und entfernter Datenbank. Insbesondere kümmert es sich in der privaten MethodeauthenticateLocalUser
um die Kommunikation mit demEntityMapperService
, um den User zu laden und sein Passwort zu überprüfen, während die private MethoderemoteDatabaseLogin
nur an den login des DatabaseManagers delegiert. Diese Ideen sind sehr eng an die PouchDB gekoppelt – die Mock-Implementierung des DatabaseManagers verwendet diese Konzepte naturgemäß nicht (und dies ist auf Basis des Interfaces des Databasemanagers auch gar nicht notwendig).- Die Frage, ob ein User eingeloggt ist, wird zunächst ausschließlich auf Basis von
authenticateLocalUser
und damit der lokalen Datenbank beantwortet. Beim initialen Anmelden, bei dem noch keine Datenbank vorhanden ist, führt dies zu Problemen. - Generell ist der Status von Login und Synchronisation weit über das System verteilt und an keiner Stelle einheitlich verwaltet. Dies hat in der Vergangenheit zu seltsamen Fehlern geführt.
Ziel war also ein Redesign der Session, dass einen konsistenten State hat und stabile Logins online und offline ermöglicht.
Redesign
State
Zunächst soll festgestellt werden, welche Zustände das System haben kann. Dabei wurden die folgenden drei Zustandsdiagramme erarbeitet:
- LoginState: Der LoginState beschreibt den wichtigsten Zustand der Anwendung: Ist der Nutzer eingeloggt oder nicht. Auch aus dem Zustand
loginFailed
kann direkt zuloggedIn
gewechselt werden, wenn sich der Nutzer am UI nach einer fehlgeschlagenen Login erneut mit den richtigen Login-Daten einloggt. - ConnectionState: Der ConnectionState beschreibt den Zustand der Verbindung zur entfernten Datenbank.
disconnected
beschreibt dabei, dass kein Verbindungsversuch unternommen werden soll, was vor Login und nach Logout der Fall ist. Die entfernte Datenbank kann die Verbindung wegen fehlgeschlagener Authentifizierung zurückweisen, was im Zustandrejected
resultiert. Zudem kann zwischenoffline
undconnected
gewechselt werden, wenn sich die Netzwerkverbindung endet. - SyncState: Der SyncState beschreibt den Zustand der Datenbanksynchronisation. Initial ist die lokale Datenbank
unsynced
. Eine nach erfolgreicher Connection zur entfernten Datenbank gestartete Synchronisation kann diesen Zustand ändern.
Um die drei Status konsistent umzusetzen, wurde eine kleine Helferklasse implementiert, die Status und Änderung verwalten kann. StateEnum
ist dabei die (TypeScript-)Enumeration, die die jeweiligen State-Werte enthält.
Der LoginState hängt nun primär von der lokalen Datenbank ab, da ein Login im Zweifel auch offline und bei schlechter Internetverbindung funktionieren muss. Der ConnectionState ist originär von der entfernten Datenbank abhängig, der SyncState lebt zwischen den beiden.
Login-Ablauf
Ein Login-Vorgang läuft nun wie folgt ab:
- Parallel werden an der lokalen und entfernten Datenbank der Login-Vorgang gestartet. Das Ergebnis des lokalen Logins wird dabei über eine Promise zurückgegeben.
- Der lokale Login wartet jedoch zunächst auf die erste Synchronisation der Datenbank und damit darauf, dass Nutzer offline vorhanden sind.
- Der Login-Vorgang an der entfernten Datenbank gibt den ConnectionState zurück. Folgende Situationen können eintreten:
- Ist der Nutzer
offline
, geschieht nichts weiter, außer die lokale Datenbank ist noch initial. In diesem Falle wird der SyncState auffailed
gesetzt, damit der lokale Login nicht unendlich lange auf die erste Synchronisation wartet und den Login-Vorgang entsprechend abbrechen kann. - Ist entfernte Datenbank erfolgreich
connected
, wird die Synchronisation angestoßen. War die Datenbank ursprünglich initial, kann bei Abschluss der Synchronisation der lokale Login abgeschlossen werden. - Wenn die entfernte Datenbank die Authentifizierung
rejected
, der lokale Login jedoch erfolgreich war, sind die Authentifizierungsdaten in einem inkonsistenten Zustand. Dies ist Beispielsweise der Fall, wenn jemand auf der entfernten Datenbank das Passwort geändert hat, der Nutzer jedoch einen Login mit alten Zugangsdaten durchgeführt hat. In diesem Fall muss der LoginState aufloginFailed
geändert werden.
- Ist der Nutzer
Als wichtige Restriktion ergibt sich somit, dass das Passwort nur geändert werden darf, wenn das Gerät online ist, und der Passwortwechsel ansonsten zurückgerollt werden muss.
Services
Für die Services soll der grundsätzliche Aufbau mit Datenbank-Abstraktion, Session und EntityMapper beibehalten werden. Da Session und DatabaseManager in der vorigen Version sehr ähnliche Aufgaben hatten, wurden der DatabaseManager in die Session aufgenommen. Hier kann nun die Komplexität der lokalen und der entfernten Datenbank sowie ihrer Synchronisation versteckt werden. Eine MockSession, die hier nicht modelliert ist, hat nun außerdem nicht mehr die Pflicht, die Schnittstelle für eine synchronisierende Datenbank zu implementieren.
Eine wichtige Entscheidung war es, die Synchronisation (Status und Methoden) auf dem LocalSessionService
zu implementieren. Dies ist notwendig, da die lokale Session von ihm abhängig ist: Wenn die lokale Datenbank initial ist, muss auf den ersten erfolgreichen Sync gewartet werden (außer der schlägt fehl). Der wird vom SessionService
getriggert, wenn der RemoteSessionSession
die Verbindung erfolgreich hergestellt hat. Nur so kann vermieden werden, dass LocalSessionService
und RemoteSessionService
den bündelnden SessionSession
sowie einander kennen müssen.
Das große Problem dieses Designs ist jedoch die zirkuläre Abhängigkeit zwischen SessionService
, LocalSessionService
und EntityMapperService
:
- EntityMapperService braucht den
SessionService
, um Zugriff auf dieDatabase
zu bekommen. Dieser Zugriff funktioniert auch ohne Login, da die lokale Datenbank verwendet wird und jegliche Anmeldung hier von der Anwendung selbst vollzogen werden muss. - SessionService braucht den
LocalSessionService
. - LocalSessionService braucht den
EntityMapperService
, um denUser
aus der eigenen Datenbank zu laden und sein Passwort zu validieren.
Somit wird zunächst zum Problem, dass die Services zirkulär voneinander abhängig sind und ineinander injected werden müssen. Darüberhinaus sind die Services auf die beiden Pakete Entity
und Session
verteilt, sodass auch hier zwischen Angular-Paketen eine zirkuläre Abhängigkeit besteht.
Ein Test mit der prototypischen Implementierung zeigt: Die App startet nicht, sondern verbleibt kommentarlos im „Loading…“-Zustand. Einzig die Warnungen aus dem Angular CLI über zyklische Abhängigkeiten zwischen den Modulen werden angezeigt.
Zirkuläre Abhängigkeiten
Wenn man auf Google nach „angular circular dependency“ sucht, findet man fast eine halbe Millionen Ergebnisse, die alle nur bedingt hilfreich für das hier vorliegende Gesamtpaket sind. Obwohl einige sehr an der jeweiligen Problemlösung interessiert sind, so ist doch diese Meinung aus einem „Tutorial“ durchaus repräsentativ:
If You Have Circular Dependencies, You’re Doing Something Wrong
Da es allerdings manchmal Situationen gibt, in denen man nicht um sie herumkommt (oder es aus anderen architektonischen Gründen nicht wirklich will), soll hier nun diskutiert werden, wie man die unterschiedlichen Ebenen der zirkulären Abhängigkeiten brechen kann.
… zwischen JavaScript-Modulen
Unsere erste zirkuläre Abhängigkeit sind die TypeScript import
-Befehle. Da TypeScript auf JavaScript basiert und zur Ausführung in reines JavaScript umgewandelt wird, schauen wir uns hier auch die Grundlagen an.
CommonJS
CommonJS bietet eine Definition von JavaScript-Modulen, die durch Node.js populär gemacht wurde: Jedes Modul erhält in seinem Ausführungskontext require
-Funktion und module
-Objekt. Das exportierte Interface des Moduls wird in das Objekt module.exports
geschrieben, dass von einem entsprechenden require
-Aufruf zurückgegeben wird.
Zirkuläre Abhängigkeiten können nun wie folgt gebrochen werden: module.exports
ist standardmäßig ein Objekt, dessen konkrete Eigenschaften auch erst später nachgetragen werden können. Somit erhält der Partner im zirkulären Aufruf zunächst ein unvollständiges Objekt, dessen zusätzliche Methoden später hinzugefügt werden – schließlich müssen sie erst beim konkreten Aufruf der Methode verfügbar sein.
Das folgende Beispiel zeigt exemplarisch eine zirkuläre Abhängigkeit zwischen a.js
und b.js
:
// a.js
const b = require('./b.js');
module.exports.first = () => b.second();
module.exports.third = () => 'Hello World';
// b.js
const a = require('./a.js');
module.exports.second = () => a.third();
AMD
Im Browser hat sich mit der Asynchronous Module Definition (AMD) zunächst ein anderer Standard für JavaScript-Module durchgesetzt. Eine define
-Funktion erhält ein Array von (Bezeichnern von) Abhängigkeiten sowie eine anonyme Funktion, die mit den aufgelösten Abhängigkeiten aufgerufen wird. Ihr Rückgabewert ist das exportierte Interface des Moduls.
Hier sind zirkuläre Abhängigkeiten etwas schwerer umzusetzen – da explizit ein neues Objekt zurückgegeben wird (danach ist die Funktions- und somit Modulausführung beendet) kann kein unvollständig definiertes Interface verwendet werden, um die zirkuläre Abhängigkeit zu brechen.
Der AMD Module Loader RequireJS beschreibt in seiner Dokumentation zwei Strategien, wie doch mit zirkulären Abhängigkeiten umgegangen werden kann. Beide bauen für sich einen Teil der CommonJS-API nach, um den Kreis zu brechen: (Die Beispiele zeigen hier nur b.js
.)
// Strategy 1: require
define(["require", "a"],
function(require, a) {
//"a" in this case will be null if "a" also asked for "b",
//a circular dependency.
return function second() {
return require("a").third();
}
}
);
// Strategy 2: exports
define(['a', 'exports'], function(a, exports) {
//If "a" has used exports, then we have a real
//object reference here. However, we cannot use
//any of "a"'s properties until after "b" returns a value.
exports.second = function () {
return a.third();
};
});
Zudem gibt es noch eine ganze Reihe weiterer Ansätze, das Problem zu umgehen, die aber alle um das Modulsystem selbst herumhantieren. Deswegen werde ich sie hier nicht beleuchten und stattdessen auf diesen Blog verweisen, der ein paar von ihnen aufzählt.
ES6 Modules
Mit ECMAScript 6 erhielten 2015 Module auch Einzug in den Sprachstandard von JavaScript, sodass sie (theoretisch) ohne zusätzlichen Laufzeitsupport durch Node.js oder Libraries wie RequireJS genutzt werden können. Der Syntax für Imports und Exports ist der gleiche wie in TypeScript, hier ein das volle Beispiel für eine zirkuläre Abhängigkeit:
// a.mjs
import second from './b.mjs';
export function first() {
return second();
}
export function third() {
return 'Hello world';
}
// b.mjs
import { first, third } from './a.mjs';
export default function second() {
return third();
}
// index.mjs
import * as a from './a.mjs';
console.log(a.first());
In Node.js müssen die Dateien mit der Endung .mjs
versehen, sowie für die Ausführung ein experimentelles Flag mitgegeben werden:
node --experimental-modules index.mjs
Auch in Browsern ist der Support für den Sprachstandard noch nicht gegeben. Daher wird JavaScript (und TypeScript natürlich auch) in ältere Versionen des Sprachstandards transpiliert, wo dann wieder ein Module-Loader die Arbeit der Modulverwaltung übernimmt. Ein Beispiel dafür liefert dieser Blogbeitrag.
Angular
Angular macht natürlich noch weit mehr, als ES6 nach ES5 umwandeln: Hier wird TypeScript umgewandelt und zudem mit Webpack ein Bundle erstellt (bzw. gleich eine ganze Reihe von Bundles). Und das funktioniert im Großen und Ganzen ganz gut, zirkuläre Abhängigkeiten können aufgelöst werden. Allerdings gibt dabei bisweilen Bugs und, wesentlich eklatanter, auch systematische Probleme bei bestimmten Inhalten von Modulen, wie dieser Blogbeitrag sehr anschaulich am Beispiel von Schere-Stein-Papier erklärt.
Im großen Projekt der neuen Session macht uns das alles aber keine Probleme: Wir verwenden (dank TypeScript) nur Klassen und die zirkuläre Abhängigkeit ist auch keine, die wegen Vererbung synchrone Ausführung notwendig macht (wie im oben verlinkten Blogbeitrag beschrieben) – die zirkulären Abhängigkeiten zwischen den JS/TS-Modulen sind also für uns kein Problem.
Sollte sich dennoch jemand bereits hier in Probleme verstrickt haben, ist ein Refactoring hin zu Interfaces eine wichtige Maßnahme, bereits in der Architektur der Anwendung den Kreis aus Abhängigkeiten zu brechen. Ein Beispiel findet sich zum Beispiel in dieser Frage auf StackOverflow.
… bei Dependency Injection
Dependency Injection (DI) erlaubt es als eine Umsetzung von Inversion of Control, losere Kopplung zwischen Klassen zu erreichen. Die Idee ist relativ simpel: Statt Klassen selbst Objekte instanziieren zu lassen, die von ihnen benötigt werden, instanziiert ein Framework die richtigen Objekte und stellt sie zentral zur Verfügung. Je nach Programmiersprache und Framework gibt es dazu unterschiedliche Methoden, von denen z.B. Martin Fowler eine Auswahl beschreibt. Ich orientiere mich hier eher am populären Java-Framework Spring:
- Constructor-Based Injection: Jede Klasse spezifiziert die Objekte/Klassen, von denen es abhängig ist, als Parameter ihres Konstruktors. Soll das Framework eine Objekt dieser Klasse instanziieren, schaut es zunächst nach, welche Klassen (oder besser Interfaces) im Konstruktor benötigt werden, instanziiert die entsprechenden Objekte (als Singletons) und gibt sie als Argumente beim Konstruktoraufruf mit.
- Setter/Field-Based Injection: Bei diesen Ansätzen werden vom Framework erst, nachdem das Objekt der Klasse konstruiert wurde, diesem die abhängigen Objekte bekannt gemacht. Beim Setter-Based Ansatz wird dazu eine öffentlicher Setter-Methode genutzt, bei Field-Based Injection wird gleich in das Feld des Objekts geschrieben.
Vorteil der Constructor-Based Injection ist, dass alle Abhängigkeiten bereits zum Instanziierungszeitpunkt vollständig vorliegen und ohne Bedenken verwendet werden können. Setter/Field-Based Injection erlauben dafür optionale Abhängigkeiten oder solche, die erst später, nach der Instanziierung gesetzt werden.
Als alternativen Ansatz zu DI beschreibt Fowler den Service Locator als zentrale Instanz, bei der man per Methodenaufruf zu einen Namen das entsprechende instanziierte Objekt erhält. Im Gegensatz zu den Injection-Ansätzen muss hier die Klasse jedoch die Implementierung des Service Locators kennen und ist eng an diese gekoppelt.
Ganz Grundsätzlich sind bei auch bei Dependency Injection zirkuläre Abhängigkeiten möglich – wenn Setter/Field-Based Injection verwendet werden. Das folgende Beispiel zeigt (in JavaScript), wie ein Injector dabei prinzipiell vorgehen würde:
class A {}
class B {}
const a = new A();
const b = new B();
a.b = b;
b.a = a;
Wichtig ist: Die Abhängigkeiten können erst benutzt werden, wenn alles instanziiert wurde. Der Start zerfällt damit in drei Schritte:
- Alle benötigten Objekte instanziieren.
Welche das sind, kriegt man über einen Abhängigkeitsgraph heraus. - Alle benötigten Abhängigkeiten setzen.
- Objekte initialisieren.
Z.B. zusätzlicher Setup, der die Abhängigkeiten benötigt oder das finale Starten der Applikation.
Dadurch erhalten jedoch alle Objekte mehr Lifecycle, als von der Sprache vorgesehen, die eigentlich nur den Konstruktor als Ort für Initialisierung vorsieht.
Angular verwendet ausschließlich Constructor-Based Injection. Dies liegt wahrscheinlich daran, dass es einfacher ist – einfach nur starten, zack fertig. Alles ist sicher da, es gibt keinen Overhead durch mehrschrittigen Start.
Allerdings lassen sich dadurch zirkuläre Abhängigkeiten nicht mehr einfach Abbilden, wie das folgende Beispiel zeigt, dass wieder das prinzipielle Vorgehen des Injectors illustriert:
class A {
constructor(b) {
this.b = b;
}
}
class B {
constructor(a) {
this.a = a;
}
}
const a = new A(b); // Uncaught ReferenceError: b is not defined
const b = new B(a);
Wir erinnern uns an das Zitat über Angular vom Anfang: „If You Have Circular Dependencies, You’re Doing Something Wrong“. Nichtsdestotrotz gibt es ein paar Workarounds:
Manuelles Injecten
Vorschlag Nr. 1 ist eine Mischung aus Constructor-Based Injection manueller Arbeit. Konkret am Beispiel der neuen Session, die wir implementieren wollen: Der EntityMapperService kriegt SessionService per Injection, und setzt sich dort per Setter-Methode selbst, sodass der SessionService ihn im Anschluss verwenden kann.
Jetzt ergibt sich nur ein schwer zu umgehendes Problem: Welches Objekt wird zuerst konstruiert? Wird der EntityMapperService zuerst instanziiert, funktioniert alles: Angular erstellt den SessionService zur ordentlichen Dependency Injection und der EntityMapperService registriert sich bei ihm. Wird jedoch der SessionService geladen, wird der EntityMapper nicht automatisch instanziiert und meldet sich somit nicht selbstständig manuell an.
Hier ist relevant, was geladen wird und was nicht – und darüber habe ich nur sehr bedingt Kontrolle! Das ist also eine in der Praxis sehr unschöne Idee.
Inject the Injector
Eine bessere Lösung wird hier auf StackOverflow vorgeschlagen. Dabei wird auf eine Mischung aus Constructor-Based Injection und Service Locator gesetzt – der Angular Injector ist nämlich Service Locator, der per Constructor-Based Injection verfügbar gemacht werden kann, wie das folgende Beispiel zeigt:
// a.ts
class A {
constructor(private b: B) {}
}
// b.ts
class B {
private a;
constructor(injector: Injector) {
setTimeout(() => this.a = injector.get(A));
}
}
Die beiden Einbindungs-Fälle sind dann wie folgt:
B
wird geladen. In diesem Fall wirdB
instanziiert und im Injector bekannt gemacht. Im nächsten JS-EventLoop-Durchlauf (setTimeout
) wird über den Service Locatorinjector
dannA
angefragt und vom Framework instanziiert.B
ist zu diesem Zeitpunkt bereits vollständig bekannt – alles klappt wie erwartet.A
wird geladen. In diesem fall wird zunächst die AbhängigkeitB
instanziiert und im Konstruktor vonA
gesetzt. Im nächsten EventLoop-Durchlauf istA
dann schon bekannt und kann vonB
abgeholt werden. Auch hier alles ein voller Erfolg.
Diese Methode ist zwar nicht ganz so schön wie eine rein auf Constructor-Based Injection basierende Lösung, funktioniert aber robust und zerstört nicht die lose Kopplung der Klassen. Ein Erfolg, wir können also auch die zirkuläre Abhängigkeit bei der Dependency Injection als gelöst betrachten.
Angulars forwardRef
Angular erwähnt in seiner API-Dokumentation und im Cookbook „DI in Action“ eine Funktion forwardRef
, die es laut Selbstbeschreibung erlaubt, zyklische Referenzen zu brechen.
Hierbei geht es jedoch nicht um zirkuläre Abhängigkeiten zwischen zu injectenden Objekten, sondern vielmehr um die Lösung eines Problems, das entstehen kann, wenn mehrere Klassen in einer Datei implementiert sind: Wird für ein zu injectendes Objekt eine erst weiter unten in der Datei implementierte Klasse verwendet, kann der Angular Injector damit nicht umgehen. Das liegt daran, dass die sogenannten Decorators (der JavaScript/TypeScript-Name für Annotations), die man in Angular nutzt, um Injections bekannt zu machen, unmittelbar ausgeführt werden müssen, und dem Angular Injector die bloße Definition der Klasse nicht ausreicht, sondern die Implementierung bereits evaluiert worden sein muss, um die Injection vorbereiten zu können.
forwardRef
kann diese Vorbereitung verzögern, indem die Referenz in einer erst später ausgeführten anonymen Funktion verpackt wird. Somit kann es alle (Vorwärts-)Referenzen korrekt auflösen. Zwei Beispiele finden sich auf den bereits oben erwähnten Seiten der Angular-Dokumentation.
Für unser Problem mit der neuen Session ist forwardRef
also scheinbar nicht sinnvoll einsetzbar, weil bei uns alle Klassen in eigenen Dateien stehen.
… zwischen NgModules
Jetzt ist also das letzte bisschen an Problemen anzugehen: Die zirkuläre Abhängigkeit zwischen den Modulen Entity mit dem EntityMapperService
und dem User
und Session mit dem SessionService
und dem LocalSessionService
. Stellt man diese her, wird man vom Angular-CLI sehr eingehend gewarnt. Ein FAQ in der Dokumentation erklärt auch in einem halben Satz, dass diese Abhängigkeit ein Problem ist, sagt aber nicht, warum:
Angular doesn't like NgModules with circular references, so don't let Module 'A' import Module 'B', which imports Module 'A'.
Vermutung: Hierarchical Dependency Injection
Meine erste Vermutung war, dass diese Einschränkung mit dem System hierarchischer Injection zusammenhängt, das Angular verwendet: Jedes Modul erhält seinen eigenen Injector mit, der in einer Baumstruktur Zugriff auf die anderen Injectors hat, um zu injectende Objekte zur Verfügung stellen zu können. Diese Idee wird noch einmal komplizierter, wenn der Angular Router dynamisch (lazy) Module hinzufügen kann.
Vielleicht sind zirkuläre Referenzen in diesem Injector-Tree schlicht nicht vorgesehen? Diese Antwort auf StackOverflow zeigt aber, dass der Injector Tree damit relativ wenig zu tun hat, weil die von den Modulen exportierten Provider schlicht an den Root Injector der Applikation angehängt werden.
Debugging: Der Angular JIT Compiler
Letzter Ausweg also, etwas herauszufinden: Schritt-für-Schritt Debugging des Bootstrappings der Anwendung. Schnell zeigt sich: Das durch Webpack übersetzte Laden der TypeScript-Module funktioniert einwandfrei. Danach wird der Angular Compiler eingesetzt, um die NgModules und ihre Abhängigkeiten aufzulösen. Spannend ist an dieser Stelle, was in CompileMetadataResolver.
passiert. Hier werden von jedem NgModule rekursiv die Metadaten aus dem entsprechenden Decorator gelesen, der zum Beispiel imports
, exports
und provider
enthält.
Es fällt auf: zunächst wird (direkt vom AppModule
) das SessionModule
geladen, was dann wiederum das EntityModule
lädt. Dieses sollte in seinen imports
(unter anderem) wieder das SessionModule
enthalten – jedoch steht an dieser Stelle undefined
! Also doch etwas, was mit noch nicht vollständig geladenen TypeScript-Definitionen zusammenhängt, die in der Dependency Injection verwendet werden sollen – und damit ein Use Case für forwardRef
. Bei einer Ersetzung des imports
in entity.module.ts
durch forwardRef(() => SessionModule)
funktioniert diese Auflösung jetzt und statt undefined
steht nun ein SessionModule
in den aufgelösten Metadaten.
@NgModule({
imports: [
CommonModule,
DatabaseModule,
forwardRef(() => SessionModule)
],
declarations: [],
providers: [EntityMapperService]
})
export class EntityModule {
}
Leider ist das kein Erfolg: Nur wenige Zeilen später überprüft Angular gegen ein Set alreadyCollecting
, ob es nicht gerade dabei ist, das angefragte Modul zu laden. Wir erinnern uns: AppModule → SessionModule → EntityModule → SessionModule
– das ist es. Die erstellte Fehlermeldung module 'SessionModule' is imported recursively by the module 'EntityModule'
wird zwar als JavaScript-Error erstellt und geworfen, aber nicht an der Konsole angezeigt. Der App-Startup bleibt stehen. Kein Weg geht drumherum.
Warum die Entscheidung getroffen wurde, die Rekursion an dieser Stelle nicht einfach zu überspringen, sondern den Start hart abzubrechen, kann ich nur mutmaßen. Letztendlich ist es aber absolut das Recht (und sogar die Aufgabe) des Frameworks, solche Designvorgaben an die mit ihm entwickelten Apps machen. Letztendlich ist es in den meisten Fällen auch einfach keine gute Idee, zyklische Abhängigkeiten in seinen Anwendungen zu haben.
Refactoring und Fazit
Daher kommen wir um ein weiteres Refactoring der neuen Session nicht herum. Die Lösung, die nun gewählt werden muss, löst die Überprüfung des Passworts für einen aus der Datenbank geladenen Nutzer vom Entity-Modul mit User
und EntityMapperService
, sodass sie losgelöst für sich im LocalSessionService
durchgeführt werden kann. Dabei kann die Passwortüberprüfung in einem einfachen Modul implementiert werden, dass nur die entsprechende Funktion zur Verfügung stellt und somit sowohl von LocalSessionService
als auch von User
verwendet werden kann.
Somit werden dann tatsächlich alle zirkulären Abhängigkeiten des initialen Neu-Designs aufgelöst und der resultierende Code ist sauber und kommt ohne all die kleinen Eigenheiten der oben beschriebenen Workarounds aus.
Ich hoffe, mit diesem Artikel eine alles in allem verständliche Reise durch das Problem zirkulärer Abhängigkeiten bei JavaScript, TypeScript, Dependency Injection und Angular beschrieben zu haben. Bei jeglicher Art von unverständlichen Ausführungen meinerseits oder gar inhaltlichen Fehlern stehe ich natürlich per Mail zur Verfügung. Ansonsten empfehle ich auch stets einen Blick in das Repository der NGO-DB des Projekts Aam Digital.
Appendix: Sequenzdiagramme
Usual Flows
Local database is present, remote password was not changed.
Correct Password
Wrong Password
Offline Flows
Local Database is present, but we are offline
Correct Password
Wrong Password
We must retry with wait here, as we might be in a situation where the remote password changed and we should actually be able to log in. See these flows for details.
Password Changed
Local Database is present, but we changed the password and password state is inconsistent between local and remote.
Use old password
Works locally but not on the remote.
Use new password
Works on the remote but not locally.
Sync Failed Flows
So the remote session connected, but for some reason other than being offline the sync fails. I don't know how, but this might happen.
Local Login succeeded
Easiest case. Just start the liveSync and hope everything works out eventually. There should be some sync-indicator listening to the sync state to make the user aware that something is going wrong in the background.
Local Login failed
This is most probably a changed password case. However, as the sync failed, we cannot log the user in locally, so we have to keep the login failed. We also don't start a liveSync here, as it confuses the hell out of the UI to be not logged in but have a running (and intermittently failing) liveSync here. We might want to revisit this behavior, though.
Initial Sync Flows
The local database is initial. We must wait for a first sync before we can log anyone in.
Online with correct password
Online with wrong password
Offline
We can't have the local login pending for too long. We also don't want the login explicitly failed (resulting in wrong password messages), so we just switch back to logged off.
Other sync failures
We don't know what to do in this case. We can't have the local login pending forever. We also don't want the login explicitly failed (resulting in wrong password messages), so we just switch back to logged off.
Das Offline-Setup stellt uns vor das folgende Dilemma: Entweder wird die lokale Datenbank stets unauthentifiziert verwendet, oder die Userdaten inklusive gehashtem Passwort sind auch offline verfügbar. Angriffsszenario ist für uns eher ein unbeobachtetes Gerät, auf dem jemand die Daten ausliest, als jemand, der über Dev-Tools auf die (de facto sowieso unverschlüsselte) Datenbank zugreift, um die Daten zu extrahieren. In diesem Szenario muss die starke kryptographische Hashfunktion die Sicherheit der Passwörter gewährleisten. Außerdem ist eine lokale Kopie der Datenbank sowieso nur vorhanden, wenn sich der Nutzer einmal erfolgreich an der entfernten Datenbank angemeldet hat. ↩︎