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

持久化資料 - Rails 開發實踐

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

建立資料表

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

欄位類型
idbigint
user_idbigint
started_atdatetime

同時,因為要讓每個使用者同時只能擁有一個「訂閱」資訊,因此我們還會針對 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_idstarted_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_manyscope 來定義關聯跟篩選條件,稍微不同的是我們將 extend_withitems << 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 命令,會發現在功能沒有被影響的狀況下,從記憶體轉換到資料庫中。