iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

「先畫出誰依賴誰的話,再聽他們說了什麼;地圖比證詞更難竄改。」
——《阿帕契開源審計錄》¹ 卷三·鳥瞰篇

幕間
女巫在最後的白天向獵人與七號留話:「銀水那件事,是我欠村莊的。你們一個有槍,一個有紙,足夠了。」
當夜,二號的狼刀落在女巫身上。女巫平靜倒牌,長桌只剩三人。

第六世,我睜眼時有幾個細節已經自己理順了,像中間又跳過了一輪沒被記錄。以前每次重開,我會逐句回想上一世怎麼死的;這一次我沒有。我改成往後退三步,把整座村莊當成一張圖來看:預言家的發言不依賴任何人,女巫的行動掛在預言家的查驗上,平民的票掛在警長指定的發言方向上——誰的話撐著誰,一目了然。

畫圖的時候我抬了一次頭。那輪一直掛在城堡上空、從不移動的月亮,邊緣缺了一小角,泛著很淡的紅——上一世好像不是這個顏色。我說不上那意味著什麼,只知道它是這片永夜裡唯一會變的東西。

七號也在做同一件事,只是她真的畫了出來。羊皮紙上不再是一條一條的紀錄,而是一張線路圖:每個座位是一個節點,箭頭代表「這個人的判斷是從誰那裡來的」。

輪到二號發言,他照例先把五號最後一句話覆誦一遍——「……所以四號昨天那一票是投錯的」——然後才接上自己的推論。我在心裡記下:他總是接在別人的句尾。當時我只當那是謹慎。

一個新人第一次打開 Kafka 或 Kubernetes 的原始碼,看到的也是這種需要先畫圖才進得去的龐然大物:幾十萬行、上千個檔案、十幾年的歷史堆疊。你不可能全部讀完,也沒必要。你需要的是一張夠用的地圖,和一條能從頭走到尾的路。


先鳥瞰,不要先鑽

面對數十萬行的遺產程式碼(legacy code),最糟的做法是從第一行開始讀。正確的順序是先建三張圖:

  1. 模組圖:目錄樹加上「允許的依賴方向」。Kafka 裡 clients 不依賴 core、core 依賴 clients,storage、raft、streams、connect 各自成塊;Kubernetes 裡 staging/src/k8s.io/{api,apimachinery,client-go} 是對外函式庫、pkg/ 是實作、cmd/ 是進入點。把這個方向畫對,你就知道一個 bug「不可能」出現在哪些地方。
  2. 呼叫鏈(Call Hierarchy):挑一個進入點——一個 CLI 子指令、一個 RPC handler、或一個會跑的測試——往下追它呼叫誰,往上追誰呼叫它。工具就是 IDE 的 Call Hierarchy、LSP 的「find references」、grep,以及一個失敗測試吐出來的堆疊追蹤。
  3. 資料流:一筆請求或一則訊息,從入口到落地,依序經過哪幾層。

這三張圖畫完,你對這個 codebase 的掌握度,已經超過很多改過它好幾次卻從沒退後一步看全景的人。

為什麼是從邊界開始,而不是從 main 開始?因為 main 只告訴你程式怎麼啟動,不告訴你它怎麼運作。真正劃出系統形狀的是公開介面與進入點:一個函式庫對外 export 哪些型別、一個服務開哪些 RPC 或 HTTP 端點、一個 CLI 有哪些子指令。這些是「別人怎麼使用這個系統」,也是行為的錨點。從邊界往內追,你追的是真的會被執行到的路徑;從 main 往下讀,你很快就會陷在初始化程式碼的泥沼裡,那些程式碼佔行數最多、卻跟你要解的問題八竿子打不著。

Call Hierarchy 跟 grep 的差別在於「它懂型別」。grep 'append' 會把所有叫 append 的函式、字串裡的 append、註解裡的 append 一起撈給你;Call Hierarchy 只給你「真的呼叫到這個方法」的位置,還能區分方法改寫(override)、介面實作、以及呼叫的方向。在一個有一萬個 send 的 codebase 裡,這個差別決定你是花十分鐘還是花一整天。

畫呼叫鏈的工具,四語言各有一套:Go 有 go doc、callgraph,加上編輯器的 LSP;Rust 靠 rust-analyzer 的「find all references」與 cargo tree 看 crate 依賴;Java 在 IntelliJ 直接按 Call Hierarchy,或用 jdeps 分析套件依賴;Python 動態性高、靜態分析先天吃虧,實務上會搭配 py-spy 之類的取樣分析器,直接看真實執行時哪些函式在熱路徑上。工具不同,做的事一樣:從一個點出發,把它的上游與下游攤開成一棵樹。


依賴切除與特徵測試

要安全地改一段你還看不懂的程式碼,先寫 characterization test(特徵測試,又叫 golden master):不去判斷它現在的行為對不對,只把「它今天會輸出什麼」原原本本釘死成斷言。這樣一來,你之後任何一個改動只要動到可觀察的行為,測試就會大聲喊出來。

接著找 seam(縫)——介面、建構子注入、可被子類改寫的方法——把周邊依賴換成假物件,讓你能單獨執行你關心的那一小段。Kafka 裡大量把 Time、Metrics 這類東西做成可注入的參數,就是為了留縫。找不到縫的時候,第一個小改動往往就是「開一道縫」:把某個直接 new 出來(或直接呼叫 Math.random()、System.currentTimeMillis())的依賴改成從建構子傳進來,這種改動風險極低,卻讓後面所有測試變得可能。

要注意的是,特徵測試不是「好測試」,它只是「安全網」。它可能把一個 bug 的行為也一起釘死了——沒關係,你現在的目標不是修對,是「在不知道對錯的情況下,確保自己沒有把事情弄得更糟」。等你真的看懂了那段邏輯、確認某個 golden value 其實是錯的,再另開一個 PR 專門修那個行為,並在描述裡說清楚為什麼舊值是錯的。一次只動一件事。

package com.castronegro.legacy;

import java.util.function.DoubleSupplier;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

// The legacy class, after the one small change that opened a seam: it used to call
// Math.random() directly for jitter, which made its output impossible to pin.
// Now the jitter source is a constructor parameter (production passes Math::random).
class RetryBackoff {
    private final long baseMs;
    private final double factor;
    private final long maxMs;
    private final DoubleSupplier jitter; // returns a value in [0, 1)

    RetryBackoff(long baseMs, double factor, long maxMs, DoubleSupplier jitter) {
        this.baseMs = baseMs;
        this.factor = factor;
        this.maxMs = maxMs;
        this.jitter = jitter;
    }

    long nextDelayMs(int attempt) {
        long exp = (long) (baseMs * Math.pow(factor, attempt - 1));
        long withJitter = exp + (long) (exp * 0.5 * jitter.getAsDouble());
        return Math.min(withJitter, maxMs);
    }
}

// Characterization test: we do NOT yet know whether this backoff behaviour is
// "correct". We pin down what the legacy code does today, so any future change
// that alters the observable output fails loudly.
class RetryBackoffCharacterizationTest {

    @Test
    void currentBackoffSequenceIsPinned() {
        // Jitter fixed at 0.0: the seam lets the test remove the randomness.
        RetryBackoff backoff = new RetryBackoff(100L, 2.0, 1000L, () -> 0.0);
        // Golden values captured from the untouched legacy implementation.
        assertEquals(100L, backoff.nextDelayMs(1));
        assertEquals(200L, backoff.nextDelayMs(2));
        assertEquals(400L, backoff.nextDelayMs(3));
        assertEquals(1000L, backoff.nextDelayMs(10)); // capped at maxMs
    }
}

這段測試沒有斷言任何「應該」的行為,只斷言「目前」的行為——四個 golden value 是從還沒被碰過的舊實作直接跑出來的。DoubleSupplier jitter 是那道縫:舊類別直接呼叫 Math.random() 加抖動,每次跑出來的延遲都不一樣,根本沒辦法釘住;把抖動來源變成建構子參數、測試裡固定成 0.0 之後,測試才拿得回控制權(上線時傳入 Math::random,行為完全不變)。這是切入 legacy 最安全的第一步:先讓它可測,再談改它。

什麼時候才鑽細節

當模組圖告訴你「問題只可能在這三個檔案裡」、呼叫鏈告訴你「熱路徑就是這五個方法」,你才打開那五個方法逐行讀。先用地圖把範圍收斂到十分之一,再進去讀細節,你就不會在枝節裡迷路,也不會改了一個看似無害的地方卻震到三個模組。

這裡有個常見的陷阱:新人一進場就想「把整個專案讀懂」,結果三個月過去還在原地。真正有效的做法是「以問題為錨」——你手上有一個具體的 issue,模組圖幫你排除掉九成不相關的程式碼,呼叫鏈幫你鎖定那條真的會被執行到的路徑,特徵測試幫你確認改動沒有波及別處。你不需要懂整座村莊,你只需要懂「跟今晚這一票有關」的那幾個節點。等你解過十個 issue,全景自然就浮現了。

決定「不讀什麼」跟決定「讀什麼」一樣重要。經驗法則:產生出來的程式碼(protobuf stub、mock)不讀,看它的介面就好;跟你的 issue 不在同一條呼叫鏈上的模組不讀;建構腳本與 CI 設定,等你真的要動它們的那天再讀。把注意力全部留給那條你追出來的熱路徑上的五到十個檔案,其餘幾十萬行就讓它們留在地圖上當背景。

https://ithelp.ithome.com.tw/upload/images/20260924/20183684ggV5XjAdM5.png


讓代理人也先鳥瞰:把地圖做成一張可查詢的圖

前面畫的模組圖與呼叫鏈,本質上是一份「誰依賴誰」的知識圖譜——只是傳統做法要靠人肉盯著 IDE 的 Call Hierarchy 一步步展開,畫完就收進抽屜,下次換一個問題又得重畫一次。如果讓 AI 代理人參與 legacy code 的探索,同一個道理成立:與其讓它每次都對著幾十萬行原始碼從頭 grep、憑印象拼湊呼叫關係,不如讓它查詢一份持久化的程式碼知識圖譜——節點是函式與類別,邊是呼叫關係與資料流向,畫一次、之後每個問題都能重複查。

這正是本系列寫作環境自己遵守的規矩:一份強制規範要求任何程式碼探索都得先用 search_graph(依名稱或型別找節點)、trace_path(沿呼叫鏈或資料流追蹤)、get_architecture(拿到模組層級的全景)這類圖譜查詢工具,把逐行翻找留到圖譜答不出來的時候才用。放回今天的比喻裡:這就是把七號那張手畫的線路圖,變成一份任何人、任何時候都能重新查詢的活文件——地圖只需要建一次,之後每一次「這個函式被誰呼叫」的提問,都是一次查詢,而不是一次重畫。


這個答案在牌桌上的後果

畫完那張線路圖,我看見一件事:二號坐在一個很奇怪的節點上。每一支箭頭都指向他——他消化別人的發言、覆誦、附議——卻沒有一支箭頭從他指出去。他沒有給過村莊任何一個原創的判斷。就像一個模組只 import、從不 export,可是少了它,整個構建又連結不起來。我把它記在心裡,沒有下結論;七號也一樣,她把節點留在圖上,繼續觀察。一張畫對方向的依賴圖,會讓某些原本藏在噪音裡的東西自己浮出來——不是因為你更聰明,而是因為結構被攤平了。這一世我第一次不是用直覺看牌,而是用一張圖看牌——這也是我們 2N1P 讀任何一個陌生大專案的起手式。

讀完今天,你應該能做到:進一個陌生的 repo 時,先產出一頁模組圖、把一條呼叫鏈從進入點追到底,再動任何一行;改 legacy 之前,先補一個特徵測試把現況釘住。可以對照 Apache Kafka 官方文件 - Design 章節 與 Kubernetes staging/ 原始碼目錄,練習把文字描述還原成自己的圖。

參考資料與延伸閱讀


¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。


上一篇
Day 24|實戰起航:在四大專案認領第一個 issue
下一篇
Day 26|狼的偶發破綻,就是一個 flaky test
系列文
狼人自爆的心路歷程:一個「AI人」的30天自學修煉 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言