エンジニアHubPowered by エン転職

若手Webエンジニアのための情報メディア

Firebase入門 フリマアプリを作りながら、認証・Firestore・Cloud Functionsの使い方を学ぼう!

Firebaseでは、バックエンドやインフラに精通したメンバーがいなくても、モバイルやWebフロントの開発に集中できます。Authentication、Firestore、Cloud Functions、さらにセキュリティルールまで、クックパッドの岸本卓(@_sgr_ksmt)さんが、実践的に解説します。

Firebase入門! 認証・Firestore・Cloud Functionsの使い方からセキュリティルールまでをアプリ作りで学ぶ

Firebaseをご存じでしょうか? Firebaseを利用したことはありますか?
今回は「Firebaseをこれから使ってみたい!」「絶賛使っているけど、初めてでどう開発したらいいかよく分からない……」という方を対象に、実践的な入門という形で説明していきます。

Firebaseとは?

Firebaseは、2011年にFirebase社がサービスを開始し、2014年にGoogleが買収したMBaaS(Mobile Backend as a Service)です。

Firebaseでは、リアルタイムでデータを同期できるCloud FirestoreやRealtime Databaseといったデータベース、プッシュ通知を簡単に実装できるFirebase Cloud Messaging、サーバーレスに何かのイベントをトリガーに関数を実行するCloud Functions for Firebaseといった機能を利用できます。

その他にも、次のようなたくさんの機能があります。

Authentication、Hosting、Cloud Storage、Crashlytics、Performance Monitoring、Test Lab、Analytics、Predictions、A/B Testing、Remote Config、Dynamic Links、App Indexing、In App Messaging、ML Kit

プロダクト - Firebase

サービスが開始された当初にはまだなかった機能やβ版だった機能も多かったのですが、今ではほとんどの機能がGA(Generally Available)となっており、Firebaseが使われるシーンも増えてきたように思えます。

Firebaseの良いところ

Firebaseの良いところは、自分自身にバックエンドやインフラの知識がなかったり、精通したメンバーがいなかったりする中でサービスを開発することになっても、Firebaseがさまざまな機能をあらかじめ提供してくれているので、サーバーの構築やインスタンスの立ち上げなどを気にすることなく、モバイルやWebフロントの開発に集中できることです。

自分でいちからサーバーを立ち上げたり、認証基盤を作成したり、プッシュ通知を配信する仕組みを作る手間と時間を短縮できることは、とても大きいと思います。

実際に筆者が携わっているプロダクトでは、Firebaseを使って、iOSエンジニアのみでサービスの開発からローンチまで行うことができました(詳しくは次の記事を参照)

また、Firebase自体の開発も盛んで、機能改善や新機能が次々と搭載されるため、Firebaseで実現できる開発の幅がどんどん広がっています。 今携わっているプロダクトでも、開発当初では機能上の制約や、そもそも提供されておらず実現できなかった機能が、今では当たり前のように実装できるようになった、ということも少なくありません。

さらに、Firebaseの利用を始めること自体は無料で、有料プランでも従量課金制のほか、毎月25ドルの定額プランも用意されています。

どういう人に向いているか?

Firebaseには、さまざまな利用シーンが考えられます。

個人開発をしている人

「これから何かアプリケーションを開発したい!」という人には、筆者は強くFirebaseの利用をオススメします。 作成するアプリケーションの特性にもよりますが、以下の恩恵を受けることができます。

  • 自分でサーバーを立てる必要がない
  • 認証機能が用意されている
  • プッシュ通知を簡単に送ることができる
  • 最初からHTTPS通信に対応されたWebサービスをホスティングできる
  • インフラの知識がなくても扱うことができる
  • 提供されているモジュールやSDKを利用することで、APIを書かずにデータベースにアクセスが可能

個人開発をする人にとって、手間とコストを大幅に削減できるのはとても大きいと思います。

そして、Firebaseは無料で利用できる機能の範囲が広く、また従量課金プランに変更したとしても、そこまで料金がかかりません(規模によります)

新規事業を立ち上げたい人・企業

一昔前のFirebaseでは、MVP(Minimum Viable Product)や検証目的で作るには適している一方で、プロダクション品質まで高めるのは難しいと言われることもありました。

しかし、ほとんどの機能がGAとなり、サービスの安定性も高くなったこともあり、採用しない手はないだろうと思います。

既存事業をグロース・改善させていきたい人

既存事業でも、Firebaseを導入することで受けられる恩恵があります。

途中からでは、Cloud Firestoreといったデータベースを導入することは難しいでしょうが、AnalyticsやPredictionsによる分析や、Cloud Messagingを用いたプッシュ通知の配信など、効果を発揮する機能があります。

また、Googleが買収したFabricのCrashlyticsがFirebaseに統合されたり、Performance Monitoringといった機能が用意されたりしたことで、クラッシュしている箇所やパフォーマンスに問題がある部分を特定し、改善していく手助けになります。

Firebaseを使った開発で重要な機能

Firebaseを使った開発で特に重要となる3つの機能について、簡単に紹介します。 このAuthentication、Cloud Firestore、Cloud Functionsを組み合わせるだけでも、簡単にサービスを構築することが可能です(本記事の後半で紹介します)

Authentication

Firebaseには認証を行う機能が備わっており、認証自体を自分で実装しなくて済みます。

認証方法としては、メールアドレス等を一切必要としない匿名認証から、メールアドレスでの認証、各種サービス(TwitterやFacebook)を使った認証などがあります。

特に、次のCloud Firestoreなどを使うには、認証済みユーザーであるかどうかを検証することがベースとなるため、Authenticationの使用が必須となるでしょう。

Cloud Firestore

Cloud Firestore(以下、Firestore)は、ドキュメント指向のNoSQLデータベースです。

Firestoreを理解する上で必要不可欠なのが、「コレクション」と「ドキュメント」です。 この関係についてはFirebaseの公式ガイドと図を参考にしてください。

Cloud Firestore  |  Firebase

また、リアルタイム性も兼ね備えており、ドキュメントに変更があった場合、クライアント側でリッスンしておくと、変更を即座に受け取ることも可能です。

なお、Realtime Databaseで特定のノードを取得する場合に子ノードも全て取得するため非効率になるケースがありますが、Firestoreでドキュメントを取得する際には配下のサブコレクションまで取得してしまうことはありません。

Cloud Functions

Cloud Functionsは、JavaScriptで記述された関数を実行する機能です。

ファンクションには、Firestoreにドキュメントが書き込まれた・更新されたといったイベントを元に実行されるもの、定期実行のもの、HTTPSリクエストで実行できるものがあります。

トリガーを起点にFirestoreのドキュメントに変更を加える、Cloud Messagingを用いてプッシュ通知を送る、外部サービスのAPIを叩くといったことが可能になります(CLoud FunctionsからFirebaseサービス外への通信が発生する場合、無料プランでは実行できず、有料プランへの切り替えが必要)

最近では、GoやPythonでも関数を定義できるようになりました。

サンプルを通してFirebaseを使った開発を学ぶ

ここからはサンプルアプリケーションを通して、Firebaseを使った開発の仕方や設計を説明していきます。 認証からデータベース、Cloud Functionsにセキュリティルールといった形で、実際にサービスを公開するまでに必須となる内容を、実践的に学ぶことができます。

サンプルアプリケーションの全体は、GitHubの次のリポジトリで公開しています。

sgr-ksmt/Shopping-Cart

サンプルアプリの概要

取り上げるサンプルは、とてもシンプルで簡素なフリーマーケット型のショッピングアプリです。

出品された商品を一覧や個別に表示でき、

気に入った商品をカートに入れて、購入できます。

利用するFirebaseの機能と開発環境

このサンプルでは、以下の機能を使用します。

  • Authentication
  • Cloud Firestore
  • Cloud Functions

筆者がiOS開発を得意としていることもあり、作成するサンプルはiOSアプリで開発言語はSwift、バックエンドはNode.jsでTypeScriptです。 使用した開発環境は、以下の通りです(できる限り執筆時点の最新バージョンを使用しています)

  • macOS: Mojave 10.14.3
  • Xcoode: 10.2.1
  • Swift: 5.0
  • TypeScript: 3.4
  • Node: 8系
  • Firebase iOS SDK: v5.20.0
  • Firebase js-sdk: admin, cloud-function, cloud-firestore

サンプルを動かすには

上記のリポジトリで公開しているサンプルを実際に動かすには、次のセクションから説明するように自身でFirebaseプロジェクトを作成することと、関連する設定ファイルが必要です。 iOSアプリ側の設定ファイルはGoogleService-Info.plistで、バックエンド側はadmin_sdk.jsonです。

詳細は、上記リポジトリのREADME.mdを参照してください。

Firebaseプロジェクトのセットアップ

ここからは実際に手を動かしながら、Firebaseを利用する方法を解説します。

まず、Firebaseのプロジェクトをセットアップしましょう。 Firebase consoleにアクセスして、「プロジェクトの追加」から新たにプロジェクトを作成します。

「プロジェクトの追加」から新たにプロジェクトを作成する

このウィンドウで、プロジェクト名、プロジェクトID、Firestoreとアナリティクスで使用されるロケーション(リージョン)を指定します。

プロジェクトIDとロケーションは後で変更できないので、注意が必要です。 また、今では東京リージョン(asia-northeast1)や大阪リージョン(asia-northeast2)を 選択できるので、国内に向けたサービスを作りたい場合は選ぶとよいでしょう。

iOSアプリ開発環境のセットアップ

iOSアプリに関しては、通常通りXcodeプロジェクトを作成したのち、FirebaseをCocoaPods経由でインストールすれば、おおむねセットアップは完了します。

今回は、Firebase iOS SDKのうちCore Auth Firestore Functionsの4つを扱うので、次のようなPodfileを記述して、pod installを実行します。

target 'Project' do
  pod 'Firebase/Core'
  pod 'Firebase/Auth'
  pod 'Firebase/Firestore'
  pod 'Firebase/Functions'
end

CocoaPodsそのものの利用について知りたい方は、次のエントリーなどが参考になります。

【Swift】CocoaPods導入手順 - Qiita

バックエンド側のセットアップ

今回はCloud Functionsと、Firestoreのセキュリティルールをデプロイする必要があるので、バックエンド側のセットアップも行います。

まず、npmあるいはyarnで、firebase-toolsをインストールします(筆者はyarnを使います)firebase-toolsは、マシンのグローバル領域にインストールしてもよいですし、プロジェクトにインストールしてもかまいいません。

グローバルでインストールする場合は、次のようになります。

$ yarn global add firebase-tools

筆者は、複数プロジェクトそれぞれで独立してバージョン管理ができるように、プロジェクト毎にインストールするようにしています。

$ yarn init                     # package.jsonを作成(対話は全てスキップでOK)
$ yarn add firebase-tools

firebaes-toolsがインストールできたら、セットアップを実施します。 以降はプロジェクトにfirebase-toolsをインストールした場合のコマンド例です (グローバルにインストールした場合、プレフィックスのyarnは不要)

$ yarn firebase login
$ yarn firebase init

このように、まずFirebaseにログインfirebase loginしないと、セットアップfirebase initやデプロイは実行できません。

Firebaseのセットアップは対話式に進みます。 セットアップしたいプロジェクトを選択し、以下のように答えていきます。

What file should be used for Firestore rules?
そのまま(firestore.rules)
What file should be used for Firestore indexes?
そのまま(firestore.indexes.json)
What language would you like to use to write Cloud Functions?
TypeScriptを選択(JavaScriptがよい場合はJavaScriptを選択)

もしセットアップしたいプロジェクトが選択項目に現れないときは、firebase loginでログインしたアカウントに閲覧権限があるかどうか確認してください。

バックエンド側の調整と整理

ここまでが済むと、指定したディレクトリに、functionsディレクトリができ、次のファイルが含まれていると思います。

  • firebase.json
  • firestore.rules
  • firestore.indexes.json
  • tslint.json
  • tsconfig.json
  • index.ts

ひとまずこの状態でもセットアップできているのですが、

  • ディレクトリ構造を見直したい
  • デプロイ時に指定するソースコードの場所を整理したい

という理由から、さらに少し手を加えます。 具体的には、次の3つのファイルを書き換え

  • package.json
  • tsconfig.json
  • firebase.json

ディレクトリの構成を、最終的に以下のようにします。

.
├── config
│   └── admin_sdk.json
├── dist
├── firebase.json
├── firestore.indexes.json
├── firestore.rules
├── node_modules
├── package.json
├── src
│   └── index.ts
├── tsconfig.json
├── tslint.json
├── yarn-error.log
└── yarn.lock

package.jsonには、次のようにbuilddeployのコマンドを記述しておきます。

{
  "name": "functions",
  "scripts": {
    "lint": "tslint --project tsconfig.json",
    "build": "tsc && cp package.json ./dist && cp config/admin_sdk.json ./dist",
    "deploy": "firebase deploy"
  },
  "engines": {
    "node" : "8"
  },
  "main": "index.js",
  "dependencies": {
    "firebase-admin": "~7.0.0",
    "firebase-functions": "^2.3.0"
  },
  "devDependencies": {
    "firebase-tools": "^6.9.2",
    "tslint": "^5.12.0",
    "typescript": "^3.2.2"
  },
  "private": true
}

buildは、functionsのソースコードをトランスパイルした.jsファイル、package.jsonadmin_sdk.jsondistディレクトリにコピーするコマンドになっています。 deployでは、firebase.jsonで指定したdistディレクトリの内容を元にデプロイを行います。

以下は、tsconfig.jsonは次のようになります。

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "dist",
    "sourceMap": true,
    "strict": true,
    "target": "es2017"
  },
  "compileOnSave": true,
  "include": [
    "src"
  ]
}

firebase.jsonです。

{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "functions": {
    "source": "dist"
  }
}

ここまでで、iOSアプリとバックエンドの両方で開発を進めるセットアップが一通り完了しました。 構成に迷ってしまった場合は、サンプルプロジェクトと照らし合わせて確認してみてください。

匿名認証を有効にする

プロジェクトがセットアップできたら、Firebase consoleで「Authentication」を選び、「ログイン方法」の項目を開きます。 ここにはFirebaseで認証するためのログイン手段が並んでいます。

今回は匿名認証を使用したいので、「匿名」の項目を有効にします。 これにより、アプリ側からメールアドレス等不要で認証させることが可能になります。

「匿名」の項目を有効にする

以降、他の認証方法を使用する場合は、同様にこの画面から有効にする必要があります。 新規で認証方法を増やす場合にエラーが発生した場合には、設定を有効にしているかどうか見直しましょう。

なお、今回のサンプルでは使いませんが、TwitterやFacebookといった認証方法を使ってのログインも可能になっています。

Firestoreをセットアップする

次に、データベースをセットアップをします。 Firebaseには、リアルタイムでデータを同期できるクラウドベースのデータベースが2つありますが、今回はRealtime Databaseではなく、Firestoreの方を使用します。

Firebase consoleで「Firestore」を選択するときに、セキュリティルールがどちらかを聞かれますが、ひとまず「テストモード」を選択します。 これにより、(一時的に)全てのドキュメントへのアクセスが可能になります。

ロックモードを選択すると、全てのドキュメントに対する操作は行えない状態になっているので、別途セキュリティルールを記述する必要があります (セキュリティルールに関しては後述)

ここまでで、Firebaseで最低限必要な機能のセットアップが完了しました。

サンプルで扱うデータモデルの構成

今回のサンプルでFirestoreに格納するデータモデルについて説明します。

登場するドキュメントは、User(ユーザー)Product(商品)CartItem(カート)Order(注文情報)の4つで、構成は以下のようになっています。

ドキュメント 場所 管理するコレクション
Userドキュメント データベースのルート usersコレクション
Productドキュメント データベースのルート productsコレクション
CartItemドキュメント Userドキュメントの配下 cart_itemsサブコレクション
Orderドキュメント Userドキュメントの配下 ordersサブコレクション

また、Productドキュメントにはそのドキュメントの作成者Userのドキュメントのパス(reference)を、CartItemドキュメントにはカートに入れた商品Productのドキュメントのパスを持たせるようにしています。

なお、今回はサンプルのため簡略化していますが、本格的にサービスを運用する場合は、次のような設計の追加が必要になるでしょう。

  • Userドキュメントにプロフィール写真のURLを持たせる
  • 注文時に必要な住所を表すAddressドキュメントを、Userドキュメントのサブコレクションとして持つようにする
  • Productドキュメントに、公開・非公開を表すbool値を持たせる(例えば、isPublished
  • Orderドキュメントに、注文ステータスや配送ステータスといった情報を持たせる
  • 売上を管理するドキュメントを設計する

決済に関わる部分の情報(カード情報等)は、適切にセキュリティルールで保護した上でFirestoreに持つのもよいですし、自社の決済基盤やStripeを活用するのもよいでしょう。

〈補足〉FirestoreとRDBとの違いや設計上の注意点

モデルの設計にあたっては、Firestoreのようなドキュメント指向のNoSQLとRDB(リレーショナルデータベース)との違いや、それぞれでできること・できないことを意識して設計する必要があります。

RDBでは、データを正規化し、JOINGROUP BYといったSQLの操作で目的のデータを結合して取得することが定石となっています。 それに対して、Firestoreではデータを冗長化しておいたほうがよかったりします。

Firestoreのクエリにも絞り込みの機能が備わっているものの、RDBと違って結合や集計の操作を行うことが現状できないため、なるべく簡単なクエリ操作でデータを取得できる設計にしておくとよいでしょう。

また、結合などの操作が行えないため、「Aドキュメントを取得した後に、関係のあるBドキュメントをあらためて取得する」といった操作が発生するケースがあります(クライアントサイドジョインと呼ばれたりします)。 いわゆる「N+1問題」のような状況が発生するため、規模が大きくなるほど問題化することも考えられます。

クエリを活用し、適切な範囲を取得して読み込むようにロジックを調整すれば、大きな問題にはならないと筆者は思っていますが、読み込み回数の増加を少しでも防ぐため、あらかじめ「AのドキュメントにBのドキュメントの情報を書いて、冗長化する」といったデータ操作をすることもあります。

具体的な例を挙げましょう。次のように参照を持たせる方法では、productドキュメントの一覧を取得した後に、それぞれのownerの参照を元にユーザーの情報を取得してUIに反映させるというように、N+1回の読み込みが必要になります。

{
  product: {
    price: 100,
    name: 'Banana',
    owner: '/users/xxxxxx'
  }
}

対して、次のように冗長化したデータを持たせた場合には、productドキュメントを取得した時点でユーザーの情報があるので、そのままUIに反映することができ、productの一覧を読み込むだけで済みます。

{
  product: {
    price: 100,
    name: 'Banana',
    owner: {
      name: 'su-',
      profile_image: 'https://xxx.com/images/profile.png'
    }
  }
}

ただし、冗長化するデータの大元であるユーザーのドキュメントに更新があった場合は、冗長化した部分も更新する必要があり、読み込みは減るものの、書き込みは必然的に増えますし、更新を忘れるとデータの整合性が保てなくなります。

このような「冗長化したデータ構造を作る」ため、Firebaseにはバッチによる一括書き込み、トランザクションを用いた書き込み、Cloud FunctionsのFirestoreイベントトリガーといった機能が備わっています。 これらを活用することで、データの整合性を担保しつつ、データを冗長化して持たせることが可能になります。

一方で、全てのデータを冗長化して持つべきかといえばそうではなく、データの読み込みの頻度、冗長化される元となるドキュメントの更新頻度によって、適切に判断する必要があります。 また、冗長化して配置する箇所が増えれば増えるほど、冗長化して配置・更新するための裏側の処理が複雑になるので、かえって設計が大変になってしまうこともあります。

今までRDBを使ってきた人にとって、同じようなデータが冗長的に配置されることは、初めのうち違和感があると思います。 その違和感も、慣れてくるとなくなり、冗長化すべきかそうでないかも見極められるようになってくると思います。

Firestoreで実現できるモデルの構成に関しては、次の記事やスライドがとても参考になります。

Cloud Firestoreを実践投入するにあたって考えたこと#CloudFirestore データベース設計

Firestore Database Design by 1amageek

ユーザーをAuthenticationで認証してFirestoreで作成する

ここから、Firebaseの機能を利用したコードの書き方を解説していきます。 まず、Authenticationを利用した認証について説明します。

より詳細な実装は、前述のリポジトリにあるFirestoreModel.swiftを参照してください。

アプリから匿名認証でログインしつつ、ユーザーを作成する

AuthのaddStateDidChangeListenerを使って、ユーザーが認証済みであるかどうかを確認します。

handle = Auth.auth().addStateDidChangeListener { auth, user in
    if let user = user {
        // 認証済み
    } else {
        //  認証が済みではない
    }
}

このメソッドで、最初の呼び出し時と、以降の認証状態が変化したときに確認することができます。 これで、認証済みであれば認証後の画面を、そうでなければ認証前のサインアップ画面を出すことが可能になります。

はじめは認証情報がないため、次のようなサインアップ画面を準備します。 名前を入力してもらい、サインアップしてもらいます。

匿名認証を行うには、次の処理を呼び出します。

Auth.auth().signInAnonymously { authDataResult, error in
    switch Result(authDataResult, error) {
    case let .success(authDataResult):
        // 匿名認証に成功
    case let .failure(error):
        // 匿名認証に失敗
    }
}

Result型を使いやすくするため、次のラッパーを用意しています(サンプルのResult+.swiftを参照)

public extension Result {
    init(_ success: Success?, _ failure: Failure?) {
        if let success = success {
            self = .success(success)
        } else if let failure = failure {
            self = .failure(failure)
        } else {
            fatalError("Illegal combination found.\n Success: \(success as Any), Failure: \(failure as Any)")
        }
    }
}

認証ができたら、その認証情報を使ってUserドキュメントを作成します。 基本的には認証で得られたuidをそのままUserドキュメントのドキュメントIDとして用いるとよいでしょう。

ユーザーは、Firestoreでusersコレクションの中に作成します。

let userRef = Firestore.firestore().collection("users").document(uid)
userRef.setData()

Firestoreのモデルを扱いやすくする

Firestoreのドキュメントの取得や書き込みに関しては、SDKそのままのインターフェースではやや扱いづらいことがあります。

例えば、Swiftで素のまま扱おうとすると、次のような問題が出てきます。

  • 取得したデータの型が[String: Any]型である
  • コレクション名、フィールド名がハードコーディングになる

コードでの例は次のようになります。

let userRef = Firestore.firestore().collection("users").document()
userRef.set(["name": "John"])

userRef.get { (snapshot, error) in
    if let snapshot = snapshot {
        let userName = snapshot.data()?["name"]
        print(userName)
    }
}

そこで、次のような構造体とプロトコルを定義し、少し扱いやすいようにしています。

protocol FirestoreModel {
    ...
}
struct Document<T: FirestoreModel> {
    var id: String {
        return ref.documentID
    }
    let ref: DocumentReference
    let data: T
}

struct User: FirestoreModel {
    let name: String
}

Swiftでは次のように使用します。

Document<User>.create(documentID: uid, model: User(name: name)) { result in
    print(result)
}

TypeScriptでの定義は次の通りで、

export default class Document<T> {
  private snapshot: FirebaseFirestore.DocumentSnapshot
  get ref(): FirebaseFirestore.DocumentReference {
    return this.snapshot.ref
  }
  get data(): T {
    return this.snapshot.data() as T
  }

  constructor(snapshot: FirebaseFirestore.DocumentSnapshot) {
    this.snapshot = snapshot
  }
}

使い方は次のようになります。

const userID = '...'
const user = await admin.firestore().collection('users').doc(userID).get()
  .then(s => new Document<Model.User>(s))

〈補足〉Firestoreのモデルを扱いやすくするOSS

今回のプロジェクトでは使用していませんが、Firestoreのモデルを扱いやすくするOSSが公開されているので紹介しておきます。

Swift向けには、次のようなプロダクトがあります。

次の2つはTypeScript向けです。

認証とユーザー作成をまとめる

ここまでを組み合わせると、サインアップの一連の処理の流れは次のようになります。

func signUp(withName name: String, completion: @escaping (Result<(), Error>) -> Void) {
    if Auth.auth().currentUser != nil {
        return
    }

    Auth.auth().signInAnonymously { authDataResult, error in
        switch Result(authDataResult, error) {
        case let .success(authDataResult):
            Document<User>.create(documentID: authDataResult.user.uid, model: User(name: name)) { result in
                switch result {
                case .success():
                    completion(.success(()))
                case let .failure(error):
                    completion(.failure(error))
                }
            }
        case let .failure(error):
            completion(.failure(error))
        }
    }
}

Firestoreのモデルに沿って商品とカートを扱う

ユーザーUserドキュメント)の処理に続いて、商品Productドキュメント)とカートCartItemドキュメント)の扱いを実装していきましょう。

商品を登録する

実際のショッピングサービスであれば、出品するユーザーがフォームに従って情報を入力し、商品を登録しますが、今回は簡易化のため既に商品情報は用意してあり、出品時にはランダムな商品情報に出品者自身のUserドキュメントのパスを付与し、Firestoreに保存します。

let product = Product.makeMock(owner: userRef)
KRProgressHUD.show()
Document<Product>.create(model: product) { result in
    KRProgressHUD.dismiss()
    print(result)
}

商品の一覧を表示する

次に、登録した商品の一覧を表示できるようにします。 ドキュメントの一覧を取得する方法には、一度だけ取得するgetと、変更をリッスンし続けるlistenがあります。 今回はlistenを用いて、変更があった場合に一覧を更新します。

ドキュメントの一覧を取得する際にはクエリを指定でき、対象のドキュメントを絞ったり、並び替えたりできます。 次のコードでは、クエリにpublishedTime(公開日時)で降順、さらに最大取得数が100という条件を付けて取得しています。

listener = Document<Product>
    .listen(queryBuilder: { query in query.order(by: "publishedTime", descending: true).limit(to: 100) })
    { [weak self] result in
        switch result {
        case let .success(products):
            self?.products = products
            self?.tableView.reloadData()
        case let .failure(error):
            print(error)
        }
}

常にリッスンしているので、商品を登録するとすぐさま変更を受け取り、商品の一覧を更新することができます。

商品をカートに入れる

続いて、カートに商品を追加します。先程の商品の登録と同様に、ドキュメントを指定の場所に作り、保存する流れになります。

カートは、商品を購入するユーザーに紐付き、他のユーザーから干渉されるドキュメントにはならないため、Userドキュメントのサブコレクションとして実装するのがよいでしょう。 具体的には、users/{user_id}/cart_items/以下にドキュメントを作成します。

@objc private func addToCart(_: Button) {
    guard let user = UserManager.shared.currentUser else {
        return
    }
    guard let product = product else {
        return
    }
    KRProgressHUD.show()
    // 既に同じ商品がカートにあるかどうか調べる
    Document<CartItem>.get(parentDocument: user, queryBuilder: { q in q.whereField("product", isEqualTo: product.ref).limit(to: 1) }) { result in
        KRProgressHUD.dismiss()
        switch result {
        case let .success(cartItems):
            if let existsCartItem = cartItems.first {
                existsCartItem.update(fields: [.quantity: FieldValue.increment(Int64(1))]) { result in
                    print(result)
                }
            } else {
                let cartItem = CartItem(product: product.ref, quantity: 1)
                Document<CartItem>.create(parentDocument: user, model: cartItem) { result in
                    print(result)
                }
            }
        case let .failure(error):
            print(error)
        }
    }
}

カートに同じ商品がない場合は、新規でCartItemドキュメントを作成します。

既に同じ商品をカートに入れている場合は、既存のCartItemquantityを更新します。

なお、数量を更新する際にはquantity + 1とするのではなく、FieldValue.increment()を使うとよいでしょう。 number型のフィールドを、安全に増減させることができます(減らすときは負数を代入)。 詳細は、公式ブログで次の記事を参照してください。

Incrementing Values Atomically with Cloud Firestore - The Firebase Blog

カートを表示する

カートに入っている商品の一覧を表示するには、Userドキュメントのサブコレクションであるcart_itemsコレクションのドキュメントの一覧を取得します。

listener = Document<CartItem>.listen(parentDocument: user) { [weak self] result in
    switch result {
    case let .success(cartItems):
        self?.cartItems = cartItems
        self?.tableView.reloadData()
    case let .failure(error):
        print(error)
    }
}

取得したCartItemドキュメントには数量とProductドキュメントのパスがありますが、Product自体の情報は持っていないので、画面に商品画像や商品名を表示するには、CartItemドキュメント毎にProductドキュメントを取得する必要があります。

final class CartItemCell: UITableViewCell, CellReusable {
    func configure(with cartItem: Document<CartItem>) {
        quantityLabel.text = "\(cartItem.data.quantity)"
        // CartItemからProductを取得し、UIに反映する
        Document<Product>.get(documentID: cartItem.data.product.documentID) { [weak self] result in
            let product = try? result.get()
            self?.nameLabel.text = product?.data.name ?? ""
            self?.priceLabel.text = product.map { $0.data.price.toPriceString() } ?? ""
            self?.productImageView.kf.setImage(with: product?.data.imageURL.asURL())
        }
    }
}

今回のケースでは、Productドキュメントにある金額・名前・在庫数といった情報が変わる可能性があるため、CartItemドキュメントに情報を書き写すのではなく、Productドキュメントのパスを持っておいて、最新の情報をその都度、取得する方が適しています。

それぞれのユーザーがカートに入れる商品の数もたかだか数十個程度でしょうから、いわゆる「N+1問題」もそこまで大きな問題にならないのではと思います。

カートに入れた商品を削除する

カートに入れた商品を削除するのはとても簡単で、cartItemドキュメントのパスに対して、.delete()を呼んでやるだけです。

let cartItem = cartItems[index]
cartItem.delete { result in
    print(result)
}

別の方法としては、ドキュメントにisActiveといったbool値のフィールドを持たせて、そのフラグを更新する方法もあります。

let cartItem = cartItems[index]
cartItem.update([.isActive: false]) { result in
    print(result)
}

物理的にデータベースから消去するのが好ましくなく、論理的に削除状態にしたい場合は、こちらを検討してみるとよいでしょう。

ここまでで商品を登録し、カートに入れたり外したりできるようになりました。次に、いよいよカートの商品を購入してみましょう。

カートに入れた商品をCloud Functionsで購入する

購入処理に関しては次のような要件が求められるため、Cloud Functionsの呼び出し可能なHTTPSファンクション(Callable HTTPS Functions)を利用します。

  • 購入に関わるロジックをクライアントに持たせない
  • ドキュメントの保存をトリガーにして実行するのではなく、任意のタイミングで処理を実行したい
  • 処理の結果(成功か失敗か)を、クライアントに適切に伝えたい

呼び出し可能なHTTPSファンクションfunctions.https.onCallが、ただのHTTPSファンクションfunctions.https.onRequestと異なる点は、クライアントからリクエストを送信する際に、Firebaseの認証情報が自動的に付与されることです。 これによって、関数実行時に認証情報を検証できるため、より安全な呼び出しが可能です (呼び出し可能ファンクションが実装されるまでは、自前で認証情報を含めて呼び出す必要がありました)

購入を処理するファンクション

購入処理のファンクションは、以下のようになります。

export const purchase = functions
  .runWith({ memory: "1GB", timeoutSeconds: 240 })
  .region('asia-northeast1')
  .https.onCall(async (data, context) => {
    try {
        // 認証済みユーザーかどうかチェックする
      if (!context.auth || !context.auth.uid) {
        throw new functions.https.HttpsError('unauthenticated', 'User is not authenticated.')
      }
      // ユーザーの取得
      const user = await admin.firestore().collection('users').doc(context.auth.uid).get()
        .then(s => new Document<Model.User>(s))

      // ユーザーのサブコレクション`cart_items`の一覧を取得する
      const cartItems = await user.ref.collection('cart_items').get()
        .then(s => s.docs.map(d => new Document<Model.CartItem>(d)))

      // カートが空でないことを確認する
      if (cartItems.length === 0) {
        throw new functions.https.HttpsError('failed-precondition', 'Cart items must be one or more items.')
      }

      // トランザクションを利用して、カートにいれた商品の在庫があり購入可能かを確認する
      await admin.firestore().runTransaction(async transaction => {
        const promises = []
        for (const cartItem of cartItems) {
          promises.push(transaction.get(cartItem.data.product)
            .then(s => new Document<Model.Product>(s))
            .then(product => {
              if (cartItem.data.quantity <= product.data.stock) {
                // 購入できるのが確認できたら、`Product`の在庫を減らす
                transaction.update(product.ref, { stock: product.data.stock - cartItem.data.quantity })
              } else {
                throw new functions.https.HttpsError('failed-precondition', 'There is less stock than the quantity to buy')
              }
            })
          )
        }
        return Promise.all(promises)
      })

      const products = await Promise.all(cartItems
        .map(c => c.data.product.get().then(s => new Document<Model.Product>(s)))
      )
      // 注文情報を作成する
      // 購入日時と、購入した時点での商品の情報を配列として持たせる
      const order: Model.Order = {
        purchaseTime: admin.firestore.Timestamp.now(),
        snapshotProducts: products.map(product => {
          return {
            id: product.ref.id,
            name: product.data.name,
            desc: product.data.desc,
            price: product.data.price,
            owner: product.data.owner,
            imageURL: product.data.imageURL,
            quantity: cartItems.find(c => c.data.product.path === product.ref.path)!.data.quantity
          }
        })
      }
      const orderRef = user.ref.collection('orders').doc()
      await orderRef.create(order)
      await Promise.all(cartItems.map(cartItem => cartItem.ref.delete()))
      return { orderID: orderRef.id }
    } catch (error) {
      throw error
    }
  })

これはindex.tsに直接ではなく、purchase.tsという別ファイルに書くことにします。

購入処理の詳細

購入処理を順に見ていくと、次のようになります。

  1. 事前条件の確認(認証の確認、ユーザードキュメントの取得、カートに商品があるか)
  2. カートにいれた商品の数量に対して在庫があるかの確認
  3. 商品から在庫を減らす
  4. 注文情報を作成する

商品の在庫確認と在庫を減らす処理は、同時に複数のアプリから実行されても問題ないように、トランザクションを張る必要があります。 それによって、仮に同じ商品が同時に購入されようとしても整合性が担保でき、在庫切れになったのに購入できてしまったというケースを防ぐことができます。

トランザクションを張るには、FirestoreモジュールのrunTransactionを実行します。

また、最後に作成するOrderドキュメント(注文情報)は、次のようなデータになるため、

  • 購入処理だけで作成され、その後は更新されない
  • 購入時点のProductドキュメントの状態が分かればよく、最新の情報が必要ではない

購入当時のProductドキュメントの情報をSnapshotProductとして書き写します。

これは、先ほど説明したCartItemドキュメントが、書き写すのではなくパスを持っていたことと対照的です。

さらに、ファンクションの定義の最初にある.runWith().region()では、Cloud Functionsのトリガーに対して、実行時のメモリやタイムアウト、リージョンを指定できます。 ここではFirestoreのリージョンを東京で作成しているので、それに合わせています。

記述できたら、この関数をindex.tsでロードするようにします。

import * as admin from 'firebase-admin'
import * as Purchase from './purchase'

const serviceAccount = require(`./admin_sdk.json`)
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) })

export const purchase = Purchase.purchase

ファンクション内でAdmin SDKを使うため、最初にセットアップする必要があります。 詳細は公式のドキュメントを参考にしてください。

サーバーにFirebase Admin SDKを追加する

最後に、ソースコードをビルドし、ファンクションをデプロイします。

$ yarn build
$ yarn deploy

アプリから購入処理のファンクションを呼び出す

デプロイしたファンクションをiOSアプリから呼び出すには、次のようなコードを記述します(サンプルのPurchaseRequest.swiftを参照)

struct PurchaseRequest {
    struct Response: Decodable {
        var orderID: String
    }

    static func call(completion: @escaping (Result<Response, Error>) -> Void) {
        Functions.functions(region: "asia-northeast1").httpsCallable("purchase").call { result, error in
            switch Result(result, error) {
            case let .success(result):
                if let orderID = (result.data as? [String: String])?["orderID"] {
                    completion(.success(.init(orderID: orderID)))
                }
            case let .failure(error):
                completion(.failure(error))
            }
        }
    }
}

// 購入処理を行う
@objc private func purchase(_: UIButton) {
    KRProgressHUD.show()
    PurchaseRequest.call { result in
        KRProgressHUD.dismiss()
        print(result)
    }
}

Firestoreのセキュリティルールを構築する

ここまでで一通りの機能がそろったので、セットアップ時に「テストモード」で構築したCloud Firestoreのセキュリティルールを、強固なものに変更しましょう。

必要なルールの条件と骨組み

まず、データモデルの構成で説明したそれぞれのコレクション・ドキュメントに対して、必要なルールの条件をまとめてみます。

なお、ドキュメントのreadやwriteは、それぞれget list create update deleteといったオペレーションに分解でき、細かく設定できます。

Userドキュメント(/users/{user_id})の条件:

オペレーション 可能な条件
get 認証済みのユーザー
create 認証済みユーザー本人
それ以外 不可

Productドキュメント(/products/{product_id})の条件:

オペレーション 可能な条件
get 認証済みのユーザー
list 認証済みのユーザー
create 書き込もうとしている認証済みユーザーがproduct.ownerと一致
不正な値で書き込もうとしていない
それ以外 不可

CartItemドキュメント(/users/{user_id}/cart_items/{cart_item_id})の条件:

オペレーション 可能な条件
get ユーザー本人
list ユーザー本人
create ユーザー本人
cartItem.quantityが数値
update ユーザー本人
create時に書き込んだproductが書き換わっていない
delete ユーザー本人

Orderドキュメント(/users/{user_id}/orders/{order_id})の条件:

オペレーション 可能な条件
get ユーザー本人
list ユーザー本人
それ以外 不可

この条件を元にルールの骨組みを形成すると、次のようになります。

service cloud.firestore {
  match /databases/{database}/documents {

    match /users/{userID} {
      allow get: if /*条件式*/;
      allow create: if /*条件式*/;

      match /cart_items/{cartItemID} {
        allow get: if /*条件式*/;
        allow list: if /*条件式*/;
        allow create: if /*条件式*/;
        allow update: if /*条件式*/;
        allow delete: if /*条件式*/;
      }

      match /orders/{orderID} {
        allow get: if /*条件式*/;
        allow list: if /*条件式*/;
      }
    }

    match /products/{productID} {
      allow get: if /*条件式*/;
      allow list: if /*条件式*/;
      allow create: if /*条件式*/;
    }
  }
}

Firestoreのルールは原則として、記述のないドキュメント・コレクションに対するアクセス権は拒否されるようになっています。 この例では、Userドキュメントの削除や、全く関係のないcitiesコレクションに対するドキュメントの書き込みは拒否されます。

このため、今後新しいドキュメントを設計して読み書きする場合には、それに対応したルールを追加していく必要があります。

ルールを構築するために便利な関数

ここから骨組みに対してルールを構築していきますが、そのために便利な関数を定義します。

セキュリティルールでは独自に関数を組むことができ、関数を活用することで分かりやすく、後でメンテナンスのしやすいルールを構築できます。

// 認証済みかどうかチェックする関数
function isAuthenticated() {
  return request.auth != null;
}

// ユーザーのIDと認証情報が一致するかチェックする関数
function isUserAuthenticated(userID) {
  return request.auth.uid == userID;
}

// request.resource.dataを返す関数
function incomingData() {
  return request.resource.data;
}

// resource.dataを返す関数
function existingData() {
  return resource.data;
}

// フィールドの値がstringの場合のバリデーション関数
function validateString(text, min, max) {
  return text is string && min <= text.size() && text.size() <= max;
}

// フィールドの値がintの場合のバリデーション関数
function validateInt(num, min, max) {
  return num is int && min <= num && num <= max;
}

また、updateの条件では、しばしば「変更前後でフィールドの値が変更されていないこと」を条件に書きたいケースがあります。 そういうときは、次のように書くとよいでしょう。 この例は、updateの操作のときに、Productドキュメントのパスが書き換わっていないことを確認するための条件です。

incomingData().product == existingData().product

これを加えることで、意図しないProductドキュメントやその他のドキュメントのパスに書き換えられる問題を防ぐことができます。 update操作に対するルールを書くときには、どのフィールドが書き換え可能で、どのフィールドは書き換え不可なのか検討しておきましょう。

Cloud Firestoreのruleで`update`の条件を書く時に気をつけたいこと - Qiita

上記の関数とテクニックを組み合わせて構築すると、最終的に次のような構成になります。

service cloud.firestore {
  match /databases/{database}/documents {

    match /users/{userID} {
      allow get: if isAuthenticated();
      allow create: if isUserAuthenticated(userID);

      match /cart_items/{cartItemID} {
        allow get: if isUserAuthenticated(userID);
        allow list: if isUserAuthenticated(userID);
        allow create: if isUserAuthenticated(userID)
                      && validateInt(incomingData().quantity, 0, 100)
                      && exists(incomingData().product);
        allow update: if isUserAuthenticated(userID)
                      && validateInt(incomingData().quantity, 0, 100)
                      && incomingData().product == existingData().product;
        allow delete: if isUserAuthenticated(userID);
      }

      match /orders/{orderID} {
        allow get: if isUserAuthenticated(userID);
        allow list: if isUserAuthenticated(userID);
      }
    }

    match /products/{productID} {
      allow get: if isAuthenticated();
      allow list: if isAuthenticated();
      allow create: if isAuthenticated()
                    && validateString(incomingData().name, 1, 40)
                    && validateString(incomingData().desc, 1, 1000)
                    && validateInt(incomingData().price, 100, 99999)
                    && validateInt(incomingData().stock, 0, 999)
                    && validateInt(incomingData().imageURL, 1, 99999)
                    && get(incomingData().owner).id == request.auth.uid;
    }
  }

  function isAuthenticated() {
    return request.auth != null;
  }

  function isUserAuthenticated(userID) {
    return request.auth.uid == userID;
  }

  function existingData() {
    return resource.data;
  }
  function incomingData() {
    return request.resource.data;
  }

  function validateString(text, min, max) {
    return text is string && min <= text.size() && text.size() <= max;
  }

  function validateInt(num, min, max) {
    return num is int && min <= num && num <= max;
  }
}

ルールをデプロイする

ルールが記述できたら、コマンドライン経由でFirebaseにデプロイします。 セキュリティルールのみをデプロイする場合は、--only firestore:rulesを付けます。

$ yarn firebase deploy --only firestore:rules

デプロイした後、1分ほどで設定が反映されるでしょう。 ルールをデプロイした後には、正しく動作するかを確認してみましょう。

セキュリティルールはどのくらいを書けばいいの?

ここで「どのくらいセキュリティルールを書けばいいの?」という疑問が出てくるかと思います。次の順で優先度を高くして、書くとよいでしょう。

  1. 認証状態のチェック
  2. アクセス権の検証
  3. 不正な値でないかのバリデーション

最も防ぐべきは、意図しないユーザーによる不正な読み取りや、意図しないデータへの書き換えです。

少なくとも次の状態でサービスを公開してしまわないよう十分注意しましょう。

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write;
      // 或いは allow read, write: if auth != null;
    }
  }
}

Firestoreのセキュリティルールに関してより詳しく学びたい方は、筆者の記事ですが、以下が参考になると思います。

セキュリティルール vs. Cloud Functions

Firestoreでセキュリティルールを書かないで、全てCloud FunctionsのHTTPSファンクション(ないしは呼び出し可能なHTTPSファンクション)でデータをやりとりすることもできます。それぞれ、次のようなPros/Consがあります。

ルールを書く場合のPros:

  • データの取得や書き込みといった処理を、各クライアントのSDKに一任できる
  • ドキュメントやコレクションの変更をリッスンして、リアルタイムに情報を取得できる
  • ルールでは、フィールドのバリデーションをかけることもできるので、不正な値が書き込まれることを未然に防ぐことができる

ルールを書く場合のCons:

  • ルールが間違っている場合の原因の特定が、慣れていないと難しい(permission-deniedとしか表示されない)
  • 読み書きの処理を各プラットフォームで記述しないといけないので、頑張って統一する必要がある
  • 誤ったルールを記述してしまうと、セキュリティリスクになる

Cloud Functionsを使う場合のPros:

  • ルールによる穴を開けないので、セキュリティは強固になる
  • 関数内ではadmin権限でデータベースを操作できるので、ルールの影響を受けることなくやりたいことが実現できる
  • APIを定義する形になるので、複数のプラットフォームで行う処理を統一できる

Cloud Functionsを使う場合のCons:

  • トリガーの入り口をしっかり守る必要がある(認証情報、トークン、呼び出し可能なファンクションの検討など)
  • ファンクションの実行が遅い場合がある

個人的には、次のような場合はルールを記述せず、閉じた状態にして、Cloud Functionsを活用するようにしています。

  • セキュリティ的にどうしても守らないといけない
  • Firebase外とのやりとりが発生する
  • トランザクション等の処理が複雑化してしまう

それ以外に関しては、適切なルールを敷いた上で、クライアントに提供されているSDKを介して読み書きを行うようにしています。 その方が読み書きの速度も早く、リッスンして変更を常に受け取るのも容易です。

最後に

Firebaseの紹介にはじまって、Authentication・Firestore・Cloud Functionsを組み合わせた実装をサンプルを通して説明し、セキュリティルールまで解説しました。

この記事が、まだ一度もFirebaseを試したことがない方や、どのように設計するか分からない方の手助けになればと思います。

岸本 卓(きしもと・すぐる) _sgr_ksmt sgr-ksmt

岸本 卓
2017年クックパッドに入社。入社後に、料理が楽しくなるマルシェアプリ「Komerco(コメルコ)」の開発に携わり、Firebaseを用いた開発、設計を行う。Firebaseは個人的にも、業務としてもたくさん触っていることもあり、ときどき情報発信をブログや登壇といった形で行っている。好きな料理はオムライス。

関連記事