[RFC] 框架统一错误码
## 背景
Egg 生态插件众多，研发流程可能会抛出各种框架、插件异常，开发者难以自行定位和解决

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

## 目标

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

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

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

<a name="fJHdd"></a>
#### FrameworkBaseError 错误码规范
对应 `EggBaseError` 的 code 就是 `${module}_${serialNumber}` 

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

<a name="XuzEq"></a>
#### FrameworkBaseError 实现
```typescript
// egg-errors

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

export class FrameworkBaseError extends EggBaseError<ErrorOptions> {
  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 `<Buffer ${bufStr}${tail}>`;
}

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 一样的思路
