バックエンドが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

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

ログイン画面


//
//  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

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

スクリーンショット 2018-01-03 13.07.25.png

FirebaseAuthは様々な認証方式が用意されているので、方式ごとに登録される情報が異なるようだ。

スクリーンショット 2018-01-03 13.11.19.png

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

Realtime Databaseへのユーザー情報の登録

スクリーンショット 2018-01-03 13.14.40.png

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

チャットルーム一覧画面


//
//  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)
    }
}

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


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

observeSingleEventとはことなりobserveは常に変更を検知する。
スクリーンショット 2018-01-03 13.24.11.png

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

let user = User(snapshot: snap)

取得できたデータはDataSnapshotクラスで取得できる。この中にKeyValueの形式で値が入っているので、適宜entityなどにマッピングする。


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にしなくてもいい説もある。書込み可能な構造は


NSString
NSNumber
NSDictionary
NSArray

です。

チャット詳細画面


//
//  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の役割と権限を見た感じ、権限の設定は可能なようだが、データごとにロールで分けたりが難しそうだ。この辺はサービス化した後の運用フローに懸念がある。

3 エラーの検知

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

4 リソース監視アラート

従量課金だし、Paas的には料金を検知するアラートを自前で設定したい。[Firebase]運用面における導入のポイント(利用料金、監視、セキュリティ)などを参考にすると、制限超過する前にはメールが来るようだが、少額でも飛ぶようにしてほしいし、止める仕組みもほしい。
これは厳密にはfirebaseにはないが、GCP連携をすると利用可能のようだ。

5 オフラインの管理

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

6 dev/stg/prodを分ける

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

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

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

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

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

まとめ

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

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

1602contribution

執筆おつかれさまです!
いくつか指摘させてください.

keyを任意で発行されているものにしているが、ここはもっとクレバーなアイデアがあったはず

UserIDなどでしょうかね.
場合によっては今のままでもクレバーとは思います.
前1/3がタイムスタンプ・後ろ2/3がランダムで,順序を担保しつつほぼ衝突しないという仕様ですので.

要素を一つずれて保存してしまった場合に、親子関係が壊れる。もちろんvalidationやunittestで回避することも可能だろうが

お気づきかもしれませんが,サーバサイドでのvalidationは利用できます.
https://firebase.google.com/docs/database/security/#section-validation

オフラインでの挙動が可能になる
データ更新が置きまくるようなアプリだと監理が大変そう

具体的にどんな懸念をされているでしょうか.
ご存知かもしれませんが,オフライン機能をオフにもできます.
https://firebase.google.com/docs/database/ios/offline-capabilities#section-disk-persistence
あるいは,そもそもスケーラブルな設計指針を取っていれば,このあたりもさほど支障来さないのでは,とも思います.

一方の更新を受けて、もう片方も更新するようにCloud Functionsで対応しておくべきなんだろうか?

オールオアナッシングで,複数箇所同時書き込みすることも可能です.
https://qiita.com/Yatima/items/8a54acc8fda3e5fce741#%E5%8E%9F%E5%89%87%E3%81%9D%E3%81%AE4%E4%B8%80%E8%B2%AB%E6%80%A7%E3%81%AE%E3%81%82%E3%82%8B%E5%90%8C%E6%99%82%E6%9B%B8%E3%81%8D%E8%BE%BC%E3%81%BF
 
 
ちなみにエラーについては,ウェブ版ではそれなりに出ていると思います.
iOSは触らないのですみません.
 
それから,ご存知とは思いますがCloud Firestoreのほうが本番向けとは思います(ベータさえ外れれば).
安定性,青天井なスケーラビリティ,それなりのクエリ対応,型のサポートなど.

1281contribution

@Yatima あけましておめでとうございます、アドバイス頂きありがとうございます! :pray:

サーバサイドでのvalidationは利用できます.

これ見落としていました、ここを厳密に定義すれば、クライアントからの構造破壊を防げますね!
他の点に関しても情報ありがとうございます。

dev/stg/prodを分ける

この件に関してはなにかナレッジをお持ちでしたら、教えていただけましたら幸いです。 :bow:
今までだとチームで開発する場合、個人ごとにVM立てたり、インスタンス用意したりしてたかと思いますがここはどう解決していくべきなんでしょうか?

519contribution

クックパッドの某サービスの人です。
Firebaseに興味を持って頂いて大変嬉しいです!

私の方からは以下の3点について

  • 2 データ管理の方法
  • 6 dev/stg/prodを分ける
  • 7 画面とリソースの紐付け

「データ管理の方法」

まずは、「データ管理の方法」について
正直AWSを利用していた方にとっては、物足りないものになるのは間違いありません。
Firebaseを利用している開発者からも声が上がっていて、中の人には伝えられているのでそのうち解決策が出てくるものだと思います。

今の所は3つの権限とプロジェクトを駆使して運用でカバーするしかなさそうです。
- オーナー
- 編集者
- 閲覧者

dev/stg/prodを分ける

弊社では、それぞれプロジェクトを分けています。上記のロールの問題も絡んできますし、今の所この方が良さそうです。
実際google-service.plistを入れ替えるだけなの非常に簡単に運用できます。
スクリーンショット 2018-01-04 1.03.26.png

Dev環境もチームで共同で使っています。
そもそも開発が少人数で済むので「ちょっとデータリセットします!」とか声がけをすれば全然問題ないですよ。
メンバーも個々で開発環境を持ってたりしますが、基本無料なのでどんどんプロジェクトを作って、それぞれいろんなことやってますよ。

画面とリソースの紐付け

1画面1APIが美しいとされているけど

残念ながら1画面1APIは実現できません。
1API出ない方が有利であることを説明するよりも、Firebaseの特性上Client Side Joinを前提にアプリを作らなければならないので、そう割り切って考えてもらった方が開発しやすいと思います。

よければこちらの記事を参考にしてもらえると嬉しいです。

https://qiita.com/1amageek/items/afc1c0ceb15ffc2372fd
https://qiita.com/1amageek/items/64bf85ec2cf1613cf507

ではでは、開発頑張ってください。

1281contribution

@1amageek
あけましておめでとうございます :pizza:
まさかの中の人ということで、ご助言いただきありがとうございます。

Dev環境もチームで共同で使っています。

これはやはりこうなるのですね、環境の再現性も気になりましたが、そもそもFirebaseのプラットフォームに乗っていれば、ある程度のリロースファイルがあればすぐ再現できそうですし、そこはメリットとしてとらえるべきなんですかね。

Firebaseの特性上Client Side Joinを前提にアプリを作らなければならないので、そう割り切って考えてもらった方が開発しやすい

いただいたリンク参考に勉強させていただきます!

ありがとうございました! :bow: