依賴方向決定程式碼分層
放在同一個資料夾底下是不是就代表同一層?
不要再靠命名或資料夾位置去猜了!
以業務邏輯、支援邏輯、基礎設施三層依賴鏈為例,判斷是否為同層呼叫
背景說明
在實作一個對接外部系統 API 的串接套件時,要先換到 JWT token 才能呼叫
並且為了避免每次呼叫都重新換發,會將 token 存進 Redis 快取
這個串接套件的建構子,因此一路往下傳了三層依賴
具體來說,在 services/yoho/ 裡,YohoRestClient 建構子收了一個 YohoTokenProvider
而 YohoTokenProvider 建構子又收了一個 RedisService
三個類別都住在同一層資料夾底下,這讓我瞬間警鈴大作:
這算是同層互相呼叫嗎?會不會有循環相依的問題?
然而把箭頭實際畫出來看,會發現這不是三個平行的 service,而是一條單向往下的鏈:
YohoRestClient [業務邏輯] 送出請求給外部系統
│ 依賴
▼
YohoTokenProvider [支援邏輯] 拿到有效的 OAuth token
│ 依賴
▼
RedisService [基礎設施] 通用的 key-value 快取
可以發現「箭頭只往下指,沒有任何一個箭頭往回走」
這一點很重要,後面每一個判別測試都會回來對照
資料夾與命名
資料夾名稱 services/ 在大多數專案裡只是一個寬鬆的分類標籤:
這裡放業務邏輯,不是控制層(controller),也不是純資料模型(model)
並不是在宣告「資料夾底下的東西都是水平對等、彼此不能依賴的同一層」
原有專案中的命名,其實已經有意無意做出區分:
| 類別 | 後綴 | 實際角色 |
|---|---|---|
RedisService |
Service | 包住外部系統(Redis)的通用基礎設施 |
MonkeyEmailService |
Service | 包住另一個外部系統(郵件發送 API)的呼叫 |
YohoTokenProvider |
Provider | 在對接 Yoho 這個領域裡,扮演「供應 token」這個角色 |
YohoRestClient |
Client | 在對接 Yoho 這個領域裡,扮演「發送請求」這個角色 |
決定「有沒有分層問題」的,從來不是資料夾名稱或字尾統不統一叫 service,而是:
- 依賴方向有沒有循環?
- 依賴的對象是不是跨到了不相干的業務領域?
這兩件事跟「其他人怎麼稱呼自己」完全無關。
六個分層測試
拿掉「憑感覺」之後,可以把這六個問題套用在任何一組依賴上
1. 依賴方向測試
- A 需要知道 B 存在才能做完自己的事嗎?
- B 知道 A 存在嗎?
只有單向「A 知道 B,B 完全不知道 A」才是分層
同層/平行關係通常是兩者互不知道對方,或由更上層的角色出面協調,而不是其中一個直接依賴另一個
2. 一句話職責測試
- 能不能用一句話講完這個元件在幹嘛,完全不必提到另一個元件?
例如 YohoRestClient 是「把請求送到 Yoho」,不用提 Redis 或 JWT
兩邊都能各自講完,是上下關係不是同層關係
如果講 A 時非得帶出 B 的細節,才比較像同層關係或耦合過緊
3. 替換測試
- 如果將 B 的實作方式替換掉,A 的職責描述會變嗎?
把 RedisService 換成別的快取實作,YohoTokenProvider 的職責描述完全不變
代表 B 是 A 底下的支撐元件,不是同層的夥伴
4. 跨領域重用測試
- 這個元件拿給完全不相干的業務用,合理嗎?
如果 RedisService 給另一個郵件發送模組拿去快取模板,完全合理
但把 YohoTokenProvider 給那個模組拿去用就說不通
能不能被拿去跨領域重用,就是判斷層級高低的依據:
越是能被不相干的業務重用,代表層級越低、越通用
5. 異動漣漪測試
- B 的實作改了,A 一定要跟著改嗎?
當 Yoho 的 OAuth 規格換掉 JWT 簽章方式時可以不用動 YohoRestClient
只有 YohoTokenProvider 要改
只要對外的介面沒變,下層的修改就不該逼上層跟著動,這才是正確分層
兩邊常常要一起改,才是真正的同層關係或耦合過緊
6. 建構參數膨脹測試
- 拿掉中間這一層,上層物件的建構子會被迫塞進一堆跟本職無關的參數嗎?
如果不透過注入,而是自己組出相關邏輯,建構子會被迫接收一堆不相干的參數
沒有分層
不注入 YohoTokenProvider 而是讓 YohoRestClient 自己組出認證邏輯
class YohoRestClient:
def __init__(
self,
base_url: str,
client_id: str,
redis_host: str,
redis_port: int = 6379,
) -> None:
...
# 自己組 RedisService、YohoTokenProvider
# 並且要記得什麼時候該重新拿 token
分層
在 services/yoho/rest_client.py 裡,YohoRestClient 建構子收了一個 YohoTokenProvider
而 YohoTokenProvider 建構子又收了一個 RedisService
class YohoRestClient:
def __init__(
self,
base_url: str,
token_provider: YohoTokenProvider,
) -> None:
self.base_url = base_url.rstrip("/")
self.token_provider = token_provider
工廠函式
「沒有分層」那個版本,建構子裡有三個參數跟「送出請求」這個職責完全無關
代表這是兩個不同的職責被硬湊進同一個建構子裡
「分層」這個版本能維持乾淨,靠的是寫一個獨立的工廠函式,手動 new 出每個物件並組合起來:
factories/yoho/rest_client_factory.py
def build_yoho_rest_client(
env: str,
client_id: str,
base_url: str,
redis_host: str,
redis_port: int = 6379,
) -> YohoRestClient:
redis_service = RedisService(redis_host, redis_port)
token_provider = YohoTokenProvider(
client_id=client_id,
redis_service=redis_service,
)
return YohoRestClient(base_url=base_url, token_provider=token_provider)
工廠函式知道整張物件圖長什麼樣子,但每一個類別自己只認識下一層
例如:YohoRestClient 只認識 YohoTokenProvider 但完全不知道 RedisService 存在
延伸思考
看著這個工廠函式,可能會冒出一個疑問:
- YohoTokenProvider 拿到的是 Redis 物件,但工廠函式接收的 redis_host 參數卻是字串,這樣算不算標準不一致?
不算,差別在於這個東西有沒有「行為」
傳入的 redis_host 參數只是一個網址字串,本身不會做任何事
工廠看到它,直接拿去 new 一個 RedisService 就好,不需要把它當物件傳來傳去
但 RedisService 這個物件不一樣!
每次呼叫 YohoTokenProvider.get_token() 函式時,都要「當下」決定:
- 快取命中了嗎?
- 要不要重新換發 token?
這些判斷只有真正呼叫這個物件的方法才做得到,字串沒辦法回答這些問題
所以規則很簡單:
純粹的設定值,傳資料就好;需要被呼叫、會做事的東西,才注入物件。
速查表
下次看到一組依賴,猶豫「這算不算同層」的時候,照著問一遍:
| # | 測試 | 問題 |
|---|---|---|
| 1 | 依賴方向 | 箭頭是不是單向、沒有循環? |
| 2 | 一句話職責 | 能不能不提到對方,講完自己在幹嘛? |
| 3 | 替換測試 | 換掉下層實作,上層的職責描述會變嗎? |
| 4 | 跨領域重用 | 丟給不相干的業務用,合理嗎? |
| 5 | 異動漣漪 | 下層改實作,上層需要跟著改嗎? |
| 6 | 建構參數膨脹 | 拿掉這層,上層建構子會被塞進不相干的參數嗎? |
