持久化資料 - Rails 開發實踐
經過這段時間的實作,我們的規格已經逐漸確定下來而且更加清晰,現在我們終於到了決定儲存方式的階段,目前的功能採用關聯式資料庫(RDBMS)儲存算是相當適合的方式,因此我們可以直接利用 ActiveRecord 來實現。
建立資料表
以訂閱的功能來說,我們一共有「訂閱」和「訂閱紀錄」兩種資料,我們先針對「訂閱」的情況處理,最為直覺的方式就是把 Subscription
上的屬性直接轉換成資料表欄位。
欄位 | 類型 |
---|---|
id | bigint |
user_id | bigint |
started_at | datetime |
同時,因為要讓每個使用者同時只能擁有一個「訂閱」資訊,因此我們還會針對 user_id
加上 UNIQUE
的索引(Index)來確保資料的正確性。
當確定使用方式後,我們就可以用使用 rails generate migration add_subscriptions
命令製作出如下的資料庫遷移設定檔。
1class AddSubscriptions < ActiveRecord::Migration[7.0]
2 def change
3 create_table :subscriptions do |t|
4 t.bigint :user_id, null: false, index: { unique: true }
5 t.datetime :started_at, null: false
6
7 t.timestamps
8 end
9 end
10end
我們將 user_id
和 started_at
設定為不可為空,來確保一定是綁定在某個使用者上,同時訂閱建立時要確定起始時間。
利用同樣的邏輯,我們將 SubscriptionItem
資料表也一起建立,因為我們的 #items
是依靠記憶體關聯的,當 Subscription
改為從資料庫載入後,就無法順利關聯到這筆資料。
1class AddSubscriptionItems < ActiveRecord::Migration[7.0]
2 def change
3 create_table :subscription_items do |t|
4 t.bigint :subscription_id, null: false
5 t.integer :amount, null: false, default: 0
6
7 t.timestamps
8 end
9 end
10end
運行 rails db:migrate
在資料庫建立資料表後,我們要將原本的 Model 修改為使用 ActiveRecord 的版本。
調整 Model 使用 ActiveRecord
目前的 Model 是使用 ActiveModel 來實現的,現在我們有資料庫後,就可以把兩者連結起來。
1# app/models/subscription_item.rb
2
3class SubscriptionItem < ApplicationRecord
4 belongs_to :subscription
5end
在 SubscriptionItem
的部分我們沒有過多的客製化行為,因此可以很簡單的簡化成像這樣的結構,單純的描述「屬於某筆訂閱」
1# app/models/subscription.rb
2
3class Subscription < ApplicationRecord
4 has_many :items, class_name: 'SubscriptionItem', dependent: :restrict_with_error
5
6 scope :by_user, ->(user_id:) { where(user_id: user_id) }
7
8 def extend_with(amount:, created_at: Time.zone.now)
9 items.build(amount: amount, created_at: created_at)
10 end
11end
在 Subscription
的部分回到我們熟悉的樣子,使用 has_many
和 scope
來定義關聯跟篩選條件,稍微不同的是我們將 extend_with
從 items << SubscriptionItem.new(...)
的方式改為 items.build(...)
的實作,這是因為如果用 <<
有可能會提前「存到資料庫」這不是我們期待發生的事情,因此使用 items.build(...)
更符合 Aggregate(聚合)的性質,保存資料庫應該在所有任務處理完畢後才進行。
修正測試失敗
在測試完善的狀況下,我們應該要可以直接通過測試,然而在先前的測試案例雖然沒有明確說明「開始時間必須存在」的狀況,但是這仍是我們預期的狀況,在 ActiveModel 的版本我們利用 default: -> { Time.zone.now }
的選項實現,因此有一部分的測試和實作在 ActiveRecord 的版本反而無法順利執行。
1# app/services/create_subscription_service.rb
2
3class CreateSubscriptionService
4 # ...
5
6 def subscribe_for(amount:, started_at:)
7 Subscription.new(user_id: @user_id, started_at: started_at).tap do |subscription|
8 subscription.extend_with(amount: amount)
9 end
10 end
11end
在 CreateSubscriptionService
這邊,我們的 started_at
需要被指定,但是我們不應該在 Service Object 使用 Time.zone.now
來設定時間,決定起始時間的應該是我們的 Use Case(使用案例)因此還要更新 Controller 的實現
1class SubscriptionsController < ApplicationController
2 # ...
3
4 def create
5 # ...
6 subscription = service.subscribe_for(amount: 30, started_at: Time.zone.now)
7 subscription.save!
8 redirect_to subscriptions_path
9 end
10end
如此一來我們也將「隨機」跟「根據當下情況決定」的數值限定在 Controller 層級才會出現,在未來對 Service Object、Model 這類物件的單元測試會更加容易。
除此之外,我們在 Cucumber 實現的步驟定義也有所缺漏,因此繼續進行修正。
1Given('會員 {word} 已經訂閱,並且在 {int} 天後到期') do |name, amount|
2 Subscription.new(user_id: @users[name], started_at: Time.zone.now).tap do |subscription|
3 subscription.extend_with(amount: amount)
4 end.save!
5end
6
7# ...
8
9Given('會員 {word} 有一些訂閱紀錄') do |name, table|
10 user_id = @users[name]
11
12 Subscription.create(user_id: user_id, started_at: Time.zone.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
最後,我們再次運行 bundle exec cucumber
命令,會發現在功能沒有被影響的狀況下,從記憶體轉換到資料庫中。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 前言 - Rails 開發實踐
- 將需求實現的準備 - Rails 開發實踐
- 獲取規格的技巧 - Rails 開發實踐
- 可以測試的規格 - Rails 開發實踐
- 快速通過測試的方法 - Rails 開發實踐
- 用測試完善規格 - Rails 開發實踐
- 資料跟資訊的差異 - Rails 開發實踐
- 用測試資料驗證邏輯 - Rails 開發實踐
- 預期外狀況的檢查 - Rails 開發實踐
- 預期外狀況的測試 - Rails 開發實踐
- 聚合多筆資料 - Rails 開發實踐
- 重構與修正邏輯 - Rails 開發實踐
- 加入聚合實體 - Rails 開發實踐
- 持久化資料 - Rails 開發實踐
- 實體與倉庫 - Rails 開發實踐
- 聚合與邊界 - Rails 開發實踐
- 使用案例與服務 - Rails 開發實踐
- 結語 - Rails 開發實踐