💻

[Angular][RxJS] 入門記事を読んでも分からなかった、Web APIをObservableで扱うときの具体的なコード例

2020/12/03に公開

はじめに

Angular Advent Calendar 2020 の3日目の記事です!🎄🌙

昨日は @ringtail003 さんの [Angular] はじめての E2E テスト でした✨

今日はAngularユーザーの鬼門とも呼べるRxJSについて書きたいと思います。

僕自身まだまだAngular勉強中の身ですが、以前、自分なりに初学時につまずいたところを丁寧に解説しながら、それなりにリッチな(UIフレームワーク導入&Firebase利用)アプリをゼロから作るチュートリアルを書きました。おかげさまでたくさんの方に読んでいただいています。

今回はこの中で解説できていなかったRxJSに関する話を改めてまとめてみたいと思います💪

RxJSとは

ReactiveX という、Observerパターン を使った非同期処理・イベント処理のためのライブラリ群があり、そのJavaScript(TypeScript)版が RxJS です。

イベントで渡ってきたデータを加工するための様々な オペレータ が提供されていることもあり、公式サイトでは「イベント用のLodash」と紹介されています。

Think of RxJS as Lodash for events.

入門記事でよく見るサンプル

実際にRxJSを使うコードの具体例をいくつか見てみましょう。

どれも入門記事などを読んでいるとよく目にする内容だと思います。

1. 最も原始的な使用例

最も原始的なRxJSのコードは以下のようなものです。

import { of } from "rxjs";

const observable$ = of(1, 2, 3);
observable$.subscribe(v => console.log(v));

// 出力結果:
// 1
// 2
// 3

🤖デモはこちら

どうやら of(1, 2, 3) というコードが Observable オブジェクトを生成しており、それを .subscribe() しているようですね。

of は、可変個の引数を受け取って、それらの値が順に流れてくるような Observable を作ってくれる関数です。

この例では、of(1, 2, 3) の結果を .subscribe() することによって、 1 2 3 という3つの値を順に受け取って処理することができているわけです。

ちなみに、サンプルコードの observable$ の末尾の $ は、Observableな変数に対する命名規則として Angularの文化圏で多く採用されている というだけで、文法上の特別な意味はありません。

2. オペレータを使った例

次に、RxJSの一番の目玉機能であるオペレータを使う例を見てみます。

import { of } from "rxjs";
import { map } from "rxjs/operators";

const observable$ = of(1, 2, 3);
observable$.pipe(map(v => v * 10)).subscribe(v => console.log(v));

// 出力結果:
// 10
// 20
// 30

🤖デモはこちら

先ほどのコードから変わったのは、 observable$.subscribe(v => console.log(v)); の間に .pipe(map(v => v * 10)) が加えられているという点だけです。

これによって、出力結果がそれぞれ10倍された値に変わっていますね。

.subscribe() する前に .pipe() によって mapオペレータ を登録し、 map() 内で元の値を10倍する処理を行っています。

このように、 Observable を実際に .subscribe() する前に、フィルタリングしたり加工したりするのに利用できるのが オペレータ です。

RxJSでは、非常にたくさんのオペレータが標準で提供されています

3. オペレータを複数組み合わせて使った例

オペレータは複数組み合わせて使うこともできます。

import { of } from "rxjs";
import { filter, map } from "rxjs/operators";

const observable$ = of(1, 2, 3);
observable$
  .pipe(
    map(v => v * 10),
    filter(v => v < 25)
  )
  .subscribe(v => console.log(v));

// 出力結果:
// 10
// 20

🤖デモはこちら

先ほどまでは .pipe() の引数に map() だけを渡していましたが、今回は map()filter() の2つを渡しています。

このように .pipe() に複数のオペレータを渡した場合は、第一引数のオペレータから順に適用されていきます。

filterオペレータ は、流れてきた値をフィルタリングするためのオペレータです。

今回の例では、 値が 25 より小さい場合にしか次に流さない というフィルタリングを行っています。

そのため、最終的に .subscribe() した際には 30 という値がフィルタリングされて、 1020 の2つだけが出力されていますね。

filter() よりも先に map() が適用されて各値が10倍されているので、「 1 2 3 の時点ですべてフィルタリングされて何も流れていかない」という結果には なっていない 点にも注目です。

もう少し実践的な例:Web APIの値を受け取る

さて、「 Observable.pipe() でオペレータを仕込んだ上で、最終的に .subscribe() して流れてきた値を利用する」というコードの流れはなんとなくイメージできたでしょうか。

ここまではRxJSの入門記事を読めばだいたい共通して説明されているので、もともと何となくは理解できていたという人が多いのではないかと思います。

ここからはもう少し実践的な例を見ていくことにします💪

まず、上記の例では of(1, 2, 3) を使って自分の手で Observable を作っていましたが、Angularを使って実際にWebアプリを作る場合には、基本的には外部ライブラリから Observable が渡ってきて、それを自分のコードで加工したり活用したりするという使い方がメインになります。(加工の過程の中で of を使うことは意外とよくありますが)

その最も代表的な例が Web APIのレスポンスを Observable として受け取って活用する というものでしょう。

というわけで、ここでは実際にWeb APIの値を Observable として受け取って処理するコードを見てみたいと思います。

1. Web APIのレスポンスをただ取得するだけの例

まずは、Web APIのレスポンスをただ取得するだけの例を見てみましょう。

今回はWeb APIとして JSONPlaceholder を使わせていただきます。

まず、AngularアプリからWeb APIを利用するために、HttpClientModule を導入します。

  import { NgModule } from "@angular/core";
  import { BrowserModule } from "@angular/platform-browser";
  import { FormsModule } from "@angular/forms";
+ import { HttpClientModule } from "@angular/common/http";
  
  import { AppComponent } from "./app.component";
  import { HelloComponent } from "./hello.component";
  
  @NgModule({
-   imports: [BrowserModule, FormsModule],
+   imports: [BrowserModule, FormsModule, HttpClientModule],
    declarations: [AppComponent, HelloComponent],
    bootstrap: [AppComponent]
  })
  export class AppModule {}

その上で、例えば AppComponent などに以下のように HttpClientサービス をインジェクトして、HttpClient#get() を使ってWeb APIにリクエストを送ります。

import { Component, OnInit } from "@angular/core";
import { HttpClient } from "@angular/common/http";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent implements OnInit {
  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.http
      .get("https://jsonplaceholder.typicode.com/users/1")
      .subscribe(response => console.log(response));
  }
}

this.http.get() の戻り値が Observable になっている ので、それを .subscribe() することで レスポンスの内容 を取得できます。

ここまでは特に難しいことはなさそうですね💪

🤖デモはこちら

2. Web APIのリソースをアプリ側のモデルに変換して取得する例

実際のアプリ開発では、Web APIから取得したJSONをそのまま扱うようなことはせず、リソースごとに対応するモデルクラスなどを定義しておいて、そこにマッピングした上で扱うことが多いでしょう。(というかそうしないと何のためにTypeScriptを使っているのか分かりません)

そこで、以下のような User インターフェースを用意して、これをユーザーの型として利用したいと思います。

// src/models/user.ts

export interface User {
  id: number;
  name: string;
  username: string;
  email: string;
  address: {
    street: string;
    suite: string;
    city: string;
    zipcode: string;
    geo: {
      lat: string;
      lng: string;
    };
  };
  phone: string;
  website: string;
  company: {
    name: string;
    catchPhrase: string;
    bs: string;
  };
}

Web APIから取得したJSONは常に User 型に変換した上で扱いたいので、その仕事を任せるための UserRepositoryService というサービスクラスを作ることにします。

// src/repositories/user-repository.service.ts

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";

import { User } from "../models/user";

@Injectable({
  providedIn: "root"
})
export class UserRepositoryService {
  constructor(private http: HttpClient) {}

  get(id: number): Observable<User> {
    return this.http
      .get(`https://jsonplaceholder.typicode.com/users/${id}`)
      .pipe(map(response => response as User));
  }
}

なるほど、.pipe(map()) を使って生JSONを User 型のオブジェクトに変換しているわけですね。

Observable.pipe() で加工したものも Observable であり、最終的に .subscribe() されたときに渡ってくる値が加工後の値になっているだけ、というイメージが持てると理解しやすいと思います。

このサービスをコンポーネントにインジェクトして .get() メソッドに取得したいユーザーのIDを与えれば、User を型引数とする Observable の形で結果が返ってきます。(つまり、それを .subscribe() すれば User 型の値が取り出せる)

というわけで、この場合の AppComponent のコードは以下のようになります。

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

import { User } from "../models/user";
import { UserRepositoryService } from "../repositories/user-repository.service";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent implements OnInit {
  constructor(private userRepository: UserRepositoryService) {}

  ngOnInit() {
    this.userRepository.get(1).subscribe((user: User) => console.log(user));
  }
}

🤖デモはこちら

モデルへの変換が入れ子になる(モデルが子フィールドに別のモデルを持っている)例

いよいよ最後の例です。

先ほどの続きで、今度は User だけでなく PostComment もモデル化してそれぞれリポジトリサービスからモデルの形で取得できるようにしたいと思います。

// src/modles/post.ts

import { User } from "./user";

export interface Post {
  id: number;
  user: User;
  title: string;
  body: string;
}
// src/models/comment.ts

import { Post } from "./post";

export interface Comment {
  id: number;
  post: Post;
  name: string;
  email: string;
  body: string;
}

特に難しいことはしていませんが、 Post#user の型が User だったり Comment#post の型が Post だったりしていることに注目してください。(これによって一気に話がややこしくなります😅)

1. PostRepositoryService

さて、それでは先ほどと同様に、まずは PostRepositoryService から作っていきましょう。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { map, mergeMap } from "rxjs/operators";

import { Post } from "../models/post";
import { UserRepositoryService } from "./user-repository.service";

@Injectable({
  providedIn: "root"
})
export class PostRepositoryService {
  constructor(
    private http: HttpClient,
    private userRepository: UserRepositoryService
  ) {}

  get(id: number): Observable<Post> {
    return this.http
      .get(`https://jsonplaceholder.typicode.com/posts/${id}`)
      .pipe(
        mergeMap((response: any) =>
          this.userRepository
            .get(response.userId)
            .pipe(map(user => ({ response, user })))
        ),
        map(({ response, user }) => Object.assign(response, { user }) as Post)
      );
  }
}

ウギャー!急にめっちゃ難しい!😵

ちょっと1つずつ順番に見ていきましょう。

get(id: number): Observable<Post> {
  return this.http
    .get(`https://jsonplaceholder.typicode.com/posts/${id}`)

ここまではいいですね。今までどおり、受け取ったIDに対応するエンドポイントにGETリクエストしているだけです。

    .pipe(
      mergeMap((response: any) =>
        this.userRepository
          .get(response.userId)
          .pipe(map(user => ({ response, user })))
      ),
      map(({ response, user }) => Object.assign(response, { user }) as Post)
    );

ここが急に難しいですね。

どうも .pipe() の中に mergeMap()map() という2つのオペレータを渡しているようですが、 mergeMap() は初見ですね。

一旦、先に2つ目の map() のほうから見てみましょう。

map(({ response, user }) => Object.assign(response, { user }) as Post)
  1. { resopnse, user } というプロパティが2つあるオブジェクトが流れてきたときに
  2. Object.assign を使って response オブジェクトに { user } オブジェクト( { user: user } の略記)を上書きコピーして
    • つまり、 response オブジェクトに user というプロパティがあれば、その内容を user 変数の値で置き換える
  3. その結果を Post 型の値として返す

ということをしています。この map()Post 型のオブジェクトを返しているため、メソッド全体の戻り値は Observable<Post> になっています。

では、1つ目のほうの mergeMap() というやつは何をしているのでしょうか。

mergeMap((response: any) =>
  this.userRepository
    .get(response.userId)
    .pipe(map(user => ({ response, user })))
),

まず、 HttpClient#get() の戻り値からレスポンスの内容を response という引数で受け取ります。( response.userId というプロパティにアクセスするコードを書きたいのであえて any 型を指定しています)

response を受け取ったら、今度はさらに this.userRepository.get(response.userId) を呼び出して Observable<User> を取得していますね。

これは、上述したとおり Post#userUser 型なので、レスポンスから取得した userId を元に User を取得する必要があるためです。

そしてこれをさらに .pipe(map()) で変形していますね。

.pipe(map(user => ({ response, user })))

流れてきた user{ response, user } というオブジェクトに変形して、 userresponse をセットにして次に流せるようにしています。

これが先ほど見た1つ目の map() に渡ってくるという構造になっているわけですね。

なぜ map() ではなく mergeMap() なんていう知らないオペレータを使っていたのかという問題が残っていますが、これは、値の変形の過程に非同期処理が入る ためです。

先ほど、 response{ response, user } に変形するために、 this.userRepository.get() を実行して Observable<User> を取得しましたよね。

何も考えずに map() で変形しようとすると、 User を流したいのに Observable<User> を流してしまうことになります。

こういう場合に有用なのが今回使った mergeMap というやつで、このオペレータは 非同期を解決した上で次に流してくれる という性質を持っています。

同じように非同期を解決してくれるオペレータに concatMapswitchMap というものもあり、それぞれ微妙に機能が異なりますが、実は今回の例ではどれを使っても同じように動作します。

mergeMap concatMap switchMap の比較については以下の記事が参考になります。(ただし、内容がRxJS v5以前を対象にしたものになっていて、コードの書き方がv6以降とは若干異なっているので注意してください)

RxJSのconcatMap, mergeMap, switchMapの違いを理解する(中級者向け) - Qiita

ここまで理解した上で、もう一度コード全体を見てみましょう。

get(id: number): Observable<Post> {
  return this.http
    .get(`https://jsonplaceholder.typicode.com/posts/${id}`)
    .pipe(

      // response を { response, user } に変換
      // ただし非同期での変換なので、その解決を待ってから次に流す
      mergeMap((response: any) =>
        this.userRepository
          .get(response.userId)
          .pipe(map(user => ({ response, user })))
      ),
      
      // { response, user } を Post に変換
      map(({ response, user }) => Object.assign(response, { user }) as Post)
    );
}

コードが読めるようになっていたら大成長です!🎉

やっぱりよく分からない…という人は、何度も読み返したり、実際に手元でコードを動かしたりしてみながらじっくり理解してみてください🙏

2. CommentRepositoryService

同様に CommentRepositoryService も作っていきます。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable, zip } from "rxjs";
import { map, mergeMap } from "rxjs/operators";

import { Post } from "../models/post";
import { Comment } from "../models/comment";
import { PostRepositoryService } from "./post-repository.service";

@Injectable({
  providedIn: "root"
})
export class CommentRepositoryService {
  constructor(
    private http: HttpClient,
    private postRepository: PostRepositoryService
  ) {}

  get(id: number): Observable<Comment> {
    return this.http
      .get(`https://jsonplaceholder.typicode.com/comments/${id}`)
      .pipe(
        mergeMap((response: any) =>
          this.postRepository
            .get(response.postId)
            .pipe(map((post: Post) => ({ response, post })))
        ),
        map(
          ({ response, post }) => Object.assign(response, { post }) as Comment
        )
      );
  }

  list(postId: number): Observable<Comment[]> {
    return this.http
      .get(`https://jsonplaceholder.typicode.com/comments?postId=${postId}`)
      .pipe(
        mergeMap((responses: any[]) =>
          zip(
            ...responses.map(response =>
              this.postRepository.get(response.postId)
            )
          ).pipe(map((posts: Post[]) => ({ responses, posts })))
        ),
        map(({ responses, posts }) =>
          responses.map(
            (response, i) =>
              Object.assign(response, { post: posts[i] }) as Comment
          )
        )
      );
  }
}

おやおや…これはまた難しい感じですね…

順に見ていきましょう💪

まず、 get メソッドについては、よく見ると先ほどの PostRepositoryService#get() とまったく同じ構造だということが分かると思いますので、こちらは説明を割愛します。

問題は list メソッドですね。

list(postId: number): Observable<Comment[]> {
  return this.http
    .get(`https://jsonplaceholder.typicode.com/comments?postId=${postId}`)
    .pipe(
      mergeMap((responses: any[]) =>
        zip(
          ...responses.map(response =>
            this.postRepository.get(response.postId)
          )
        ).pipe(map((posts: Post[]) => ({ responses, posts })))
      ),
      map(({ responses, posts }) =>
        responses.map(
          (response, i) =>
            Object.assign(response, { post: posts[i] }) as Comment
        )
      )
    );
}

postId を指定して コメントの一覧 を取得するメソッドです。なので戻り値の型は Observable<Comment[]> となっていますね。

つまり、 .pipe() 内で値の変形を行った結果、最終的に Comment の配列を作成できればよい ということになります。

今回もまた初見の zip() なるオペレータが登場していますが、先ほどと同様、一旦後半から先に見ていきましょう。

map(({ responses, posts }) =>
  responses.map(
    (response, i) =>
      Object.assign(response, { post: posts[i] }) as Comment
  )
)
  1. { resopnses, posts } というプロパティが2つ(いずれも配列)あるオブジェクトが流れてきたときに
  2. responses 配列を Array.prototype.map() で回して
  3. 1つ1つの response について、Object.assign を使って response オブジェクトに { post: posts[i] } オブジェクト( responses のループインデックスに対応する posts の中身)を上書きコピーして
    • つまり、 response オブジェクトに post というプロパティがあれば、その内容を posts[i] の値で置き換える
  4. その結果を Comment 型の値として返すことにより、最終的に Comment の配列( Comment[] 型)を返す

ということをしています。これによりメソッド全体の戻り値は Observable<Comment[]> になっています。

この部分は、渡ってくる値が配列になっている以外は PostRepositoryService のときとやっていることは同じですね💪

問題は前半です。

mergeMap((responses: any[]) =>
  zip(
    ...responses.map(response =>
      this.postRepository.get(response.postId)
    )
  ).pipe(map((posts: Post[]) => ({ responses, posts })))
),

zip という初見のオペレータが登場しています。

zip() オペレータは、複数の Observable を受け取って、それを組み合わせた Observable を返します。

以下の例を見るとイメージしやすいと思います。

const zip$ = zip(of(1, 2, 3), of(4, 5, 6));
zip$.subscribe(v => console.log(v));

// 出力結果:
// [1, 4]
// [2, 5]
// [3, 6]

つまり、

zip(
  ...responses.map(response =>
    this.postRepository.get(response.postId)
  )
)

この部分は、

  1. responses 1つ1つについて this.postRepository.get() を使って Observable<Post> を取得し
  2. それらすべての Observable<Post>zip() に引数として渡し
  3. 結果として Observable<Post[]> を作る

ということをしているわけです。

そして、その Observable<Post[]> をさらに

.pipe(map((posts: Post[]) => ({ responses, posts })))

に渡して、 { responses, posts } に変形して次の map() に渡しているというわけですね。

では、ここまで理解した上で、もう一度コード全体を見てみましょう。

list(postId: number): Observable<Comment[]> {
  return this.http
    .get(`https://jsonplaceholder.typicode.com/comments?postId=${postId}`)
    .pipe(
      mergeMap((responses: any[]) =>
        zip(
          ...responses.map(response =>
            this.postRepository.get(response.postId)
          )
        ).pipe(map((posts: Post[]) => ({ responses, posts })))
      ),
      map(({ responses, posts }) =>
        responses.map(
          (response, i) =>
            Object.assign(response, { post: posts[i] }) as Comment
        )
      )
    );
}

コードが読めるようになっていたら大成長です!🎉

例によって、やっぱりまだよく分からないという人はぜひ何度も読み返してみてください💪

3. AppComponent

最後に、呼び出し元である AppComponent のコードは例えば以下のようになります。

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

import { User } from "../models/user";
import { Post } from "../models/post";
import { Comment } from "../models/comment";
import { UserRepositoryService } from "../repositories/user-repository.service";
import { PostRepositoryService } from "../repositories/post-repository.service";
import { CommentRepositoryService } from "../repositories/comment-repository.service";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent implements OnInit {
  constructor(
    private userRepository: UserRepositoryService,
    private postRepository: PostRepositoryService,
    private commentRepository: CommentRepositoryService
  ) {}

  ngOnInit() {
    this.userRepository.get(1).subscribe((user: User) => console.log(user));
    this.postRepository.get(1).subscribe((post: Post) => console.log(post));
    this.commentRepository.get(1).subscribe((comment: Comment) => console.log(comment));
    this.commentRepository.list(1).subscribe((comments: Comment[]) => console.log(comments));
  }
}

4つの .subscribe() がそれぞれ非同期に処理されるので、必ずしも user post comment comments の順に出力されない、というのも地味にポイントです。

🤖デモはこちら

余談

ちなみに、今回はWeb APIのデータが正規化されていたので複数回リクエストしないと完全なモデルを獲得できませんでしたが、サーバーサイドも自分で作っている場合や、FirestoreなどのNoSQLをバックエンドに使っている場合には、無駄なリクエストを減らすためにデータを非正規化することも検討してみるとよいと思います。

おわりに

というわけで、Angularユーザーの鬼門であるRxJSについて解説してみました。

全然そんなつもりはなかったのに、気付いたら2万文字越えの大作になってしまいました💨

入門記事などで雰囲気は分かっていたけど、実際のプロダクトコードでどんな風に使うのかイメージできていなかったという人のお役に立てば嬉しいです💪

実際、僕自身も初心者の頃にこの記事の最後の例のようなコードがまったく書けなくて、ググっても具体的な情報が1ミリも見つけられず大変苦労しました😭

なので、同じように困っている初心者の人が具体的なサンプルコードを見て理解を深める助けになればと筆をとった次第です。

この記事に書いた内容をベースに Angular実践入門チュートリアル のほうにもRxJSについてのより詳しい解説を加筆したいと思っているので、そちらもぜひ楽しみにしていてください!😇

Angular Advent Calendar 2020、明日は @FuwattoFlower さんです!お楽しみに!

参考サイト

GitHubで編集を提案

Discussion