[Angular][RxJS] 入門記事を読んでも分からなかった、Web APIをObservableで扱うときの具体的なコード例
はじめに
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
という値がフィルタリングされて、 10
と 20
の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 だけでなく Post と Comment もモデル化してそれぞれリポジトリサービスからモデルの形で取得できるようにしたいと思います。
// 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
だったりしていることに注目してください。(これによって一気に話がややこしくなります😅)
PostRepositoryService
1. さて、それでは先ほどと同様に、まずは 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)
-
{ resopnse, user }
というプロパティが2つあるオブジェクトが流れてきたときに -
Object.assign を使って
response
オブジェクトに{ user }
オブジェクト({ user: user }
の略記)を上書きコピーして- つまり、
response
オブジェクトにuser
というプロパティがあれば、その内容をuser
変数の値で置き換える
- つまり、
- その結果を
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#user
が User
型なので、レスポンスから取得した userId
を元に User
を取得する必要があるためです。
そしてこれをさらに .pipe(map())
で変形していますね。
.pipe(map(user => ({ response, user })))
流れてきた user
を { response, user }
というオブジェクトに変形して、 user
と response
をセットにして次に流せるようにしています。
これが先ほど見た1つ目の map()
に渡ってくるという構造になっているわけですね。
なぜ map()
ではなく mergeMap()
なんていう知らないオペレータを使っていたのかという問題が残っていますが、これは、値の変形の過程に非同期処理が入る ためです。
先ほど、 response
を { response, user }
に変形するために、 this.userRepository.get()
を実行して Observable<User>
を取得しましたよね。
何も考えずに map()
で変形しようとすると、 User
を流したいのに Observable<User>
を流してしまうことになります。
こういう場合に有用なのが今回使った mergeMap というやつで、このオペレータは 非同期を解決した上で次に流してくれる という性質を持っています。
同じように非同期を解決してくれるオペレータに concatMap や switchMap というものもあり、それぞれ微妙に機能が異なりますが、実は今回の例ではどれを使っても同じように動作します。
mergeMap
concatMap
switchMap
の比較については以下の記事が参考になります。(ただし、内容がRxJS v5以前を対象にしたものになっていて、コードの書き方がv6以降とは若干異なっているので注意してください)
ここまで理解した上で、もう一度コード全体を見てみましょう。
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)
);
}
コードが読めるようになっていたら大成長です!🎉
やっぱりよく分からない…という人は、何度も読み返したり、実際に手元でコードを動かしたりしてみながらじっくり理解してみてください🙏
CommentRepositoryService
2. 同様に 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
)
)
-
{ resopnses, posts }
というプロパティが2つ(いずれも配列)あるオブジェクトが流れてきたときに -
responses
配列を Array.prototype.map() で回して - 1つ1つの
response
について、Object.assign を使ってresponse
オブジェクトに{ post: posts[i] }
オブジェクト(responses
のループインデックスに対応するposts
の中身)を上書きコピーして- つまり、
response
オブジェクトにpost
というプロパティがあれば、その内容をposts[i]
の値で置き換える
- つまり、
- その結果を
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)
)
)
この部分は、
-
responses
1つ1つについてthis.postRepository.get()
を使ってObservable<Post>
を取得し - それらすべての
Observable<Post>
をzip()
に引数として渡し-
...
はJavaScriptの スプレッド構文 です
-
- 結果として
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
)
)
);
}
コードが読めるようになっていたら大成長です!🎉
例によって、やっぱりまだよく分からないという人はぜひ何度も読み返してみてください💪
AppComponent
3. 最後に、呼び出し元である 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 さんです!お楽しみに!
Discussion