機能を見極めてmoduleに抜き出す
restful_authenticationは、ログイン認証という限定された機能を、適度なサイズで提供してくれている。ログイン認証には多くのRailsの技が駆使される傾向にあり、コードを眺めていると勉強になることが多い。今回はモジュールの使い方に注目してみた。
- restful_authentication classic版のUserモデルの実装は以下のようになっている。
- 保存データの検証、アクティベーションに関する処理、パスワードのエンコード・デコード・照合、ログイン状態の維持等、とても多くのことをこなしている。
# ---------- app/models/user.rb ---------- require 'digest/sha1' class User < ActiveRecord::Base # Virtual attribute for the unencrypted password attr_accessor :password validates_presence_of :login, :email validates_presence_of :password, :if => :password_required? validates_presence_of :password_confirmation, :if => :password_required? validates_length_of :password, :within => 4..40, :if => :password_required? validates_confirmation_of :password, :if => :password_required? validates_length_of :login, :within => 3..40 validates_length_of :email, :within => 3..100 validates_uniqueness_of :login, :email, :case_sensitive => false before_save :encrypt_password before_create :make_activation_code # prevents a user from submitting a crafted form that bypasses activation # anything else you want your user to change should be added here. attr_accessible :login, :email, :password, :password_confirmation # Activates the user in the database. def activate @activated = true self.activated_at = Time.now.utc self.activation_code = nil save(false) end def active? # the existence of an activation code means they have not activated yet activation_code.nil? end # Authenticates a user by their login name and unencrypted password. Returns the user or nil. def self.authenticate(login, password) u = find :first, :conditions => ['login = ? and activated_at IS NOT NULL', login] # need to get the salt u && u.authenticated?(password) ? u : nil end # Encrypts some data with the salt. def self.encrypt(password, salt) Digest::SHA1.hexdigest("--#{salt}--#{password}--") end # Encrypts the password with the user salt def encrypt(password) self.class.encrypt(password, salt) end def authenticated?(password) crypted_password == encrypt(password) end def remember_token? remember_token_expires_at && Time.now.utc < remember_token_expires_at end # These create and unset the fields required for remembering users between browser closes def remember_me remember_me_for 2.weeks end def remember_me_for(time) remember_me_until time.from_now.utc end def remember_me_until(time) self.remember_token_expires_at = time self.remember_token = encrypt("#{email}--#{remember_token_expires_at}") save(false) end def forget_me self.remember_token_expires_at = nil self.remember_token = nil save(false) end # Returns true if the user has just been activated. def recently_activated? @activated end protected # before filter def encrypt_password return if password.blank? self.salt = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{login}--") if new_record? self.crypted_password = encrypt(password) end def password_required? crypted_password.blank? || !password.blank? end def make_activation_code self.activation_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join ) end end
- 上記コードをじっと見つめて、ログイン状態の維持ということに注目してみると、5つのメソッドが関係していることが読み取れる。
- moduleに抜き出してみると、以下のように簡単に抜き出すことができる。
# ---------- lib/by_remember.rb ---------- module ByRemember def remember_token? remember_token_expires_at && Time.now.utc < remember_token_expires_at end # These create and unset the fields required for remembering users between browser closes def remember_me remember_me_for 2.weeks end def remember_me_for(time) remember_me_until time.from_now.utc end def remember_me_until(time) self.remember_token_expires_at = time self.remember_token = encrypt("#{email}--#{remember_token_expires_at}") save(false) end def forget_me self.remember_token_expires_at = nil self.remember_token = nil save(false) end end
- 同じように今度は、パスワードに関する処理ということに注目してみる。
- しかし今度は前回と違って、単純にはmoduleに抜き出すことが出来ない。
- クラスメソッド定義が混じっていたり、
- beforeフィルターがあったり、
- 保存データの検証をしているからだ。
- そこで役立つのがincludeする時にクラスメソッドの呼び出しと定義を行うmodule書式だ。その書式に当てはめて、以下のようにしてみた。
# ---------- lib/by_password.rb ---------- module ByPassword def self.included( recipient ) recipient.extend( ModelClassMethods ) recipient.class_eval do include ModelInstanceMethods # Virtual attribute for the unencrypted password attr_accessor :password validates_presence_of :password, :if => :password_required? validates_presence_of :password_confirmation, :if => :password_required? validates_length_of :password, :within => 4..40, :if => :password_required? validates_confirmation_of :password, :if => :password_required? before_save :encrypt_password end end module ModelClassMethods # Authenticates a user by their login name and unencrypted password. Returns the user or nil. def authenticate(login, password) u = find :first, :conditions => ['login = ? and activated_at IS NOT NULL', login] # need to get the salt u && u.authenticated?(password) ? u : nil end # Encrypts some data with the salt. def encrypt(password, salt) Digest::SHA1.hexdigest("--#{salt}--#{password}--") end end # class methods module ModelInstanceMethods # Encrypts the password with the user salt def encrypt(password) self.class.encrypt(password, salt) end def authenticated?(password) crypted_password == encrypt(password) end protected # before filter def encrypt_password return if password.blank? self.salt = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{login}--") if new_record? self.crypted_password = encrypt(password) end def password_required? crypted_password.blank? || !password.blank? end end # instance methods end
- Userモデルに残ったコードは、以下のようになった。password以外の検証とアクティベーションに関する処理だけのシンプルさ。非常にスッキリした。
- 抜き出したモジュールはincludeしてみた。
# ---------- app/models/user.rb ---------- require 'digest/sha1' class User < ActiveRecord::Base include ByPassword include ByRemember validates_presence_of :login, :email validates_length_of :login, :within => 3..40 validates_length_of :email, :within => 3..100 validates_uniqueness_of :login, :email, :case_sensitive => false before_create :make_activation_code # prevents a user from submitting a crafted form that bypasses activation # anything else you want your user to change should be added here. attr_accessible :login, :email, :password, :password_confirmation # Activates the user in the database. def activate @activated = true self.activated_at = Time.now.utc self.activation_code = nil save(false) end def active? # the existence of an activation code means they have not activated yet activation_code.nil? end # Returns true if the user has just been activated. def recently_activated? @activated end protected def make_activation_code self.activation_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join ) end end
- このように機能別にmoduleにまとめておくと、関連するメソッドが近くに集まるのでコードを読み易くなる?
- しかし、二つ以上のmoduleに共通のメソッド等が存在する場合はどうするべきか?
- 上記の例では、ByRememberモジュールのremember_me_untilメソッドでは、ByPasswordモジュールのencryptメソッドを呼び出している。この状況はあまり宜しくないと思う。
- その場合は、モジュール名を階層化して、共通するメソッドを上位のモジュールに抽出するのが良さそう。*1
- そのようなことを考えながら、以下のように変更してみた。
- ハッシュコードの生成方法を工夫すれば、もっとシンプルにできるかもしれない。
- remember_token、salt、activation_codeを生成する部分はちょっとずつ違うけど、コードの意味合い*2としては、とてもよく似ている。
# ---------- app/models/user.rb ---------- require 'digest/sha1' class User < ActiveRecord::Base extend Authentication include Authentication::ByPassword include Authentication::ByRemember ...(中略)... protected def make_activation_code self.activation_code = self.class.encrypt( Time.now.to_s.split(//).sort_by {rand}.join ) end end # ---------- lib/authentication.rb ---------- module Authentication def encrypt(*args) Digest::SHA1.hexdigest("--#{args.join('--')}--") end end # ---------- lib/authentication/by_remember.rb ---------- module Authentication module ByRemember ...(中略)... def remember_me_until(time) self.remember_token_expires_at = time self.remember_token = self.class.encrypt("#{email}--#{remember_token_expires_at}") save(false) end end # ---------- lib/authentication/by_password.rb ---------- module Authentication module ByPassword ...(中略)... module ModelInstanceMethods def authenticated?(password) crypted_password == self.class.encrypt(salt, password) end protected # before filter def encrypt_password return if password.blank? self.salt = self.class.encrypt("--#{Time.now.to_s}--#{login}--") if new_record? self.crypted_password = self.class.encrypt(salt, password) end def password_required? crypted_password.blank? || !password.blank? end end # instance methods end end
- まるで因数分解で共通項を抜き出していくような感じだ。
lib |-authentication.rb |-authentication/ |-by_password.rb |-by_remember.rb
- 深すぎる階層はコード全体の見通しを悪くするかもしれないが、このくらいの階層ならコードがとても読み易くなる。
restful_authentication master版は、このようなmoduleの階層化とメソッド定義の内容を吟味して、セキュリティとメンテナンス性が向上したより洗練されたコードになっていた。