某SNSのチャットを真似る時に悩んだ話

#はじめに
[Firebase Advent Calendar 2019 6日目](https://qiita.com/advent-calendar/2019/firebase)の記事になります

前日の5日目は[@watanabe_yu](https://qiita.com/watanabe_yu/items/3184a88072647112ed4b)さんが投稿された[Firebaseをなるべく安い料金で頑張りたい人へ](https://qiita.com/watanabe_yu/items/3184a88072647112ed4b)というタイトルの記事でした！

最近よくFirebaseって聞くけどぶっちゃけコスト面どんな感じなの？と気になる方もいらっしゃると思うので参考にしてみると良いですね！

前日の記事の紹介はここまでにして今回の記事は、
Firebaseを使用してチャット開発、その中でもルームの扱い方で悩んだのでその悩みと解決策を書いてみようと思います

#開発中の悩みと解決策

## メンバーが二人のルーム作成方法

チャットにはルームという概念が存在してますよね。
そしてルームは個別 or グループと大きく2つに分けられるのかなと思っております。

悩んだのは個別のルームの扱い方です。
Instagramのチャットルームをイメージしてもらえると良いかもしれません。

InstagramのStoryにアクションをした時の例です。

AがBの投稿したStoryXにいいねをした際にStoryXにAがいいねしたことをBにチャットで知らせます。
チャットが投稿されてるRoomをroomABとします。
このRoomABにはAとBのみがメンバーとして含まれます。

さらに、
AがBの投稿したStoryYにいいねをした場合にもStoryYにAがいいねしたとチャットで通知
BがAの投稿したStoryZにいいねした場合もStoryZにBがいいねしたとチャットで通知
これらのケースもRoomABに投稿されます。

Storyに対してRoomが作られるのではなくStoryを投稿した人とのRoomになるので個別Roomになります。


イメージ
<img width="965" alt="スクリーンショット 2019-12-05 18.33.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75593/815586d8-a990-22e4-48e8-e48ee0c32f11.png">

RoomのDocumentはこんな感じです。

```swift
struct Room: Modelable, Codable , RoomProtocol {

    typealias TranscriptType = Transcript

    var name: String?
    var thumbnailImage: File?
    var members: [String] = [] // Roomに入ってるUserIDが入る
    var lastTranscript: Transcript?
    var lastTranscriptReceivedAt: ServerTimestamp = .pending
    var isMessagingEnabled: Bool = true
    var isHidden: Bool = false
    var lastViewedTimestamps: [String : ServerTimestamp] = [:]
}
```

どうやれば
指定の二人のみが参加してる個別RoomのDocumentが引けるQueryが投げれるでしょうか？


membersにAとBが含まれるというANDのQueryでしょうか？
残念ながら型がArrayのFieldに対してANDのQueryは投げれません:cry:
※IN句が最近追加されOR検索は可能
[FirestoreにINQueryが対応されたのでSDK,Ballcapで試してみる](https://qiita.com/giiiita/items/1e2381fbe708514cf5d0)

仮にANDのQueryが投げられたとしても
Cが作ったGroupにA,Bが参加している場合GroupもHitしてしまうためAB個別のRoomを取得するのは厳しそう:disappointed_relieved:

**解決策**
**RoomのDocumentにpairIDという二人のUserIDを足した文字列をFieldに設ける**

```swift
Document<Room>
    .where("pairID", in: [user.id + ownerID, ownerID + user.id])
    .get() { (snapshots, error) in
        if let error = error {
            print(error)
            return
        }
        if (snapshots?.documents.count ?? 0) > 0 {
            //　すでにルームができているのでいいね！の投稿のみ
        } else {
            // ルームがないので作成していいねの投稿
        }
}
```

Storyに対していいねというアクションが行われた場合、
いいねした人のIDとStoryを投稿したUserIDを足した文字列でRoomに対して検索を投げてます。

Queryの結果二人の個別Roomがまだない場合、
投稿したAのUserID + いいねしたBのIDをpairIDとしてroomのFiledに保存しそのroomを作成し、subCollectionのTranscriptsに"いいね"の内容を添えて保存します。
個別Roomが存在している場合は、
subCollectionのTranscriptsに"いいね"の内容を添えて保存するだけです。

`[user.id + ownerID, ownerID + user.id]`
二つ指定している理由は、もうお分かりの方もいらっしゃると思いますが、
投稿したBのUserID + いいねしたAのIDをpairIDとしてroomのFieldに保存される可能性があるためです。
AとB両方がStoryに投稿しどっちが先にいいねするかに起因してるためです。

正直一つは存在しないPairIDになるので無駄と言えば無駄ですが、これで現状解決してます。

他の解決案では、
userIDはユニークなので二つのIDを元に新しくユニークなIDを生成するという案もありましたが、実装とテストに時間がかかりそうだったので今回の方法を選択してます。

※追記
> 正直一つは存在しないPairIDになるので無駄と言えば無駄

この課題の解決策
コメントいただいた方法で
membersのArrayの中身がソートされている前提であれば以下Queryでも大丈夫そうです。
`firestore.collection('rooms').where('members', '==', ['uidA', 'uidB'])`

仕様次第なところがある内容なのですが、
userAが誰でも入れるグループを作りuserBがRoomに入り、その後にuserCがRoomに入る場合
['uidA', 'uidB'] <- userBが入る
['uidA', 'uidB', 'uidC'] <- userCが入る

タイミングによっては個別のRoomをQueryで引きたいのにGroupのRoomも引いて来てしまう可能性があるので、個別とGroupのRoomが混在する場合は個別のRoomを引けるように一つPairFiledを設けるのが良いと思っております。

pairIDを作成する方法についてはIDを比較してアルファベットの若い方を先にして文字列結合するなどの工夫をすればIN Queryを使わずとも個別Roomを引けそうな気がするのでINで指定する要素が一つ減ってラッキーって感じですかね！

# 締め

まだいくつかチャット系で知見あったんですがまたいつかの日に記事にしようと思います！
(Roomの負荷分散とか:frowning2:

明日は[Shohei Nakagawa](https://qiita.com/hukusuke1007)さんがFlutterのFirebase Firestoreモデルライブラリを作った話（Flamingo）というタイトルで記事を投稿してくれるようです！

iosだといくつかライブラリがあるのですがFlutterのは聞いたことがなかったので楽しみです！

最後に
MessageStoreではSampleプロジェクトが用意されているのでまずは手元で動作確認してみたい方はこちらを参考に試してみてください！

**MessageStoreでチャットの動作を確認**
MessageStoreにはSampleプロジェクトが用意されているのでこちらで動作を確認してみましょう。


・[MessageStore](https://github.com/1amageek/Messagestore)をcloneしてみてください
・clone後projectのルートで`pod install`
・chat用にfirebaseプロジェクトを作成
・firebaseコンソールでauthenticationの匿名を有効にする
<img width="1440" alt="スクリーンショット 2019-12-05 17.09.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75593/7f0d1860-ff61-f298-5256-38b8ed34cf9a.png">
・firebaseコンソールでfirestoreを使えるようにする
動作チェックなのでテストモードで確認
<img width="1440" alt="スクリーンショット 2019-12-05 17.12.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75593/1120e9b9-1887-3a66-539f-bff19694a02f.png">
・複合indexの設定

クエリにスコープはCollectionで大丈夫です
<img width="1092" alt="スクリーンショット 2019-12-05 20.04.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75593/db9b44b4-ecb6-d7e6-7e42-83e86168967d.png">


アプリを実行して複合Indexを貼るためのURLを得るものありです。
アプリを実行してlogより以下の文字をコピぺし検索すると複合index貼るようのurlが確認できると思います。
`failed: The query requires an index`
<img width="1440" alt="スクリーンショット 2019-12-05 20.09.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75593/85d972d9-5d70-bea1-ef23-72788f990c58.png">



urlを元にwebページを開くと以下スクショの画面になるかと思うのでIndex作成ボタンを押してください
<img width="608" alt="スクリーンショット 2019-12-05 16.18.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75593/059a5081-59ef-89c5-4cd8-1f3ea629d7b3.png">
・動作確認
![ezgif.com-video-to-gif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75593/d6b8853f-d72f-1d21-ca23-d22617d254e0.gif)