[rails] users テーブルに id しか保存しないの気持ちいいーー!!🥳
はじめに
本記事は以下のポストとスライドを見て、実際にその通りにやってみたら個人的にめっちゃ気持ち良かったというだけのただの感想ポエムです。(ほぼ内容をなぞるだけです)
rails 8 の generate authentication
Ruby on Rails 8 に generate authentication という機能が追加されました。rails g authentication
という generate コマンドを打てば、User
モデルクラスと共にメアドとパスワードによるログインやパスワードリマインダーといった基本的な認証機構を生成してくれるというものです。User
モデルクラスを0から作る想定ならサインアップ機能も作ってくれても良かった気がしなくはないですが、パスワードリマインダーなんかはいつも面倒な思いをしながら実装するので、ジェネレーターで楽に生成できるのは非常に良いものです。
このジェネレーターを実装した目的は DHH 曰く、「認証システムはあなたでも書けるから挑戦してみてね(超意訳)」ということを伝えたかったことのようで、実際に生成されるコードは普段自分が書いているコードとあまり変わらない雰囲気で安心したような気がしなくもありません。せっかくなので生成されたコードを直接利用してみようかと思い、「皆この生成コードをどんな感じにカスタマイズしていくんだろう」と調べてみると冒頭に共有した以下の記事が見つかりました。
上記の記事と記事内で紹介されていた Kaigi on Rails 2024 のスライドから、User
モデルのアイデンティティとは何か、User
が持つ情報のテーブル設計を如何様にすべきか、ということを考える機会になりました。
users テーブル、どうやってもキモい問題
私は今まで悪い設計であると頭の片隅にありつつも users テーブルに email
や password_digest
や name
その他をぶち込んでくることが多かったです。ユーザーが入力するプロフィールが多いシステムだと profiles テーブルを設けたりもするのですが、すると name
を取り出す時に User#name
で暗黙的に profiles テーブルから引っ張ってくるのがキモかったり(毎回 @user.profile.name
するのはもっとキモい)、どちらにせよキモくない設計には絶対にならないのが users テーブルというもので、「何でもいいから『この設計が正解!』と誰かデカい声で言ってくれよ!」とずっと思い続けてきました。そんなところに上記の記事とスライドと出会い、「正解を言ってくれたな」という感想を持ちました。勿論、上記のキモい問題が解決した訳ではなく依然としてキモいままですが、ソフトウェア設計というのは常にトレードオフなので「どのキモさを諦めるのか」という絶対的な指針を我々のような一般凡人プログラマーは求めています。規約大好き rails プログラマーは特にそうではないでしょうか。何でもいいからデファクトスタンダードを決めてくれと。そこで私がこの人の設計に付いていこうと思わせてくれたのが上記のスライドです。
id しか持たない users テーブル
嘘です。created_at
は持ちます。何となく t.timestamps
は使いたいので updated_at
も持ちます。まあこれらはオマケみたいなものなので置いておいて(created_at
の役割は全然おまけ程度じゃありませんが)、便宜上「id しか持たない」という表現をします。私は頭が悪いので上記の記事とスライドを読んでも「何を(ユーザー設計の文脈において)アイデンティティと呼ぶのか」についての答えはよく分かりませんでした。1つだけ確かに感じたのはアイデンティティは一意的でイミュータブル(不変)であるべきということです。つまり name
や email
はユーザーが自由に変更にできるので(この文脈に限っては)アイデンティティとは見做されないということです。必然的にそれは主キーとしての役割を果たせることにも繋がる気がします。その超典型例が id
です。users
テーブルにはアイデンティティ足り得る id
だけを保存して、アイデンティティ足り得ない email
や name
は別の場所に保存しようという設計を紹介しているのが上記のスライドです。(私の個人的な解釈で読み込みも甘いので以降も含めて間違っているかもしれません。)
さて、Ruby on Rails 8 の generate authentication は users テーブルに email_address
と password_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_address
と password_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_password
を Credential
モデルに移して belongs_to :user
する形です。ちなみにいつの世のシステムもログイン単位は複数になりがちなので、User
モデル配下に限定されるようなモデルは app/models/user/credential.rb の User::Credential
と名前空間を設けるのが個人的な好みです。そうしないと user_
プレフィックス等を持ったファイルがログイン単位ごとに大量にできるのがしんどいですし、User
名前空間配下では Credential
と言えば言うまでもなく User
の Credential
のことに決まってるよね、という世界観でいられるのでプログラミングが楽になります。
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_password
な User::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 :credential
に delegate
してやれば user.email_address
と馴染み深いアクセスができますが良くも悪くも気軽なコードになってしまうので、あえて user.credential.email_address
のままにしておいて「このコードはユーザー様の大切な認証情報を扱ってるんやで!」というお気持ちをコードから表明させるのも一興ですよね。この記事は冒頭に書いたように tech ではなく idea で個人の感想ポエムなので答えを示して問題を終息させることはしません。「どうする方が気持ちいいんだろうね」で終わりです。
このような形で、users テーブルを拡張する際には name
や birthday
等は user_profiles テーブルに置いたり等して、とにかく users
テーブルにカラムを増やさないように設計していきます。
受ける恩恵(レコード丸ごと物理削除気持ちいいーー!🥳)
さて、ここまで頑張ってきて何が嬉しいのでしょうか。表面的には、シンプルに User
を引いた時にイタズラに email_address
がデータベースから引き出されたり適当に組んだ API の json に乗ってきたりしなくなるので、この時点で結構気持ちいいです。秘匿性の高いデータって必要時以外にあんまり引き出したくないですよね。User.find
した時にずらずらと並ぶ attributes、見るだけで気分が悪いですしその中にメアドや電話番号みたいな秘匿性の高いデータまで含まれてると最悪ですよね。これが無くなるだけでプログラマーのストレス値がかなり下がるような気がします。
ただ、私がスライドを読んで、そして実際に新しくシステムを組んで運用してみて、一番気持ち良かったのはユーザーが退会した時です。なんと何も気にせず Credential
や Profile
を物理削除できるんです。大元となるユーザーのアイデンティティは 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