我已經受夠了“系統異常”!

作爲用戶,你有沒有這樣的經驗:用個軟件,隔三岔五彈個框:系統異常!

作爲程序員,你有沒有這樣的經驗:

運營同學又屁顛屁顛跑來求助:“用戶不能下單了!”

“報什麼錯?”

“系統異常!”

無論作爲用戶還是程序員,一見到“系統異常”四個大字,我整個人都不好了。

它除了告訴我係統出問題了,沒有任何有價值的信息。

這往往是程序員一天苦逼生活的開始。

我們獲取不到任何有價值的信息,只能到處抓蝦。

先看看系統負載,嗯,沒問題。

再看看錯誤日誌,一大堆日誌滾來滾去,也看不出所以然。

於是我們不得不求助運營同學:“去要一下用戶手機號或者賬號,手機型號、版本,最好能錄個頻!”

等了半天,運營妹妹終於搞來了這些信息,於是我們又一頓各種查日誌,然後盯着代碼一行一行找,最終發現了 bug 所在。



爲什麼會有“系統異常”?

喜歡將對外錯誤信息一股腦寫成“系統異常”的,一般處於以下幾種原因:

  1. 剛入行的小白,尚未深入體驗程序員的苦難生活。
  2. “敏感信息”信徒,對他們來說,任何系統錯誤信息都屬於敏感信息,需要“包裝”一下。
  3. 高敏行業,公司強制要求。

我見過一些系統是這樣處理的:

class BaseController {
    errorHandler(err) {
        this.response.sendJSON({code: 500, message: '系統異常'})
    }
}

意思是,該系統的所有 throws 都被轉成“系統異常”!

關鍵還連個日誌都不記錄!

後續的開發人員爲了方便定位錯誤,便在業務層代碼裏面各種 log,業務代碼慘不忍睹。



“系統異常”愛好者們的改進措施

上面那種極端的代碼是比較少見的,一般遇到更多的是這樣:

class BaseController {
    errorHandler(err) {
        // 生成異常標識並記錄日誌
        let flag = random()
        log(err, flag)
        this.response.sendJSON({"code": 500, "message": `系統異常(${flag})`})
    }
}

給系統異常後面帶了個 flag 標識,當出現問題時,根據標識就能快速定位日誌來排查問題了,對於有完善日誌系統(如 ELK)的項目來說已經大大改善了程序員們的生存狀況。

但上面的代碼有什麼問題呢?

試想某支付邏輯有如下代碼:

if (balance < amount) {
    throw new NotEnoughException('卡餘額不足')
}

餘額不足,很常見的場景,但用戶看到的是這樣的提示:“系統異常(1877618)”。

此時,我不知道用戶和程序員有沒有崩潰,至少你的老闆是崩潰的。



“系統異常”們的終結:“錯誤碼”們橫空出現

“系統異常”們搞出的事情令人猿共憤,如今這些信徒已經不多了,要麼迫於壓力改邪歸正了,要麼被主管開除殆盡了。

如今,你更可能遇到的是這樣的代碼:

配置文件:

// 全局:定義統一的錯誤碼和錯誤文字
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)})
    }
}

這種錯誤處理原則是通過錯誤碼統一整個項目的 code 和 message,開發人員不能在程序中自己定義錯誤描述。

我稱這類程序員爲”錯誤碼“信徒。

“錯誤碼”們主要的擔心是:如果讓開發人員自己在代碼裏面定義錯誤描述,會導致“哈莫雷特”問題,即每個人的描述可能都不一樣,而且有可能會導致敏感信息泄露。

相對於“系統異常”們,“錯誤碼”們已經有了長足的進步,大家終於知道系統發生了什麼樣的錯誤,老闆們也不用擔心因客戶卡餘額不足導致的“系統異常”砸了品牌形象了。

從此人猿共歡了!

從此人猿共歡了?

用戶購買 500 元商品時提示“卡餘額不足”,但更好的提示應該是“卡餘額不足,當前可用餘額 420.00”。

當根據 userId 查不到用戶信息時,應該提示“用戶不存在”,但不能保證開發人員因不想定義新 code 而直接使用 404(未找到資源)。

錯誤碼機制的問題是其文字提示過於籠統,導致在某些錯誤場景下丟失重要價值信息(進而導致問題排查上的困難,問題遲遲得不到解決),另一些場景下則帶來不好的用戶體驗。

對於開發人員來說,它會帶來兩種效果:一些開發人員不想新定義一大堆錯誤碼,於是將就着使用現有的錯誤碼,導致錯誤提示不倫不類;另外一些開發人員則傾向於定義大量的錯誤碼,幾乎每處異常都定義一個新錯誤碼(理由是每處異常文字提示都不一樣),最終導致錯誤碼失控。



“錯誤碼”們的改進

改進其實很簡單,就是允許異常類傳入自定義描述:

// 增加了可選參數 message,允許傳入自定義描述
class MyException(code, message = '') {
    ...
}

期望程序中有如下調用:

if (balance < amount) {
    throw new MyException(NOT_ENOUGH, '卡餘額不足,當前可用餘額' + balance)
}

但你會驚奇地發現,大部分地方仍舊是這樣調的:

if (balance < amount) {
    throw new MyException(NOT_ENOUGH)
}

“錯誤碼”們忽略了很重要的心理學上的問題。

人都是有惰性的,如果你提供了偷懶的途徑,他沒有理由不偷懶。



反“錯誤碼”們:我們追求自由

和“系統異常”們以及“錯誤碼”們力求嚴格限制系統輸出不同,“自由派”追求極致的自由,code 和 message 都不用約束,開發人員想怎麼寫就怎麼寫。

所以你可能在多個地方看到“卡餘額不足”的錯誤,但每個的錯誤碼都不同(可能是不同的人寫的,也可能是同一個開發人員在不同時期寫的,甚至是同一個人在同一天寫的,寫的時候完全看心情)。

自由派的做法對於錯誤提示是有好處的,開發人員可以盡情地定製個性化的提示內容,當系統出現異常時能根據現場提示很快定位錯誤所在。不過由於錯誤碼是隨性寫的,對於依賴錯誤碼的調用方(系統)並不友好。一些系統需要依據 API 返回的錯誤碼做一些特殊邏輯處理,當調用方認爲 405 表示餘額不足,然而過幾天又來個 503 的餘額不足時,調用方程序員的內心肯定是崩潰的。



中庸之道

本人的異常處理原則是:強制固定 code、自定義 message

要想設計出“人猿共歡”的異常處理機制,必須先搞清楚誰需要用到這些信息。

異常信息的第一使用者是人,這裏包括使用者(用戶)和異常處理者(運營人員、程序員)。

細分一下,異常又分爲業務異常系統 bug

業務異常是指業務流程中的異常場景,如支付時卡餘額不足導致無法支付、用券時發現券不符合使用條件、用戶執行了某個未授權的操作等。這類異常的觸發者是用戶自己(而不是系統),信息受衆是用戶。所以業務異常的信息提示必須注重用戶體驗,優秀的提示文字至少要做到以下幾點:

  1. 尊重用戶,不要讓用戶感覺受到冒犯或戲謔(請慎用自認爲很“幽默”的話語);
  2. 清晰,應包含觸發異常的關鍵信息(如當餘額不足時應提示當前餘額是多少);
  3. 具備指引性,用戶看了之後清楚該怎麼做;

第二類異常是系統 bug,如接口超時、非預期參數導致程序崩潰、代碼邏輯 bug 等。該類異常的觸發者是系統(或者說開發系統的程序員),信息受衆是程序員。所以 bug 類型異常的信息提示必須對程序員友好,讓程序員看到錯誤提示後能夠快速定位到問題的原因、代碼所在的位置。

我們說異常,一般就是指 bug 型異常,這類異常佔程序員的精力也是最多的,也最值得優化處理機制。

bug 型異常具有如下特徵:

  1. 不可控性。沒有程序員會主動去寫 bug,但沒有哪個系統完全沒有 bug。我們無法預知 bug 到底來自哪裏、會有什麼樣的提示信息;
  2. 定位困難。當系統提示“餘額不足”時,我們很快知道是用戶卡沒錢了,但當系統提示“參數類型錯誤”時,我們往往只能一臉懵逼;
  3. 可能涉及敏感信息。如 SQL 操作錯誤時可能會將整個 SQL 語句暴露給外界;

因而優秀的 bug 型異常處理機制應做到:

  1. 提示信息對程序員友好;
  2. 記錄函數調用棧信息;
  3. 脫敏。

提示信息對程序員友好,可能意味着對用戶並不友好,一些程序員正是據此以“用戶體驗”之名將 bug 提示信息轉換成了“對用戶友好”的提示文案,結果是所有人看了都雲裏霧裏。

我的觀點是:bug 型異常壓根不用考慮用戶體驗。

爲啥?

因爲系統出 bug 本身已經是非常糟糕的用戶體驗了,用戶不會因諸如“哎呀,系統開小差了”之類的廢話就變得好受些,用戶真正關心的是儘快能正常下單。

此時的當務之急是快速修復 bug,所以提示文案的定位功能就非常重要,一段純技術性的文字,對於用戶來說可能是天書,但對於程序員很實用。

然而,這不意味着給到用戶端的錯誤提示就可以爲所欲爲。如果我們爲了方便定位便將整個程序調用棧 alert 出來,雖然可能並不會進一步拉低用戶體驗,但至少給人的感覺是不專業,而且過多的信息也意味着很容易暴露敏感信息(如程序路徑、軟件版本、SQL 語句),如果對方是個黑客,你只能自祈多福了。

另外要注重脫敏。大部分框架在數據庫操作失敗時,其 message 信息中都會包含諸如 SQL 語句之類的敏感信息,這類信息不可暴露到外面。

綜上,我們可以採取文案+日誌的策略,文案中包含關鍵信息,日誌中包含詳細信息(包括調用棧信息)。

大部分的 DB 庫拋出的異常都有共同基類(如 DBException),我們可以針對這類異常做脫敏處理。

這也告訴我們另一件事:當我們自己開發公共庫時,最好爲該庫定義一個統一基類異常,這樣當使用者想要特殊處理該庫拋出的所有異常時不至於狗咬刺蝟無處下牙了。

另外,有些團隊並不想記錄業務型異常的調用棧信息(“卡餘額不足”時,調用棧信息並無多大意義)。我們可以在框架層面定義個業務異常基類:BusinessException,異常處理時不記錄該類型的調用棧信息。

異常信息的另一個使用者是系統。包括其他服務、前端 js 腳本等。

我見過類似這樣的代碼:

try {
    ...
} catch (e) {
    switch (e.message) {
        case '用戶不存在':
            ...
        case ...
    }
}

如果某個後端程序員哪天心血來潮將“用戶不存在”改成“用戶信息不存在”,系統就崩了。

寫出如此脆弱系統的程序員應該被釘到 1024 號恥辱柱上!

不過,在釘釘子之前,我們應該傾聽一下他那痛苦的心聲:接口返回的錯誤碼實在是雜亂無章,光“用戶不存在”的錯誤碼就有八個,說不定未來還會增加。爲“系統穩定性”考慮,最終選擇匹配 message。

好吧,應該將後端程序員一起釘上去!

系統只會,也只應該關注錯誤碼。所以和 message 的隨意性不同,code 應具備相當的穩定性。

同一個系統,如果 406 表示“用戶不存在”,就絕不應該再用其他值(如 604)表示相同的含義。

另外,“code 面向系統”這一特點也要求 code 定義的是某一類異常(而不是某一個異常)。例如“訂單創建失敗”是一類異常,在業務代碼中針對不同的失敗原因有不同的 message,但其 code 都是一樣的。

然而人類對數字並不敏感,要不同的程序員都保證寫 throw new Exception('用戶不存在', 406)(而不是寫throw new Exception('用戶不存在', 604))是不可能的。

所以需要將數字文本化,也就是定義錯誤碼常量:

const USER_NOT_EXISTS = 406

代碼中只能使用錯誤碼常量:

throw new Exception('用戶不存在', USER_NOT_EXISTS)

禁止使用字面量。

不過上面這段 throw 並不理想,首先默認類型 Exception 並不具備業務語義,另外開發人員如果硬是用數字字面量誰也沒辦法。更可取的方式是針對每種類型異常定義單獨的異常類,該異常類僅允許傳入 message,類內部自行綁定 code:

// 用戶不存在
class UserNotExistsException extends Exception { 
    constructor(message) {
        super(message)
        
        this.code = ErrCode.USER_NOT_EXISTS
    }
}

使用:

if (!User.find(uid)) {
    // 此寫法更具表達性,而且開發人員無需關注錯誤碼
    throw new UserNotExistsException(`用戶不存在(uid:${uid})`)
}



異常捕獲機制僞代碼示例

先總結一下中庸主義的異常捕獲機制特點:

  1. 強制開發人員自己編寫異常描述文案;
  2. 整個項目強制使用統一的錯誤碼定義;
  3. 爲業務型異常定義單獨的基類;
  4. 關鍵信息脫敏處理;

統一錯誤碼定義:

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) {
            // 非業務型異常記錄 flag 標識
            message += `(flag:${flag})`
        }
        
        // 記錄日誌(日誌要記錄原始的 message)
        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)
}

一行代碼就給你打回原形!

所以異常處理機制是基於約定的(團隊公約)。

技術 Leader 必須對全員做系統的培訓,並公開制定團隊代碼規範,對不符合規範的 pull request 堅決打回,對屢教不改的要進行“小黑屋談話”!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章