iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

AI時代下的軟體工程系列 第 27 篇

Day27: Dependency topology:dependency 可以怎麼設計?

  • 分享至 

  • xImage
  •  

前幾天談的,都是一個 boundary 自己的事:什麼放在一起、什麼留在裡面、什麼時候開擴充點。

今天談兩個 boundary 之間的那條線。

「把 A 和 B 解耦」是一句聽起來永遠正確的話。但只要 A 需要 B 才能完成工作,兩者之間就一定有 dependency,它拿不掉。能決定的,只有它長什麼樣子。

Dependency 的形狀

先約定箭頭的意思:A → B 代表 A 知道 B。B 改了,A 可能要跟著改;A 改了,B 不受影響。

最直接的寫法,是 A 需要什麼就伸手去拿:

A ───▶ B(以及 B 的各種細節)

這條箭頭有三種方法可以調整:

減少      A ───▶ [窄門]───▶ B
反轉      A(interface)◀─── B
間接化    A ───▶ contract ◀─── B

三種形狀動的是同一條箭頭的不同地方:誰連到誰、箭頭朝哪一邊、中間隔了什麼。以下把這件事稱為 dependency topology。它不是哪一條有名的原則,只是今天用來整理的角度。

三種形狀各有其想解決的問題。

減少

以下用一個小工具當例子:它會定期把檔案備份到 S3。job 決定備份哪些檔案、多久備份一次、保留幾份。

一開始,job 直接使用 AWS 的 SDK:

# backup/job.py
import boto3

s3 = boto3.client("s3")


def run(paths: list[Path]) -> None:
    for path in paths:
        s3.put_object(Bucket="backups", Key=path.name, Body=path.read_bytes())

同樣的寫法也出現在 restore.py、cleanup.py。每一個檔案都知道三件事:用的是 boto3、bucket 叫什麼、參數怎麼傳。

減少的做法,是在中間放一扇窄門:

# backup/blob.py
import boto3

_s3 = boto3.client("s3")
_BUCKET = "backups"


def save(name: str, data: bytes) -> None:
    _s3.put_object(Bucket=_BUCKET, Key=name, Body=data)


def load(name: str) -> bytes:
    return _s3.get_object(Bucket=_BUCKET, Key=name)["Body"].read()
# backup/job.py
from backup import blob


def run(paths: list[Path]) -> None:
    for path in paths:
        blob.save(path.name, path.read_bytes())

箭頭的方向沒有變,job 還是依賴放檔案的 code。變的是寬度:知道 boto3 的地方,從好幾個檔案變成一個。這種做法通常叫 wrapper 或 facade。

買到什麼:A 能簡單的使用 B;當 B 在門後進行調整,A 不受影響

付出什麼:

  • 多一層要維護。當 A 需要 B 的新功能,得先更新這扇門。
  • 門可能什麼都沒擋住。如果它只是把 B 的參數原封不動轉出去,A 知道的一樣多,只是多繞了一層。

什麼時候值得:很多地方都知道 B 的細節,而 B 不歸我們管(第三方套件、別的團隊的服務),或者它常常變。

核心理念是:把複雜的實作細節藏在乾淨的介面之後。

反轉

窄門處理的是寬度。方向沒有動,還是 job → blob。

假設來了兩個新需求:

  • 公司要求另外存一份到內部的 NAS。
  • 保留規則越來越複雜,需要在沒有網路的環境測試 run()。

兩件事都得動到 job,而它們其實都跟備份的規則無關。

反轉的做法,是讓 job 自己說出它需要什麼,再由 blob 來配合:

# backup/job.py
# 寫在 job 裡面,由 job 定義的抽象
class Destination(ABC):
    @abstractmethod
    def put(self, name: str, data: bytes) -> None: ...

# offline 的 run 測試
def run(paths: list[Path], dest: Destination) -> None:
    for path in paths:
        dest.put(path.name, path.read_bytes())
# backup/s3.py
from backup.job import Destination

class S3Destination(Destination):
    def put(self, name: str, data: bytes) -> None:
        _s3.put_object(Bucket=_BUCKET, Key=name, Body=data)
# backup/main.py
run(paths, S3Destination())

注意 import 的方向。原本是 job import 放檔案的 code,現在是 s3 import job。執行的時候,仍然是 run() 呼叫 put();反過來的只有「誰知道誰」。

這就是 DIP(Dependency Inversion Principle,依賴反轉原則),SOLID 的第五條。Robert C. Martin 的說法是:

High-level modules should not depend on low-level modules. Both should depend on abstractions.

高層指的是job(client),低層指的是blob(implementor)。兩者都依賴 client 所提供的 abstraction。

所以重點不在多了一個 interface,而在 interface 歸誰:

同樣的形狀很常見:

  • Callback:sorted(words, key=len)。sorted() 不知道我們要怎麼排,由我們把規則交給它。
  • Plugin:pytest 不知道世界上有哪些 plugin。它定義好一組 hook,plugin 照著實作,pytest 自己一行都不必改。

買到什麼:當 B 換一種做法,或是多一種 B,A 都不必修改

付出什麼:

  • 多一個 interface,還多一個負責把 A 和 B 接起來的地方。
  • 只讀 A,看不出實際做事的是哪一個 B,得找到是誰把它傳進來。
  • interface 改動成本變高。它一動,每個不同的 B 都要跟著改。

什麼時候值得:A 比 B 穩定、也比 B 重要,而 B 的 change 確實一直傳到 A;或者 B 確定有不只一種。

核心理念是:由穩定的 client 定義出 Spec, 讓不穩定的 implementers 來遵守。

間接化

換一個需求:想讓 coding agent 也能查備份、觸發備份。

最直接的寫法,是替每一種 agent 各寫一份整合。agent 有 m 種、工具有 n 個,就是 m × n 份。

間接化的做法,是讓兩邊不直接知道對方,只知道一份共同的 contract。這裡的 contract 是 MCP(Model Context Protocol)。備份工具這一邊,照著它開出自己的功能:

# backup/mcp_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("backup")


@mcp.tool()
def list_backups(limit: int = 10) -> list[str]:
    """列出最近的備份"""
    ...


if __name__ == "__main__":
    mcp.run()

agent 那一邊,沒有任何一行跟備份有關的 code,只有一段設定,寫著這個 server 怎麼啟動:

{
  "mcpServers": {
    "backup": { "command": "python", "args": ["-m", "backup.mcp_server"] }
  }
}

備份工具不知道接上來的是哪一種 agent,agent 也不知道備份工具是怎麼寫的。兩邊只知道同一份 contract:怎麼列出工具、怎麼呼叫、參數與結果長什麼樣子。

於是 m × n 變成 m + n:每一種 agent 支援一次 MCP,每一個工具實作一次 MCP。LSP(Language Server Protocol)是同一個形狀,編輯器與各種語言的分析工具互不認識,只認 protocol。

買到什麼:多一個 B,A 不必改。A 和 B 可以用不同的語言、跑在不同的 process、由不同的人維護,各自更新。

付出什麼:

  • 順著呼叫追不到對方。 從 A 的呼叫「跳到定義」,到的是中間那一層,不是 B。實際由哪一個 B 處理,只能用搜尋去找。
  • contract 變成最難改的東西。 A 改了 contract 裡的一個欄位:A 這邊的 type checker 與 test 都通過,import 的圖上沒有任何一條箭頭受影響。然後 B 在執行時壞掉。

什麼時候值得:另一邊有很多個、會持續增加、由不同的人維護。LSP 與 MCP 都是這種情況:兩邊各有許多實作,由不同的組織各自開發。

核心理念是:client & implementer 都不必知道對方怎麼設計,遵守好 contract,一切相安無事

三種形狀的價目表

減少 反轉 間接化
對箭頭做的事 收窄 反轉 中間隔一份共同的 contract
買到 B 的細節改了,只改一個地方 B 怎麼換,A 都不必改 A 跟 B 持續增加都可以互相使用
付出 多一層要維護 多一個 interface 與組裝的地方;讀 A 看不出實際是誰在做 順著呼叫追不到;contract 的 change 工具看不見
適合的情境 B 容易變動並且 提供太多不需要的功能 A 比 B 穩定且重要,或 B 確定不只一種 A 跟 B 都有很多種類、各自維護
常見例子 facade pattern / wrapper DIP MCP / LSP

坦白說,大部分的箭頭三種都不需要。兩個 module 直接互相呼叫,改的時候一起改,往往就是最便宜的結構。

Dependency topology 真正想表達的事

回到開頭那句「把 A 和 B 解耦」,在部分情況下可能是成立的,但有時候也是不可或缺的。

這時透過不同的形狀,對 coding agent 而言,具有不同的優勢:

  • 減少:讀到門就可以停,B 的細節不必進 context。
  • 反轉:改 A 的時候不必真的去理解 B,設計一個假的 B 就能跑 test。新增一種 B 是加檔案,不是改 A;interface 本身就是任務說明。
  • 間接化:只讀 contract 就能做其中一邊。A 和 B 可以交給不同的 agent 各自進行。

但也有代價的。每多一層,agent 順著 code 追下去就也多了一層。到了 contract,import 與 type checker 都幫不上忙,除非把它寫成兩邊都 import 的型別。

當 Dependency 拿不掉時,先看清楚是哪一種 change 太貴,再選對應的形狀。

Reference


上一篇
Day26: OCP 與 YAGNI:設計越多,越好?
下一篇
Day28: 階段三回顧:沒有標準答案,只有取捨
系列文
AI時代下的軟體工程 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言