---
title: "持久化資料 - Rails 開發實踐"
date: 2023-10-06T00:00:00+08:00
publishDate: 2023-10-06T00: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/10/06/rails-in-practice-persist-data/"
language: "zh-tw"
---


經過這段時間的實作，我們的規格已經逐漸確定下來而且更加清晰，現在我們終於到了決定儲存方式的階段，目前的功能採用關聯式資料庫（RDBMS）儲存算是相當適合的方式，因此我們可以直接利用 ActiveRecord 來實現。

<!--more-->

## 建立資料表{#create-table}

以訂閱的功能來說，我們一共有「訂閱」和「訂閱紀錄」兩種資料，我們先針對「訂閱」的情況處理，最為直覺的方式就是把 `Subscription` 上的屬性直接轉換成資料表欄位。

| 欄位 | 類型 |
|------|------|
| `id` | bigint |
| `user_id` | bigint |
| `started_at`  | datetime |

同時，因為要讓每個使用者同時只能擁有一個「訂閱」資訊，因此我們還會針對 `user_id` 加上 `UNIQUE` 的索引（Index）來確保資料的正確性。

當確定使用方式後，我們就可以用使用 `rails generate migration add_subscriptions` 命令製作出如下的資料庫遷移設定檔。

```ruby
class AddSubscriptions < ActiveRecord::Migration[7.0]
  def change
    create_table :subscriptions do |t|
      t.bigint :user_id, null: false, index: { unique: true }
      t.datetime :started_at, null: false

      t.timestamps
    end
  end
end
```

我們將 `user_id` 和 `started_at` 設定為不可為空，來確保一定是綁定在某個使用者上，同時訂閱建立時要確定起始時間。

利用同樣的邏輯，我們將 `SubscriptionItem` 資料表也一起建立，因為我們的 `#items` 是依靠記憶體關聯的，當 `Subscription` 改為從資料庫載入後，就無法順利關聯到這筆資料。

```ruby
class AddSubscriptionItems < ActiveRecord::Migration[7.0]
  def change
    create_table :subscription_items do |t|
      t.bigint :subscription_id, null: false
      t.integer :amount, null: false, default: 0

      t.timestamps
    end
  end
end
```

運行 `rails db:migrate` 在資料庫建立資料表後，我們要將原本的 Model 修改為使用 ActiveRecord 的版本。

## 調整 Model 使用 ActiveRecord{#use-activerecord}

目前的 Model 是使用 ActiveModel 來實現的，現在我們有資料庫後，就可以把兩者連結起來。

```ruby
# app/models/subscription_item.rb

class SubscriptionItem < ApplicationRecord
  belongs_to :subscription
end
```

在 `SubscriptionItem` 的部分我們沒有過多的客製化行為，因此可以很簡單的簡化成像這樣的結構，單純的描述「屬於某筆訂閱」

```ruby
# app/models/subscription.rb

class Subscription < ApplicationRecord
  has_many :items, class_name: 'SubscriptionItem', dependent: :restrict_with_error

  scope :by_user, ->(user_id:) { where(user_id: user_id) }

  def extend_with(amount:, created_at: Time.zone.now)
    items.build(amount: amount, created_at: created_at)
  end
end
```

在 `Subscription` 的部分回到我們熟悉的樣子，使用 `has_many` 和 `scope` 來定義關聯跟篩選條件，稍微不同的是我們將 `extend_with` 從 `items << SubscriptionItem.new(...)` 的方式改為 `items.build(...)` 的實作，這是因為如果用 `<<` 有可能會提前「存到資料庫」這不是我們期待發生的事情，因此使用 `items.build(...)` 更符合 Aggregate（聚合）的性質，保存資料庫應該在所有任務處理完畢後才進行。

## 修正測試失敗{#fix-test-failed}

在測試完善的狀況下，我們應該要可以直接通過測試，然而在先前的測試案例雖然沒有明確說明「開始時間必須存在」的狀況，但是這仍是我們預期的狀況，在 ActiveModel 的版本我們利用 `default: -> { Time.zone.now }` 的選項實現，因此有一部分的測試和實作在 ActiveRecord 的版本反而無法順利執行。

```ruby
# app/services/create_subscription_service.rb

class CreateSubscriptionService
  # ...

  def subscribe_for(amount:, started_at:)
    Subscription.new(user_id: @user_id, started_at: started_at).tap do |subscription|
      subscription.extend_with(amount: amount)
    end
  end
end
```

在 `CreateSubscriptionService` 這邊，我們的 `started_at` 需要被指定，但是我們不應該在 Service Object 使用 `Time.zone.now` 來設定時間，決定起始時間的應該是我們的 Use Case（使用案例）因此還要更新 Controller 的實現

```ruby
class SubscriptionsController < ApplicationController
  # ...

  def create
    # ...
    subscription = service.subscribe_for(amount: 30, started_at: Time.zone.now)
    subscription.save!
    redirect_to subscriptions_path
  end
end
```

如此一來我們也將「隨機」跟「根據當下情況決定」的數值限定在 Controller 層級才會出現，在未來對 Service Object、Model 這類物件的單元測試會更加容易。

除此之外，我們在 Cucumber 實現的步驟定義也有所缺漏，因此繼續進行修正。

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

# ...

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

  Subscription.create(user_id: user_id, started_at: Time.zone.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
```

最後，我們再次運行 `bundle exec cucumber` 命令，會發現在功能沒有被影響的狀況下，從記憶體轉換到資料庫中。

