Byte Ebi's Logo

Byte Ebi 🍤

每天一小口,蝦米變鯨魚

依賴方向決定程式碼分層

放在同一個資料夾底下是不是就代表同一層?

Ray

不要再靠命名或資料夾位置去猜了!
以業務邏輯、支援邏輯、基礎設施三層依賴鏈為例,判斷是否為同層呼叫

背景說明

在實作一個對接外部系統 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 建構參數膨脹 拿掉這層,上層建構子會被塞進不相干的參數嗎?

最新文章

文章分類

Tag