1

image.png

作为用户,你是否有过这样的经历:使用软件时偶尔弹出一个消息,显示“系统异常!”?

作为程序员,你是否有过这样的经历:

运维同事跑来求助:“用户不能下单了!”

“显示什么错误?”

“系统异常!”

无论是作为用户还是程序员,当看到这四个字“系统异常”时,我都感到不安。

它只告诉我系统有问题,却没有提供任何有价值的信息。

这通常标志着程序员另一个痛苦日子的开始。

我们无法获取任何有价值的信息,只能盲目地到处查找。

首先,我们检查系统负载。嗯,没问题。

然后,我们查看错误日志。成堆的日志滚动不断,但似乎没有任何意义。

于是,我们不得不向运维同事求助:“能否帮我们获取用户的电话号码或账号信息?另外,他们的手机型号和版本也会有帮助!如果可以,请录制一个视频!”

等待了好像一个世纪,运维同事终于收集到了这些信息。然后我们花费数小时查看各种日志,仔细审查每一行代码,最终找到了错误所在。

为什么会有“系统异常”?

那些喜欢将所有外部错误信息写成“系统异常”的人通常有以下几种原因:

  1. 刚进入行业的新手,还没有经历过程序员的辛苦。
  2. 相信“敏感信息”,对他们来说,任何系统错误信息都是敏感的,必须“包装”起来。
  3. 公司所在的行业敏感,强制要求如此处理。

我见过一些系统是这样处理的:

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

这意味着这个系统的所有抛出的错误都会转换为“系统异常”!

而最糟糕的是,甚至没有记录任何日志!

为了方便后续开发人员定位错误,各种日志被添加到业务层代码中,使得业务代码不堪重负。

“系统异常”爱好者的改进措施

上述极端代码相对较少见。通常,我们会遇到这样的情况:

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)”。

此时,我不知道用户和程序员是否崩溃了,但至少你的老板是崩溃的。

“系统异常”的终结:错误代码的出现

“系统异常”引发的事情让人愤怒。如今,已经没有多少信徒了。要么他们在压力下改变了做法,要么已经被主管完全开除了。

现在,你更有可能遇到这样的代码:

配置文件:

// 全局:定义统一的错误代码和错误消息。
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)})
    }
}

这种错误处理原则是通过错误代码统一项目的代码和消息。开发人员不能在程序中定义自己的错误描述。

我称这些程序员为“错误代码”信徒。

“错误代码”组的主要担忧是,如果允许开发人员在代码中定义错误描述,可能会导致“哈姆雷特”问题,即每个人的描述可能不同,还可能导致敏感信息泄漏。

相比“系统异常”,“错误代码”组取得了显著进步。大家终于知道系统中发生了什么错误,老板们也不再担心因为客户卡余额不足导致的“系统异常”损害品牌形象。

当用户购买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)
}

追求自由:反“错误代码”

与“系统异常”和“错误代码”努力严格限制系统输出不同,自由派追求终极自由,对代码和消息没有任何限制。开发人员可以随意编写它们。

你可能会在多个地方看到“余额不足”的错误,但每个错误代码都不同(可能由不同的人编写,甚至是同一个开发人员在不同时间或同一天心情不同)。

自由派的方法对于错误提示有其好处。开发人员可以自由定制个性化的提示内容,当系统遇到异常时可以快速定位错误。然而,由于错误代码随意编写,对于依赖这些错误代码的调用方(系统)不友好。一些系统需要根据 API 返回的错误代码执行特殊逻辑。当调用方认为 405 代表余额不足,但几天后遇到 503 也表示余额不足时,程序员的心肯定会崩溃。

中庸之道

我对异常处理的原则是:强制使用固定代码和自定义消息。

要设计一个让用户和程序员都开心的异常处理机制,首先要了解谁需要使用这些信息。

异常信息的首要用户是人,包括用户(客户)和异常处理者(运维人员、程序员)。

进一步细分,异常可以分为业务异常和系统错误。

业务异常是指业务流程中的异常场景,例如支付时卡余额不足导致支付失败,使用优惠券时发现不符合使用条件,以及用户进行未授权操作。这类异常的触发是用户自己(而非系统),信息的受众是用户。因此,业务异常的信息提示必须关注用户体验。优秀的提示文本至少应达到以下几点:

  1. 尊重用户,避免让用户感到被冒犯或嘲笑(请谨慎使用你认为“幽默”的词语);
  2. 清晰,并包含触发异常的关键信息(如余额不足时提示当前余额);
  3. 引导用户,让他们知道看完提示后该做什么;

第二类异常是系统错误,例如接口超时,意外参数导致的程序崩溃,代码逻辑错误等。这类异常的触发是系统(或开发系统的程序员),信息的受众是程序员。因此,错误消息对错误类型异常必须对程序员友好,允许他们快速识别问题的原因并定位代码中的位置。

我们通常谈论的异常是指错误类型异常。这类异常消耗了程序员的大部分精力,也值得优化处理机制。

错误类型异常具有以下特点:

  • 不可预测性:没有程序员会主动写错误,但没有系统是完全没有错误的。我们无法预测错误来自哪里或会产生什么样的错误信息。
  • 难以定位:当系统提示“余额不足”时,我们很快知道这是用户的卡没有钱。然而,当系统提示“参数类型错误”时,我们通常感到困惑。
  • 可能涉及敏感信息:例如,当出现 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 请求,并与那些屡教不改的人进行“黑房对话”。

首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68k 声望104.9k 粉丝