一個 12 萬行的 Rust 後端,260 個模組,ships 成單一 binary、一句 docker compose up 就起得來。但真正值得偷的不是規模,而是作者反覆把「不該發生的事」從 runtime 的祈禱挪進 compile time 的事實——「插入一個沒簽名的 plugin」這句話,在他的型別系統裡根本寫不出來。
120k 行 Rust 後端的一趟導覽——把多租戶、同步引擎與外掛安全都交給型別系統
Nosdesk 的作者把一個生產級後端攤開來寫了一篇導覽:120,000 行 Rust、約 260 個模組、約 1,030 個測試,stack 是 Actix-web 跑 HTTP、Diesel 打 Postgres、Redis 做 fan-out、Tokio 當 runtime。這篇文章不是線性教學,而是「值得學的設計決策」的散點集合——他自己挑了一連串決策,攤開來講為什麼這樣設計。本文也不打算複述全部,而是挑其中五條我認為最有遷移價值的主軸,織成一趟導覽:同步引擎的 single-write log、Postgres LISTEN/NOTIFY 的去重喚醒、把多租戶與外掛安全壓進型別層、email 子系統裡那套手寫韌性機制,以及一套用 lint 當測試逼著每個 write 函式發事件的紀律。
挑這五條的理由很簡單:它們都不是 Rust 特有的炫技,而是「任何後端遲早會碰到、但大多數人會用 runtime check 草草帶過」的問題,被這個 codebase 用更狠的方式解掉了。每一段都會落到具體的 crate 名與機制——Actix-web、Tokio、Diesel、tokio-postgres、yrs、ring——因為脫離了具體機制,這種文章只剩雞湯。
sync_actions:一次寫入,三個 consumer 讀同一行
先講整篇文章我認為最值得偷的一招。Nosdesk 是一個協作型產品,意味著「有人改了東西」這件事要同時餵給好幾個下游:正在連線的 client 要即時收到 push、剛上線在追進度的 client 要 HTTP delta sync 把落後補回來、合規面要一條 audit trail。樸素做法是每個下游各自監聽、各自從業務表撈差異——三條讀路徑,三種「我以為的最新狀態」,遲早 diverge。
Nosdesk 的做法是一張 append-only 的 sync_actions log:每一個業務變更,除了寫進它自己的表,還在 sync_actions append 一列,描述「發生了什麼、順序是多少」。然後三個 consumer——HTTP delta sync、live push channel、audit trail——全部只從這一張 log 讀。作者那句話點破了全部:「If the write landed, every consumer sees the same canonical event in the same order.」只要寫進去了,每個 consumer 看到的就是同一個 canonical event、同一個順序。
這個轉換的威力在於它把「三個下游一致性」這個分散式問題,降維成「三個 reader 讀同一張單調遞增的表」這個單機問題。順序由 sync_id 這個單調遞增的欄位定義,consumer 之間不需要互相協調,各自記住自己讀到哪、下次從那之後撈即可。下面這張可點擊的架構圖是這一段的單一入口——點任一個 box,看它負責什麼、以及它刻意不知道什麼。
write handler · responsibility
執行一個業務變更,寫進它自己的表的同時,在 sync_actions append 一列 canonical event。兩件事在同一個 transaction 裡——要嘛都成立,要嘛都不成立。
Does not know:誰會消費這個 event、有幾個 consumer、它們各自讀到哪。
「emit 一個 sync event」這件事後面會被一條 lint-as-test 強制檢查。
sync_actions · responsibility
append-only,永不就地修改。sync_id 單調遞增,定義了全 workspace 的 canonical order。它是三條讀路徑唯一的事實來源。
Does not know:event 的業務語意。它只保證「順序」與「不可變」。
把「三個下游一致」降維成「三個 reader 讀同一張單調表」。
HTTP delta sync · responsibility
給剛上線、落後的 client 補進度。client 帶上自己的 last_seen,server 用 WHERE sync_id > last_seen 撈出之後的所有 event 回放。
Does not know:live push 推了什麼、audit 記了什麼。它只認 sync_id 區間。
catch-up 與 live 共用同一份 event,不會出現「補進度補到跟即時推的不一致」。
live push · responsibility
把新 event 即時推給連線中的 client,走 Redis fan-out。被喚醒後一樣用 sync_id > last_seen 撈,而不是信任喚醒訊號本身帶來的內容。
Does not know:哪些 client 在 catch-up、audit 何時讀。
喚醒只是「去看一下」的訊號,真相永遠回 log 撈。
audit trail · responsibility
合規面的完整歷史。因為 log 本身 append-only 且有序,audit 不需要另一條寫路徑——它就是同一張 log 的第三個 reader。
Does not know:sync 的即時性需求。它在意的是「不漏、有序、不可改」。
audit 與 sync 共用同一份 canonical event,意味著「審計看到的」永遠等於「使用者同步到的」。
互動圖表
sync_actions 把三個 consumer 的一致性降維成「讀同一張單調遞增 log」的單機問題。
這個設計有一個容易被忽略的副作用:它讓「正確性」與「即時性」解耦。catch-up 走 HTTP、live 走 Redis push,兩條傳輸機制完全不同,但它們讀的是同一份 event、同一個順序,所以一個剛上線的 client 透過 delta sync 補到的狀態,跟一個全程連線的 client 透過 live push 收到的狀態,必然收斂到同一個結果。如果這三條是各自從業務表撈差異,光是「delta sync 撈的 cut-off 時間點」跟「live push 開始監聽的時間點」之間那道縫,就足以讓兩個 client 對「現在是什麼狀態」產生永久分歧。
值得強調的是 append-only 這個約束本身。一旦 sync_actions 允許就地修改或刪除,sync_id 的單調性就失去意義,consumer 的 last_seen watermark 也就不再可信。append-only 不是為了節省什麼,而是為了讓「順序」這個性質變成 structural invariant——資料結構本身保證的,而不是靠程式紀律維持的。
LISTEN/NOTIFY:通知不帶 payload,50 筆 commit 收斂成 1 次喚醒
有了 log,live push 還缺一件事:怎麼知道「有新東西寫進來了」。Nosdesk 用 Postgres 的 LISTEN/NOTIFY,但做法有兩個刻意的反直覺選擇,都值得學。
第一,監聽走一條獨立的 tokio-postgres 連線,跟主 connection pool 完全分開。Diesel 是同步驅動,主 pool 上的連線隨時可能被 spawn_blocking 的重查詢佔住;如果 LISTEN 也擠在這個 pool 裡,喚醒延遲就會被慢查詢綁架。所以監聽自己一條 async 連線,作者用 stream::poll_fn(move |cx| conn.poll_message(cx)) 把 tokio-postgres 的底層 poll 介面橋成一個 Rust async stream,喚醒就是這個 stream 吐出一個 item。
第二,也是更關鍵的:NOTIFY 刻意不帶 payload。作者寫得很白——它「carries no payload, no row id, no hint at what changed」。通知就是一個空的「有事發生」訊號,收到之後 consumer 不去解讀通知內容,而是回 sync_actions 跑一次 WHERE sync_id > last_seen 把自己落後的部分撈出來。
為什麼要這樣自廢武功、不在 NOTIFY 裡塞 row id?因為「不帶 payload」換來兩個性質。其一是 correctness under concurrent writers:作者點出,一個信任 payload 的 handler「would silently miss the rows everyone else committed」——如果你信通知帶來的那個 row id,那麼在你處理它的同時、別人 commit 進來的那些 row 你就漏了,而且是無聲地漏。回 log 撈 watermark 區間則永遠撈得乾淨,不管期間有多少並發寫入。
其二,也是最漂亮的一個性質:去重。NOTIFY 不帶內容,意味著「收到 N 個通知」跟「收到 1 個通知」在 consumer 眼裡是一樣的——反正都是「回去撈 last_seen 之後的全部」。作者那句話是整段的核心:「Fifty rows committed in one transaction collapse to one wakeup instead of fifty. A burst of writes debounces on its own.」一個 transaction 裡 commit 50 列,不是觸發 50 次喚醒、各自撈一列,而是收斂成 1 次喚醒、一次撈完 50 列。一陣寫入的爆量,自己就 debounce 掉了。下面這個 slider 把這個收斂算給你看。
互動圖表
NOTIFY 不帶 payload,同一 transaction 的 50 列 commit 只觸發 1 次喚醒而非 50 次。
把 rows 拖到 50、transaction 留 1,naïve 路徑是 50 次喚醒,coalesced 是 1 次——這正是作者那句話的數字。把 transaction 數拉高,你會看到 coalesced 的喚醒次數只跟 transaction 數成正比,完全不理會每個 transaction 裡塞了多少列。這就是「空 payload」這個看似自廢武功的決定真正買到的東西:在寫入爆量時,喚醒成本與 context switch 次數被天然壓到 transaction 的數量級,而不是 row 的數量級。一個重寫的 batch job 一次 commit 上千列,consumer 只被叫醒一次。
這裡有一個微妙的取捨值得點出:空 payload 換來去重與正確性,代價是每次喚醒都要回 Postgres 跑一次查詢,而不是直接從通知裡讀內容。但這個代價在 sync_id > last_seen 有索引的前提下極小——它是一次有界的索引掃描——而它換來的「並發寫入下不漏、爆量自動 debounce」兩個性質,是 payload-carrying 設計用任何補丁都補不回來的。這是一個典型的「用一次便宜的查詢,換掉一整類 race condition」的交易。
spawn_blocking + ReceiverStream:同步 Diesel 怎麼餵 async 串流
Nosdesk 一個有意思的選擇是 async runtime 用 Tokio,但 ORM 用同步的 Diesel。這不是矛盾,而是一個務實的接縫設計:Diesel 的型別安全與成熟度作者要,async runtime 的並發他也要,於是中間用 spawn_blocking 把同步查詢丟到 blocking thread pool,不去阻塞 async executor。
真正精彩的是 bootstrap sync——新 client 上線要把整個 workspace 的初始快照拉下來。樸素做法是把所有 row 撈進一個 Vec 序列化後回傳,但作者點明這會造成「workspace-sized memory spike on every connection」:每一條新連線都在 server 記憶體裡堆起一份整個 workspace 大小的緩衝。對一個多租戶系統,這是一條等著被大客戶引爆的路。
他的解法是串流 + back-pressure。row 被序列化成 newline-delimited JSON(NDJSON),透過一個 mpsc::channel(64) 推出去,Diesel 查詢跑在 spawn_blocking 上,產出的 bytes 用 ReceiverStream 包成一個 HTTP response body 串回 client。channel 容量 64 是關鍵:當 client 讀得慢,channel 填滿,producer 就被 back-pressure 卡住,「a slow reader back-pressures the producer instead of pinning the whole result set in RAM」——慢的 reader 反壓 producer,而不是把整個結果集釘在 RAM 裡。整段 bootstrap 跑在單一 transaction 內,所以 client 拿到的是一個一致的 point-in-time 視圖,不會撈一半被別人的寫入插隊。
// bootstrap:同步 Diesel 查詢丟到 blocking pool,
// NDJSON 透過 bounded channel 串回,慢 reader 自動 back-pressure
let (tx, rx) = mpsc::channel::<Bytes>(64);
tokio::task::spawn_blocking(move || {
conn.transaction(|conn| {
for row in stream_workspace_rows(conn)? {
let line = to_ndjson(&row);
// channel 滿時 blocking_send 卡住 → 反壓到查詢游標
if tx.blocking_send(line).is_err() {
break; // client 斷線,停止掃描
}
}
Ok(())
})
});
// ReceiverStream 把 rx 包成 HTTP response body
HttpResponse::Ok().streaming(ReceiverStream::new(rx))
這個 channel(64) 的數字本身就是一個 back-pressure 旋鈕:太小,producer 與 consumer 頻繁 ping-pong,throughput 受損;太大,慢 client 能在你被卡住前先讓你緩衝大量資料,記憶體保護就鬆了。64 是一個「足以吸收抖動、又不至於讓記憶體無界成長」的折衷。重點不在這個數字,而在「有界 channel」這個結構本身——它把 memory safety 從「希望 client 讀得夠快」變成「channel 容量數學上保證的上界」。下面這張示意圖把這條串流路徑的四個環節攤開,並把那條從慢 reader 一路反推回查詢游標的 back-pressure 回饋線畫出來。
互動圖表
mpsc::channel(64) 容量上界讓慢的 client 反壓 Diesel 查詢游標,記憶體不隨 workspace 膨脹。
而 bounded mpsc 只是這個子系統挑用的並發原語之一。作者那段話把整套挑選邏輯講得極乾淨——「DashMap for the lazily-populated topic map. tokio::broadcast for fan-out with built-in lag detection. Bounded mpsc where I want back-pressure. std::sync::RwLock where no await crosses the critical section; tokio::sync::RwLock only where one does.」每一個選擇都掛在兩條問題上:critical section 裡有沒有 .await?要不要 back-pressure 或 lag detection?下面這張決策圖把這五個原語照這兩條軸攤開——它不是教你某個 API,而是把「該用哪個」這個每天都要做的判斷,定成一棵可以照著走的樹。
互動圖表
critical section 有無 .await 與需不需要 back-pressure 兩個問題決定五種並發原語的選擇。
型別層安全:illegal states 寫不出來
這是整篇文章我認為最有 Rust 味、也最值得搬到任何強型別語言的一段。Nosdesk 把兩類「絕對不能出錯」的事——跨租戶資料隔離、未簽名 plugin 安裝——從 runtime check 挪進了型別系統,讓錯誤的程式根本編不過。
先講多租戶。作者的第一句話就很狠:「Handlers don't get a raw database connection at all.」handler 拿不到裸的資料庫連線。能碰 connection pool 的只有兩個 extractor:TenantConn 與 PlatformConn。TenantConn 會把每個查詢包在一個設好 workspace context 的 transaction 裡,讓 Postgres 的 Row-Level Security(RLS)自動過濾掉不屬於這個 workspace 的 row;PlatformConn 則 elevate 到一個特殊 role,做那種罕見的跨租戶操作。關鍵在於:一個 handler 的簽名只要寫了 PlatformConn,它就在型別上公開宣告「我會跨越租戶邊界」——「announces 'I cross tenant boundaries' right there in its type」。code review 與 audit 不再需要逐行讀 SQL 找有沒有人漏了 workspace filter,只要 grep 函式簽名裡的 PlatformConn 就鎖定了所有跨租戶的 surface。
plugin 那一段更是型別技巧的教科書範例。插入一筆 plugin row 的函式,要求一個 InstallToken 當參數——「the function that inserts a plugin row requires one as an argument」。而 InstallToken 唯一的建構路徑,是 private 在「verified-install 模組」裡的。模組外的任何程式碼都拿不出一個 InstallToken,於是「插入未簽名 plugin」這件事,在型別系統裡表達不出來——你想呼叫 insert 函式,就得先有 token;想有 token,就得走過驗證模組裡的簽名檢查。簽名本身是 Ed25519 over「a length-prefixed canonical digest with a domain-separation prefix」。下面這張圖把這兩條建構路徑並排:一條編得過,一條從根本上拼不出來。
互動圖表
insert_plugin 需要 InstallToken,其建構子只存在於 verified-install 模組,未驗簽的插入根本寫不出來。
這個 pattern 在 Rust 圈有個名字:把不變式編碼進建構子可見性(也常被叫做 witness type 或 capability token)。InstallToken 不是一個帶資料的物件,它是一個「我已經通過驗證」的型別層證明——持有它本身就是證據。把這個證明設成「只有驗證模組能簽發」,等於把整條簽名檢查管線,變成 plugin 進資料庫的唯一道路:「the type system makes the signing-checked install pipeline the single path that can get a plugin into the database」。沒有第二條路可以漏掉,因為第二條路在型別上不存在。
對照 runtime 的做法你就懂這招的價值:runtime check 是「在 insert 前記得呼叫 verify()」——而「記得」是人類紀律,會在某個 1am 的 hotfix 裡被某個趕時間的人略過。型別層的做法把這個紀律變成編譯器強制:你不是「應該」先驗證,而是「不先驗證就生不出能呼叫 insert 的參數」。這正是 opener 那句話的意思——「插入一個沒簽名的 plugin」這個句子,在這個 codebase 裡根本造不出來。
email 子系統:手寫的韌性機制
整個 codebase 裡,email 是最大的單一子系統——約 14,000 行。理由不難猜:email 要對接外部 SMTP/IMAP 伺服器,而外部世界是不可靠的,於是這 14k 行裡塞滿了各種「對抗不確定性」的機制,每一個都手寫、每一個都值得學。
第一個是 circuit breaker。作者手刻了一個 closed / open / half-open 的狀態機,建在「a rolling window of recent failures」上——一個滾動視窗統計最近的失敗。最聰明的細節是狀態轉換「computed lazily when the state is next read, not from a background timer」:不是開一個背景 timer 去定時把 open 轉回 half-open,而是在下次讀取狀態時才惰性計算該不該轉。這省掉了一整類「timer 與狀態 race」的併發 bug,也省掉了背景 task 的資源。下面這張圖是這個狀態機的形狀。
互動圖表
circuit breaker 的 open→half-open 轉換惰性計算而非 timer 推送,消除計時器與狀態之間的 race condition。
第二個是退避:full-jitter backoff,作者照搬 AWS Builders' Library 那條公式,寫成一個帶小心 overflow 處理的純函式。full-jitter 的重點不是「指數退避」這件事本身,而是那個 jitter——在 [0, base × 2^n] 這個區間裡均勻取一個隨機值,而不是退避到 base × 2^n 這個確定值。為什麼?因為當一批 client 同時撞到失敗、同時開始退避,純指數退避會讓它們在同一個時間點同時重試,形成 retry storm 的同步尖峰;full-jitter 把每個 client 的下次重試打散到整個區間裡,把尖峰抹平成均勻分布。下面這個可拖的 widget 讓你比較兩者。
互動圖表
full-jitter 把重試時機散布在 [0, cap] 區間,防止多個 client 同時退避到相同時間點爆發重試風暴。
第三個是 email 投遞的 at-least-once 保證,用 FOR UPDATE SKIP LOCKED 配上五分鐘的 lease。多個 worker 同時搶待送的 email row,FOR UPDATE SKIP LOCKED 讓每個 worker 只鎖到自己搶到的那批、跳過別人鎖住的,天然做到無協調的工作分配。lease 是五分鐘——一個 worker 拿走一封信但中途掛了,五分鐘後 lease 過期,別的 worker 可以重新領走。這意味著同一封信可能被送兩次,所以 Message-ID 在 enqueue 時就被決定性地戳上去,靠接收端伺服器用 Message-ID 去重。作者把這個取捨講得很白:「I'd rather send twice than drop once.」寧可送兩次,不要漏一次。
這三個機制疊在一起,構成一套很清楚的失敗哲學:circuit breaker 在對方掛掉時快速止血、不浪費資源去撞一面死牆;full-jitter 在重試時不製造同步尖峰;SKIP LOCKED + lease + Message-ID 則在 worker 自己掛掉時保證信不丟、最多重送。每一條都是「假設一切都會壞」之後的具體設計,而不是「希望一切正常」的樂觀路徑。
幾個值得單獨記住的細節
除了上面五條主軸,文章還散落著幾個短而利的招式,列出來當作工具箱。
CRDT 的 client id 推導。協作編輯用 yrs(Yjs 的 Rust 實作)的 CRDT。一個容易踩的坑是:server 自己也是 CRDT 的一個參與者,需要一個 client ID。如果 server 每次重啟都隨機選一個新 ID,那麼「每次後端重啟」在 CRDT 眼裡都像「一個全新的參與者加入」,狀態會無限膨脹。Nosdesk 的解法是把 server 的 client ID 從 document ID 的 hash 決定性地推導出來,再 mask 到 53 bits 以塞進 JavaScript 的 safe integer 範圍。重啟之後同一個 document 推導出同一個 ID,CRDT 認得它是同一個老參與者。
不信任的程式碼用 catch_unwind 圍起來。yrs 在遇到 malformed UTF-8 時可能在 library 深處 panic。Nosdesk 對它的每一次呼叫都包一層 catch_unwind,把 panic 攔在 call site,不讓它炸穿整個 worker。作者把這上升成一條通則:「I treat anything I don't own the same way」——任何不是我自己寫的東西,都當成可能 panic 的,在呼叫點就攔住。
SSRF 防護的 custom DNS resolver。當後端要對使用者給的 URL 發請求時,SSRF 的經典繞法是「DNS 解析出來是公網 IP、實際連線時卻指向內網」。Nosdesk 把過濾做在 DNS resolver 層、跟連線用的是同一次解析結果,枚舉所有 non-routable 範圍,包含 CGNAT 與那個 IPv4-mapped-IPv6 的把戲——::ffff:127.0.0.1 這種把 loopback 偽裝成 v6 位址、想騙過只檢查 v6 的過濾的招數。把過濾釘在「連線真正會用的那次解析」上,是這招的關鍵:在別的層檢查都可能被 TOCTOU 繞過。
登入的 timing 防護。每一條登入失敗路徑——不管是帳號不存在還是密碼錯——都統一走一次對 dummy hash 的 bcrypt 驗證,讓「帳號不存在」與「密碼錯」兩種失敗耗時一致,堵住 user-enumeration-via-timing。作者甚至 prewarm 這條路徑,免得第一次真實登入付出一次性的冷啟動成本、反而從 timing 上洩漏出「這是第一次」。
AES-256-GCM 把 context 綁進 auth tag。加密走 ring 的 AES-256-GCM,並把一個 context 字串綁進 auth tag(也就是 AEAD 的 associated data)。效果是「為某個用途封的 ciphertext,不能被當成另一個用途解開」——換了 context,auth tag 驗證就失敗。plaintext buffer 在用完後 zeroize。這擋住的是「把 A 場景加密的東西餵進 B 場景的 decrypt」這類 confused-deputy。
~1030 個純函式測試,與 lint-as-tests
最後一條主軸是測試策略,它跟前面所有設計決策互為表裡。約 1,030 個測試,但作者刻意不追求均勻覆蓋率——測試「cluster where the code is most likely to be wrong」,集中在最容易錯的地方:manifest 驗證、IMAP 解析、email threading、HTML sanitisation、plugin 的型別層。這些幾乎全是純函式,不碰資料庫、不碰 socket,所以跑得快、好寫、不 flaky。需要碰資料庫的測試則跑在一個「drop 時 rollback 的 transaction」裡,不留痕跡、可以並行對同一個 dedicated test DB 跑。
但全文最讓我印象深刻的,是那條把架構決策變成測試的 lint-as-test。還記得開頭 sync_actions 那一段嗎?整套 sync 引擎的正確性,建立在「每一個 write 函式都有 append 一個 sync event」這個前提上。漏掉一個,那個變更就不會被任何 consumer 看到——一個無聲的、極難 debug 的資料同步 bug。Nosdesk 怎麼保證沒人會漏?一條測試「walks the repository layer, finds every write function」,然後「fails the build unless that function emits a sync event or carries an explicit marker saying it doesn't」——走遍 repository 層、找出每一個 write 函式,除非它發了 sync event、或帶了一個明確標記說「我故意不發」,否則直接讓 build 失敗。
// lint-as-test:把架構不變式變成編譯/測試強制
#[test]
fn every_write_emits_a_sync_event() {
for f in repository_layer::write_functions() {
assert!(
f.emits_sync_event() || f.has_marker("intentionally_silent"),
"write fn `{}` 既沒發 sync event 也沒標記豁免——build 失敗",
f.name()
);
}
}
作者點破了這條測試的真正目的:「how the rule survives me forgetting about it at 1am six months from now」——讓這條規則能在六個月後某個凌晨一點、當我自己都忘了它存在的時候,依然活著。這跟 InstallToken 是同一個哲學的兩種實作:把「人類必須記得做的事」轉成「機器強制檢查的事」。InstallToken 用型別系統強制,lint-as-test 用測試套件強制——目標都是把正確性從紀律問題降級成 build 問題。
當然,這個 codebase 不是完美的,作者自己也誠實列了 pre-v1 的待辦:main.rs 還是個約 1,900 行的 monolith 待拆、graceful shutdown 接好了但還沒接到 SIGTERM、search service 上有一個「在現代 Tantivy 上幾乎確定不必要」的 unsafe impl Send/Sync、plugin proxy 與 SMTP 處理那邊的 error type 還在退化。把這些攤出來寫,反而讓前面那些漂亮的設計更可信——它們不是事後美化的 architecture diagram,而是一個還在演進、有債也有亮點的真實系統。
The model:這篇文章真正的主題只有一句——把「不該發生的事」從 runtime 的祈禱,往 compile time 或 build time 挪。TenantConn/PlatformConn 把「跨租戶」變成簽名上看得見的事;InstallToken 把「未簽 plugin 安裝」變成寫不出來的句子;sync_actions 把「三個下游一致」變成「讀同一張單調表」;空 payload 的 NOTIFY 把「並發漏寫」變成不可能;lint-as-test 把「記得發事件」變成 build gate。每一招都在做同一件事:把一個本來要靠人類紀律維持的不變式,焊進機器會替你檢查的地方。值得搬走的不是 Rust,是這個習慣。