[Angular] Promiseを使うべきかObservableを使うべきかみたいな話

10 min read読了の目安(約9200字

本記事はAngular Advent Calendar 2020 4日目の記事です。
前日に引き続きRxJSネタですがとりあえず丸被りしてなくてホッとしてますw

AngularにはRxJSという非同期処理ライブラリがビルトインで入っています。
RxJSは非同期処理をする際にオブザーバーパターンのような恩恵を受けられるメリットがあります。
Angularの公式チュートリアルを見れば非同期処理はすべてRxJSで書かれていますが、
一方でJavaScript(ES2015以降)にはPromiseという非同期処理の仕組みがあります。

RxJSとPromiseどちらをどういうときに使えばいいのでしょうか?
僕はWebフロントエンドにAngularから入ったので、RxJS(Observable)の性質もよく考えず使った結果、
switchMapなどのObservableを切り替えるオペレータやcombineLatestなどのObservableを組み合わせるオペレータの多用で反って読みづらいコードにしてしまった苦い経験があります。

この記事では両者の性質を踏まえた上でRxJSに向く場合とPromiseに向く場合について考えてみたいと思います。

Promiseの簡単なおさらい

PromiseはES2015に登場した非同期処理の仕組みです。
大まかに説明するとPromise.then()で非同期的な実行をすることができ、async/await構文で同期的にも書けます。

// 1→2の順で実行される
new Promise((resolve) => {setTimeout(() => resolve(2), 1000)})
  .then((n) => console.log(n));
console.log(1);
function asyncRandom(n: number): Promise<number> {
  return new Promise((resolve) => setTimeout(() => resolve(n), Math.random() * 1000));
}
// 実行順は毎回異なる
asyncRandom(1).then((n) => console.log(n));
asyncRandom(2).then((n) => console.log(n));
asyncRandom(3).then((n) => console.log(n));

async function run() {
  // 上から下に順番に実行される
  let n = await asyncRandom(1);
  console.log(n);
  n = await asyncRandom(2);
  console.log(n);
  n = await asyncRandom(3);
  console.log(n);
}
run();

RxJSと比較する上ではPromiseは生成されたときに1度だけ値を渡すという点を抑えておいてください。

RxJSの簡単なおさらい

RxJSはオブザーバーパターンというソフトウェア設計手法を用いた非同期処理ライブラリです。
また、公式ドキュメントThink of RxJS as Lodash for events.と例えられているように、
filtermapなどのオペレータを用いて宣言的に非同期的な値を操作することができるのも特徴の一つです。

RxJSを使うメリット

RxJSを使うメリットはまさにオブザーバーパターンにあります。
オブザーバーパターンによりObservableの購読者は一度購読(subscribe)するだけで継続的に値の変更を受けることができます。
また、1つのObservableを複数箇所で購読している状況下ではそのObservableに値を流すだけで、複数の購読者に対し同時に同じ値を送ることができます。

Observable

const obs = from([1, 2]); // 1→2と順番に値を送る

obs.subscribe(v => console.log('Observer 1:', v));
obs.subscribe(v => console.log('Observer 2:', v));

// Observer 1: 1
// Observer 1: 2
// Observer 2: 1
// Observer 2: 2

Observableの実用的な例としては、ActivatedRouteを使ったURLに含まれるIDの継続的な取得という例があります。
例えば、/users/:idというルーティング設定があったとして、以下のように書くとIDが変わるごとにリクエストを送ることができます。

@Component({
  selector: 'app-user-profile',
  template: `<app-user-details *ngIf="user" [user]="user"></app-user-details>`
})
class UserProfileComponent implements OnInit {
  user?: User;

  constructor(private route: ActivatedRoute, private userService: UserService) {}

  ngOnInit() {
    this.route.paramMap.pipe(
      // ParamMap → string
      map(params => params.get('id')),
      // Observable<string> → Observable<User>
      switchMap(id => this.userService.fetch(id))
    )
    .subscribe(user => (this.user = user));
  }
}

switchMapは別のObservableな非同期処理に切り替えるときに使います。
また、switchMapは直前のイベントが完了する前に次のイベントが発生した場合に、直前のイベントを中断して次のイベントに切り替えるという性質を持っています。
上記の例だとid=tanakaを受け取りGET /users/tanakaのレスポンスを受け取る前に、id=suzukiが送られてきた場合にGET /users/tanakaをキャンセルし、GET /users/suzukiのリクエストを送ります。

Subject

RxJSの恩恵を受けるにはSubjectも欠かせない存在でしょう。
Subjectは特殊なパターンのObservableです。Observableはread onlyなObservable、Subjectはread/write可能なObservableと捉えるとわかりやすいです。
また、SubjectがObservableと異なる点として、Observableは購読されるまで実際に値を流すのを保留するのに対し、Subjectはいつ購読されるかに関わらず値を流すことができます。

以下はシンプルなObservableの例です。subscribeしている行をコメントアウトすると値が流れないことが確認できます。

そしてSubjectの例です。Subjectに値がnext()された時点でSubjectの購読者は値を受け取ることができます。途中から購読した場合はその時点以降に流された値のみ受け取ることができます。

const subject = new Subject<number>();

subject.subscribe(x => console.log('Subscriber 1:', x));

subject.next(1);
subject.next(2);

subject.subscribe(x => console.log('Subscriber 2:', x));

subject.next(3);

// Subscriber 1: 1
// Subscriber 1: 2
// Subscriber 1: 3
// Subscriber 2: 3

BehaviorSubject

BehaviorSubjectはSubjectの派生型の中でもよく使うので紹介します。
BehaviorSubjectはSubjectと異なり、subscribe時に現在の値を取得することができます。

const subject = new BehaviorSubject(0); // BehaviorSubjectは初期値を指定する必要がある

subject.subscribe(x => console.log('Subscriber 1:', x));

subject.next(1);
subject.next(2);

subject.subscribe(x => console.log('Subscriber 2:', x));

subject.next(3);

// Subscriber 1: 0
// Subscriber 1: 1
// Subscriber 1: 2
// Subscriber 2: 2
// Subscriber 1: 3
// Subscriber 2: 3

以下は2回目以降の呼び出しではインメモリキャッシュを使うシンプルな実装例です。(別のユーザーIDの取得やキャッシュクリアは考慮しません)

export class UserService {
  // コンポーネントはngOnInitでこのuser$をsubscribeするかasyncパイプに渡すだけでいい
  get user$() {
    return this.userSubject.asObservable();
  }
  private userSubject = new BehaviorSubject<User |  null>(null);

  constructor(private httpClient: HttpClient) { }
  
  async fetch(userId: User['id']) {
    const cached = await this.user$.pipe(take(1)).toPromise();
    if (!cached) {
      const user = await this.httpClient.get<User>(`/users/${userId}`).toPromise();
      userSubject.next(user);
    }
  }

  async update(user: User) {
    const user = await this.httpClient.put<User>(`/users/${user.id}`, user);
    userSubject.next(user);
  }
}

この例では単方向のデータフローも実現しています。Promiseに変換して操作していますが、これに関しては後の節で詳しく説明します。

備考

この実装にはいくつか注意点があります。

  1. asyncパイプにuser$のような元SubjectのObservableを渡したい場合、BehaviorSubjectにする必要があります。[コードサンプル]

  2. また、実装例にある通り、Subject/BehaviorSubjectをPromiseに変換するにはtake(1)を挟む必要があります。
    toPromise()はObservableのcompleteを待つ実装になっているためです。[やや古いですが情報源はこちら] [コードサンプル]

  3. この例ではtoPromise()を用いてPromiseに変換していますが、実は次のバージョンのRxJS v7ではdeprecatedになります(そしてv8で消されます)。
    RxJS v7/v8以降ではlastValueFrom()/firstValueFrom()という関数が用意されているのでそれらを使うようにしましょう。[参考]
    変換が大変なのでAngularでRxJSのバージョンが上がるときにはng updateでよしなにしてくれるといいですね(切実)。

コンポーネントはuser$を初期化時に購読しておけば、いつuserを取得して表示すればいいのかを気にする必要がありません。
以下はコンポーネントの実装例です。

@Component({
  selector: 'app-user-profile',
  template: `
    <app-user-details
      *ngIf="user$ | async as user"
      [user]="user"
      (update)="onUpdate($event)"
    ></app-user-details>
  `
})
class UserProfileComponent implements OnInit {
  get user$() {
    return this.userService.user$;
  }

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.route.paramMap.pipe(
      map(params => params.get('id'))
    )
    .subscribe(id => this.userService.fetch(id));
  }

  onUpdate(user: User) {
    this.userService.update(user);
  }
}

asyncパイプを用いるとObservableの購読を簡潔に書ける場合があります。これに関してはAngular After Tutorialの説明が詳しいです。

https://zenn.dev/lacolaco/books/angular-after-tutorial/viewer/2-2-subscribe-observables-in-components

RxJSを使うデメリット

RxJSを使うメリットとして、オブザーバーパターンにより非同期的な変更を継続的に受け取れるという側面を中心に説明しました。
他にもRxJSにはfiltermapなどの沢山のオペレータが用意されています。これはこれで便利なのですが個人的には沢山ありすぎるように思っています。
どれくらい沢山あるかというと現時点でこのページのoperatorsの項目だけでも100種類近くあります。

100種類近くあるとしても実際に使うのは数種類に絞り込めると思いますが、同じ目的でも異なるオペーレータで実現できることもあるので人によって使うオペレータが微妙に異なるということも出てきてしまいます。
例えば、mapToで済むときにmapToを使うのかmapを使うのか、あるいは複数Observableの組合わせにzipを使うのかcombineLatestを使うといった場合です。zipcombineLatestは厳密には挙動が違うのですが、どちらを使ってもいい場合も中にはあります。
チームで、こういうときにはこういうオペレータを使えばいいというルールを明確に示せればいいですが、RxJSを使った開発を熟知した人がいなければ難しい気もします。

RxJSとそのオペレータを使うことにこだわり過ぎると反って記述量が増えて複雑性が増してしまう場合もあります。
例えば、ユーザー設定をObservableで取得して、その設定によって追加情報の取得を決定したり、設定によって追加取得する情報の種類を変えたりする場合です。

const id$ = this.route.paramMap.pipe(map(params => params.get('id')));
const user$ = id$.pipe(switchMap(id => this.userService.fetch(id)));
const options$ = id$.pipe(switchMap(id => this.optionsService.fetch(id)));
const additionalInfo$ = combineLatest([id$, options$]).pipe(
  map(([id, options]) => ([id, options.additional])), // additional: boolean
  switchMap(([id, additional]) => {
    if (additional) { 
      return this.userService.fetchAdditionalInfo(id);
    } else {
      return of(null);
    }
  })
);
combineLatest([user$, additionalInfo$]).subscribe(([user, additionalInfo]) => {
  this.user = user;
  this.additionalInfo = additionalInfo;
});

途中のof(null)は一見必要にないように見えますが、combineLatestは組み合わせるObservableの値がそれぞれ最低1回は出揃わないと値を流さないので必要になります。
user$additionalInfo$をasyncパイプに突っ込めば最後のcombineLatestは必要なくなるのでシンプルになりますが、additionalInfo$を取得する場合は最初の描画タイミングをuser$と揃えたいという場合にはやはり必要になります。
additionalInfoは設定に関わらず取得しておいて*ngIfで表示可否を決めるという判断もできますが、できることならパフォーマンスの都合上必要のない情報の取得は避けておきたいです。

まとめ

PromiseとRxJSはどちらとも非同期処理を扱うことができますが、PromiseにはPromiseの思想がありRxJSにはRxJSの思想があるので、当然両者の性質も異なります。
今回、オブザーバーパターンの側面からRxJSの便利なところに触れましたが、非同期処理を扱う場合の中にはこのメリットがあまり活きない場面もあると思います(例えば購読するも1度しかその値を参照しない場合)。
PromiseにはPromiseのRxJSにはRxJSのメリットがあるので複数の手段を取れる場合には、両者の性質を並べた上で比較してみると答えが見えてくるのではないでしょうか。

明日のAngular Advent Calendarは@shioyangさんです!