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で実装
特に何も考えずに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
というのは変更を値を一度だけ取得するときに使う。
FirebaseAuthは様々な認証方式が用意されているので、方式ごとに登録される情報が異なるようだ。
メールでの会員登録だと、送信用のテンプレートをwebコンソールから編集できるようだ。
よくできている。
Realtime Databaseへのユーザー情報の登録
上記のソースで登録するとこんな感じで保存される。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は常に変更を検知する。
この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アプリは作れるのかという問に対してはもろもろの懸念はあるが**「できる」**と言っても過言ではないし、今後もっと強力になることを考えると、今のうちにナレッジを貯めておくのはいいことだと思う。
既存サービスをこれにリプレイスするのは超大変だと思うので、新規サービスで「ユーザー同士のインタラクション」が重視されるようなものは親和性がいいと思う。