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

ActiveRecord 的限制 - 重新思考 Rails 架構

這篇文章是 重新思考 Rails 架構 系列的一部分。

當我們要透過 Ruby on Rails 這個框架來開發這樣的系統時,該如何進行設計呢?大多數時候我們會以模型(Model)為基礎思考,這在 MVC(Model ViewController)框架上還算常見,因為核心邏輯大多會被實作在 Model 上。

ActiveRecord

在 Rails 中最常見的就是 ActiveRecord 這種架構模式,所有的 Model 都會繼承自 ActiveRecord::Base 這個類型(Class)來實作。同時 ActiveRecord 能夠自動的將資料表欄位進行對應,因此我們可以很簡單的實現跟資料庫的互動。

這也讓我們會很習慣以「資料」的角度去思考 Model 該如何進行設計,以 PChome 下單後「分庫出貨」的例子,我們可能會像這樣設計。

分庫出貨是指商品從不同倉庫送出,因此在 PChome 的訂單畫面上會有兩筆運送紀錄。

 1class Order < ApplicationRecord
 2  validates :shipped_at, presence: true, if: :shipped?
 3  validate :allow_mark_shipped, if: :shipped?
 4
 5  enum state: {
 6    # ...
 7    shipped: 'shipped'
 8  }
 9
10  # ...
11
12  private
13
14  def allow_mark_shipped
15    return if shippings.all?(&:shipped?)
16
17	errors.add(:state, :all_products_not_shipped)
18  end
19end

我們會用一個狀態(State)欄位來保存訂單的狀態,並且還會有一個送達時間(Shipped At)來記錄送達的時間,同時還需要在修改成特定狀態時,要再加以驗證是否滿足改變狀態的條件。

然而,如果要表示「送達」的狀態,所有的運送狀態(shippings)都是送達時,我們是否還需要有一個「送達狀態」去表示呢?

同樣的,訂單的送達時間應該會是運送狀態中最晚送達的那一筆紀錄。

思考誤區

習慣了 ActiveRecord 的特性後,我們很容易的對於物件狀態的思考有一些盲點,會認為必須存在「資料欄位」才能夠去反應物件(或模型)的狀態。

實際上,我們在訂單上的實作並不需要運送狀態以其送達時間的欄位,完全能透過運送資訊來滿足這些資訊呈現。

 1class Order < ApplicationRecord
 2  # ...
 3
 4  def shipped?
 5    shippings.all?(&:shipped?)
 6  end
 7
 8  def shipped_at
 9    shippings.max_by(&:shipped_at)&.shippted_at
10  end
11end

這個時候,我們可能會開始考慮一些問題。

例如,我們每次都用 shippings 篩選資料,是否會讓運行速度變慢之類的?然而 Rails 會幫我們快取(Cache)關聯的查詢,一筆訂單的運送狀態也不會太多,運算上的消耗幾乎是可以忽略不計的。

另一方面,可能會以這樣進行查詢時,以 JOIN Query 去篩選狀態、時間,會讓查詢變得緩慢。這點的確是正確的,不過我們需要考量一些問題。

我們的資料庫在多少資料量會變得「緩慢」需要評估,如果真的變慢需要在 orders 資料表加上 shipped_at 欄位時,這個欄位的角色是什麼?

以加速查詢來看,shipped_at 這個欄位跟 Rails 提供的 Counter Cache 機制相同,是一種快取機制,實際上這個欄位可能不該由 Order 自己進行管理,而是 Shipping 判斷所有相關的商品都送達後,通知 Order 進行更新(Counter Cache 也是由被統計的物件觸發)

如果仔細思考,當系統需要做的處理、判斷變更多、更複雜,會有非常多細節很難用資料表直接映射(Mapping)成物件來看待。