Cloud Firestoreで変更可能なユーザーIDを設計する
技術・テクノロジー一般的な Web サービスにおけるユーザーID を Firebase の Cloud Firestore で設計する手法についてまとめます。
※本記事のコードは未検証のため、動作しない可能性があります。ご了承ください。
仕様
ユーザー ID の仕様は以下の通りとします。
- いつでも 変更可能
- 他のユーザーのユーザーID と 重複不可
- 半角英数小文字とアンダースコアのみ許可し、文字数は 3~15 文字
Firestore の設計
まずはユーザー情報を格納する /users
コレクションを設計します。ドキュメントの ID は認証したユーザーの uid とします(/users/{uid}
)。
とりあえず、あると便利なので publishedAt
と updatedAt
を追加しておきつつ、別に今回の説明ではなくてもいいのですがそれっぽく displayName
も追加しておきます。
以上を TypeScript の型で表現すると以下のようになります。
type User = {
displayName: string
userId: string
publishedAt: firebase.firestore.Timestamp
updatedAt: firebase.firestore.Timestamp
}
・・・とまぁ、一般的な DB 設計だとこれで終了、となりそうなんですが、Firestore で厳密にセキュリティルールを記述しつつ、クライアントサイドのみからデータを更新する場合は追加で「ユーザー ID を保存するコレクション」も作成する必要があります。
イメージは /userIds/{userId}
みたいなドキュメントで、型は以下のような感じです。
type UserId = {
uid: string
publishedAt: firebase.firestore.Timestamp
}
このコレクションのドキュメントは先ほど設計した /users/{uid}
ドキュメントの onCreate
もしくは onUpdate
フック時に作成されるものです。このコレクションのドキュメントが存在しているかどうかを確認することによって、クライアントサイドからのデータの更新時にもセキュリティルールでユーザー ID が重複しないようにできます。
セキュリティルールの記述
次にセキュリティルールを記述します。まずは、仮に userId
が存在しないとして、displayName
のみを更新するルールをさっと書いてみます。
displayName
は適当に 1~40 文字を許可するようにしています。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{uid} {
allow read;
allow create: if isAuthenticated()
&& isUserAuthenticated(uid)
&& isValidUser(incomingData())
&& validateString(incomingData().displayName, 1, 40)
&& incomingData().publishedAt == request.time
&& incomingData().updatedAt == request.time;
allow update: if isAuthenticated()
&& isUserAuthenticated(uid)
&& isValidUser(incomingData())
&& validateString(incomingData().displayName, 1, 40)
&& incomingData().publishedAt == existingData().publishedAt
&& incomingData().updatedAt == request.time;
}
function isAuthenticated() {
return request.auth != null;
}
function isUserAuthenticated(uid) {
return request.auth.uid == uid;
}
function incomingData() {
return request.resource.data;
}
function existingData() {
return resource.data;
}
function validateString(text, min, max) {
return text is string
&& min <= text.size()
&& text.size() <= max;
}
function isValidUser(user) {
return user.size() == 3
&& 'displayName' in user && user.displayName is string
&& 'publishedAt' in user && user.publishedAt is timestamp
&& 'updatedAt' in user && user.updatedAt is timestamp;
}
}
}
余談ですが、セキュリティルールを記述する際は isAuthenticated
のように、よく使う処理は関数化しておくと便利です。
次に、このルールに userId
を追加してみます。前述の通り、ユーザー ID は他のユーザーのユーザー ID と重複を許さないという性質と、半角英数小文字とアンダースコアのみ許可し、文字数は 3~15 文字という制限があるため、これを反映します。
// ...省略...
match /users/{uid} {
allow read;
allow create: if isAuthenticated()
&& isUserAuthenticated(uid)
&& isValidUser(incomingData())
&& validateString(incomingData().displayName, 1, 40)
&& isValidUserId(incomingData().userId)
&& !exists(userIdPath(incomingData().userId))
&& incomingData().publishedAt == request.time
&& incomingData().updatedAt == request.time;
allow update: if isAuthenticated()
&& isUserAuthenticated(uid)
&& isValidUser(incomingData())
&& validateString(incomingData().displayName, 1, 40)
&& isValidUserId(incomingData().userId)
&& (!exists(userIdPath(incomingData().userId))
|| getData(userIdPath(incomingData().userId)).uid == uid)
&& incomingData().publishedAt == existingData().publishedAt
&& incomingData().updatedAt == request.time;
}
function documentPath(paths) {
return path([
['databases', database, 'documents'].join('/'),
paths.join('/')
].join('/'));
}
function userIdPath(userId) {
return documentPath(['user-ids', userId]);
}
function isValidUserId(userId) {
return userId.matches('^[a-z0-9_]{3,15}$');
}
function getData(path) {
return get(path).data;
}
// ...省略...
}
}
上記のように、exists
でユーザー ID の存在確認をしつつ、isValidUserId
でユーザー ID の形式チェックを行っています。
またユーザーの更新時に関しては、ユーザー ID を同じ値でそのまま更新する可能性もあるため、
getData(userIdPath(incomingData().userId)).uid == uid
として自分でプロフィールを更新する場合はユーザー ID を更新しても OK という処理を書いています。
Cloud Firestore の関数トリガーを追加
最後に、/users/{uid}
のドキュメントの作成・更新時に /userIds/{userId}
を作成する関数トリガーを実装します。
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
type User = {
displayName: string
userId: string
publishedAt: admin.firestore.Timestamp
updatedAt: admin.firestore.Timestamp
}
const firestore = admin.firestore()
export const onCreateUser = functions
.region('asia-northeast1')
.firestore.document('users/{uid}')
.onCreate(async (snapshot, context) => {
const user = snapshot.data() as User
const userId = user.userId
const uid = context.params.uid as string
const newUserIdRef = firestore.collection('userIds').doc(userId)
newUserIdRef.create({
publishedAt: admin.firestore.FieldValue.serverTimestamp(),
uid,
})
})
export const onUpdateUser = functions
.region('asia-northeast1')
.firestore.document('users/{uid}')
.onUpdate(async (change, context) => {
const newUser = change.after.data() as User
const newUserId = newUser.userId
const newUserIdRef = firestore.collection('userIds').doc(newUserId)
const oldUser = change.before.data() as User
const oldUserId = oldUser.userId
if (newUserId === oldUserId) return
const oldUserIdRef = firestore.collection('userIds').doc(oldUserId)
const uid = context.params.uid as string
const batch = firestore.batch()
batch.create(newUserIdRef, {
publishedAt: admin.firestore.FieldValue.serverTimestamp(),
uid,
})
batch.delete(oldUserIdRef)
await batch.commit()
})
あまり説明することがないのですが、/users/{uid}
の作成時はシンプルに新しく /userIds/{userId}
を作成し、更新時は /userIds/{userId}
を作成しつつ古くなったドキュメントを削除しています。
関数トリガーでデータの作成を行っているので、/userIds/{userId}
のデータの作成に関するセキュリティルールの記述はいりません。
さいごに
似たような実装事例がすぐに見つかるかな?と思っていたのですが、案外すぐに見つからなかった(自分の検索方法が悪かった可能性もあるが・・・)ので、自分で設計を考えつつ記事にしてみました!
参考になりましたら幸いです 👍
ちなみに、先日自分が個人開発している Web サービスである AnyMake に、今回解説したユーザー ID 機能を実装しましたので、実プロダクトでの挙動が気になる方はぜひチェックしてみてください 🙌
ユーザー ID を設定できるようになりました! | AnyMake