この記事を読む前に(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
内にありますので、ご自分の環境の値で置き換えてください。
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を設定します。
"rewrites": [
{
"source": "**",
"function": "render"
}
]
(完全なfirebase.jsonはこちら)
すべての存在しないリソースに対するリクエストをrender
という名前のFunctionで処理します。rewrites設定はもし要求したリソースが存在する場合は効かなくなってしまうので、うっかりindex.htmlをおいたりしないように注意してください。
render.ts
こちらがFirebase + Nuxt.jsでサーバサイドレンダリングを行うためのFunctionの完全なコードです。
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の他の機能と連動しているので大変便利です。
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)
}
}
// このコードはユーザが/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アプリで作成しているコメントデータのドキュメントです。
{
uid: `${firebase.auth().currentUser.uid}`,
message: 'ここにコメントの本文が入ります'
admin: false,
createdAt: 2018-12-24 12:34:56.000
}
このドキュメントを作成する操作を許可するためのルールは以下のようになります。単純なデータ作成処理に対してこの量のルールが必要なのかと驚かれる方も多いかもしれません。ここではあえて冗長に書いていますが、実運用においてはrequest.resource.data
を取得する関数を用意したりしてもいいかもしれません。
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()
などで取得できる値は端末の設定時刻に引きずられる可能性がるので、ここでは使用できません。このルールをパスするためにはcreatedAt
にfirebase.firestore.FieldValue.serverTimestamp()
を指定します。
セキュリティルールが重要なのはわかった。他に覚えておくべきことは?
たくさん書いておきたいことがあるのですが、すべてを書きつくすにはAdvent Calendarの締め切りが近すぎました。こちらの過去記事を併せてご覧いただければと...
Functionsのバックエンドトリガー実行
今回つくったサンプルアプリではメールリンク認証を利用しているため、サインインしてきた場ユーザのメールアドレスをやむなく取得してしまいます。メールアドレスの収集は目的としておりませんので、アカウントを削除する機能をFunctionsに用意しました。
サンプルアプリにおいては、コメントが作成されたことをトリガーにしてclean
を実行しています。
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やろうぜ!
-
ご利用にあたっては一切の制限を設けませんが、ご自身の責任でお願いします。なんらかの不利益を被ったとしても何も補償しません。 ↩
-
一例として、ユーザの認証状態によってコンテンツを切り替えるような処理をSSRで実現するには認証情報をいいかんじにリクエストに乗せてサーバ側に持っていく必要があります。が、これは結構難しいです。このようなコンテンツはSSRの対象としないよううまく何とかする必要があります。ちなみにNuxt.jsなら一瞬でできます。 ↩
-
Hostingに乗せるようなファイルはハッシュ値が名前に含まれると思われるので、ファイル名を推測してアクセスするのは非常に困難だと思われます。(ファイル名が固定の)静的サイトをホストするような場合は、要件に応じて他のソリューションを検討すべきでしょう。 ↩
-
今回作ったサンプルアプリではメール内のリンクからアクセスした先で再度メールアドレスの入力を要求していますが、同一のUser AgentであればIndexed DBなどのローカルストレージに入力されたメールアドレスを保持しておくことで、再入力の手間をスキップすることが可能です。 ↩
-
この条件は指定したフィールドが全て含まれていることは要求していません。今回は後続のルールによってカバーできているので問題ありません。「存在してもしなくてもいいフィールド」などがあれば、hasAll()と併用する必要があります。 ↩