← 上一章: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。
- 請先在
app
目錄下建立一個services
目錄(不一定要叫這名字)。 - 在
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),同時也不要有太多的公開方法,最好是可以統一的公開方法,例如 perform
、call
、run
或 execute
之類的方法。
以這個例子來說,如果之後還想提供其它的「通知」,例如簡訊通知,可考慮使用 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.rb
到 app/models
目錄底下,內容如下:
# 檔案 app/models/bmi_calculator.rb
class BmiCalculator
attr_accessor :body_height, :body_weight
end
現在看起來只是有二個可讀可寫的屬性(事實上是方法)的普通 PORO 而已,沒什麼了不起的。但只要引入(include)ActiveModel::Model
模組,原本只是一般的 PORO,也有著一般 Rails Model 的特殊功能,例如可使用 validates
來驗證這 body_height
跟 body_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="✓" />
<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_height
跟 body_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
說明:
- 跟一般 CRUD 的 Controller 差不多,就是判斷如果有效就進行 BMI 值的計算(以我們這個範例來說是
body_height
以及body_weight
這兩個欄位是不是有填寫),如果無效,就重新用 renderapp/views/bmi/index.html.erb
這個 template,效果如下圖。 - 把原本在 Controller 那段計算 BMI 的邏輯搬到 Model 裡,在 Controller 只要呼叫
perform
方法便可進行計算。 - 別忘了使用 Strong Parameters 的用法,不然在按下送出按鈕後會發生
ActiveModel::ForbiddenAttributesError
的錯誤訊息。
最後,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 這個程式語言設計美妙的地方喔。
Comments