前幾天談的,都是一個 boundary 自己的事:什麼放在一起、什麼留在裡面、什麼時候開擴充點。
今天談兩個 boundary 之間的那條線。
「把 A 和 B 解耦」是一句聽起來永遠正確的話。但只要 A 需要 B 才能完成工作,兩者之間就一定有 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 不受影響
付出什麼:
什麼時候值得:很多地方都知道 B 的細節,而 B 不歸我們管(第三方套件、別的團隊的服務),或者它常常變。
核心理念是:把複雜的實作細節藏在乾淨的介面之後。
窄門處理的是寬度。方向沒有動,還是 job → blob。
假設來了兩個新需求:
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 歸誰:
同樣的形狀很常見:
sorted(words, key=len)。sorted() 不知道我們要怎麼排,由我們把規則交給它。買到什麼:當 B 換一種做法,或是多一種 B,A 都不必修改
付出什麼:
什麼時候值得: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、由不同的人維護,各自更新。
付出什麼:
什麼時候值得:另一邊有很多個、會持續增加、由不同的人維護。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 直接互相呼叫,改的時候一起改,往往就是最便宜的結構。
回到開頭那句「把 A 和 B 解耦」,在部分情況下可能是成立的,但有時候也是不可或缺的。
這時透過不同的形狀,對 coding agent 而言,具有不同的優勢:
但也有代價的。每多一層,agent 順著 code 追下去就也多了一層。到了 contract,import 與 type checker 都幫不上忙,除非把它寫成兩邊都 import 的型別。
當 Dependency 拿不掉時,先看清楚是哪一種 change 太貴,再選對應的形狀。