🐘

AngularFirestoreでreference型のフィールドを再帰的に解決しつつアプリ側のモデルに変換してデータを受け取る

2020/06/08に公開

Angular + @angular/fire + Cloud Firestore でデータの取得に苦心したので備忘録です😓

Firestoreのデータにreferenceの配列があるケース

下図のような「投稿( posts )」と「コメント( comments )」というコレクションがあります。

posts のドキュメントは comments というフィールドを持っていて、これは comments コレクションのドキュメントへの参照( reference 型)になっています。

このような場合に、投稿を取得すると同時に配下のコメントも全件再帰的に取得して、AngularFirestoreのドキュメントオブジェクトではなくアプリ側で作ったモデルのオブジェクトに変換して受け取りたいというのが今回の要件です。

大前提として、Firestoreの reference 型は親を取得したら子も芋づる式に取得できたりはしません。クライアント側で参照を辿って取得してあげる必要があります。

参考: angular - access data from document referenced inside firestore collection - Stack Overflow

1. モデルのインターフェースを用意

// src/models/post.ts
import { Comment } from './comment';

export interface Post {
  title: string,
  body: string,
  comments: Comment[],
}
export interface Comment {
  body: string,
}

2. リポジトリサービスクラスを用意

$ ng g s post-repository
$ ng g s comment-repository
// src/repositories/post-repository.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { AngularFirestore } from '@angular/fire/firestore';
import { Post } from '../models/post';

@Injectable({
  providedIn: 'root'
})
export class PostRepositoryService {
  constructor(
    private firestore: AngularFirestore,
  ) {}

  get(id: string): Observable<Post> {
    // ここをどう実装するか
  }
}
// src/repositories/comment-repository.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { AngularFirestore } from '@angular/fire/firestore';
import { Comment } from '../models/comment';

@Injectable({
  providedIn: 'root'
})
export class CommentRepositoryService {
  constructor(
    private firestore: AngularFirestore,
  ) {}

  get(id: string): Observable<Comment> {
    // ここをどう実装するか
  }
}

こんな感じでバックエンドと会話する責務をリポジトリに閉じ込めて、コンポーネントからはリポジトリの get() メソッドを使うようにします。

あとはこのリポジトリを実装するだけです。(それが一番難しい)

3. リポジトリサービスクラスを実装

必死でObservable職人になった結果、今回は以下のような実装になりました。

// src/repositories/post-repository.service.ts
import { Injectable } from '@angular/core';
import { Observable, zip } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { AngularFirestore } from '@angular/fire/firestore';
import { Post } from '../models/post';
import { Comment } from '../models/comment';
import { CommentRepositoryService } from './comment-repository.service';

@Injectable({
  providedIn: 'root'
})
export class PostRepositoryService {
  constructor(
    private firestore: AngularFirestore,
    private commentRepository: CommentRepositoryService,
  ) {}

  get(id: string): Observable<Post> {
    return this.firestore.doc<Post>(`posts/${id}`).valueChanges().pipe(
      map((post: Post) => {
        return {
          post: post,
          commentIds: post.comments.map((comment: any) => comment.path.replace(/^comments\//, '')),
        };
      }),
      switchMap(({post, commentIds}) => {
        return zip(...commentIds.map(commentId => this.commentRepository.get(commentId))).pipe(
          map((comments: Comment[]) => {
            return Object.assign(post, {comments: comments}) as Post;
          }),
        );
      })
    );
  }
}
// src/repositories/comment-repository.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { AngularFirestore } from '@angular/fire/firestore';
import { Comment } from '../models/comment';

@Injectable({
  providedIn: 'root'
})
export class CommentRepositoryService {
  constructor(
    private firestore: AngularFirestore,
  ) {}

  get(id: string): Observable<Comment> {
    return this.firestore.doc<Comment>(`comments/${id}`).valueChanges();
  }
}

CommentRepositoryService のほうは普通に ドキュメント に書かれているとおりの使い方をしているだけです。

問題は PostRepositoryService のほうですね。やっていることの流れは、

  1. 普通に post を取得
  2. この時点では post.comments はAngularFirestoreのドキュメントの配列になっているので、それを一旦 ドキュメントIDの配列 に変換
  3. postcommentIds をセットにして次のパイプライン( switchMap )に流す
  4. 受け取った commentIds を分解して CommentRepositoryService.get() に投げる(結果、 Observable<Comment> の配列ができる)
  5. zip を使って「Observableの配列」を「配列のObservable」に変換
  6. post.comments の中身を、取得完了したコメントの配列で上書きして、完了

という感じです。

おまけ

ちなみに上記のように .valueChanges() でドキュメントをストリームに変換すると、ドキュメントIDなどのメタデータは取得することができず、あくまでデータの内容しか手に入りません。

もしアプリ側でドキュメントIDを持つような実装にしたい場合は、 .valueChanges() の代わりに .snapshotChanges() を使って自力でデータを取り出すコードを書けば対応できます。

参考:angularfire2 - How to include the document id in Firestore collection in Angular 5 - Stack Overflow

GitHubで編集を提案

Discussion