Egg: [RFC] 应用自定义 4xx 和 5xx 的方案

Created on 21 Jun 2017  ·  51Comments  ·  Source: eggjs/egg

Updated: 最新的提案看下面 @popomore 的回复: https://github.com/eggjs/egg/issues/1086#issuecomment-413840182

--

目标

让应用自身可以定制这些特殊响应的功能,而不是通过 302 跳转到其他地方。

兼容性原则

如果应用没有开启此功能,则保持原来的是否方式不变化。

notfound 中间件 throw 404 error

将 notfound 的逻辑也统一到 egg-onerror 来处理。

this.throw(404);

应用通过 app/onerror.js 来配置自定义的处理逻辑

框架和应用都可以覆盖 app/onerror.js 来实现统一处理逻辑。

  • 优先选择准确的 status handler
  • 找不到就找 4xx,5xx 这种通用 handler

    • 如果有 all,优先使用 all,否则根据 accepts 判断来选择 html,json

  • 都找不到就找全局默认 onerror 处理
// app/onerror.js
module.exports = app => {
  return {
    '404': {
      * html(ctx, err) {
        // 这里可以使用 render
        yield ctx.render('404.html');
      },
      * json(ctx, err) {
        // 不处理或者不配置或者返回 null, undefined,都会使用默认的 json 逻辑来处理
      },
    },
    '403': function* (ctx, err) {
      // all 的精简版本写法
    },
    '4xx': {
      * all(ctx, err) {
        // all 不区分 accepts,由开发者自行处理
      },
    },
    '500': {
      * html(ctx, err) {
      },
      * json(ctx, err) {
      },
    },
    '5xx': {
      * html(ctx, err) {
      },
      * json(ctx, err) {
      },
    },
  };
};

简写方式

  // app/onerror.js
  module.exports = {
    '404': {
      * html(ctx, err) {
        // 这里可以使用 render
        yield ctx.render('404.html');
      },
      * json(ctx, err) {
        // 不处理或者不配置或者返回 null, undefined,都会使用默认的 json 逻辑来处理
      },
    },
    '403': function* (ctx, err) {
      // all 的精简版本写法
    },
    '4xx': {
      * all(ctx, err) {
        // all 不区分 accepts,由开发者自行处理
      },
    },
  };

不分状态码的统一 handler

// app/onerror.js
  module.exports = {
    '404': {
      * html(ctx, err) {
        // 这里可以使用 render
        yield ctx.render('404.html');
      },
      * json(ctx, err) {
        // 不处理或者不配置或者返回 null, undefined,都会使用默认的 json 逻辑来处理
      },
    },
    '403': function* (ctx, err) {
      // all 的精简版本写法
    },
    '4xx': {
      * all(ctx, err) {
        // all 不区分 accepts,由开发者自行处理
      },
    },
  };

支持 async function

  // app/onerror.js
  module.exports = function* all(ctx, err, status) {
    // all 不区分 accepts 和 status,由开发者自行处理
  };

支持标准 Controller 的方式

这里只是扩展联想,API 没想好怎样设计

// app/onerror.js
module.exports = app => {
  class ErrorController extends app.Controller {
    * all(err, status) {

    }
    // 或者 async function 统一支持
    async all(err, status) {

    }
  }

  return ErrorController;
};
discussion feature proposals

Most helpful comment

我是从 https://github.com/eggjs/egg/issues/3593 在任何阶段终止流程并响应数据 过来的
转了一圈,最终我用了 middleware 的方式解决了自己抛出业务错误json的需求

我的需求其实是实现 PHP 的 die()

实现 die

// module/res/index
function die (data) {
  let error = new DieError(JSON.stringify(data))
  throw error
}

class DieError extends Error {}

实现中间件

// middleware/error.js
import { DieError } from "../../module/res/index"

module.exports = () => {
  return async function (ctx, next) {
    try {
      await next();
    }
    catch(err) {
      if (err.constructor === DieError) {
        ctx.status = 200
        ctx.set('Content-Type', 'text/json')
        return ctx.body = JSON.parse(err.message)
      }
      throw err
    }
  }
};

model 层中断并响应(可以是其他任何层)

let user = await user.find(query)
if (!user) {
  die({type: 'fail', code: 'USER_NOT_EXIST'})
}

如果进入 !user 逻辑。浏览器会响应 {"type":"fail","code":"USER_NOT_EXIST"} 并中断后续操作

这样即满足了我在 “任何阶段终止流程并响应数据” 的需求,又能利用 egg-onerror 漂亮的错误页面。

All 51 comments

app/onerror.js 文件存放路径参考了 app/router.js 的思路。

再脑洞一个点,如果 onerror 想由一个 Controller 来统一处理呢?因为 render 很可能是在 Controller 定制实现的,而不是 ctx,这种怎么办?

循环报错容灾一定要考虑在框架实现掉

循环报错容灾一定要考虑在框架实现掉

这个其实挺严重的,如果在里面调用了 render,render 还是会报错。

思考了一下,应用的业务错误不应该放在 onerror 处理,两者还是有一些不同的。

  • onerror 主要处理全局异常,这类基本都是未捕获异常,也就是应用开发者不知道哪里会抛异常,onerror 是用来兜底的。
  • 业务错误一般是应用开发者已知的, 所以都会有对应的处理,常见的就是反回对应的错误文案。这些错误尤其不能出现在错误大盘上,应该使用其他的监控方式,比如 xxx 业务的成功率。

所以我觉得业务的错误处理可以单独做一个插件,比如 egg-bizerror,这个插件提供如下功能

config/errorcode.js

错误码配置,可以定义如下规范

module.exports = {
  CODE_NAME: {
    message: 'error message',
    status: 'http status code',
  },
};

ctx.throwBizError(code, addition)

这个方法会 throw 一个异常,可在应用任何地方抛出,可用于打断操作。

// 以前的打断逻辑
const errors = app.validator.validate();
if (errors) return;

// 新的打断逻辑
const errors = app.validator.validate();
if (errors) ctx.throwBizError('CODE_NAME', errors);

上面的写法其实不好看出问题,如果嵌套很深,前一种写法会在每一层都会判断是否异常,而后一种写法只需要通过 throw。

throwBizError 的时候会读取 errorcode 的配置,将里面的配置都放到 Error 对象上。addition 是额外的数据,开发者可以增加一些动态的数据,比如某某 ID,最后通过 responseBizError 添加到 response 上。

ctx.responseBizError(err)

这个方法会处理 throwBizError 抛出的异常,将其转化成响应,转换后已经不是一个异常了。如果 err 非 throwBizError 抛出则交给 onerror 处理。

响应规范,根据 err.status 设置状态码

{
  "message": "",
  "code": "",
  "addition": ""
}

可以在任何地方使用此方法,如

try {
  // 执行
} catch (err) {
  ctx.responseBizError(err);
}

middleware

比较常用的是通过中间件,这样可以捕获所有的异常。所以插件可以提供一个中间件供配置

// app/middleware/bizerror.js
module.exports = function() {
  return function* (next) {
    try {
      yield next;
    } catch (err) {
      this.responseBizError(err);
    }
  };
};

任何地方 throwBizError 是否不需要 responseBizError 也能被正确处理 biz error 并返回相应的 response?

你说的就是中间件做的

就是要把内部 mbp / koi 用的那套业务错误码响应方式抽象出来。

现在提供的是中断抛出、拦截和响应,基本涵盖了每个步骤,有自定义就覆盖就好了。

关于错误码内容不是很强求,我看看能不能做到直接将 err 的属性放到 body 上。

弱弱的问下这个有进展没……等着用=。=||
有个疑问,jsonp如何处理?有两个方案:

  1. 这个bizerror只拦截controller层逻辑,可重载Controller基类,加层代理,这样处理完业务异常,还是可以走jsonp插件逻辑。
  2. egg-jsonp插件,在有请求时,在ctx打个标记,开放出来jsonp包装的方法,在bizerror中检测调用相关逻辑,我个人觉得这个可能更好点。
    仅供参考,可以的话,我提个jsonp的pr

@beliefgp 可以提个 jsonp pr 看看

@fengmk2 千总……提了,有空看看

老哥们……看你们迟迟没动静……我先写了个egg-bizerror,有空帮看看

看来还是很难统一,看看社区是否能够接受 egg-bizerror

现在的错误处理插件是 egg-onerror,但这个插件主要是优雅处理未捕获异常,也就是了为了让应用不挂进行兜底,但是现在没有一种统一的业务错误处理方案。

问题

业务校验

比如参数校验、业务验证等等,这些并不属于异常,一般会在响应时转成对应的数据格式。常见的处理方式是接口返回错误,并在 response 转换

class User extends Controller {
  async show() {
    const error = this.check(this.params.id);
    if (error) {
      this.ctx.status = 422;
      this.ctx.body {
        message: error.message,
      };
      return;
    }

    // 继续处理
  }

  check(id) {
    if (!id) return { message: 'id is required' };
  }
}

但是业务场景是非常复杂的,可能在 controller 里面调用多层 service,这样就必须把错误结果一层层传递。所以这种场景业务校验推荐使用异常的方式,类似上面的场景只需要抛出一个异常

class User extends Controller {
  async show() {
    this.check(this.params.id);

    // 继续处理
  }

  check(id) {
    if (!id) throw new Error('id is required');
  }
}

然后再中间件处理这个异常

异常类型区分

上面的示例也同样抛出 Error,如果不写中间件处理同样会走到 onerror 插件,根据规则会打印错误日志并返回 500 。

这不是我们期望的,开发者希望返回正确的格式,比如 status 是 422,body 是一个含错误信息的 json。所以我们需要明确已知异常和未捕获异常,并对他们做差异处理。

标准化响应

如果在写一个 api server 的时候,希望响应格式是规范的,而开发者一般都比较关注正常结果,异常时会返回各种格式,所以对于一个 api server 来说这也是非常重要的。

内容协商

有些应用会根据 content-type 来返回对应的数据,这种情况错误处理也需要根据这种场景来返回相应的结果。

Spec

错误定义

种类

错误分为三种未捕获异常、系统异常、业务异常,以下是分类比较

定义 | 未捕获异常 | 系统异常 | 业务错误
--- | --- | --- | ---
类名 | Error | xxxException | xxxBizError
说明 | js 内置错误,未做任何处理 | 自己抛出的系统异常 | 自己抛出的业务异常
错误处理方 | 由 onerror 插件处理 | 业务可扩展处理 | 业务可扩展处理
可识别 | 否 | 是 | 是
属性扩展 | 否 | 是 | 是

类名只是用来区分三种错误,继承可以自定义

所有的类均继承自 Error 类,并定义 BaseError 类,继承自 BaseError 的错误是可以被识别的,而其他三方继承 Error 的类都无法被识别。

class BaseError extends Error {}

class HttpClientError extends BaseError {}
class HttpServerError extends BaseError {}

BaseError.check(BaseError); // true
BaseError.check(Error); // false

如果业务抛出自定义的系统异常和业务错误,可直接在错误处理里面处理,未捕获异常在 onerror 中处理。

继承的错误可增加额外属性,比如 HttpError 可增加 status 属性作为处理函数的输入。

字段

标准字段包括

  • name: 一般为类名,如 NotFoundError
  • message: 错误的具体信息,可读的,如 404 Not Found
  • code: 大写的字符串,描述错误,如 NOT_FOUND

http 扩展

  • status: http 状态码,400

错误抛出

自行在代码里面引入对应的类

import { http } from 'egg-errors';

class User extends Controller {
  async show() {
    this.check(this.params.id);

    // 继续处理
  }

  check(id) {
    if (!id) throw new http.UnprocessableEntityError('id is required');
  }
}

自定义类

import { BaseError } from 'egg-errors';

class CustomError extends BaseError {
  constructor(message) {
    super(message);
    this.code = 'CUSTOM_ERROR';
  }
}

throw new CustomError('xxx');

错误处理

错误处理是最核心的功能,有如下规则

  1. 未捕获异常不做处理,向上抛
  2. 系统异常会打印错误日志,但是会按照标准格式 format
  3. 业务异常根据标准格式 format
  4. 根据内容协商,返回对应的 format 值
  5. 可自定义 format

标准 format

{
  "code": "",
  "message": ""
}

业务异常是否叫 xxxBizError 根据合适?要不然很难区分 js 内置的 TypeError 等 xxxError 命名的异常。

这里不是根据 name 来区分的,是根据继承链路,这个类名主要用来区分三种错误。

有业务处理,意思是业务来 throw error 吧?最终处理层还是在 onerror 吧?

@fengmk2 提供业务做 format,如果标准输出不符合要求,可以根据 err 对象的数据自行输出?我修改了描述“业务可扩展处理”

有个地方不是很清楚,系统和业务之间的边界是什么?是「Chair vs. 业务逻辑」还是「Chair 以及生态比如插件 vs. 业务逻辑」?

我理解系统错误(Exception)是相对未捕获异常而言的,比如一个底层模块抛出的错误是 Error,这时错误处理函数无法识别这个错误,所以可以在调用这个模块的时候捕获并创建一个系统错误,这样就可以识别了。

这里需要明确的是未捕获异常不会在业务的错误处理里面处理,会直接到 onerror 处理,所以一般业务的异常需要包一层来做到统一处理。

一般用法

class InternalException extends BaseException {}

try {
  // call method
} catch (err) {
  throw InternalException.from(err);
}

@popomore 就按你的这个 rfc,以应用代码的角度,先写一个 example 看看?

egg-errors 独立一个库这个没问题,不过我期望是在 egg 里面是集成进去,而不是开发者需要手动安装和import 。

cc @dead-horse https://github.com/eggjs/egg/issues/1086#issuecomment-413840182

egg 集成不好,比如插件要用就得依赖 egg,这样的依赖不是很合理。

不能 exports 出来? 就像 egg.Controller 那样,提供 egg.Error 就可以给插件用了吧?

不集成也不好的,如果到时候重构一个大版本,或者用户不小心锁版本之类的造成引入多个 egg-errors 可能造成一些特殊的问题。

我倾向于如要要做就要做成内置的,然后从 app 上获取这种模式

不能 exports 出来? 就像 egg.Controller 那样,提供 egg.Error 就可以给插件用了吧?

那相当于插件要在 dependencies 配 egg 了。

这个库没有特殊逻辑,基本上应用都不会直接使用这个 error 的,都需要继承来定义自己的 error,然后处理也是要自己定义 format,否则还是走到 onerror。

总的来说这是一个方案,不是一个功能。

那相当于插件要在 dependencies 配 egg 了。

嗯,这个不应该配置,要看有没有其他方式,但现在不是很推荐用 app.XX 的方式

我们现在是支持在插件里面提供 Service 的,好像只能用 app => class XService extends app.Service {} 的方式?

我们现在是支持在插件里面提供 Service 的,好像只能用 app => class XService extends app.Service {} 的方式?

是啊,所以很尴尬。

但在插件里面提供这些功能是合理的,要想个办法

这种只能是独立库,或者不要基类

new http.UnprocessableEntityError('message')

最好提供一些简化的方法,例如 new http.E422('params error')

请问异常跟错误有什么区别呢

还有就是希望能够通过ctx拿到自己定义的所有的异常,这样就不用自己来管理了,不然各种require很乱。就像service一样就挺好

请教下,自定义的 Error 类文件放在项目的哪个位置合适呢? @popomore

@104gogo 还没实现,你可以自己先放在 lib 目录吧。

Koa 其实有个 ctx.throw([status], [msg], [properties])

  • [x] egg-errors 统一的 Error 方案 #1086 (comment)
  • [ ] egg-onerror 改造支持 egg-errors @popomore

@fengmk2 第二点是按你楼顶的 RFC 做 ?

我是从 https://github.com/eggjs/egg/issues/3593 在任何阶段终止流程并响应数据 过来的
转了一圈,最终我用了 middleware 的方式解决了自己抛出业务错误json的需求

我的需求其实是实现 PHP 的 die()

实现 die

// module/res/index
function die (data) {
  let error = new DieError(JSON.stringify(data))
  throw error
}

class DieError extends Error {}

实现中间件

// middleware/error.js
import { DieError } from "../../module/res/index"

module.exports = () => {
  return async function (ctx, next) {
    try {
      await next();
    }
    catch(err) {
      if (err.constructor === DieError) {
        ctx.status = 200
        ctx.set('Content-Type', 'text/json')
        return ctx.body = JSON.parse(err.message)
      }
      throw err
    }
  }
};

model 层中断并响应(可以是其他任何层)

let user = await user.find(query)
if (!user) {
  die({type: 'fail', code: 'USER_NOT_EXIST'})
}

如果进入 !user 逻辑。浏览器会响应 {"type":"fail","code":"USER_NOT_EXIST"} 并中断后续操作

这样即满足了我在 “任何阶段终止流程并响应数据” 的需求,又能利用 egg-onerror 漂亮的错误页面。

@nimojs 基本就是这个思路。

请问这个需求大概什么时候会更新啊,有计划么?

我继续跟进下

我的实现参考了之前写 springboot 的思路。

根据错误,自定义了

BadRequestError 400
UnauthorizedError 401
ForbiddenError 403
NotFoundError 404

等一系列 error。

service 遇到问题,就直接 throw 相应等 error。然后用中间件处理,转换成对应的状态码。

请问 eggjs/egg-errors 这个是官方后续的解决方案么,如果是的话,我就直接用了,以后再跟着升级就好了。

https://github.com/eggjs/egg-onerror/pull/29

可以看看这个 PR,就是加个中间件来处理,主要卡在文档上。还有现在应用不是很广,会有什么未知的问题还不知道。

所以现在的方案是把 egg-errors 配合 egg-onerror 内置在 egg 了吗?

是的,内置就是加一个中间件。

@popomore 请问那个 PR 除了文档还有啥问题嘛,什么时候可以直接用上呢。

我想把它 fork 出一份先用上,但是怎么替换内置的 egg-onerror

回来备个注提供另外一种方案:

代码实现如下:

class Out {
  constructor() {
    this.fail = false
    this.message = ""
  }
  end(...messageList) {
    this.fail = true
    this.message = messageList.join("")
  }
  failReply() {
    return {
      type: "fail",
      msg: this.message,
    }
  }
}
function homeCtrl(req){
  let out = new Out()
  aService(req.id, out)
  // 控制层要检查out,并响应 out.failReply
  if (out.fail) {
    return out.failReply()
  }
  return {type:"pass"}
}
function aService(id, out){
 if (id === "1") {
   out.end("can not be 1") ; return
 }
 bService(id, out) ; if (out.fail) return // 如果出现代码 函数名(参数1, out) ,在 out) 后面必须要出现 if (out.fail) return
}
function bService(id, out){
  if (id === "2") {
    // 注意 js是隐式指针,所以如果 重新赋值 out,将会导致“out指针失效”, 如果增加  out = new Out()  会导致错误无法传递
    out.end("can not be 2") ; return
  }
}


console.log(
  homeCtrl({id: "1"})
) // {type: "fail", msg: "can not be 1"}
console.log(
  homeCtrl({id: "2"})
) // {type: "fail", msg: "can not be 2"}
console.log(
  homeCtrl({id: "3"})
) // {type: "pass"}


function cService(idList, out) {
  // 有些场景下如果在回调函数使用了 out.end 需要注意检查
  idList.some(function(id) {
    if (id == "c") {
      out.end("不能是c") ; return true
    }
  }) ; if (out.fail) return
}

一些用户正常操作,但是被拒绝的请求是应该给到友好的消息的,这些消息使用 out传递,如果是无法预料的异常,且错误信息不能暴露在响应中,则采取500,并隐藏错误信息。

这样可以让 try catch 捕获的都是异常,而不是业务错误。

当然这种out传递信息的方式,有一定的心智负担,需要记住几点

  1. out 本身只能在控制器通过 new Out() 新建,在后续函数中不允许出现 out = new Out() ,只允许出现 out.fail 这种通过指针修改参数的情况
  2. 每次使用 out.end() 时候,后面必须跟着 ; return
  3. 回调函数中只要使用了 out.end 就需要在函数执行完后进行检查参考 cService (最好是只要用了回调函数,无论有没有使用都进行 out.fail 检查)

另外:因为隐式指针,切记不要出现修改了指针变量。

Was this page helpful?
0 / 5 - 0 ratings