Cloud Firestoreで変更可能なuser_idを設計する

技術・テクノロジー

一般的な Web サービスにおけるユーザー ID を Firebase の Cloud Firestore で設計する手法についてまとめます。

※本記事のコードは未検証のため、動作しない可能性がありますがご了承ください。

仕様

ユーザー ID の仕様は以下の通りとします。

  • いつでも変更可能
  • 他のユーザーと重複不可
  • 半角英数小文字とアンダースコアのみ許可し、文字数は 3~15 文字

Firestore の設計

では、いきなりですが設計に入ります!

まずユーザー情報を格納する /users コレクションを設計します。ドキュメントの ID は認証したユーザーの uid とします( /users/{uid} )。

とりあえず、あると便利なので createdAtupdatedAt を追加しておきつつ、別に今回の説明ではなくてもいいのですがそれっぽく displayName も追加しておきます。

以上を TypeScript の型で表現すると以下のようになります。

type User = {
  displayName: string
  userId: string
  createdAt: firebase.firestore.Timestamp
  updatedAt: firebase.firestore.Timestamp
}

・・・とまぁ、一般的な DB 設計だとこれで終了、となりそうなんですが、Firestore で厳密にセキュリティルールを記述しつつ、クライアントサイドのみからデータを更新する場合は追加で「ユーザー ID を保存するコレクション」も作成する必要があります。

イメージは /userIds/{userId} みたいなドキュメントで、型は以下のような感じです。

type UserId = {
  uid: string
  createdAt: 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().createdAt == request.time
        && incomingData().updatedAt == request.time;
      allow update: if isAuthenticated()
        && isUserAuthenticated(uid)
        && isValidUser(incomingData())
        && validateString(incomingData().displayName, 1, 40)
        && incomingData().createdAt == existingData().createdAt
        && 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
        && 'createdAt' in user && user.createdAt 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().createdAt == 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().createdAt == existingData().createdAt
        && 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
  createdAt: 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({
      createdAt: 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, {
      createdAt: admin.firestore.FieldValue.serverTimestamp(),
      uid,
    })
    batch.delete(oldUserIdRef)
    await batch.commit()
  })

あまり説明することがないのですが、/users/{uid} の作成時はシンプルに新しく /userIds/{userId} を作成し、更新時は /userIds/{userId} を作成しつつ古くなったドキュメントを削除しています。

関数トリガーでデータの作成を行っているので、/userIds/{userId} のデータの作成に関するセキュリティルールの記述はいりません。

さいごに

似たような実装事例がすぐに見つかるかな?と思っていたのですが、案外すぐに見つからなかった(自分の検索方法が悪かった可能性もあるが・・・)ので、自分で設計を考えつつ記事にしてみました!

参考になりましたら幸いです 👍


ちなみに、先日自分が個人開発している Web サービスである AnyMake に、今回解説したユーザー ID 機能を実装しましたので、実プロダクトでの挙動が気になる方はぜひチェックしてみてください 🙌

ユーザー ID を設定できるようになりました! | AnyMake

参考ページ