以下の内容はhttps://madogiwa0124.hatenablog.com/entry/2020/09/05/124230より取得しました。


Ruby on Rails: deviseが使用する項目とユーザー情報のモデルを分離して肥大化を抑制するMEMO

最近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が実行されているため、UserUser::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のユーザー作成ページではあくまで認証で使用するemailpasswordの入力だけにしておく(こうしておくと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が想定していないような作りのものを実現しようとすると、割と慎重な判断が求められる部分でもありますし、どう実装するのが良いか適切に判断してくのが難しいですね💦

参考

medium.com

qiita.com

rubydoc.info




以上の内容はhttps://madogiwa0124.hatenablog.com/entry/2020/09/05/124230より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14