AUTOR Matthias vom Bruch

Radikale Reaktivität in Angular Teil 3: Fehler

Dieser Artikel ist Teil einer Serie, in der ich versuche, mentale Modelle zu vermitteln, die zu gutem RxJS-Code führen. Ich empfehle, sie der Reihe nach zu lesen. Anhand des Codes einer Demo-Anwendung werden häufige Probleme in RxJS aufgezeigt und Verbesserungsmöglichkeiten vorgeschlagen.

Allgemeine Anmerkung zu RxJS-Fehlern

Die Fehlerbehandlung in RxJS kann knifflig sein, weil Observables bei Fehlern completen. Sobald ein einziger Fehler aufgetreten ist, wird das Observable beendet und produziert keine Werte mehr. Ironischerweise ist dies in naivem Code viel weniger ein Problem, da naiver Code dazu neigt, Observables in dem Moment zu erzeugen, in dem sie benötigt werden. Besserer Code versucht, ein Ereignisnetzwerk aufzubauen, das für immer existieren soll. Wenn aber ein Teil dieses Netzwerks aufgrund von Fehlern ausfällt, hat man ein großes Problem. Aus diesem Grund ist es sehr wichtig, Fehler so früh wie möglich zu behandeln. Versuchen Sie, den grundlegenden Unterschied in den folgenden zwei Codeausschnitten zu erkennen:

// dies
const myData$ = someEventSource$.pipe(
  switchMap(() => http.get<number>("some/random/number")),
  catchError(error => {
    console.error(error);
    return of(DEFAULT_VALUE);
  }),
  map(num => num**2)
);
// lives
const myData$ = someEventSource$.pipe(
  switchMap(() => http.get<number>("some/random/number").pipe(
    catchError(error => {
      console.error(error);
      return of(DEFAULT_VALUE);
    })
  )),
  map(num => num**2)
);

In der ersten Version hört myData$ tatsächlich auf, Werte zu liefern, wenn der HTTP-Aufruf schief geht. Und warum? Weil Operatoren wie switchMap und Co. ein Observable als Input nehmen, es abonnieren (vereinfacht gesagt), etwas mit den Daten machen und ein anderes Observable zurückgeben, welches an den nächsten Operator in der Kette weitergegeben wird. Zwischen jedem Paar von Operatoren gibt es eine implizite Subscription. Wenn ich also sage, dass Observables keine Werte mehr liefern, wenn sie einen Fehler machen, dann gilt das auch für diese dazwischen liegenden Observables. Im ersten Fall wird das Observable, das zwischen switchMap und catchError sitzt, einen Fehler liefern, wenn http.get fehlschlägt. catchError fängt diesen Fehler ab, wandelt ihn in den DEFAULT_VALUE um und gibt ihn weiter, aber jetzt ist seine Quelle tot und der Rest der Pipe von nun an auch. Das zweite Beispiel ist intelligenter, indem es sicherstellt, dass das innere Observable, das von http.get zurückgegeben wird, keinen Fehler mehr machen kann, indem es den Fehler sofort in den DEFAULT_VALUE umwandelt, bevor er die äußere Pipe erreicht. Dieses innere Observable stirbt immer noch, aber bedenken Sie, dass diese http Observables sowieso nur einen einzigen Wert produzieren. Und das innere Observable wird jedes Mal neu erstellt, wenn someEventSource$ ausgelöst wird. Nun, da wir die Grundlagen aus dem Weg geräumt haben, lasst uns sehen, wie man das wirklich gut macht!

Anti-Pattern 2: Dezentralisierte Fehlerbehandlung

Unser dataService ist nur ein dünner Wrapper um einen (imaginären) HTTP-Client. HTTP-Aufrufe können fehlschlagen. Mit Fehlern muss umgegangen werden. Daher sieht praktisch jeder Service-Aufruf etwa so aus:

naiveVersion$().subscribe(
  (data) => handleData(data),
  (error) => handleError(error)
);

Anders sieht es aus, wenn wir eine sehr wichtige Änderung in der Art und Weise vornehmen, wie wir Ereignisse, wie z.B. Benutzerklicks, behandeln. Wir müssen von

// ...
  userRemove($event: Event) {
    $event.stopPropagation();

    // do things like service call

    this.selectedClient = undefined;
  }
// ...

umstellen zu

// ...
  private readonly removeUser$$ = new Subject<void>();

  private readonly userRemoved$ = this.removeUser$$.pipe(
    withLatestFrom(this.selectedClient$$),
    map(([_, user]) => user),
    filter((user): user is User => !!user),
    switchMap(user => this.dataService.deleteUser(user).pipe(
      map(() => user.id),
      catchError(() => of(Errors.OutdatedData)),
    )),
  ).pipe(
    share()
  );

// ...

  userRemove($event: Event) {
    $event.stopPropagation();
    this.removeUser$$.next();
  }
// ...

userRemoved$ ist eines dieser Observables, das clients$ beeinflusst, und jetzt wissen wir, warum es entweder einen Fehler oder echte Daten (in diesem Fall die ID des gelöschten Benutzers) liefern kann. Wie gehen wir jetzt mit Fehlern um?

// ...

  private readonly refresh$: Observable<void> = merge(
    this.refresh$$,
    of(undefined).pipe(
      switchMap(() => this.errors$.pipe(
        filter(error => error === Errors.OutdatedData),
        map(() => window.confirm('Data is outdated. Do you want to refresh?')),
        filter(Boolean),
      )),
      map(() => undefined)
    )
  ).pipe(
    share()
  );

// ...

  private readonly errors$: Observable<Errors> = merge(
    this.carRemoved$.pipe(filter((error): error is Errors => typeof error === 'number')),
    this.carUpdated$.pipe(filter((error): error is Errors => typeof error === 'number')),
    this.carAdded$.pipe(filter((error): error is Errors => typeof error === 'number')),
    this.userAdded$.pipe(filter((error): error is Errors => typeof error === 'number')),
    this.userUpdated$.pipe(filter((error): error is Errors => typeof error === 'number')),
    this.userRemoved$.pipe(filter((error): error is Errors => typeof error === 'number')),
    this.carRemovedFromUser$.pipe(filter((error): error is Errors => typeof error === 'number')),
    this.carAssignedToUser$.pipe(filter((error): error is Errors => typeof error === 'number')),
  ).pipe(
    share()
  );

// ..

  constructor(
    private readonly dataService: MockDataService,
  ) {
    this.subscriptions.add(this.errors$.subscribe(error => {
      switch (error) {
        case Errors.CannotFetchData:
          alert('An error occurred while fetching data');
          break;
        case Errors.EmptyResponse:
          alert('Received an unexpected empty response');
          break;
      }
    }));
  }
// ...

Alle Fehler werden in einem einzigen errors$ Observable aggregiert. Jede Fehlerbehandlungslogik muss nur einmal definiert werden. Wir fassen alle Observables, die Fehler produzieren können, in einem einzigen zusammen. Zuerst wird nach tatsächlichen Fehlern gefiltert, dann wird zusammengeführt. Was haben wir davon? Angenommen, wir haben kein potentiell Fehler produzierendes Observable vergessen, können wir sofort sehen, was schief gehen kann, und schnell herausfinden, wie diese Probleme behandelt werden. Das ist Lesbarkeit und Wartbarkeit durch Komposition.

 

Sie fragen sich vielleicht, wozu das Konstrukt of(undefined).pipe(switchMap(...)) gebraucht wird. Sieht sehr kryptisch aus. Wir brauchen es, weil das, was wir haben, im Grunde ein Ereigniskreis ist: Wenn ein Fehler auftritt, wollen wir vielleicht den Status aktualisieren. Aber die Aktualisierungsaktion kann selbst einen Fehler erzeugen. Wir können diese Observables also nicht einfach initialisieren: Wenn wir mit der Initialisierung des refresh$ Observable beginnen, brauchen wir eine Referenz auf das errors$ Observable. Aber wenn wir errors$ initialisieren, brauchen wir einen Verweis auf refresh$. Unsere IDE wird uns anschreien, dass das nicht funktionieren wird. Also verstecken wir den Verweis auf errors$ in dem Callback zu switchMap. of(undefined) wird sofort feuern, wenn refresh$ abonniert wird und die Kontrolle an errors$ übergeben, aber das wird erst passieren, nachdem errors$ initialisiert wurde. Wenn wir vorsichtig sind. TypeScript wird den Bug nicht abfangen, der auftritt, wenn wir refresh$ abonnieren, bevor errors$ initialisiert wurde!

 

Um ein wenig Boilerplate zu sparen habe ich für den typischen Fall der Fehlerbehandlung, z.B. im Kontext von HTTP-Aufrufen, eine Bibliothek geschrieben. Diese ist von Rusts Fehlerbehandlungssystem und NgRx inspiriert und stellt drei simple Operatoren zur Verfügung, welche es uns erlauben, Streams in einen Fehler- und einen Erfolgspfad zu splitten, welche dann getrennt behandelt werden können. Diese heißen handleError(), unwrapError() und unwrapSuccess().