Navigation

Stabile Sessions für offline-fähige Web-Apps mit PouchDB

Oder: Der ewige Kreis – über zirkuläre Abhängigkeiten in Angular

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

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.

#direction:down [EntityMapperService|private _db: Database|public load(entityType, id): Promise;public loadType(entityType, id): Promise; public save(entity: T, forceUpdate: boolean): Promise] [SessionService|public onSessionStatusChanged: EventEmitter|public isLoggedIn(): boolean;public getCurrentUser(): User;public login(username: string, password: string): Promise;public logout()] [DatabaseManagerService|public onSyncStatusChanged: EventEmittery|abstract login(username, password): Promise; abstract logout(); abstract getDatabase()|DatabaseProvider] [Database||abstract get(id: string): Promise;abstract allDocs(options?): Promise;abstract put(object, forceUpdate?: boolean): Promise;abstract remove(object): Promise;abstract query(fun, options?): Promise;abstract saveDatabaseIndex(designDoc): Promise] [Entity|static ENTITY_TYPE = 'Entity';private readonly entityId: string|constructor(id: string);public getId(): string;public getType(): string;public load(data)] [User|public name: string; private password|public setNewPassword(password: string);public checkPassword(givenPassword: string): boolean] [DatabaseManagerServiceProvider] [DatabaseProvider] [EntityMapperService]-[DatabaseProvider] [DatabaseProvider]->[Database] [SessionService]->[EntityMapperService] [SessionService]->[DatabaseManagerService] [EntityMapperService]-->[Entity] [User]--:>[Entity] [DatabaseManagerService]-->[Database] [PouchDatabase]--:>[Database] [MockDatabase]--:>[Database] [PouchDatabaseManagerService]--:>[DatabaseManagerService] [MockDatabaseManagerService]--:>[DatabaseManagerService] [MockDatabaseManagerService]-->[MockDatabase] [PouchDatabaseManagerService]-->[PouchDatabase] [SessionService]-->[User] [DatabaseManagerServiceProvider]->[PouchDatabaseManagerService] [DatabaseManagerServiceProvider]->[MockDatabaseManagerService]

Design-Probleme

Leider kommt dieses Design mit einer Reihe von Problemen:

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| [st]->[loggedOut] [loggedOut]->[loggedIn] [loggedOut]->[loginFailed] [loggedIn]->[loggedOut] [loginFailed]->[loggedIn] [loginFailed]->[loginFailed] ] [ConnectionState| [st]->[disconnected] [disconnected]->[offline] [disconnected]->[connected] [disconnected]->[rejected] [connected]->[offline] [offline]->[connected] [offline]->[disconnected] [offline]->[rejected] [connected]->[disconnected] [rejected]->[offline] [rejected]->[disconnected] ] [SyncState| [st]->[unsynced] [unsynced]->[started] [started]->[completed] [started]->[failed] [completed]->[started] [failed]->[started] ]

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.

[StateChangedEvent| fromState: StateEnum; toState: StateEnum ] [StateHandler| private state: StateEnum; private stateChangedStream: EventEmitter>| constructor(defaultState?: StateEnum); getState(): StateEnum; setState(state: StateEnum): StateHandler; getStateChangedStream(): EventEmitter>; waitForChangeTo(toState: StateEnum, failOnState?: StateEnum): Promise ] [StateChangedEvent]<--[StateHandler]

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:

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.

#direction:down [EntityMapperService|private _db: Database|public load(entityType, id): Promise;public loadType(entityType, id): Promise; public save(entity: T, forceUpdate: boolean): Promise] [Database||abstract get(id: string): Promise;abstract allDocs(options?): Promise;abstract put(object, forceUpdate?: boolean): Promise;abstract remove(object): Promise;abstract query(fun, options?): Promise;abstract saveDatabaseIndex(designDoc): Promise] [Entity|static ENTITY_TYPE = 'Entity';private readonly entityId: string|constructor(id: string);public getId(): string;public getType(): string;public load(data)] [User|public name: string; private password|public setNewPassword(password: string);public checkPassword(givenPassword: string): boolean] [DatabaseProvider] [SessionService||public login(username: string, password: string): Promise;public logout(); public getDatabase(): Database; public isLoggedIn(): boolean; public getCurrentUser(): User; public getLoginState(): StateHandler; public getConnectionState(): StateHandler; public getSyncState(): StateHandler|DatabaseProvider] [LocalSessionService|public database: PouchDB; public loginState: StateHandler; public syncState: StateHandler; public currentUser: User|public login(username: string, password: string): Promise; public logout(); public sync(remoteDB: PouchDB): Promise; public waitForFirstSync(): Promise; public isInitial(): Promise] [RemoteSessionService|public database: PouchDB; public connectionState: StateHandler|public login(username: string, password: string): Promise; public logout()] [EntityMapperService]-[DatabaseProvider] [DatabaseProvider]->[Database] [LocalSessionService]->[EntityMapperService] [EntityMapperService]->[SessionService] [SessionService]->[LocalSessionService] [SessionService]->[RemoteSessionService] [EntityMapperService]-->[Entity] [User]--:>[Entity] [SessionService]-->[Database] [LocalSessionService]-->[User]

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:

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:

  1. 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.
  2. 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:

  1. Alle benötigten Objekte instanziieren.
    Welche das sind, kriegt man über einen Abhängigkeitsgraph heraus.
  2. Alle benötigten Abhängigkeiten setzen.
  3. 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:

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.prototype.getNgModuleMetadata 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.

#direction:down [EntityMapperService|private _db: Database|public load(entityType, id): Promise;public loadType(entityType, id): Promise; public save(entity: T, forceUpdate: boolean): Promise] [Database||abstract get(id: string): Promise;abstract allDocs(options?): Promise;abstract put(object, forceUpdate?: boolean): Promise;abstract remove(object): Promise;abstract query(fun, options?): Promise;abstract saveDatabaseIndex(designDoc): Promise] [Entity|static ENTITY_TYPE = 'Entity';private readonly entityId: string|constructor(id: string);public getId(): string;public getType(): string;public load(data)] [User|public name: string; private password|public setNewPassword(password: string);public checkPassword(givenPassword: string): boolean] [DatabaseProvider] [validatePassword] [SessionService||public login(username: string, password: string): Promise;public logout(); public getDatabase(): Database; public isLoggedIn(): boolean; public getCurrentUser(): User; public getLoginState(): StateHandler; public getConnectionState(): StateHandler; public getSyncState(): StateHandler|DatabaseProvider] [LocalSessionService|public database: PouchDB; public loginState: StateHandler; public syncState: StateHandler; public currentUser: User|public login(username: string, password: string): Promise; public logout(); public sync(remoteDB: PouchDB): Promise; public waitForFirstSync(): Promise; public isInitial(): Promise] [RemoteSessionService|public database: PouchDB; public connectionState: StateHandler|public login(username: string, password: string): Promise; public logout()] [EntityMapperService]-[DatabaseProvider] [DatabaseProvider]->[Database] [EntityMapperService]->[SessionService] [SessionService]->[LocalSessionService] [SessionService]->[RemoteSessionService] [EntityMapperService]-->[Entity] [User]--:>[Entity] [SessionService]-->[Database] [LocalSessionService]-->[validatePassword] [User]-->[validatePassword]

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

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loggedIn LocalSession-->>-SyncedSession: LoginState.loggedIn SyncedSession-->>User: LoginState.loggedIn RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: connected Note over RemoteSession: ConnectionState.connected RemoteSession-->>-SyncedSession: ConnectionState.connected SyncedSession->>+SyncedSession: sync Note over LocalSession: SyncState.started SyncedSession-->>-SyncedSession: SyncState.completed Note over LocalSession: SyncState.completed Note over SyncedSession: liveSyncDeferred deactivate Session deactivate User

Wrong Password

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loginFailed LocalSession-->>-SyncedSession: LoginState.loginFailed SyncedSession-->>User: LoginState.loginFailed RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: rejected Note over RemoteSession: ConnectionState.rejected RemoteSession-->>-SyncedSession: ConnectionState.rejected deactivate Session deactivate User

Offline Flows

Local Database is present, but we are offline

Correct Password

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loggedIn LocalSession-->>-SyncedSession: LoginState.loggedIn SyncedSession-->>User: LoginState.loggedIn RemoteSession->>+Remote PouchDB: login Note over RemoteSession,Remote PouchDB: Offline Detection via fetch Remote PouchDB-->>-RemoteSession: failed Note over RemoteSession: ConnectionState.offline RemoteSession-->>-SyncedSession: ConnectionState.offline SyncedSession->>SyncedSession: login Note over SyncedSession: retry with wait deactivate Session deactivate User

Wrong Password

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loginFailed LocalSession-->>-SyncedSession: LoginState.loginFailed SyncedSession-->>User: LoginState.loginFailed RemoteSession->>+Remote PouchDB: login Note over RemoteSession,Remote PouchDB: Offline Detection via fetch Remote PouchDB-->>-RemoteSession: failed Note over RemoteSession: ConnectionState.offline RemoteSession-->>-SyncedSession: ConnectionState.offline SyncedSession->>SyncedSession: login Note over SyncedSession: retry with wait deactivate Session deactivate User

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.

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loggedIn LocalSession-->>-SyncedSession: LoginState.loggedIn SyncedSession-->>User: LoginState.loggedIn RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: rejected Note over RemoteSession: ConnectionState.rejected RemoteSession-->>-SyncedSession: ConnectionState.rejected SyncedSession->>LocalSession: logout + fail Note over LocalSession: LoginState.loginFailed Note over User,SyncedSession: Login Guard rejects deactivate Session deactivate User

Use new password

Works on the remote but not locally.

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loginFailed LocalSession-->>-SyncedSession: LoginState.loginFailed SyncedSession-->>User: LoginState.loginFailed RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: connected Note over RemoteSession: ConnectionState.connected RemoteSession-->>-SyncedSession: ConnectionState.connected SyncedSession->>+SyncedSession: sync Note over LocalSession: SyncState.started SyncedSession-->>-SyncedSession: SyncState.completed Note over LocalSession: SyncState.completed SyncedSession->>LocalSession: login Note over LocalSession: LoginState.loggedIn Note over User,SyncedSession: Login Guard to passes Note over SyncedSession: liveSyncDeferred deactivate Session deactivate User

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.

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loggedIn LocalSession-->>-SyncedSession: LoginState.loggedIn SyncedSession-->>User: LoginState.loggedIn RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: connected Note over RemoteSession: ConnectionState.connected RemoteSession-->>-SyncedSession: ConnectionState.connected SyncedSession->>+SyncedSession: sync Note over LocalSession: SyncState.started SyncedSession-->>-SyncedSession: SyncState.failed Note over LocalSession: SyncState.failed Note over SyncedSession: liveSyncDeferred deactivate Session deactivate User

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.

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loginFailed LocalSession-->>-SyncedSession: LoginState.loginFailed SyncedSession-->>User: LoginState.loginFailed RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: connected Note over RemoteSession: ConnectionState.connected RemoteSession-->>-SyncedSession: ConnectionState.connected SyncedSession->>+SyncedSession: sync Note over LocalSession: SyncState.started SyncedSession-->>-SyncedSession: SyncState.failed Note over LocalSession: SyncState.failed deactivate Session deactivate User

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

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+LocalSession: waitForFirstSync RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: connected Note over RemoteSession: ConnectionState.connected RemoteSession-->>-SyncedSession: ConnectionState.connected SyncedSession->>+SyncedSession: sync Note over LocalSession: SyncState.started SyncedSession-->>-SyncedSession: SyncState.completed Note over LocalSession: SyncState.completed LocalSession->>-LocalSession: success LocalSession->>+Local PouchDB: get User Local PouchDB-->>-LocalSession: User Note over LocalSession: LoginState.loggedIn LocalSession-->>-SyncedSession: LoginState.loggedIn SyncedSession-->>User: LoginState.loggedIn Note over SyncedSession: liveSyncDeferred deactivate Session deactivate User

Online with wrong password

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+LocalSession: waitForFirstSync RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: rejected Note over RemoteSession: ConnectionState.rejected RemoteSession-->>-SyncedSession: ConnectionState.rejected SyncedSession->>LocalSession: set login and sync failed Note over LocalSession: LoginState.loginFailed Note over LocalSession: SyncState.failed LocalSession->>-LocalSession: failure LocalSession-->>-SyncedSession: LoginState.loginFailed SyncedSession-->>User: LoginState.loginFailed deactivate Session deactivate User

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.

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+LocalSession: waitForFirstSync RemoteSession->>+Remote PouchDB: login Note over RemoteSession,Remote PouchDB: Offline Detection via fetch Remote PouchDB-->>-RemoteSession: rejected Note over RemoteSession: ConnectionState.offline RemoteSession-->>-SyncedSession: ConnectionState.offline SyncedSession->>LocalSession: set sync failed Note over LocalSession: SyncState.failed LocalSession->>-LocalSession: failure Note over LocalSession: LoginState.loggedOff LocalSession-->>-SyncedSession: LoginState.loggedOff SyncedSession-->>User: LoginState.loggedOff Note over SyncedSession: liveSyncDeferred deactivate Session deactivate User

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.

sequenceDiagram participant User participant SyncedSession participant LocalSession participant Local PouchDB participant RemoteSession participant Remote PouchDB activate User User->>+SyncedSession: login SyncedSession->>+LocalSession: login SyncedSession->>+RemoteSession: login LocalSession->>+LocalSession: waitForFirstSync RemoteSession->>+Remote PouchDB: login Remote PouchDB-->>-RemoteSession: connected Note over RemoteSession: ConnectionState.connected RemoteSession-->>-SyncedSession: ConnectionState.connected SyncedSession->>+SyncedSession: sync Note over LocalSession: SyncState.started SyncedSession-->>-SyncedSession: SyncState.failed Note over LocalSession: SyncState.failed LocalSession->>-LocalSession: failure Note over LocalSession: LoginState.loggedOff LocalSession-->>-SyncedSession: LoginState.loggedOff SyncedSession-->>User: LoginState.loggedOff deactivate Session deactivate User

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

Mehr lesen