バックエンドがFirebaseだけでiOSアプリは作れるのか？

Firebaseのイベントでクックパッドの某サービス様が、**「うちはエンジニアはiOSエンジニアだけで、APIも3本くらいです」**とおっしゃっており、「これが時代か」と感動して、いつか触ろうと思っていて、年始で時間もあるし調べて考察。

> 2018-01-04 追記

コメント、Twitterで返信いただき誠にありがとうございます！懸念部分はfirebaseの既存の仕組み+GAE/GCPである程度解決できそうです。また記事書きますー！

# よくあるチャットアプリを例にする

ログインしてチャットができるアプリを作ってみる

## 必要な画面
- ログイン画面
    - ログイン
- チャットルーム一覧画面
    - チャット一覧表示
    - 最新の更新ルームを取得して、自動更新
- チャット詳細画面
    - チャット一覧表示
    - チャットが来たら更新
    - チャット送信

## もし普通にサーバ立ててやるなら

### API

- [POST] /login
- [POST] /logout
- [GET] /chatroom
- [GET] /chat/{targetUserId}
- [POST] /chat/{targetUserId}

必要なエンドポイントはこの辺ですかね。

### インフラ

- サーバ用意
    - ミドルウェアセットアップ（PHP,nginx,mysqlとか）
    - セキュリティ周りの設定
- アプリケーション実装
- 監視の設定
- デプロイ用の設定・準備
- プッシュ通知関連の設定

Sakuraで適当なCentOSのインスタンス借りて始めるとこんなとこ。
「チャットが送られた」とか更新を検知するなら、バックエンドからプッシュを送るかwebsocketなりで検知するしかない。これらはちょっと面倒ではある。

# iOS + Firebaseで実装

![winter.gif](https://qiita-image-store.s3.amazonaws.com/0/31237/9586c5bd-85ab-3022-f9b4-8b8e3a6fab9a.gif)

特に何も考えずに2時間くらいでできた。

## ログイン画面

```swift

//
//  LoginViewController.swift

import UIKit
import FirebaseAuth
import FBSDKLoginKit
import FirebaseDatabase

class LoginViewController: UIViewController {

    @IBOutlet weak var loginBtn : FBSDKLoginButton!
    var ref:DatabaseReference!

    override func viewDidLoad() {
        super.viewDidLoad()
        loginBtn.readPermissions = ["public_profile", "email", "user_friends"]
        loginBtn.delegate = self
        ref = Database.database().reference()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

extension LoginViewController:FBSDKLoginButtonDelegate{
    
    func loginButtonDidLogOut(_ loginButton: FBSDKLoginButton!) {
    }
    
    func loginButton(_ loginButton: FBSDKLoginButton!, didCompleteWith result: FBSDKLoginManagerLoginResult!, error: Error!) {
        
        if (error != nil) {
            print("Error \(error)")
        } else if result.isCancelled {
            print("Cancelled")
        } else {
            print("Login Succeeded")
            let credential = FacebookAuthProvider
                .credential(withAccessToken: FBSDKAccessToken.current().tokenString)
            Auth.auth().signIn(with: credential) { (user, error) in
                if let error = error {
                    print(error)
                    return
                }
                self.postUser()
            }
        }
    }
    
    func postUser(){
        guard let user = Auth.auth().currentUser else{
            assert(true, "post user with nil")
            return
        }
        
        let facebookId = FBSDKAccessToken.current().userID
        let userRef = ref.child("users")

        userRef
            .queryOrdered(byChild: "facebookId")
            .queryEqual(toValue: facebookId)
            .observeSingleEvent(of: DataEventType.value) { (snapshot) in
                if snapshot.exists() {
                    print("Exist user")
                }else{
                    let postUser = ["facebookId": FBSDKAccessToken.current().userID,
                                    "updated_at": Date().toStr(),
                                   "name": user.displayName]
                    let postUserRef = userRef.childByAutoId()
                    postUserRef.setValue(postUser)
                }
                
                self.dismiss(animated: true, completion: nil)
            }
    }
}

```

FacebookログインからのFirebaseAuthを使ってユーザー登録、ログイン管理。
これをやるとFirebaseにアカウントが登録されて、アプリ内にもキャッシュされる。

```
observeSingleEvent
```

というのは変更を値を一度だけ取得するときに使う。


<img width="1077" alt="スクリーンショット 2018-01-03 13.07.25.png" src="https://qiita-image-store.s3.amazonaws.com/0/31237/e9eb2170-9fdb-aa30-2773-657e6f52edaa.png">

FirebaseAuthは[様々な認証方式](https://firebase.google.com/docs/auth/?hl=ja)が用意されているので、方式ごとに登録される情報が異なるようだ。

<img width="392" alt="スクリーンショット 2018-01-03 13.11.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/31237/edc7f5c5-2c9e-c647-9306-f38063154348.png">

メールでの会員登録だと、送信用のテンプレートをwebコンソールから編集できるようだ。
よくできている。

### Realtime Databaseへのユーザー情報の登録
<img width="363" alt="スクリーンショット 2018-01-03 13.14.40.png" src="https://qiita-image-store.s3.amazonaws.com/0/31237/bb7cb035-0bf8-32d0-64c7-360fb41ece7a.png">

上記のソースで登録するとこんな感じで保存される。facebookIdをクエリにしてユーザーの存在チェックをする。keyを任意で発行されているものにしているが、ここはもっとクレバーなアイデアがあったはず。NoSQLな感じなので、あんま階層深くしちゃうと、クライアント側で探索したり、構造の変化に柔軟な実装が難しそうだなぁと思った。

## チャットルーム一覧画面

```swift

//
//  ChatTableViewController.swift

import UIKit
import FirebaseAuth
import FBSDKLoginKit
import FirebaseDatabase
import SDWebImage

class ChatTargetUserCell:UITableViewCell{
    @IBOutlet weak var nameLabel:UILabel!
    @IBOutlet weak var iconImageView:UIImageView!
    
    func bind(user:User){
        self.nameLabel.text = user.name
        self.iconImageView.sd_setImage(with: user.iconURL, completed: nil)
    }
}

class ChatTableViewController: UITableViewController {

    var ref:DatabaseReference!
    var users = [User]()
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if let me =  Auth.auth().currentUser{
            self.title = me.displayName
        }else{
            let loginvc = UIStoryboard(name: "Login", bundle: nil).instantiateViewController(withIdentifier: "login") as! LoginViewController
            self.present(loginvc, animated: true, completion: nil)
        }
        
        self.observe()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    func observe(){
        ref = Database.database().reference()
        
        ref.child("users").observe(DataEventType.value) { (snapshot) in
            self.users = [User]()
            for item in snapshot.children{
                if let snap = item as? DataSnapshot{
                    let user = User(snapshot: snap)
                    self.users.append(user)
                }
            }
            self.users.sort(by: { (pre, next) -> Bool in
                pre.updateAt > next.updateAt
            })
            self.tableView.reloadData()
        }
    }

    @IBAction func tapLogout(){
        let firebaseAuth = Auth.auth()
        do {
            try firebaseAuth.signOut()
            FBSDKLoginManager().logOut()
 
            let loginvc = UIStoryboard(name: "Login", bundle: nil).instantiateViewController(withIdentifier: "login") as! LoginViewController
            self.present(loginvc, animated: true, completion: nil)
        } catch let signOutError as NSError {
            print ("Error signing out: %@", signOutError)
        }
    }
}

// MARK: - Table view data source

extension ChatTableViewController{
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return users.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! ChatTargetUserCell
        let user = self.users[indexPath.row]
        cell.bind(user: user)
        return cell
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let vc = ChatViewController.create(user: users[indexPath.row])
        self.navigationController?.pushViewController(vc, animated: true)
    }
}

```

値の更新を検知する、この辺がキモ。

```swift

ref.child("users").observe(DataEventType.value) { (snapshot) in
   ...
}
```

observeSingleEventとはことなりobserveは常に変更を検知する。
<img width="533" alt="スクリーンショット 2018-01-03 13.24.11.png" src="https://qiita-image-store.s3.amazonaws.com/0/31237/82a5a42a-3ef0-3709-8a63-dcd1ebb58361.png">

このDataEventTypeの種別に寄って、子要素や値全体の変更をどう検知するかを設定できる。

> let user = User(snapshot: snap)

取得できたデータは[DataSnapshot](https://firebase.google.com/docs/reference/android/com/google/firebase/database/DataSnapshot)クラスで取得できる。この中にKeyValueの形式で値が入っているので、適宜entityなどにマッピングする。

```swift

import Foundation
import FirebaseDatabase

struct User {
    let faceboookId:String
    let name:String
    let updateAt:Date
    
    var iconURL:URL?{
        get{
            return URL(string: "https://graph.facebook.com/\(self.faceboookId)/picture")
        }
    }
    
    init(snapshot:DataSnapshot) {
        self.faceboookId = snapshot.childSnapshot(forPath: "facebookId").value as! String
        self.name        = snapshot.childSnapshot(forPath: "name").value as! String
        let dateStr      = snapshot.childSnapshot(forPath: "updated_at").value as! String
        self.updateAt = dateStr.toDate()
    }
}

```

Dataは別にStringにしなくてもいい説もある。書込み可能な構造は

```swift

NSString
NSNumber
NSDictionary
NSArray
```

です。

## チャット詳細画面

```swift

//
//  ChatViewController.swift

import UIKit
import FirebaseAuth
import FBSDKLoginKit
import JSQMessagesViewController
import FirebaseDatabase
import SDWebImage

class ChatViewController: JSQMessagesViewController {

    var messages = [JSQMessage]()
    var targetUser:User!
    var ref:DatabaseReference!
    var roomKey:String!
    
    fileprivate var incomingBubble: JSQMessagesBubbleImage!
    fileprivate var outgoingBubble: JSQMessagesBubbleImage!
    fileprivate var incomingAvatar: JSQMessagesAvatarImage!
    fileprivate var outgoingAvatar: JSQMessagesAvatarImage!

    class func create(user:User)->ChatViewController{
        let vc = UIStoryboard(name: "Chat", bundle: nil).instantiateViewController(withIdentifier: "chat") as! ChatViewController
        vc.targetUser = user
        return vc
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let facebookId = FBSDKAccessToken.current().userID!
        self.senderId = facebookId
        self.senderDisplayName = Auth.auth().currentUser?.displayName
        self.ref = Database.database().reference()

        self.title = targetUser.name
        
        let bubbleFactory = JSQMessagesBubbleImageFactory()
        self.incomingBubble = bubbleFactory?.incomingMessagesBubbleImage(with: UIColor.jsq_messageBubbleLightGray())
        self.outgoingBubble = bubbleFactory?.outgoingMessagesBubbleImage(with: UIColor.jsq_messageBubbleBlue())
        
        SDWebImageDownloader.shared().downloadImage(with: targetUser.iconURL, options: [], progress: nil) { (image, data, err, res) in
            self.incomingAvatar = JSQMessagesAvatarImageFactory.avatarImage(with: image, diameter: 64)
        }
        let url = URL(string: "https://graph.facebook.com/\(facebookId)/picture")
        SDWebImageDownloader.shared().downloadImage(with: url, options: [], progress: nil) { (image, data, err, res) in
            self.outgoingAvatar = JSQMessagesAvatarImageFactory.avatarImage(with: image, diameter: 64)
        }

        createTalkRoomIfNeeded()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

extension ChatViewController{
    
    func createTalkRoomIfNeeded(){
        let facebookId = FBSDKAccessToken.current().userID!
        let roomRef = ref.child("rooms")
        let userIds:[String] = [targetUser.faceboookId, facebookId].sorted()
        roomRef
            .observeSingleEvent(of: DataEventType.value) { (snapshot) in
                if snapshot.exists(){
                    for item in snapshot.children{
                        if let roomSnap = (item as? DataSnapshot),
                            let room = (roomSnap.value as? [String]),
                            room == userIds{
                            print("exist room")
                            self.roomKey = roomSnap.key
                            self.observe()
                            return
                        }
                    }
                }
                print("create room")
                let newRoomRef = roomRef.childByAutoId()
                newRoomRef.setValue(userIds)
                self.roomKey = newRoomRef.key
                self.observe()
        }
    }
    
    func updateUserDate(){
        let userRef = ref.child("users")
        
        userRef
            .queryOrdered(byChild: "facebookId")
            .queryEqual(toValue: targetUser.faceboookId)
            .queryLimited(toFirst: 1)
            .observeSingleEvent(of: DataEventType.value) { (snapshot) in
                
                if let key = (snapshot.children.allObjects[0] as? DataSnapshot)?.key{
                    let myuserRef = userRef.child(key)
                    myuserRef.updateChildValues(["updated_at": Date().toStr()])
                }
        }
    }
    
    func observe(){
        print(self.roomKey)
        
        let chatRef = ref.child("chats")
        chatRef
            .queryOrdered(byChild: "roomId")
            .queryEqual(toValue: self.roomKey)
            .observe(DataEventType.value) { (snapshot) in
                self.messages = [JSQMessage]()
                for item in snapshot.children{
                    if let chatSnap = item as? DataSnapshot{
                        let senderId = chatSnap.childSnapshot(forPath: "senderId").value as? String
                        let text = chatSnap.childSnapshot(forPath: "text").value as? String
                        if senderId == self.senderId{
                            let message = JSQMessage(senderId: senderId, displayName: self.senderDisplayName, text: text)
                            self.messages.append(message!)
                        }else{
                            let message = JSQMessage(senderId: senderId, displayName: self.targetUser.name, text: text)
                            self.messages.append(message!)
                        }
                    }
                }
                self.collectionView.reloadData()
        }
    }
}

// MARK: JSQMessagesViewController

extension ChatViewController{
    
    override func didPressSend(_ button: UIButton!, withMessageText text: String!, senderId: String!, senderDisplayName: String!, date: Date!) {
        let facebookId = FBSDKAccessToken.current().userID!
        let chatRef = ref.child("chats").childByAutoId()
        let newMessage = ["roomId":roomKey ,"senderId": facebookId, "text": text]
        chatRef.setValue(newMessage)
        
        self.updateUserDate()
    }
    
    override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAt indexPath: IndexPath!) -> JSQMessageBubbleImageDataSource! {
        if self.messages[indexPath.item].senderId == senderId {
            return self.outgoingBubble
        }
        return self.incomingBubble
    }
    
    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = super.collectionView(collectionView, cellForItemAt: indexPath) as! JSQMessagesCollectionViewCell
        if self.messages[indexPath.item].senderId == senderId {
            cell.textView.textColor = UIColor.white
        }else{
            cell.textView.textColor = UIColor.darkGray
        }
        return cell
    }
    
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return messages.count
    }
    
    override func collectionView(_ collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAt indexPath: IndexPath!) -> JSQMessageAvatarImageDataSource! {
        if self.messages[indexPath.item].senderId == senderId {
            return self.outgoingAvatar
        }else{
            return self.incomingAvatar
        }
    }
    
    override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageDataForItemAt indexPath: IndexPath!) -> JSQMessageData! {
        return messages[indexPath.row]
    }
}

```

すでにdeprecatedいなっているライブラリを惜しげもなく使う。ここでもobserveを惜しげもなく使い、チャットの更新を検知して自動でUIを更新する。プッシュを受けてどうこう・websocketでどうこうするより、非同期な双方向データ通信が明示的に実装できる印象。

# まとめ

## 懸念

### 1 クライアント依存なNoSQLによるデータ管理

データが構造が各クライアント依存なので、**webなりiosなりandroidなりで、どれかで例えば要素を一つずれて保存してしまった場合に、親子関係が壊れる。**もちろんvalidationやunittestで回避することも可能だろうが、それを結局クライアントごとに実装が必要で、それならAPIで実装されていたほうがメンテナブルじゃないか？直接アプリから共通のvalidationなしに直接DBを触れるのは便利だが、怖い部分もあるなと感じた。

### 2 データ管理の方法

どうセキュアに、ロール分けて用意すればいいのだろうか？もちろん管理画面をwebで実装して、そこにアクセスできるユーザーのロールを定義して、CS対応などすればできるはできる。ただFirebase Realtime Database上ではログインすれば全てのデータが表示されているので、これはどう管理するのがベストなんだろうか？[IAMの役割と権限](https://cloud.google.com/iam/docs/understanding-roles?hl=ja&_ga=2.210251413.-162238856.1514955478#curated_roles)を見た感じ、権限の設定は可能なようだが、**データごとにロールで分けたりが難しそう**だ。この辺はサービス化した後の運用フローに懸念がある。


### 3 エラーの検知

Firebase Realtime Databaseに関するエラーがクラッシュするだけで、内容がわからないし、例外を履くわけでもないので**エラー箇所がわからない。**ビルドの設定が悪かったのかもしれないし、Firebaseの使い方を間違えている可能性もあるが、もうちょっとわかりやすいエラーが欲しい・・・

### 4 リソース監視アラート

従量課金だし、Paas的には料金を検知するアラートを自前で設定したい。[[Firebase]運用面における導入のポイント（利用料金、監視、セキュリティ）](https://qiita.com/yoshiro_ma/items/07e2d19dd36e26eabf3a)などを参考にすると、制限超過する前にはメールが来るようだが、少額でも飛ぶようにしてほしいし、止める仕組みもほしい。
これは厳密にはfirebaseにはないが、**[GCP連携](https://firebase.google.com/docs/firestore/pricing?hl=ja#set_usage_limits)をすると利用可能**のようだ。

### 5 オフラインの管理

これまで多くのアプリは「オフラインのため利用できません」みたいなトースト出して、画面をロックするような処理がおおかったが、オフラインでの挙動が可能になる。メディア系のアプリなら便利かもしれないが、ガッツリユーザーのイベント起因で、データ更新が置きまくるようなアプリだと監理が大変そうだ。
さらにここに**Cloud Functionsをつけて、データ更新をフックして何かするような処理を入れてたらカオス。** もし本番導入するなら一分機能を除いて、更新はさせないようにしたい。

### 6 dev/stg/prodを分ける

ただ分けるだけならFirebaseのコンソール上で、分ければいいけど、

- 定期的に一部データを本番からstgに流す
- stgは定期的に洗替する
- devを個人ごとに用意する

とかやり始めると、どうするのがベストなんだろうか？パッと触ってみた感じ泥臭くなりそうだ。

### 7 画面とリソースの紐付け

1画面1APIが美しいとされているけど、NoSQLに直アクセスするとそうも行かないだろうし、複数のキーの値を取得して、マージするシーンも出てくるはず。Rxなにがしで両方の変更をさらに監視すれば行けそうだが、そもそもそんなことしないで、一方の更新を受けて、もう片方も更新するようにCloud Functionsで対応しておくべきなんだろうか？

## まとめ

懸念はもっとあるけど、本当に便利なのは間違いない。データの更新・アカウントの登録などをフックしてイベントベースで、プッシュ通知やデータ更新のような処理をできるのは本当に強力だし、その多くをfirebaseまかせにできるのはすごい。
**バックエンドがFirebaseだけでiOSアプリは作れるのか**という問に対してはもろもろの懸念はあるが**「できる」**と言っても過言ではないし、今後もっと強力になることを考えると、今のうちにナレッジを貯めておくのはいいことだと思う。

既存サービスをこれにリプレイスするのは超大変だと思うので、新規サービスで「ユーザー同士のインタラクション」が重視されるようなものは親和性がいいと思う。