蒼時弦也
蒼時弦也
資深軟體工程師
發表於

重構與修正邏輯 - Rails 開發實踐

到目前為止,我們已經透過驗收測試保護了我們想實作的功能,然而有一些實作如果不儘快重構,很快就會變成難以維護的技術債,因此在提交部分程式碼後,我們需要盡快的對這些地方做處理。

區分命名與角色

我們在實作時,為了方便將 #expired_in_days 方法放在 Model 中,然而這個行為是「顯示」用的行為,更好的方式是以 Rails 的 View Helper 或者 Presenter 物件來處理,因此我們需要進行一個抽離的調整。

除此之外,因為加入了「訂閱紀錄」的機制,原本的 expired_at 欄位似乎也不太適合,改為 started_at 可能更能表達出訂閱機制的性質,因此我們還需要將這個欄位做出一些調整,來符合整個系統的脈絡(Context)

建立時間

我們希望將 expired_at 重新命名為 started_at 來符合新的商業邏輯,我們可以先著手修改 Model 的實作。

 1class Subscription
 2  # ...
 3  attribute :started_at, :datetime, default: -> { Time.zone.now }
 4  attribute :items, array: true, default: -> { [] }
 5
 6  def expired_in_days
 7     (([0, started_at - Time.zone.now].max + items.sum.days) / 1.day).ceil
 8  end
 9
10  # ...
11end

如果試著跑測試,會發現因為我們改為 started_at 後,因為命名改變有許多地方都無法正常運作,因此可以先將命名修正。

1# app/services/create_subscription_service.rb
2class CreateSubscriptionService
3  # ...
4  def subscribe_for(amount:)
5    Subscription.new(user_id: @user_id, started_at: amount.days.from_now)
6  end
7end
 1Given('會員 {word} 已經訂閱,並且在 {int} 天後到期') do |name, amount|
 2  Subscription.create(user_id: @users[name], started_at: amount.days.from_now)
 3end
 4
 5Given('會員 {word} 已經訂閱,並且在 {int} 天前到期') do |name, amount|
 6  Subscription.create(user_id: @users[name], started_at: amount.days.ago)
 7end
 8
 9Given('會員 {word} 有一些訂閱紀錄') do |name, table|
10  user_id = @users[name]
11
12  Subscription.create(user_id: user_id, started_at: 0.days.from_now)
13  subscription = Subscription.by_user(user_id: user_id).first
14  table.hashes.each do |row|
15    subscription.extend_with(amount: row['amount'].to_i)
16  end
17  subscription.save!
18end

不過這些調整也讓原本的 expired_at 的意義跟原本有點出入,因此我們還需要將 started_at 的時間修改為正確的樣子,搭配 #extend_with 方法來擴充正確的時間。

1class CreateSubscriptionService
2  # ...
3  def subscribe_for(amount:)
4    Subscription.new(user_id: @user_id).tap do |subscription|
5      subscription.extend_with(amount: amount)
6    end
7  end
8end
1Given('會員 {word} 已經訂閱,並且在 {int} 天後到期') do |name, amount|
2  Subscription.new(user_id: @users[name]).tap do |subscription|
3    subscription.extend_with(amount: amount)
4  end.save!
5end

CreateSubscriptionService 的部分,為了減少一次性過多的修改,我們先維持介面 #subscribe_for 的行為,利用 Ruby 的特性 #tap 暫時性的接續呼叫後續的處理來加入訂閱項目。

Rails 也預期可能會這樣使用,像是 User.create { |u| u.admin = ture } 之類的,在這邊因為我們沒有特別實作這樣的機制,因此利用 #tap 來達成類似的方法。

抽離天數計算

我們將 #expired_at 轉換成 #started_at 後,就可以著手把計算過期日期的實作抽離出來,用 View Helper 來維護,避免 Subscription 中有額外的邏輯影響使用。

1# app/helpers/subscription_helper.rb
2
3module SubscriptionHelper
4  def expired_in_days(subscription:)
5    expired_at = subscription.started_at + subscription.items.sum.days
6    distance = [0, expired_at - Time.zone.now].max
7    (distance / 1.day).ceil
8  end
9end

原本的 #exired_in_days 方法也不太能明確的解釋意圖,在抽離的過程中也一起重構成比較明確的版本,讓脈絡更加清晰。

最後,清除掉 Model 上的實作,以及調整 View 之後,運行測試會發現測試恢復正常,這表示我們在沒有破壞產品的功能的狀態下,改善了原有的程式碼實作,以及調整了資料儲存的方式。