Kolumne

Die Angular-Abenteuer: Redux mit @ngrx/store und @ngrx/effects in Angular implementieren
Keine Kommentare

Zustandslos und schlank – das sind die Tugenden des wartbaren Weblayers. Wenngleich diese Faustregel für klassische serverseitige Webanwendungen stimmen mag, gilt sie nicht mehr für moderne Single Page Applications. Die sind nämlich in den Ring gestiegen, um die Benutzerfreundlichkeit klassischer Desktopanwendungen ins Web zu bringen.

In puncto Benutzerfreundlichkeit ist es nicht gerade von Vorteil, wenn die Anwendung ständig Daten vom Server nachlädt. Das bedeutet, dass plötzlich eine ganze Menge Daten lokal vorliegt und der Client doch nicht mehr ganz so schlank ausfällt.

Damit das nicht im Chaos mündet, entwickelte man in den letzten Jahren einige Ansätze zur lokalen Zustandsverwaltung. Der populärste ist wohl Redux, der aus der Welt von React stammt. Mit @ngrx/store liegt auch eine Implementierung für Angular vor. Da sie unter anderem von einem Angular Core Team Member entwickelt wurde und auf die in Angular weit verbreiteten Observables setzt, kommt ihr der Stellenwert eines De-facto-Standards zu.

In dieser Ausgabe zeige ich anhand eines Beispiels, warum Redux bzw. @ngrx/store sinnvoll sind und wie sie funktionieren. Den hier besprochenen Quellcode stelle ich wie immer über mein GitHub-Konto zur Verfügung.

Zustandsverwaltung mit Services

Zur Verwaltung von Zuständen gibt uns Angular Services an die Hand. Da sie in ihrem Wertebereich Singletons sind, können sie Daten zentral vorhalten und als Drehscheibe für einzelne Komponenten dienen. Solange die Interaktion mit ihnen wie in Abbildung 1 aussieht, ist alles wunderbar.

Abb. 1: Komponenten und Services

Die Erfahrung hat jedoch gezeigt, dass selbst bei kleineren Anwendungen die Daten eines Service an vielen Stellen der Anwendung benötigt werden. Somit ergibt sich eine verworrene Struktur mit schwer nachvollziehbaren Datenflüssen (Abb. 2).

Abb. 2: Verworrene Struktur

Besonders undurchsichtig wird es, wenn Services und Komponenten sich gegenseitig benachrichtigen, um Daten weiterzugeben. Schnell ergeben sich Zyklen, die die Performance negativ beeinflussen und im schlimmsten Fall die Anwendung einfrieren. Da jeder Service seinen eigenen Ausschnitt des Anwendungszustands hat, kommt es auch zu Redundanzen, die auseinanderlaufen und zu Inkonsistenzen führen. Im Fehlerfall kann sich dann auch niemand erklären, wie es der Benutzer geschafft hat, den jeweiligen Anwendungszustand herbeizuführen. Genau deswegen ist in den letzten Jahren in der React-Community das Redux-Muster entstanden.

Mehr zum Thema: Angular in einer Microservices-Welt

Redux

Das Muster Redux sieht vor, dass der gesamte Anwendungszustand von einem globalen Store verwaltet wird. Dieser lässt sich mit einer einfachen In-Memory-Datenbank im Browser vergleichen (Abb. 3).

Abb. 3: Redux

Der Store verwaltet den Anwendungszustand in einem Objektbaum. In erster Näherung könnte man sich die Wurzel als Datenbankschema und die Knoten in der Ebene darunter als Tabellen vorstellen. Dieser Zustandsbaum ist unveränderbar (immutable), was bedeutet, dass bei jeder Änderung die betroffenen Objekte zu tauschen sind. Somit kann der Store durch Vergleich der Objektreferenzen herausfinden, welche Knoten sich geändert haben, ohne sämtliche Eigenschaften zu überwachen. Das führt zu einer besseren Performance bei der Änderungsverfolgung.

Da beim Lesen nichts zu Bruch gehen kann, gewährt Redux jedem Systembestandteil einen direkten Lesezugriff. Dazu bietet die hier betrachtete Implementierung @ngrx/store Observables an, die den Interessenten bei Datenänderungen informieren. Direkt schreiben dürfen die einzelnen Komponenten jedoch nicht. Hier wäre die Gefahr von Inkonsistenzen und Redundanzen zu groß. Stattdessen senden sie lediglich Actions an den Store. Grundsätzlich könnte man sich eine Action wie den Aufruf einer Stored Procedure vorstellen. Sie beinhaltet einen Typ, der sich mit dem Namen der Stored Procedure vergleichen lässt, und eine Payload mit Parametern.

Lesen Sie auch: Angular 6: Das sind die neuen Features

Zum Abarbeiten der Actions finden sich im Store Reducer. Dabei handelt es sich um Funktionen, die für einen bestimmten Ast des Zustandsbaums verantwortlich sind. Um Zyklen zu vermeiden, erhält jeder Reducer jede vom Store empfangene Action und kann mit den darin zu findenden Informationen seinen Teil des Zustandsbaums auf den neuesten Stand bringen. Natürlich wird nicht jeder Reducer auf alle Actions reagieren, sondern zunächst einmal ermitteln, ob die aktuelle Action für ihn überhaupt relevant ist.

Reducer laufen immer synchron ab. Für asynchrone Nebeneffekte kommt das Schwesterprojekt @ngrx/effects zum Einsatz. Actions können damit außerhalb des Stores asynchrone Operationen, Effects, anstoßen. Sobald diese fertig sind, senden sie eine weitere Action an den Store. Somit signalisieren sie den Erfolg samt Ergebnis oder einen Fehlerzustand.

ngrx einrichten

Um die Umsetzung des Redux-Musters mit @ngrx/store zu veranschaulichen, verwende ich eine Beispielanwendung zum Laden von Flügen. Diese basiert auf den Nrwl Extensions for Angular, kurz Nx. Dabei handelt es sich unter anderem um einen Codegenerator, der auf Basis des Angular CLI bei der Entwicklung großer Angular-Anwendungen unterstützt. Die Dokumentation auf der offiziellen Website veranschaulicht, wie sich mit Nx ein neues Projekt erzeugen lässt. Ist erst einmal alles eingerichtet, kann man mit dem CLI zum AppModule der Anwendung @ngrx/store hinzufügen:

ng generate ngrx app --module=apps/flight-app/src/app/app.module.ts --root

Zusätzlich klinkt man @ngrx/store auch in die einzelnen Feature Modules ein. Hierzu kommt dieselbe CLI-basierte Anweisung ohne den Schalter root zum Einsatz:

ng generate ngrx flight-booking --module=apps/flight-app/src/app/flight-booking/flight-booking.module.ts

Zu jedem Modul, das auf diese Weise für den Einsatz mit @ngrx/store vorbereitet wird, fügt das CLI einen Ordner +state mit Dateien für die oben beschriebenen Building Blocks wie Actions oder Reducer hinzu (Abb. 4).

Abb. 4: Generierter Ordner mit Building Blocks

Daneben importiert das CLI die für den Betrieb von @ngrx/store notwendigen Module in die angeführten Feature Modules sowie ins AppModule.

Building Blocks implementieren

Nachdem Nx für die einzelnen Building Blocks Dateien angelegt hat, gilt es, diese mit Leben zu füllen. Zuerst stellt man sich dazu die Frage, wie sich der Zustand des Feature Modules gestaltet. Da wir Flüge laden wollen, liegt es nahe, im Zustand ein Array dafür vorzusehen. Deswegen erhält das Interface FlightBooking in der Datei flight-booking.state.ts eine entsprechende Eigenschaft:

export interface FlightBooking {
flights: Flight[];
}

Außerdem sieht der Codegenerator in derselben Datei ein Interface FlightBookingState vor:

export interface FlightBookingState {
readonly flightBooking: FlightBooking;
}

Dieses Interface repräsentiert jenen Teil des State Trees, der auf den Zustand des Modules verweist, und wird nicht verändert.

Um sich Prüfungen gegen null und undefined zu ersparen, erhält die Datei flight-booking.init.ts als Initialzustand ein leeres Array:

export const flightBookingInitialState: FlightBooking = {
flights: []
};

Als Nächstes steht die Frage im Raum, welche Aktionen man mit diesen Daten ausführen möchte. All diese Aktionen werden in der Datei flight-booking.action.ts als Klassen eingerichtet (Listing 1).

export const FlightsLoadedActionType = 'FLIGHTS_LOADED';
export const FlightsLoadActionType = 'FLIGHTS_LOAD';

export class FlightsLoadedAction {
  readonly type = FlightsLoadedActionType;
  constructor(readonly flights: Flight[]) {
  }
}

export class FlightsLoadAction {
  readonly type = FlightsLoadActionType;
  constructor(readonly from: string, readonly to: string, 
                                     readonly  urgent: boolean ) {
  }
}

export type FlightBookingAction = FlightsLoadedAction | FlightsLoadAction;

Gerade asynchrone Operationen, wie das Laden von Flügen, wollen durch mehrere Actions repräsentiert sein. Beispielsweise fordert die FlightsLoadAction das Laden von Flügen an, während die FlightsLoadedAction anzeigt, dass dieser Vorgang erfolgreich war, und dem Store die ermittelten Flüge übergibt. Zusätzlich bräuchte man noch eine Action, die auf einen Fehler beim Laden hinweist. Aus Platzgründen verzichten wir hier darauf.

Der Reducer, der sich um das Abarbeiten der Actions kümmert, kommt in der Datei flight-booking.reducer.ts (Listing 2).

export function flightBookingReducer(state: FlightBooking, action: FlightBookingAction): FlightBooking {
  switch (action.type) {
    case FlightsLoadedActionType: {
      // Laden abgeschlossen; 
      // Daten liegen vor
      return { flights: action.flights }
    }
    case FlightsLoadActionType: {
      // Starte Laden
      return { flights: [] }
    }
    default: {
      return state;
    }
  }
}

Interessiert sich diese Funktion für die jeweils empfangene Action, erzeugt sie damit einen neuen State und liefert ihn zurück. Ansonsten retourniert sie den aktuellen State ohne Änderung. Wichtig ist, dass der State nicht verändert werden darf, da er bei Redux per Definition immutable ist. Stattdessen erzeugt der Reducer jeweils ein neues State-Objekt.

Beim Zweig für das Laden fällt auf, dass lediglich das Array mit dem Suchergebnis zurückgesetzt wird. Da Reducer immer synchron arbeiten, findet das asynchrone Laden außerhalb des Stores in einem Effect statt. Dieser ist in der Datei flight-booking.effects.ts zu ergänzen (Listing 3).

@Injectable()
export class FlightBookingEffects {

  constructor(
    private flightService: FlightService,
    private actions$: Actions) { }

  @Effect() flightsLoad$ 
    = this
      .actions$
      .ofType(FlightsLoadActionType)
      .pipe(
        map(a => a as FlightsLoadAction),
        switchMap(a => this.flightService.find(a.from, a.to, a.urgent)),
        map(flights => new FlightsLoadedAction(flights))
      );

}

Der verwendete Service lässt sich einen FlightService für die Abarbeitung der Effects injizieren. Auf demselben Wege erhält er auch ein Actions-Objekt. Dabei handelt es sich um ein Observable, das sämtliche Aktionen empfängt, die auch an den Store gesendet werden. Daraus gilt es nun, weitere Observables abzuleiten, die sich um die Ausführung der Effects kümmern. Diese sind in Eigenschaften mit dem Dekorator Effect abzulegen und werden automatisch von @ngrx/effects entdeckt.

Da sich dieses Observable nur für Actions mit dem FlightsLoadActionType interessiert, filtert es mit ofType die empfangenen Nachrichten. Der Operator map kümmert sich ums Downcasting nach FlightsLoadAction, und switchMap delegiert an den FlightService. Der Operator switchMap kommt zum Einsatz, weil find ein neues Observable liefert, auf das zu wechseln ist. Dieses transportiert ein Flight-Array, das map in eine FlightsLoadedAction umgewandelt. Diese Action sendet @ngrx/effects an den Store, wo sich der gezeigte Reducer darum kümmert.

Auf Store zugreifen

Die gute Nachricht vorweg: Den schwierigen Teil haben wir hinter uns gebracht. Jetzt müssen die einzelnen Komponenten den Store nur noch konsumieren. Dazu injiziert man den Store zunächst in die Komponenten der Wahl:

constructor(private store: Store<FlightBookingState>) { ... }

Danach überlegt man sich, welche Ausschnitte aus dem Store man benötigt, und erzeugt dafür Eigenschaften vom Typ Observable:

flights$: Observable<Flight[]>;

Die Methode ngOnInit der Komponente bietet sich an, um die gewünschten Teile aus dem Store abzurufen:

ngOnInit() {
this.flights$ = this.store.select(s => s.flightBooking.flights);
}

Die so erhaltenen Observables lassen sich entweder manuell abonnieren oder mit der async-Pipe ans Template binden:

<div *ngFor="let f of flights$ | async">...</div>

Zusätzlich gilt es noch, in den Event Handlern die entsprechenden Actions an den Store zu senden. Hierfür bietet er die Methode dispatch an:

this.store.dispatch(new FlightsLoadAction(this.from, this.to, this.urgent));

Wie die letzten Ausführungen zeigen, sind Komponenten beim Einsatz von Redux häufig sehr schlank. In erster Linie delegieren sie an den Store.

Bewertung und Fazit

Die Nutzung von @ngrx/store und @ngrx/effects zwingt uns ein strukturiertes Vorgehen auf. Ausgehend vom Zustandsbaum über Actions und Reducer beziehungsweise Effects hangelt man sich zu den Komponenten vor. Redux bringt eine sehr genaue Vorstellung darüber mit, wie die Welt zu funktionieren hat, und lenkt daher das Entwicklungsteam. Es gibt also wenig Diskussionen darüber, wo etwas hingehört. Das trifft sicher nicht den Geschmack jedes Entwicklers, erweist sich bei größeren Teams jedoch als vorteilhaft.

Zyklen vermeidet Redux, indem es alle Aktionen an jeden Reducer sendet. Somit können die unterschiedlichen Anwendungsteile in einem Atemzug auf ein Ereignis reagieren. Ereigniskaskaden sind somit weitgehend unnötig. Trotz eines zentralen Zustands sind die einzelnen Actions, Reducer und Effects unabhängig voneinander und lassen sich in jeweils eigenen Dateien verstauen.

Mit den richtigen Werkzeugen verbessert sich sogar die Nachvollziehbarkeit: Kommt der Store durchgängig zum Einsatz, ergibt sich der aktuelle Anwendungszustand aus der Folge der einzelnen Aktionen. Protokolliert die Anwendung diese, lässt sich leicht herausfinden, wie der Benutzer einen bestimmten Fehlerzustand herbeigeführt hat. Unterstützend greifen sogenannte zeitreisende Debugger ein, die ausgewählte Aktionen rückgängig machen und es erlauben, die durchgeführten Aktionen nochmals abzuspielen. Ein Beispiel dafür sind die Redux DevTools, die als Chrome-Plug-in zur Verfügung stehen (Abb. 5).

Abb. 5: Zeitreisender Debugger mit Redux DevTools

Da @ngrx/store und @ngrx/effects mit Angular im Hinterkopf entworfen wurden, passen sie wunderbar in die Welt von Angular und berücksichtigen dort vorherrschende Konzepte wie Module, Lazy Loading und Services. Da Immutables und Observables konsequent zum Einsatz kommen, lässt sich bei den Komponenten der Optimierungsmodus OnPush aktivieren. Dieser führt zu einer stark verbesserten Datenbindungsperformance.

Wo so viel Licht ist, gibt es natürlich auch Schatten. Ein Manko ist, dass die Arbeit mit Immutables gewöhnungsbedürftig ist. Ein anderer Nachteil ist, dass die zusätzlichen Building Blocks die Komplexität der Anwendung erhöhen. Glücklicherweise gibt es Codegeneratoren wie Nx, die diesen Umstand kompensieren.

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 -