💊

[rails] users テーブルに id しか保存しないの気持ちいいーー!!🥳

2024/12/11に公開

はじめに

本記事は以下のポストとスライドを見て、実際にその通りにやってみたら個人的にめっちゃ気持ち良かったというだけのただの感想ポエムです。(ほぼ内容をなぞるだけです)

https://kinoppyd.dev/blog/rails8-authentication/

https://speakerdeck.com/moro/identifying-user-idenity

rails 8 の generate authentication

Ruby on Rails 8 に generate authentication という機能が追加されました。rails g authentication という generate コマンドを打てば、User モデルクラスと共にメアドとパスワードによるログインやパスワードリマインダーといった基本的な認証機構を生成してくれるというものです。User モデルクラスを0から作る想定ならサインアップ機能も作ってくれても良かった気がしなくはないですが、パスワードリマインダーなんかはいつも面倒な思いをしながら実装するので、ジェネレーターで楽に生成できるのは非常に良いものです。

このジェネレーターを実装した目的は DHH 曰く、「認証システムはあなたでも書けるから挑戦してみてね(超意訳)」ということを伝えたかったことのようで、実際に生成されるコードは普段自分が書いているコードとあまり変わらない雰囲気で安心したような気がしなくもありません。せっかくなので生成されたコードを直接利用してみようかと思い、「皆この生成コードをどんな感じにカスタマイズしていくんだろう」と調べてみると冒頭に共有した以下の記事が見つかりました。

https://kinoppyd.dev/blog/rails8-authentication/

上記の記事と記事内で紹介されていた Kaigi on Rails 2024 のスライドから、User モデルのアイデンティティとは何か、User が持つ情報のテーブル設計を如何様にすべきか、ということを考える機会になりました。

users テーブル、どうやってもキモい問題

私は今まで悪い設計であると頭の片隅にありつつも users テーブルに emailpassword_digestname その他をぶち込んでくることが多かったです。ユーザーが入力するプロフィールが多いシステムだと profiles テーブルを設けたりもするのですが、すると name を取り出す時に User#name で暗黙的に profiles テーブルから引っ張ってくるのがキモかったり(毎回 @user.profile.name するのはもっとキモい)、どちらにせよキモくない設計には絶対にならないのが users テーブルというもので、「何でもいいから『この設計が正解!』と誰かデカい声で言ってくれよ!」とずっと思い続けてきました。そんなところに上記の記事とスライドと出会い、「正解を言ってくれたな」という感想を持ちました。勿論、上記のキモい問題が解決した訳ではなく依然としてキモいままですが、ソフトウェア設計というのは常にトレードオフなので「どのキモさを諦めるのか」という絶対的な指針を我々のような一般凡人プログラマーは求めています。規約大好き rails プログラマーは特にそうではないでしょうか。何でもいいからデファクトスタンダードを決めてくれと。そこで私がこの人の設計に付いていこうと思わせてくれたのが上記のスライドです。

id しか持たない users テーブル

嘘です。created_at は持ちます。何となく t.timestamps は使いたいので updated_at も持ちます。まあこれらはオマケみたいなものなので置いておいて(created_at の役割は全然おまけ程度じゃありませんが)、便宜上「id しか持たない」という表現をします。私は頭が悪いので上記の記事とスライドを読んでも「何を(ユーザー設計の文脈において)アイデンティティと呼ぶのか」についての答えはよく分かりませんでした。1つだけ確かに感じたのはアイデンティティは一意的でイミュータブル(不変)であるべきということです。つまり nameemail はユーザーが自由に変更にできるので(この文脈に限っては)アイデンティティとは見做されないということです。必然的にそれは主キーとしての役割を果たせることにも繋がる気がします。その超典型例が id です。users テーブルにはアイデンティティ足り得る id だけを保存して、アイデンティティ足り得ない emailname は別の場所に保存しようという設計を紹介しているのが上記のスライドです。(私の個人的な解釈で読み込みも甘いので以降も含めて間違っているかもしれません。)

さて、Ruby on Rails 8 の generate authentication は users テーブルに email_addresspassword_digest の認証情報カラムを持たせるように以下のような migration ファイルを作ります。

class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users do |t|
      t.string :email_address, null: false
      t.string :password_digest, null: false
      t.timestamps
    end
    add_index :users, :email_address, unique: true
  end
end

対して、スライドに従って users テーブルではアイデンティティのみを保存するとするならば、以下のように email_addresspassword_digest を user_credentials テーブルに逃がすことになります。

class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users do |t|
      t.timestamps
    end
  end
end

class CreateUserCredentials < ActiveRecord::Migration[8.0]
  def change
    create_table :user_credentials do |t|
      t.belongs_to :user, null: false, foreign_key: true
      t.string :email_address, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
    add_index :user_credentials, :email_address, unique: true
  end
end

モデルクラスの分離

これに従ってモデルクラスを作っていくと、User モデルと Credential モデルは以下のようになります。つまり、.has_secure_passwordCredential モデルに移して belongs_to :user する形です。ちなみにいつの世のシステムもログイン単位は複数になりがちなので、User モデル配下に限定されるようなモデルは app/models/user/credential.rb の User::Credential と名前空間を設けるのが個人的な好みです。そうしないと user_ プレフィックス等を持ったファイルがログイン単位ごとに大量にできるのがしんどいですし、User 名前空間配下では Credential と言えば言うまでもなく UserCredential のことに決まってるよね、という世界観でいられるのでプログラミングが楽になります。

class User < ApplicationRecord
  has_one :credential, class_name: "User::Credential", dependent: :destroy
  has_many :sessions, dependent: :destroy
end

class User::Credential < ApplicationRecord
  belongs_to :user

  has_secure_password

  normalizes :email_address, with: ->(e) { e.strip.downcase }
end

テーブル設計が変わったので generate authentication が生成したコードのあちこちには以下のような修正をしていくことになります。これは認証プロセスの authenticate_by のレシーバーを User クラスから .has_secure_passwordUser::Credential クラスに変更する例です。

-    if user = User.authenticate_by(params.permit(:email_address, :password))
+    if user = User::Credential.authenticate_by(params.permit(:email_address, :password))&.user

コードを置き換えていく過程で若干のキモさを抱えることはやはりあります。例えば Action Mailer のメール送信先のメアドを引っ張る時のこれ。

-    mail subject: "Reset your password", to: user.email_address
+    mail subject: "Reset your password", to: user.credential.email_address

メアドを user.email_address から user.credential.email_address に変えるか問題です。User モデルから has_one :credentialdelegate してやれば user.email_address と馴染み深いアクセスができますが良くも悪くも気軽なコードになってしまうので、あえて user.credential.email_address のままにしておいて「このコードはユーザー様の大切な認証情報を扱ってるんやで!」というお気持ちをコードから表明させるのも一興ですよね。この記事は冒頭に書いたように tech ではなく idea で個人の感想ポエムなので答えを示して問題を終息させることはしません。「どうする方が気持ちいいんだろうね」で終わりです。

このような形で、users テーブルを拡張する際には namebirthday 等は user_profiles テーブルに置いたり等して、とにかく users テーブルにカラムを増やさないように設計していきます。

受ける恩恵(レコード丸ごと物理削除気持ちいいーー!🥳)

さて、ここまで頑張ってきて何が嬉しいのでしょうか。表面的には、シンプルに User を引いた時にイタズラに email_address がデータベースから引き出されたり適当に組んだ API の json に乗ってきたりしなくなるので、この時点で結構気持ちいいです。秘匿性の高いデータって必要時以外にあんまり引き出したくないですよね。User.find した時にずらずらと並ぶ attributes、見るだけで気分が悪いですしその中にメアドや電話番号みたいな秘匿性の高いデータまで含まれてると最悪ですよね。これが無くなるだけでプログラマーのストレス値がかなり下がるような気がします。

ただ、私がスライドを読んで、そして実際に新しくシステムを組んで運用してみて、一番気持ち良かったのはユーザーが退会した時です。なんと何も気にせず CredentialProfile を物理削除できるんです。大元となるユーザーのアイデンティティは users テーブルの id が全てを担っているので、user_credentials テーブルのレコードも、user_profiles テーブルのレコードも、退会されたら物理削除してしまえば良いんです。これには目から鱗が落ちました。これはただの私のお気持ちの問題で、物理削除するデータ設計は他にもやったことはあるし結果論同じような設計に辿り着いたことはあるのですが、スライドが「存在した、というアイデンティティは消えない」と明言してくれたことにより、users テーブルにレコードが残り続ける罪悪感や、なんか色々考えた末に結局 deleted みたいな論理削除に行き着いたり、各種属性にせっせと NULL を詰め込んでいったりするような設計を右往左往していた私のお気持ちが少し軽くなりました。そうだよな、「存在した」というアイデンティティは消えないよな(よくわかんないけど)。スライドのあちこちから垣間見える隠しきれない知性、それを持つ人物がそう言っているのだから間違いない、と考えられるようになりました。本当はちょっとだけ嘘です。未だに「アイデンティティ」を退会後もシステムが担保することのモヤモヤは心の中に結構あります。ただ、プログラミングにはお気持ちと説得力が何より大事で「俺より賢い人がそう言っている」の事実が俺の責を軽くするので良いんです。

まあ良いとして、これね、実際にやってみると超気持ちいいんですよ。退会の実行プロセス中にどこにも NULL を詰めなくて良いんです。deleted みたいな属性を変更することもありません。全て destroy! で完結するんです。Users#destroy という DELETE メソッドのエンドポイントで destroy しか実行せずに済む、その事実が何より気持ちいいんです。字面ではこえーことしてるって感じがしますが、実際にプログラミングしてみると超気持ちいいんです。こういう思い切ったコードを書いていると何かから解放された気分になります。

@user.profile.destroy!
@user.credential.destroy!

とはいえ退会ユーザーの考慮は当然として必要なので後処理は必要です。昨日この記事を読んで昨日今日でちょっとしたシステムを組んでみただけなので大したユースケースを考慮できている訳ではありませんが、例えば何かへのコメント機能があった場合にコメントは消したくないけど名前は出したくない、みたいな場合は User#name をこんな感じにしておくことになります。

def name
  profile&.name || "退会済みユーザー"
end

現実問題、has_one :profile が存在しない可能性をシステム全体で常に考慮しなければならないのは結構ストレスがかかるんですが、上記の User#name のようにできるだけ User モデルで吸収するようにしておけば、言うほど混沌としないんじゃないかと思っています。まあ先ほどの user.credential.email_address の件と早くも矛盾しますし、ユーザーの一覧を引き出す時に退会ユーザーを弾くためには結局 User.scope :active, -> { joins(:credential) } を噛まなきゃいけないみたいな、何かしらどこかしらでモヤる面倒を背負うのは変わりませんが、この例に関しては認証情報を持っているか否かで判定できるのは多少気持ちいいですよね。まあ完璧な設計はなく何事もキモさのトレードオフなので言い出せば気になる事象はいくらでもありますが、諸々を考えた結果として私はここまで述べた設計が一番気持ちいいと思いました。

おわりに

そんな感じで、冒頭の素晴らしい記事やスライドに従って users テーブルから id 以外の情報を締め出してみたら気持ち良かった、ということでした。本記事では触れませんが、スライド内のサインアップでまだメールを送っただけ等の仮登録ユーザーを別テーブルに保存しない設計手法も非常に良いな、と思いました。普段から仮登録ユーザーを別テーブルに置くことはありましたが「正規ユーザーとなった瞬間に初めてシステム目線でユーザーとしてのアイデンティティが生まれる」という考え方や心持ちそのものが設計への説得力が増してお気持ち1つで美しさが増したなって思いました。しかし結局は上述したように退会したユーザーのアイデンティティは本当にシステムが担保すべきかという心残りもあるので頭のモヤモヤは完全には晴れませんが、少なくとも今の私がユーザーのアイデンティティとデータ設計を考えるにあたってはスライドの内容が現状この上ない指針となりました。私としては結構曲解してそうな自覚はあって、ご本人様達にそんなつもりで書いてねーよって思われてしまいそうな内容も本記事内にはありそうですが、私は勝手に納得感を覚えて気持ちいいので許してください。ともあれ、プログラミングは所詮お気持ちで行う所作であり、思想をコードと設計にいかに美しく説得力を持って表現できるかを競うゲームなので(言い過ぎ)、その点で今回の学びは非常にためになりました。

記事の kinoppyd 様、スライドの MOROHASHI Kyosuke 様、ありがとうございました。

Discussion