Rails4 Patterns I: Models

今天起春節希望每天介紹 Rails4 Patterns 內的一個章節給大家,這是一個在 Code School 的課程,原址:https://www.codeschool.com/courses/rails-4-patterns

肥肥的 Controller 是不好的

原因有幾個:

  • 會難以理解
  • 商業邏輯難以統整
  • 程式碼間容易衝突
  • 難以開發新功能

像是以下程式碼

class ItemsController < ApplicationController
  def publish
    if @item.is_approved?
      @item.published_on = Time.now

      if @item.save
        flash[:notice] = "Your item published!"
      else
        flash[:notice] = "There was an error."
      end
    else
      flash[:notice] = "There was an error."
    end

    redirect_to @item
  end
end

當時間的推進,應用的發展,條件式只會越來越複雜

if @item.is_approved? # 原先的條件式
# ->
if @item.is_approved? && @iterm.user != "Loblaw" # 新增條件

這裡切記一個原則:「Tell, Don't Ask」,應該要直接的告訴物件它要做什麼事情。而不是透過問句的方式去確認目前它的狀態是什麼後,再決定作什麼事情。

商業邏輯該丟到 Models 內

如剛剛的 controller,我們可以將邏輯性的功能包裝丟至 model 內,那對 controller 而言就可以符合 Tell, Don't Ask 原則。

# app/controllers/items_controller.rb
class ItemsController < ApplicationController
  def publish
    if @item.publish # 明確知道這邊的動作是將 item 發佈
      flash[:notice] = "Your item published!"
    else
      flash[:notice] = "There was an error."
    end

    redirect_to @item
  end
end
# app/models/item.rb
class Item < ActiveRecord::Base
   belongs_to :user

   def publish
     if !is_approved? || user == 'Loblaw'
       return false
     end

     self.published_on = Time.now
     self.save
   end
end

函式為確保是簡潔與可讀性的程式碼,特別將 if-else 的邏輯單純化,如果一個 if 就能乾淨俐落,為什麼還要來個 else,大家都愛直線,不要拐彎抹角阿!

所以這邊才會是

if !is_approved? || user == 'Loblaw'
  return false
end
...

而非

# wrong case
if is_approved? || user != 'Loblaw'
  ...
else
  return false
end

避免在 Callbacks 中呼叫其他功用的物件

因為 callback 本身就像是一個物件生命週期的一個環節,如果牽扯到其他的物件,不只讓人在看程式碼會覺得有點跳,甚至因為交流密切,間接影響到物件的資料庫運作週期。

class User < ActiveRecord::Base
  before_create :set_token

  protected
    def set_token
      self.token = TokenGenerator.create(self) # another class object
  end
end

為此建立一個客製化的函式

# app/models/user.rb
class User < ActiveRecord::Base
  def register # 另外拉一個函式,專作註冊
    self.token = TokenGenerator.create(self)
    save
  end
end

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.register # 使用客製化的函式
      redirect_to @user, notice: 'Success'
    else
      ...
    end
  end
end

Callbacks 應該是用來作為更改欄位值或其他內在因素為主

# app/models/user.rb
class User < ActiveRecord::Base
  before_create :set_name

  protected
    def set_name
      self.name = self.login.capitalize if name.blank?
    end
end

也要特別留意,callbacks 放在 protected 內是 Rails 的 Convention,務必要遵守

不是所有的 Models 都一定要是 ActiveRecord

以被檢舉的使用者需要的停權動作為例,裡面牽扯到許多要考慮的層面,例如停權後就不能發言、不能登入等等

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def suspend
    @user = User.find(params[:id])
    @user.suspend!

    redirect_to @user, notice: 'Successfully suspended.'
  end
end

# app/models/user.rb
class User < ActiveRecord::Base
  has_many :items
  has_many :reviews

  def suspend!
    self.class.transaction do # transaction 包起來可以讓這些動作對於資料庫來說只是一件事情
      self.update!(is_approved: false)
      self.items.each { |item| item.update!(is_approved: false) }
      self.reviews.each { |review| review.update!(is_approved: false) }
    end
  end
end

以上例來說,問題是同個函式內包含太多的邏輯

同個 class 裡面太多邏輯,可以拆成各自負責的工作。也要注意函式牽扯到太多其他的物件,可能會導致「God Object」現象發生,就是指作太多事情的意思。

class User < ActiveRecord::Base
  ...
  def suspend!
    self.class.transaction do
      disapprove_user!
      disapprove_items!
      disapprove_reviews!
    end
  end

  def disapprove_user!
  def disapprove_items!
  def disapprove_reviews!
end

抽象化:不是所有跟使用者有關係就得都在使用者的 Model 內

以停權使用者這件事情來說,抽象化有幾個好處:

  • PORO: Plain Old Ruby Object
  • 只需一個正規的 instance method
  • 這個類別就只需要專注一件事情就好
# app/models/user_suspension.rb
class UserSuspension
  def initialize(user)
    @user = user
  end

  def create!
    @user.class.transaction do
      disapprove_user!
      disapprove_items!
      disapprove_reviews!
    end
  end

  private
    def disapprove_user!
    def disapprove_items!
    def disapprove_reviews!
end

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def suspend
    @user = User.find(params[:id])
    suspension = UserSuspension.new(@user)
    suspension.create!

    redirect_to @user, notice: 'Successfully suspended.'
  end
end

其他常見的抽象化:

  • UserRegistration
  • PlanSubscription
  • CreditCardCharge
  • ShoppingCart

小結一下,所以常會使用非 ActiveRecord 的方式將獨立且唯一的商業邏輯搬出來成一個新的類別。