---
title: "重復使用的反思 - 重新思考 Rails 架構"
date: 2024-09-06T00:00:00+08:00
publishDate: 2024-09-06T00: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/09/06/rethink-rails-architecture-think-reuse/"
language: "zh-tw"
---


在學習軟體開發的過程中，很常會看到重用（Reuse）這樣的概念，或者說我們會試著讓一段程式碼可以在許多地方被呼叫跟使用。這也跟我們將程式碼歸類成特定類型的物件、或者一系列的方法模組的開發方式有關，這件事情似乎是非常自然的。

<!--more-->

## 為何可以重用{#why-we-can-reuse}

開發過程中可能會想像，這個功能被實作後就可以在某某地方也套用相同的情境，最後很多事情都一次性的解決，然而現實往往不是如此。

然而，我們並未仔細思考在怎樣的情境、條件下重復使用某段程式碼是成立的。如同在使用關聯式資料庫（RDBMS）時我們會直覺地想要進行正規化（Normalization）的處理，卻又會遇到需要反正規化的情境，要產出「重複」的內容在不同資料表上。

在接觸到領域驅動設計（Domain-Driven Design）時，看到了即使同樣是金錢（Money）在不同的領域下，也可能代表不同的意義，因此會有像是 `Account::Balance` 和 `Product::Price` 這樣的差異，兩者可能都有幣種（Currency）和數量（Amount）的屬性，也都表示金錢跟具有同樣的方法，卻表示不同意義，那麼就是兩個「看起來重複」的物件，實際上並不是。

因此，實際上的重用應該是建構在具有相同的前提（或者脈絡）之下，相關的物件都套用相同的情境時才適用的，我們可以用領域（Domain）來稱呼這樣的集合。

> 學習軟體開發時，通常都不會用規模太大或者複雜的系統作為例子，我們很可能不會接觸到這種情境，而缺乏了相關的思考訓練。

## 高階元件{#high-level-component}

除了透過領域去區分之外，也可從層級來區分。在 [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 使用 High-Level Component（高階元件）和低階元件做對比，這之間的差異在於越高階的元件對於細節了解越少。

舉例來說，我們有一個可以把文字輸出到某個地方的功能，裡面包含了兩個元件，一個是寫入的 `HelloWriter` 另一個是處理輸入跟輸出的 `StandardIO` 元件。

```ruby
class HelloWriter
  def initialize(dest)
    @dest = dest
  end

  def call
    @dest.write "Hello World"
  end
end

class StandardIO
  def write(content)
    $stdout.puts content
  end
end
```

依照 Clean Architecture 的定義，距離 I/O 或者使用者越近的，就更低階，也知道更多的細節。在上述的例子，`StandardIO` 物件知道輸出的位置 `$stdout` 的細節，而 `HelloWriter` 只知道有一個位置可以輸出，並不清楚怎麼實現的。

這也意味著，我們可以提供不同的 I/O 實作來達到不一樣的變化。

```ruby
class NetIO
  def write(content)
    @socket.write content
  end
end

class FileIO
  def write(content)
    @file.write content
  end
end
```

以此為依據，就會發現 `StandardIO`、`NetIO`、`FileIO` 基本上是非常相似的，我們應該歸類成一種「重複（Duplicate）」嗎？實際上並不會，而更上一層級的 `HelloWriter` 抽離了細節，另一個面向來看確實是可以被重複使用的。

之所以能被重複使用，是因為這類型的物件更接近「原則」類型，像是「餘額不得小於 0」這樣的概念，至於怎麼取得餘額之類的都是無關緊要的細節。

> Rails 開發技巧上一直都有所謂的 Fat Model 的概念存在，然而從上述的情境來看，能放入 Model 的方法是有條件的，因此有一種說法是商業邏輯（Business Logic）才屬於 Model 負責的部分，這些商業邏輯通常都是原則性的問題。

## 難以通用{#general-is-hard}

如同通用人工智慧（Artificial General Intelligence）至今仍難以實現一樣，一個通用的軟體也是非常難以設計的，要涵蓋越大的範圍、情境，我們就需要抽離出更多的原則，然後一層一層的區隔出來。相比這樣的複雜度，切割成多個小範圍的問題獨立處理，可能更加簡單。

以 Rails 開發者熟悉的 MVC（Model View Controller）作為例子，假設我們要實現一個錢包機制，那麼就會需要先釐清「餘額是否可以小於 0」這類資訊，才能往下實現。

那麼，假設在所有情境下錢包都不可以小於 0 的前提，我們可以設計出像這樣的結構。

```ruby
class Wallet < ApplicationRecord
  validates :balance, numericality: { greater_than: 0 }
end

class WithdrawController < ApplicationController
  def create
    @wallet.withdraw!(params[:amount])
  end
end

class TransferController < ApplicationController
  def create
    @source.with_lock do
	  @source.withdraw!(params[:amount])
	  @target.deposit!(params[:amount])
    end
  end
end
```

因為錢包不能小於 0 因此我們可以將驗證條件設定在 Model 上，這是一條通用的規則。如果用於購買商品，那麼「餘額檢查」可能就不是 Wallet 該負責的範圍。

```ruby
class CheckoutController < ApplicationController
  def create
    raise InsufficientBalanceError unless @wallet.balance > @cart.total
    # ...
  end
end
```

像這樣子，會發現實際上可以「通用」的情境並不多，大多都會是原則性的問題。

> 然而，當系統變得複雜時，就會因為切分多層而有許多不同層級的情境。例如，經常處理「轉帳」的狀況時，我們可以會有一個 `TransferService` 在不同的 Controller 之間使用，此時就從 Model - Controller 的兩層，轉變為 Model - Service - Controller 三層的關係。


