重復使用的反思 - 重新思考 Rails 架構
在學習軟體開發的過程中,很常會看到重用(Reuse)這樣的概念,或者說我們會試著讓一段程式碼可以在許多地方被呼叫跟使用。這也跟我們將程式碼歸類成特定類型的物件、或者一系列的方法模組的開發方式有關,這件事情似乎是非常自然的。
為何可以重用
開發過程中可能會想像,這個功能被實作後就可以在某某地方也套用相同的情境,最後很多事情都一次性的解決,然而現實往往不是如此。
然而,我們並未仔細思考在怎樣的情境、條件下重復使用某段程式碼是成立的。如同在使用關聯式資料庫(RDBMS)時我們會直覺地想要進行正規化(Normalization)的處理,卻又會遇到需要反正規化的情境,要產出「重複」的內容在不同資料表上。
在接觸到領域驅動設計(Domain-Driven Design)時,看到了即使同樣是金錢(Money)在不同的領域下,也可能代表不同的意義,因此會有像是 Account::Balance
和 Product::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
以此為依據,就會發現 StandardIO
、NetIO
、FileIO
基本上是非常相似的,我們應該歸類成一種「重複(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 三層的關係。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 軟體架構的挑戰 - 重新思考 Rails 架構
- 資料驅動設計 - 重新思考 Rails 架構
- 複雜的操作 - 重新思考 Rails 架構
- 時區換算 - 重新思考 Rails 架構
- 報表機制 - 重新思考 Rails 架構
- 通用化功能 - 重新思考 Rails 架構
- ActiveRecord 的限制 - 重新思考 Rails 架構
- 領域驅動設計 - 重新思考 Rails 架構
- 從架構到設計 - 重新思考 Rails 架構
- 重復使用的反思 - 重新思考 Rails 架構
- 釐清脈絡 - 重新思考 Rails 架構
- 劃分邊界 - 重新思考 Rails 架構
- 職責劃分 - 重新思考 Rails 架構
- 架構規劃 - 重新思考 Rails 架構