はじめに
2024年4月に株式会社プレックスにエンジニアとして新卒入社した佐藤祐飛と申します。現在はサクミルという建設業界向けのSaaSプロダクト開発を行っています。
Firebase Authentication(以下Firebaseと略します)を利用した認証において、ユーザー作成時に分散トランザクションによってデータの整合性を担保する実装をRuby on Railsで行ったのでその知見について共有したいと思います。
背景
サクミルにおけるユーザー認証について
サクミルではFirebaseを活用したJWTによるユーザー認証を行なっています。ユーザー認証完了後、FirebaseのユーザーUIDを元にサクミルはDB上にあるユーザーデータを提供し、各ユーザーはサクミルにログインすることができます。
ユーザー作成方法について
サクミル管理画面ではアカウント発行機能としてFirebase上へのユーザー作成とサクミルのDB上へのユーザー作成を同時に行う機能を提供しています。サクミル管理画面のAPIにおけるユーザー作成手順を以下に示します。
サクミル管理画面のAPIはRuby on Railsで書かれているのですが、Firebase公式からはRubyのSDKが提供されていないので、google-api-ruby-clientというgemを活用して、Firebase上へのユーザー作成を行なっております。
課題
ユーザーデータの不整合が生じる可能性がある
ユーザーデータがDBとFirebaseという異なる2つのノードに保存されるので、データの不整合が生じる可能性があります。
例えば、Aさんのユーザーデータを作成することを考えます。Firebase上へAさんのユーザーデータを保存することに成功したものの、その後のサクミルのDB上への保存が何らかの原因で失敗した場合、「サクミルDBにはAさんのデータがなく、FirebaseにはAさんのデータがある」という不整合が発生します。
この不整合の状態で、再度サクミル管理画面からAさんのアカウント発行を実行するとFirebase上の一意制約によってアカウント発行の処理が失敗してしまいます。
Firebaseのコミット制御やロールバックができない
いわゆるIDaaSであるFirebaseを利用すると、認証サービスの実装コストや監視コストが下がるというメリットがある反面、デメリットとして認証サービスのカスタマイズ性が低下します。
FirebaseはFirebase内部のDBについて、コミットのタイミング制御やロールバックを行う機能を提供していないので、分散トランザクションの代表的手法である2フェーズコミット(2PC)を行うことができません。
サーガパターンによる整合性担保
上記の課題を解決するために、サーガパターンによる分散トランザクション管理を実装しました。
サーガパターンとは
サーガパターンとは、複数のサービスにまたがるビジネスプロセスを管理し、データの整合性を担保する分散トランザクション手法です。サーガパターンは、各サービスのローカルトランザクションのシーケンスであり、ローカルトランザクションはDBを更新して、次のローカルトランザクションをトリガーします。
ロールバックを実行する場合は、各ローカルトランザクションを取り消す操作として補償トランザクションを実行します。
サクミル管理画面 APIの実装
Firebaseへの操作を管理するFirebaseAdmin
クラスを以下に示します。
工夫したポイントは2点あります。
1点目はFirebaseへの操作を行うメソッド(今回はsign_up_user
)内で補償トランザクションをスタックに積んだことです。
2点目は、補償トランザクションの処理をproc インスタンスとしてスタックに保持させ、rollback
メソッドによってロールバックを行えるようにしたことです。
これらの工夫によって、動的にUIDが変化するロールバックの処理を実現することができます。
class FirebaseAdmin def initialize # HTTP通信のclientを初期化する @client = Google::Apis::IdentitytoolkitV3::IdentityToolkitService.new # 認証 ... # 補償トランザクションの処理を保持するスタック @revert_proc_stack = [] end def sign_up_user(email:, password:) # Firebaseへユーザー作成のリクエストを送信 request = Google::Apis::IdentitytoolkitV3::SignupNewUserRequest.new(email:, password:) response = @client.signup_new_user(request) uid = response.local_id # ユーザー削除の処理(補償トランザクション)をスタックにプッシュする @revert_proc_stack.push(proc { delete_user(uid:) }) uid end def delete_user(uid:) request = Google::Apis::IdentitytoolkitV3::DeleteAccountRequest.new(local_id: uid) @client.delete_account(request) end ... def rollback # スタックが空になるまで補償トランザクションを実行する until @revert_proc_stack.empty? proc = @revert_proc_stack.pop proc.call end end def cleanup @revert_proc_stack.clear end end
以下に、アカウント発行を実行した際に叩かれるcreate_user!
メソッドを示します。
工夫したポイントはActiveRecordのトランザクション内でFirebaseへの操作とDBへの操作を実行し、例外処理でロールバックを行ったことです。例外(FirebaseまたはDBに対する処理の失敗を想定)が発生するとFirebase上のロールバック処理とDBのロールバック処理が実行されることが保証されるため、ユーザーデータの整合性が担保されます。
def create_user!(email:, password:) # FirebaseAdminインスタンスを作成 client = FirebaseAdmin.new begin User.transaction do # Firebase上にユーザーを作成する uid = client.sign_up_user(email:, password:) # DB上にユーザーを作成する user_record = User.create!(..., email:, uid:) ... save! end rescue StandardError => e # 例外をキャッチし、ロールバックを実行する client.rollback raise(e) ensure # スタックのクリーンナップを必ず実行する client.cleanup end ... end
最後に
プレックスではエンジニアを募集しております。ご興味ある方がいらっしゃれば是非連絡をください! dev.plex.co.jp
また、入社エントリを執筆いたしましたのでご覧いただけるととても嬉しいです!