Teil 2: Zustandsverwaltung mit NgRx und Fassaden

Domain-Layer für den Projektstart mit Angular und NgRx
Keine Kommentare

Fassaden bereiten die Möglichkeiten einer Domäne für einzelne Anwendungsfälle auf und verbergen Details der Zustandsverwaltung. So wird der Projektstart mit Angular unter Verwendung von NgRx vereinfacht.

Jeder Entwickler und jede Entwicklerin dürfte dieses Problem kennen: Nach einer Änderung an einer Stelle funktioniert plötzlich eine ganz andere nicht mehr. Der Grund für dieses „Kaputt erweitern“ ist häufig eine zu starke Verknüpfung der einzelnen Systembestandteile. Wer wartbare Softwaresysteme möchte, muss also Entkopplung anstreben.

Domain-driven Design, genauer gesagt dessen Disziplin Strategic Design, bietet hierfür eine Lösung: Es unterteilt Anwendungen in (Sub-)Domänen, die so wenig wie möglich voneinander wissen müssen.

International JavaScript Conference

Effective Microservices Architecture In Node.js

by Tamar Stern (Palto Alto Networks)

React Components And How To Style Them

by Jemima Abu (Telesoftas)

API Summit 2020

API-Design? Schon erledigt! – Einführung in JSON:API

mit Thilo Frotscher (Freiberufler)

Praktischer Einstieg in Go

mit Frank Müller (Loodse GmbH)

Zur Organisation der einzelnen Domänen bieten sich hexagonale Architekturen oder auch Layer an. Der erste Artikel dieser dreiteiligen Serie hat für das Frontend unter anderem zwei Arten von fachlichen Layern vorgeschlagen: Der Featurelayer beinhaltet fachliche UI-Komponenten, die den Benutzer durch einen Anwendungsfall leiten. Der Domain-Layer hingegen enthält die Domänenlogik.

Den Domainlayer wollen wir in diesem Artikel anhand einer Angular-basierten Fallstudie etwas näher betrachten. Den Quellcode gibt es wie immer in meinem GitHub-Account.

Artikelserie

  • Teil 1: Domänenschnitt mit Nx Monorepos
  • Teil 2: Zustandsverwaltung mit NgRx und Fassaden
  • Teil 3: OAuth 2.0 und die neuen Security Best Practices

Domänenlogik

Es lässt sich vortrefflich darüber streiten, ob ein Frontend überhaupt Domänenlogik beinhaltet. Schließlich hat es sich ja bewährt, diese im Backend zu kapseln. Auf der anderen Seite sind moderne SPAs aber auch mehr als bloße Empfänger von Datentransferobjekten. Gerade wenn State-Management-Lösungen im Spiel sind, sammeln sich nach und nach umfangreiche Modelle an, die nicht selten anwendungsfallübergreifend zum Einsatz kommen.

Diese Modelle unterliegen bestimmten Regeln, und manche darauf basierende Berechnungen gilt es zur Verbesserung der Benutzerfreundlichkeit auch schon vorab im Frontend zu erledigen. Genau um diese Aspekte geht es hier.

Um zu verhindern, dass sich Domänenlogik mit anderen Aspekten wie Anwendungsfällen oder Datenzugriff vermengt, hat es sich eingebürgert, sie zu isolieren (Abb. 1).

Abb. 1: Domänenbibliothek

Abb. 1: Domänenbibliothek

Während die eigentliche Domänenschicht in der Mitte die angesprochenen Modelle und die darauf anzuwendenden Logiken enthält, bietet der Application-Layer anwendungsfallspezifische Fassaden an. Diese Fassaden, die auch Details des Zustandsmanagements verbergen, korrelieren nicht nur mit den Application Services in DDD, sondern sind auch unabhängig davon in der letzten Zeit im Angular-Umfeld sehr beliebt. Die Kommunikation mit dem Backend, aber auch mit den Browser-APIs, übernimmt die Infrastrukturschicht.

Die Modelle der Domänenschicht sind in der objektfunktionalen Welt von TypeScript in der Regel blutarm, um einen – nicht gerade wertschätzenden – Begriff aus der DDD-Community zu benutzen, die traditionell eine stark objektorientierte Sichtweise hat. Das bedeutet, dass es sich bei diesen Modellen lediglich um Datencontainer ohne Methoden handelt (Listing 1).

export interface Flight {
  readonly id: number;
  readonly from: string;
  readonly to: string;
  readonly date: string; 
  readonly delayed: boolean;
}

Um zu verhindern, dass trotz öffentlicher Eigenschaften Inkonsistenzen entstehen, kommt der Modifier readonly zum Einsatz. Hat die Anwendung ein Modell also einmal erfolgreich validiert, kann es davon ausgehen, dass es valide bleibt.

Die Logiken für diese Modelle befinden sich in eigenen Funktionen:

export function getOfficialAirportName(city: string, format: CityFormat) { […] }

Application Service aka Fassade

Die vom Application-Layer gebotenen Fassaden sind einfache Services, die die Modelle und Logiken für einen bestimmten Anwendungsfall aufbereiten (Listing 2).

@Injectable({ 
  providedIn: 'root'
})
export class FlightFacade {

  private flightsSubject = new BehaviorSubject<Flight[]>([]);
  public flights$ = this.flightsSubject.asObservable();

  constructor(private flightService: FlightService) {
  }

  search(from: string, to: string, urgent: boolean): void {
    this.flightService.find(from, to, urgent).subscribe(
      flights => {
        this.flightsSubject.next(flights)
      },
      err => {
        console.error('err', err);
      }
    );
  }
}

Zur Verwaltung des Zustandes kommt hier ein als Observable veröffentlichtes BehaviorSubject zum Einsatz. Dabei handelt es sich um eine sehr einfache reaktive Lösung. „Reaktiv“ bedeutet hier, dass das Observable Angular benachrichtigt, wenn sich die Daten ändern. Angular muss die Datenstruktur somit nicht mehr ständig auf Änderungen prüfen, das wirkt sich positiv auf die Performance aus.

Diese einfache Lösung ist zu bevorzugen, solange sie nicht zu schwer nachvollziehbaren Zugriffen wie in Abbildung 2 führt.

Abb. 2: Zyklen

Abb. 2: Zyklen

Es ist offensichtlich, dass solche Zugriffspfade zu Inkonsistenzen und zyklischen Abhängigkeiten führen. Letztere verschlechtern die Nachvollziehbarkeit, aber auch die Performance. Bibliotheken zur Verwaltung von Zuständen versprechen eine Lösung für dieses Dilemma. Die wohl populärste in der Welt von Angular ist NgRx. Der nächste Abschnitt geht darauf etwas genauer ein.

Zustandsverwaltung

Zur zentralen Verwaltung des Anwendungszustandes bietet NgRx einen Store, den man als In-Memory-Datenbank für die gesamte Anwendung sehen kann (Abb. 3).

Abb. 3: Bibliothek zur Verwaltung von Zuständen

Abb. 3: Bibliothek zur Verwaltung von Zuständen

Somit ergibt sich eine sogenannte Single Source of Truth, die die Redundanzen und Inkonsistenzen verhindert. Da sämtliche gespeicherten Zustände als Observables bezogen werden, ergibt sich automatisch ein reaktives System. Angular profitiert davon bei der Datenbindung. Statt gebundene Daten ständig auf Änderungen prüfen zu müssen, wird das System nun durch die Observables über Änderungen auf dem Laufenden gehalten.

Die Tatsache, dass sämtliche verwalteten Zustände unveränderbar (immutable) sind, sorgt ebenfalls für eine bessere Performance. Ändern sich Daten, ersetzt die Anwendung das betroffene Objekt durch einen Klon. Deswegen muss Angular nur noch Objektreferenzen vergleichen, um herauszufinden, was sich geändert hat.

Der Einsatz von Immutables ist auch im Zusammenspiel mit Observables sinnvoll: Obwohl das Observable darauf hinweist, dass sich Daten einer Komponente geändert haben, muss Angular herausfinden, welche Kindkomponenten betroffen sind. Hierfür bietet sich der Vergleich von Objektreferenzen an.

NgRx lässt sich bequem über ng add für eine Anwendung einrichten:

ng add @ngrx/store --project flight-app
ng add @ngrx/store-devtools --project flight-app
npm i @ngrx/schematics

Die Bibliothek @ngrx/store kümmert sich um das Verwalten des Zustandes und @ngrx/store-devtools bietet während der Entwicklung eine Brücke zu Chrome-Plug-ins für das Debugging. Die Bibliothek @ngrx/schematics erweitert die CLI um Codegeneratoren, sogenannte Schematics, für NgRx.

Das Schematic feature stellt das Grundgerüst zur Zustandsverwaltung für einen Bereich der Anwendung zur Verfügung:

ng g @ngrx/schematics:feature +state/flight -m booking-domain.module.ts --project booking-domain --creators --api

In unserer Architektur handelt es sich bei diesen Bereichen um die einzelnen Domain-Bibliotheken: Jede Domäne verwaltet ihren eigenen Zustand.

Verändern von Zuständen mit NgRx

Den Zustand der Domäne repräsentiert ein Interface namens FlightBookingState, dessen Grundgerüst vom Schematic feature generiert wurde (Listing 3).

export interface FlightBookingState {
  flights: Flight[],
}

export const initialState: FlightBookingState = {
  flights: [],
};

export const flightBookingFeatureKey = 'flightBooking';

export interface FlightBookingAppState {
  [flightBookingFeatureKey]: FlightBookingState;
}

Der initialState beinhaltet Standardwerte, um zu verhindern, dass später gegen null und undefined geprüft werden muss. Da NgRx den Zustand aller Domänen in einem Zustandsbaum (State Tree) verwaltet, muss dessen Wurzel aus Sicht unserer Domäne modelliert werden. Zu diesem Zweck kommt der FlightBookingAppState zum Einsatz. Wichtig ist, im Hinterkopf zu behalten, dass dieses Interface tatsächlich nur die Sicht der betroffenen Domäne verkörpert und sich deswegen auf eine einzige Eigenschaft, die zum Domänenzustand führt, beschränkt.

Könnten mehrere Komponenten den Zustand direkt verändern, ergäbe sich trotz globalem Zustandsbaum die Gefahr von Inkonsistenzen und Redundanzen. Aus diesem Grund sieht NgRx an dieser Stelle eine Indirektion vor: Jede Komponente darf lediglich Actions an den Store senden, der Store stößt daraufhin wohldefinierte Funktionen an, die sich um die Änderung kümmern.

Die Actions kann man ein wenig mit Ereignissen vergleichen. Zur Identifizierung weisen sie einen String-basierten Typ auf und transportieren die für die Änderung notwendigen Informationen, wie hier im Folgenden gezeigt:

export const loadFlightsSuccess = createAction(
  '[Flight] Load Flights Success', // Typ
  props<{ flights: Flight[] }>() // Nutzdaten
);

Die im Store registrierten Funktionen lassen sich mit Ereignisbehandlungsroutinen vergleichen. Sie nehmen die Action sowie den aktuellen Zustand entgegen und liefern den neuen Zustand zurück. Da sie die übergebenen Informationen zum neuen Zustand reduzieren, ist auch von Reducern die Rede (Listing 4).

const flightReducer = createReducer(
  initialState,

  on(FlightActions.loadFlightsSuccess, (state, action) => {
    const flights = action.flights;
    return { ...state, flights };
  }),
}

Der hier betrachtete Reducer nimmt zum einen den Initialzustand entgegen und weist zum anderen eine Funktion zur Behandlung der Action loadFlightsSuccess auf. Da der Zustand, wie oben erwähnt, unveränderbar ist, klont ihn der Reducer mit dem Spread-Operator (drei Punkte) und verändert dabei die Eigenschaft flights.

Store in Fassade nutzen

Zur Verwaltung des Anwendungszustandes bietet NgRx einen Store an. Dieser lässt sich nun in die Fassade injizieren (Listing 5).

@Injectable({ providedIn: 'root' })
export class FlightFacade {

    public flights$ = this.store.select(s => s.flightBooking.flights);

    constructor(private store: Store<FlightBookingAppState>, private flightService: FlightService) {
    }

    search(from: string, to: string, urgent: boolean): void {
      this.flightService.find(from, to, urgent).subscribe(
        flights => {
          this.store.dispatch(loadFlightsSuccess({ flights }));
        },
        err => {
          console.error('err', err);
        }
      );
    }
}

Der Store gibt der Fassade die Möglichkeit, mit select einzelne Teile des verwalteten Zustands als Observables zu beziehen und mit dispatch Actions auszulösen.

Durch diese Vorgehensweise verwaltet die Anwendung den gesamten Zustand an einer zentralen Stelle. Aus Sicht der konsumierenden Komponente ändert sich jedoch nichts: Sie hat nach wie vor die Möglichkeit, Zustände über Observables zu lesen und Zustandsänderungen durch den Aufruf von Methoden zu veranlassen.

Zustandsverwaltungsbibliotheken lassen sich durch dieses Vorgehen bei Bedarf einführen. Da sie, wie die Beispiele veranschaulichen, einige nicht gerade triviale Building-Blocks mit sich bringen, ist das auch eine gute Idee.

Fazit und Ausblick

Domänenbibliotheken kümmern sich um anwendungsfallübergreifende Logiken. Fassaden, die sich in der letzten Zeit im Angular-Umfeld großer Beliebtheit erfreuen, bereiten diese Möglichkeiten für einzelne Anwendungsfälle auf und verstecken die Details der Zustandsverwaltung. Die an einem Anwendungsfall beteiligten Komponenten kommunizieren nur noch mit jeweils einer Fassade, die eine möglichst einfache Schnittstelle bietet.

Zustandsverwaltungsbibliotheken wie das populäre NgRx helfen bei der Zentralisierung des gesamten Anwendungszustandes. Hierdurch sollen Redundanzen und Inkonsistenzen vermieden werden. Allerdings steigert NgRx mit allen seinen Konzepten auch die Komplexität des Codes. Deswegen sollte sein Einsatz gut durchdacht sein. Ist sich ein Entwicklungsteam nicht sicher, bietet es sich an, mit einer Fassade zu starten und NgRx erst später, wenn sich der Bedarf dafür herauskristallisiert, hinter der Fassade einzuführen.

Windows Developer

Windows DeveloperDieser Artikel ist im Windows Developer erschienen. Windows Developer informiert umfassend und herstellerneutral über neue Trends und Möglichkeiten der Software- und Systementwicklung rund um Microsoft-Technologien.

Natürlich können Sie den Windows Developer über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. Außerdem ist der Windows Developer weiterhin als Print-Magazin im Abonnement erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -