[Firebase][iOS] Firebase Cloud Firestore でプロフィール機能を作ってみよう

はじめに

モバイルアプリサービス部の中安です。

Firebaseを触ってみるシリーズ」の続きになります。 前回はAuthentication で会員機能を作ってみようというお題で書かせていただきましたが、 今回はFirebase のデータベースサービスFirebase Cloud Firestoreでプロフィール機能を作ってみようと思います。

またもウダウダと書きますが、何かのお役に立てば幸いです。

準備

さて、この記事は

  • Firebaseプロジェクトの作成は終わっている
  • Xcode側ではFirebaseSDKの組み込みなどが終わっている

という前提で書いていきます。

このあたりがまだという方は、ドキュメントを参照してくださいませ。

また、認証したユーザと絡めてデータベースを扱うので、冒頭で紹介した前回の記事で作った認証機能がすでにアプリに組み込まれていることも前提にしています。

Xcodeプロジェクト側

Cocoapods

PodfileFirebase Cloud Firestore用のライブラリを含めて、ターミナルで pod install します

pod 'Firebase/Firestore'

Firebaseプロジェクト側

まだ未使用の場合はFirebaseのメニューからDatabaseを選ぶと、Cloud Firestoreのウェルカム画面が表示されます。

初期準備としてはその中のデータベースの作成ボタンを押して次へ進みます。 途中で出てくるダイアログはとりあえずそのままOKでいいと思います。

※旧版のデータベースであるRealtime DatabaseをすでにFirebaseプロジェクトで使用している場合は、 Cloud Firestoreへのデータ移行などが必要になるとのことで注意してください。詳しくはドキュメントを参照ください。

そもそも Cloud Firestore はどんなDB

さて、手を動かしていく前に「そもそもCloud Firestoreとはどんなデータベースなんだ」ということを復習しておきましょう。

ドキュメントには以下のように書かれています。

Cloud Firestore は NoSQL ドキュメント指向データベースです。SQL データベースとは違い、テーブルや行はありません。代わりに、データは「ドキュメント」に格納し、それが「コレクション」にまとめられます。

各「ドキュメント」には、一連のキーと値のペアが含まれています。Cloud Firestore は、小型のドキュメントの大きなコレクションを格納するために最適化されています。

すべてのドキュメントはコレクションに保存する必要があります。ドキュメントには、「サブコレクション」と、ネストされたオブジェクトを格納できます。このどちらにも、文字列などの基本フィールドや、リストなどの複雑なオブジェクトを含めることができます。

もしもリレーショナルデータベース(RDB)に頭が慣れていると少し混乱してしまいそうなのですが、 平たくいうと「クラウドサービス上に大きなデータ保存用のJSONファイルがある」というイメージを持つとわかりやすくなるかもしれません。

JSONファイルの中の細かな住所を指定して、その中の値を加えたり書き換えたり消したりするというようなイメージですね (厳密には違うかもですが、自分はそれでしっくりきました)。

上記の引用にも既に言及されてはいますが、 Firestoreの世界ではいくつかの覚えておくべき用語があります。

ドキュメント

イメージでいうと「レコード」に近いかもしれません。

しかし、一般的なRDBの世界ではレコードはそのスキーマ(どういうカラムがあるか)が最初に決まっているのに対して、Firestoreスキーマレスなデータベースなので、1つのドキュメント内のデータはそれぞれ自由なキーと値が入ることになります。

このあたりもJSONをイメージして考えると難しくはないかもしれませんね。

コレクション

イメージでいうと「テーブル」に近いかもしれません。

コレクションは複数のドキュメントの入れ物にあたり、ドキュメントは必ずどこかのコレクションに含まれています。 また、ドキュメントの中に更にコレクションを入れることも可能で「サブコレクション」と呼ばれるそうです。

ルール

「セキュリティルール」ともいいます。 コレクション内のドキュメントへのアクセス制限などを詳細かつ直感的に指定することができます。

REST APIのインターフェイスを作るような感覚(ノリ的にはSwagger的な…いや、違うか)で、「どのコレクションの」「どのドキュメントに」「誰が」「どういう条件で」「何ができる」といったことをプログラマブルに書ける点が良いです。

コンソール画面ではエディタに加えてシンタックスチェックもしてくれたり、ルールのシミュレーション機能もあるので 構築の段階で躓く時間は短くなるのではと思います。

ルールについては後ほどにも書くのですが、さらなる詳細はドキュメントを参照くださいませ。

今回のサンプルアプリとその準備

今回は題名の通り「プロフィール機能のあるアプリ」を作ってみます。

前回の認証機能で作成したログインユーザには「名前」や「メールアドレス」くらいしか有益な情報を持っていませんので、 プロフィール画面を使ってユーザに属性を追加するイメージになります。

アプリ側

UI

プロフィール画面は下図のような見た目にしました。

(プロフィールというよりアンケートのような項目になってしまいましたが・・・)

簡単な論理名や仕様は図の矢印で示したとおりです。

ビューコントローラ

ビューコントローラはProfileViewControllerとして以下のように用意します。

class ProfileViewController: UIViewController {
    
    // 好きな食事
    @IBOutlet private weak var favoriteMealSegment: UISegmentedControl!
    // 好きなスポーツ
    @IBOutlet private weak var favoriteSportsSegment: UISegmentedControl!
    // 飼ってるペット
    @IBOutlet private weak var yourPetSegment: UISegmentedControl!
    // 毎日朝食を食べる (yes or no)
    @IBOutlet private weak var breakfastEverdaySwitch: UISwitch!
    
    // 登録ボタン押下時
    @IBAction private func didTapRegisterButton() {
        // あとで実装
    }
}

その他

以下のような列挙型(enum)も用意しておきます。

enum FavoriteMeal: String {
    case japanese, weastern, chinese, italian
    static let items: [FavoriteMeal] = [.japanese, .weastern, .chinese, .italian]
}

enum FavoriteSports: String {
    case baseball, soccer, tennis
    static let items: [FavoriteSports] = [.baseball, .soccer, .tennis]
}

enum YourPet: String {
    case dog, cat
    static let items: [YourPet] = [.dog, .cat]
}

見たままなので説明は割愛です。

Firestore側

Firestoreのコンソール画面からルールタブを開きます。

この画面ではルールの編集ができます。 今回は各ユーザのプロフィール情報を格納する場所としてusersというコレクションを使うものとし、それに対するルールを設定していきます。

ルールの概要は

  • 認証機能でサインインしたユーザが自分のプロフィール情報のみを触ることができる
  • usersコレクションのドキュメントの識別子はユーザのIDとする

にしたいと思うので、ここを下記のように編集します。

service cloud.firestore {
  match /databases/{database}/documents {
   match /users/{userID} {
     allow create, update: if request.auth.uid == userID
   }
  }
}

基本的な文法

このルール設定は下記の予約語を基本的には使うことになります。

  • matchステートメント ・・・ 適用するドキュメントの場所を指します。WebAPIのパスを設定するような感じですね。
  • allow式 ・・・ 指定の場所に対して許可するアクセス権限とその条件です。

match /users/{userID}としているのは、 usersというコレクションの中の{userID}という動的なドキュメント識別子に対してのルールであることを示しています。

条件指定

allow式の中の:の右側を見てみてください。ここでは条件指定をしています。

matchステートメントで書かれている{userID}は、任意の変数のようなものです。それを条件文で使用することができます。 request.auth.uidは認証されたユーザのIDを指すので、 このIf文では「認証したユーザのIDと等しい識別子のドキュメント」という条件指定をしているわけです。

アクセス権限

allow式の中の:の左側はアクセスのパーミッションを表します。

基本的なCRUDであるcreatereadupdatedeleteの権限を指定できます。 この例では「認証したユーザは自分のIDと等しい識別子のドキュメントに追加と更新のパーミッションがある」というusersへのルールができあがったことになります。

データ保存の実装

それでは、準備で作っていた「登録ボタン押下時」のハンドラメソッドに、Firestoreへのデータ保存機能を実装していきます。

@IBAction private func didTapRegisterButton() {
    guard let user = Auth.auth().currentUser else {
        // サインインしていない場合の処理をするなど
        return
    }
    
    let favoriteMeal = FavoriteMeal.items[favoriteMealSegment.selectedSegmentIndex].rawValue
    let favoriteSports = FavoriteSports.items[favoriteSportsSegment.selectedSegmentIndex].rawValue
    let yourPet = YourPet.items[yourPetSegment.selectedSegmentIndex].rawValue
    let breakfastEverday = breakfastEverdaySwitch.isOn
    
    let db = Firestore.firestore()
    
    db.collection("users").document(user.uid).setData([
        "favoriteMeal": favoriteMeal,
        "favoriteSports": favoriteSports,
        "yourPet": yourPet,
        "breakfastEverday": breakfastEverday,
    ]) { error in
        if let error = error {
            // エラー処理
            return
        }
        // 成功したときの処理
    }
}

実装としてはざっとこんな感じです。 本来であればFirestoreの処理はビューコントローラからは切り分けたいところですが、 サンプルなのでベタ書きしています。ご了承を。

ログインしているユーザを取得する

ここまでに何度か書いていますが、 この記事では既に前回の記事で作った認証機能がアプリには備わっているという前提にして話を進めています。

一番最初のAuth.auth().currentUserでログインユーザを取得しておきます。 ここでnilが返ってくるということはログインしているユーザがいないということなので、 ここから先には進ませないようにしています。

項目の値を取得する

ここは各アプリによって様々だと思うので、深くは書きません。 このサンプルでは列挙体の値はそのままDBに渡すことにします。

ここでは文字列とブール値を取っていますが、整数や配列などの値も渡すことが可能です。 扱えるデータ型についての詳細はドキュメントを参照ください

Firestoreオブジェクトを取得

FirestoreオブジェクトはFirestoreクラスのシングルトンとして用意されているのでそれを呼び出します。

let db = Firestore.firestore()

データのセット

コレクション

Firestoreオブジェクトはcollection(_:)を使って、コレクションが抽象化された参照オブジェクトを取得できます。

今回はusersコレクションに対してアクセスをしたいので下記のような指定をしています。

db.collection("users")

ドキュメント

先述のルールの項でも書いたように今回はユーザIDをドキュメントの識別子として扱うルールに設定しました。 コレクションの参照オブジェクトに対してdocument(_:)メソッドを使うことで ドキュメントを抽象化した参照オブジェクトを取得することができます。

db.collection("users").document(user.uid)

データのセット

ドキュメント参照オブジェクトのsetData()メソッドを使用することで Firestoreデータベースに実際にデータを渡すことができます。

引数に[String : Any]型のディクショナリで以下のように任意のデータ構造を渡すことができ、 その完了時にはエラーがあったかどうかのコールバックを受け取ることができます。

db.collection("users").document(user.uid).setData([
    "favoriteMeal": favoriteMeal,
    "favoriteSports": favoriteSports,
    "yourPet": yourPet,
    "breakfastEverday": breakfastEverday,
]) { error in
    if let error = error {
        // エラー処理
        return
    }
    // 成功したときの処理
}

動作確認

では、実際にアプリを起動させて、各項目を色々と動かした後に登録ボタンを押してみましょう。

コンソールを見てみるとデータが送られ、保存されていることが確認できました。

さらに、このコンソール画面を見ながらアプリで各項目を色々と変更してから再び登録ボタンを押してみると、 リアルタイムにコンソール画面の値が更新されるのも確認できると思います。 (更新された値がオレンジ色に光って楽しいっ)

また、試しに別のアカウントでログインして同じことをしてみると、 今度は別のドキュメントがusersに新規作成されのも確認できると思います。

データについて

スキーマレスだから…

ここで面白いのは、Firestoreのコンソール画面でセキュリティルールこそ設定したものの、 実際にデータのコンソールにusersというコレクションを足したわけでもないのにデータの送信と保存がされたということです。

ドキュメントの中身もアプリ側で指定した通りに保存されています。 スキーマに合わせたSQL文を書くなどして挿入・更新をするRDB脳でいるとビックリしますね。

これがスキーマレスというわけです。

ルールがあるから…

setData()により、 usersコレクションにユーザIDが識別子になっているドキュメントが存在しない場合は、ドキュメントが自動的に新規作成されます。 また、存在する場合はそのドキュメントを自動で更新してくれます。便利ですね。

先ほど設定したルールでallow create updateと指定しているので、このような動きをしてくれるわけです。

試しにupdateをルール記述から消してからアプリを動かしてみてください。 ドキュメントの新規作成はしてくれますが、更新しようとするとコールバック時にパーミッションエラーが返されるはずです。

データ取得の実装

さて、登録したプロフィールがデータベースに保存されたので、今度はそれを取得する動作を実装しようと思います。

ルールの変更

先ほどはcreateupdateのパーミッションをルールとして与えていました。 では、取得するときはどうでしょうか。

プロフィールは「自分にしか見えない情報」というよりは「他のユーザに見せる情報」とも言える気がします。 なので、データの追加や更新とは異なり、他ユーザにもリソースの取得権限は与えたほうが良さそうです。

service cloud.firestore {
  match /databases/{database}/documents {
   match /users/{userID} {
     allow create, update: if request.auth.uid == userID
     allow read: if request.auth != null
   }
  }
}

「認証したユーザであれば他のユーザのプロフィールが見れる」という仕様にするならば、 readパーミッションはこのように設定することになると思います。

アプリ側の実装

画面が表示されるときに、前回登録した内容が初期値として表示されていてほしいところです。

今回のサンプルではviewWillAppear()のタイミングで自身のプロフィールを取得し、 各ビュー部品にバインドしていくことにします。

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    
    guard let user = Auth.auth().currentUser else {
        return
    }
    
    let db = Firestore.firestore()
    
    db.collection("users").document(user.uid).getDocument() { [weak self] snapshot, error in
        guard let self = self else { return }
        
        if error != nil {
            // エラー処理
            return
        }
        
        guard let snapshot = snapshot, snapshot.exists, let data = snapshot.data() else {
            // データがないときの処理
            return
        }
        
        if let value = data["favoriteMeal"] as? String,
            let favoriteMeal = FavoriteMeal(rawValue: value),
            let index = FavoriteMeal.items.firstIndex(of: favoriteMeal) {
            self.favoriteMealSegment.selectedSegmentIndex = index
        }
        
        if let value = data["favoriteSports"] as? String,
            let favoriteSports = FavoriteSports(rawValue: value),
            let index = FavoriteSports.items.firstIndex(of: favoriteSports) {
            self.favoriteSportsSegment.selectedSegmentIndex = index
        }
        
        if let value = data["yourPet"] as? String,
            let yourPet = YourPet(rawValue: value),
            let index = YourPet.items.firstIndex(of: yourPet) {
            self.yourPetSegment.selectedSegmentIndex = index
        }
        
        if let value = data["breakfastEverday"] as? Bool {
            self.breakfastEverdaySwitch.isOn = value
        }
    }
}

ドキュメント取得

ドキュメントの参照オブジェクトを取得するまでは保存時と大差ありません。 取得の際はそこにgetDocument()メソッドを呼び出します。

すると、コールバックとして「ドキュメントのスナップショットオブジェクト」と「エラーオブジェクト」が渡されてきます。

スナップショットには指定したドキュメントの有無、存在した場合のドキュメントの内容が入っているので、それを使います。 データ保存時に渡したディクショナリと同形式でデータが取得ができるので、加工も簡単かと思います。

バインド

snapshot.data()で取得したデータを各ビュー部品にバインドしていきます。 上記のサンプルソースはあまりキレイな組み方とは言えないですが、 これによりアプリの表示とデータベースの内容が同期したといえるでしょう。

実際はカスタムな構造体またはクラスを作って、 ディクショナリからデータオブジェクトを作成するほうがソースコード的にはスッキリすると思いますが、ここでは割愛します。

最後に

今回はFirestoreの基本的なところ、 CRUDの中でもCRUの部分だけをとりあげて、簡単なプロフィール画面を作成しました。

実務的に使うのであれば、ルールはもっと細分化したり、データもキレイに構造化したり、インデックスを使用したりするべきでしょうが、 まずはサービスの雰囲気がつかめるところまでをやってみました。

もっと突っ込んだところは別機会にアウトプットしようと思います。

詳しくは Firebase Cloud Firestore のリファレンスを参照ください