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

聚合多筆資料 - Rails 開發實踐

大多數的訂閱功能都會希望有紀錄的機制,假設我們也收到了同樣的需求。那麼,我們現在除了訂閱之外,還需要加入「訂閱紀錄」的設計,讓我們的使用者可以知道訂閱了多少次,或者可以手動延展訂閱。

加入規格

現有的規格已經無法滿足新的情境,因此我們需要加入新的規格進行驗證這件事情,繼續補充規格文件,加入新的條件。

 1#language:zh-TW
 2# ...
 3場景: 假設 Aotoki 有兩筆 30 天的訂閱,可以看到 60 天後到期
 4    假設 會員 Aotoki 有一些訂閱紀錄
 5      | amount |
 6      | 30     |
 7      | 30     |
 8     我打開訂閱狀態頁面
 9    那麼 我會看到 2 次 "延展 30 天"
10    並且 我會看到 "60 天後到期"

這一次我們希望在訂閱狀態的畫面上,除了到期天數之外,還會有訂閱了幾次的紀錄。要實現這樣的機制,我們可以利用 Aggregate(聚合)的特性處理,如果有寫過 SQL 的話可能不陌生,像是 SELECT SUM(level) FROM player 就是一種聚合的使用。

紀錄延展

為了要實現這個功能,我們需要針對延展的時間進行紀錄,在開始實作之前我們可以思考如何在 Cucumber 中實現建立這類假資料的行為,在回去修改我們的 Model 行為。

 1# features/step_definitions/subscription.rb
 2
 3# ...
 4
 5Given('會員 {word} 有一些訂閱紀錄') do |name, table|
 6  user_id = @users[name]
 7
 8  Subscription.create(user_id: user_id, expired_at: 0.days.from_now)
 9  subscription = Subscription.by_user(user_id: user_id).first
10  table.hashes.each do |row|
11    subscription.extend_with(amount: row['amount'].to_i)
12  end
13  subscription.save!
14end
15
16# ...
17
18Then('我會看到 {int} 次 {string}') do |count, text|
19  expect(page).to have_text(text, count: count)
20end

在定義會員有多個訂閱紀錄的步驟定義中,我們會幫使用者產生一個「馬上到期」的訂閱,然後用 #extend_with 方法加入兩筆訂閱紀錄。

在 Aggregate 的使用中,所有屬於 Aggregate 底下的物件都會由這個 Aggregate 物件所控管,在我們的例子中 Subscription 從原本的 Entity(實體)變成會管理多筆資料的 Aggregate 物件。

實作聚合

同樣的,我們要控制實作的數量來減少修改,因此我們可以像這樣調整 Subscription 來達到更少的調整,但是仍然可以獲得相同的效果。

 1class Subscription
 2  # ...
 3
 4  attribute :user_id, :integer
 5  attribute :expired_at, :datetime, default: -> { 30.days.from_now }
 6  attribute :items, array: true, default: -> { [] }
 7
 8  def expired_in_days
 9    (([0, expired_at - Time.zone.now].max + items.sum.days) / 1.day).ceil
10  end
11
12  def extend_with(amount:)
13    items << amount
14  end
15
16  # ...
17end

我們加入了 items 屬性到 Subscription 之中,當我們使用 #extend_with 方法時就會插入一筆資料到裡面,同時也將 #expired_in_days 做了修改,在計算完以 expired_at 為基準的日期差之後,會再額外加上延展的日期。

最後,我們更新 Controller 和加入 View 就可以通過這次新增的測試。

 1# app/controllers/subscriptions_controller.rb
 2
 3class SubscriptionsController < ApplicationController
 4  # ...
 5  def index
 6    @subscription = Subscription.by_user(user_id: current_user.id).first
 7
 8    # 移除 render plain: "..." 改為使用 View
 9    # render plain: "#{@subscription.expired_in_days} 天後到期"
10  end
11  # ...
12end

這裡我們移除掉了 render plain: "..." 讓 Rails 改為使用 View 去呈現內容。

 1<!-- app/views/subscriptions/index.html.erb -->
 2<div><%= @subscription.expired_in_days %> 天後到期</div>
 3<table>
 4  <thead>
 5    <tr>
 6      <th>#</th>
 7      <th>天數</th>
 8    </tr>
 9  </thead>
10  <tbody>
11    <% @subscription.items.each_with_index do |item, idx| %>
12      <tr>
13        <td><%= idx %></td>
14        <td>延展 <%= item %> 天</td>
15      </tr>
16    <% end %>
17  </tbody>
18</table>

我們增加了一個表格,會將 items 依序列出後印出「延展 30 天」這類字樣,這樣就可以通過新加入的測試條件。

現階段的實作我們已經有一些原本暫時不需要處理的實作可以重構,像是 #expired_in_days 應該是要由 Helper 來實現,但是現在跟聚合的資料綁定在一起,這表示我們需要做一些調整來改善這個狀況。