最近deviseをちょっと触ってて自分が特に何も考えずに使っていくとUserモデルがどんどん肥大化していってしまうなぁと思い、、、
deviseのために追加する項目(email
, encrypted_password
等)とプロフィール的な項目(nickname
, birthday
等)でモデルを分けると下記のようなメリットがあって良さそうかなと思ったので、
- 認証とプロフィール的な部分が分離出来るのでUserモデルの肥大化が抑えられる
- 認証システムをdeviseから変更する場合にもアプリケーションで使用するユーザーの情報への影響を防げる
- 認証部分をユーザー情報のカラム定義変更等によるテーブルへのロックの影響から防げる
deviseのコードとかを読んだりしながらいい方法かは微妙ですが、実現方法等を色々考えたことをMEMOしておきます📝
実現したいこと
下記のようにUser
にdeviseに必要な項目を持たせて、User::Profile
にアプリケーションで必要な情報をもたせるような構成を目指していこうと思います。
class User < ApplicationRecord devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :confirmable, :lockable, :trackable, :timeoutable has_one :profile, class_name: 'User::Profile', dependent: :destroy inverse_of: :user end class User::Profile < ApplicationRecord belongs_to :user validates :nickname, presence: true end
なぜdeviseを使っているとUserモデルにカラムを追加したくなってしまうのかの個人的な考え
一旦そもそもdeviseを使っているとなぜUserモデルにすべてを入れてしまいたくなってしまうのかをdeviseのコードを読んで考えてみました、下記がdeviseのユーザー作成時のコードを抜粋したものになっています。
class Devise::RegistrationsController < DeviseController def create build_resource(sign_up_params) resource.save yield resource if block_given? if resource.persisted? if resource.active_for_authentication? set_flash_message! :notice, :signed_up sign_up(resource_name, resource) respond_with resource, location: after_sign_up_path_for(resource) else set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}" expire_data_after_sign_in! respond_with resource, location: after_inactive_sign_up_path_for(resource) end else clean_up_passwords resource set_minimum_password_length respond_with resource end end
一見メソッド内にyield resource if block_given?
があるのでForm必要な項目を追加 + strong_parameterを調整した上で下記のようにControllerのアクションをオーバーライドしてあげれば一応、User
の作成時にUser::Profile
も作成出来そうなのですが、
yield resource if block_given?
前にresource.save
が実行されているため、User
とUser::Profile
のトランザクションが別になってしまうので、バリデーションが必要な項目等がUser::Profile
に含まれているとUser
は作成されるけどUser::Profile
は作成されないといったことが発生する恐れがある気もしました😢
class Users::RegistrationsController < Devise::RegistrationsController def create super do |user| user.create_profile(nickname: sign_up_params[:nickname]) end end end
こんな感じでやるとトランザクションが同じになり、User::Profile
の作成に失敗したときにUser
の作成をロールバックできそうですが、トランザクションの範囲が広いのと、エラーハンドリング周り等は作り込みが必要になります。。。
class Users::RegistrationsController < Devise::RegistrationsController def create ActiveRecord::Base.transaction do super do |user| user.create_profile!(nickname: sign_up_params[:nickname]) end end end end
このような感じでdevise実装を活かしつつ、複数のモデルを扱うのは、deviseの既存実装を追わないとなかなか難しそうな気がしたのでUser
にカラムを追加していくという判断がされやすいのかなと思いました🙇♂️
どうやるのがよさそうか個人的に考えた方法
nested_attributes_forを使う
nested_attributes_for
は、結構ハマりどころが多いのですが今回のケースだと複数のモデルを同一トランザクションで扱いかつ割とスッキリ書けそうかなと・・・!(ちょっとdevise_parameter_sanitizer
のオーバーライドの箇所だけあれですが💦)
ポイントはnested_attributes_for
を使うことで既存実装のresouce.save
で関連モデルも作成出来るとこでしょうか。
class User < ApplicationRecord devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :confirmable, :lockable, :trackable, :timeoutable has_one :profile, class_name: 'User::Profile', dependent: :destroy, required: true, inverse_of: :user accepts_nested_attributes_for :profile end class User::Profile < ApplicationRecord belongs_to :user validates :nickname, presence: true end
class Users::RegistrationsController < Devise::RegistrationsController before_action :configure_sign_up_params, only: [:create] before_action :configure_account_update_params, only: [:update] def new super do |user| user.build_profile # formにprofileの要素を表示するためにbuildしとく end end # If you have extra params to permit, append them to the sanitizer. def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |user| user.permit( *Devise::ParameterSanitizer::DEFAULT_PERMITTED_ATTRIBUTES[:sign_up], :email, profile_attributes: [:nickname, :id] ) end end # If you have extra params to permit, append them to the sanitizer. def configure_account_update_params devise_parameter_sanitizer.permit(:account_update) do |user| user.permit( *Devise::ParameterSanitizer::DEFAULT_PERMITTED_ATTRIBUTES[:account_update], :email, profile_attributes: [:nickname, :id] ) end end end
UserとUser::Profileの更新が同一トランザクションになってます👀
Started POST "/users" for 172.29.0.1 at 2020-08-30 07:34:45 +0000 Processing by Users::RegistrationsController#create as HTML Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"profile_attributes"=>{"nickname"=>"testaaaa"}, "email"=>"test1@example.com", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Sign up"} (0.7ms) BEGIN User Exists? (1.4ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "test1@example.com"], ["LIMIT", 1]] User Create (4.2ms) INSERT INTO "users" ("email", "encrypted_password", "confirmation_token", "confirmation_sent_at", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id" [["email", "test1@example.com"], ["encrypted_password", "$2a$12$2dlJsGlGxCauby1HwZ8VkOmlrVQHHrgdRsm8t2nPCmS6eMujijnyW"], ["confirmation_token", "gezndhZwPZayruyXoxsQ"], ["confirmation_sent_at", "2020-08-30 07:34:46.193200"], ["created_at", "2020-08-30 07:34:46.192922"], ["updated_at", "2020-08-30 07:34:46.192922"]] User::Profile Create (3.2ms) INSERT INTO "user_profiles" ("user_id", "nickname", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["user_id", 118], ["nickname", "testaaaa"], ["created_at", "2020-08-30 07:34:46.202306"], ["updated_at", "2020-08-30 07:34:46.202306"]] (3.1ms) COMMIT
またdeviseで扱うscopeをUser
ではなくてAccount
にして、アプリケーションで管理する情報をUser
に保存してあげてcurrent_user
を下記のようにしてあげると、current_user.attributes
とかをしてもemail
とか認証用に保持している情報が出力されにくくなるので良いのかもしれない🤔
class ApplicationController < ActionController::Base before_action :authenticate_user! def current_user current_account.user end end
deviseのregistrationとは別画面でアプリケーションに必要な情報を入力させる
deviseのユーザー作成ページではあくまで認証で使用するemail
とpassword
の入力だけにしておく(こうしておくとSNS認証とかページのレイアウトを制御出来ない場合にも対応できそう)とdeviseのcontollerをいじらなくて済みますし、ログイン出来るようになるまでに入力項目がたくさんあると、そもそもアカウントを作ってもらえなくなってしまう恐れもあるので入力項目が多い場合は分けたほうがいいのかなと思いました😓
下記のような感じのUser::Profile
に値がなかったらプロフィール入力画面に遷移させるようなメソッドを定義しておいて、before_actionで遷移させるとかするといいのかも知れないですね👀
class ApplicationController < ActionController::Base before_action :authenticate_user! private def required_profile redirect_to new_profile_path if current_user.profile.nil? end end
※deviseのcontrollerはデフォルトだとApplicationController
を継承しているのでrequired_profile
を実行する場所は注意
頑張って既存のメソッドをオーバーライドする
既存のメソッドを頑張ってオーバーライドするのはセキュリティパッチ等があった場合にも自分で対応しないと行けないので割と大変そうなので、なんとか最終手段にしときたいですね😓
おわりに
個人的には最初は既存の作成/更新ロジックに手を入れなくて済むので追加項目が非常に少ないならnested_attributes_for
にしておいて、項目がある程度あるなら画面を分けて、要件的にかなり独自色があり厳しくなってきたら、deviseのコードを理解した上で、メソッドを適切にオーバーライドしつつFormObjectとかにうまいこと切り出してあげるのがいいのかなぁと思いました。
deviseは非常に便利な反面、deviseが想定していないような作りのものを実現しようとすると、割と慎重な判断が求められる部分でもありますし、どう実装するのが良いか適切に判断してくのが難しいですね💦