iOS
Firebase

バックエンドが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アプリは作れるのかという問に対してはもろもろの懸念はあるが「できる」と言っても過言ではないし、今後もっと強力になることを考えると、今のうちにナレッジを貯めておくのはいいことだと思う。

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