我已经受够了“系统异常”!

时间:2023-02-14 17:21:12

作为用户,你有没有这样的经验:用个软件,隔三岔五弹个框:系统异常!

作为程序员,你有没有这样的经验:

运营同学又屁颠屁颠跑来求助:“用户不能下单了!”

“报什么错?”

“系统异常!”

无论作为用户还是程序员,一见到“系统异常”四个大字,我整个人都不好了。

它除了告诉我系统出问题了,没有任何有价值的信息。

这往往是程序员一天苦逼生活的开始。

我们获取不到任何有价值的信息,只能到处抓虾。

先看看系统负载,嗯,没问题。

再看看错误日志,一大堆日志滚来滚去,也看不出所以然。

于是我们不得不求助运营同学:“去要一下用户手机号或者账号,手机型号、版本,最好能录个频!”

等了半天,运营妹妹终于搞来了这些信息,于是我们又一顿各种查日志,然后盯着代码一行一行找,最终发现了 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 坚决打回。