自分を攻略していく記録

自分がやりたいことを達成するには何をすればいいのか、その攻略していく過程をつらつらと

Firebaseでサーバレスなバックエンド構成のiOS開発をする ~Firestore・Pring~

f:id:ngo275:20180701153549p:plain

Firebaseを使ってサーバレスなiOS開発をしていたのだが、うまく使えば非常に強力な武器である一方、Firebase独特のデータ設計周りのBest Practiceがわからず苦戦したので、Firebase、特にFirestoreでのデータの扱いを中心に書いていく。

FirestoreはFirebase Realtime Databaseの後継のイメージで、データをどう扱うのかという議論はしばしば取り上げられている(参考)。

利用するサービス

以下のライブラリやサービスを使うと完全にサーバレスなネイティブアプリ開発ができる。

ここではPringを用いてFirestoreのデータを取得している。コードの書き方はFirebaseのドキュメントのそれとは異なり、Pringに依存したものになっていることに注意。

ちなみにFirestoreはベータだが、スケールアウト時の速度が物足りないから、だそうで、データのバックアップやロールバックはないもののそれ以外は特に問題がないそうだ。なので破壊的なアップデートはないとのこと。

具体的な実装を見ていく

Userが複数のBookを所有している例(User has many Book)を考える。

リレーションシップを持たせるには NestedCollectionReferenceCollection がある。前者は同じツリーの中にネストしてデータを保存していく。 User -> Book -> Highlight のように段々とネストが深くなっていく(Firestoreではネストが深くなればなるほど親のデータの読み込みが重くなる、という問題はない)。ReferenceCollectionはテーブル自体をフラットにして、お互いのテーブルのリレーションを参照でつないでいくものだ。Railsでいうところの user.books.where(name: 'test') のような書き方を実現するにはNestedCollectionを使うことになる。単純なリレーションシップを実現するならNestedCollectionで良いのではないかと思う。ので、まずUserとBookの関係はNestedCollectionで進めていく。

NestedCollectionを利用してみる

モデルを定義する

以下のようにclassを定義する。

import Pring

@objcMembers
class User: Object {
    dynamic var name: String?
    dynamic var thumbnail: File?
    dynamic var books: NestedCollection<Book> = []
}

@objcMembers
class Book: Object {
    dynamic var thumbnail: File?
    dynamic var name: String?
}

データをFirestoreに書き込む

以下のようにしてFirebaseのコンソールを開くと、Userの中にBooksが記録されているのが確認できるはずだ。サムネイル画像のアップロードもこれだけで完了するというお手軽さ...!!確認するには書き込み権限まわりをゆるくしておいたほうが良いかもしれない。Firebase Anonymous Loginを利用すれば認証周りも一瞬で実装できる。

let user = User()
user.name = "Alice"
user.thumbnail = File(data: UIImageJPEGRepresentation(someUIImageA, 1), mimeType: .jpeg)

let book = Book()
book.name = "Alice in Wonderland"
book.thumbnail = File(data: UIImageJPEGRepresentation(someUIImageB, 1), mimeType: .jpeg)

user.books.insert(book)
user.save()

UserとBookをFirestoreから取得する方法いろいろ

// userをid指定で取得する
User.get(id) { user, error in
    print(user) // 存在すればデータが出力される
    print(user.books) // これだけでは必ずnilが出力される
}

// あるuserが持っているbookをid指定で取得する
user.books.get(bookID) { book, error in  
    print(book)
}

// userの一覧を取得する
var dataSource: DataSource<User>?
dataSouce = User.order(by: \User.createdAt).dataSource()
    .onCompleted() { (snapshot, users) in 
        // 各userは例えばusers.first?.booksとしてもデータはこれだけだと入っていない
        print(users)
    }.listen()

// 各userのbooks付きでuserの一覧を取得する
dataSouce = User.order(by: \User.createdAt).dataSource()
    .on(parse: { (_, user, done) in
        // 意図的にuserと関連付けされているbooksを持ってくるように指示しないといけない
        user.books.get() { (snapshot, books) in
            done(user)
        }
    })
    .onCompleted() { (snapshot, users) in 
        // on(parse:)をはさんでbooksを取得すると、ここでusersの中にある各userはbooksを参照できる
        print(users)
    }.listen()

// あるuserが持っているbookの一覧を取得する
var bookDataSource: DataSource<Book>?
bookDataSouce = user.books.order(by: \Book.createdAt).dataSource()
    .onCompleted() { (snapshot, books) in 
        print(books)
    }.listen()

// あるuserが持っている特定のbookを取得する
bookDataSouce = user.books.where(\Book.name, isEqualTo: "Alice in Wonderland").dataSource()
    .onCompleted() { (snapshot, books) in 
        print(books)
    }.listen()

// あるuserが持っているbookの一覧を更新したもの順に取得する
// user.books.order(by: \Book.createdAt)の設定とDataSourceに渡しているソートは別で、DataSourceの並びはoptionsを渡す必要がある
var bookDataSource: DataSource<Book>?
let options: Options = Options()
let sortDescriptor: NSSortDescriptor = NSSortDescriptor(key: "updatedAt", ascending: false)
options.sortDescirptors = [sortDescriptor]
bookDataSouce = user.books.order(by: \Book.createdAt).dataSource(options: options)
    .onCompleted() { (snapshot, books) in 
        print(books)
    }.listen()

このDataSourceはUITableViewやUICollectionViewのDataSourceとして利用することができる。また、 dataSource: DataSource<User>[User] (Userの配列)だと思って操作することも可能である。UITableViewやUICollectionViewと合わせて利用する場合は、PringのREADMEにあるように記述すればデータの更新と同時にUIの更新が走っていい感じになる。

NestedCollectionの制約

Bookのidがわかってもいきなりbookの取得ができず、親(User)のidが必要になる。具体的には以下の通り。

// bookIDがわかっていてもuserIDがわかっていないと取り出せない
book.get(bookID) { book, error in
    print(book)  // nilが返ってくる
}

// userIDをどこかで取得してから以下のようにbookを取り出す必要がある
let user = User(id: id, value: [:])
user.books.get(bookID) { book, error in
    // print(book)
}

さらにもっと大きな制約がある。

ここではUser -> BookとたどっていたのでNestedCollectionで問題なかったが、もしCategory -> Bookという経路でもBookを取得したくなると、途端に問題が複雑になる。NestedCollectionだと、Userのidが必須になるためである。したがって、User以外の経路でBookにアクセスすること可能性が出てきうるなら、最初からUserの直下に入れずにReferenceCollectionを利用しておくのが良さそうだ。実際に利用してみた感想としては、密接に関係していないところはReferenceCollectionで管理した方が安全だが、確実にこのルートからしか取得しないよね、というものはNestedCollectionで良いと思う。

NestedCollectionを利用しつつ、Userと同じ階層にもう一つBookのノードを作成する(すなわちUserの直下とUserと同じ階層にBookデータを保存する)ことも可能ではあるが、データの実体が複数存在させるのはメンテナンスコストを考えると避けたほうが良い。暗号通貨のウォレットを作っているGincoではデータの冗長性は割り切っており、Cloud Functionsでデータの整合性を取るように処理しているらしい。

ReferenceCollectionを利用してみる

モデルを定義する

前回に加えてCategoryを定義しておく。

import Pring
import FirebaseFirestore

@objcMembers
class User: Object {
    dynamic var name: String?
    dynamic var thumbnail: File?
    dynamic var books: ReferenceCollection<Book> = []
}

@objcMembers
class Book: Object {
    dynamic var thumbnail: File?
    dynamic var name: String?
}

@objcMembers
class Category: Object {
    dynamic var thumbnail: File?
    dynamic var name: String?
    dynamic var books: ReferenceCollection<Book> = []
}

データをFirestoreに書き込む

以下のようにしてFirebaseのコンソールを開くと、User、Book、Categoryがフラットに記録されている。

let user = User()
user.name = "Alice"
user.thumbnail = File(data: UIImageJPEGRepresentation(someUIImageA, 1), mimeType: .jpeg)

let book = Book()
book.name = "Alice in Wonderland"
book.thumbnail = File(data: UIImageJPEGRepresentation(someUIImageB, 1), mimeType: .jpeg)

let category = Category()
category.name = "Fairy tale"
category.thumbnail = File(data: UIImageJPEGRepresentation(someUIImageC, 1), mimeType: .jpeg)

user.books.insert(book)
category.books.insert(book)

// batchでUser、Category、Bookを保存する(Userは保存したけどBookは保存できていない、ということが起こらない)
let batch = Firestore.firestore().batch()
user.pack(.save, batch: batch)
category.pack(.save, batch: batch)
book.save(batch)

User・Book・CategoryをFirestoreから取得する方法いろいろ

// userをid指定で取得する
User.get(id) { user, error in
    print(user) // 存在すればデータが出力される
    print(user.books) // これだけでは必ずnilが出力される
}

// bookをid指定で取得する
Book.get(id) { book, error in
    print(book) // 存在すればデータが出力される
}

// categoryをid指定で取得する
Category.get(id) { category, error in
    print(category) // 存在すればデータが出力される
    print(category.books) // これだけでは必ずnilが出力される
}

// あるuserが持っているbookの一覧を取得する
// これに加えてbook.nameでフィルターをかけることはできない
var bookDataSource: DataSource<Book>?
bookDataSouce = user.books.order(by: \Book.createdAt).dataSource()
    .onCompleted() { (snapshot, books) in 
        print(books)
    }.listen()

// あるcategoryのbookの一覧を取得する
bookDataSouce = category.books.order(by: \Book.createdAt).dataSource()
    .onCompleted() { (snapshot, books) in 
        print(books)
    }.listen()

ReferenceCollectionだとwhereを使ったフィルターが利用できないというデメリットがある。

まとめ

  • NestedCollectionとReferenceCollectionの使い分けは、モデル間の関係性の度合いによる。依存関係があるなら前者を採用して良さそう
  • 今までのRDBと同じ感覚で利用するとかえって辛い。割り切って見た目に依存する形で設計すると良さそう
  • Fileのアップロード・認証周りがめちゃくちゃ簡単