cs.Meinung();

Promises in Angular Services... und wie man damit umgeht

  • Angular
  • Rxjs
  • TypeScript

Es gab vor kurzem einen PR in unserem Mono-Repo, bei dem es hauptsächlich um die Einführung eines zentralen Services ging. Er sollte ab einem bestimmten Zeitpunkt von allen Apps des Repos konsumiert werden. Das einzige Manko des Services war: Sein API arbeitete mit Promises, sodass der Aufrufende gezwungen war, in seinen Komponenten oder eigenen Services, nicht wie gewohnt, mit Rxjs-Observables zu arbeiten, sondern mit Promises – so die Ausgangslage.

Hier eine stark abstrahierte Beispielimplementierung des Services:

@Injectable({
provideIn: 'root',
})
export class FooService {
constructor(
private barService: ExternerBarService,
private http: HttpClient
) {}
public async get<T>(url: string): Promise<T> {
const headers = await this.barService.getHeaders();
return this.http
.get<T>(url, { headers })
.toPromise();
}
}
Die Ausgangslage: async Methode mir Promise

Begründet wurde die ungewohnte Schnittstelle damit, dass ein externer Service (im Beispiel ExternerBarService) zum Einsatz kam, der mit Promises arbeitete. Dies führte dazu, dass im eigenen Code await verwendet werden musste, um das Ergebnis des Promise abzuwarten. Um nicht ein Observable mit einem Promise im Bauch zu liefern, wurde anschließend das Observable des eigenen httpClient-Aufrufs in ein Promise umgewandelt. Dadurch musste sich zwingend auch das eigene API ändern: Die Methode erhielt als Rückgabewert ein Promise und musste mit async versehen werden.

Wieso, es funktioniert doch?

Und wo liegt das Problem? Es funktioniert doch! Die Verwendung von Promises im Angular-Kontext, der ansonsten die Rxjs-Library einsetzt, ist zumindest gewöhnungsbedürftig. Trotzdem fände die Diskussion wahrscheinlich erst gar nicht statt, hätte dieser Service nicht von sämtlichen Projekten verwendet und darin in etlichen Komponenten eingesetzt werden müssen. Denn technisch spräche gegen Promises in Angular erstmal mal nichts, was ihre Verwendung gänzlich infrage stellen würde. Schließlich sind sie das asynchrone Werkzeug, das von Browsern nativ verstanden wird. Dennoch stoßen sie hier und da an ihre Grenzen und sind für manche Aufgaben einfach nicht genug – z.B. dann nicht, wenn mehrere Werte hintereinander emittiert werden müssen. Observables sind für viele Aufgaben einfach besser gewappnet. Ihre Schnittstelle erlaubt es, eine Vielzahl von Operatoren einzusetzen, um den emittierten Wert zu verarbeiten, zu transformieren oder auch Seiteneffekte auszulösen, ohne damit in eine Callback-Falle zu tappen. Warum also die Umstände, wenn es eine "bessere" Alternative gibt?

Die Alternative

Wie sehe die Alternative aus? Dass die externe Abhängigkeit auf Promises setzt, lässt sich nicht ändern – wir haben in seltenen Fällen Einfluss darauf. Was wir selbst in der Hand haben, ist unser eigener Code, also jener, in dem die Abhängigkeit verwendet wird. Ziel ist es daher, die Implementierungsdetails nach außen hin zu verbergen und die Schnittstelle den eigenen Ansprüchen und der Erwartungshaltung des Teams entsprechend zu gestalten: Wir wollen also keine asynchrone Methode bereitstellen, die einen Promise liefert, sondern eine, die einen Datenstrom, also ein Observable, zurückgibt und sich damit in Angular hürdenfrei einsetzen lässt.

Statt den Promise mit await abzuwarten und die Methode dadurch asynchron werden zu lassen, sollte der Aufruf des externen Services in ein Observable gekapselt werden. Stellt man es richtig an, so kommt der Promise erst dann zur Ausführung, wenn die aufrufende Stelle das zurückgelieferte Observable über ein subscribe abonniert.

//...
const headers$ = defer(() => from(this.barService.getHeaders()));
//...
Schritt 1: Den Promise in ein Observables kapseln

Hierzu bieten sich die Hilfsfunktionen from und defer aus der Rxjs-Library an. Das Util from konvertiert den übergebenen Promise in ein Observable, verhindert aber nicht, dass dieser gleich aufgelöst wird. Dies übernimmt das andere Util – defer. Diese Funktion nimmt einen Callback entgegen, das verspricht ein Observable zu liefern. Die Ausführung des Callbacks wird dabei so lange hingehalten, bis der Datenstrom über ein subscribe abonniert wurde.

Eine Frage bleibt noch offen: Wie greifen wir auf den tatsächlichen Wert in dem headers$-Observable zu? Denn schließlich wird dieser als Parameter für den nachgelagerten Request-Call benötigt. Es zu abonnieren, ergäbe keinen Sinn. Damit würden wir einerseits die Verwendung von defer obsolete machen und hätten andererseits nicht mehr die Möglichkeit selbst ein Observable zu liefern.

Der folgende Ansatz würde nicht funktionieren:

// Dies führt nicht zum gewünschten Ergebniss
headers$.subscribe((headers) => {
// Das innere Observable wäre für den Aufrufer
// nicht mehr zugängig
this.http.get<T>(url, { headers });
});
Keine so gute Idee

Um es richtig anzustellen, müsste ein flattening-Operator verwendet werden, der das Ergebnis des headers$-Observable in ein weiteres Observable transformiert. Der Operator switchMap kommt ins Spiel. Dieser wird, wie alle anderen Operatoren aus der Rxjs-Library, über die Methode pipe in die Verarbeitungskette des Datenstroms eingehängt. Er bekommt das Ergebnis des Datenstroms übergeben und liefert seinerseits ein Observable zurück. Der richtige Ansatz wäre also der Folgende:

headers$.pipe(
switchMap((headers) => this.http.get<T>(url, { headers }))
);
Schritt 2: den switchMap-Operator verwenden

Die vollständige Umsetzung der fiktiven Methode sehe damit wie folgt aus:

// …
public get<T>(url: string): Observable<T> {
const headers$ = defer(() => from(this.barService.getHeaders()));
return headers$.pipe(
switchMap((headers) => this.http.get<T>(url, { headers }))
);
}
// …
Das Endergebnis – die gewohnte Schnittstelle

Fazit

Die Schnittstelle des Services entspricht nun der Erwartungshaltung eines Angular-Entwicklers: Man stolpere nicht mehr über den Promise und habe die gewohnte Schnittstelle eines Observable. Wie bereits erwähnt, spricht technisch gesehen, nichts dagegen, einen Promise statt eines Observable zu verwenden, sofern es sich um eine einmalige Emission handle und nicht um ein Datenstrom, wie es z.B. bei einem Type-ahead-Konstrukt der Fall wäre. Und doch sollte man sich fragen, ob es sinnvoll ist, beide Ansätze innerhalb eines Projekts zu mischen, und ein weiteres Mal, wenn es sich um mehrere Projekte eines Mono-Repos handle. Denn es wird nicht lange dauern und ein weiterer Service wird eingeführt, in dem Promises verwendet werden – diesmal womöglich aus Gewohnheit und unbegründet. Der Konsens innerhalb des Projekts würde mit der Zeit leiden und das Projekt zu einer Code-Basis heranwachsen, in der keiner mehr mit Sicherheit sagen könnte, warum an so vielen Stellen ähnliche Aufgaben mit unterschiedlichen Ansätzen gelöst werden. Selbstverständlich kann es, wie in diesem Beispiel gezeigt, vorkommen, dass man aus Gründen nun doch mit Promises arbeiten muss. Dann gilt es aber, meiner Meinung nach, dies als Implementierungsdetail zu betrachten und es von der Außenwelt zu verbergen. Wie es geht, hat dieser Artikel hoffentlich zeigen können.

Zur Blog-Post Übersicht