← 上一章:Rails 程式碼整理術(入門) 下一章:購物車 Part 1 →

Rails 程式碼整理術(進階)

當 Rails 專案成長到一定程度後,如果沒有好好的整理程式碼,很有可能發生重複的程式碼到處散落的情況。在上個章節介紹了使用 Ruby 跟 Rails 內建的方法或設計來整理重複的程式碼,接下來這個章節將介紹如何使用 Ruby 物件導向程式設計的手法來把類似功能的程式碼整理在同一個地方。

使用 Service Object

在 Rails 專案中,大部份的類別幾乎都繼承之 Rails 內建的上層類別,例如 Model 是繼承自 ActiveModel::Base、Controller 是繼承自 ActionController::Base …等,但其實並不是所有類別都必須要這樣設計。我們可以透過寫一些簡單的 Ruby 類別,把一些判斷的邏輯放在裡面。這樣簡單的純 Ruby 類別,通常又稱之 PORO(Plain Old Ruby Object)。

雖然說在 Rails 的 MVC 結構中,Model 好像也適合做這樣的事,但並不是所有的邏輯或功能都找得到一個合適的 Model 來擺放,舉例來說,各位覺得串接外部金流刷卡服務該放在哪個 Model 裡? 而且就算找得到合適的 Model 擺放,也可能會造成 Model 越來越大的情況,對整個專案來說不是好的發展。

幫「胖 Model(Fat Model)」減肥的做法之一,是把跟該 Model 看起來不相關功能的程式碼都拆出去放在另外的 Ruby 類別,需要使用的時候再拉進來使用,這樣除了可增加彈性且更容易重複使用外,這些程式碼也會因為另外拆出來而變得容易測試了。廣義的來說,你也可把 Service Object 也當做 Model 的一種。

設計 Service Object 類別

Service Object 類別聽起來好像很複雜,但說穿了就只是一般的 PORO 類別而已,可能會在 new 的時候帶一些參數做為設定值(非必要),然後會有一些公開的實體方法(public instance method)來操作這些資料或與外部服務串接。舉個例子來說,以下是某個借書服務的片段程式碼:

  class BooksController < ApplicationController
    # ...[略]...
    def borrow
      if @book && @book.is_available?
        @book.borrow!

        # Slack 通知
        notifier = Slack::Notifier.new ENV["WEBHOOK_URL"] do
          defaults channel: ENV["NotifyChannel"], username: ENV["SlackUser"]
        end
        notifier.ping "#{@book.title} 已出借!"

        redirect_to books_path, notice: "已完成預約(出借)!"
      else
        redirect_to books_path, notice: "此書目前無法出借!"
      end
    end
    # ...[略]...
  end

borrow Action 在書本出借的當下,我希望可以發一個通知給管理員人的 Slack 群組。這裡我使用了 slack-notifiery 這個 gem 來發送通知。

上面的寫法可以正常運作沒問題,但因為設定及發送 Slack 通知的程式碼是放在 Controller,所以每次要發送 Slack 通知的時候就得寫這幾行。而這段通知的程式碼似乎放在 Controller 或 Book Model 裡都不太合適,這時候我們可把這個獨立成一個 Service Object。

  1. 請先在 app 目錄下建立一個 services 目錄(不一定要叫這名字)。
  2. app/services 目錄下建立一檔案 slack_notify_service.rb,內容如下:
  # 檔案 app/services/slack_notify_service.rb
  class SlackNotifyService
    def initialize(message = "")
      @message = message
    end

    def perform
      notifier = Slack::Notifier.new ENV["WEBHOOK_URL"] do
        defaults channel: ENV["NotifyChannel"], username: ENV["SlackUser"]
      end

      notifier.ping @message unless @message.empty?
    end
  end

原來 borrow Action 就可改成這樣:

  def borrow
    if @book && @book.is_available?
      @book.borrow!

      # Slack 通知
      service = SlackNotifyService.new("#{@book.title} 已出借!")
      service.perform

      redirect_to books_path, notice: "已完成預約(出借)!"
    else
      redirect_to books_path, notice: "此書目前無法出借!"
    end
  end

爾後如果在其它 Controller 也需要做類似的事,只要像這樣使用 SlackNotifyService 類別即可。

在設計 Service Object 的時候,一種 Service 最好只就做一件事情就好(SRP, Single Responsibility Principle),同時也不要有太多的公開方法,最好是可以統一的公開方法,例如 performcallrunexecute 之類的方法。

以這個例子來說,如果之後還想提供其它的「通知」,例如簡訊通知,可考慮使用 namespace 來整理這些 Service Object,例如原本的 Slack 通知可改成這樣:

# 檔案 app/services/notification/slack_service.rb
  module Notification
    class SlackService
      def initialize(message = "")
        @message = message
      end

      def perform
        # ...[略]...
      end
    end
  end

如果還有簡訊通知服務,可照著 Slack 通知的方式設計:

  # 檔案 app/services/notification/sms_service.rb
  module Notification
    class SmsService
      def initialize(message = "")
        @message = message
      end

      def perform
        # ...[略]...
      end
    end
  end

因為這些 Service Object 統一的公開方法都是 perform,所以之後在用的時候甚至可以寫成這樣:

service = case settings.notify_service
          when "slack"
            Notification::SlackService.new("...")
          when "sms"
            Notification::SmsService.new("...")
          else
            # .. 其它通知方式
          end

service.perform

使用 Form Object

第 12 章有介紹到使用 form_tag 方法來製作表單並依據使用者輸入的身高、體重,計算出 BMI 值。當時的 View 是這樣寫的:

  <h1>BMI 計算機</h1>

  <%= form_tag '/bmi/result' do %>
    身高:<%= text_field_tag 'body_height' %> 公分<br />
    體重:<%= text_field_tag 'body_weight' %> 公斤<br />
    <%= submit_tag "開始計算" %>
  <% end %>

而 Controller 是這樣寫的:

  class BmiController < ApplicationController
    def index
    end

    def result
      height = params[:body_height].to_f / 100   # 把單位換算成公尺
      weight = params[:body_weight].to_f

      # BMI 計算公式: BMI = 體重(單位:公斤) / 身高平方(單位:公尺).
      @bmi = (weight / (height * height)).round(2)
    end
  end

雖然這樣算出來的結果是正確的,但就覺得使用 form_tag 不像使用 form_for 那麼方便,而且也沒辦法直接使用一般 Model 附贈的特異功能,例如 validates 等方法,除此之外,計算 BMI 的邏輯也是散落在 Controller 裡,不方便重複使用。

但 BMI 本身並不需要實際的資料表來存放這些資料(雖然要也是可以),所以要建立一個一般的 Rails Model,讓它去繼承 ActiveRecord::Base 類別好像又有點多餘。

在 Rails 裡,我們可以使用一種稱之 Form Object 的手法,讓一般的 PORO 搖身一變就變成跟一般 ActiveRecord 有類似功能的 Model,而且還不需要特別為它建立資料表。讓我們先新增一個檔案 bmi_calculator.rbapp/models 目錄底下,內容如下:

  # 檔案 app/models/bmi_calculator.rb
  class BmiCalculator
    attr_accessor :body_height, :body_weight
  end

現在看起來只是有二個可讀可寫的屬性(事實上是方法)的普通 PORO 而已,沒什麼了不起的。但只要引入(include)ActiveModel::Model 模組,原本只是一般的 PORO,也有著一般 Rails Model 的特殊功能,例如可使用 validates 來驗證這 body_heightbody_weight 兩個欄位必須存在:

  # 檔案 app/models/bmi_calculator.rb
  class BmiCalculator
    include ActiveModel::Model
    attr_accessor :body_height, :body_weight

    validates :body_height, :body_weight, presence: true
  end

回到 Controller,原本的 BmiController 的 index 方法可改成:

  class BmiController < ApplicationController
    def index
      @bmi_calculator = BmiCalculator.new
    end

    def result
      # ...[略]...
    end
  end

而原本在 View 的 form_tag 也可改用 form_for 來改寫:

  <h1>BMI 計算機</h1>

  <%= form_for @bmi_calculator, url: '/bmi/result' do |f| %>
    身高:<%= f.text_field :body_height %> 公分<br />
    體重:<%= f.text_field :body_weight %> 公斤<br />
    <%= f.submit "開始計算" %>
  <% end %>

重新整理,畫面應該不會有什麼變化,但檢視原始碼就看得出來有些不同了:

  <form class="new_bmi_calculator" id="new_bmi_calculator" action="/bmi/result" accept-charset="UTF-8" method="post">
  <input name="utf8" type="hidden" value="&#x2713;" />
  <input type="hidden" name="authenticity_token" value="6SFQ6yEUx...[略]...bsZbIQ==" />
    身高:<input type="text" name="bmi_calculator[body_height]" id="bmi_calculator_body_height" /> 公分<br />
    體重:<input type="text" name="bmi_calculator[body_weight]" id="bmi_calculator_body_weight" /> 公斤<br />
    <input type="submit" name="commit" value="開始計算" data-disable-with="開始計算" />
  </form>

可以看得出來原本身高跟體重欄位的名字,由原本的 body_heightbody_weight 變成 bmi_calculator[body_height]bmi_calculator[body_weight] 了,當表單送出的時候,這些欄位就會被收到同一個 Hash 裡,用起來就跟一般的 ActiveRecord 的手感差不多。接著來看看 Controller:

  # 檔案 app/controllers/bmi_controller.rb
  class BmiController < ApplicationController
    def index
      @bmi_calculator = BmiCalculator.new
    end

    def result
      @bmi_calculator = BmiCalculator.new(bmi_calculator_params)

      if @bmi_calculator.valid?
        @bmi = @bmi_calculator.perform
      else
        render :index
      end
    end

    private
    def bmi_calculator_params
      params.require(:bmi_calculator).permit(:body_height, :body_weight)
    end
  end

說明:

  1. 跟一般 CRUD 的 Controller 差不多,就是判斷如果有效就進行 BMI 值的計算(以我們這個範例來說是 body_height 以及 body_weight 這兩個欄位是不是有填寫),如果無效,就重新用 render app/views/bmi/index.html.erb 這個 template,效果如下圖。
  2. 把原本在 Controller 那段計算 BMI 的邏輯搬到 Model 裡,在 Controller 只要呼叫 perform 方法便可進行計算。
  3. 別忘了使用 Strong Parameters 的用法,不然在按下送出按鈕後會發生 ActiveModel::ForbiddenAttributesError 的錯誤訊息。

image

最後,Model 的樣子長這樣:

  # 檔案 app/models/bmi_calculator.rb
  class BmiCalculator
    include ActiveModel::Model
    attr_accessor :body_height, :body_weight

    validates :body_height, :body_weight, presence: true

    def perform
      height = body_height.to_f / 100   # 把單位換算成公尺
      weight = body_weight.to_f

      # BMI 計算公式: BMI = 體重(單位:公斤) / 身高平方(單位:公尺).
      (weight / (height * height)).round(2)
    end
  end

經過這樣的整理後,不僅可用到 ActiveRecord Model 附贈的好用功能,也可把邏輯搬到裡面放著,當別的 Controller 也需要用的時候,不用重寫一次。是說,這個 BMI 值的計算不算是一個好的使用 Form Object 手法的例子,不過因為在前面的章節已介紹過而且相對的單純,所以用它來解釋。

小結

整理程式碼的手法很有多種,在上個章節我們介紹了 Rails 內建的方法,例如 View Helper 或是 Concern,或是本章節介紹 Service Object 以及 Form Object,本質上都是把程式碼放到適當的地方。對新手來說,使用 PORO 來整理程式碼一開始會比較抽象,不知從何下手或不知道怎麼寫比較好,但習慣之後就會發現用這個手法可以讓你的程式碼更有組織、更有架構,同時也會發現 Ruby 這個程式語言設計美妙的地方喔。

← 上一章:Rails 程式碼整理術(入門) 下一章:購物車 Part 1 →

Comments