Quantcast
Browsing Latest Articles All 24 Live
Mark channel Not-Safe-For-Work? (0 votes)
Are you the publisher? or about this channel.
No ratings yet.
Articles:

Cloud Firestoreを実践投入するにあたって考えたこと

はじめに

Firebase Realtime DBを実践投入するにあたって考えたことを読んで頂いてありがとうございます。 多くの方から「いいね」を頂いて、今回のこの記事を書くモチベーションになりました:bow_tone1:
本当にありがとうございました!

さて、CloudFirestoreは、Firebase Realtime Databaseとは全く違うデータベースです。特にSubCollectionQueryが導入されたことにより、リレーションシップの設計に関して大きく異なります。

この記事では、主にCloudFirestoreにおけるリレーションシップの設計方法から、アプリ・CloudFunctionsに至るまでを幅広く解説して行こうと思います。

Cloud Firestoreでの開発について

私の経験上確実に断言できることがあります。

Cloud Firestoreだけでサービスを作ることは不可能ではない

でもしんどい。
開発には、他のSaaSを活用にするのがいいと思います。マイクロサービスをつくる観点から考えても機能を分離しておくことは大きなメリットがあります。

もし今から新規サービスを作ろうとして技術選定に困っている方にアドバイスするならば、私はCloud Firestoreを強くお勧めします。簡単に理由を並べると以下の点です。

  • 既存のDBと比較して、今からNoSQLを始める学習コストを考慮しても開発速度が早い
  • スケールするまでは無料で使える
  • グロースさせるまでをFirebaseで完結できる

正直、ネイティブアプリからREST APIを使ってデータを取り扱うメリットはほぼ無いと考えています。完全私の予想ですが、次のような流れになるはずです。

  • 通信プロトコルはgRPCが主流になる
  • RESTはGraphQLに置き換わる
  • RESTは外部サービスとの連携のために残る

完全に個人的な予想なのであまり期待しない方がいいかも知れませんが、僕はそう信じてこの記事を書きます。

Cloud Firestoreの構造

Cloud Firestore は、NoSQLデータベースです。さらに特徴的なのはデータ構造です。
図のようにPCのファイルシステムのような構造を持つことができます。

structure-data.png

このあたりの説明は丁寧にドキュメントで説明されているのでこちらをご覧ください。

一般的なRDBでもなく、MongoDBのような構造でもなく、CloudFirestoreは独特の構造を持ちますので、雰囲気だけでも構造を理解してこの後を読み進めることをお勧めします。

CloudFirestoreのリレーションシップについて

さて、Cloud Firestoreでサービス開発において重要なのはCloudFirestoreのデータ構造をどう設計していくかです。もちろんリレーションシップの設計が重要な鍵となります。Realtime Databaseでは、リレーションシップの方法はそう多くなく、Fan outによるリレーションシップを構築していく程度でしたが、Cloud Firestoreでは違います。Query SubCollection Referenceなどリレーションを行う方法が複数用意されているからです。

NoSQLのベストプラクティスは資料が本当に少なくて色々考えるのに苦労したんですが、僕が考えたベストプラクティスをみんな見てください。そして指摘があればください。

参考になりそうな資料を載せておきます。

DynamoDB のベストプラクティス
サーバーやインフラの性能に触れながら読めるのでとてもいい資料です。

NoSQLデータモデリング技法
Realtime Databaseを設計するなら必ず読んだ方がいい資料です。

残念ながらこれらより時代は進化してまして。
SubCollectionについて考慮された資料は公式のFirebaseがリリースしている情報をのぞいて皆無に近い状態です。
YouTube Firebaseをご参照ください。

CloudFirestore データベース設計

2018年のDevFestで登壇した資料をより深く解説します。🙏🏻
Firestore Database Design

リレーションシップの種類

Cloud Firestoreの複数の方法でリレーションシップ作ることが可能です。まずはその種類を紹介します。タイプ別に種類を図にしました。
スクリーンショット 2018-11-30 13.06.56.png
最終的にこの8パターン組み合わせになるのかなと考えています。
以降Swiftのコードが掲載されますなんとなく読めると思うのでご参考ください。

■ Key

スクリーンショット 2018-11-30 13.19.34.png
これはRDBでも使われる一般的なリレーション方法です。RDBで言うならテーブルに参照先のレコードのIDを持っている状態です。ここでは、ItemuserIDを保持していることからItemUserの関係を表しています。

■ Reference

スクリーンショット 2018-11-30 13.38.53.png

これはCloud FirestoreがもつReference型を使ったリレーション方法です。
Keyとの違いについて考えてみましょう。CloudFirestoreでは次のようにパスにJSONデータを持たせます。

// /user/:id
{
  "name": "hoge",
  "age": "25"
}

ユーザーの情報のマイグレーションしなければならない状態を想定しましょう。例えばageをStringで定義してしまったのでNumberに変更したい場合、今の構造では次のようにするしかなくなります。

// /user/:id
{  
  "name": "hoge",
  "age": "25"
  "age_number": 25
}

ちょっと残念ですよね。 ちなみにこれベストプラクティスです。色々考慮するとこのマイグレーションが一番コストかからずシンプルに移行できます。

ちょっと残念だから綺麗にしたい方はこうするのがオススメです。

// /version/1/user/:id
{
  "name": "hoge",
  "age": "25"
}

最初からパスにバージョン情報を持たせましょう。そうすると

// /version/2/user/:id
{
  "name": "hoge",
  "age": 25
}

バージョンの変更に合わせて、データをマイグレーションできます。増大したデータのマイグレーションにはコストもかかるので、モデルのバージョンをあげることは稀ですが可能です。
しかし、ここでリレーションに話を戻すと問題が出てきます。

ItemUserの関係を表すuserIDはIDのみを保持しており、バージョン情報を持っていません。そこで登場するのがReferenceになります。Referenceパスそのものを保持することが出来るようになります。

Referenceは多用できない

「Reference便利💪🏻」となったかも知れませんが、Referenceは多用できません。なぜでしょうか?ItemにReferenceを持たせるとどうなるかを考えてみましょう。

次の状態では、Itemはバージョン1Userを参照しています。

// /version/1/item/:id
{
  "userID": "user_ID" // :id
  "userReference": "<Ref>", // /version/1/user/:id
}

もしUserのバージョンが更新さたらどうなるでしょうか?Itemは古いバージョンのReferenceを持っているためItemもマイグレーションが必要になります。
どうやら違うモデルを参照する場合は、Keyのみを保持する方が良さそうです。

ではReferenceはいつ使うのか?

  • 新しいモデルから古いモデルを参照する時
  • ネストの深いモデルを参照する時

ではないかと考えています。
例えば次のように現行バージョンが旧バージョンを参照する場合や

// /version/2/item/:id
{
  "userID": "user_ID" // :id
  "oldItem": "<Ref>", // /version/1/item/:id
}

Keyでは表現しきれない階層の任意の情報を示したい場合

// /version/1/user/:id
{
  "userID": "user_ID" // :id
  "pinComment": "<Ref>", // /version/1/item/:item_id/comment/:comment_id
}

// /version/1/item/:item_id/comment/:comment_id
{
  "userID": "user_ID" // :id
  "oldItem": "<Ref>", // /version/1/item/:id
}

■ Same ID

スクリーンショット 2018-11-30 14.37.58.png
このリレーション方法は、CloudFirestoreのパス構造を使った方法です。セキュリティールールを効率的に与えることができるのでセキュアなデータを扱いたいときにオススメです。ユーザーにセキュアな情報を持たせたい場合を考えてみましょう。CloudFirestoreのセキュリティルールではフィールド単位でセキュリティをかけることができません。つまり高いセキュリティを持つドキュメントと公開可能なドキュメントは別々に保持する必要があります。例えばユーザーの情報のセキュリティを高く保つための構造は以下の二つの方法が考えられると思います。

1. SubCollectionを利用した構造

/version/1/user/:user_id/secure/:id

UserのSubCollectionにセキュリティを高めたいデータを持ちます。
SubCollectionのセキュリティルールを設定しデータをセキュアに保ちます。

2. Same ID構造

/version/1/user/:user_id
/version/1/_user/:user_id

上ではUser_Userの別のCollectionを定義しています。Same IDを利用してシンプルにセキュアな情報を保持できます。

Same IDを使ったソーシャル機能

/version/1/user/:user_id
/version/1/social/:user_id

UserSocialを別のCollectionに定義しています。

Userに定義されるであろうデータ

{
  "name": "1amageek",
  "age": 31,
  "gender": "male"
}

Socialに定義されるであろうデータ

{
  "followerCount": 2000,
  "followeeCount": 23
}

一見全てユーザーがもつべきデータに見えますが、UserSocialには明確に分けるべき理由があります。

  • Userデータは自分以外から更新させたくない
  • Socialデータは自分以外からの更新できるようにしたい

理由はそれだけではありません。Socialデータの方が圧倒的に更新頻度が多いはずです。
ここでUserデータの特性を考えてみましょう。このデータはどこで利用されるでしょうか。ソーシャル機能を作っているのであればユーザー検索は必須な機能となり得るでしょう。Cloud Firestoreの検索機能は非常に貧弱なのでAlgoliaやElasticSearchなどに検索機能を任せることが考えられます。
同時にAlgoliaやElasticSearchの更新にはCloud Functionsを利用することが想像できると思います。もしUserデータにSocialデータを含めていたらどうなるでしょうか。カウントがインクリメントされるだけでCloud Functionsがトリガーされることになりそうです。

■ Query

スクリーンショット 2018-11-30 16.03.45.png

このリレーションの方法もRDBでも使われる一般的な方法です。RTDBとは違いwhereが利用できるようになってとても便利になりました。

■ Sub Collection

スクリーンショット 2018-11-30 15.48.50.png
Cloud Firestore最大の特徴がこのSubCollectionです。この構造は他のデータベースには存在しません。

QueryとSubCollectionの使い分け

ここでQueryとSubCollectionの使い分けについて考えてみましょう。
Cloud FirestoreのSubCollectionとQueryっていつ使うの問題
過去のこの様な記事をリリースしましたが、この問題の解答編になります。

- メリット デメリット
Query 横断的にQueryを行える Readのセキュリティルールは必ず全員に公開する必要がある
SubCollection セキュリティの設定が容易 ネストしている親をまたいで検索は行えない
CollectionGroup ネストしている親をまたいで検索を行える 横断範囲が広くセキュリティの設定が難しい

横断的にQueryを行えるの説明をしておきます。
Cloud Firestoreではドキュメントに強固なセキュリティルールを設定した場合にQueryを実行することができなくなります。
セキュリティルールとQueryの関係はこちらに記載されています。
https://firebase.google.com/docs/firestore/security/rules-query?hl=ja
つまりRoot Collectionに配置するドキュメントは緩やかなセキュリティルールを持っている必要があります。

2019年6月のアップデートでCloud FirestoreでCollectionGroupが利用可能になりました。
SubCollectionでも横断的にQueryを実行できるようになりました。
待ち焦がれたCollectionGroupがCloud Firestoreへやってきた。

データのインサートが多いCollectionはRootCollectionには配置しない
Collectionへのインサートには1秒間500回の制限があります。コンシューマー向けのサービスで瞬間的性能が必要なサービスを作るのであればSubCollectionを利用した方が良いでしょう。

例えば

  • ECなどの売上を管理したい場合、Transaction DocumentをRootCollectionに配置してしまうと1秒に500件以上の販売することができない。/user/:user_id/transactions/:transaction_idとしてCollectionGroupで計算する方が良さそうです。

セキュリティが使い方を分ける
2つのリレーション方法の使い分けはセキュリティに依存します。
サービスの全利用者に公開できる情報はRoot CollectionとしてQueryでリレーションするのが良さそうです。一定のセキュリティを保ちたい情報はSubCollectionにする方が良さそうです。

例えば

  • ブログの記事などの公開情報はルートコレクションへ
  • 決済情報などセキュアな情報はUserのSubCollectionへ

■ Junction Collection

スクリーンショット 2018-11-30 16.56.09.png
この方法もRDBで使われる中間テーブルをもつ方法です。

■ Reference Collection

スクリーンショット 2018-11-30 17.05.23.png
この方法はCloud FirestoreのSubCollectionを利用した方法です。
NoSQLで唯一Cloud Firestoreだけが出来る構成です。

Firebase Realtime Databaseで、この方法を使うと構造上データが肥大化することになり使うことが出来ませんでした。Cloud Firestoreでは、DocumentとCollectionに分離された構造になっているため、その制約がなくなりました。

Junction CollectionとReference Collectionの使い分け
- メリット デメリット
Junction Collection 横断的にリレーションシップの検索を行える
状態を持てる
Readのセキュリティルールは必ず全員に公開する必要がある
ReferenceCollection  リレーションシップを持っている状態を隠せる ネストしている親をまたいで検索は行えない

N:Nのリレーションシップにおいても、セキュリティルールに依存します。

例えば、招待機能の機能を考えてみましょう。

User AからUser Bに送られた招待状が未開封のままである。

これをJunction Collectionのデータにするならば下のようになります。

// Invitation
{
  "fromID": "userA",
  "toID": "userB",
  "status": "isUnopened"
}

次に、この招待状を受け入れるとそれぞれフォロー関係が成り立つとしましょう。
これを構造で表すと下のようになります。

/user/userA/followers/userB
/user/userB/followers/userA

User AUser Bfollowersとして保持し、User BUser Afollowersとして保持する。

FirestoreではWriteBatchを使って複数の書き込み先に同時に一度に書き込むことが可能なので、どの処理も簡単に行えます。

トランザクションと一括書き込み
Firestotre のバッチ処理とトランザクション処理

■ Duplicated Collection

スクリーンショット 2018-11-30 17.54.05.png

この方法は、セキュリティを保ちつつデータを参照するデータを参照する方法です。
決済情報をもつデータ構成を考えてみましょう。

ShopProductUserが購入した情報をTransactionとして保持する。
TransactionShopUserのみが参照できる。

この要件を満足したい時、Transactionはどこに保持するのがいいでしょうか。

まず、Transactionには必ずセキュリティルールを設定するので、横断的なQueryは機能しなくなります、そのためルートコレクションに配置することは避けた方が良さそうです。

次に、セキュリティを担保するためにはSubCollection構造にするのが良さそうですが、ShopUserのどちらに持たせるのがいいでしょうか?Userが持たせるとShopからは参照できませんし、Shopに持たせるとUserからは参照できません。

ということで双方に保持するようにしましょう。これもWriteBatchを利用することで簡単に実現可能です。ただしこの方法は、Transactionのように書き換え頻度が低いデータに限った方が良さそうです。

冗長化すべきデータの制限

次のデータ構造を考えてみましょう。 ユーザーの情報を冗長化してフォローに保存しています。

// /user/:user_id
{
  "name": "hoge",
  "location": [0, 0],
  "age": "25"
}

// /user/:user_id/followers/:id
{
  "name": "hoge",
  "location": [0, 0],
  "age": "25"
}

ユーザー情報は高頻度で更新されることが予想されます。ユーザーの情報が更新される度にフォロー先のデータを更新するのはとてもいい設計とは言えません。

私が実戦で利用しているデータ構造を8個ご紹介しましたが、他にもあらゆる構成が考えられます。見つけたらぜひ教えてください。

アプリで考慮すること

REST API

Cloud Firestoreは、SDKを利用することでDBに直接書き込みができます。一方でCloud Functionsを使うことでREST APIを設けCloud Functions経由でDBに書き込むことも可能です。
ではどちらを使えばいいのか考えてみましょう。

セキュリティについて
セキュリティ的にはREST APIでもSDKであっても大きな差はありません。ただSDKではセキュリティルールだけ考慮すればいいのに対して、REST APIではCloud Functionsの中で全てをAdminで動かすことになりますのでAPIのセキュリティには注意をする必要があります。

実装工数
プロトタイピングなどではSDKを利用する方が圧倒的に実装工数を低減できます。ただし、セキュリティルールを最低限にしてるものに限ります。個人的にはプロトタイピングの段階で強固なセキュリティルールは必要ないと思っているので、最低限のルールを記載して開発を進めるのがいいでしょう。

Callable Functions
FirebaseにはCallable functionsと呼ばれる専用のAPIが準備されています。このAPIにはAuth情報も含まれているのでREST APIを作るよりも安全に実装することが可能です。
REST APIを外部から呼ぶことがないようであればREST APIは利用せずCallable Functionsを利用することがいいでしょう。

Callable Functions vs SDK

Cloud Functionsに処理を任せることの最大のメリットはセキュリティルールをバイパスする事です。運用が開始され、セキュリティルールを強固にしていった時必ず権限の持たせ方に困ることがあります。例えば先ほどの紹介したDuplicated Collectionでは必ず相手の保護された領域に書き込みを行うことになります。となるとCloud Functionsを経由するのは必須となります。
また、SDKではセキュリティを考慮せず書き込みが行われる場合に活用するのがいいでしょう。やはりFirebaseの開発の醍醐味は開発の高速化にあると思いますので、あえてAPIを利用しなくていいのであれば可能な限りこちらを使うのが得策であると考えています。

Cloud Firestore Best Practice

Killswitch

Firebase Realtime Databaseと同様に、Cloud Firestoreは開発側の都合でサーバーを停止することはできません。必ずクライアントからの利用を制限する機能を設けましょう。
KillSwitch自体をCloud Firestoreに持たせる事も可能です。

例えば下の図のように強制アップデートが必要なバージョンやアプリが利用可能かを示すフラグを持たせることでハンドリングしましょう。

スクリーンショット 2018-12-01 9.56.58.png

Model

Model設計

RDBを利用してきたエンジニアでばあるほど、NoSQLの設計には苦労します。僕の周りの人間も実際にそうです。NoSQLの設計には割り切りも必要ですし、テクニックを知っている必要がありますが、Cloud FirestoreへQueryが実装されたこともあり、ある程度RDBで利用されてきた考え方が通用します。まずは、SubCollectionについて考えるのではなくRoot CollectionにModelを配置し、プロトタイピングを行ってみましょう。データ取得の最適化が必要なポイントはそこで整理できますし、セキュリティルールを強固にする必要があるポイントも見えてくるはずです。

Model設計の制約

■ Modelは並列に構成する

SubCollectionが準備されたことで、ネスト構造こそがCloud Firestoreの真骨頂のように見えるかも知れませんが、あくまでNoSQLデータベースの欠点を補う機能にすぎません。NoSQLのデータベース設計を理解し、効率的にSubCollectionを活用しましょう。

■ ModelはupdatedAt, createdAtを保持する

もはやアプリ開発系の慣例的な部分でもありますが、やはりこの情報は持っておくことはすごく重要です。
運用時にも役にたちますし、開発においてもソートで利用することは結構あります。

■ Model内のArrayを活用する

ここはFirebase Realtime Databaseと全く逆の考えになるの注意してください。Cloud Firestoreでは、Arrayの制御も追加されました。ArrayをQueryで利用することも可能なので積極的にArrayを利用しましょう。

Better Arrays in Cloud Firestore!

■ Model内にパーミッションを持たせる

public privateなどのパーミッションを持たせることで、セキュリティルールのハンドリング簡単に行うことが可能になります。

スクリーンショット 2018-12-01 10.21.17.png

Firebase Summit 2018のセッションでも詳しく解説されているのでこちらをご参照ください。

https://www.youtube.com/watch?v=pvLkkLjHdkw&index=6&list=PLl-K7zZEsYLnqdlmz7iFe9Lb6cRU3Nv4R

最後に

Firebaseにおいての上記の設計思想からModelを管理できるLibraryを作りました。
ライブラリの利用実績も増えて行ってます!ぜひ利用してください!!コントリビューターも募集しております!

MENTAでFirebaseを学ぶ講座やってます!
https://menta.work/plan/913

Pring for iOS
https://github.com/1amageek/Pring

Pring for Cloud Functions
https://github.com/1amageek/pring-admin.ts

Pring for Web
https://github.com/1amageek/pring.ts

Firebaseについてさらに詳しく知りたい方は次をご覧ください。

Googleに喧嘩を売るFirestoreの変態的かつ実用的なつかいかた

ゼンチン!ゼンチン!ドネシア!ゼンチン!

こんばんは、突然ですが、Firestoreって、めっちゃ便利ですよね。

でも、ぼくおもったんです。

これはもっと便利につかえると。

ただし、

ここから先を読むにあたり、つぎのことに注意してください。

  • このやり方を、大きなプロダクトに突然「入れよう!」って言わない。正論の前に返り討ちに遭います:cop:
  • 正論をお持ちの方。人間誰でも元は健気な赤ちゃんだったんです。健気な気持ちで読んで、くれぐれもお手持ちのマサカリは投げないように!:baby:
  • Googleの人のまえで、「Firestore便利っすよね!こんな使い方できますもんね!」って言わない:no_good:

ではいきましょう!

こんなひとたちに、ありがたみ!

  • 「管理画面をつくりたいけど、時間ないからまずは、DBオペでデータを入れちゃうか..:frowning2:
  • 「管理画面にするほどのデータじゃないからソースに定数として含めているけど、たまに『あの文言変えたいんだよね、、』と言われてビルドし直している..:dizzy_face:
  • 「ほぼリアルタイムにデータをキャッシュをしたいんだけど、今そこまでやるぅ、、、?:rolling_eyes:

やりかた:walking:

  • Firestoreで、「コンフィグデータ専用」のコレクションを1つ作成する
  • サーバから、当該コレクションのリアルタイムアップデートを入手する

これだけです。

具体的に!

例をひとつ。

例えば、あなたのアプリケーション上で、突然、国名わけっこゲーム(さっきまでM-1見ていたので、、)というミニゲームでキャンペーンをすることになりました!

今後も、もし流行ったら、明日もまた、国名わけっこゲームをしよう!となります。
そのときに、ゲームの参加者や、開始時間などは変わる、という設定です。
逆に、流行らなかったら、もうやることはありません。

エンジニア泣かせの仕様ですね。
でも、Firestoreなら大丈夫。

Firestoreでこんなコレクションを作成します。

kokumei.png

settings、というコレクションの中に、 kokumei-wakekko-game というドキュメントがあり、その下に、いくつかフィールドがあります。

1つのコレクションを、もう『スキーマ』とか、『行』、みたいな世界観を忘れて、上記のようにデータを入れておけば、次もう一度国名わけっこゲームを別の変数でやるとなっても、(Firebaseコンソールから、)10秒くらいでDBデータを書き変えて、準備は完了します。

しかも、国名わけっこゲームの最中に、『アッ配信日間違えてる!今すぐ終了しないと!』みたいなときに、
じゃあ家に帰ってPC開いて緊急リリース!とかしなくても、
手元のスマホでfirestoreのコンソールをピッと開いて、サッと変えてやればおわりです。

リアルタイムアップデートでサーバが取得しているので。

ちなみに実際にスマホで変えようとしたときのスクリーンショットがこちら。

Screenshot_20181202-222426.png
↓ ↓ ↓ ↓ ↓ ↓ ↓
Screenshot_20181202-222441.png
↓ ↓ ↓ ↓ ↓ ↓ ↓
Screenshot_20181202-222446.png

簡単にできそうでしょ。

イメージ湧きましたでしょうか?

この辺を既存の技術でガチでやろうとすると、
GUIは必要だわ、イベントをトリガーにリッスンするor定期的なキャッシュ機能必要だわ、色々めんどくさいですからね。

もうFirestoreをただの(リアルタイムアップデート付き型ありGUI付きの贅沢な)コンフィグファイル、とみなすということですね。

ちなみに、もう一つ並行でキャンペーン走るなら、もう一本ドキュメントを作ればいいし(※その場合は本記事下部の注意を読んでね)、
1ドキュメントで、iOSのinfo.plistみたいに管理しちゃってもいい。

まぁほんとうにinfo.plistみたいなもんですからね、この使い方。

使った結果どうなる?

うちのslackの風景。

スクリーンショット 2018-12-02 22.04.25.png

1分で即バリューがでるので、自分がスゴイエンジニアになったんじゃないか、と勘違いしたりします。

ありがたみを整理

  • PC/スマホ両用の管理画面が用意される
    • っていうかFirestoreのコンソール画面だけど
    • スマホでもok。緊急対応も楽々
  • リアルタイムに情報が反映されるので、キャッシュも必要なし
  • 何なら型がついてくる

注意すること!

これは、次の点で、正しい使い方と異なってます。
なので、それぞれ、実用してみて、問題がおこることもありました

1. スキーマがないゆえに

Firestoreデータをすべてexportして、すべてをBigQueryに突っ込もうとしたら、スキーマ読み取れない、で怒られました。

対策

一括exportではなく、collectionを指定してexportもできます。それをBigQueryに吐き出せばok。

また、やってないけれど、1ドキュメントのみでやれば、それイコールスキーマ、になので、怒られないと予想してます(試してません)。

僕は複数ドキュメントを全然違うスキーマで入れていたので、かなりワルいです:imp:

2. リアルタイムアップデートで繋いでいるゆえに

コネクションが切れることがありました。
そもそも、サーバってずっと稼働してるじゃないですか。
いつまでリアルタイムアップデート接続しているのかなぁ、、ってやってみたら、1週間くらいしたらアカンくなってました。(GAEなので、間に再起動してますが)

対策

ただ繋ぎっぱなしなだけでなく、3hおきにつなぎ直すようにしたら、ほぼほぼ切れることはなくなりました。っていうか、3hにする意味は本当に特にないので、もっと短くすべきです←

(おまけ)Remote Configがほぼリアルタイムに反映してくれるようになったと聞いているけど??

そうなんですよね。

敢えて今回紹介したやり方をすることの意味をかくと、
Remote ConfigはConfigに寄りすぎていて、型がない。文字列 or JSONなんですよね。

image.png

JSONデータをサクッと変えたり、スマホから変えたりしますか、というと厳しいよなぁと。

それだけでなく、Firestoreの方が高機能だし、たくさんの機能を流用して悪いことをあんなこともこんなことも、、、

・・・。
・・・・。
・・・・・。

えぇ、ちゃんと技術は正しくつかいましょうね!
Remote Config、使いましょう!

ちなみにまじめに、Remote Configは、環境によって値を変えることができたり、Firebaseの分析系ツールと連携して使えたりするので、本当にシステムコンフィグのリモート化や、PDCAを楽に回していく際に役立つと思います!

それでは、皆さん、2018年も、M-1お疲れ様でした!!

霜降り明星、優勝おめでとう!!!

Firestoreをもっと手軽に使えるfirestore-simpleがバージョン2になりました

External article

Vue.jsにおけるFirebaseの主要な機能の取扱い

External article

Google HomeとFirebase Cloud Functionsでビットコインを全力買いする

この記事はFirebase Advent Calendar 2018 5日目の記事です。
ネタ記事かつ、投稿が遅れて肩身が狭いですが書いてきたいと思います。@daikiojmです。
Google Homeでうんこなうに引き続きGoogle Homeで遊んでみます。

何をしたいのか

以下の動画のように、Google Homeに話しかけることで仮想通貨を全力買いします。
Action on Google(Dialogflow)のwebhookからhttp(s)経由でFirebase Cloud Functions呼び出し、仮想通貨取引所の注文APIを叩くというシンプルなものですが、スマートスピーカーに話しかけるだけで全資産をかけて仮想通貨の売買をするのはなかなかのスリルがありますね😇

構成

構成は次の図の通りです。
Action on Googleと仮想通貨取引所とのつなぎ込みにFirebase Cloud Functions使います。
また、今回はアカウント開設済みかつNode.jsのSDKが提供されているbitbankの取引所APIを使って仮想通貨の注文を行います。

image.png

実現方法

大まかな流れ

  • Action on Googleでプロジェクト作成
  • DialogflowでEntities/Intentsの定義
  • Firebase Cloud Functionsでdialogflow webhookのハンドラ/取引所注文の実装
  • Firebase Cloud Functionsのデプロイ
  • Dialogflowからwebhook連携の設定

それぞれ、ポイントをかいつまんで紹介していきたいと思います。
(それぞれの項目に記載する参考リンクが有用なので、詳しく解説するまでもなさそう...)

Action on Googleでプロジェクト作成

Actions ConsoleからAction on Googleプロジェクトを作成します。
DialogflowでのEntities/Intentsの定義を含めて、以下の記事が参考になりました。

スクリーンショット_2018-12-06_2_22_05.png

Dialogflow と Firebase Cloud Functions で Actions On Google 作り

DialogflowでEntities/Intentsの定義

今回、DialogflowのEntitiesには以下の2つを定義しています。
これら2つが、注文時のパラメータになります。(数量は指定できません全力です)
それぞれ、対話時の発話の揺れを吸収できるように複数の言い回しを登録しています。

  • Asset
    • 売買する通貨名
  • Side
    • 売り/買いの種別

スクリーンショット 2018-12-06 2.25.46.png

スクリーンショット 2018-12-06 2.25.56.png

Intentsは次のような構成になっています。
actions-on-googleのSDK v2以降では、ハンドラ内でIntent名でアクションを識別するため、それぞれわかり易い名前にしておいたほうが良さそうです。
(ここでは、zenryoku-buy-sell - order などが識別子として使われる)

スクリーンショット 2018-12-06 2.29.39.png

その他、Training phrases、Action and parametersは以下の通りに設定しています。

スクリーンショット 2018-12-06 2.33.48.png

Firebase Cloud Functionsでdialogflow webhookのハンドラ/取引所注文の実装

細かいプロジェクト構成は省きますが、firebase-toolsで初期化したTypeScriptのFirebase Cloud Functionsプロジェクトをベースに、以下の3ファイルで構成されています。

  • index.ts
    • Firebase Cloud Functionsのhttpハンドラ
  • types.ts
    • enum定義
  • bitbank-handler.ts
    • 取引所APIの呼び出し
    • ロジックは至ってシンプルで、Cloud Functionが発火した際の最新価格 & 指定された通貨の全保有数量で成行注文を入れる仕様になっています

index.ts

import * as functions from 'firebase-functions';
import { Request, Response } from 'express';
import { dialogflow, DialogflowConversation, Parameters } from 'actions-on-google'

import { zenryokuBuyOrSell } from './bitbank-handler';
import { IntentNames, ZenryokuStatus } from './types';

const runtimeOptions = {
  timeoutSeconds: 10,
};

function intentHandler(request: Request, response: Response): void {
  const app = dialogflow();

  app.intent(IntentNames.Default, async (conv: DialogflowConversation, params: Parameters) => {
    conv.ask('まだ仮想通貨持ってないの?');
  });

  app.intent(IntentNames.Order, async (conv: DialogflowConversation, params: Parameters) => {
    const asset = params.Asset;
    const side = params.Side as ('buy' | 'sell');

    if (!asset || !side) {
      conv.close('エラーが発生しました');
    }

    conv.ask(`${asset}を全力${side === 'buy' ? '買い' : '売り'}します`);

    const result = await zenryokuBuyOrSell(`${asset}_jpy`, side);

    if (result === ZenryokuStatus.Done) {
      conv.close(`全力${side === 'buy' ? '買い' : '売り'}しました!`);
    } else if (result === ZenryokuStatus.Ordered) {
      conv.close(`全力で${side === 'buy' ? '買い' : '売り'}注文しました!`);
    } else {
      conv.close('エラーが発生しました');
    }
  });

  app.intent(IntentNames.Cancel, async (conv: DialogflowConversation, params: Parameters) => {
    conv.close('やめておきました');
  });

  app(request, response);
}

export const fulfillment = functions.runWith(runtimeOptions).https.onRequest((request, response) => intentHandler(request, response));

types.ts

export enum IntentNames {
  Default = 'zenryoku-buy-sell',
  Order = 'zenryoku-buy-sell - order',
  Cancel = 'zenryoku-buy-sell - no',
}

export enum ZenryokuStatus {
  Done = 'DONE',
  Ordered = 'ORDERED',
  Error = 'ERROR',
}

bitbank-handler.ts

import * as functions from 'firebase-functions';
import * as bitbank from 'node-bitbankcc';
import { ZenryokuStatus } from './types';

const limitMaxBuyRate = 0.8;
const limitMaxSellRate = 1;
const apiKey = process.env.BITBANK_API_KEY;
const apiSecret = process.env.BITBANK_API_SECRET;

export async function zenryokuBuyOrSell(pair: string, side: 'buy' | 'sell' = 'buy'): Promise<ZenryokuStatus> {
  try {
    const { baseAsset, quoteAsset } = getBaseAndQuoteAssetByPair(pair);
    const privateClient = getBitbankPrivateClient();
    const publicClient = getBitbankPublicClient();

    let price = 0;
    let freeAmount = 0;

    if (side === 'buy') {
      price = await getPriceByPairNameAndSide(publicClient, pair);
      freeAmount = await getAmountByAssetName(privateClient, quoteAsset);
    } else {
      freeAmount = await getAmountByAssetName(privateClient, baseAsset);
    }

    // market 買い注文では資産の80%までの注文しかできない
    const canBuyAmmount = Math.floor((freeAmount / price) * limitMaxBuyRate * 10000) / 10000;
    const canSellAmmount = Math.floor(freeAmount * limitMaxSellRate * 10000) / 10000;
    const orderResult = await privateClient.postOrder({ pair, amount: side === 'buy' ? `${canBuyAmmount}` : `${canSellAmmount}`, side, type: 'market' });

    // 約定を待つ
    await new Promise((resolve) => setTimeout(resolve, 3000));

    const order = await privateClient.getOrder({ order_id: orderResult.data.order_id, pair });

    if (!order || order.success !== 1) {
      return ZenryokuStatus.Error;
    }

    if (order.data.status === 'FULLY_FILLED') {
      return ZenryokuStatus.Done;
    }

    return ZenryokuStatus.Ordered;
  } catch (e) {
    console.log(e);
    return ZenryokuStatus.Error;
  }
}

function getBitbankPublicClient(): bitbank.PublicApi {
  const conf: bitbank.ApiConfig = {
    endPoint: 'https://public.bitbank.cc',
    keepAlive: false,
    timeout: 3000,
  };
  return new bitbank.PublicApi(conf);
}

function getBitbankPrivateClient(): bitbank.PrivateApi {
  const conf: bitbank.PrivateApiConfig = {
    endPoint: 'https://api.bitbank.cc/v1',
    apiKey: functions.config().api.key,
    apiSecret: functions.config().api.secret,
    keepAlive: false,
    timeout: 3000,
  };
  return new bitbank.PrivateApi(conf);
}

function getBaseAndQuoteAssetByPair(pair: string): { baseAsset: string, quoteAsset: string } {
  const sepaPair = pair.split('_');
  return { baseAsset: sepaPair[0], quoteAsset: sepaPair[1] };
}

async function getAmountByAssetName(client: bitbank.PrivateApi, asset: string): Promise<number> {
  const assets = await client.getAssets();

  if (!assets || assets.success !== 1) {
    return 0;
  }

  return +assets.data.assets.find((a) => a.asset === asset).free_amount;
}

export async function getPriceByPairNameAndSide(client: bitbank.PublicApi, pair: string, side: 'buy' | 'sell' = 'buy'): Promise<number> {
  const ticker = await client.getTicker({ pair });

  if (!ticker || ticker.success !== 1) {
    return 0;
  }

  return side === 'buy' ? +ticker.data.buy : +ticker.data.sell;
}

Firebase Cloud Functionsのデプロイ

こちらも、firebase-toolsを使ってデプロイするだけなのですが、取引所APIのAPI Keyを環境変数から取得するようにしているため以下のコマンドで環境変数を設定してからデプロイを行います。

$ firebase functions:config:set api.key="apikey" api.secret="apisecret"
$ firebase deploy --only functions

参考: Firebase functions に環境変数を設定してみる

課題

  • 認証の問題
    • 今回とりあえず動かしてみることに集中したため、Action on Google → Firebase Cloud Functions間での認証が全くありません😱
    • デプロイされているFirebase Cloud Functionsのエンドポイントが分かれば、誰でも全力注文し放題で激ヤバです
    • 取引所のAPI Keyの権限を絞ることももちろんですが、実際に使っていくには何かしらの認証を入れることを検討する必要がありそうです
  • Firebase Cloud Functionsのアウトバウンド制約
    • 無料プランでは外部APIの呼び出しができないため、今回は従量課金制の Blaze プランに上げています...
    • Action on GoogleとFirebase Cloud Functions間はwebhookでの連携なので、AWS Lambdaに置き換えてもいいかも
  • 会話のやり取りが雑すぎる問題
    • 誤注文を防ぐためにも確認のIntentをしっかりと用意してあげたほうが良さそうです
    • Transactions APIなんかも合せて使える?

以上、少し雑な記事になってしまいましたが、ここらへんにしたいと思います。
今後もFirebase/スマートスピーカで遊んできいたいです。

SNS映えするWebアプリを...!FirebaseとVue.jsでSPAのOGP画像の動的生成をやってみたら案外楽だった

この記事はFirebase Advent Calendar 2018 6日目の記事です。

はじめまして、ゆき(@twitter:yuneco)です。日頃は絵を描いたりちょっとしたWebアプリを個人開発したりして遊んでいます。今日は個人でTwitter連携アプリを開発した際に悩んだSPAの動的な(ページごとの)OGP生成について情報をまとめてみたいと思います。NuxtやSSRは使いません。

作ったもの&OGPのイメージ

今回作った colorinco*カラーインコ はTwitter連携したユーザの投稿画像やお気に入り画像を表示し、そこから自動的にカラーパレットを生成、Twitterでシェアできるサービスです。共有するとタイムラインに↓こんな感じでコンテンツにあわせた画像が大きく表示されます。

colorinco on twitter

:camera: 映える!テンション上がる! :heart_eyes:

SNS連携アプリならOGPは必須

OGP(Open Graph Protocol)はざっくり言うと、決まったmetaタグをhtmlに書いておくと、SNSのタイムライン/ウォールでページタイトルとかイメージとかを良い感じに表示してくれる、アレです。

最近はPeing-質問箱-ためしがきなど、写真や絵を扱っているわけではないサービスでもOGP画像をいい感じで活用するケースが増えて来ているように感じます。

SPAでOGPどうするの問題

colorincoの場合、ページ(シェアされた画像とカラーパレット)ごとに異なるOGP画像を返す必要があります。トップページ以外がシェアされるWebアプリならだいたい同じようなニーズはあるはず。
ただ、これがちょっと難しくて、VueみたいなSPAだと「動的に異なるOGPタグを生成する」の部分ができません。理由はTwitterやFbのクローラがJSを解釈しないから。 クライアント側でタグをいくら書き換えてもSNSには表示されないのです。

  • じゃあSSR(サーバサイドレンダリング)する? → OGPのためだけにやるのは辛い:scream_cat:
  • 割り切って全ページ共通のOGPにする? → タイムラインに同じOGPが並ぶのはむしろマイナスなのでは?:poop:
  • じゃあOGPやめれば? → SNS連携アプリなんだから画像が出ないのはやっぱ辛い:sob:

こんな感じでつらみループ:loop:に陥ったのは私だけじゃない、はず。割り切りで同じOGP画像を全ページに適用しているアプリも多いけど、せっかく流行った時にTLが同じOGPで埋まっちゃうのはやっぱり印象良くないよね、と思うのです。

HostingとFunctionsでOGPだけ動的生成

:angel: でもできる。そう、Firebaseならね :angel_tone1:

と言うわけで、今回はSSRなしで普通のVueアプリでコンテンツに合わせた動的OGPだけをやる方法を考えます。使うのはHostingとFunctionsです。DBはCloubFirestoreでもRealtimeDBでもお好きなものを。

最初にざっくりアプローチを。

  1. OGPを動的に生成したいパスのみ、Hostingの設定でFunctionsを呼び出し
  2. FunctionsでリクエストURLを元にOGPタグを組み立ててhtmlを出力。body部にscriptで"元URL+α"に飛ばすリダイレクト処理を書く
  3. (JavaScriptを解釈しない)Twitter/Facebook:このOGPタグを読み取って終了
  4. (JavaScriptを解釈する)通常の利用者やGoogleクローラ:
    1. リダイレクト指示に従って"元URL+α"に飛ぶ
    2. "元URL+α"はFunctions呼び出しの対象外なので、普通にVueアプリがロードされて起動
    3. Vueのrouterで"元URL+α"を元URLに書き戻す
    4. 本来のコンポーネントがマウントされて終了

ogp_vue_firebase.png

ポイントだけかいつまんでコードを載せます。

OGPを動的に生成したいパスのみ、Hostingの設定でFunctionsを呼び出す

Firebase Hostingでは設定でパスのrewriteができ、ここで飛ばし先にFunctionsの指定ができます。Firebaseプロジェクトのルートのfirebase.jsonはおそらく↓こんな感じになっていると思います。

firebase.json
{
  "hosting": {
    "public": "dist",
    "ignore": [ /*略*/ ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

ファイルが実在しない全てのURLに対してindex.htmlを返す、という設定ですね。(firebase initするときの「SPA用の設定をするか?」みたいな質問にYで答えるとこうなります)

今回はカラーパレットのストックページ/stock/*のOGPを動的に生成したいので、rewritesを以下のように修正します。

firebase.json
  "rewrites": [
      {
        "source": "/stock/*",
        "function": "stockpage"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
  ]

これで/stock/*の全てのリクエストがstockpageというFunctionの呼び出しになりました。

FunctionsでリクエストURLを元にOGPタグを組み立ててhtmlを出力

次にFunctions側でこのリクエストを受け取って、正しいOGP入りのhtmlを返します。

functions/stockpage.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const db = admin.firestore()

const CONFIG = functions.config()
const app_domain = CONFIG.app.domain
const OGP_IMG_WIDTH = 1200
const OGP_IMG_HEIGHT = 630

const func = functions.https.onRequest((req, res) => {
  const [, , stockid] = req.path.split('/')
  return db.collection('user-stocks').doc(stockid).get().then(snap => {
    if (!snap) {
      res.status(404).end('404 Not Found')
      return
    }
    const stockItem = snap ? snap.data() : {}
    const uname = stockItem.uname || ''
    const html = createHtml(uname, stockid)
    res.set('Cache-Control', 'public, max-age=600, s-maxage=600')
    res.status(200).end(html)
    return
  }).catch((err) => {
    console.warn(err)
    // 略 : エラー時はデフォルトのhtml(固定のOGP)を返す
  })
});

const createHtml = (uname, stockid) => {
  const SITEURL = `https://${app_domain}`
  const PAGEURL = `${SITEURL}/stock/${stockid}`
  const TITLE = `view ${escapeHtml(uname)}'s colorsets on colorinco`
  const DESCRIPTION = 'カラーインコはTwitterでお気に入りしている画像のカラーパレットを表示・ストックできるサービスです。'
  return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>colorinco</title>
    <meta property="og:title" content="${TITLE}">
    <meta property="og:image" content="${SITEURL}/ogp/stockimg/${stockid}">
    <meta property="og:image:width" content="${OGP_IMG_WIDTH}">
    <meta property="og:image:height" content="${OGP_IMG_HEIGHT}">
    <meta property="og:description" content="${DESCRIPTION}">
    <meta property="og:url" content="${PAGEURL}">
    <meta property="og:type" content="article">
    <meta property="og:site_name" content="colorinco*カラーインコ">
    <meta name="twitter:site" content="${SITEURL}">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="${TITLE}">
    <meta name="twitter:image" content="${SITEURL}/ogp/stockimg/${stockid}">
    <meta name="twitter:description" content="${DESCRIPTION}">
  </head>
  <body>
    <script type="text/javascript">window.location="/_stock/${stockid}";</script>
  </body>
</html>
`
}

module.exports = func

大半がOGPのテンプレですね。ポイントだけ列挙すると

  • functions.https.onRequestの引数からリクエストパスを取得、DBに繋いでOGPのためのデータをとる
  • res.set('Cache-Control', 'public, max-age=XX秒, s-maxage=XX秒')でキャッシュを有効にする
    これを入れないとFunctionsはデフォルトではキャッシュされないので、毎回Functionが呼ばれて死にます。 キャッシュの有効期間はよしなに決めてください。長めでいいと思います。
  • OGPの中身はいい感じに書いてあげてください。og:imageの部分だけあとで説明書きます
    OGPに書くURL類は絶対パスじゃないといけないらしいので、そこだけ注意。
  • <body>内でアプリに飛ばします。上の例では/stock/${stockid}/_stock/${stockid}に飛ばしています

TwitterやFacebookはこのhtmlのOGPだけ読めれば満足なので、bodyの中身は空っぽで構いません。通常のブラウザはこのままだと困るので、scriptで/_stock/${stockid}にリダイレクトしています。このパスはFunctionsの対象にしていないので、普通にindex.htmlが呼ばれ、Vueアプリが起動します:innocent::v:

Vueのrouterで"元URL+α"を元URLに書き戻す

これでなんとかOGPクローラを満足させつつ、Vueアプリまで戻って来ました。
最後に/_stock/${stockid}を元のURLに書きもどして正しいコンポーネントをマウントします。

src/router/index.js
export default new Router({
  mode: 'history',
  routes: [
    // ...略
    {
      path: '/_stock/:stockid',
      redirect: '/stock/:stockid'
    }
    // ...略
  ]
})

ルーターの設定に一つ追加するだけです。このredirectはあくまでVueの画面内のものなので、
リダイレクト先URLにリクエストが飛ぶわけではありません。リダイレクトループにはならないのでご安心を。

OGP画像も動的生成

これでページタイトルや説明等、OGPの基本的な部分はできました。残るOGP画像の動的生成も同様にやっていきます。基本的なアプローチはhtmlの生成と同じです。レスポンスはPNGデータなので、今度はリダイレクトは考えなくてOKです。

画像用:OGPを動的に生成したいパスのみ、Hostingの設定でFunctionsを呼び出す

og:image,twitter:image/ogp/stockimg/${stockid}と指定したので、/ogp/stockimg/*をFunctionsに飛ばすよう、Hostingの設定を追加します。

firebase.json
  "hosting": {
    "public": "dist",
    "ignore": [/*略*/],
    "rewrites": [
      {
        "source": "/stock/*",
        "function": "stockpage"
      },
      { /* 追加↓ */
        "source": "/ogp/stockimg/*",
        "function": "stockimg"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }

FunctionsでリクエストURLを元にOGP画像を生成

ここも画像を動的に生成していること以外は特別なことはしていません。
firebase/functions-samples/image-makerに公式のサンプルがあります。(いつの間にかサンプルがNode8に上がってる...)

functions/stockimg.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const db = admin.firestore()
const Canvas = require('canvas-prebuilt')
const Image = Canvas.Image
// カラーセットをタイル状に配置するための座標計算を行うクラス
const TileLayout = require('./src/TileLayout')

// OGP関連の定数
const OGP_IMG_WIDTH = 1200
const OGP_IMG_HEIGHT = 630
const OGP_IMG_ARTWORK_WIDTH = OGP_IMG_WIDTH * 0.5

// "/ogp/stockimg/:sid" の処理本体(最後にexportしています)
const func = functions.https.onRequest((req, res) => {
  const [, , , stockid] = req.path.split('/')
  const canvas = new Canvas(OGP_IMG_WIDTH, OGP_IMG_HEIGHT)
  res.set('Cache-Control', 'public, max-age=600, s-maxage=600')

  // DBからstock itemの情報を取得
  // ここからの処理は全てPromiseチェーンにぶら下げる
  return db.collection('user-stocks').doc(stockid).get().then(snap => {
    if (!snap) {
      res.status(404).end('404 Not Found')
      return null
    }
    return snap.data();
  }).then((stockItem) => {
    // DBから取得したstock itemからカラーセットを取得し、タイル状に色を描画
    if (!stockItem) { return null }
    // ... 座標計算等 ... 
    drawColorset(canvas, colorset, layout, /*...略*/)
    // 画像をダウンロードして描画。非同期なのでPromiseを返す
    return drawImgWithUrl(canvas, imgUrl, /*...略*/)
  }).then((isSuccess) => {
    // エラーなく完了したらCanvasの内容をpngで出力
    if (isSuccess) {
      res.writeHead(200, {'Content-Type': 'image/png'})
      canvas.pngStream().pipe(res)
    } else {
      res.status(500).end('500 Server Internal Error')
    }
    return isSuccess
  }).catch((err) => {
    res.status(500).end('500 Server Internal Error')
  })
})

だいぶ端折っちゃってますが、ここもポイントだけ。

  • Functionsで画像生成するのはcanvas-prebuiltが便利
  • DBデータ取得処理・画像ダウンロード処理など、複数の非同期処理が入るので、途中でPromiseのチェーンがきれないように注意。今ならNode8が使えるのでasync/awaitにした方が幸せになれそう
  • htmlと同様、キャッシュの設定を忘れずに。忘れると課金で(ry
  • Twitter側の画像データはStorageに保存(キャッシュ)した方が性能的にはいいかも。今回はユーザーのデータを不用意に保持したくなかったので、都度Twitter側から取得しています。画像取得自体はAPIではないので180req/15分のAPI制限は関係ない、はず

ここまでの成果

ここまでで一通りOGPを動的に生成することができました。
Twitter Card validatorFacebookシェアデバッガーで設定したOGPが正しく動くことを確認できます。

延長戦:リダイレクトなしで動的にOGPを生成する

これで実用上は十分なのですが、一点だけ残念なのが無駄にややこしいダミーURLへのリダイレクト。
SEO的にどうなの、という話もある(らしい)のと、リクエストが1ターン増えるので初期表示が遅くなるのが気になる(といっても数百msレベルですが)。あと、PCブラウザで見てるとURLの書き換えが一瞬見えてちょっとダサい。

そんなわけでここからは(必須感は薄いですが)より完璧を目指してリダイレクトなしの方法を考えます。

考えかたのポイント:

  • Functionsから返すhtmlがそのままブラウザで使える完全なページならリダイレクトはいらない
  • Vueのhtmlは全画面共通でindex.htmlのみ。このファイルはほとんど編集しない
  • なので、Functionsから返すhtmlをindex.htmlと同じ内容(でOGPだけコンテンツに合わせて生成したもの)にすればよい
  • index.htmlで頻繁に変わるのは自動で挿入されるバンドルされたjs/cssファイル名のみ(キャッシュ回避のためにファイル名にダイジェストがつく = ビルドするたびに名前が変わる)

最後のひとつだけが曲者です。
普段Vueにお任せしていると意識しない部分なので、認識ない方はVueアプリを開いてブラヴサからソースを表示して見てください。

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <meta name=viewport content="width=device-width,initial-scale=1">
  <title>colorinco*カラーインコ</title>
  <!-- GOPのmetaタグがいっぱい -->
  <link href=/static/css/app.78eb8346fb720a49941bfeca9b64356f.css rel=stylesheet>
</head>
<body>
  <div id=splash style="height:100%;font-family:sans-serif;padding:30% 10%;text-align:center;font-size:20pt;color:#aaa;">colorinco</div>
  <div id=app></div>
  <script type=text/javascript src=/static/js/manifest.2ae2e69a05c33dfc65f8.js></script>
  <script type=text/javascript src=/static/js/vendor.4bd743272820dc4398f1.js></script>
  <script type=text/javascript src=/static/js/app.d4cd8aa92b80dfd544ab.js></script>
</body>
</html>

細かい部分は違うと思いますが、cssが一つ、jsが3つ、乱数的な文字列のついたファイル名で読み込まれているはずです。
このランダムな文字列はビルドのたび(内容変更されるたび)に変わるので、Functions側にハードコードするわけには行きません。

解決のアプローチ:

  • Functionsで返しているhtmlからリダイレクトを削除し、本物のindex.htmlと同じ内容(OGPのみ動的に生成されたもの)に変える
  • バンドルされたjs/cssファイル名はデプロイ時にFunctionsの環境変数に設定し、出力htmlに反映させる

大前提として、Functionsは「本物」のindex.htmlの中身を知りません。
HostingとFunctionsは動いている場所が別なので、ちょっとローカルファイルを読んできて...と言うわけにはいかないのです。なので、今回はビルド時にFunctionsの環境変数にファイル名を設定してあげることにします。

具体的なやり方を書いていきます。

バンドルのファイル名を取得する

まずはビルドのたびに変わるバンドルのファイル名を取得します。
ビルド処理なのでWebpackの設定をいじります。(今回はWebpack4なので最新版だとちょっと違うかも。。)

まずwebpack-manifest-pluginnpm i -D webpack-manifest-pluginでインストールして、webpack.config.jsから呼び出します。
プラグインの配列の最後にnew ManifestPlugin()を追加するだけです。

webpack.prod.conf.js
const webpackConfig = merge(baseWebpackConfig, {
  /*...*/
  plugins: [
    // 長い長いプラグインのリスト
    ,
    new ManifestPlugin()
  ]
})

ローカルサーバでデバッグする分には不要な設定なので、追加するのはwebpack.prod.conf.jsの方です。これでビルド時にdist/manifest.jsonが生成されます。内容はこんな感じのシンプルなjsonです↓

dist/manifest.json
{
  "vendor.js": "/static/js/vendor.4bd743272820dc4398f1.js",
  "vendor.js.map": "/static/js/vendor.4bd743272820dc4398f1.js.map",
  "app.js": "/static/js/app.d4cd8aa92b80dfd544ab.js",
  "app.css": "/static/css/app.78eb8346fb720a49941bfeca9b64356f.css",
  // ... 下略
}

バンドルのファイル名をFunctionsの環境変数にセットする

このmanifest.jsonをデプロイ処理の前にFirebaseFunctionsの環境変数に設定します。設定した環境変数が有効になるのは次回Functionをデプロイしたときなのでデプロイ後にセットしてもダメ。

npmスクリプトで呼び出したいので、この辺りの処理も一つのファイルにまとめます。

build/set-functions-envs.js
'use strict'
const childProcess = require('child_process')

const manifest = require('../dist/manifest')
const TARGET_FILES = ['manifest.js', 'app.js', 'vendor.js', 'app.css']

const cmd = 'firebase functions:config:set ' +
  TARGET_FILES
    .map(name => `${name}=“${manifest[name].split('/').pop()}”`)
    .join(' ')

console.log(cmd)
childProcess.exec(cmd, (error, stdout, stderr) => {
  if(error) {
    console.warn(stderr)
  } else {
    console.log(stdout)
  }
})

環境変数の名前は適当に決めてあげてくださしい。
これで環境変数にhosting.manifest.app.js = "app.d4cd8aa92b80dfd544ab.js"のような設定ができました。

これをnodeで実行すると、こんな感じで↓メッセージが出ます。反映するにはこのあとFunctionsのデプロイがいるよ、と忠告してくれてますね。

firebase functions:config:set hosting.manifest.manifest.js="manifest.2ae2e69a05c33dfc65f8.js" hosting.manifest.app.js="app.d4cd8aa92b80dfd544ab.js" hosting.manifest.vendor.js="vendor.4bd743272820dc4398f1.js" hosting.manifest.app.css="app.78eb8346fb720a49941bfeca9b64356f.css"
✔  Functions config updated.

Please deploy your functions for the change to take effect by running firebase deploy --only functions

firebase deploy --only functionsしろと言っていますが、もちろんフルでデプロイしても反映されます。

デプロイ時に環境変数の設定処理を走らせる

以後、デプロイ時には必ずこの環境変数のセットも行わないといけないので、まとめてnpmスクリプトに入れておきます。このあたりはみなさんお好みで。

package.json
"scripts": {
  "release-deploy": "node build/build.js; firebase use release; node build/set-functions-envs.js; firebase deploy; firebase use staging;"
}

ここでは、ビルド→release環境に切り替え→環境変数セット→デプロイ→ステージ環境に戻す、までをまとめてやっています。

Functionsで環境変数を読み取って出力htmlに反映

最後にFunctionsでこの環境変数を使ってhtmlを生成します。
環境変数はfunctions.config()で取得できるので、そこからバンドルのファイル名を取り出してindex.htmlと同じものを組み立てるだけです。

stockpage.js
// ...略... //
const CONFIG = functions.config()
const hosting_manifest = {
  'manifest.js': CONFIG.hosting.manifest.manifest.js,
  'app.js': CONFIG.hosting.manifest.app.js,
  'vendor.js': CONFIG.hosting.manifest.vendor.js,
  'app.css': CONFIG.hosting.manifest.app.css
}

// ...略... //

const createHtml = (uname, stockid) => {
  // ...略... //
  return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>colorinco</title>
    <meta property="og:title" content="${TITLE}">
    /* 略 : metaタグたくさん */
    <link href="/static/css/${hosting_manifest['app.css']}" rel=stylesheet>
  </head>
  <body>
    <div id="splash" style="height:100%;font-family:sans-serif;padding:30% 10%;text-align:center;font-size:20pt;color:#aaa;">colorinco</div>
    <div id=app></div>
    <script type="text/javascript" src="/static/js/${hosting_manifest['manifest.js']}"></script>
    <script type="text/javascript" src="/static/js/${hosting_manifest['vendor.js']}"></script>
    <script type="text/javascript" src="/static/js/${hosting_manifest['app.js']}"></script>    
  </body>
</html>
`
}

ここまでやってビルド・デプロイすれば、リダイレクトなしで動的なOGP生成ができるようになっているはずです。

課題と制約

上の方でもちょろっと書きましたが、この対応を行うと基本的にHostingにデプロイするたびに環境変数の設定とFunctionsのデプロイも必ず必要になります。つまり deploy --only hostingは使えません:cry: Functionsのデプロイはfirebase deployの中でも時間のかかる部分なので、ちょっと歯がゆいところです。

この制約を課してでもリダイレクトを避けるべきなのかはアプリの要件次第な気がするので、お好みで採用していただければ、と。

まとめ:Firebase + VueならリッチなOGPも案外簡単

Vue.js + Firebaseでアプリ開発してる方、結構多いと思うのですがあまりOGP周りの情報がなかったので四苦八苦の結果をまとめてみました。わかってしまえば案外簡単ですし、既存のVueアプリにも後付けできるので、ぜひ試して間違いや改善などフィードバックいただけると嬉しいです。

colorincoもよろしくね

FlutterとFirebaseで始めるモバイルアプリ

ついにFlutterも正式版になり、これからどんどん広がっていくといいなと思っています。

そんなFlutterとFirebaseをつなげて遊んでみたら面白いのではないでしょうか?

Flutterとは

2018-12-06_23h52_24.png

Googleが開発しているAndroidとiOS上で動くクロスプラットフォーム開発フレームワークです。

「React Native」や「Xamarin」と同じようなものです!

つい先日の12/4にカンファレンスがあり、そこで正式版がリリースされました。
https://flutter.io/

FlutterとFirebase

2018-12-07_00h02_04.png

https://github.com/flutter/plugins/blob/master/FlutterFire.md

Flutterでアプリを開発する際にはプラグインを活用するのが基本だと思います。
Firebaseのだいたいの機能についてはライブラリが公開されており、やりたいことは簡単に実現できるようになっています!

Cloud Firestoreを例にもう少し細かいところまで見てみようと思います。

Cloud Firestore

2018-12-07_00h10_39.png
https://pub.dartlang.org/packages/cloud_firestore

Flutterのライブラリは上記のサイトにまとめられており、このサイトをみることで他のライブラリでも使いかたが大体わかるはずです。
「Installing」のタブをクリックするとインストール方法が記載されているのでそれを参考にするだけで準備できます。

コードをみてみよう

https://github.com/flutter/plugins/tree/master/packages/cloud_firestore/example

Github上にサンプルプログラムがあります。
それをベースにコードを見てみようと思います。

取得

return StreamBuilder<QuerySnapshot>(
      stream: firestore.collection('messages').snapshots(),
      builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
        if (!snapshot.hasData) return const Text('Loading...');
        final int messageCount = snapshot.data.documents.length;
        return ListView.builder(
          itemCount: messageCount,
          itemBuilder: (_, int index) {
            final DocumentSnapshot document = snapshot.data.documents[index];
            return ListTile(
              title: Text(document['message'] ?? '<No message retrieved>'),
              subtitle: Text('Message ${index + 1} of $messageCount'),
            );
          },
        );
      },
    );
  }

https://github.com/flutter/plugins/blob/master/packages/cloud_firestore/example/lib/main.dart#L35-L52

ListViewでFirebaseに保存されたメッセージを表示するサンプルプログラムになります。

stream: firestore.collection('messages').snapshots(),でFirestoreのどの場所からデータを取ってくるのか指定をします。
if (!snapshot.hasData) return const Text('Loading...');でデータの取得が完了するまで「Loading...」という表示を行います。
title: Text(document['message'] ?? '<No message retrieved>'),で取得したメッセージを表示します。Nullの場合は「No message retrieved」を表示します。

保存

https://github.com/flutter/plugins/blob/master/packages/cloud_firestore/example/lib/main.dart#L58-L65
https://github.com/flutter/plugins/blob/master/packages/cloud_firestore/example/lib/main.dart#L75
サンプル通りだと上記の個所なんですが、ちょっとぱっと見分かりにくいなと思ったので別の書き方で進めてみます。

firestore.collection('messages').document().setData({
      'message': 'Hello world!',
      'created_at': FieldValue.serverTimestamp(),
    });

これを実行すると裏側で非同期でFirestoreにデータを保存します。

まとめ

簡単にですが、FlutterとFirebaseについてまとめてみました。難しくない…とおもうのでぜひFlutterを始めてみましょう!

最後に宣伝ですが、Flutterの入門本を作成しているので興味がある方はぜひ!
Flutterでの詳しいFirebaseの設定やアイコンの設定、多言語対応などおこなっております!

https://booth.pm/ja/items/1040806

Firebase Hosting で自動で古いバージョンを削除する(今のところ)最善の方法

External article

Firebaseの本番と開発環境の用意

はじめに

アプリエンジニアの@daisuke0131と申します。
qiita投稿久しぶりで緊張しています。
この記事は、Firebase Advent Calendar 2018 9日目の記事になります。

前回の記事は、@takuchalleさんのFirebase Hosting で自動で古いバージョンを削除する(今のところ)最善の方法でした。私はまだFirebase Hosting触っていないですが今度使ってみようと思います。

私は、本業ではアプリ開発(ios/android)をメインでやっているエンジニアです。アプリエンジニアの為のFirestore入門本、基礎から実施までCloud FirestoreによるiOSアプリ開発を執筆したので興味ある方は是非買ってください!一冊手元に置いておくと便利だと思います。

書籍を色々な方達に読んでもらったのですが、全体的には分かりやすいというフィードバックをもらっています。アプリ開発も楽しいですが、執筆も楽しいですね。分かりやすい入門書を書くことでCloud Firestoreの普及に貢献できれば嬉しいです。
今回は、フィードバックの中で追記して欲しいと要望のあったiOSアプリ開発時の本番と開発環境の用意方法に関する記事を書きたいと思っております。一般的なFirestoreのセットアップに関する詳細は書籍を参照してください。

Firebaseとクライアント環境の本番と開発環境の用意

Firebaseのプロジェクトですが、以下にアクセスして
https://console.firebase.google.com/
本番用と開発用のプロジェクトをそれぞれ作成します。

通常プロジェクト作成後は、iOSアプリを作成するボタンよりGoogleService-Info.plistを取得してクライアント側のiOSプロジェクト内に配置して、次の初期化処理を呼び出すことでFirebaseのクライアント側セットアップが完了します。

AppDelegate.swift
FirebaseApp.configure()

引数なしのconfigureを呼び出すことでFirebase SDKは"GoogleService-Info.plist"を探して初期化を完了させます。

今回は、本番用と開発用のプロジェクトを分けたいので、開発用のプロジェクトで作成したplistを"GoogleService-develop-Info"として用意します。iOSアプリ開発では、プリプロセッサマクロのDEBUGオプションで本番、開発環境を分けることが多いと思いますのでそちらを使用してplistの読み込み先を変更します。コードにすると次のようになります。

AppDelegate.swift
#if DEBUG
    let filePath = Bundle.main.path(forResource: "GoogleService-develop-Info", ofType:"plist")
#else
    let filePath = Bundle.main.path(forResource: "GoogleService-Info", ofType:"plist")
#endif
let options = FirebaseOptions(contentsOfFile:filePath!)
FirebaseApp.configure(options: options!)

Firebaseとクライアント側の環境の分け方は以上で完了です。

Firebase CLIの本番と開発環境の用意

次にFirebase CLI環境の本番と開発環境の用意方法についてです。
Firebase CLIはFirebase上のセキュリティルール、インデックス、Cloud Functionsなどのデプロイを簡易にするツールのことです。開発環境は次のinitコマンドを実行することで作成されます。

$firebase init

初期化コマンド実行後には.firebasercが生成されています。

.firebaserc
{
  "projects": {
    "default": "firestoresample-xxxx"
  }
}

こちらのファイルを次のように書き換えることで本番(release)と開発(develop)環境を切り替えられるようになります。yourapp-production,yourapp-developはfirebaseのプロジェクトIDをそれぞれ設定してください。

.firebaserc
{
  "projects": {
    "release": "yourapp-production", #本番環境
    "develop": "yourapp-develop" #開発環境
  }
}

上記の設定をしたあとに次のようにuseコマンドを実行することで本番、開発環境の切り替えが可能になります。

$ firebase use develop #開発環境に切り替える
Now using alias develop (yourapp-develop)
$ firebase use release #本番環境に切り替える
Now using alias release (yourapp-production)

以上でfirebase CLIで作成したコードを本番、開発環境で切り替えられる環境が作成できました。とっても簡単ですね。firebase、だぁーいすき!(ハズ◯ルーペ風)

おわりに

書籍の執筆後に開発環境について聞かれることが多かったので、Advent Calendarの締め切りを利用して記事にさせてもらいました。Firestoreの勉強を初めて本番運用まで考え始めた段階で疑問になるところだと思うのでどなたかのお役に立てれば幸いです。こちらの内容は、Boothで販売中の基礎から実施までCloud FirestoreによるiOSアプリ開発にも加筆してアップデートしたいと思っています。

明日は、@gremitoさんのFirebaseとUnityについて何かです。よろしくお願い致します。

FirebaseとUnityでどんなアプリが開発できるのか考えてみた

本記事はFirebase Advent Calendar 2018の10日目の記事です。

まえふり

以前からFirebaseとUnityでどんなゲーム開発ができるのか触りながら探っています。
SDKがアプリに入った時のSDK自体の品質問題もあるのですが、そもそもサーバサイドの開発とサーバ管理のコストを減らせるメリットを持っているFirebaseに全てバックエンドを任せることは、とても危険です。

  

いくつかの発表資料(上記資料など)でも、一部の機能のバックエンドをFirebaseで対応されている話しはよく聞くため、どこまでできるのかをベースに採用判断しなければならない。

マイグレーションはできないと思って良い(所感)

   

個人的にFirebaseは、マイグレーションはできないと考えています。
ゲームアプリのバックエンドは、Webアプリと同じ構成をベースに作って良いのですが、Webアプリとゲームアプリの一番の違いはアクセス頻度であり、全アクセスに耐えられる環境をすぐに用意できるかできないかで開発・運用がゴロッと変わります。

Firebaseのデータベースは、JsonまたはNoSQLとなっており、NoSQLだったらDynamoDBに移行できるんじゃないかと考えますが、Cloud Firestoreのドキュメントに以下の内容が書かれています。

データをエクスポートする
エクスポートを実行すると、データベース内のドキュメントが Cloud Storage バケットの一連のファイルにコピーされます。エクスポートは、エクスポート開始時に取得された正確なデータベース スナップショットではありません。エクスポートには、オペレーションの実行中に追加された変更が含まれる場合があります。

現時点でDynamoDBへの移行はできず(もしかしたら超頑張ったらできるかもしれないけども)、FirebaseはGCPと連携できるため、おそらくCLOUD DATASTOREにマイグレーションは可能かもしれません。

ですが、ゲーム系でGCPの事例は少ないためFirebaseもGCPもゲームアプリで使うには、要件次第では両方茨の道だと思いました笑

Firebaseでゲームアプリをどこまで開発できるのか

    

Firebaseの使用例Cloud Functions で可能な処理を見ると、マイクロサービスに向いているのかなぁという印象でした。

なので、ゲームアプリでFirebaseを使うのならランキングの集計やRemote Configで操作できるシステムに関しては、Firebaseに任せてもいいなと考えていました。
また、Firebaseを用いてギリギリどこまでなら作れるか考えてみました。

Firebaseと大規模サービス

先にFirebaseでできない開発パターンを洗い出すと意外とFirebaseで作れるゲームアプリは考えられます。

  • 例えば、数百万ユーザー以上で数千万PV以上などの大規模アプリを作ってしまうとFirebaseの従量課金制で酷い事になるため使えない。
  • ガチャやイベント、PvPなどのゲームアプリのキモになるようなシステムのバックエンドは柔軟な運用が必要になるため使えない。
  • 課金システムのバックエンドをFirebaseにしてしまうと、セキュリティーを意識したルールの対応ができずにハッキングされやすい状況になっていたり、簡単にデータ削除できてしまうことがあるため、安全に任せることができない判断&諸々課題をカバーする対応にコストがかかってしまう問題があるため使えない。
  • ...etc

ゲームに関することがベースであるものの、Webアプリでも1つ目は当てはまるかなと思います。Webアプリでこの内容だと、何かのプラットフォームサービスに等しい規模だと感じますね。
ゲームアプリに関わらずFirebaseの強みを把握した上で、どこのシステムのバックエンドをFirebaseに任せることができるのか、という観点を持たなくてはなりません。

Firebaseの強みを活かしたゲームアプリ開発

大きく2つに分けて一緒に考えていきましょー!

バックエンド全てFirebase

だいたい相談されるのがこれですね。全部Firebaseでサーバサイドの開発とインフラのコストのコストカットを考えていると。もし、PvPやマルチ対戦を考えているゲームでこの話が来た時はすごい必死なんだろうなぁって苦笑

ゲームアプリのバックエンドで作られるシステムを洗い出していきましょう。

  • ユーザーに関連する情報(Cloud Firestore & Firebase Authentication
    • 固有情報
    • 履歴情報
    • 所持している情報
    • レシート情報
  • ゲームに関連する情報(Cloud Firestore & Cloud Storage
    • クエスト
    • イベント
    • キャラクター
    • アイテム
  • アプリ内課金に関する情報(Cloud Firestore & Cloud Storage
    • 課金アイテム
  • ゲームの共通設定(Remote Config
    • ゲーム内メッセージ
    • メンテナンスモード
  • プッシュ通知(Cloud Messaging
  • WebAPIやサーバサイドの処理(Cloud Functions
    • 複数のデータをサーバ側で取得して整形してアプリに(GET/POST)通信する
    • バックエンド完結する処理(ユーザー履歴やクエスト達成後の後処理など)
  • ...etc

細かく追っていくと他にもいっぱいありますが、一旦こんな感じでFirebaseに適応させるならこのサービスかなぁと思って付けてみました。

ここでバックエンドを全てFirebaseにできる条件を以下の3つほど考えました。

  1. データが早いスピードで肥大化しない
  2. 頻繁にデータが作成/変更/削除などが行われない
  3. アプリ内課金をしない

1については、ユーザーが増えていくことは止められないし止めたくないのでしょうがないとして、ゲーム関連のデータが1ヶ月で数万件〜数千万件以上右肩上がりに増えていくゲームアプリは、運用が大変になることと金銭面も一緒に肥大すると本末転倒なので避けたいです。2も似たような理由ですね。

3は、ルールの設定のミスやデータを謝って全削除してしまった時の対応・対策が柔軟にできないからです。
従来だとバックアップの他に何らかの対応で復帰できる術があったりするものの、Firebaseはブラックボックスで柔軟に対応できないため、日々のバックアップを怠らず、2重3重と対策を考えないと問題が起きた時に一番怖い箇所なので、大変なら最初っから避けたいところとして上げました。

なので、買い切りアプリまたはアプリ内課金の無い完全無料アプリでサクッと楽しめるカジュアルゲームだったら、バックエンド全てFirebaseでもいいかなと思いましたー!

一部のシステムのバックエンドがFirebase

お金がかからないFirebaseのサービスをうまく活用すると良いと思います。例えば、
- Firebase Authentication
- Cloud Messaging
- Remote Config

などはSDKもありますし、お金もかからないですし、管理さえしっかり押さえておけば要件に合うゲームアプリ内のシステムに使って良いと考えています。これが、Firebaseの強みを活かす使い方じゃないかなぁと思いますー!

プライベートやカジュアルゲームについて

逆にFirebaseを活用して、多くのゲームアプリを開発すると良いと思います。

プライベートなら色々考えなくて済みますし、逆に上記のようなことを経験できる訳なので、バンバン使っていきましょー!!
カジュアルゲームは、Webアプリのマイクロサービスに近しい規模だとならFirebaseを使っても大きな問題にはならないんじゃないかなぁと思うけども、やっぱし要件次第だなぁ笑
ただし、ルールの設定や個人情報の取り扱いを意識したサービス開発は、Webアプリでもゲームアプリでもどんなシステム開発でも共通する話しだと思うのでお忘れなく!

さいごに

これを把握した上でさらにFirebaseを使い倒すドMがいらっしゃると思うので(僕もその1人ですかね)w、そのために次の技術書典でFirebaseとUnityについてだけの丸々1冊を作ろうと企んでいます。

実はまだ1WeekGameJamは参加していないので、Firebaseを使ったカジュアルゲームを1つ作りたいなぁと考えていたりするので、これからまたTwitterの方でいろんな情報をアップしていきますー!\\\٩( 'ω' )و ////

AdventCalendarの捕捉

当初やりたかったことが完成できず、記事にするのも辛かったため、GDG DevFest 2018発表した資料を見直し上記の記事を急遽予定変更して作りました。

できなかったものは、来年のFJUGイベントで発表できるものに仕上げてリベンジしようと思います🙇

 

Firebaseを使ってアプリをグロースさせていこう!

はじめに

新卒入社時からスマホゲーム開発を行っておりましたが、今年よりiOS開発に転向したgiiiitaと申します!

今年からFirebaseを触る機会が多くなり年の締めくくりとして
今年学んだことのアウトプットとしてFirebase Advent Calendar 2018の11日目を担当させていただきます!

今回の記事について

Firebaseには、FireStoreやAuthentication,Functionsなどアプリ開発に必要な多くの機能が備わってますよね!

Firestoreにデータが入ったことをトリガーにAlgoliaにデータを流すfunctionを書くなど
Firebaseが提供するサービス間の連携が可能なのもFirebaseの強みの一つかと思います。

開発の助けとなる機能が目立つFirebaseですが、
実はアプリをグロースさせるためのサポートをしてくれるサービスもFirebaseが提供していることはご存知でしょうか?

そこで今回はFirebaseのグロースをサポートしてくれるサービスのうちの一つA/BTestingについて書いていこうと思います!

A/B Testingとは?

Firebaseが提供するABTestを行うためのサービスです。
主な機能として以下4つが公式で挙げられております。
・製品の使用体験をテスト
・Notifications Composer を使用してユーザーに再度アプローチする方法
・新機能を安全にロールアウト
・予測に基づいてユーザーグループのターゲットを設定

個人的に気になっているのは
予測に基づいてユーザーグループのターゲットを設定です!
Predictionsでどれほどユーザの行動を予想する精度があるのか気になってます笑

仕組みのイメージ
スクリーンショット 2018-12-11 11.49.36.png

Firebaseを使って考えられるABTestを行う方法

FirebaseのA/BTestingだけを使用する

以前Qiitaにまとめてみましたので以下記事を参考していただけると幸いです。
※最近A/BTestingのUIとは異なる部分がありますのでご了承くださいmm
目標指標に定義したAnalyticsEventを使用しFirebaseでA/BTestを行う手順(iOS向け)

FirebaseのRemoteConfig + 外部サービス(Repro等)

大まかな説明として、Firebase側で行うことはRemoteConfigの設定です。
RemoteConfigを設定しその値で外部サービスに通知する内容を分けるというものです。

RemoteConfigの設定

スクリーンショット 2018-12-11 8.21.13(2).png

スクリーンショット 2018-12-11 8.21.26(2).png

スクリーンショット 2018-12-11 8.22.00(2).png

スクリーンショット 2018-12-11 8.22.04(2).png

挙げた二つの方法の違い

考えられる違いを列挙してみました。
・テスト経過、結果の表示画面UI
・テストをサポートする機能

ABTestを行う時にRemoteConfigで気をつけていること

テスト対象のViewでRemoteConfigのfetchを行わないようにすること

なぜかというと
RemoteConfigから値を取得する初回はキャッシュがなく少し時間がかかるためです。

もう少し具体的に例を出して説明してみますと、
UserGroupAには赤色のボタン(試したいパターン)
UserGroupBには緑色のボタン(通常)

テスト対象のViewでfetchを行う場合
RemoteConfigの値の結果、ボタンを赤色に変える必要があった場合
ユーザがテスト対象のViewを表示し緑色のボタンが表示されていたが、ボタンの色を赤に変える必要があるためパッと赤色に切り替わってたように感じます。
これはremoteConfigのfetch完了後にボタンの色を切り替える以下のような実装になっているためです。

2回目からはキャッシュがあるのでパッと赤色に切り替わることないのですが、1回目の挙動はユーザに不信感を与えかねません。

ViewController.swift
import UIKit
import FirebaseRemoteConfig
class ViewController: UIViewController {

    @IBOutlet weak var button: UIButton!
    override func viewDidLoad() {
        super.viewDidLoad()
        let remoteConfig = RemoteConfig.remoteConfig()
        let expirationDuration: TimeInterval = 3.0
        remoteConfig.fetch(withExpirationDuration: TimeInterval(expirationDuration)) { (status, error) -> Void in
            if status == .success {
                print("Config fetched!")
                remoteConfig.activateFetched()
                let isTestUser: Bool = remoteConfig["register_button_test"].boolValue
                if isTestUser {
                    self.button.backgroundColor = UIColor.red
                }
            } else {
                print("Error: \(error?.localizedDescription ?? "No error available.")")
            }
        }
    }
}

改善案

そこで、以下の改善案を提案します!

RemoteConfigを事前にfetchしておく

AppDelegate.swift
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        FirebaseApp.configure()
        let remoteConfig = RemoteConfig.remoteConfig()
        let expirationDuration: TimeInterval = 3.0
        remoteConfig.fetch(withExpirationDuration: TimeInterval(expirationDuration)) { (status, error) -> Void in
            if status == .success {
                print("Config fetched!")
                remoteConfig.activateFetched()
            } else {
                print("Error: \(error?.localizedDescription ?? "No error available.")")
            }
        }
        return true
    }

keyを渡せば対応するRemoteConfigのvalueを返してくれるようにする

FirebaseRemoteConfig.swift
import Foundation
import FirebaseRemoteConfig

enum FirebaseRemoteConfigBoolParameter: String {
    case registerButtonTest = "register_button_test"

    var defaultValue: Bool {
        switch self {
        case .registerButtonTest: return false
        }
    }
}

class FirebaseRemoteConfigBoolParameterStore {
    func value(forKey param: FirebaseRemoteConfigBoolParameter) -> Bool {
        let remoteConfig = RemoteConfig.remoteConfig()
        remoteConfig.setDefaults([param.rawValue: param.defaultValue as NSObject])
        return remoteConfig[param.rawValue].boolValue
    }
}

テスト対象のViewで適切なkeyを渡してボタンの色を切り替える

ViewController.swift
import UIKit
import FirebaseRemoteConfig
class ViewController: UIViewController {

    @IBOutlet weak var button: UIButton!
    override func viewDidLoad() {
        let isTestUser: Bool = FirebaseRemoteConfigBoolParameterStore().value(forKey: .registerButtonTest)
        if isTestUser {
            self.button.backgroundColor = UIColor.red
        }
    }
}

簡単なプロジェクトではありますが、一様サンプルのせておきます!
A/BTesting

おわりに

Firebaseが提供数アプリのグロースをサポートするサービスA/BTestingについて今回この場を借りて記事にさせていただきました。

仮説を検証する際に迅速にテストを行う環境としてFirebaseのA/BTestingを用いることは良いと思いますが、ABTestを行うことはあくまで手段でありコンテンツのどこをどのようにテストしどの数値を伸ばしたいのかを決めることが重要だと思っております!

また、各社のグロース施策で成功、失敗談があればぜひ聞きたいな〜とも思ってたりします!

最後に僕自身もまだまだ勉強中ではありますが、参考になったサイトと本を共有させていただきます!
Hacking Growth グロースハック完全読本

growthhackers.com

VASILY GROWTH HACK BLOG

【改訂版】Firebase Cloud Firestore rules tips

External article

Firestoreトランザクションの不親切をセキュリティルールで解決

はじめに

この記事はFirebase Advent Calendar2018の13日目の記事です。

Firestoreのトランザクション機能を利用していく中で、めんどうなケースがあったため共有します。
今回の記事で触れたいのはサインイン処理などでよくある下記のようなケースです。

あるドキュメントIDのデータを確認し、存在しない場合のみ複数ドキュメントを作成したい
ドキュメントIDが重複する場合:理由をユーザに伝える
通信そのものが失敗した場合 :リトライ

この処理をFirestoreトランザクションで素直に書こうとすると、エラー内容が不明瞭で利用できません。
これを
1. セキュリティルールで意図しない書込みを防ぐ
2. データ取得前に確認処理を挟む
ことで解決しました。

Firebaseにおけるトランザクション処理

Firebaseではトランザクション処理/一括書き込みという便利なものがあります
参考:公式:トランザクションと一括書き込み

トランザクションでは、書き込みが部分的に適用されることはありません。成功したトランザクションの完了時にすべての書き込みが実行されます。

とあるように、トランザクション処理は、

  • データ書き込みの漏れをなくす
  • 同時書き込みに対する対処

などを考える上で重要な機能です。

データが存在しない場合のみデータを書き込みたい

あるドキュメントIDのデータを確認し、存在しない場合のみデータを書き込みたい

というのを、公式ドキュメントを参考に、トランザクション処理を用いて単純に書くと以下のようなコードになります

transaction.swift
let sfReference = db.collection("users").document(userID)
db.runTransaction({ (transaction, errorPointer) -> Any? in
    let sfDocument: DocumentSnapshot
    do {
        try sfDocument = transaction.getDocument(sfReference)
    } catch let fetchError as NSError {
        errorPointer?.pointee = fetchError
        return nil
    }

    if sfDocument.exists {
        print("すでにuserIDが登録されています")
    }else {
        transaction.setData(userData, forDocument: sfReference)
    }

    return nil
}) { (object, error) in
    if let error = error {
        print("Transaction failed: \(error)")
    } else {
        print("Transaction successfully committed!")
    }
}

しかし、このコードでは
print("すでにuserIDが登録されています")
が呼ばれることはなく、データが存在する場合はcatchされてしまいます。

トランザクション処理では書き込み失敗時の理由の判別ができない

理由は存在しないドキュメントIDにアクセスすると
Foundation._GenericObjCError.NilError
と、なんとNilErrorをcatchしています。
Swift Developer Japanにて質問したところ、おそらくこの部分のエラーが伝搬してるのだろうということです

DocumentSnapshotにはexistsプロパティがあるのでそこで確認できるのが嬉しいのですが、
トランザクションでは利用することができません。
これではドキュメントIDが重複するか否かを判別することができず要件を叶えることができません。

どう対処したか

まず、今回の要件でFirebase トランザクションの機能を利用することを一部諦めています。
具体的には、トランザクション内でのドキュメントの読み込み、IDの存在確認をやめ、
書き込み機能のみの一括書き込みのみを使用してます。

そして、前述の解決するために以下の2つの施策を取っています。
1. セキュリティルールで意図しない書込みを防ぐ
2. データ取得前に確認処理を挟む

セキュリティルールで意図しない書込みを防ぐ

セキュリティルールでデータの上書きそのものを禁止しています。
ちょうど同じFirebase アドベントカレンダーの昨日の【改訂版】 Firebase Cloud Firestore rules tipsが参考になります。

今回のセキュリティルールでは、すでに存在するドキュメントの作成を禁止しています。

match /users/{userID} {
      allow read: if request.auth.uid != null;
      allow create: if request.auth.uid == userID
                    && request.resource.data.createdAt != null
      allow update: if request.auth.uid == userID
                    && request.resource.data.createdAt == resource.data.createdAt;

追記:この辺りを詳しく説明した記事を書きました
Firestoreセキュリティルールで存在するドキュメントの上書き作成を防ぐ

データ取得前に確認処理を挟む

下記のような書き込もうとしているdocumentIDがすでにcollection上に存在しているかどうかの確認処理
をトランザクション処理の前に挟むことで、ユーザに失敗理由を伝えています。

isExist.swift
static func exists(path: String, completion: @escaping ((Bool) -> Void)) {
    firestore.document(path).getDocument(completion: { snapshot, _ in
        completion(snapshot?.exists ?? false)
    })
}

仮に同じドキュメントIDに同タイミングで書き込もうとした場合でもセキュリティルールによりトランザクションが失敗します。
そのため、再度確認処理からリトライすることで、原因を特定しユーザに理由を伝えることができます。

まとめ

今回の知見をまとめるとこんなかんじです。

  • トランザクション中でエラーの内容により処理を分けることはできない。「失敗は失敗」
  • セキュリティルール側で処理を失敗させた上で原因を特定する工夫が必要
  • setメソッドでの同一パスへの書き込みはUpdate扱いとなるため、フィールドを用いたセキュリティルールの記述が要る

意見、感想などありましたら書き込んでいただけると幸いです。

追記

Firestore 5.16.0でエラーにならずに exists で判定できるように改善されたようです!

Breaking change: FIRTransaction.getDocument() has been changed to return a non-nil FIRDocumentSnapshot with exists equal to false if the document does not exist (instead of returning a nil FIRDocumentSnapshot). Code that includes if (snapshot) { ... } must be changed to if (snapshot.exists) { ... }.

https://firebase.google.com/support/release-notes/ios#5.16.0

@mono0926 さんにコメントいただきました!
ありがとうございます!

Firebase AuthとExpress + Typescript + Sequelizeで認証機能を作ろう(更新中ですすみません)

External article

チャット機能を部分的にレベルアップさせる

この記事はFirebase Advent Calendar 2018の15日目の記事です.

この記事に書いてあること

  • firestore を利用したチャットについて
  • プラットフォーム → iOS (Web, Androidでも参考にはなるかと)
  • 未読/既読表示
  • 送信ステータス表示 & 再送

はじめに

最近はかなり firebase を利用する人が増えてきましたね :clap:
自分も今年の夏くらいから利用してまして, 会社(※スタートアップ)のプロダクトもバックエンドはほぼすべて firebase(GCP) にしてます.

今回はその中でも, firestore について少し知見を共有させていただこうと思います.
世の中に公開したアプリでチャット機能を載せたのですが, その中で考慮したこととその実装例について書いていきます.
今ではこのチャット機能もだいぶチュートリアル的な記事が増えて, 最初のハードルは越えやすくなっているかなと思うので, また一つ先に進めるための参考になれば幸いです.

実装するチャットの要件

  • 一応複数人にスケールできる設計にする
  • 未読/既読を表示する
  • 自分がメッセージを送信した直後に表示されるようにする( firestore に届いてからではなく, すぐに )
  • 送信に失敗したことを表示する(オフライン時や弱電界時に, メッセージのタイムアウトに引っかかるなど)
  • 自動再送

※ 以下やらないこと

  • 認証
  • ルーム画面の実装
  • ページング(やり取りするメッセージ数が100前後の想定)
  • 更新・削除
  • ユーザによる手動再送
  • 細かいエラーハンドリング

環境

  • Xcode 10.1
  • Swift 4.2.1
  • Cocoapods 1.6.0.beta.2
  • firebase-ios-sdk 5.13.0 (執筆時点で最新の 5.14.0 だとissues/2177に引っかかってしまった...)

Collection - Document構成

手抜きですみません :bow:
今回はルームまで突っ込まないので表示しそうな情報を置いてます.

ルーム( /rooms ) メッセージ( /rooms/chatMessages )
Screen Shot 2018-12-15 at 16.44.36.png Screen Shot 2018-12-15 at 16.45.19.png

ソースコード & デモ

https://github.com/gates1de/ChatSandbox

実装説明

全部書こうとすると長くなるので, firestore に焦点を当ててTips的な部分だけ書きます.
後, ViewModelに通信処理書いてる部分とかはスルーして頂ければと...ViewControllerだけだとやばかったので笑

未読/既読表示

ここまでは結構やったことがある人が多いかもしれないのでササッと :runner:

基本方針

  • チャットルームに入ってメッセージを取得したとき, 「相手のメッセージかつ未読のメッセージ」を保持
  • Firestore.firestore().batch() を利用して一気に既読更新リクエスト

この方針だと, 画面表示外のメッセージを既読にされる可能性もありますが, ユーザにとっては違和感ないんじゃないかと思います :bulb: (プロダクトの要件次第ですかね)

メッセージ受信側の処理

※ 一部書き換えて表示しています.

ChatSandbox/ChatViewModel.swift
Firestore.firestore()
    .collection(Room.collectionName)
    .document(roomId)
    .collection(ChatMessage.collectionName)
    .getDocuments() { querySnapshot, error in
        ...

        let messageId   = documentSnapshot.documentID
        let messageData = documentSnapshot.data()
        guard var newMessage = ChatMessage.initialize(id: messageId, json: messageData) else {
            continue
        }

        ...

        // 相手のメッセージ かつ 未読のメッセージを保持するための配列
        var unreadPartnerMessageList: [ChatMessage] = []

        ...

        if newMessage.userId != self.myUserId && !newMessage.isReadUserId.contains(self.myUserId) {
            newMessage.isReadUserId.append(self.myUserId)
            unreadPartnerMessageList.append(newMessage)
        }

        ...

        resetUnreadCount(unreadOtherMessageList)

        ...
    }

func resetUnreadCount(targetMessageList: [ChatMessage]) {
    guard let messageCollectionRef = self.messageCollectionRef, !targetMessageList.isEmpty else {
        return
    }

    let batch = FirestoreManager.shared.db.batch()

    for message in targetMessageList {
        guard let messageId = message.id else { continue }
        batch.updateData(
            [ ChatMessage.CodingKeys.isReadUserId.rawValue: FieldValue.arrayUnion([self.myUserId]) ],
            forDocument: messageCollectionRef.document(messageId)
        )
    }

    batch.commit { error in
        if let error = error { print(error) }
    }
}

基本方針通りにやるとこんな感じで, 特に難しい処理はしていないのですが, 便利なものがあるので紹介します.

[ ChatMessage.CodingKeys.isReadUserId.rawValue: FieldValue.arrayUnion([self.myUserId]) ]

この処理は, document 上にある配列データを結合しています.
配列ごと更新せず, もし他の人が既読更新処理をおこなったとしても上書きすることなく, この配列で設定した値のみ追加してくれます.
すでに同じ値が入っていても上書きなので, 二重登録の心配もないです.

メッセージ送信側の処理

ChatSandbox/ChatViewModel.swift
Firestore.firestore()
    .collection(Room.collectionName)
    .document(roomId)
    .collection(ChatMessage.collectionName)
    .addSnapshotListener(includeMetadataChanges: true) { snapshot, error in

        ...

        // 更新されたデータがあるかどうかのフラグ
        var isExistChangedData = false

        // 変更メッセージの取得
        for documentChange in snapshot.documentChanges {

            // 無駄な更新を避けるために, 既に取得してあるメッセージ(oldMessage)と変更後のメッセージ(changedMessage)に差分がある場合のみ, 保持するフラグを有効にする
            if oldMessage.isDiff(chatMessage: changedMessage) {
                isExistChangedData = true
            }

            // 各種変更処理(ここで新しい isReadUserId が入る) → 保持

            ...

        }

        // 新規のメッセージと更新後のメッセージをUIに反映する
        self.updateMessageCompletion?(newMessageCellViewModelList, changedMessageCellViewModelList)
    }

何の特徴もない処理ですが笑, これで既読マークが表示されます.

送信ステータス表示 & 再送

実質この記事で一番伝えたい部分です.
結構このケースで考慮漏れありそうだなと思って記事を書こうと決心しました :sweat_smile:

(以下の内容は, 前提としてオフラインデータを有効にしている [firestorePersistenceEnabled オプションを有効にしている] 場合に限ります.)

少々主観が入りますが, それなりに有名なチャット系のアプリは 「送信に失敗したことがわかる & 後で再送できるし削除もできる」 という要件が盛り込まれているように思います.
最悪削除は目を瞑るとして, 送信の失敗表示と再送機能が入っていないと, UXが悪いと思われることも少なくない世の中になってきているのではないかと :sweat: (送ったように見えて実は送れてなかった, は最悪ですね...)

firestore の場合, 通常の書き込みではローカルに書き込まれただけでも成功とみなされる仕組みになっているので, サーバに到達したことを判定しなければなりません.
runTransaction を使えばオフライン時は書き込まれないので変則的ですが, オンラインになるまで送信できないとなるとユーザが待機状態になってしまう可能性があるため, 場合によっては避けたい状況です.

ちょっと長くなりましたが対応方法は様々あるので, 今回は一例を紹介させていただく形にして「私はこんな対応してますよ〜」のようなご意見がもしありましたらご教示頂けますと幸いです :bow:

メッセージ送信側の処理

ChatSandbox/ChatViewModel.swift
Firestore.firestore()
    .collection(Room.collectionName)
    .document(roomId)
    .collection(ChatMessage.collectionName)
    .addSnapshotListener(includeMetadataChanges: true) { snapshot, error in

        ...
        // 新規メッセージの取得
        for document in snapshot.documents {

            guard var newMessage = ChatMessage.initialize(id: messageId, json: document.data()) else {
                continue
            }

            // まだ送信済みになっていないメッセージの場合
            if newMessage.isSent == nil || newMessage.isSent != true {
                // 書き込み待ち以外は送信済みとみなす
                newMessage.isSent = !document.metadata.hasPendingWrites
                notSendMessageList.updateValue(newMessage.isSent, forKey: messageId)
            }

            ...

        }

        ...

        // 送信ステータス更新処理
        self.updateMessageIsSent(targetList: notSendMessageList)
    }

知っている方も多いかもしれませんが, QueryDocumentSnapshot.metadata (SnapshotMetadata) には hasPendingWrites というのがあるので, このフラグからサーバに到達したことを判定します.

これ使うだけなんですけど...ちょっとハマりポイントがありまして, 他にも isFromCache というフラグがあります.

Returns YES if the snapshot was created from cached data rather than guaranteed up-to-date server data.
If your listener has opted into metadata updates (via includeMetadataChanges:YES)
you will receive another snapshot with isFromCache equal to NO once the client has received up-to-date data from the backend.

ドキュメントを見る限りでは, 今回のケースで言えばサーバに到達していなければ false になると捉えて, 厳密にサーバに到達したことを判定するならこのフラグも見る必要がありそうだと思って何度も検証したのですが...値がブレブレだったんですよね... true にも false にもなるという...
一旦こいつは見ずに, hasPendingWrites だけに着目しました.

結局, オフライン時には hasPendingWritestrue になり, オンライン復帰してデータがサーバに到達すると false に変わるのでそこをキャッチして再送処理が完了していることを表示することができました.

ベータ版だからかは不明ですが, このあたりの細かい挙動は少々怪しい感じです :bulb:

また, 今回のようにメッセージの状態を管理する場合, addSnapshotListener 内部の処理が高頻度に発生しますので, 差分更新処理はポイントになりそうです.
この記事で作成したアプリではあまり考慮していない部分ですが, 本番投入する際にはそこも気にかけて頂ければと思います.

おわりに

今回はよく実装する内容ではあるかもしれませんが, 意外に記事として見かけない実装かなと思って書いてみました.
何にせよ firebase を使いこなせる人が増えれば嬉しいですし, 結果的にプロダクト開発に拍車がかかって業界全体のボトムアップに繋がり, ユーザが使いやすいサービスを爆速で提供していけるようになったら幸いです.

今後も何かしら面白そうな事例が出てきたら共有させて頂きます〜 :bow:

【Firebase】CLI 経由で Firestore に大量のテストデータを投入する

はじめに

アドベントカレンダー初投稿です!
業務で Firestore を利用したアプリ開発をしていて、ページネーションを実装するにあたって大量のテストデータを用意したくなったので勉強がてら実装してみました。。。

この投稿は Firebase Advent Calendar 2018 16日目の記事です

やりたいこと

  • firestore の users コレクションにテストデータを投入する
  • 各ドキュメントはランダムに生成されたデータが入っている
  • 多めの件数でも任意に指定して書き込めるようにする(最大10,000件)
  • テストデータだけを全件消去できる
  • 以上すべてをコマンドラインツールとして実現する(引数で制御したい)

環境構築

以下の前提で進める

  • firebase init でディレクトリを生成済
  • firebase-admin を使うために functions ディレクトリ内でコードを書く
  • TypeScript を使用
  • パッケージマネージャーは yarn

必要なパッケージをインストール

/functions
$ yarn add ts-node faker commander

typescript ファイルを作成

functions
└── cli
    ├── index.ts
    └── user.ts

注:functions としてデプロイするわけではないのでビルドの対象には含めなくてよい

CLI 実行用の run scripts を作成

package.json
{
  "scripts": {
    "cli": "ts-node cli/index.ts"
  }
}

モックデータ用関数の実装

  • ランダムなデータを作成するために faker を利用
  • 一括書き込みの上限が500件なので、再帰関数で500件ずつ書き込む
    参考:トランザクションと一括書き込み
  • 作成したモックデータのみ消せるように、isMock フィールドを用意した
user.ts
import * as admin from 'firebase-admin'
import * as faker from 'faker'

// 共通化するために Admin SDK の初期化は index.ts で行う
const db = admin.firestore()
const batchSize = 500

// 作成用関数
export const createUsers = async (n: number) => {
  const usersRef = await db.collection('users')

  // 再帰関数
  const excuteBatch = async (size: number) => {
    if (size === 0) {
      return
    }
    // 500件を超えないようにする
    const length = Math.min(size, batchSize)
    const batch = db.batch()
    for (let i = 0; i < length; i = i + 1) {
      const user = {
        firstName: faker.name.firstName(),
        lastName: faker.name.lastName(),
        createdAt: admin.firestore.FieldValue.serverTimestamp(),
        updatedAt: admin.firestore.FieldValue.serverTimestamp(),
        isMock: true // 削除時のクエリ用フィールド
      }
      batch.create(usersRef.doc(), user)
    }
    const results = await batch.commit()
    // 書き込みが成功した分を引いた残りの件数を代入
    await excuteBatch(size - results.length)
  }
  await excuteBatch(n)
}

// 削除用関数
export const deleteUsers = async () => {
  // モックデータのみを500件ずつ取得
  const query = await db
    .collection('users')
    .where('isMock', '==', true)
    .limit(batchSize)

  // 再帰関数
  const executeBatch = async () => {
    const snapshot = await query.get()
    if (snapshot.size === 0) {
      return
    }
    const batch = db.batch()
    snapshot.docs.forEach(doc => {
      batch.delete(doc.ref)
    })
    await batch.commit()
    await executeBatch()
  }
  await executeBatch()
}

削除用の関数の実装は公式ドキュメントを参考にした
Cloud Firestore からデータを削除する#コレクションを削除する

ドキュメントでは process.nextTick を使っているが、上記の実装では async/await で制御できているから要らない?(マサカリお待ちしています)

CLI ツールの実装

index.ts でそのまま呼び出して実行しても良かったが、CLI ツールとして実装した
commander というライブラリを利用(大したことはしないのでなんでも良い)
バージョン情報や、ヘルプコマンドなど必要なものはよしなに設定してくれたので中々良かった

index.ts
import * as admin from 'firebase-admin'
import * as program from 'commander'

// user.ts を import する前に Admin SDK を初期化する
admin.initializeApp()
admin.firestore().settings({ timestampsInSnapshots: true })

import { createUsers, deleteUsers } from './user'

program.version('1.0.0', '-v, --version')
program
  .command('user')
  .option('-d, --delete', 'delete only the created documents')
  .option('-n, --number <n>', 'A number of test documents', parseInt, 0)
  .description('create test user documents')
  .action(cmd => {
    if (cmd.number > 10000) {
      return console.error('The number must be 10000 or less')
    }
    const promise = cmd.delete ? deleteUsers() : createUsers(cmd.number)
    promise.then(() => console.log('Command has completed')).catch(console.error)
  })
program.parse(process.argv)

console.log('Firestore Mocking CLI')

テストデータ作成

初めに作成した run scripts 経由で実行する

$ yarn cli user -n 1000

yarn run v1.12.3
$ ts-node test/cli.ts user -n 1000
Firestore Mocking CLI
Command has completed
✨  Done in 6.71s.

テストデータ削除

$ yarn cli user -d

yarn run v1.12.3
$ ts-node test/cli.ts user -d
Firestore Mocking CLI
Command has completed
✨  Done in 5.73s.

おわりに

無料枠の制限にひっかからないように気をつけましょう…
2018/12/15 現在は以下の通りです

firebase_2018-12-14_23-09-07.png

そもそも一括書き込みやトランザクションは1回の実行で1件?500件?どちらなんでしょう:thinking:

[追記]
@mono0926 さんよりコメントいただきました

500ドキュメント扱った場合は、いかなる方法でも500件のコストです。

書き込み件数の節約のためにバッチやトランザクションを使うことはできないということですね!

Nuxt.js+Firestoreの場合に安全にSSRする方法

External article

ハッカソンで差をつけろ!瞬速 Firebase Authentication ~3分クッキング~ Vueを添えて

はじめに

この記事は Firebase Advent Calendar 2018 の 18日目の記事です。

まえがき

最近ブロックチェーンのハッカソンに参加しまくって国内外で5回以上賞を取って賞金だけで100万ぐらい稼いでいると思うのですが、ハッカソンで優勝しまくってる秘密の1つを教えちゃいます!

最新の実績です。


ハッカソンで大事なのは、

  • 実装スピード
  • アイデア
  • 完成度
  • プレゼン

です。

実装スピードと完成度は似てるようで少し違います。
しかし、FirebaseとVue.jsを使えば両方かなりカバーできるのでオススメです。

3分でGoogle認証を作る

vueのpwaのテンプレート使う

 npm install -g @vue/cli
 npm install -g firebase-tools
 vue init pwa my-project # ここお好きなプロジェクト名
 cd my-project
 npm i
 npm i --save firebase
 firebase init

firebaseのコンソールで設定する

https://console.firebase.google.com/u/0/?hl=ja にアクセスします。

Screenshot from 2018-12-18 22-51-29.png

適当にプロジェクトの設定をする。
Screenshot from 2018-12-18 22-53-17.png

Authentication を選択する。
Screenshot from 2018-12-18 22-54-56.png

ログイン方法を設定
Screenshot from 2018-12-18 22-55-57.png

ここでは適当にGoogle認証を入れる。
Screenshot from 2018-12-18 22-56-14.png

有効にする のトグルをスライドさせて保存。
Screenshot from 2018-12-18 22-57-20.png

歯車を押して 設定 に入る
Screenshot from 2018-12-18 22-54-56.png

ウェブアプリにFirebaseを追加
Screenshot from 2018-12-18 22-59-42.png

コンフィグが表示されるのでコピー
Screenshot from 2018-12-18 23-00-09.png

コードの修正をする

この環境ではCLIでテンプレートを使っているので、HMR(差分検知してビルドしてくれる超便利機能)も入ってますし、ただ npm run build するだけで開発用のサーバと自動ビルド環境が手に入ります。

npm run build

src/components/Hello.vue を好きなエディタで開いてfirebaseのコンフィグやらを書いたら、もうGoogle認証が出来ちゃいましたね!!

<template>
  <div class="hello">
    <button @click="login">login</button>
  </div>
</template>

<script>
import firebase from 'firebase'

// コンソールから取得したコンフィグをペースト
const config = {
    apiKey: "",
    authDomain: "hogefuga.firebaseapp.com",
    databaseURL: "https://hogefuga.firebaseio.com",
    projectId: "hogefuga",
    storageBucket: "hogefuga.appspot.com",
    messagingSenderId: "323003240989"
  };
firebase.initializeApp(config)


export default {
  name: 'hello',
  data () {
    return {
      msg: 'Welcome to Your Vue.js PWA'
    }
  },
  methods: {
    login() {
      const provider = new firebase.auth.GoogleAuthProvider()
      firebase.auth().signInWithPopup(provider)
        .catch(error => alert(error.message))
        .then(data => {
          // ここにログインできたときの処理を書く
          alert('login success')
          console.log(data)
        })
    }
  }
}
</script>

たったこれだけでログイン画面が出来てしまいました!!!

Screenshot from 2018-12-19 10-10-29.png

Screenshot from 2018-12-19 10-13-16.png

Firebase Hostingにデプロイする

なんとこのサイトすぐにサーバにデプロイできちゃいます!

そう、Firebase Hostingならね!

2つのコマンドを打つだけです。

npm run build
firebase deploy

デプロイされたURLに飛んでログインしてみると、ちゃんと管理画面の方でユーザが増えているのが確認できます。
Screenshot from 2018-12-19 10-19-10.png

まとめ

FirebaseのAuthenticationやVueを使えば爆速でPWA&いつでもデプロイ可能&Google認証の下地が一瞬で出来ています。

基本的に毎回この構成でやっていて、優勝してます。

ブロックチェーンのハッカソンに出て賞を取りたい人は、僕をTwitterでフォローして、ハッカソンに誘ってくれたら出るかも?です。

Follow @_serinuntius

FlutterFireを利用したFirestoreへの書込・読込をする簡易アプリを作成した過程の躓きと、その対応

はじめに

@swchrmと申します。
本記事はFirebase Advent Calendar2018 19日目の記事です。

何が書いてあるのか

FlutterでFirebaseを扱うことのできるプラグインの集まりFlutterFireを利用して簡易投票アプリを作成した際に、Flutterはおろかクロスプラットフォーム開発の経験がなかった身には躓きポイントが幾つかありました。

その作成過程で躓いた箇所とその対応の内容が書かれています。

Flutterとはなにか、ふんわりと

Flutterは、クロスプラットフォーム開発で用いられるモバイルアプリケーションフレームワークです。
自分が比較対象としてよく耳にするのはReactNativeやXamarinでしょうか。
こちらの記事「Xamarin と React Native と Flutter の違いを正しく理解しよう」の比較がざっくり掴むのにちょうど良さそうです。

iOSとAndroidに加え、今後はWebも対応する予定です。

つくるものとゴール

今回はGoogle Codelabを題材にしています。
Firebase + Flutterの使い方の雰囲気くらいは掴める内容になっています。

こちらで掲載されている赤ちゃんの名前候補に投票できるアプリの完成を目指します。
基本的にはCodelabの案内に沿って進めていけばよいですが、本記事は躓いた際の補助資料的な位置づけになれば嬉しいです。

使った環境・必要なツール

  • macOS(CPU: 1.3GHz, Intel Core i5)
  • Dart 2.1.0
  • iPhone6s iOS12
  • AndroidStudio 3.2.1
  • VS Code 1.29.1(インストール前提)
  • homebrew 1.8.6(インストール前提)
  • Flutter 1.0.0
  • cloud_firestore 0.8.2

筆者の開発環境はmacOS、検証用デバイスはiPhone6Sを使用しています。
その他のOSの方はごめんなさい。未検証です。
また、デバイスはiOSかAndroidOSのものどちらかが必要です。

Flutter

Flutterの公式からSlutterのSDKをインストールし、プロジェクトを開始します。

DartSDKについては、The Dart SDK is bundled with Flutter; it is not necessary to install Dart separately. とあり、FlutterSDKに紐付けられているため、別々でインストールする必要がないとあります。
が、自分は結局インストールしました。1

zipを解凍し、PATHを通します。
flutter docter [-v]コマンドで、Flutterを使うにあたって現在の自分の環境の状態を確認します。

このあたりはCode labの説明どおり進めます。

以下はflutter docter [-v]時に出たメッセージ。

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.0.0, on Mac OS X 10.14.1 18B75, locale ja-JP)
[✗] Android toolchain - develop for Android devices
    ✗ Unable to locate Android SDK.
      Install Android Studio from: https://developer.android.com/studio/index.html
      On first launch it will assist you in installing the Android SDK components.
      (or visit https://flutter.io/setup/#android-setup for detailed instructions).
      If Android SDK has been installed to a custom location, set $ANDROID_HOME to that location.
      You may also want to add it to your PATH environment variable.

[✗] iOS toolchain - develop for iOS devices
    ✗ Xcode installation is incomplete; a full installation is necessary for iOS development.
      Download at: https://developer.apple.com/xcode/download/
      Or install Xcode via the App Store.
      Once installed, run:
        sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
    ✗ libimobiledevice and ideviceinstaller are not installed. To install with Brew, run:
        brew update
        brew install --HEAD usbmuxd
        brew link usbmuxd
        brew install --HEAD libimobiledevice
        brew install ideviceinstaller
    ✗ ios-deploy not installed. To install with Brew:
        brew install ios-deploy
    ✗ CocoaPods not installed.
        CocoaPods is used to retrieve the iOS platform side's plugin code that responds to your plugin usage on the Dart side.
        Without resolving iOS dependencies with CocoaPods, plugins will not work on iOS.
        For more info, see https://flutter.io/platform-plugins
      To install:
        brew install cocoapods
        pod setup
[!] Android Studio (not installed)
[!] VS Code (version 1.29.1)
[!] Connected device
    ! No devices available

! Doctor found issues in 5 categories.

[✗]は基本的には解決しなければならない問題で、[!]に関しては無視しても先に進める問題です。
今回はすべて[✓]になるまで解決します。

まずはiOS関連のErrorから解決を試みます。

iOS

下記に注意。

MacOSは、iOSとAndroid用のFlutterアプリの開発をサポートしています。最初のFlutterアプリケーションをビルドして実行するには、2つのプラットフォーム設定ステップの少なくとも1つを完了してください。
https://flutter.io/docs/get-started/install/macos#platform-setup

私はiOSの環境を整えました。
Apple Developer Agreementの処理を行います。
その後はXCodeをインストールしたあとhomebrewで下記をインストールします。

sudo xcodebuild -license accept  
brew update  
brew install --HEAD usbmuxd  
brew link usbmuxd  
brew install --HEAD libimobiledevice  
brew install ideviceinstaller

このあと続けて

brew install ios-deploy
brew install cocoapods

を実行。
CocoaPodsはiOSライブラリ管理ツールです。

iOS toolchainのError

Error: The `brew link` step did not complete successfullyと言われる場合、brew uninstallを行う必要があります。
私は下記を実行したらErrorが消えました。

brew update  
brew uninstall --ignore-dependencies libimobiledevice  
brew uninstall --ignore-dependencies usbmuxd  
brew install --HEAD usbmuxd  
brew unlink usbmuxd  
brew link usbmuxd  
brew install --HEAD libimobiledevice`  

flutter docter [-v]で確認したところ、iOS toolchainのエラーが消えたらOKです。

CocoaPods installed but not initialized.

pod setupで解決しました。

10分弱待たされる。
実行結果ログは下記の通り。

Setting up CocoaPods master repo
  $ /usr/bin/git clone https://github.com/CocoaPods/Specs.git master --progress
  Cloning into 'master'...
  remote: Enumerating objects: 316, done.
  remote: Counting objects: 100% (316/316), done.
  remote: Compressing objects: 100% (231/231), done.
  remote: Total 2668685 (delta 125), reused 153 (delta 72), pack-reused 2668369
  Receiving objects: 100% (2668685/2668685), 597.44 MiB | 3.89 MiB/s, done.
  Resolving deltas: 100% (1573495/1573495), done.
  Checking out files: 100% (289715/289715), done.

CocoaPods 1.6.0.beta.2 is available.
To update use: `gem install cocoapods --pre`
[!] This is a test version we'd love you to try.

For more information, see https://blog.cocoapods.org and the CHANGELOG for this version at https://github.com/CocoaPods/CocoaPods/releases/tag/1.6.0.beta.2

Setup completed

flutter docter [-v]でも確認。

[✓] iOS toolchain - develop for iOS devices (Xcode 10.1)
[!] Android Studio (not installed)
[!] VS Code (version 1.29.1)
[!] Connected device
    ! No devices available

ということで、iOSの設定はOK。

Android Studio

公式サイトからインストール。

別途AndroidSDKのインストールは必要ありません。
flutter docterコマンド実行結果のAndroidの欄からも案内が確認できます。(下記参照)

[✗] Android toolchain - develop for Android devices
    ✗ Unable to locate Android SDK.
      Install Android Studio from: https://developer.android.com/studio/index.html
      On first launch it will assist you in installing the Android SDK components.
      (or visit https://flutter.io/setup/#android-setup for detailed instructions).
      If Android SDK has been installed to a custom location, set $ANDROID_HOME to that location.
      You may also want to add it to your PATH environment variable.

Android Studioを起動して、SDKをインストールする案内があるので実行してください。
Android Studioをインストール&起動しただけではAndroid SDKはされず、✗は消えないので注意してください。

Android Licenceの承諾

Android SDKインストール後、flutter doctor [-v]を実行すると、下記のメッセージが出ます。

[!] Android toolchain - develop for Android devices (Android SDK 28.0.3)
    ✗ Android licenses not accepted.  To resolve this, run: flutter doctor --android-licenses

示されているコマンドflutter doctor --android-licensesを実行するとライセンスの承諾の処理が始まります。
私はここですべてyにしないと[!]が消えませんでした。
GoogleTV〜などとでてくるので、最初は必要ないと思い承諾しなかったのですが、最終的にはすべてyを選択しました。

ライセンス認証後の状態でまたflutter doctor [-v]を実行すると下記のとおりになりました。

[✓] Android Studio (version 3.2)
    ✗ Flutter plugin not installed; this adds Flutter specific functionality.
    ✗ Dart plugin not installed; this adds Dart specific functionality.

この2つの問題はAndoroid StudioのPreferenceからプラグインをインストールすれば解決します。
Android Studio Preferences > Pluginsを選択し、
Install JetBrains plugin -> Dart
Browse Repositories -> Flutter
をインストールで対応できます。

その結果…[✘]はなくなりました。

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.0.0, on Mac OS X 10.14.1 18B75, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK 28.0.3)
[✓] iOS toolchain - develop for iOS devices (Xcode 10.1)
[✓] Android Studio (version 3.2)
[!] VS Code (version 1.29.1)
[✓] Connected device (1 available)

! Doctor found issues in 1 category.

Android

AVDが起動画面から動かない

Androidも少しは試したのでかける箇所を共有。
起動はしたものの、そこから一向にAVDが動かなくなりました。

System imageをRecommendのものをそのまま使っていたましたが、x86のx86_64のものを使うことで解決しました。

https://akira-watson.com/android/avd-manager.html

これは私のマシンスペックがしょぼかったからの可能性もある…?

VS Code

VSCodeの[!]は、VSCodeにFlutterとDartのプラグインを追加すれば解決することが多いと思います。
もちろんエラーの内容はflutter doctor -vで確認してください。

ここでDartのプラグインを入れようとしますが、今度はVSCodeでDartのSDKがないと怒られが発生します。
これが、上で述べたDartのSDKを入れた理由です。
VS Codeの指示に従ってDart公式サイトのインストールページからインストールします。

サイトに飛ぶと、入れるDart SDKの候補が現在は3つ並んでいます。
私はWebを選択しましたが、問題なく動きました。

brew tap dart-lang/dartCould not link

下記のようなErrorが出る場合。

Error: Could not link:
/usr/local/etc/bash_completion.d/brew

Please delete these paths and run `brew update`.
Error: Could not link:
/usr/local/share/zsh/site-functions/_brew

Please delete these paths and run `brew update`.
Error: Could not link:
/usr/local/share/man/man1/brew.1

Please delete these paths and run `brew update`.
Error: Could not link:
/usr/local/share/doc/homebrew

Please delete these paths and run `brew update`.
==> Tapping dart-lang/dart
Cloning into '/usr/local/Homebrew/Library/Taps/dart-lang/homebrew-dart'...
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (7/7), done.
remote: Total 7 (delta 1), reused 2 (delta 0), pack-reused 0
Unpacking objects: 100% (7/7), done.
Tapped 3 formulae (35 files, 33.5KB).

対応として下記を実行しました。

rm -rf /usr/local/share/doc/homebrew
rm -rf /usr/local/etc/bash_completion.d/brew
rm -rf /usr/local/share/zsh/site-functions/_brew
rm -rf /usr/local/share/man/man1/brew.1
brew update

Already up-to-date.が表示されました。
再度brew tap dart-lang/dartbrew install dartを実行した結果が以下。

==> Installing dart from dart-lang/dart
==> Downloading https://storage.googleapis.com/dart-archive/channels/stable/release/2.1.0/sdk/dartsdk-macos-x64-release.zip
######################################################################## 100.0%
==> Caveats
Please note the path to the Dart SDK:
  /usr/local/opt/dart/libexec
==> Summary
🍺  /usr/local/Cellar/dart/2.1.0: 339 files, 300.1MB, built in 30 seconds

成功したみたいですね。
dart --versionの結果も

Dart VM version: 2.1.0 (Tue Nov 13 18:22:02 2018 +0100) on "macos_x64"

で、無事インストールが確認できた。

その他flutter コマンド実行時

flutter create {任意のプロジェクト名}

"{作成したFlutterプロジェクト名}" is not a valid Dart package name.はパッケージ名の不正です。

"Flutterfire-Sample" is not a valid Dart package name.

From the [Pubspec format description](https://www.dartlang.org/tools/pub/pubspec.html):

**DO** use `lowercase_with_underscores` for package names.

Package names should be all lowercase, with underscores to separate words,
`just_like_this`.  Use only basic Latin letters and Arabic digits: [a-z0-9_].
Also, make sure the name is a valid Dart identifier -- that it doesn't start
with digits and isn't a reserved word.

ファイルの命名規則にひっかかってます。
この場合、just_like_this部のようにflutter create flutter_fire_sampleと名前を変えてプロジェクトを作成してみるとOKでした。

flutter createコマンドが成功した様子の見本(長いです)

Creating project flutter_fire_sample...
  flutter_fire_sample/ios/Runner.xcworkspace/contents.xcworkspacedata (created)
  flutter_fire_sample/ios/Runner/Info.plist (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png (created)
  flutter_fire_sample/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png (created)
  flutter_fire_sample/ios/Runner/Base.lproj/LaunchScreen.storyboard (created)
  flutter_fire_sample/ios/Runner/Base.lproj/Main.storyboard (created)
  flutter_fire_sample/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata (created)
  flutter_fire_sample/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme (created)
  flutter_fire_sample/ios/Flutter/Debug.xcconfig (created)
  flutter_fire_sample/ios/Flutter/Release.xcconfig (created)
  flutter_fire_sample/ios/Flutter/AppFrameworkInfo.plist (created)
  flutter_fire_sample/test/widget_test.dart (created)
  flutter_fire_sample/flutter_fire_sample.iml (created)
  flutter_fire_sample/.gitignore (created)
  flutter_fire_sample/.metadata (created)
  flutter_fire_sample/ios/Runner/AppDelegate.h (created)
  flutter_fire_sample/ios/Runner/main.m (created)
  flutter_fire_sample/ios/Runner/AppDelegate.m (created)
  flutter_fire_sample/ios/Runner.xcodeproj/project.pbxproj (created)
  flutter_fire_sample/android/app/src/main/res/mipmap-mdpi/ic_launcher.png (created)
  flutter_fire_sample/android/app/src/main/res/mipmap-hdpi/ic_launcher.png (created)
  flutter_fire_sample/android/app/src/main/res/drawable/launch_background.xml (created)
  flutter_fire_sample/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png (created)
  flutter_fire_sample/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png (created)
  flutter_fire_sample/android/app/src/main/res/values/styles.xml (created)
  flutter_fire_sample/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png (created)
  flutter_fire_sample/android/app/src/main/AndroidManifest.xml (created)
  flutter_fire_sample/android/gradle/wrapper/gradle-wrapper.properties (created)
  flutter_fire_sample/android/gradle.properties (created)
  flutter_fire_sample/android/settings.gradle (created)
  flutter_fire_sample/pubspec.yaml (created)
  flutter_fire_sample/README.md (created)
  flutter_fire_sample/lib/main.dart (created)
  flutter_fire_sample/android/app/build.gradle (created)
  flutter_fire_sample/android/app/src/main/java/com/example/flutterfiresample/MainActivity.java (created)
  flutter_fire_sample/android/build.gradle (created)
  flutter_fire_sample/android/flutter_fire_sample_android.iml (created)
  flutter_fire_sample/.idea/runConfigurations/main_dart.xml (created)
  flutter_fire_sample/.idea/libraries/Flutter_for_Android.xml (created)
  flutter_fire_sample/.idea/libraries/Dart_SDK.xml (created)
  flutter_fire_sample/.idea/libraries/KotlinJavaRuntime.xml (created)
  flutter_fire_sample/.idea/modules.xml (created)
  flutter_fire_sample/.idea/workspace.xml (created)
Running "flutter packages get" in flutter_fire_sample...     5.5s
Wrote 64 files.

All done!
[✓] Flutter is fully installed. (Channel stable, v1.0.0, on Mac OS X 10.14.1 18B75, locale ja-JP)
[✓] Android toolchain - develop for Android devices is fully installed. (Android SDK 28.0.3)
[✓] iOS toolchain - develop for iOS devices is fully installed. (Xcode 10.1)
[✓] Android Studio is fully installed. (version 3.2)
[✓] VS Code is fully installed. (version 1.29.1)
[✓] Connected device is fully installed. (2 available)

In order to run your application, type:

  $ cd flutter_fire_sample
  $ flutter run

Your application code is in flutter_fire_sample/lib/main.dart.

flutter runをうつ。

More than one device connected; please specify a device with the '-d <deviceId>' flag, or use '-d all' to act on all devices.
{hogehoge} の iPhone • {xxxxxxxxxxxxxxxxxxxxxxxxxxxx} • ios • iOS 12.1

実行できるようになりました。

flutter run

note: Using new build systemnote: Planning buildnote: Using build description from memory

CodelabにはXcodeのBuild SystemはLegacyに変更する必要との記述があります
リンク先のTroubleshooting Xcode build fail:部が該当箇所です。

ほかの参照

https://github.com/flutter/flutter/issues/22123#issuecomment-423618945
https://github.com/flutter/flutter/issues/19241#issuecomment-404601754

iPhoneでのデバイスの認証

またiPhoneで、一般 -> デバイス管理 -> デベロッパでアプリを認証を許可していなくてもBuildはコケます。
そちらも許可する必要があります。

Error: No pubspec.yaml file found.

下記のエラーは、作成したプロジェクトディレクトリ直下でflutter runすればOKです。
pubspec.yamlは、雑に言えば依存関係を管理するファイルで、配置場所は作成したflutter runで作成したプロジェクトの直下です。

Error: No pubspec.yaml file found.
This command should be run from the root of your Flutter project.
Do not run this command from the root of your git clone of Flutter.

The default Firebase app has not yet been configured....

Launching lib/main.dart on {hogehoge} の iPhone in debug mode...
Automatically signing iOS for device deployment using specified development team in Xcode project: 8KYC4WC652
Running pod install...
Starting Xcode build...
Xcode build done.                                           627.3s
Installing and launching...
5.14.0 - [Firebase/Core][I-COR000003] The default Firebase app has not yet been configured. Add `[FIRApp configure];` (`FirebaseApp.configure()` in Swift) to your application initialization. Read more: https://goo.gl/ctyzm8.
5.14.0 - [Firebase/Core][I-COR000012] Could not locate configuration file: 'GoogleService-Info.plist'.
5.14.0 - [Firebase/Core][I-COR000005] No app has been configured yet.
5.14.0 - [Firebase/Core][I-COR000003] The default Firebase app has not yet been configured. Add `[FIRApp configure];` (`FirebaseApp.configure()` in Swift) to your application initialization. Read more: https://goo.gl/ctyzm8.
5.14.0 - [Firebase/Core][I-COR000012] Could not locate configuration file: 'GoogleService-Info.plist'.
5.14.0 - [Firebase/Core][I-COR000003] The default Firebase app has not yet been configured. Add `[FIRApp configure];` (`FirebaseApp.configure()` in Swift) to your application initialization. Read more: https://goo.gl/ctyzm8.
5.14.0 - [Firebase/Core][I-COR000012] Could not locate configuration file: 'GoogleService-Info.plist'.

FirebaseにiOSとAndroidで使う登録をしていないため、Errorです。
Firebase consoleにアクセスし、設定を行います。

XcodeからバンドルIDを設定し、アプリのニックネームをつける。
そのあと、GoogleService-Info.plistをダウンロードして、{任意のプロジェクトファイル名}/iOS/Runner/配下に置く。

CocoaPodsのPodfileがない場合は、{hoge}/ios/pod initする。
今回はすでにあるので、Podfileにpod 'Firebase/Core'を追加する。
Podfileの書き方に関してはこちらを参照

Podfileで開いた結果がこれ

  # Flutter Pods
  generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig')
  if generated_xcode_build_settings.empty?
    puts "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first."
  end
  generated_xcode_build_settings.map { |p|
    if p[:name] == 'FLUTTER_FRAMEWORK_DIR'
      symlink = File.join('.symlinks', 'flutter')
      File.symlink(File.dirname(p[:path]), symlink)
      pod 'Flutter', :path => File.join(symlink, File.basename(p[:path]))
    end
  }

  # Plugin Pods
  plugin_pods = parse_KV_file('../.flutter-plugins')
  plugin_pods.map { |p|
    symlink = File.join('.symlinks', 'plugins', p[:name])
    File.symlink(p[:path], symlink)
    pod p[:name], :path => File.join(symlink, 'ios')
  }
end

pod 'Firebase/Core'をどこに書けばいいか

自分はここでOKでした。

...(省略)...

    pod p[:name], :path => File.join(symlink, 'ios')
    pod 'Firebase/Core'
  }
end

flutter packages get

下記のエラーが出る場合は、追加した文字の書き方が悪いと出ます。
yamlファイルだからインデントを揃えればOKです。

[flutter_fire_sample] flutter packages get
Running "flutter packages get" in flutter_fire_sample...        
Error on line 17, column 5 of pubspec.yaml: A dependency may only have one source.
    sdk: flutter
    ^^^^^^^^^^^^^^

pub get failed (65)
exit code 65

正しいのはこちら。

dependencies:
  flutter:
    sdk: flutter
  cloud_firestore: ^0.8.2     # new

Firebase

Firebaseの登録やプロジェクト作成は公式をお読みください🙇‍♂️

Firebaseで「iOSアプリにFirebaseを追加」の手順4「初期コードの追加」はどこに行えばいいのか

Objective-Cの場合、ios/Runner/AppDelegate.hios/Runner/AppDelegate.mとに書けば解決します。

ios/Runner/AppDelegate.hの内容

#import <Flutter/Flutter.h>
#import <UIKit/UIKit.h>

@import Firebase;

@interface AppDelegate : FlutterAppDelegate

@end

ios/Runner/AppDelegate.mの内容

include "AppDelegate.h"
#include "GeneratedPluginRegistrant.h"

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  [FIRApp configure];

  [GeneratedPluginRegistrant registerWithRegistry:self];
  // Override point for customization after application launch.
  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end

参照

https://github.com/flutter/flutter/issues/13534#issuecomment-427620251)

Homebrew使用時

複数ユーザーを使い分けている場合、権限の問題でDart SDKのインストール時にErrorになる場合があります。

MyMacBookAir:flutter_fire_sample swchrm$ brew tap dart-lang/dart  
touch: /usr/local/Homebrew/.git/FETCH_HEAD: Permission denied  
touch: /usr/local/Homebrew/Library/Taps/dart-lang/homebrew-dart/.git/FETCH_HEAD: Permission denied  
touch: /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core/.git/FETCH_HEAD: Permission denied  
fatal: Unable to create '/usr/local/Homebrew/.git/index.lock': Permission denied  
fatal: Unable to create '/usr/local/Homebrew/.git/index.lock': Permission denied  
error: could not lock config file .git/config: Permission denied  

sudoでもだめ。

MyMacBookAir:traveling_jirorian_problem_flutter swchrm$ sudo brew tap dart-lang/dart
Password:
Error: Running Homebrew as root is extremely dangerous and no longer supported.
As Homebrew does not drop privileges on installation you would be giving all
build scripts full access to your system.

下記いずれも試したがだめだった。

https://qiita.com/masa_36/items/a811c9335e2e985f6adc

https://qiita.com/k-hotta/items/7236f68ef26f7771b02f

https://qiita.com/yshishido/items/ba5cd86afe217b221457

自分はこれで解決しました。

chmod -R g+w /usr/local/var
chgrp -R admin /usr/local/var

参照

https://gist.github.com/jaibeee/9a4ea6aa9d428bc77925#gistcomment-1943612

おわりに

Flutter+Firebaseかぶった(白目)

本アドベントカレンダーの7日目にFlutterFireの紹介記事が投稿されてしまったこともあり、本当はFirestoreに格納したデータをFlutterで取得してGoogleMap上に表示させる記事を書きたかったのですが、間に合わずでした。
FlutterFireに興味を持った方は上記記事の筆者の方が12/21に書籍を出版されるそうなので、そちらもあわせてどうぞ。僕も買いました。楽しみです。

内容に誤りがありましたら修正しますのでご指摘いただけると幸いです。


  1. VSCodeの設定参照。 

Codelabで学ぶFirebaseとその先

Codelabに触れよう!

Firebaseを学ぶ手段の1つとしてCodelabがある。実際に手を動かしながら学ぶことができるので、何ができて何ができないのか学ぶことができます。

数多くのCodelabでもオススメのハンズオンが こちらのCloud Firestore Web Codelabです。このハンズオンは少ない時間でFirebaseの基礎となるAuth, Firestore, Rulesを学ぶことができます。Node.jsの環境があればすぐ試せるのも魅力のひとつです。

Cloud Firestore Web Codelabで学べること

  • Firestoreの読み書き
  • Firestoreとのリアルタイム通信
  • Auth認証
  • セキュリティルールの設定
  • Firestoreの複雑なクエリ
  • Firebase CLIツールの使い方

できあがるもの

レストランの検索やレーティング、レビュー、フィルタリングをFirebaseを使って簡単に実装できます。こんな豪華な機能が数行のコードで実装できるのもFirebaseの魅力のひとつですね。

Screen Shot 2018-12-19 at 21.41.08.png

このコードラボの可能性

今回はレストラン検索とどこでもありそうなWebアプリですが、このレストランという項目を変えるだけで自分オリジナルのWebアプリを作ることができると思います。たとえば、スポーツ選手図鑑、何かのイベント情報、商品レビューなどなど。

完成したら公開したい

完成したら公開したいですよね。Codelabでは公開の方法を解説していませんが、いくつか注意点をおさえれば簡単に公開できます。

承認済ドメインの確認

Authentication > ログイン方法 > 承認済ドメイン
のlocalhost を削除しましょう。

Rulesの確認

誰でもデータへフルアクセスできる状態になっていないか今一度確認しましょう。Auth認証を利用し必要な機能にのみ権限を付与しましょう

Pricingの確認

料金プランが自分の意図しているものか確認しましょう。

Firebase Hostingで公開

下記のコマンドで簡単にデプロイできます。

$ firebase deploy

デプロイ後 Hosting URL: の xxxxx.firebaseapp.com にアクセスし localhost と同じ機能ができていれば成功です!

Firebase Hostingの特徴

Firebase Hosting ではカスタムドメインも簡単にあてることができるほか、強力なCDNによりウェブアプリ、静的コンテンツ、動的コンテンツ向けの高速で安全性の高いホスティングを提供します。

デプロイしたものを無効にしたい

$ firebase hosting:disable

まとめ

  • CodelabでFirebaseのプロダクトを体系的に学ぶことができる
  • 自分のアイディア次第でオリジナルコンテンツを作成できる可能性を秘めている
  • Firebase Hostingを使えばスケールアップ可能な環境にWebアプリをデプロイすることができる

firebaseを使って匿名型チャットmimichaを作ったのでどんな感じに作ったかを紹介

External article

若手エンジニアがFirebase(Firestore + Cloud Functions + Firebase storage) + Vue.jsでシンプルなWebアプリをつくってみた

若手エンジニアがFirebase(FireStore + Cloud Functions + FireStorage) + Vue.jsでシンプルなWebアプリをつくってみた

Yoki(@enyyokii)と申します。

渋谷のIT企業でアプリエンジニアしている25才です。
仕事では iOS、Android、Webフロントエンドなど色々しており、週末は勉強を兼ねて個人開発したりしています。

今回は大好きなFirebaseを使ってWebアプリを作成してみました。

なに作ったの?

Skill App

自分のスキルセットを画像化してTweetできるWebアプリです🙂

  • 自分のできることをアピールしたいときに
  • Twitter転職の際のスキルセット一覧に

みたいな時に使っていただけると幸いです😆

スクリーンショット 2018-12-10 23.45.14.pngスクリーンショット 2018-12-10 23.45.29.pngスクリーンショット 2018-12-10 23.46.14.png

こんなツイートが作成できます👇

IMG_38B45AA3CEA9-1.jpeg

なぜ作ったの?

  • スキルアップのため 普段はモバイルアプリの開発をしているので、Webアプリの開発はあまり慣れていませんでした。なので、Vue.jsで1からWebアプリを開発できるようになりたかったのと、WebでもFirebaseを使えるようになりたかったので開発してみました🤗
  • Twitter転職をみていて・・・ もう1つの理由は、Twitter転職でのスキルアピールに使えるアプリあったらおもしろいかも、と思ったからです。 エンジニア界隈ではTwitter上で希望年収や、勤務地、経歴、スキルを呟いて転職に繋げる「Twitter転職」が流行っています。 Tweet内容でスキルの項目は文字数とるし、画像化した方が目についていいかもと思い開発してみました🎧

どんな技術をつかったの?

使った技術としては「Vue.js」と「Firebase」だけです💡

Vue.js

  • vue-router

    • Vue Router は Vue.js 公式のルータです。使わない手はないです!
  • vuetify

    • 最高のUIを提供してくれます。80種類以上の素敵なコンポーネントがあります!
  • Free and Premium themes

    • Vuetifyで作成されている、無料 or 有料なテーマがあります。 これがなかなかイケてます😳(現在は有料のものが1つ、無料のものが2つあります。)特に「Parallax」という無料テーマはあらゆるサイトのトップページとして使用できるナイスなテーマになっています。

スクリーンショット 2018-12-11 0.23.33.png

  • html2canvas

    • ライブラリです。Tweetする際のコンテンツの画像化する際にhtml2canvasを使用しました。ページ内のDOMに基づいて画像を生成してくれます。
  • jimp

    • ライブラリです。JavaScriptの画像加工ライブラリです。Tweetする際にTwitterの規定に合うようなサイズに画像をリサイズするために使用しました。

Firebase

  • Firestore

    • ユーザーが入力したスキル情報をFireSoreに格納するようにしています。 現在はこれを利用した機能はありませんが、どんなスキルを入力した人が多いかなどの分析ができると面白いなと思っています。

スクリーンショット 2018-12-16 23.44.39.png

  • Cloud Functions

    • Twitterカードを作成するために動的にタグを生成しています。 パスの最後についているidを取得し、それに該当する画像をStorageから取得、そのファイルパスをOGPとして設定し、htmlを返しています。
    exports.returnWithOGP = functions.https.onRequest((req, res) => {
        res.set('Cache-Control', 'public, max-age=300, s-maxage=600')
    
        const path = req.params[0].split('/')
        const id = path[path.length -1]
    
        fs.readFile('index.html', 'utf8', (error, templateHtml) => {
            if (error) { res.status(500).send(error) }
            const filePath = `skillImages/${id}.jpg`
            const bucket = admin.storage().bucket()
            const file = bucket.file(filePath)
            const config = {
                action: 'read',
                expires: '03-01-2500',
            }
    
            file.getSignedUrl(config).then(urls => {
                const signedUrl = urls[0]
                const responseHtml = templateHtml.replace(/(<meta property=og:image content=)(.*?)>/, "$1" + signedUrl + '>')
                return res.status(200).send(responseHtml)
            }).catch(error => {
                console.log(error)
                return res.status(404).send(templateHtml)
            })
        })
    });
    
    
  • Firebaseでデプロイ

Others

作ってみてどうだった?

Firebaseいいですね!
バックエンドやインフラを意識することなく、アプリケーションの開発に注力できます👍

ただ、開発は思ったより時間がかかってしまった感があります。

  • 個人開発する上でのやる気の問題
  • フィードバックを早くもらってブラッシュアップしていくべき(PDCAを早く回す)

を考慮すると、「ある程度のもの」ができる段階をいかに早く作れるかが大事だと思っています。

そのために、

  • リリース可能段階のアプリの仕様、デザイン

これをできるだけ明確に決めておく必要があると思っています。
今回の場合、雰囲気レベルでしか決まっていなかったのでずるずると伸びてしまって気がします。

あと、目標設定やモチベーションの維持のための工夫は大事ですね。
そこら辺は科学的に効果的なものがあるのでそれらを実践していきたいです。
👇👇👇
開発を支える科学的な目標設定の仕方とモチベーションの持続について

ここまで読んでくれた方へ

「若手エンジニアがFirebase(FireStore + Cloud Functions + FireStorage) + Vue.jsでシンプルなWebアプリをつくってみた」を作った話でした。
ここまで読んでくださりありがとうございます🙇‍♂️

Twitterカードをタップ時の個別詳細ページを作ったり、ランキングをだしたり
とかできればいいなあ、なんて思っています。

Skill App、少しでも興味を持たれましたらアクセスしていただけると幸いです。

[Firebase] AuthenticationとFunctionsとHostingでまぁまぁ動くWebアプリのサンプルと解説

この記事を読む前に(2019/04/10追記)

2019年4月開催のGoogle Cloud NextにてHostingとCloud Runの連携が発表されました。
https://firebase.google.com/docs/hosting/cloud-run
これにより、以下に記述されている内容は現時点で筆者の思う最適解ではなくなりました。
ご利用される方はこの点を念頭においた上でお願いします。

これはなに?

Firebase Advent Calendar 2018 24日目の記事です。

Firebaseの個別の機能を試してみた系の記事はたくさんあるが、複数の機能を盛り込んで完成したWebアプリを作って動かしている例は少ない。設定ファイルみたいな細かいところみんなどうしているんだろうという点が気になっている。

そこで、とりあえず自分が動かしている例を公開することにより強い人にマサカリを投げてもらえばいいんじゃないかと思った。ついでに自分の知見を共有することにより、Firebaseのさらなる普及を願った。

サーバサイドレンダリングがつらいという風潮に一石を投じるべく、FirebaseとNuxt.jsのコンビネーションが最強というメッセージを発信すると見せかけてNuxt.jsのステマをしたかった。

※という事情により、JSフレームワークとしてNuxt.jsを採用しています。FirebaseのAdvent Calendarなので、Nuxt.jsの要素はあまり深く解説しません。また別の機会と需要があれば...

完成したWebアプリの概要

https://mikuappend.com/ (2019/01/07 公開終了しました)

  • メールリンク認証が体験できます。
  • サインインするとコメントが1件投稿できます。
    • コメントを1回投稿するとサインアウトさせられます。
    • ついでにアカウントが消えます。
  • 他人が投稿したものも含め、コメントをリアルタイムで受信できます。

※メールリンク認証を使用しているため、一時的にメールアドレスをお預かりします。サインアウトすると削除されますが、気になる方はご利用をお控えいただくか、ソースコードを用意しましたのでお手元で実行してみてください。

ソースコード

GitHub1からどうぞ。だいたい4時間くらいで作りました。この記事を書くほうが時間かかりました。
https://github.com/hecateball/firebase-nuxt

実行する場合、.firebasercは上げていませんので、適宜用意してください。
また、FirebaseのWeb設定がnuxt.config.js内にありますので、ご自分の環境の値で置き換えてください。

nuxt.config.js
  env: {
    firebase: {
      //この中身をご自分の環境の設定値で置き換えてください。
    }

ローカルで動かすには上記のFirebaseの設定を行った上で、npm install => npm run dev としてください。

※サーバサイドレンダリングにあたり、Functionsを外部から呼び出します。課金プラン(Blaze)へのアップグレードが必要なのでご注意ください。

Functionsを使ってサーバサイドレンダリングを実現する

近年、検索エンジンのクローラーはサーバサイドレンダリングをしなくてもいい感じにページの中身を解釈してくれるようになりました。しかし、残念ながらTwitterやFacebookのWebViewはまだそんなに賢くありません。OGPタグをページごとに出しわけたいという比較的よくある要望に答えるためにはやはりSSRで実現する必要があります。また、性能面でもLighthouseにおけるFirst Meaningful PaintやFirst Interactiveといった指標の改善に大きく寄与します。

firebase.json

Hostingの設定で、以下のようにrewritesを設定します。

firebase.json
"rewrites": [
  {
    "source": "**",
    "function": "render"
  }
]

(完全なfirebase.jsonはこちら)

すべての存在しないリソースに対するリクエストをrenderという名前のFunctionで処理します。rewrites設定はもし要求したリソースが存在する場合は効かなくなってしまうので、うっかりindex.htmlをおいたりしないように注意してください。

render.ts

こちらがFirebase + Nuxt.jsでサーバサイドレンダリングを行うためのFunctionの完全なコードです。

render.ts
import * as functions from 'firebase-functions'
import { Nuxt } from 'nuxt'

const nuxt = new Nuxt({
    dev: false,
    debug: process.env.GCP_PROJECT !== 'your-firebase-project'
})

module.exports = functions.https.onRequest(nuxt.render)

Nuxt.jsが非常に優秀なので、たったこれだけのコードでサーバサイドレンダリングが実現できます。設計上は考えることが多くなります2が、サーバサイドレンダリング自体は何も難しいことではなくなりました。

おまけ: FirebaseのIP制限

業務利用においては開発環境ではIP制限をかけたいみたいな要望もあるかもしれません。いまのところFirebase単体で完全なIP制限をかける方法はありませんが、サーバサイドレンダリング用のFunctionに対するリクエストをIPでフィルタすることでまぁまぁ利用に耐える3IP制限が可能です。

Authenticationでユーザ認証の仕組みを作る

Authenticationで認証できればなんでも良かったのですが、せっかくなのでサンプルの少ないメールリンク認証を使ってみました。メールアドレスを入力してフォームをsubmitすると、入力したメールアドレス宛に認証のためのURLが書かれたメールが送られてきます。このURLにアクセスするだけでパスワードの入力なしにユーザを認証できます4

Webアプリにおけるほぼ必須の機能でありながら、セキュリティとか面倒な事をいろいろ考えないといけないユーザ認証がわずかなコードで実現できます。しかも、Firebaseの他の機能と連動しているので大変便利です。

pages/index.vue
    signIn: async function() {
      try{
        await firebase.auth().sendSignInLinkToEmail(this.email, {
          url: `https://${process.env.firebase.authDomain}/auth`,
          handleCodeInApp: true
        })
        this.$message(`${this.email} にメールを送信しました。`)
      } catch ({ message }) {
        this.$message.error(message)
      }
    }
pages/auth/index.vue
  // このコードはユーザが/authにアクセスしたときに実行されます
  mounted: async function() {
    // メール内リンクからアクセスするとURLにはクエリパラメータが付与されています。
    // ここで妥当性を検証します。
    if (!firebase.auth().isSignInWithEmailLink(window.location.href)) {
      this.$router.push('/')
      return
    }
    try {
      const { value } = await this.$prompt('認証メールを受け取ったメールアドレスを入力してください。',
        'メールアドレスの確認', {
          confirmButtonText: 'OK',
          showCancelButton: false
        })
      // 認証のコードはここから
      await firebase.auth().signInWithEmailLink(value, window.location.href)
      this.$router.push('/')
    } catch ({ message }) {
      this.$router.push('/')
    }
  }

クライアントからFirestoreへ直接アクセスさせる

Firestoreを使うならやっぱりSDKを介してクライアントから直接読み書きさせたいですよね!もうデータストアにアクセスするためにAPIをたてたり、APIのロードバランシングやスケーリングに悩む時代は終わりました。普遍的に終わりましたって書くと偉い人に怒られそうですが、すくなくともこの記事に興味を持っていただいて、Firebase上にサービスを構築しようと思っている一流のFirebaserのみなさまにとっては終わりましたので、心置きなくFirestoreへの直接アクセスをご利用ください。

リアルタイム通信

せっかくFirestoreなので、リアルタイム通信を使ってみました。この機能に関しては他にも解説記事がたくさんあるのでそちらに譲ることにします。まだ動きを見たことがない方はソースコードと合わせて実際の動きを試してみてください。

直接アクセスって、データバリデーションはどうやるの?

「いままでサーバサイドでやっていたデータバリデーションはどうやるの?」という疑問をお持ちになった方も多いと思います。セキュリティルールでやります。以下は今回作ったサンプルWebアプリで作成しているコメントデータのドキュメントです。

message
{
  uid: `${firebase.auth().currentUser.uid}`,
  message: 'ここにコメントの本文が入ります' 
  admin: false,
  createdAt: 2018-12-24 12:34:56.000
}

このドキュメントを作成する操作を許可するためのルールは以下のようになります。単純なデータ作成処理に対してこの量のルールが必要なのかと驚かれる方も多いかもしれません。ここではあえて冗長に書いていますが、実運用においてはrequest.resource.dataを取得する関数を用意したりしてもいいかもしれません。

firestore.rules
      allow create: if request.auth != null
        && request.auth.token.firebase.sign_in_provider != 'email'
        && request.resource.data.keys().hasOnly(['uid', 'message', 'admin', 'createdAt'])
        && request.resource.data.uid is string
        && request.resource.data.message is string
        && request.resource.data.admin is bool
        && request.resource.data.createdAt is timestamp
        && request.resource.data.uid == request.auth.uid
        && request.resource.data.message.size() != 0
        && request.resource.data.message.size() <= 100
        && request.resource.data.admin == false
        && request.resource.data.createdAt == request.time;

allow create: if ~
if節が満たされる場合に限り、データの新規作成を許可します(updateやdeleteは許可しません)。あなたがupdate操作やdelete操作に対して安全であることを保証できる場合を除き、ここで安易にallow writeとしてはいけません。可能な限り強い制限をかけることが重要です。

request.auth != null
ユーザがFirebase Authenticationによって認証されていることを要求します。

request.auth.token.firebase.sign_in_provider != 'email'
認証方式がメール認証であることを要求します。通常はあまり必要ないかもしれません。匿名認証を利用する場合において匿名でないユーザと区別したいときはこの値を参照します。

また、今回は取り上げませんでしたが、Authenticationユーザのカスタムクレームに設定した値も同様にセキュリティルールから参照できます。ユーザのアカウントロックを実現する際に利用することがあるかもしれません。

request.resource.data.keys().hasOnly(['uid', 'message', 'admin', 'createdAt'])
作成するドキュメントの中にuid message admin createdAt以外の余計なフィールドが含まれていないことを要求します5

request.resource.data.uid is string など
データの型を制限します。後続のsize()などを安全に実行するために、あらかじめ型を検査しておきます。

request.resource.data.uid == request.auth.uid
uid フィールドがデータの作成を要求したユーザのuidと一致することを要求します。
今回のサンプルアプリではこの値を使ってAuthenticationのユーザ削除を実行しますので、他人になり済ませないようにする必要があります。

request.resource.data.createdAt == request.time
timestampの値がリクエストの時刻に設定されていることを要求します。この構文は頻出です。
Timestamp.now()などで取得できる値は端末の設定時刻に引きずられる可能性がるので、ここでは使用できません。このルールをパスするためにはcreatedAtfirebase.firestore.FieldValue.serverTimestamp()を指定します。

セキュリティルールが重要なのはわかった。他に覚えておくべきことは?

たくさん書いておきたいことがあるのですが、すべてを書きつくすにはAdvent Calendarの締め切りが近すぎました。こちらの過去記事を併せてご覧いただければと...

Functionsのバックエンドトリガー実行

今回つくったサンプルアプリではメールリンク認証を利用しているため、サインインしてきた場ユーザのメールアドレスをやむなく取得してしまいます。メールアドレスの収集は目的としておりませんので、アカウントを削除する機能をFunctionsに用意しました。

サンプルアプリにおいては、コメントが作成されたことをトリガーにしてcleanを実行しています。

clean.ts
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'

admin.initializeApp()
admin.firestore().settings({ timestampsInSnapshots: true })

module.exports = functions.firestore
  .document('messages/{message}')
  .onCreate(async (snapshot) => {
    if (snapshot.get('admin')) {
      return Promise.resolve()
    }
    // Authentication ユーザーの削除
    await admin.auth().deleteUser(snapshot.get('uid'))
    // メッセージの送信
    return admin.firestore().collection('messages').add({
      uid: null,
      admin: true,
      message: `アカウントを削除しました。(UID: ${snapshot.get('uid')})`,
      createdAt: admin.firestore.FieldValue.serverTimestamp()
    })
  })

あまり話題に上がりませんが、Functionsのバックエンドトリガーはさまざまな場面で活用できます。特にFirestore上にあるデータが作成されたときに、関連する別のデータを操作したいというような場合に非常に便利です。たとえば、ぱっと思いつく範囲でも以下のような例があります。

具体例1: TwitterのようなSNSアプリで、あるユーザの書き込みに対して返信されたときに、アプリ内通知を送りたい

このような例は似たような形でたくさんあると思います。返信ドキュメント作成をトリガーとして通知ドキュメントを作成するという形になります。

「クライアント上で両方つくればいいんじゃないの?」と思った方、もしそれがあなたのアプリでできるとしたらFirestoreのセキュリティルールに穴が空いている可能性があります

上の例で行くと、通知ドキュメントの読み書きは通知を受け取ったユーザに制限されると思われますので、返信を行ったユーザには書き込み権限がないことが一般的です。このようなケースでセキュリティルールに縛られないFunctions(Admin SDK)が便利に働きます。結果として、クライアントに通知ドキュメントの書き込みをさせる必要がなくなり、より安全性を高めることができます。

具体例2: あるドキュメントが削除されたときに、そのサブコレクションのデータもまとめて消したい

コンソールから操作した場合はサブコレクション内のネストしたデータをまとめて消してくれるのですが、アプリケーションからはいちいち削除をする必要があります。これをクライアント上でやるのは処理としても重くなかなか辛いうえ、例1と同様に不必要なセキュリティルールの緩和が必要となることが予想されるので、Functionsにおまかせするほうが良さそうです。

Hosting: カスタムドメイン連携

特に意味はありませんが、カスタムドメインを利用してみました。
DNSにAレコードを2件追加するだけでおわりました。どこからともなくSSL証明書も出してくれるようなのでありがたいです。

同様に、メールリンク認証時に送信されるメールにもカスタムドメインが利用できます。

彼はFirebaseの素晴らしさを伝える機会を願ったが、準備のための時間を願い損ねた。

力尽きました。すみません。これ以上まとまりのない文章をつらつら書くよりもコードを見てもらうほうが早いと思うので、ぜひご自分で動かしてみてください。

おわりに: なぜヒトはFirebaseに惹かれるのか

  • 「バックエンドの本質的でないことはFirebaseに任せて、お前はサービスのコードを書くことに集中するんだ!」という熱い想いを感じた
  • サンプルやドキュメントが最高に充実していた
  • 洗練された機能性を美しいと思ってしまった
  • 困ったときは天に祈ると新機能が出て解決してくれる
  • Firebase Japan User Groupが非常に質の高い活動を続けている

みんなFirebaseやろうぜ!


  1. ご利用にあたっては一切の制限を設けませんが、ご自身の責任でお願いします。なんらかの不利益を被ったとしても何も補償しません。 

  2. 一例として、ユーザの認証状態によってコンテンツを切り替えるような処理をSSRで実現するには認証情報をいいかんじにリクエストに乗せてサーバ側に持っていく必要があります。が、これは結構難しいです。このようなコンテンツはSSRの対象としないよううまく何とかする必要があります。ちなみにNuxt.jsなら一瞬でできます。 

  3. Hostingに乗せるようなファイルはハッシュ値が名前に含まれると思われるので、ファイル名を推測してアクセスするのは非常に困難だと思われます。(ファイル名が固定の)静的サイトをホストするような場合は、要件に応じて他のソリューションを検討すべきでしょう。 

  4. 今回作ったサンプルアプリではメール内のリンクからアクセスした先で再度メールアドレスの入力を要求していますが、同一のUser AgentであればIndexed DBなどのローカルストレージに入力されたメールアドレスを保持しておくことで、再入力の手間をスキップすることが可能です。 

  5. この条件は指定したフィールドが全て含まれていることは要求していません。今回は後続のルールによってカバーできているので問題ありません。「存在してもしなくてもいいフィールド」などがあれば、hasAll()と併用する必要があります。 

webpackでFirebaseのプロジェクトを切り替える方法

External article
Browsing Latest Articles All 24 Live