---
title: "聚合多筆資料 - Rails 開發實踐"
date: 2023-09-15T00:00:00+08:00
publishDate: 2023-09-15T00:00:00+08:00
lastmod: 2023-09-15T19:46:45+08:00
tags: ["聚合","經驗","心得","Rails","Rails 開發實踐"]
series: "rails-in-practice"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/09/15/rails-in-practice-aggregate-data/"
language: "zh-tw"
---


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

<!--more-->

## 加入規格{#add-specification}

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

```gherkin
#language:zh-TW
# ...
場景: 假設 Aotoki 有兩筆 30 天的訂閱，可以看到 60 天後到期
    假設 會員 Aotoki 有一些訂閱紀錄
      | amount |
      | 30     |
      | 30     |
    當 我打開訂閱狀態頁面
    那麼 我會看到 2 次 "延展 30 天"
    並且 我會看到 "60 天後到期"
```

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

## 紀錄延展{#extend-record}

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

```ruby
# features/step_definitions/subscription.rb

# ...

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

  Subscription.create(user_id: user_id, expired_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

# ...

Then('我會看到 {int} 次 {string}') do |count, text|
  expect(page).to have_text(text, count: count)
end
```

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

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

## 實作聚合{#implementate-aggregate}

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

```ruby
class Subscription
  # ...

  attribute :user_id, :integer
  attribute :expired_at, :datetime, default: -> { 30.days.from_now }
  attribute :items, array: true, default: -> { [] }

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

  def extend_with(amount:)
    items << amount
  end

  # ...
end
```

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

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

```ruby
# app/controllers/subscriptions_controller.rb

class SubscriptionsController < ApplicationController
  # ...
  def index
    @subscription = Subscription.by_user(user_id: current_user.id).first

    # 移除 render plain: "..." 改為使用 View
    # render plain: "#{@subscription.expired_in_days} 天後到期"
  end
  # ...
end
```

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

```html
<!-- app/views/subscriptions/index.html.erb -->
<div><%= @subscription.expired_in_days %> 天後到期</div>
<table>
  <thead>
    <tr>
      <th>#</th>
      <th>天數</th>
    </tr>
  </thead>
  <tbody>
    <% @subscription.items.each_with_index do |item, idx| %>
      <tr>
        <td><%= idx %></td>
        <td>延展 <%= item %> 天</td>
      </tr>
    <% end %>
  </tbody>
</table>
```

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

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

