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

重復使用的反思 - 重新思考 Rails 架構

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

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

為何可以重用

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

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

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

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

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

高階元件

除了透過領域去區分之外,也可從層級來區分。在 Clean Architecture 使用 High-Level Component(高階元件)和低階元件做對比,這之間的差異在於越高階的元件對於細節了解越少。

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

 1class HelloWriter
 2  def initialize(dest)
 3    @dest = dest
 4  end
 5
 6  def call
 7    @dest.write "Hello World"
 8  end
 9end
10
11class StandardIO
12  def write(content)
13    $stdout.puts content
14  end
15end

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

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

 1class NetIO
 2  def write(content)
 3    @socket.write content
 4  end
 5end
 6
 7class FileIO
 8  def write(content)
 9    @file.write content
10  end
11end

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

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

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

難以通用

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

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

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

 1class Wallet < ApplicationRecord
 2  validates :balance, numericality: { greater_than: 0 }
 3end
 4
 5class WithdrawController < ApplicationController
 6  def create
 7    @wallet.withdraw!(params[:amount])
 8  end
 9end
10
11class TransferController < ApplicationController
12  def create
13    @source.with_lock do
14	  @source.withdraw!(params[:amount])
15	  @target.deposit!(params[:amount])
16    end
17  end
18end

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

1class CheckoutController < ApplicationController
2  def create
3    raise InsufficientBalanceError unless @wallet.balance > @cart.total
4    # ...
5  end
6end

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

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