受夠了“系統異?!保?/h1>
作為用戶,你是否有過這樣的經歷:使用軟件時偶爾彈出一個消息,顯示“系統異常!”?
作為程序員,你是否有過這樣的經歷:
運維同事跑來求助:“用戶不能下單了!”
“顯示什么錯誤?”
“系統異常!”
無論是作為用戶還是程序員,當看到這四個字“系統異?!睍r,我都感到不安。
它只告訴我系統有問題,卻沒有提供任何有價值的信息。
這通常標志著程序員另一個痛苦日子的開始。
我們無法獲取任何有價值的信息,只能盲目地到處查找。
首先,我們檢查系統負載。嗯,沒問題。
然后,我們查看錯誤日志。成堆的日志滾動不斷,但似乎沒有任何意義。
于是,我們不得不向運維同事求助:“能否幫我們獲取用戶的電話號碼或賬號信息?另外,他們的手機型號和版本也會有幫助!如果可以,請錄制一個視頻!”
等待了好像一個世紀,運維同事終于收集到了這些信息。然后我們花費數小時查看各種日志,仔細審查每一行代碼,最終找到了錯誤所在。
為什么會有“系統異?!??
那些喜歡將所有外部錯誤信息寫成“系統異?!钡娜送ǔS幸韵聨追N原因:
- 剛進入行業的新手,還沒有經歷過程序員的辛苦。
- 相信“敏感信息”,對他們來說,任何系統錯誤信息都是敏感的,必須“包裝”起來。
- 公司所在的行業敏感,強制要求如此處理。
我見過一些系統是這樣處理的:
class BaseController {
errorHandler(err) {
this.response.sendJSON({code: 500, message: '系統異常'})
}
}這意味著這個系統的所有拋出的錯誤都會轉換為“系統異?!?!
而最糟糕的是,甚至沒有記錄任何日志!
為了方便后續開發人員定位錯誤,各種日志被添加到業務層代碼中,使得業務代碼不堪重負。
“系統異?!睈酆谜叩母倪M措施
上述極端代碼相對較少見。通常,我們會遇到這樣的情況:
class BaseController {
errorHandler(err) {
// 生成異常標識符并記錄日志。
let flag = random()
log(err, flag)
this.response.sendJSON({"code": 500, "message": `系統異常(${flag})`})
}
}在系統異常后添加一個標識符。當出現問題時,可以根據這個標識符快速定位和排查日志。對于擁有完善日志系統的項目(如 ELK),大大提高了程序員的生存狀態。
但是,上述代碼有什么問題?
假設某個支付邏輯有如下代碼:
if (balance < amount) {
throw new NotEnoughException('卡余額不足。')
}余額不足是一個非常常見的場景,但用戶看到的提示是:“系統異常(1877618)”。
此時,我不知道用戶和程序員是否崩潰了,但至少你的老板是崩潰的。
“系統異?!钡慕K結:錯誤代碼的出現
“系統異?!币l的事情讓人憤怒。如今,已經沒有多少信徒了。要么他們在壓力下改變了做法,要么已經被主管完全開除了。
現在,你更有可能遇到這樣的代碼:
配置文件:
// 全局:定義統一的錯誤代碼和錯誤消息。
const OK = 200
const SYS_ERR = 500
const NOT_FOUND = 404
const NOT_ENOUGH = 405
const map = {
200: "OK",
500: "系統錯誤",
404: "資源未找到",
405: "余額不足"
}
// 錯誤代碼轉文本
function error(code) {
return map[code]
}業務層代碼:
if (balance < amount) {
// 此自定義異常類只允許傳遞錯誤代碼,并在內部使用 error() 函數將其轉換為文本。
throw new MyException(NOT_ENOUGH)
}控制器:
class BaseController {
errorHandler(err) {
log(err)
this.response.sendJSON({"code": err.code, "message": err.message})
// 或者:this.response.sendJSON({"code": err.code, "message": error(err.code)})
}
}這種錯誤處理原則是通過錯誤代碼統一項目的代碼和消息。開發人員不能在程序中定義自己的錯誤描述。
我稱這些程序員為“錯誤代碼”信徒。
“錯誤代碼”組的主要擔憂是,如果允許開發人員在代碼中定義錯誤描述,可能會導致“哈姆雷特”問題,即每個人的描述可能不同,還可能導致敏感信息泄漏。
相比“系統異?!?,“錯誤代碼”組取得了顯著進步。大家終于知道系統中發生了什么錯誤,老板們也不再擔心因為客戶卡余額不足導致的“系統異?!睋p害品牌形象。
當用戶購買500元商品時,收到的提示是“余額不足”,而更好的提示應該是“余額不足,目前可用余額:420.00”。
當根據 userId 找不到用戶信息時,應該顯示“用戶不存在”的提示,但開發人員不應僅因為不想定義新代碼而直接使用 404(資源未找到)。
錯誤代碼的問題
錯誤代碼的問題在于它們的文本提示過于模糊,導致某些錯誤場景中丟失了重要的有價值信息(這導致未解決問題的排查困難),同時在其他場景中導致用戶體驗不佳。
對于開發人員來說,它帶來了兩種影響:一些開發人員不愿定義大量新錯誤代碼,所以他們湊合使用現有代碼,導致錯誤提示不一致;其他開發人員傾向于為幾乎每個異常定義大量錯誤代碼(認為每個異常的文本提示不同),最終導致錯誤代碼的失控增長。
改進錯誤代碼
改進非常簡單,只需允許異常類傳入自定義描述。
// 添加可選參數 "message",以允許自定義描述輸入。
class MyException {
constructor(code, message = '') {
this.code = code
this.message = message
}
}期望程序有如下調用:
if (balance < amount) {
throw new MyException(NOT_ENOUGH, '卡余額不足,目前可用余額:' + balance)
}追求自由:反“錯誤代碼”
與“系統異?!焙汀板e誤代碼”努力嚴格限制系統輸出不同,自由派追求終極自由,對代碼和消息沒有任何限制。開發人員可以隨意編寫它們。
你可能會在多個地方看到“余額不足”的錯誤,但每個錯誤代碼都不同(可能由不同的人編寫,甚至是同一個開發人員在不同時間或同一天心情不同)。
自由派的方法對于錯誤提示有其好處。開發人員可以自由定制個性化的提示內容,當系統遇到異常時可以快速定位錯誤。然而,由于錯誤代碼隨意編寫,對于依賴這些錯誤代碼的調用方(系統)不友好。一些系統需要根據 API 返回的錯誤代碼執行特殊邏輯。當調用方認為 405 代表余額不足,但幾天后遇到 503 也表示余額不足時,程序員的心肯定會崩潰。
中庸之道
我對異常處理的原則是:強制使用固定代碼和自定義消息。
要設計一個讓用戶和程序員都開心的異常處理機制,首先要了解誰需要使用這些信息。
異常信息的首要用戶是人,包括用戶(客戶)和異常處理者(運維人員、程序員)。
進一步細分,異??梢苑譃闃I務異常和系統錯誤。
業務異常是指業務流程中的異常場景,例如支付時卡余額不足導致支付失敗,使用優惠券時發現不符合使用條件,以及用戶進行未授權操作。這類異常的觸發是用戶自己(而非系統),信息的受眾是用戶。因此,業務異常的信息提示必須關注用戶體驗。優秀的提示文本至少應達到以下幾點:
- 尊重用戶,避免讓用戶感到被冒犯或嘲笑(請謹慎使用你認為“幽默”的詞語);
- 清晰,并包含觸發異常的關鍵信息(如余額不足時提示當前余額);
- 引導用戶,讓他們知道看完提示后該做什么;
第二類異常是系統錯誤,例如接口超時,意外參數導致的程序崩潰,代碼邏輯錯誤等。這類異常的觸發是系統(或開發系統的程序員),信息的受眾是程序員。因此,錯誤消息對錯誤類型異常必須對程序員友好,允許他們快速識別問題的原因并定位代碼中的位置。
我們通常談論的異常是指錯誤類型異常。這類異常消耗了程序員的大部分精力,也值得優化處理機制。
錯誤類型異常具有以下特點:
- 不可預測性:沒有程序員會主動寫錯誤,但沒有系統是完全沒有錯誤的。我們無法預測錯誤來自哪里或會產生什么樣的錯誤信息。
- 難以定位:當系統提示“余額不足”時,我們很快知道這是用戶的卡沒有錢。然而,當系統提示“參數類型錯誤”時,我們通常感到困惑。
- 可能涉及敏感信息:例如,當出現 SQL 操作錯誤時,可能會將整個 SQL 語句暴露給外部。
綜合考慮
錯誤提示對程序員友好,這可能意味著對用戶不友好。一些程序員利用這一點,以“用戶體驗”的名義將錯誤提示信息轉換為“用戶友好”的提示。結果是每個人都感到困惑。
我的觀點是,錯誤類型異常根本不需要考慮用戶體驗。
為什么?
因為系統出現錯誤本身已經是一個糟糕的用戶體驗。用戶不會因為像“Oops,系統出錯了”這樣的詞句而感覺好一點。用戶真正關心的是能盡快正常下單。
此時的當務之急是快速修復錯誤,因此提示文本的定位功能變得非常重要。純技術性的文本對用戶可能是胡言亂語,但對程序員來說非常有用。
然而,這并不意味著可以隨意給用戶錯誤提示。如果為了便于定位而提示整個程序調用棧,雖然這可能不會進一步降低用戶體驗,但會給人一種不專業的印象,過多的信息也意味著容易暴露敏感信息(如程序路徑、軟件版本、SQL 語句)。如果對方是黑客,你只能祈禱好運。
此外,應注意脫敏。在大多數框架中,當數據庫操作失敗時,它們的消息信息通常包含敏感信息,如 SQL 語句。這種信息不應暴露在外。
因此,我們可以采用文本 + 日志結合的策略,在文本中包含關鍵信息,在日志中記錄詳細信息(包括調用棧)。
這也告訴我們另一件事:當我們自己開發公共庫時,最好為該庫定義統一的基類異常。這樣,想要以特殊方式處理該庫拋出的所有異常的用戶就不會手足無措。
此外,有些團隊不想記錄業務異常的調用棧信息(“余額不足”的調用棧信息沒有太大意義)。我們可以在框架級別定義業務異常的基類:BusinessException,并在處理異常時不記錄此類異常的調用棧信息。
另一個異常信息的用戶是系統。這包括其他服務、前端 JavaScript 腳本等。
我見過類似的代碼:
try {
...
} catch (e) {
switch (e.message) {
case '用戶不存在':
...
case ...
}
}如果有一天后端程序員心血來潮,將“用戶不存在”改為“用戶信息不存在”,系統就會崩潰。
創建這樣脆弱系統的程序員應被釘在第1024柱羞恥柱上!
然而,在釘他們之前,我們應該聽聽他們痛苦的呼聲:接口返回的錯誤代碼混亂,已經有八個不同的錯誤代碼表示“用戶不存在”,將來也可能會更多。為了“系統穩定”,最終決定基于消息進行匹配。
好吧,那讓我們一起釘所有后端程序員!
系統只應關注錯誤代碼,而不是其他內容。與消息可以自由變化不同,錯誤代碼應具有相當的穩定性。
在同一系統中,如果 406 表示“用戶不存在”,則不應使用其他值(如 604)表示相同含義。
此外,代碼面向系統的特性還要求代碼定義一類異常(而不僅僅是一種異常)。例如,“訂單創建失敗”是一類異常,不同的失敗原因在業務代碼中有不同的消息,但共享相同的代碼。
然而,人類對數字不敏感。每個程序員都不能確保寫 throw new Exception('用戶不存在', 406) 而不是 throw new Exception('用戶不存在', 604)。
因此,有必要通過定義常量錯誤代碼將數值轉換為文本:
const USER_NOT_EXISTS = 406代碼中只能使用錯誤代碼常量。
throw new Exception('用戶不存在', USER_NOT_EXISTS)禁止使用文字常量。
然而,上述 throw 語句并不理想。首先,默認的 Exception 類型沒有業務語義。其次,如果開發人員堅持使用數字常量,誰也無法阻止。更好的方法是為每種異常類型定義單獨的異常類,只允許傳遞消息,并在內部綁定代碼。
// 用戶不存在。
class UserNotExistsException extends Exception {
constructor(message) {
super(message)
this.code = ErrCode.USER_NOT_EXISTS
}
}使用:
if (!User.find(uid)) {
// 這種寫法更有表現力,開發人員不需要關注錯誤代碼
throw new UserNotExistsException(`用戶不存在(uid:${uid})`)
}異常處理機制的示例
首先,總結中庸之道的異常處理機制的特點:
- 強制開發人員編寫異常描述文本;
- 整個項目要求使用統一的錯誤代碼定義;
- 為業務異常定義單獨的基類;
- 對敏感信息進行脫敏處理;
錯誤代碼的統一定義:
const OK = 200
const SYS_ERR = 500
const NOT_FOUND = 404
const NOT_ENOUGH = 405
const USER_NOT_EXISTS = 406
...業務異常基類:
class BussinessException extends Exception {
...
}異常類定義:
class UserNotExistsException extends BussinessException {
constructor(message) {
super(message)
this.code = ErrCode.USER_NOT_EXISTS
}
}業務層使用:
if (!User.find(uid)) {
throw new UserNotExistsException(`用戶不存在。(uid:${uid})`)
}基礎控制器捕獲異常:
class BaseController {
...
errorHandler(err) {
// 是否是業務異常
const isBussError = err instanceof BussinessException
// 是否是數據庫異常
const isDBError = err instanceof DBException
// 生成用于跟蹤異常日志的隨機字符串
const flag = isBussError ? '' : random()
let message = err.message
if (isDBError) {
// 數據庫異常,脫敏處理
message = `數據異常(flag:${flag})`
} else if (!isBussError) {
// 非業務異常記錄標識符
message += `(flag:${flag})`
}
// 記錄錯誤(日志應記錄原始消息)
log(err.message, isBussError ? '' : err.stackTrace(), flag)
// 返回給調用方
this.response.sendJSON({"code": err.code, "message": message})
}
function log(message, stackTrace, flag) {
...
}
...
}約定機制
即使框架提供了全面的異常處理機制,你仍然無法阻止開發人員編寫這樣的代碼:
if (!User.find(uid)) {
throw new Exception('系統異常', 500)
}一行代碼將使你回到原點!
因此,異常處理機制是基于約定的(團隊約定)。
技術負責人必須為所有成員提供系統培訓,并公開建立團隊代碼標準。他們應堅決拒絕不符合標準的 pull 請求,并與那些屢教不改的人進行“黑房對話”。


























