---
title: "重構與修正邏輯 - Rails 開發實踐"
date: 2023-09-22T00:00:00+08:00
publishDate: 2023-09-22T00:00:00+08:00
lastmod: 2023-09-03T17:33:12+08:00
tags: ["重構","經驗","心得","Rails","Rails 開發實踐"]
series: "rails-in-practice"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/09/22/rails-in-practice-refactor-and-fix-logic/"
language: "zh-tw"
---


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

<!--more-->

## 區分命名與角色{#identity-name-and-role}

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

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

## 建立時間{#add-datetime}

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

```ruby
class Subscription
  # ...
  attribute :started_at, :datetime, default: -> { Time.zone.now }
  attribute :items, array: true, default: -> { [] }

  def expired_in_days
     (([0, started_at - Time.zone.now].max + items.sum.days) / 1.day).ceil
  end

  # ...
end
```

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

```ruby
# app/services/create_subscription_service.rb
class CreateSubscriptionService
  # ...
  def subscribe_for(amount:)
    Subscription.new(user_id: @user_id, started_at: amount.days.from_now)
  end
end
```

```ruby
Given('會員 {word} 已經訂閱，並且在 {int} 天後到期') do |name, amount|
  Subscription.create(user_id: @users[name], started_at: amount.days.from_now)
end

Given('會員 {word} 已經訂閱，並且在 {int} 天前到期') do |name, amount|
  Subscription.create(user_id: @users[name], started_at: amount.days.ago)
end

Given('會員 {word} 有一些訂閱紀錄') do |name, table|
  user_id = @users[name]

  Subscription.create(user_id: user_id, started_at: 0.days.from_now)
  subscription = Subscription.by_user(user_id: user_id).first
  table.hashes.each do |row|
    subscription.extend_with(amount: row['amount'].to_i)
  end
  subscription.save!
end
```

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

```ruby
class CreateSubscriptionService
  # ...
  def subscribe_for(amount:)
    Subscription.new(user_id: @user_id).tap do |subscription|
      subscription.extend_with(amount: amount)
    end
  end
end
```

```ruby
Given('會員 {word} 已經訂閱，並且在 {int} 天後到期') do |name, amount|
  Subscription.new(user_id: @users[name]).tap do |subscription|
    subscription.extend_with(amount: amount)
  end.save!
end
```

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

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

## 抽離天數計算{#extract-days-calculate}

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

```ruby
# app/helpers/subscription_helper.rb

module SubscriptionHelper
  def expired_in_days(subscription:)
    expired_at = subscription.started_at + subscription.items.sum.days
    distance = [0, expired_at - Time.zone.now].max
    (distance / 1.day).ceil
  end
end
```

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

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

