eggjs / eggjs/egg

[RFC] 框架统一错误码

Open
#4,632 4 comments 3 reactions 4 assignees Claimed by @killagu View on GitHub
type: discussion type: feature type: proposals
Dominant language
TypeScript
Stars
19k
Forks
1.8k
PR merge metrics
No merged PRs in 30d

Description

## 背景
Egg 生态插件众多,研发流程可能会抛出各种框架、插件异常,开发者难以自行定位和解决

- 没有统一的错误引导文档,目前的文档分散在官网、语雀各个空间里;错误和文档之间没有建立联系
- 一些底层 sdk 的抛错已经丢失了链路信息,定位很困难。例如:mysql 客户端抛 ACCESS_DENIED_ERROR,已经在最后环节,但是问题可能是在上层 SDK 的使用上

## 目标

- 所有框架、中间件抛出的异常信息,带上对应的 FAQ 自查文档;向下兼容对非统一 Error 的错误展示
- 覆盖研发流程的所有阶段:CLI 命令、应用启动、运行时
- 逐步对框架和中间件使用统一的 Error 改造,补充 FAQ 文档

## 方案
**整个方案包含两部分:统一的 FrameworkError 和 FrameworkError 的 format**


### **统一的 FrameworkError**
[egg-errors](https://github.com/eggjs/egg-errors) 已经提供了基类,为了区分出业务错误和框架异常,需要增加一些属性,继承出一个基类 `FrameworkBaseError` 


#### FrameworkBaseError 错误码规范
对应 `EggBaseError` 的 code 就是 `${module}_${serialNumber}` 

| | **说明** | **取值** |
| --- | --- | --- |
| module | 模块 | 各种中间件、sdk、库的名字,例如:
- EGG-USERSERVICE
- CHAIR-BIN
- CHAIR-BUILD
|
| serialNumber | 序号 | 每个模块内抛错误的序号,人肉递增
0000-9999
000-999
00-99 
都可以 |
| errorContext | 错误上下文信息 | 类型 any,用于存放有助于排查异常的一些上下文信息,例如 traceId、userId 等 |


#### FrameworkBaseError 实现
```typescript
// egg-errors

import { EggBaseError, ErrorOptions } from '../';
import assert from 'assert';
import util from 'util';

export class FrameworkBaseError extends EggBaseError {
protected module: string;

constructor(message: string, serialNumber: string, errorContext?: any) {
super({ message, serialNumber, errorContext });
assert(message, 'message is required');
assert(serialNumber, 'serialNumber is required');
assert(this.module, 'module should be implement');

this.serialNumber = serialNumber;
this.errorContext = errorContext || '';
this.code = `${this.module}_${serialNumber}`;
}
}
```

#### 上层错误类实现
各个中间件和库实现类似,继承基类,在各自的仓库里独立实现。把原来 throw Error 的地方改造成 throw FrameworkError
```javascript
// egg-session
const { FrameworkBaseError } = require('egg-errors');

class EggSessionError extends FrameworkBaseError {
protected module = 'EGG_SESSION';
}
- throw new Error('xxx');
+ throw new EggSessionError('xxx', '00');
```

### FrameworkError 的 format

实现 `FrameworkErrorFormater` 基类,用于对 `FrameworkBaseError` 进行结构化信息输出,展示 faq 链接等相关信息,会把 error -> string,大的方向是不改原来的 error,避免副作用。format 的格式参考 `egg-logger` 的 [formatError](https://github.com/eggjs/egg-logger/blob/master/lib/utils.js#L136)
```typescript
// egg-errors

import { FrameworkBaseError } from '../';
import assert from 'assert';
import util from 'util';
import circularJSON from 'circular-json-for-egg';
import os from 'os';
const hostname = os.hostname();

function inspect(key, value) {
return `${key}: ${formatObject(value)}`;
}

function formatString(str) {
if (str.length > 10000) {
return `${str.substr(0, 10000)}...(${str.length})`;
}
return str;
}

function formatBuffer(buf) {
const tail = buf.data.length > 50 ? ` ...(${buf.data.length}) ` : '';
const bufStr = buf.data.slice(0, 50).map(i => {
i = i.toString(16);
if (i.length === 1) i = `0${i}`;
return i;
}).join(' ');
return ``;
}

function formatObject(obj) {
try {
return circularJSON.stringify(obj, (key, v) => {
if (typeof v === 'string') return formatString(v);
if (v && v.type === 'Buffer' && Array.isArray(v.data)) {
return formatBuffer(v);
}
if (v instanceof RegExp) return inspect(v);
return v;
});
} catch (_) {
/* istanbul ignore next */
return String(obj);
}
}

export class FrameworkErrorFormater {
protected static faqPrefix: string = 'https://eggjs.org/zh-cn/faq';
private static faqPrefixEnv = process.env.EGG_FRAMEWORK_ERR_FAQ_PERFIX;

static format(err: Error): String {
let errMessage = err.message;
if (err instanceof FrameworkBaseError) {
errMessage += `[参考:${FrameworkErrorFormater.faqPrefixEnv || FrameworkErrorFormater.faqPrefix}/${err.module}#${err.serialNumber}]`;
}
const errStack = err.stack || 'no_stack';
const errProperties = Object.keys(err).map(key => inspect(key, err[key])).join('\n');
return util.format('nodejs.%s: %s\n%s\n%s\npid: %s\nhostname: %s\n',
err.name,
errMessage,
errStack.substring(errStack.indexOf('\n') + 1),
errProperties,
process.pid,
hostname
);
}
}
```
上层可以这么封装,替换掉 faqPrefix
```typescript
// @custom/egg-errors
import { FrameworkErrorFormater } from 'egg-errors';

export const FAQ_PREFIX = 'https://www.coustomFramework.com/error_faq';

export class CoustomFrameworkErrorFormater extends FrameworkErrorFormater {
protected static faqPrefix = FAQ_PREFIX;
}
```

### Error format 的时机
和 [egg-onerror](https://github.com/eggjs/egg-onerror) 面向的场景不一样,它是面向应用使用者做兜底异常捕获的,只针对应用运行时阶段。而 FrameworkError 是面向应用开发者的,时机还包括 CLI、应用启动等阶段

#### 运行时
在 [https://github.com/eggjs/egg-onerror/blob/master/app.js#L27](https://github.com/eggjs/egg-onerror/blob/master/app.js#L27) 其实已经有统一的 logger 处理,在 `egg-logger` formatError 里做 Error 类型的判断从而 format

```javascript
// egg-logger/lib/util.js
const { FrameworkBaseError, FrameworkErrorFormater } = require('egg-logger');

function formatError(err) {
if (err instanceof FrameworkBaseError) {
return FrameworkErrorFormater.format(err);
} else {
if (err.name === 'Error' && typeof err.code === 'string') {
err.name = err.code + err.name;
}

if (err.host) {
err.message += ` (${err.host})`;
}
}

// name and stack could not be change on node 0.11+
const errStack = err.stack || 'no_stack';
const errProperties = Object.keys(err).map(key => inspect(key, err[key])).join('\n');
return util.format('nodejs.%s: %s\n%s\n%s\npid: %s\nhostname: %s\n',
err.name,
err.message,
errStack.substring(errStack.indexOf('\n') + 1),
errProperties,
process.pid,
hostname
);
}
```

#### 启动生命周期
在 `egg-core` lifecycle 里统一处理,在 ready(err) 前进行 ConsoleLogger.error(err),因为前面一步已经把 logger 底层的 formatError 处理好,所以这里直接用 ConsoleLogger 即可

```javascript
// egg-core/lib/lifecycle.js
class Lifecycle extends EventEmitter {
...

[INIT_READY]() {
this.loadReady = new Ready({ timeout: this.readyTimeout });
this[DELEGATE_READY_EVENT](this.loadReady);
this.loadReady.ready(err => {
debug('didLoad done');
if (err) {
this.ready(this.formatError(err));
} else {
this.triggerWillReady();
}
});

this.bootReady = new Ready({ timeout: this.readyTimeout, lazyStart: true });
this[DELEGATE_READY_EVENT](this.bootReady);
this.bootReady.ready(err => {
if (err) {
this.ready(this.formatError(err));
} else {
this.ready(true);
}
});
}

formatError(err) {
this.logger.error(err);
return err;
}

...
}
```

#### CLI 命令
诸如 `egg-bin dev/test/cov` 等 ,并不在 Egg 框架内,可以直接在各个 SDK 内部使用 `FrameworkBaseError` 和 `ConsoleLogger` 进行输出
```javascript
// egg-bin
const { FrameworkErrorFormater } = require('egg-errors');
const { EggConsoleLogger } = require('egg-logger');
class EggBinError extends FrameworkErrorFormater {
protected module = 'EGG_BIN';
}

const logger = new EggConsoleLogger();
const err = new EggBinError('xxx', '0000');
logger.error(err);
```

### FAQ prefix
`FrameworkBaseErrorFormater` 的 faqPrefix 可以通过继承基类复现,默认是到 Egg 官网

上层框架的订制者,可能会遇到上层框架、插件和 Egg 框架、插件都会抛 FrameworkError 的情况。对用户来讲,最好的体验是所有框架异常都统一到一个 faqPrefix 里进行引导

可以通过设置 `process.env.EGG_FRAMEWORK_ERR_FAQ_PERFIX` 环境变量来统一设定,比如 FAQ 链接到公司内部的文档,这就要求每个 FAQ 文档必须包含自身和下层框架的所有 FAQ 内容(社区的是开源,可以自行 COPY)

```
process.env.EGG_FRAMEWORK_ERR_FAQ_PERFIX = 'https://内部文档路径';
```

举个例子:
#### @custom/egg-bin
本地开发

```javascript
// lib/cmd/dev.js
* run(context) {
...

context.env.EGG_FRAMEWORK_ERR_FAQ_PERFIX = 'xxx';
yield super.run(context);
}
```

#### @custom/egg-script
应用部署启动,和 bin 一样的思路

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.