我已經受夠了「系統異常」!

2023-02-14 18:00:30

作為使用者,你有沒有這樣的經驗:用個軟體,隔三岔五彈個框:系統異常!

作為程式設計師,你有沒有這樣的經驗:

運營同學又屁顛屁顛跑來求助:「使用者不能下單了!」

「報什麼錯?」

「系統異常!」

無論作為使用者還是程式設計師,一見到「系統異常」四個大字,我整個人都不好了。

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

這往往是程式設計師一天苦逼生活的開始。

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

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

再看看錯誤紀錄檔,一大堆紀錄檔滾來滾去,也看不出所以然。

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

等了半天,運營妹妹終於搞來了這些資訊,於是我們又一頓各種查紀錄檔,然後盯著程式碼一行一行找,最終發現了 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 堅決打回,對屢教不改的要進行「小黑屋談話」!