作为用户,你是否有过这样的经历:使用软件时偶尔弹出一个消息,显示“系统异常!”?
作为程序员,你是否有过这样的经历:
运维同事跑来求助:“用户不能下单了!”
“显示什么错误?”
“系统异常!”
无论是作为用户还是程序员,当看到这四个字“系统异常”时,我都感到不安。
它只告诉我系统有问题,却没有提供任何有价值的信息。
这通常标志着程序员另一个痛苦日子的开始。
我们无法获取任何有价值的信息,只能盲目地到处查找。
首先,我们检查系统负载。嗯,没问题。
然后,我们查看错误日志。成堆的日志滚动不断,但似乎没有任何意义。
于是,我们不得不向运维同事求助:“能否帮我们获取用户的电话号码或账号信息?另外,他们的手机型号和版本也会有帮助!如果可以,请录制一个视频!”
等待了好像一个世纪,运维同事终于收集到了这些信息。然后我们花费数小时查看各种日志,仔细审查每一行代码,最终找到了错误所在。
为什么会有“系统异常”?
那些喜欢将所有外部错误信息写成“系统异常”的人通常有以下几种原因:
- 刚进入行业的新手,还没有经历过程序员的辛苦。
- 相信“敏感信息”,对他们来说,任何系统错误信息都是敏感的,必须“包装”起来。
- 公司所在的行业敏感,强制要求如此处理。
我见过一些系统是这样处理的:
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
也表示余额不足时,程序员的心肯定会崩溃。
中庸之道
我对异常处理的原则是:强制使用固定代码和自定义消息。
要设计一个让用户和程序员都开心的异常处理机制,首先要了解谁需要使用这些信息。
异常信息的首要用户是人,包括用户(客户)和异常处理者(运维人员、程序员)。
进一步细分,异常可以分为业务异常和系统错误。
业务异常是指业务流程中的异常场景,例如支付时卡余额不足导致支付失败,使用优惠券时发现不符合使用条件,以及用户进行未授权操作。这类异常的触发是用户自己(而非系统),信息的受众是用户。因此,业务异常的信息提示必须关注用户体验。优秀的提示文本至少应达到以下几点:
- 尊重用户,避免让用户感到被冒犯或嘲笑(请谨慎使用你认为“幽默”的词语);
- 清晰,并包含触发异常的关键信息(如余额不足时提示当前余额);
- 引导用户,让他们知道看完提示后该做什么;
第二类异常是系统错误,例如接口超时,意外参数导致的程序崩溃,代码逻辑错误等。这类异常的触发是系统(或开发系统的程序员),信息的受众是程序员。因此,错误消息对错误类型异常必须对程序员友好,允许他们快速识别问题的原因并定位代码中的位置。
我们通常谈论的异常是指错误类型异常。这类异常消耗了程序员的大部分精力,也值得优化处理机制。
错误类型异常具有以下特点:
- 不可预测性:没有程序员会主动写错误,但没有系统是完全没有错误的。我们无法预测错误来自哪里或会产生什么样的错误信息。
- 难以定位:当系统提示“余额不足”时,我们很快知道这是用户的卡没有钱。然而,当系统提示“参数类型错误”时,我们通常感到困惑。
- 可能涉及敏感信息:例如,当出现 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 已收录,有一线大厂面试完整考点、资料以及我的系列文章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。