驚くほどシンプルな方法Firestoreのパフォーマンスを向上させる&コスト削減


このポストは、すでによく知られている人々のためのものですFirebase's Firestore .
最近、簡単なクエリキャッシュを使ってFirebaseのFirestoreの性能を高め、コストを下げることができました.

  • Edit :しばらくの間プロダクションでこれを使用して、私はこのテクニックが主にパフォーマンス改善であるとわかります、そして、コストに大きな影響を与えません(多分、私はすでにコストを減らすFirestore Persistenceを可能にしました).

  • 問題
    FireBaseのFirestoreはかなり速いですが、自動的にデータを結合するのをサポートしていないので、一般的な回避策はObservablesを使用して以下のようなことをすることですrxFire docs ):
    import { collectionData } from 'rxfire/firestore';
    import { getDownloadURL } from 'rxfire/storage';
    import { combineLatest } from 'rxjs';
    import { switchMap } from 'rxjs/operators';
    
    const app = firebase.initializeApp({ /* config */ });
    const citiesRef = app.firestore().collection('cities');
    
    collectionData(citiesRef, 'id')
      .pipe(
        switchMap(cities => {
          return combineLatest(...cities.map(c => {
            const ref = storage.ref(`/cities/${c.id}.png`);
            return getDownloadURL(ref).pipe(map(imageURL => ({ imageURL, ...c })));
          }));
        })
      )
      .subscribe(cities => {
        cities.forEach(c => console.log(c.imageURL));
      });
    
    この例では、都市のコレクションが読み込まれているし、それぞれの都市に返される画像データを個別に取得する必要があります.
    このメソッドを使用すると、クエリが1つのクエリが数十個のクエリになるにつれて、すぐに遅くなります.私は非営利のために作った小さなアプリケーションでは、これらのすべてのクエリは、時間をかけて追加された(新機能が追加された)と突然すべてのロードするために特定のページの5〜30秒待っているだろう.任意の遅延は、特に迅速にページ間を移動する場合は特に迷惑です.

    "I just loaded this data a second ago, why does it need to load everything again?"


    私がしたかったことは、誰かがいくつかのページの間を前後に移動する場合、それはすぐに再利用することができるように時間のキャッシュクエリデータだった.しかし、多くの考えを与えずに、キャッシュのような実装が時間をかけて、かなりの複雑さを加えるように思えました.私はFirestoreの永続性を使用して、これは自動的にクエリを削除し、キャッシュデータ、およびパフォーマンスを向上させることを願っていますが、それは私が期待していたほどのインパクトを持っていませんでした.
    より良いキャッシュを作成することは本当に簡単でした.

    解決策
    私は、すべての観測者がデータから取り除かれた後でさえ、いくつかの設定可能な時間のために質問への購読を維持する単純な質問キャッシュを実装しました.コンポーネントが新しいクエリを実行すると、すぐにFirestoreを呼び出す代わりに、クエリキャッシュをチェックして、関連するクエリが既に作成されているかどうかを確認します.もしあれば、既存のクエリを再利用します.また、新しいクエリを作成し、将来キャッシュします.
    コード:
    import { Observable, Subject } from 'rxjs';
    import stringify from 'fast-json-stable-stringify';
    import { delay, finalize, shareReplay, takeUntil } from 'rxjs/operators';
    
    /** Amount of milliseconds to hold onto cached queries */
    const HOLD_CACHED_QUERIES_DURATION = 1000 * 60 * 3; // 3 minutes
    
    export class QueryCacheService {
      private readonly cache = new Map<string, Observable<unknown>>();
    
      resolve<T>(
        service: string,
        method: string,
        args: unknown[],
        queryFactory: () => Observable<T>,
      ): Observable<T> {
        const key = stringify({ service, method, args });
    
        let query = this.cache.get(key) as Observable<T> | undefined;
    
        if (query) return query;
    
        const destroy$ = new Subject();
        let subscriberCount = 0;
        let timeout: NodeJS.Timeout | undefined;
    
        query = queryFactory().pipe(
          takeUntil(destroy$),
          shareReplay(1),
          tapOnSubscribe(() => {
            // since there is now a subscriber, don't cleanup the query
            // if we were previously planning on cleaning it up
            if (timeout) clearTimeout(timeout);
            subscriberCount++;
          }),
          finalize(() => { // triggers on unsubscribe
            subscriberCount--;
    
            if (subscriberCount === 0) {
              // If there are no subscribers, hold onto any cached queries
              // for `HOLD_CACHED_QUERIES_DURATION` milliseconds and then
              // clean them up if there still aren't any new
              // subscribers
              timeout = setTimeout(() => {
                destroy$.next();
                destroy$.complete();
                this.cache.delete(key);
              }, HOLD_CACHED_QUERIES_DURATION);
            }
          }),
          // Without this delay, very large queries are executed synchronously
          // which can introduce some pauses/jank in the UI. 
          // Using the `async` scheduler keeps UI performance speedy. 
          // I also tried the `asap` scheduler but it still had jank.
          delay(0),
        );
    
        this.cache.set(key, query);
    
        return query;
      }
    }
    
    /** 
     * Triggers callback every time a new observer 
     * subscribes to this chain. 
     */
    function tapOnSubscribe<T>(
      callback: () => void,
    ): MonoTypeOperatorFunction<T> {
      return (source: Observable<T>): Observable<T> =>
        defer(() => {
          callback();
          return source;
        });
    }
    
    私はこのようにキャッシュを使うことができます.
    export class ClientService {
      constructor(
        private fstore: AngularFirestore,
        private queryCache: QueryCacheService,
      ) {}
    
      getClient(id: string) {
        const query =
          () => this.fstore
            .doc<IClient>(`clients/${id}`)
            .valueChanges();
    
        return this.queryCache.resolve(
          'ClientService', 
          'getClient', 
          [id], 
          query
        );
      }
    }
    
    さて、ClientService#getClient() メソッドが呼び出されると、メソッドの引数と識別子がクエリファクトリ関数とともにクエリキャッシュサービスに渡されます.クエリキャッシュサービスはfast-json-stable-stringify ライブラリは、クエリの識別情報をstringifyし、クエリの可観測性をキャッシュするキーとしてこの文字列を使用します.クエリをキャッシュする前に、次の方法で観測可能です
  • shareReplay(1) 将来の購読者がすぐに最新の結果を得るように加えられます、そして、そのように、基礎的なfirestoreデータへの購読がこの質問への最後の加入者の後でさえ維持されるのをキャンセルします.
  • 最後の加入者が取り消した後に、タイマーが自動的に基礎になっているfirestoreデータから取り除かれて、ユーザーが定義された時間の後にキャッシュをクリアするようにセットされるように、質問への加入者は追跡されます(現在、私は現在3分を使用しています).
  • delay(0) を使用すると、asyncSchedular . キャッシュされた大規模なデータセットをロードするときに、これはUIのSNAPYを保つのを助けることになります.
  • このキャッシュを設定することで、HOLD_CACHED_QUERIES_DURATION クエリ毎に

    結論
    この単純なキャッシュは、性能が大幅に増加し、同じドキュメントが再び再ロードされるのを妨げるなら、潜在的にコストを減らします、そして、再び、連続して.つの潜在的な“gotcha”は、クエリが使用されている場合Date 引数.この場合、使用について慎重にする必要がありますnew Date() クエリに対する引数として、呼び出しごとにクエリに関連付けられたキャッシュキーを変更する(基本的に、これはキャッシュが使用されないようにする).この問題を正規化することで解決できますDate 作成startOfDay(new Date()) 使用date-fns ).
    これが役に立つことを望んでください.