Edited at

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

More than 1 year has passed since last update.

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

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