🐤

Flutter製SNSを2ヶ月で作るために【Firebase編】

2021/05/07に公開

Flutterがめちゃくちゃ良いですね.なんと言ってもホットリロードとVSCodeが使える素晴らしさ.しかし,Flutterで開発していると初心者向けの記事か玄人向け(本当は僕の頭では理解できないだけ)の記事が多い気がします.下の上くらいの僕の記事を必要としている誰かのために,下の上の知識でSNSを作るには?なる記事を書いていきたいと思います.使っている状態管理はriverpod+StateNotifier+freezed+Hooksです.

今回の記事ではFirebase周りに焦点を当てていきます.

まず宣伝

作ったアプリはこれです.
https://apps.apple.com/jp/app/kidsroom/id1563660199
https://play.google.com/store/apps/details?id=com.yone.kids_room

(公式以外の)主な参考文献

https://qiita.com/1amageek/items/3270b87cca8390e053ac
https://qiita.com/1amageek/items/d606dcee9fbcf21eeec6
https://tech-blog.sgr-ksmt.org/2019/12/31/160623/
https://qiita.com/takashikatt/items/e17401ff301b71e821cd
https://techlife.cookpad.com/entry/2021/04/21/110000

Komercoの記事は,ほぼ完成してから読んだので参考にできてないですが非常に分かりやすかったです.(もっと前に読みたかった...)
(以下で引用するときには上から[1],[2],[3],[4],[5]の記事と呼ばせていただきます.)

以下本文

参考文献を踏まえて,できるだけ楽に,できるだけ安価に実装する方法を考えました.はっきり言って無理矢理な方法だと思います.参考程度にしていただけたら幸いです.(それはマジで危ないからやめとけということがありましたら早急に教えてください!!!)

Authentication

ユーザー登録の実装はメール&パスワードで行いました.
https://firebase.flutter.dev/docs/auth/usage#emailpassword-registration--sign-in

一番簡単で手っ取り早く,1画面で終わると思ったからです.しかし結局は,メール認証するようなときと同様に2画面(Authの登録とFirestoreの登録)に分けています.Authが成功→Firestoreが失敗,のときに面倒だからです.(Authを登録できてFirestoreが失敗→アプリを再度立ち上げたらAuthのログインに成功するのにFirestoreにユーザー情報がない.みたいなことが起きます.)

以下ロジックのみの実装です.

import 'package:firebase_auth/firebase_auth.dart' as auth;
import 'package:kids_room/domain/repository/auth_repository.dart';

class AuthDataSource extends IAuthDataSource {
  auth.FirebaseAuth authInstance = auth.FirebaseAuth.instance;

  
  auth.User? getCurrentUser() {
    return authInstance.currentUser;
  }

  
  Future<auth.User?> registerWithEmailAndPassword(String email, String password) async {
    await authInstance.createUserWithEmailAndPassword(email: email, password: password);
    return authInstance.currentUser;
  }

  
  Future<auth.User?> logInWithEmailAndPassword(String email, String password) async {
    await authInstance.signInWithEmailAndPassword(email: email, password: password);
    return authInstance.currentUser;
  }

  
  Future<void> logOut() async {
    await authInstance.signOut();
  }

  
  Future<void> updatePassword(String oldPassword, String newPassword) async {
    final email = authInstance.currentUser?.email;
    final credential = auth.EmailAuthProvider.credential(email: email ?? '', password: oldPassword);

    await authInstance.currentUser?.reauthenticateWithCredential(credential);
    await authInstance.currentUser?.updatePassword(newPassword);
  }
}

IAuthDataSourceというのはabstractで作った抽象クラスです.そのProviderを作ってどこからでも使えるようにしてあげます.

final authDataSourceProvider = Provider<IAuthDataSource>((ref) => AuthDataSource());

なぜこんな事するのか解説できる気がしないので以下の記事を共有させていただきます.本来,Firestoreみたいなときに威力を発揮する書き方だと思います.
https://little-hands.hatenablog.com/entry/2018/12/10/ddd-architecture
とりあえず小並感としましては,「こういう風に書いた方が確かに分かりやすいし使いやすい.インスタンスも一つで済んでるのかも?」といった感じです.ただ,Authでわざわざやる必要はないのかもしれません.また,もっと粒度を大きくすると(AuthとFirestoreを合わせて同じように作ってあげるとRepositoryになって)良いのかもしれません.後述するFirestoreやStorageもこの実装方法に則っています.
https://qiita.com/os1ma/items/46947ef1fb37c8d11878

Firestore

ここからが問題です.なにが問題かというと,SNSに手を出すことが問題なのです.
まず,きれいなタイムライン機能は諦めました.以下の記事がローコストで実装できていますが,運用が大変そう&実装が難しそう(こっちが本音)だと思ったので諦めました.
https://qiita.com/d-nakajima/items/ef9d87dc2c5cdf8d9486
また,サブコレクションにコピーして持ってくる方法も考えましたが,できるだけ無料の範囲でやりたい&SaaS使うのめんどい&そんなユーザー増えんじゃろと思いやめました.(多分このやりかたが一番確実で一番簡単です.)
https://note.com/deerboy/n/n6fb4e57d30c6

ということで,主にクライアントサイドジョインを使います.N+1回のreadが発生する方法です.ただし投稿(item)で冗長化も使います.以下がデータ構造です.(実際の命名とは変えています.)

データ構造

social

サブコレクションのsocialでフォロー機能を実現します.followingとfollowerでコレクションを分け,相手と自分のサブコレクションにbatchで書き込む([1]の記事参照)方法もあります.しかし,ユーザーを削除したタイミングで一緒に削除するのがめんどくさそうな為,今回はsocialコレクションだけでフォロー機能を実装しました.フォロワーを取得する場合にはコレクショングループクエリを使用します.

Future<QuerySnapshot> getFollowerList(String currentUid, int limit) async {
  final _q = await firestore.collectionGroup('social').where('follower', isEqualTo: currentUid).limit(limit).get();
  return _q;
}

これで自分をフォローしているユーザーのuidリストが取得できます.ここで,Referenceではなくuidを使用しているのは[2]の記事で

もしUserのバージョンが更新さたらどうなるでしょうか?Itemは古いバージョンのReferenceを持っているためItemもマイグレーションが必要になります。

とのことからドキュメントIDを使っています.ただ,ReferenceからもドキュメントIDを取得できるのでどちらでも大丈夫かもしれません.(Userごとバージョン更新する場合が想像できない.)

取得したuidのリストを使ってクライアントサイドジョインを行います.本来であれば,N+1回のreadにおいて1にあたるreadが終わったので,あとはuid(N)回readします.しかし,本アプリでは,得られたuidのリストを10個ずつに分けて(正確には上記コードのlimitに10を代入して)whereInで探しました.そうすることでN/5回のreadで済みます.しかし誰もやってないのでこんなことして良いのか不安です.

追記(2021/08/20)

料金は1ドキュメントにつき課金されるので10個ずつに分ける必要はないかもしれません🙇‍♂️

以下がコードです.

Future<QuerySnapshot> getUsers(List<String> uids) async {
  final _q = await firestore.collection('user').where('uid', whereIn: uids).get();
 return _q;
 }

1回のwhereInには10個までしか含められないので注意が必要です.(逆に言うと,10個まで含められるんじゃん!ということです.)また,documentIdでもwhereInは行えますが,フィールドにuidを持たせておいた方が確実です.

フォロー・フォロワー数を取得するのは,userドキュメントで行います.これは[3]の記事が分かりやすいかと思います.socialコレクションの変更をトリガーにCloud Functionsを発火させます.

item

itemでは[1]の記事のJOINを使わない実装を参考にしました.名前より変更頻度の少なくてユーザー自身が設定できる(ほぼ不変な)publicなidと,iconのURLを冗長的に持たせています.以下[1]の記事からの引用です.

個人的にはCloud Firestoreの構造とStorageの構造を一致させることは非常に効果的で、開発時に楽になりますし、セキュリティルールの記載も楽になります。またthumbnailなど特定の場所に使う場合のファイル名は固定するといいでしょう。こうすることで画像データの差し替えではDocument dataの更新は行わずに済みます。

storageのpathを固定するだけで1回のreadで終わるなんて目から鱗です.ただ,cached_network_imageを使っていると情報が更新されないことにだけ注意が必要です.僕はitemごとに入っているユーザーアイコンの更新は諦めました.providerを使えば自分のuser情報だけは最新に保つことができます.

タイムライン

socialコレクションとitemコレクションを使ってタイムラインを実装します.
まずはフォロー中のuidリストを取得します.ここではタイムラインという性質からlimitを使わず全てのuidを取得します.

Future<QuerySnapshot> getFollowingAllList(String currentUid) async {
  final _q = await usersCollection.doc(currentUid).collection('social').get();
  return _q;
}

これを10個ずつに分割してその分割した個数分クエリを飛ばします.(当たり前ですが分割しなくても可能です)

for (var i = 0; i < (_uids.length / 10); i++) {
  final _processedUids = _uids.skip(10 * i).take(10 * (i + 1)).toList();
  // 以下の関数を使ってここでクエリを飛ばす
}

Future<QuerySnapshot> getTimeLine(List<String> uids) async {
  final _q = await firestore.collectionGroup('item').where('uid', whereIn: uids).get();
  return _q;
}

(ここまであげた全てのコードは必要最低限にしてあります.ページングや時間降順などを考慮していません.)

JOINでのタイムラインはフォロー数が増えると破綻する気がします.例えば100人フォローしており,時間順で取得した場合,同じ時間に投稿していても1回目のクエリか10回目のクエリかでタイムラインの下の方に追いやられる場合があります.(これは10回ずつ取ることではなく,JOIN自体に限界があるように感じます.なぜなら,ユーザーごとにlimitで投稿を取得しているからです.これはアクティブ率の低いユーザーを想像すれば分かりやすいです.ユーザーごと等しい数取得するために,異なるユーザーの1年前の投稿と1分前の投稿が,同じタイムライン上を流れることになります.)

新着記事一覧

タイムラインの新着記事一覧を取得する場合,今までであればCloud Functionsでルートにトリガーコピーする必要がありました.
https://medium.com/google-cloud-jp/firestore2-920ac799345c
しかし現在では,コレクショングループクエリを使えばその必要はないかもしれません.コレクショングループクエリの方がセキュリティルールの設定も簡単なうえに,ユーザー削除も非常にシンプルに書けます.

userブロック

見落としがちですがiosアプリでは必須の機能です.userのサブコレクションとして用意しました.splashページで読み取り,providerでuidのListを所持しておいています.
もし訪れたユーザーページがそのブロックリストに含まれていたら,UI側で非表示にします.
日本語の記事がほとんど見つからず,セキュリティルールでガードするような旨の記事がヒットしますが,セキュリティルールはフィルタではないので,そのやり方は良くないと思っています.
https://firebase.google.com/docs/firestore/security/rules-conditions?hl=ja#rules_are_not_filters

その他のユーザーサブコレクション

itemに対するコメント,ブックマーク(いいね)などもuserのサブコレクションにして,コレクショングループクエリで取得します.例えば,あるitemのコメント一覧を取得したい場合,commentにitemIdを含ませ(冗長化),以下のようにして取得します.

Future<QuerySnapshot> getComments(String itemId) async {
  final _q = await firestore.collectionGroup('comment').where('itemId', isEqualTo: itemId).get();
  return _q;
}

user削除

[4]の記事の方法で実現しています.このときに,今までuserのサブコレクションで全て済ませてきたことが報われます.
簡単にまとめると,deleteコレクションにuidを保存→ExtensionsのUserDeleteがトリガーされAuth・Firestore・Storageから該当uidを削除.Firestoreの削除はオプションでサブコレクションを含めることも可能.という感じです.

報告機能

これも見落としがちですがiosアプリでは必須の機能です.僕はルートのコレクションに書き込むだけにしています.ここでは何のコンテンツなのか(コメントなのか投稿なのかユーザーなのか)分かりやすいためReferenceを使っています.

Firestoreのキャッシュ

https://speakerdeck.com/ryunosukeheaven/20201202-port-flutter-firebase-architecture?slide=15
このスライドで紹介されている方法がとても良く,僕も真似させていただいています.null safetyとQueryDocumentSnapshot(Quryも同様)の場合を反映すると以下のように書けるかと思います.

extension GetCaCheElseServerExtensionFromCollection on CollectionReference {
  Future<QuerySnapshot> getCacheElseServer() async {
    QuerySnapshot? snapshot;
    snapshot = await _getFromCache() ?? await _getFromServer();
    return snapshot.docs.isEmpty ? await _getFromServer() : snapshot;
  }

  Future<QuerySnapshot> _getFromServer() => get();
  Future<QuerySnapshot?> _getFromCache() async {
    try {
      return await get(const GetOptions(source: Source.cache));
    } catch (e) {
      return null;
    }
  }
}

Storage

上述したように,userのicon(サムネ)を固定しました.
また,ちょっとしたtipsではありますが,コンソールで画像が確認できるのでmetadataは設定しておいた方がいいかもしれません.(基本的にはFirebaseが自動で判別してくれます.)

final SettableMetadata metadata = SettableMetadata(
  contentType: 'image/jpeg',
);

Future<String> uploadUserIcon(File file, String uid) async {
  final path = 'user/$uid/icon.jpeg';
  await storage.ref().child(path).putFile(file, metadata);
  final url = await storage.ref().child(path).getDownloadURL();
  return url.toString();
}

また,itemのファイル名に関してはUUIDを使用しており,user削除のExtensionsを使うためにuidでフォルダを構成する必要があります.

まとめ

大雑把にですが,以上が僕がアプリを作る際に考えたFirebaseのアレコレです.Firestoreのデータ構造は,無理した分?さっぱりしていて結構気に入っています.また,折角Firestoreで完結しているのに全文検索でalgoliaなどを導入するのは嫌だったのでFirestoreだけで全文検索するためのmapも保存しておきました.(先人たちは偉大だなとつくづく思います.)
https://qiita.com/oukayuka/items/d3cee72501a55e8be44a
書いていくうちにだんだんまとめ記事みたいになってしまいましたが,誰かの役に立てたら嬉しいです.特に参考文献ではコレクショングループクエリをあまり使っていない印象(記事自体がコレクショングループクエリが出る以前のため)なので,少しでも参考になればと思います.

追記 (2021/06/08)

タイムラインの設計stampさんの以下の動画を見て,結局サブコレクションにコピーした方がいい気がしてきました.ですので,修正が終わりしだいまた追記いたします.
https://www.youtube.com/watch?v=TfSVbOhR6Cg&t=613s

サブコレキョンにコピーするやり方

https://twitter.com/dshukertjr/status/919997005836849152?s=20
やっぱりサブコレクションにコピーした方がいい(上記スレ参照)かなと思い、そうしました。
実装自体は、post投稿したらCloud Functionsでトリガーし、フォローされているユーザーのサブコレクションにコピーするだけです。つまり投稿したら、

  1. フォロワーを取得。
  2. そのフォロワーごとのtimelineコレクションに投稿をコピー。(フォロワー回ループ)

となります。
また、フォローしたら、

  1. フォローしたユーザーのpostを◯◯件取得。
  2. 自分のtimelineコレクションに上記をコピー。(post回ループ)

となります。
コピーに関しては、こちらの記事がわかりやすいです。
https://medium.com/google-cloud-jp/firestore2-920ac799345c

上限の設定

stampさんの動画で説明されている「500件の上限」は一旦設けないことにしました。削除するか保持しておくかはコストバランスを考える必要がありそうです。
500件の上限を設ける場合には、timelineコレクションにドキュメントが作られたらトリガーして、

  1. timelineコレクション全体のデータを取得。sizeプロパティで500件以上か確認。
  2. 500件以上なら、削除したいdocを取得。(.orderBy(古い順).limit(差分)
  3. 取得したdecを削除。(doc.ref.delete()

みたいに、『(2Read+1Write)× フォロワー数』が発生する気がします。

また、ユーザー削除したときのことを考えるのがややこしかったです。
Firebaseに用意されているユExtensionsでユーザー削除した際にも、サブコレクションのdeleetトリガーは走ります。よって、【ユーザーがpostを削除した時】など、小さい単位のトリガー(サブコレクションごとのトリガー)だけを考えれば大丈夫です。
削除処理を一度にできる心地よさを考えていましたが、削除はドキュメントごとの料金になる為、消せていれば特にそういったことは考える必要がなさそうです。

Algolia

Algoliaに関しては、コスト面の懸念と、アプリ仕様(投稿する数<読み込む数の仕様)を考え辞めました。Algoliaで実装するのもFirebase Extensionsが使えるので簡単そうで魅力的です。正直流行らなければ無料でいけそうです。ただ、タイムライン機能にAlgoliaを使うのはお金高くて適しているのか謎です。100人が100回featchしたら1万オペレーション。。。
https://qiita.com/qrusadorz/items/74a2e0e5edd7061d8b21

低コストによる実装

あとこの、記事ですが、個人開発で一番おすすめなのはこの方法かもしれません。
https://qiita.com/d-nakajima/items/ef9d87dc2c5cdf8d9486
ルールもFunctions経由でやれば、実装コストは従来のやり方と変わらない気がします。deleteのことを考えて採用しませんでしたが、前述したように削除はドキュメントごとの料金になる為Functionsでどうにか消せば大丈夫です。つまり、記事の仕様外である(記事内コメント参照)フォロー時の処理、ユーザーブロック時の処理も、従来のやり方と同じCRUDで済みます。

Discussion