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