![]()
去年某跨境電商平臺(tái)大促,凌晨3點(diǎn)支付系統(tǒng)崩潰。排查日志發(fā)現(xiàn):一筆訂單狀態(tài)顯示"已支付",卻找不到對(duì)應(yīng)發(fā)票ID。代碼完全合規(guī),TypeScript編譯通過(guò),測(cè)試覆蓋率87%——但業(yè)務(wù)規(guī)則被悄無(wú)聲息地撕碎了。
這不是孤例。作者統(tǒng)計(jì)過(guò)自己經(jīng)手的生產(chǎn)事故,近半數(shù)bug并非算法錯(cuò)誤或基礎(chǔ)設(shè)施故障,而是"無(wú)效狀態(tài)"在作祟:空訂單、超額支付、已發(fā)貨卻無(wú)物流單號(hào)。類(lèi)型系統(tǒng)對(duì)此視而不見(jiàn),它只檢查語(yǔ)法,不守護(hù)業(yè)務(wù)邏輯。
本文拆解一套讓"非法狀態(tài)"在編譯期就暴露的建模模式。核心思路很樸素:把業(yè)務(wù)規(guī)則寫(xiě)進(jìn)類(lèi)型本身,而非注釋或某個(gè)校驗(yàn)函數(shù)。當(dāng)違規(guī)操作發(fā)生時(shí),代碼要么編譯失敗,要么顯式報(bào)錯(cuò)——沒(méi)有第三種可能。
「合法」的陷阱:一個(gè)典型Order類(lèi)的隱患
先看這段被無(wú)數(shù)項(xiàng)目復(fù)制的代碼:
class Order { status: "DRAFT" | "PLACED" | "PAID"; items: OrderItem[]; invoiceId?: string; }
問(wèn)題藏在問(wèn)號(hào)里。invoiceId是可選的,但業(yè)務(wù)規(guī)則要求:PAID狀態(tài)的訂單必須有發(fā)票ID。TypeScript不會(huì)阻止你創(chuàng)建一個(gè){status: "PAID", invoiceId: undefined}的對(duì)象,運(yùn)行時(shí)也不會(huì)自動(dòng)攔截。
常見(jiàn)的補(bǔ)救方案是在"支付完成"的用例里加校驗(yàn)。但作者指出致命漏洞:校驗(yàn)只發(fā)生在特定路徑,狀態(tài)本身仍可被任意篡改。另一個(gè)開(kāi)發(fā)者、一段遺留腳本、甚至未來(lái)的你自己,都可能繞過(guò)這層防護(hù)直接修改對(duì)象。
更隱蔽的風(fēng)險(xiǎn)是"讀操作污染"。假設(shè)某報(bào)表功能直接讀取Order狀態(tài)生成對(duì)賬單,它不會(huì)觸發(fā)支付用例的校驗(yàn)——臟數(shù)據(jù)就這樣流入了財(cái)務(wù)系統(tǒng)。
不變量:把規(guī)則焊進(jìn)類(lèi)型系統(tǒng)
作者提出的解法是"不變量"(Invariant)模式。不變量是一條必須恒成立的業(yè)務(wù)規(guī)則,而非某個(gè)時(shí)點(diǎn)的校驗(yàn)條件。
實(shí)現(xiàn)上,基類(lèi)DomainEntity在構(gòu)造函數(shù)中注冊(cè)規(guī)則,每次readState()時(shí)自動(dòng)執(zhí)行:
const paidOrderHasInvoiceId = new BaseDomainInvariant( "Paid Order Has Invoice Id", (state) => { if (state.status === "PAID") { return state.invoiceId !== undefined; } return true; } );
![]()
Order實(shí)體在實(shí)例化時(shí)聲明這些約束:
class Order extends DomainEntity { private constructor(id: string, state: OrderState) { super(id, state); this.addInvariant(orderHasAtLeastOneItem); this.addInvariant(paidOrderHasInvoiceId); } }
關(guān)鍵設(shè)計(jì)在于readState()的強(qiáng)制性。作者刻意封禁了直接屬性訪(fǎng)問(wèn),所有狀態(tài)讀取必須經(jīng)過(guò)這個(gè)方法。一旦不變量被違反,立即拋出"Corrupted state detected"——錯(cuò)誤無(wú)法被靜默吞沒(méi),也無(wú)法被下游代碼忽略。
這套機(jī)制解決了前文提到的"讀操作污染"。報(bào)表功能調(diào)用order.readState()時(shí),不變量同樣生效。臟數(shù)據(jù)在源頭就被攔截,不會(huì)擴(kuò)散。
不變量還支持組合運(yùn)算,應(yīng)對(duì)復(fù)雜業(yè)務(wù)場(chǎng)景:
const complexRule = invariantA.and(invariantB).or(invariantC);
Result模式:讓失敗成為顯式契約
TypeScript的throw有一個(gè)設(shè)計(jì)缺陷:異常類(lèi)型不出現(xiàn)在函數(shù)簽名中。調(diào)用方除非閱讀實(shí)現(xiàn)代碼,否則無(wú)法預(yù)知哪些錯(cuò)誤可能發(fā)生。這在大型代碼庫(kù)中制造了持續(xù)的"驚喜"。
作者引入Result模式,將失敗作為一等公民納入類(lèi)型系統(tǒng):
lockCredits(params: { amount: number }): Result { if (this.state.subCreditBalance < params.amount) { return err(new NotEnoughFunds( `Not enough credits`, { available: this.state.subCreditBalance, amount: params.amount } )); } // ... }
返回類(lèi)型明確宣告:此操作可能成功(CreditLocked),也可能因余額不足失敗(NotEnoughFunds)。調(diào)用方必須處理兩種情形,編譯器會(huì)強(qiáng)制檢查分支完整性。
對(duì)比傳統(tǒng)try-catch,Result模式的優(yōu)勢(shì)在于錯(cuò)誤處理的局部性與可組合性。異常在調(diào)用棧中跳躍,常常在最不合適的層級(jí)被捕獲;Result則要求每層顯式傳遞或轉(zhuǎn)換錯(cuò)誤,形成清晰的錯(cuò)誤處理鏈條。
作者團(tuán)隊(duì)在實(shí)踐中發(fā)現(xiàn),Result模式顯著降低了"意外崩潰"的頻率。當(dāng)所有失敗路徑都被類(lèi)型強(qiáng)制暴露,遺漏處理的情形在編譯期就能被靜態(tài)分析捕獲。
![]()
從"防御式編程"到"不可能狀態(tài)"
這些模式的共同目標(biāo),是讓無(wú)效狀態(tài)在代碼層面"無(wú)法被構(gòu)造"。作者引用了一條設(shè)計(jì)原則:"Make illegal states unrepresentable"——與其到處校驗(yàn),不如讓非法組合根本寫(xiě)不出來(lái)。
以訂單狀態(tài)機(jī)為例。傳統(tǒng)實(shí)現(xiàn)用字符串枚舉加校驗(yàn)函數(shù),作者建議改用狀態(tài)作為類(lèi)型:
DraftOrder、PlacedOrder、PaidOrder成為獨(dú)立的類(lèi),各自持有該狀態(tài)允許的字段。PaidOrder的構(gòu)造函數(shù)強(qiáng)制要求invoiceId,不存在"可選"的漏洞。狀態(tài)轉(zhuǎn)換通過(guò)顯式方法實(shí)現(xiàn):draft.place()返回PlacedOrder,placed.pay(invoiceId)返回PaidOrder。
這種設(shè)計(jì)下,你無(wú)法在編譯通過(guò)的代碼中構(gòu)造出"已支付但無(wú)發(fā)票"的訂單。業(yè)務(wù)規(guī)則從"運(yùn)行時(shí)校驗(yàn)"降級(jí)為"類(lèi)型系統(tǒng)約束",從"可能遺漏"升級(jí)為"物理上不可能"。
代價(jià)是代碼量增加。每個(gè)狀態(tài)一個(gè)類(lèi),轉(zhuǎn)換方法需要維護(hù)。但作者認(rèn)為,這與生產(chǎn)事故的排查成本相比微不足道——尤其是當(dāng)bug發(fā)生在凌晨、涉及資金、需要跨時(shí)區(qū)協(xié)調(diào)時(shí)。
某次內(nèi)部復(fù)盤(pán)顯示,采用這套模式后,團(tuán)隊(duì)狀態(tài)相關(guān)bug下降62%,平均修復(fù)時(shí)間從4.2小時(shí)降至23分鐘。因?yàn)殄e(cuò)誤總是在第一時(shí)間、最接近源頭處被捕獲,而非在下游系統(tǒng)的某個(gè)角落爆炸。
何時(shí)不必過(guò)度設(shè)計(jì)
作者也劃定了適用范圍。不變量與Result模式適合核心業(yè)務(wù)實(shí)體與資金相關(guān)操作,而非所有CRUD場(chǎng)景。一個(gè)內(nèi)部管理后臺(tái)的配置表單,用傳統(tǒng)校驗(yàn)足矣。
判斷標(biāo)準(zhǔn)是狀態(tài)錯(cuò)誤的代價(jià):如果臟數(shù)據(jù)可能流入財(cái)務(wù)系統(tǒng)、觸發(fā)合規(guī)審計(jì)、或?qū)е掠脩?hù)資金損失,則值得投入。如果最壞結(jié)果只是顯示異常,可重新提交,則保持簡(jiǎn)單。
另一個(gè)考量是團(tuán)隊(duì)規(guī)模。小型團(tuán)隊(duì)、快速迭代階段,嚴(yán)格類(lèi)型約束可能成為拖累。但當(dāng)代碼庫(kù)超過(guò)10萬(wàn)行、貢獻(xiàn)者超過(guò)15人,"防御式編程"的隱性成本會(huì)指數(shù)級(jí)上升——你永遠(yuǎn)不知道誰(shuí)的PR繞過(guò)了哪層校驗(yàn)。
作者最后提到一個(gè)反直覺(jué)的觀察:最危險(xiǎn)的代碼往往是"看起來(lái)沒(méi)問(wèn)題"的。那些顯式拋出異常、返回錯(cuò)誤碼的函數(shù),調(diào)用者自然保持警惕;而那些默默返回、類(lèi)型正確的函數(shù),反而成為bug的溫床。這套模式的核心,正是把這種隱性風(fēng)險(xiǎn)轉(zhuǎn)化為顯式契約。
你的代碼庫(kù)里,有多少"合法"的無(wú)效狀態(tài)正在沉睡?下次Code Review時(shí),不妨檢查那些可選字段與字符串枚舉——它們可能是凌晨告警的伏筆。
特別聲明:以上內(nèi)容(如有圖片或視頻亦包括在內(nèi))為自媒體平臺(tái)“網(wǎng)易號(hào)”用戶(hù)上傳并發(fā)布,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.