---
title: "ActiveRecord 的限制 - 重新思考 Rails 架構"
date: 2024-08-16T00:00:00+08:00
publishDate: 2024-08-16T00:00:00+08:00
lastmod: 2024-06-02T17:03:47+08:00
tags: ["Rails","Domain-Driven Design","設計","Clean Architecture"]
series: "rethink-rails-architecture"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/08/16/rethink-rails-architecture-activerecord-limitation/"
language: "zh-tw"
---


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

<!--more-->

## ActiveRecord

在 Rails 中最常見的就是 [ActiveRecord](https://zh.wikipedia.org/zh-tw/%E4%B8%BB%E5%8A%A8%E8%AE%B0%E5%BD%95) 這種架構模式，所有的 Model 都會繼承自 `ActiveRecord::Base` 這個類型（Class）來實作。同時 ActiveRecord 能夠自動的將資料表欄位進行對應，因此我們可以很簡單的實現跟資料庫的互動。

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

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

```ruby
class Order < ApplicationRecord
  validates :shipped_at, presence: true, if: :shipped?
  validate :allow_mark_shipped, if: :shipped?

  enum state: {
    # ...
    shipped: 'shipped'
  }

  # ...

  private

  def allow_mark_shipped
    return if shippings.all?(&:shipped?)

	errors.add(:state, :all_products_not_shipped)
  end
end
```

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

然而，如果要表示「送達」的狀態，所有的運送狀態（`shippings`）都是送達時，我們是否還需要有一個「送達狀態」去表示呢？

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

## 思考誤區{#bias}

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

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

```ruby
class Order < ApplicationRecord
  # ...

  def shipped?
    shippings.all?(&:shipped?)
  end

  def shipped_at
    shippings.max_by(&:shipped_at)&.shippted_at
  end
end
```

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

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

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

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

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

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

