Elemente eines Observable Array mit abhängigen inneren Observables kombinieren


Changelog:

  • 17.06.2019: Rechtschreibfehler korrigiert.

Gegeben ist folgendes Szenario:

  • Ein Observable wird uns ein Array von Blog-Artikeln liefern.
  • Jedes Blogeintrag-Objekt enthält eine ID, die auf den Autor des Eintrags verweist.
  • Außerdem verweisen eine Reihe von Kommentar-Objekten per ID auf den Eintrag.

Wir wollen nun zum Beispiel eine Suche programmieren, die sowohl alle Text-Felder des Beitrags-Objektes durchsucht, als auch den Namen des Autors und alle verknüpften Kommentare… und zwar im Frontend. Weil anders wäre es ja auch zu einfach.

Die Aufgabe:

Das Observable, welches das Array von Blogeinträgen liefert, muss dahingehend erweitert werden, dass jeder Blogeintrag seinen Autor und seine Kommentare enthält.

Erste Schritte:

Der Code, in dem ich auf dieses Problem gestoßen bin, ist ziemlich komplex. Darum machen wir uns erst einmal an ganz kleines Beispiel, dass sich nur auf das Wesentliche beschränkt. Dabei lassen wir alle Typen-Deklarationen und Zugriffsmodifikatoren weg, um den Code möglichst überschaubar zu halten.

Zuerst bereiten wir uns ein paar Beispiel-Objekte vor, die meine Datenbank-Einträge simulieren sollen.

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'my-app',
  styleUrls: ['./app.component.scss'],
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit{

  authors = [
    {
      id: 1,
      name: 'Max Mustermann'
    }
  ];

  articles = [
    {
      id: 1,
      authorId: 1,
      headline: "Article 1",
      body: "My first article..."
    },
    {
      id: 2,
      authorId: 1,
      headline: "Article 2",
      body: "My second article..."
    },
    {
      id: 3,
      authorId: 1,
      headline: "Article 3",
      body: "New article for you..."
    },
    {
      id: 4,
      authorId: 1,
      headline: "Article 4",
      body: "Here are 10 things you need to know..."
    }
  ];

  comments = [
    {
      id: 1,
      articleId: 1,
      commentatorId: 2,
      body: "Nice Article!"
    },
    {
      id: 2,
      articleId: 2,
      commentatorId: 3,
      body: "I have a comment."
    },
    {
      id: 3,
      articleId: 2,
      commentatorId: 1,
      body: "To your point..."
    },
    {
      id: 4,
      articleId: 1,
      commentatorId: 4,
      body: "Please visit my website: https://malikdirim.me"
    },
    {
      id: 5,
      articleId: 3,
      commentatorId: 9,
      body: "I don't get it."
    }
  ];

  ngOnInit() {}
}

Um aber darzustellen, wohin wir wollen, und gleichzeitig die Hinweise der IDE bezüglich Typen zumindest für das Ergebnis zu nutzen, definieren wir aber eben noch das Interface unseres Ziel-Objektes, des erweiterten Artikels.

import { Component, OnInit } from '@angular/core';

interface Article {
  id: number;
  authorId: number;
  headline: string;
  body: string;
  author?: any;
  comments?: Array<any>;
}

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {

...

Wir ergänzen außerdem unser Artikel-Array um diese Information:

articles: Article[] = [ ... ]

Als Nächstes brauchen wir Funktionen, die uns die Objekte in Observables packen. Die Funktionen sind in einer richtigen Anwendung dann Aufrufe an das API oder den Store, die man über einen injizierten Service ausführen würde.

Wir beginnen in diesem Schritt mit dem Aufrufen der Einträge, in etwa so:

import { of } from 'rxjs';

...

export class AppComponent implements OnInit{

  ...
  
  getAllArticles() {
    return of(this.articles);
  }

 ...

}

Die Funktion zum Laden der Kommentare zu einem Artikel mocken wir wie folgt:

import { of } from 'rxjs';

...

export class AppComponent implements OnInit{

  ...
  
  getCommentsByArticleId(articleId) {
    return of(this.comments.filter(comment => comment.articleId === articleId));
  }

 ...

}

Und schließlich noch der Autor / die Autorin. Die ID haben wir auf unserem Blogeintrags-Objekt:

import { of } from 'rxjs';

...

export class AppComponent implements OnInit{

  ...
  
  getAuthorById(authorId) {
    return of(this.authors.find(author => author.id === authorId));
  }

 ...

}

Jetzt prüfen wir, dass alles bis hierher funktioniert, in dem wir in ngOnInit() zu jeder Funktionen eine Subscription schreiben und die Werte ausgeben lassen:


  ...
  
  ngOnInit() {
    this.getAllArticles().subscribe(val => console.log(val));
    this.getAuthorById(1).subscribe(val => console.log(val));
    this.getCommentsByArticleId(1).subscribe(val => console.log(val));
  }

 ...

Die Konsole gibt uns ein Ergebnis aus, wie wir es erwarten würden:

(4) […]
​0: Object { id: 1, authorId: 1, headline: "Article 1", … }
​1: Object { id: 2, authorId: 1, headline: "Article 2", … }
​2: Object { id: 3, authorId: 1, headline: "Article 3", … }
​3: Object { id: 4, authorId: 1, headline: "Article 4", … }

{…}
​id: 1
​name: "Max Mustermann"

(2) […]
​0: Object { id: 1, articleId: 1, commentatorId: 2, … }
​1: Object { id: 4, articleId: 1, commentatorId: 4, … }

Jetzt bauen wir uns noch eine vierte Funktion, die schlussendlich das Ergebnis unserer angestrebten Kombination der Observables zurückreichen wird. Über die Angabe des Rückgabetypen an dieser Stelle können wir zumindest teilweise sicherstellen, dass wir uns auf dem richtigen Weg befinden:


  ...  

  getEnrichedArticles(): Observable<Article[]> {
    // Vorerst nur:
    return this.getAllArticles();
  }

  ...

Und wir subscriben zu diesem Observable in ngOnInit(), um die Ausgabewerte ansehen zu können:

  ...  

  ngOnInit() {
    this.getEnrichedArticles().subscribe(val => console.log(val));
  }

  ...

Derzeit sieht also die gesamte Komponente aus wie folgt:

import { Component, OnInit } from '@angular/core';
import { of, Observable } from 'rxjs';

interface Article {
  id: number;
  authorId: number;
  headline: string;
  body: string;
  author?: any;
  comments?: Array<any>;
}

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  authors = [
    {
      id: 1,
      name: 'Max Mustermann'
    }
  ];

  articles: Article[] = [
    {
      id: 1,
      authorId: 1,
      headline: 'Article 1',
      body: 'My first article...'
    },
    {
      id: 2,
      authorId: 1,
      headline: 'Article 2',
      body: 'My second article...'
    },
    {
      id: 3,
      authorId: 1,
      headline: 'Article 3',
      body: 'New article for you...'
    },
    {
      id: 4,
      authorId: 1,
      headline: 'Article 4',
      body: 'Here are 10 things you need to know...'
    }
  ];

  comments = [
    {
      id: 1,
      articleId: 1,
      commentatorId: 2,
      body: 'Nice Article!'
    },
    {
      id: 2,
      articleId: 2,
      commentatorId: 3,
      body: 'I have a comment.'
    },
    {
      id: 3,
      articleId: 2,
      commentatorId: 1,
      body: 'To your point...'
    },
    {
      id: 4,
      articleId: 1,
      commentatorId: 4,
      body: 'Please visit my website: https://malikdirim.me'
    },
    {
      id: 5,
      articleId: 3,
      commentatorId: 9,
      body: 'I don\'t get it.'
    }
  ];

  getAllArticles() {
    return of(this.articles);
  }

  getCommentsByArticleId(articleId) {
    return of(this.comments.filter(comment => comment.articleId === articleId));
  }

  getAuthorById(authorId) {
    return of(this.authors.find(author => author.id === authorId));
  }

  getEnrichedArticles(): Observable<Article[]> {
    return this.getAllArticles();
  }

  ngOnInit() {
    // this.getAllArticles().subscribe(val => console.log(val));
    // this.getAuthorById(1).subscribe(val => console.log(val));
    // this.getCommentsByArticleId(1).subscribe(val => console.log(val));
    this.getEnrichedArticles().subscribe(val => console.log(val));
  }
}

Der Lösungsansatz

Nach dieser kurzen Vorbereitung sind wir jetzt beim eigentlichen Problem angekommen: Wie können wir ein Observable anbieten, dass einen Strom von Arrays von Blogeinträgen liefert, in dem jeder Eintrag im Array um seinen Autor und seine Kommentare erweitert ist?

Grundsätzlich sollte die Dokumentation von RxJS in greifbarer Nähe liegen, da wir mit verschiedenen RxJS Operatoren hantieren werden.

Zunächst müssen wir jetzt einen Weg finden, jedes Element im Array einzeln anfassen zu können. Außerdem müssen wir diese einzeln betrachteten Elemente am Ende wieder zusammenführen. Wir erwarten schließlich einen Strom von Arrays, genau wie das ursprüngliche „Articles“ Observable es auch liefert.

Um jedes Element innerhalb des Arrays im Stream zu manipulieren, können wir das Array wohl mit dem RxJS map-Operator aufgreifen und dann mithilfe der JavaScript map-Funktion über das Array iterieren und die Objekte verändern. Zum Testen versuchen wir mal folgendes:

Hinweis: Ich werde im folgenden jetzt nicht alle Imports der Operatoren noch mal anzeigen. Deine IDE sollte das für dich automatisch erledigen. Bei Unsicherheiten, kannst du dir auch den gesamten Code am Ende dieses Blog-Eintrags anschauen, in dem alle Import-Anweisungen enthalten sind.

...

  getEnrichedArticles(): Observable<Article[]> {
    return this.getAllArticles().pipe(
      map(articles => // RxJS map
        articles.map((article, index) => { // JS map
          article.headline = `I changed you at ${index}`;
          return article;
        })
      )
    );
  }

  ngOnInit() {
    this.getEnrichedArticles().subscribe(val => console.log(val));
  }

...

Die Konsole wirft uns dabei folgendes Ergebnis aus:

(4) […]
0: Object { id: 1, authorId: 1, headline: "I changed you at 0", … }
1: Object { id: 2, authorId: 1, headline: "I changed you at 1", … }
2: Object { id: 3, authorId: 1, headline: "I changed you at 2", … }
3: Object { id: 4, authorId: 1, headline: "I changed you at 3", … }

Das scheint schon einmal geklappt zu haben. Nun gilt es aber zu bedenken, dass wir für jedes Element in diesem Array ein statisches Element (einen einzelnen Artikel) mit weiteren Observables (AutorIn und Kommentare) verbinden müssen. Das Ergebnis dieser Verbindung kann an sich nur ein Observable sein. Damit verändern wir das ursprüngliche Observable sehr gravierend.

Wir haben also eine Transformations-Operation, die das ursprüngliche Observable in ein erweitertes Observable verwandeln wird. Dafür stehen uns laut RxJS-Dokumentation eine ganze Reihe von Operatoren zur Verfügung. Wir haben im Hinterkopf, dass wir neue, innere Observables erzeugen werden. Das wiederum reduziert die Anzahl der möglichen Operatoren.

Typischerweise greift man in diesem Fall auf switchMap zurück. Der switchMap-Operator ist für uns nicht nur hilfreich, weil er uns erlaubt, den Strom eines inneren Observables auf den Strom des äußeren Observables zu mappen. Er hat außerdem den nützlichen Nebeneffekt, dass er vorherige Operationen abbricht, wenn das ursprüngliche Observable einen neuen Wert ausgibt. Sollten also mehrere Autoren gleichzeitig mehrere Artikel veröffentlichen, erzeugen wir nicht mehrere, parallel laufende Ströme.

...

  getEnrichedArticles(): Observable<Article[]> {
    return this.getAllArticles().pipe(
      switchMap(articles => of(articles))
    );
  }

...

Jetzt dürfen wir statt mit einem einfach Array von „Articles“ mit einem weiteren Observable hantieren. Dieses Observable muss natürlich seinerseits ein Array ausgeben, weshalb wir nun einen Operator suchen, der mehrere Observables in ein beobachtbares Array kombiniert. Auch hier ist wichtig, dass dieser Operator eine Subscription für jedes Element des Arrays auslöst. Üblicherweise werden für dafür forkJoin, combineLatest und zip genutzt. Zip kommt mir in unserem Kontext ganz hilfreich vor:

...

  getEnrichedArticles(): Observable<Article[]> {
    return this.getAllArticles().pipe(
      switchMap(articles =>
        zip(
          ...articles.map((article, index) => {
            article.headline = `I changed you at ${index}`;
            return of(article);
          })
        )
      )
    );
  }

...

Der JavaScript-eigene Spread-Operator (…articles) ist hier besonders nützlich und erlaubt uns, die Einträge, die wir aus dem Blogeinträge-Array erhalten haben, einzeln weiterzuverarbeiten und mit zip wieder zu kombinieren.

Nun dürfen wir für jeden Blog-Eintrag ein eigenes Observable erzeugen. Wir wollen schließlich ein Observable haben, dass den Blog-Eintrag mit den zwei Anfragen an die Datenbank kombiniert. Dafür müssen wir erneut einen Operator zur Kombination von Observables verwenden.

Aber Achtung! Ich hatte zuerst erneut den zip-Operator an dieser Stelle verwendet. Nun sagen die Docs allerdings, dass das resultierende Observable nur dann einen Wert ausgibt, wenn alle kombinierten Observables einen Wert ausgeben. In unserem Fall kann es aber sein, dass zum Beispiel zu einem Artikel ein Kommentar geschrieben, der Autor aber nicht verändert wird. Diese Änderung verursacht keinen neuen Wert im Higher-Order-Observable und darum erfahren die Subscribers auch nichts vom neuen Kommentar.

Die Lösung ist der Einsatz von combineLatest, welcher bei einem neuen Wert in einem der inneren Observables einfach den letzten Wert aller inneren Observable ausgibt.

Was wir dabei allerdings zurückbekommen ist ein Array aus Werten aus jedem der drei angegebenen Streams. Was wir wollen, ist diese zu kombinieren. Also wird direkt noch der map-Operator mit angehangen, in dem wir nun endlich die fehlenden Felder des Article-Objektes auffüllen dürfen.

Die finale Funktion sieht nun so aus:

...

  getEnrichedArticles(): Observable<Article[]> {
    return this.getAllArticles().pipe(
      switchMap(articles =>
        zip(
          ...articles.map(article =>
            combineLatest(
              of(article),
              this.getAuthorById(article.authorId),
              this.getCommentsByArticleId(article.id)
            ).pipe(
              map(([enrichedArticle, author, comments]) => {
                enrichedArticle.author = author;
                enrichedArticle.comments = comments;
                return enrichedArticle;
              })
            )
          )
        )
      )
    );
  }

...

Hier noch einmal die gesamte Komponente in der Übersicht:

import { Component, OnInit } from '@angular/core';
import { of, zip, Observable } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';

interface Article {
  id: number;
  authorId: number;
  headline: string;
  body: string;
  author?: any;
  comments?: Array<any>;
}

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  authors = [
    {
      id: 1,
      name: 'Max Mustermann'
    }
  ];

  articles: Article[] = [
    {
      id: 1,
      authorId: 1,
      headline: 'Article 1',
      body: 'My first article...'
    },
    {
      id: 2,
      authorId: 1,
      headline: 'Article 2',
      body: 'My second article...'
    },
    {
      id: 3,
      authorId: 1,
      headline: 'Article 3',
      body: 'New article for you...'
    },
    {
      id: 4,
      authorId: 1,
      headline: 'Article 4',
      body: 'Here are 10 things you need to know...'
    }
  ];

  comments = [
    {
      id: 1,
      articleId: 1,
      commentatorId: 2,
      body: 'Nice Article!'
    },
    {
      id: 2,
      articleId: 2,
      commentatorId: 3,
      body: 'I have a comment.'
    },
    {
      id: 3,
      articleId: 2,
      commentatorId: 1,
      body: 'To your point...'
    },
    {
      id: 4,
      articleId: 1,
      commentatorId: 4,
      body: 'Please visit my website: https://malikdirim.me'
    },
    {
      id: 5,
      articleId: 3,
      commentatorId: 9,
      body: 'I don\'t get it.'
    }
  ];

  getAllArticles() {
    return of(this.articles);
  }

  getCommentsByArticleId(articleId) {
    return of(this.comments.filter(comment => comment.articleId === articleId));
  }

  getAuthorById(authorId) {
    return of(this.authors.find(author => author.id === authorId));
  }

  getEnrichedArticles(): Observable<Article[]> {
    return this.getAllArticles().pipe(
      switchMap(articles =>
        zip(
          ...articles.map(article =>
            combineLatest(
              of(article),
              this.getAuthorById(article.authorId),
              this.getCommentsByArticleId(article.id)
            ).pipe(
              map(([enrichedArticle, author, comments]) => {
                enrichedArticle.author = author;
                enrichedArticle.comments = comments;
                return enrichedArticle;
              })
            )
          )
        )
      )
    );
  }

  ngOnInit() {
    this.getEnrichedArticles().subscribe(val => console.log(val));
  }
}

Hast du einen Fehler entdeckt? Einen eleganteren Weg gefunden? Ein ähnliches Problem, gelöst oder ungelöst? Ich freue mich über jeden hilfreichen Beitrag in den Kommentaren!

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden /  Ändern )

Google Foto

Du kommentierst mit Deinem Google-Konto. Abmelden /  Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden /  Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden /  Ändern )

Verbinde mit %s