レースコンディションを解決する:AngularにおけるRxJSとリアクティブプログラミングのマスター術

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

同期が取れないUIのフラストレーション

かつて、まるで何かに取り憑かれたような検索バーのデバッグに6時間も費やしたことがあります。理屈の上では、ロジックは単純でした。ユーザーが入力し、APIを呼び出し、UIを更新する。しかし、テスト中に奇妙なバグが発生しました。「iphone」と普通の速さで入力すると、「ipho」の結果が「iphone」の結果の後に届くことがあったのです。これにより、正しいデータが古い情報で上書きされてしまいました。UIはちらつき、誤った価格が表示され、最終的にはフリーズしてしまいました。

皆さんもこのような経験があるのではないでしょうか。ステップ2の結果に依存するステップ3のマルチステップフォームで、ユーザーが「次へ」を2回クリックしてしまい、二重送信がトリガーされるようなケースです。あるいは、3つの異なるマイクロサービスからデータを取得しており、コードがネストされた.subscribe()呼び出しの複雑な網の目のようになっているかもしれません。これらのパターンは、キャンセルしたり効果的に管理したりすることがほぼ不可能です。

これは典型的な非同期データストリームの問題です。エンタープライズ向けアプリの開発経験から言えば、このフローをマスターできるかどうかが、「Hello World」レベルのプロトタイプ作成で終わるか、信頼性の高いキビキビとしたAngularアプリケーションをリリースできるかの境界線になります。

根本原因:並列の世界で線形に考えてしまうこと

通常、私たちのデフォルトの考え方は命令型ロジックです。つまり、AをしてからBをし、その次にCをするという考え方です。これは同期タスクには完璧に機能します。しかし、連打されるクリックや変動するWebSocketメッセージのように、イベントが予測不能な間隔で発生する場合、「その次に」というロジックは崩壊します。

Promisesは「中間」状態を制御する手段をほとんど提供しません。一度Promiseが発行されると、それを簡単にキャンセルすることはできません。ユーザーが2秒間に5回リクエストをトリガーした場合、Promiseベースのシステムには最初の4回を無視して最新のものだけを処理する組み込みの仕組みがありません。この制御の欠如が、完了の順序が意図した順序と一致しないレースコンディション(競合状態)を引き起こします。

Angularは根本からRxJS(Reactive Extensions for JavaScript)に基づいて構築されています。手動の状態フラグやネストされたサブスクリプションを使ってこのリアクティブな性質に抗おうとすると、脆弱で管理不能なコードになってしまいます。

解決策の比較:Promise vs RxJSオペレーター

なぜRxJSがAngularの業界標準であるかを理解するために、単純な検索機能を異なるアプローチで処理した場合を見てみましょう。

アプローチ1:Promiseの罠

// 一見きれいに見えますが、レースコンディションが隠れています
async onSearch(query: string) {
  this.loading = true;
  try {
    const results = await this.apiService.search(query).toPromise();
    this.results = results;
  } finally {
    this.loading = false;
  }
}

問題点:「ABC」の結果が100msで返ってくる一方で、「AB」の結果に500msかかると想像してください。UIには最後に「AB」の結果が表示されます。ユーザーが単語を最後まで入力し終えているにもかかわらず、古いデータが表示されてしまうのです。リクエストが処理中(in-flight)になった後、その「AB」リクエストを止める方法はありません。

アプローチ2:ネストされたサブスクリプション

// この「コールバック地獄」パターンは避けてください
this.searchSubject.subscribe(query => {
  this.apiService.search(query).subscribe(results => {
    this.results = results;
  });
});

問題点:これは単にアプローチ1を冗長にしただけです。レースコンディションを制御できないことに変わりはありません。さらに悪いことに、これらのサブスクリプションが追跡されたりクローズされたりしていないため、メモリリークが発生します。

アプローチ3:高階マッピングオペレーター

ここでRxJSの本領が発揮されます。オペレーターを使用して、「外部」のオブザーバブル(ユーザーの入力)に基づいて「内部」のオブザーバブル(API呼び出し)を処理します。最近のプロジェクトでは、このパターンに切り替えたことで、API関連のバグが約60%減少しました。

  • mergeMap: すべてのリクエストを発行し、すべての結果を保持します。アイテムのリストを保存する場合など、すべての操作を完了させる必要がある場合に使用します。
  • concatMap: リクエストをキューに入れます。前のAPI呼び出しが終了するのを待ってから次を開始します。データベースの更新など、順序が不可欠なシーケンスに最適です。
  • switchMap: 「キャンセラー」です。新しいリクエストが来ると、直前のリクエストを即座に破棄します。検索バーの第一選択肢です。
  • exhaustMap: 「無視」オペレーターです。リクエストが実行中の場合、それが終わるまで新しい入力はすべて無視されます。ログインボタンの二重クリック防止などにに使用します。

宣言的ストリーム戦略

Angularで非同期フローを処理する最も堅牢な方法は、コンポーネント内での手動の.subscribe()呼び出しを避けることです。代わりに、データをストリームとして定義し、テンプレートでAsyncPipeを使用します。これにより、ライフサイクル全体が自動的に管理されます。

本番環境で私が構築している検索とフィルタリングのストリーム構造は以下の通りです:

// component.ts
readonly searchResults$ = this.searchSubject.pipe(
  // 1. ユーザーが入力を止めてから300ms待機
  debounceTime(300),
  // 2. テキストが実際に変更された場合のみ検索
  distinctUntilChanged(),
  // 3. 空文字や短いクエリは無視
  filter(query => query.length > 2),
  // 4. 保留中のリクエストをキャンセルして最新のものに切り替える
  switchMap(query => this.apiService.search(query).pipe(
    // 5. メインストリームが壊れないよう、ここでエラーをキャッチ
    catchError(err => {
      this.errorService.notify(err);
      return of([]); 
    })
  )),
  // 6. 後からサブスクライブした人のために結果をキャッシュ
  shareReplay(1)
);

HTMLでの実装は非常にクリーンです:

<div *ngIf="searchResults$ | async as results; else loading">
  <ul>
    <li *ngFor="let item of results">{{ item.name }}</li>
  </ul>
</div>
<ng-template #loading>検索中...</ng-template>

メリット:

  1. メモリリーク・ゼロ:コンポーネントが破棄される際、AsyncPipeが自動的にクリーンアップを行います。
  2. 鉄壁のUI:switchMapにより、最新のAPI結果のみがテンプレートに届くことが保証されます。
  3. サーバー負荷の軽減:debounceTimeにより、10文字の単語を入力する際に10回バックエンドを叩くのを防ぎます。
  4. 回復性:switchMap内でcatchErrorを処理することで、1つのリクエストが失敗してもセッションの残りの間、検索バーが機能しなくなることはありません。

フローの制御を手に入れる

RxJSの学習曲線は急です。私も、ロジックがようやく腑に落ちるまで、何ヶ月もオペレーターの暗記に費やしました。私からの最高のアドバイスは、データを静的な変数として見るのをやめることです。データを「パイプ」として捉え始めてください。水が一方の端から流れ込み(ユーザーイベント)、フィルター(オペレーター)を通り、もう一方の端からUIの準備が整った状態で出てくるのです。

まずは、switchMapmergeMapconcatMapexhaustMapの4つのマッピングオペレーターをマスターすることに集中してください。これらがどのように内部オブザーバブルを管理するかを理解すれば、複雑な非同期バグの90%は消え去るでしょう。コードはより短く、予測可能になり、メンテナンスが格段に容易になります

Share: