從修改紀錄畫模組邊界:修改競爭、熱點的量測與分類、職責歸類、拆分時維持行為的做法、拆分時機與拆完的驗證
本篇是軟體設計原則模組的章節,範圍從用腳本讀 git 歷史、統計每個檔案被幾個不同的需求修改開始,到熱點的分類、從修改紀錄決定切法、拆分時與語言無關的做法、拆分合併的時機,以及拆完之後怎麼驗證切法;量測與歸類只讀 git 歷史,與專案用什麼語言寫無關,單一職責等原則的定義在本模組的知識卡片。範例是一支以 Python 寫成的 commit 守衛 guard.py:多個 AI 代理人各自在自己的工作目錄同時開發時(下文稱並行期間),代理人工具在執行每一條 shell 指令之前把整條指令字串交給它判斷,它擋下把 commit 寫進其他代理人工作目錄的寫法。命令解析、狀態判斷與訊息產生三種職責寫在同一個檔案裡。
保留原本的匯入路徑、改寫分派、避免循環匯入這幾件事的寫法依語言而定,Python 的做法在拆分 Python 模組;Go 搬移時用 type alias 保留舊的匯入路徑、一次只搬一個邊界,在 7.5 以 domain 重新整理 package,多檔案、多個 package 與 import cycle 的基礎在 1.5 從單檔到多檔案;規則文件、操作手冊這類文字文件的拆分寫在依職責拆分文字文件。
修改競爭與單一職責原則
修改競爭指的是處理不相干需求的人或代理人要改同一個檔案。兩個需求不相干,指它們要求的改動屬於不同種類(例如一個改命令的解析方式、一個改提示訊息的措辭);讀修改紀錄的人要逐個 commit 歸類之後才判得出來,下文〈從修改紀錄找出職責邊界〉說明做法。git 只在兩邊改動的行重疊或相鄰時產生合併衝突,所以同一個檔案的修改不一定互相阻擋;會讓它們互相等待的是三種情形:
- 改動落在同一處:例如兩個需求都在同一份清單或檔尾追加一筆。處理第一個需求的人先改、先合併,處理第二個需求的人拉最新版、解衝突、重跑測試。
- 合併沒有文字衝突,而行為互相影響:兩個需求各改一個函式,git 合併成功,而後合併的那一方 rebase 之後仍要重跑測試,確認兩邊沒有改壞對方。
- 並行的代理人共用同一個工作目錄:兩方的改動還沒提交就混在同一份檔案裡,而 commit 記錄的是整份檔案的內容,任一方提交都會帶走另一方的改動,所以一方要等另一方提交或清空改動才能動手。
團隊排好修改的順序、加強溝通,能縮短每次等待的時間,而一個檔案承載的職責越多,落在它身上的不相干修改越多,等待的次數跟著增加。只有一個人依序開發時沒有等待成本,不相干的修改落在同一個檔案仍然有代價:一個需求的改動有機會改壞另一個需求在乎的行為。讓一個需求的改動碰不到另一個需求在乎的行為,這種隔離正是單一職責原則要保護的對象。
單一職責原則(SRP)把職責定義成改變的理由:一個模組應該只有一個改變的理由,而理由的計數單位是改動的種類。提出變更的那群人(需求的提出者,例如某個業務部門)通常各自要求同一類改動,所以可以拿來辨認種類;同一群人要求了好幾類改動、或不同的人要求同一類改動時,照改動的種類算(定義、Martin 的原文與「只做一件事」為什麼是另一條原則,在那張卡片)。這個定義把抽象的「職責」換成了可以觀察的對象——改變的理由。版本歷史記下了每一次改變,也記下了它是為了哪一個需求(票、issue、PR)而做,需求數因此可以當成改變理由的間接量測(proxy measure)。
本篇說的模組是有對外介面的程式單位(Coupling(耦合)卡片有同樣的界定),量測的單位則是檔案。Python 一個檔案就是一個模組,Dart 不用 part 時一個檔案就是一個 library,兩者等價;Go 的一個 package 由好幾個檔案組成,熱點檔依職責拆成同一個 package 裡的幾個檔案,不同需求改的就是不同檔案,git 層面的修改競爭就解掉了;拆出的這幾類是否需要各自的對外介面、是否再拆成不同的 package,是另一個判斷。
| 現象 | 代表什麼 |
|---|---|
| 同一個需求的多次 commit 都改同一個檔 | 正常。同一主題的演進集中在一處,這是內聚(cohesion) |
| 三、四個不同的需求都要改同一個檔 | 這個檔可能有好幾種改變的理由;實際幾種,由逐個 commit 歸類決定 |
腳本數的是不同的需求,不判斷這些需求相不相干,所以需求數只是篩選訊號:讀排行的人用它決定要回頭讀哪幾個檔案的修改紀錄,一個檔案實際有幾種改變的理由,要等他逐個 commit 歸類之後才判定。讀排行的人只從前段挑檔案,排在後段的檔案不會再被逐個 commit 檢查,所以會讓需求數少算的情形(見〈用 git 歷史量出熱點〉的「一張票涵蓋好幾類改動」)要另外留意。本篇範例 commit 守衛的需求都服務同一件事(擋下跨工作目錄的 commit),而它們要求的改動分成三種:命令語法跟著 git 的寫法變、並行政策跟著團隊的工作流程變、提示措辭跟著使用者的回饋變,三種改動的起因不同、發生的時間也不同,即使所有票都由同一位 PM 開,只看開票者是誰分不出來,看每張票要求的改動種類才分得出來。這正是 Divergent Change(發散式變更):一個模組因為不同的理由、以不同的方式被修改。
行數與不同需求數的量測對象
團隊判斷一個檔案要不要拆時,常用的門檻是行數,例如超過 300 行就考慮拆。行數是檔案過大的症狀,而它在兩種檔案上給出的結果與不同需求數相反:
| 情況 | 行數判斷 | 不同需求數判斷 |
|---|---|---|
| 200 行的骨架產生器,被 6 個不同需求改過 | 不到門檻 | 排在前段,回頭讀修改紀錄 |
| 900 行的解析器,只屬於同一個需求的連續演進 | 超過門檻(誤判) | 只有一個需求,不會排在前段 |
行數量的是檔案的大小,不同需求數量的是一個檔案可能混了幾種改變的理由,SRP 在意的是改變的理由有幾種。開發者照行數把 900 行的解析器切成三份、不看修改落在哪裡時,同一主題之後的修改常常要同時改好幾個檔;一個需求改多個檔案是 Shotgun Surgery(散彈式修改)。與它成對的發散式變更,正是本篇用修改紀錄量的那種壞味道(從程式碼結構偵測的規則在 Code Smell 檢查清單的〈B1. Divergent Change〉一節),發散式變更在並行開發時造成的等待,就是修改競爭的成本。
用 git 歷史量出熱點
量測的前提是需求與檔案的對應查得到。本篇從 commit 標題裡的需求編號取(commit 訊息帶票號的慣例見 Commit message vs source code doc 的〈Bug 號、ticket 連結、incident 紀錄〉一節;那裡的例子把票號寫在內文,下面的腳本只讀標題,處理方式見〈只讀 commit 標題〉那一條)。想避開只讀標題這個限制的團隊,也可以從 issue tracker 取:匯出每個 PR 連到的 issue 與 PR 改的檔案,整理成「檔案 → 需求編號集合」的對應,就是腳本裡 collect_changes 回傳的那個形狀,之後的排行與判讀照用,而且沒有只讀標題帶來的限制。
commit 標題帶有需求編號(票號、issue 號)時,下面的腳本算出每個檔案被幾個不同需求改過。下文把排行前段、與同一次統計裡的其他檔案相比被明顯更多不同需求修改的檔案稱為修改熱點(簡稱熱點),例如排行前幾名與其後的檔案之間有一段明顯的落差;它不設絕對的門檻,也沒有固定的名次。它與效能分析裡「最常執行的程式路徑」那種熱點是兩件事;git 歷史分析的文獻裡也有 hotspot(Adam Tornhill 在《Your Code as a Crime Scene》裡結合修改頻率與程式碼的複雜度),本篇數的是不同需求數,不數 commit 次數,也不乘複雜度。腳本以 Python 寫成,只呼叫 git log、不讀程式碼,放進 Go 或 Dart 的 repo 照樣能跑。
1"""依 commit 標題中的需求編號,統計每個檔案被幾個不同需求修改。"""
2import collections
3import fnmatch
4import os
5import re
6import subprocess
7
8ISSUE_ID = re.compile(r"#(\d+)") # 依專案的 commit 慣例調整
9PR_SUFFIX = re.compile(r"\s*\(#\d+\)\s*$") # squash merge 在標題結尾附加的 (#PR號)
10STRIP_PR_SUFFIX = True # 只在用 squash merge、標題結尾的 (#N) 是 PR 號時設 True
11# 工具產生的檔案,不列入統計;樣式比對檔名或相對 repo 根目錄的路徑,依專案的套件管理與程式碼產生工具調整
12# generated/* 只比對根目錄那一層,子目錄裡的要另外寫 */generated/*
13EXCLUDE = {"*.lock", "package-lock.json", "go.sum", "*.g.dart", "*.pb.go", "*_pb2.py",
14 "generated/*", "*/generated/*"}
15EXCLUDE_ISSUES: set[str] = set() # 橫切的需求編號(相依套件升級、全域改名),不列入統計
16
17def excluded(path: str) -> bool:
18 return any(fnmatch.fnmatch(path, p) or fnmatch.fnmatch(os.path.basename(path), p) for p in EXCLUDE)
19
20def collect_changes(since: str, path: str = ".") -> tuple[dict[str, set[str]], int]:
21 log = subprocess.run(
22 # core.quotePath=false:非 ASCII 檔名照原樣輸出,不轉成跳脫碼
23 ["git", "-c", "core.quotePath=false", "log", f"--since={since}", "--no-merges",
24 "--name-only", "--format=@@%s", "--", path],
25 capture_output=True, text=True, check=True,
26 ).stdout
27
28 issues_per_file: dict[str, set[str]] = collections.defaultdict(set)
29 current: set[str] = set()
30 skipped = 0
31 for line in log.splitlines():
32 if line.startswith("@@"):
33 subject = line[2:]
34 if subject.startswith('Revert "'):
35 current = set() # revert 的標題帶著被還原那個 commit 的 PR 號
36 continue
37 if STRIP_PR_SUFFIX:
38 subject = PR_SUFFIX.sub("", subject)
39 current = set(ISSUE_ID.findall(subject)) - EXCLUDE_ISSUES
40 skipped += not current
41 continue
42 if line and current and not excluded(line):
43 for issue in current:
44 issues_per_file[line].add(issue)
45 return issues_per_file, skipped
46
47def report(since: str, top: int = 20) -> None:
48 changes, skipped = collect_changes(since)
49 ranked = sorted(changes.items(), key=lambda kv: -len(kv[1]))
50 for path, issues in ranked[:top]:
51 print(f"{len(issues):3d} 個需求 {path} {' '.join(sorted(issues))}")
52 print(f"略過 {skipped} 個標題沒有需求編號的 commit")
53
54if __name__ == "__main__":
55 report("2 weeks ago")1# 存成 hotspots.py 之後,在 repo 的根目錄執行(Python 3.9 以上)
2python3 hotspots.py腳本裡影響計數的設計:
統計的時間範圍:
report()的引數是git log --since接受的時間字串("2 weeks ago"、"2026-01-01")。執行腳本的人設定的範圍要涵蓋幾輪需求,讀排行時才看得出同一個檔案被不同需求反覆修改;範例的兩週適合需求密集的專案,修改頻率低的專案要拉長。一個檔案被幾個需求修改才算多,這個數字跟著時間範圍變,所以讀排行的人比較的是檔案在排行裡的相對位置,不比絕對數字。統計的目錄:
collect_changes的path預設是".",指的是執行腳本時所在的目錄;執行腳本的人從子目錄執行時,腳本只統計那個子目錄底下的檔案。repo 裡文件與程式碼混在一起、執行腳本的人只想統計其中幾個目錄時,在git log的--之後列多個路徑。只讀 commit 標題:腳本傳給
git log的--format=@@%s讓 git 對每個 commit 只輸出標題。把需求編號寫在內文或 trailer(訊息結尾Refs: #160這種鍵: 值形式的欄位)的專案,腳本會靜默略過這些 commit;這類專案改用%B取完整訊息時,內文的行會和檔名混在一起,分隔方式與解析都要跟著改。PR 號:GitHub 的 issue 與 PR 共用同一組
#N編號,squash merge(把整個 PR 壓成一個 commit 合併)又會在標題結尾附上(#PR號),只改ISSUE_ID分不開兩者;STRIP_PR_SUFFIX為True時,腳本先去掉標題結尾的(#N)再比對。STRIP_PR_SUFFIX這個設定只適用用 squash merge 的 repo,而且需求編號要寫在 PR 標題裡;習慣在標題結尾寫(#45)引用 issue 的專案,要把STRIP_PR_SUFFIX設成False,否則那個 issue 號會被刪掉。需求編號與 PR 編號格式不同的專案(例如需求用PROJ-123),改ISSUE_ID就分得開。revert 的標題會把被還原那個 commit 的 PR 號帶進引號裡,腳本略過Revert "開頭的標題(不計入最後印出的略過數)。工具產生的檔案:lock 檔、每個 PR 都跟著升號的版號檔、產生的程式碼,每個需求都會改到,排行會被它們佔滿,而它們沒有任何人帶著意圖去編輯;執行腳本的人在統計前把檔名或檔名樣式(
*.g.dart、*/generated/*)加進EXCLUDE;路徑樣式比對的是相對 repo 根目錄的完整路徑。改名與橫切的需求:
git log加了--name-only不追蹤改名,大量重新命名之後,舊路徑與新路徑各自計數。相依套件升級、全域改名、格式化這類橫切的需求會讓大量檔案各加一,排行前段一整批同時出現的檔案,多半來自同一個橫切需求;EXCLUDE_ISSUES用來排除這類需求的編號。沒有編號的 commit:腳本最後印出略過的 commit 數。Dependabot、Renovate、發版機器人這類 bot 的 commit 也不帶需求編號,讀排行的人判讀前先扣掉它們(
git log --format加%an看作者)。扣掉之後的數字仍接近全部 commit(例如九成以上),統計沒有意義;commit 完全不帶編號時排行是空的、只印出這一行。這時讀排行的人改用〈從修改紀錄找出職責邊界〉的做法,讀 commit 標題與git show人工歸類;沒有排行可以挑檔案時,先數每個檔案被幾個 commit 改過,取前段當候選:1# 時間範圍用與熱點統計相同的範圍 2git log --no-merges --since="2 weeks ago" --name-only --format= | LC_ALL=C sort | LC_ALL=C uniq -c | sort -rn | head -20這條指令數的是每個檔案被幾個 commit 改過:同一個需求分成多次 commit 時每次各算一次,大檔案又常在開發者處理別的需求時被順手修改,所以一個檔案排在前段,不代表它的改變理由比較多。讀排行的人只拿這份清單挑出要讀修改紀錄的檔案,每個檔案有幾種改變的理由,要等逐個 commit 歸類之後才判定。
編號是不是需求:略過數量的是多少 commit 沒有編號,量不出比對到的
#N是不是需求——標題裡的#N也可能是文件編號、討論串引用或內部卡片的序號。執行腳本的人跑完之後,抽幾筆被計入的 commit 讀標題,確認編號指的是需求,再看排行。--no-merges:git log預設不列出 merge commit 改了哪些檔案,所以 merge 不影響排行;標題沒有編號的 merge(Merge branch 'x')會被算進略過的 commit 數,腳本加的--no-merges讓 git 不輸出它們。改用-m時,merge 對每個 parent 各列一次檔案:對第一個 parent 列出的是分支帶進來的檔案,它們除了分支 commit 的編號,又記到 merge 標題的編號下;對第二個 parent 列出的是主線同期改過的檔案,它們被記到一個沒改過它們的需求名下。改用--first-parent時只走主線,分支上的 commit 不列出,改動改由 merge commit 列出、記在 merge 標題的編號下;--first-parent與--no-merges一起用時,以 merge commit 合併的 repo 會失去全部分支上的改動。一張票涵蓋好幾類改動:一張概括性的票(一張「維護」票、一個 epic 編號)底下做了好幾類改動時,有好幾個理由的檔案只算一個需求,排不進前段。這一類少算的方向腳本看不出來,常用這類票的專案要把它們拆回實際的需求,或把那些票號另外列出來,對它們改過的檔案直接做逐個 commit 歸類。
一則 commit 帶多個編號:腳本用
ISSUE_ID.findall取出標題裡的全部編號。一個 commit 同時處理多個需求時,它改過的每個檔都會記到每個需求名下。排行裡一整批檔案同分、而它們共有的編號都來自同一個 commit 時,讀排行的人要排除的是那個 commit,不是那幾個編號(排除編號會連帶扣掉其他檔案正當的計數):修改腳本的人把--format改成@@%h %s,subject = line[2:]之後先切出開頭的 hash,讀到要排除的 hash 時清空current,其餘照原本比對標題。把子需求歸到同一個主題:點號格式的子需求(例如
#120.3),正規式#(\d+)只比對到第一段數字,本來就會算進根需求 120。子需求若有獨立編號(例如父需求#120、子需求#135),執行統計的人要另備一份父子對照表,把子需求歸到根需求再計數;不歸併的話,同一主題的連續修改會被誤算成多個改變理由。〈熱點的分類與處理方式〉表格裡「同一主題的連續演進」那一列,指的是子票已經歸到根需求之後的計數。同一功能的各輪追修另開新編號:同一個功能經過好幾輪審查時,每一輪的追修常另開一個新需求,編號不同、彼此沒有父子關係,父子對照表也歸併不了。這些編號在排行裡各算一個,一個被反覆修改的功能看起來像好幾個需求在搶改同一個檔,這個檔因此排進前段,讀排行的人逐個 commit 歸類時,時間會先花在它身上。讀排行的人對排在前段的檔案,先用〈從修改紀錄找出職責邊界〉那條
git log --format='%h %s'列出 commit 標題,把屬於同一功能的編號合併成一個主題,再用合併後的主題數與其他檔案比較;合併之後仍排在前段,才進入逐個 commit 歸類。腳本不讀標題的語意,哪些編號屬於同一功能要由人判讀。
熱點的分類與處理方式
排行前段的檔案有些該拆、有些不該拆,讀排行的人先依下表認出每個檔案的熱點類型,再照該列處理:
| 熱點類型 | 例子 | 處理 |
|---|---|---|
| 一般程式碼,排在前段、被多個不同的需求修改 | commit 守衛 guard.py:命令解析、狀態判斷、訊息產生寫在同一個檔 | 依〈從修改紀錄找出職責邊界〉評估拆分 |
| 同一主題的連續演進 | 一個佇列模組,被同一個「排隊機制」需求的 5 張子票陸續修改(子票歸到根需求後只算一個需求) | 不拆,這是內聚 |
| 集中登記檔:每加一個功能就要在這裡登記一筆的檔案 | URL 路由表、外掛註冊表、settings.json、依類型分支的長 if/elif 或 switch | 不以職責拆分,改成每個項目一個片段檔 |
| 只有測試檔是熱點 | 一個大測試檔被多個需求加測項 | 見表下說明 |
| 工具產生或衍生的檔案 | lock 檔、每個 PR 都升號的版號檔、產生的程式碼 | 不拆,從統計排除;衝突靠重新產生解 |
讀排行的人對照這張表時,有些檔案同時符合兩種類型,例如 i18n 字串檔:來源語系的字串由開發者在每加一個功能時登記一筆,屬於集中登記檔;翻譯語系的字串由翻譯平台寫入,屬於工具產生的檔案。兩種類型的處置不同,所以他依每次修改的寫入者是人還是工具,把這個檔案的修改分到兩種類型,各自照該列處理。
熱點統計以檔案為單位計數,所以不落在單一檔案上的競爭不會出現在排行裡。例如 schema 遷移:開發者每次改資料庫結構都新增一個遷移檔,兩個分支同時各加一個遷移時,合併後會出現兩個都排在最後的遷移,競爭發生在遷移的先後順序上,而每個遷移檔在統計裡都只被一個需求改過。
lock 檔與產生的程式碼由工具改寫:開發者執行套件管理或程式碼產生工具時,工具依套件清單或來源定義重新寫出整個檔,內容由工具的輸入決定,所以這些修改歸不進任何一種職責:執行腳本的人在統計前把它們加進 EXCLUDE 排除;兩個分支同時改到同一個 lock 檔而衝突時,解衝突的人重新執行產生它的工具,不逐行挑選兩邊的內容。每個 PR 都升號的版號檔,團隊改由發版時打的 tag 衍生版號之後,PR 就不必再改它。
版號寫在開發者手動編輯的檔案裡時(package.json 的 version、__init__.py 的 __version__),同一個檔也有人為了需求修改其他內容,把整個檔加進 EXCLUDE 會連這些修改一起從統計拿掉;逐個 commit 歸類的人略過只改版號的那幾個 commit。根本的解法同樣是改由 tag 衍生版號,開發者就不必再手動修改這個欄位。
集中登記檔的處置跟一般程式碼相反。註冊表的職責是集中列出所有項目,每加一個功能就要登記一次,所以修改它的需求數會隨功能數一起增加。開發者把它依職責拆成三份註冊表之後,每次登記仍然要改其中一份,衝突只是換到另一個檔案;兩個需求都在同一份清單的尾端追加,照樣是相鄰的改動。這類檔案的解法是讓每次登記不碰同一個檔案:每個項目一個片段檔,由載入端掃描目錄合併。這要載入端由自己控制;設定檔由外部工具讀取、而工具只認單一檔案時,片段檔進版控,由一個產生步驟(例如建置前執行的一支合併腳本)合併成那一個檔,合併出來的檔案當成工具產生的檔案從統計排除。
登記的對象是程式碼時,由誰掃描依語言而定:Python 在執行期匯入目錄底下的每個模組(寫法見拆分 Python 模組的〈把巨型分支改成自動登記的分派表〉),Go 在同一個 package 裡讓每個檔案用自己的 init() 登記,不需要掃描;既沒有執行期掃描、也沒有這類機制的語言,走上面那個產生步驟。
片段檔的代價是登記關係不再寫在一個看得到的地方,放進目錄的半成品檔也會被登記。登記項目之間有先後的語意時(路由表先比對到的先生效、middleware 依序執行、依類型分支裡子類別要排在父類別之前),掃描目錄得到的是檔名或檔案系統列出的順序,改成片段檔可能悄悄改掉哪個項目先被比對到;每個片段要宣告自己的排序鍵,由載入端依它排序,特徵測試要涵蓋會被兩個項目同時比對到的輸入。
集中本身有用途時(例如公開 API 的介面檔,集中是為了讓每次新增都經過同一處審查),或衝突多半是單行、由工具重新產生就能解決時(例如套件清單檔),保留集中、接受衝突也是一個出口。
測試檔的修改跟著它測的產品檔走,所以只有測試檔是熱點時,讀排行的人回到它測的產品檔判讀。產品檔也是熱點,測試隨產品檔一起拆。產品檔不是熱點,多半代表這個測試檔一次測了好幾個產品模組;產品檔已經拆成幾個模組、測試卻還集中在一個檔,也是同一種情形,兩者都依它測的模組拆成對應的測試檔。另一種情形是這個測試檔是共用的 fixture 或測試案例清單,每個需求都在裡面登記一筆,那是集中登記檔,依上一段處理。
從修改紀錄找出職責邊界
職責的邊界記在修改紀錄裡:每個需求改的是哪一種職責,讀它的 commit 就知道。熱點統計腳本的 report() 在每個檔案後面印出了改過它的需求編號,負責歸類的人要看每個需求做了什麼時,列出這個檔案的 commit 標題:
1# 時間範圍用與熱點統計相同的範圍,路徑換成要拆的那個檔案
2git log --no-merges --since="2 weeks ago" --format='%h %s' -- guard.py把每個需求歸到它改的職責(並行期間指多個代理人同時在各自的工作目錄開發的期間):
1需求 A:修正命令解析遇到引號時的錯誤 → 命令解析
2需求 B:新增「並行期間」的判斷條件 → 狀態判斷
3需求 C:提示訊息改成指引使用者改寫命令 → 訊息產生
4需求 D:解析器支援 -C 參數 → 命令解析
5需求 E:並行期間改依工作位置過濾 → 狀態判斷逐個 commit 歸類的人看的單位是每個 commit 改到的程式:git show <hash> 看得到那個 commit 改了哪些函式。類別依需求要求的改動種類建立,與歸類的先後順序無關。長期計畫(橫跨多張票、為同一個目標分批進行的工作,例如分好幾輪做完的一次重構)有根需求編號時照根需求歸併;沒有的話,它的 commit 改到好幾處,也只歸進其他需求已經要求過的那些種類,計畫本身不構成一個模組。只有一個需求、改到同一個檔案的好幾處,是同一主題的連續演進,不產生新的類別;那個需求若要同時改好幾個檔案,是散彈式修改的訊號,處理見 Shotgun Surgery 卡片。bug 修正與重構不另算一個理由,依它修的那段程式碼原本屬於哪一類歸進那一類。一段程式(例如兩類都呼叫的計算函式)被兩類需求各自改過時,記成兩類的共用處,不歸給先改它的那一類,拆分時再判斷它跟著哪一類走。這個例子裡每個需求只改到一類,所以逐個需求列出來就是逐個 commit 的結果。
多數需求(需求數超過一半;這是判斷用的經驗值,不是量測結果)都同時改到某兩類時,這兩類對需求而言是同一個改變的理由,歸類的人把它們歸成同一類——依它們切開,每個需求都要同時改兩個新檔,就是散彈式修改。需求本來就是跨層的功能切片時(例如每個新參數都要同時加解析規則與擋下訊息),另外看有沒有需求只改了其中一類:一個都沒有,才把兩類併成一類。
歸類之後,每一類職責是一個候選模組,這個例子分出命令解析、狀態判斷與訊息產生。歸類後只剩一類時,這些需求要求的是同一類改動,這個檔案只有一個改變的理由(內聚),或者它是集中登記檔,歸類的人回到〈熱點的分類與處理方式〉的表重新判斷。歸出的幾類裡有一類是「登記一筆」(同一個檔裡同時有處理邏輯與一張對照表)時,先把那張表拆成自己的檔,依集中登記檔處理,其餘幾類照候選模組判斷。
團隊決定候選模組是否拆開時,看這些修改的代價有沒有出現:有沒有人在這個檔案上等待、有沒有一個需求改壞了另一類在乎的行為,出現任一種就有拆的理由。各類在統計範圍裡只被改過一兩次、而且時間上一個接一個、沒有兩個需求同時在改時,代價還沒有出現,記下這次的歸類,下次重跑統計時再判斷。
拆分時與語言無關的做法:特徵測試、搬移與修改分開提交、單向依賴
團隊決定切法之後,動手拆分的人要把 guard.py 依這三類拆成 command_parse.py、state.py、messages.py,同時維持對外行為不變。做到這一點有三件事與語言無關,各語言的寫法放在各語言模組;要拆的邏輯在其他檔案另有副本時,還有一件事排在這三件之前。
拆分前先收斂散在多個檔案的重複實作。 同一段邏輯在好幾個檔案各寫了一份時(例如讀取同一份設定的函式,在共用模組與幾支腳本裡各有一份),拆分的人先把它收斂成一份、其他檔案改為呼叫它,收斂合併之後再拆各個熱點檔。不先收斂有兩個代價:各份副本跟著需求各自被修改,同一個修正常常只落在其中一份,其餘幾份留著舊的行為;這段邏輯每改一次都要同時改好幾個檔(散彈式修改),這幾個檔都被推進熱點排行,各自拆完之後,每個新模組裡仍帶著一份副本,修改照樣要同時改好幾個新檔。所以收斂是這幾個拆分共同的前置:它自成一個需求、先合併,之後各熱點檔再以收斂後的版本為基準拆分,拆出的模組依賴那一份共用實作。收斂只把同一份知識合成一份;各份已經分歧時,先判斷是某一份漏修,還是兩類需求本來就要不同的行為,後者照 DRY 卡片的判斷各留一份。前者在收斂時會改到漏修那幾份的行為,這個行為改動與合併副本分開提交,理由見下文〈搬移與修改分開提交〉。
拆分前用特徵測試鎖住行為。 拆分前後跑同一組測試、結果相同,才證明拆分沒有改到對外行為。這種測試叫特徵測試(characterization test):預期值取自拆分前程式當下的實際輸出,不是規格上應該如何。既有的測試已經涵蓋要鎖住的輸入與輸出時,直接用它們;沒有時才補特徵測試,拆完、正確性測試覆蓋同一段行為之後,照那張卡片〈設計責任〉的說明移除它。它抓得到拆分在測到的輸入與斷言的輸出上造成的任何差異,包括原本就存在的錯誤被「順手修好」;沒有斷言的輸出抓不到。測試表要涵蓋應該被擋下或應該回報錯誤的反例:只驗證正常情況會放行的測試表,在拆分改壞了擋下的判斷時依然全綠。
搬移與修改分開提交。 搬移和修改混在同一個 commit 裡,測試一紅,就分不出是搬錯了還是改錯了,所以純搬移的 commit 不同時修改行為,行為的修改放在之後的 commit。git diff --color-moved 會把搬移過的行與新寫的行用不同顏色標出,用來確認一個 commit 裡只有搬移。拆分要把原檔換到新路徑時(例如 Python 改成同名套件),改名也單獨提交一個只改名的 commit:git 依內容相似度判定改名,改名與大量搬移混在同一個 commit 時,留下來的那份檔案只剩原檔的一小部分,git 認不出它是改名而來,git log --follow 就追不到改名之前的歷史。只改名的 commit 保住的是留下原名的那一份;其他新檔是新建的,--follow 追不到它們的內容在拆分前的修改,日後想看搬走的程式之前怎麼改的人,用 git blame -C -C <新檔>:-C 給兩次時,git 也會在建立這個檔的 commit 裡找從其他檔複製過來的行,標出它們在原檔的來源 commit。
拆出來的模組依賴只朝一個方向。 依賴單向時,改動下層的模組可能影響上層,改動上層不會影響下層,一次修改會波及哪些模組可以預測;兩個模組互相依賴時,改任何一邊都可能波及另一邊。判斷上下層的依據是誰需要知道誰:被較多拆出模組使用、而且自己不需要知道其他拆出模組的那一個在下層,兩個條件都要成立。這個例子裡 command_parse.py 只把指令字串解析成結構,在最下層;state.py 與 messages.py 使用解析結果;組合三者的那一層在最上面。兩個新模組互相呼叫時,拆分的人看它們過去的修改:幾乎每次都一起發生,代表它們是同一類職責、歸類時切錯了,併回同一個模組(判斷見 Coupling 卡片的〈設計責任〉);修改各自發生,先確認兩個方向的呼叫是否都必要,有時拿掉其中一邊就夠了;兩邊都必要,多半代表中間有一塊共用的定義沒有抽出來,例如兩邊都用到的資料型別,把它往下抽一層,讓兩者都依賴它。往下抽的前提是那塊定義只因一個理由改變;兩類需求各自會改它時(Martin 的 regularHours 例子,見單一職責原則卡片),抽成共用正是那張卡片警告的情形,各留一份(判斷同一份知識與碰巧長得一樣,見 DRY 卡片)。熱點檔拆成同一個 package 裡的幾個檔案時(Go 的常見做法),編譯器不檢查同一個 package 裡檔案之間的依賴方向,這一條作用在之後是否再拆成不同 package 的判斷上。
拆分的時機與等待中的需求
拆分合併的時間點,決定等待中的需求以哪一版為基準。拆分先合併,等待中的需求以新版本為基準,各自只改自己職責所在的新檔:
1拆分前:命令解析、狀態判斷、訊息產生各有一個需求在等,三個都要改 guard.py → 一個接一個
2拆分後:處理命令解析需求的人改 command_parse.py,狀態判斷的改 state.py,訊息產生的改 messages.py → 可以同時進行原本要一個接一個做的三個需求,拆分之後可以平行處理。拆分若在某個需求開工之後才合併,那個需求的分支改的是舊的 guard.py,rebase 到拆分之後的版本時,git 的改名偵測(內容相似度預設 50% 以上就判定為改名,下文的「過半」就是指這個相似度)決定衝突的形態。原檔留在原路徑、只剩組合三者的那一層時(Go 常把 guard.go 留著當組合層),沒有改名,衝突以一般的內容衝突出現在原檔,處置同下;原檔被換到新路徑或刪除時,有兩種形態:
- 沒有一份新檔保留原檔過半的內容:git 判定
guard.py被刪除,出現 modify/delete 衝突。 - 有一份新檔保留了原檔過半的內容:git 把它當成
guard.py改名而來,衝突以一般的內容衝突出現在那份新檔,衝突區塊裡是已經搬到其他模組的舊函式。解衝突的人照一般衝突處理接受這一段,會把搬走的函式重新塞回那份新檔,分支的改動落在那份新檔裡的舊函式副本上。工具是否擋下這份副本,依語言與檔案配置而定:Go 把幾類拆成同一個 package 裡的檔案、Dart 用part把一個 library 拆成好幾個檔時,重複宣告在編譯時就被擋下;Python 這類每個檔案各自是一個命名空間的語言不報錯(Dart 不用part時也是,編譯與dart analyze都不擋),又分兩種情形——那份新檔沒有匯入同名函式時,副本沒有人呼叫,分支的改動等於消失;那份新檔先匯入了同名函式(例如from .command_parse import parse_command)、塞回的def排在 import 之後時,副本蓋掉匯入的名字,被呼叫的反而是帶著分支改動的副本,而真正的新家留著舊版。後一種情形 ruff 或 flake8 的 F811 規則(pyflakes 的 redefinition of unused 檢查)抓得到。
兩種形態的處置相同:解衝突的人把這個分支的改動移到那一職責所在的新檔,不接受衝突區塊裡整段的舊程式碼。rebase 沒有衝突時,分支的負責人也要檢查一次:分支在被當成改名目標的那份新檔裡新增的函式會乾淨合併,即使它屬於另一類職責。用 git diff main --stat 與 git diff main 看分支新增的每個函式落在哪個檔(main 換成拆分合併進去的那條分支)。在檔案各自是命名空間的語言裡,拆分前就有的測試在上面這幾種情形下(modify/delete、內容衝突,以及 Python 的兩種副本)仍然全綠;分支為自己的改動補的測試可能紅,也可能因為副本蓋掉了匯入的名字而全綠,所以上面那個 git diff 的檢查不論落在哪一種情形都有效。這也是拆分要排在等待中的需求開工之前合併的原因。
長期分支很多、團隊找不到「沒有需求在進行中」的時間點時,有三種退路:先把進行中的分支合完再拆,排一個短暫的凍結窗口;一次只抽出一類,原檔保留其餘內容,衝突只落在動過那一類的分支、位置也預測得到;rebase 之後跑會抓重複定義的工具(Go 的編譯、TypeScript 的 tsc、Python 的 ruff F811),再加上面那個 git diff 檢查。分支都很短的團隊(例如 trunk-based development)少有這個情形。
拆分之後重跑熱點統計:驗證切法與新檔再成熱點的判讀
團隊用拆分之後的修改紀錄驗證拆分是否切對。驗證的對象是拆分之後的修改,所以過了幾輪需求之後重跑熱點統計,report() 的引數設成拆分合併的那一天;拆分本身的需求編號加進 EXCLUDE_ISSUES,否則拆分那個 commit 會讓每個新檔各多算一個需求。重跑統計的人比較拆出來的各個新檔被幾個需求修改,並看每個需求是否只改了一個新檔:多數需求同時改到兩個新檔,代表當初把同一個改變的理由切成了兩類。
某個新檔很快又成為熱點時,讀排行的人把它的修改再歸類一次:
- 修改落在拆分時已有的幾類而混在一起:當初的職責分類切錯了位置。
- 出現拆分時沒有的一類:多了一種新的職責。
- 每次修改都是登記一筆:那個新檔本身是集中登記檔。
- 修改全部落在它自己那一類:在這一類裡照〈從修改紀錄找出職責邊界〉再歸類一次。依需求要求的改動種類再分一次:這些需求要求的改動屬於兩種以上的種類(例如有的改解析規則、有的改輸出格式),代表這一類粒度太粗,可以再拆;全部屬於同一種,代表這一類就是程式最常變動的那一處,拆分已經把變動集中在那裡,不必再拆。
拆分時若另抽了一個讓幾個新檔共用的定義模組(例如幾個模組都用到的資料型別),它被每個需求都改到時,常見的原因是共用定義抽得太大,裡面混了只有某一個模組用的型別,開發者把那些型別搬回使用它的模組。
這次驗證之後,團隊照原本的時間範圍定期重跑熱點統計:讀排行的人從〈熱點的分類與處理方式〉重新判讀新出現在排行前段的檔案。
拆分的語言實作:拆分 Python 模組(同名套件與重新匯出、monkeypatch 的替換目標、自動登記的分派表、拆出模組之間的循環匯入)、7.5 以 domain 重新整理 package(type alias 過渡、每次只搬一個邊界、測試保護搬移)、1.5 從單檔到多檔案(Go 的多檔案、多 package 與 import cycle)。文字文件的對應做法:依職責拆分文字文件。