Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] 应用自定义 4xx 和 5xx 的方案 #1086

Open
fengmk2 opened this issue Jun 21, 2017 · 51 comments
Open

[RFC] 应用自定义 4xx 和 5xx 的方案 #1086

fengmk2 opened this issue Jun 21, 2017 · 51 comments

Comments

@fengmk2
Copy link
Member

fengmk2 commented Jun 21, 2017

Updated: 最新的提案看下面 @popomore 的回复: #1086 (comment)

--

目标

让应用自身可以定制这些特殊响应的功能,而不是通过 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;
};
@fengmk2
Copy link
Member Author

fengmk2 commented Jun 21, 2017

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

@fengmk2
Copy link
Member Author

fengmk2 commented Jun 21, 2017

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

@fengmk2
Copy link
Member Author

fengmk2 commented Jul 3, 2017

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

@popomore
Copy link
Member

popomore commented Jul 4, 2017

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

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

@popomore
Copy link
Member

思考了一下,应用的业务错误不应该放在 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);
    }
  };
};

@fengmk2
Copy link
Member Author

fengmk2 commented Aug 19, 2017

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

@popomore
Copy link
Member

你说的就是中间件做的

@dead-horse
Copy link
Member

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

@popomore
Copy link
Member

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

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

@beliefgp
Copy link

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

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

@fengmk2
Copy link
Member Author

fengmk2 commented Sep 11, 2017

@beliefgp 可以提个 jsonp pr 看看

@beliefgp
Copy link

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

@beliefgp
Copy link

beliefgp commented Sep 20, 2017

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

@fengmk2
Copy link
Member Author

fengmk2 commented Jul 27, 2018

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

@popomore
Copy link
Member

popomore commented Aug 17, 2018

现在的错误处理插件是 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": ""
}

@fengmk2
Copy link
Member Author

fengmk2 commented Aug 17, 2018

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

@popomore
Copy link
Member

popomore commented Aug 17, 2018

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

@fengmk2
Copy link
Member Author

fengmk2 commented Aug 17, 2018

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

@popomore
Copy link
Member

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

@sang4lv
Copy link

sang4lv commented Aug 17, 2018

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

@popomore
Copy link
Member

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

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

一般用法

class InternalException extends BaseException {}

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

@fengmk2
Copy link
Member Author

fengmk2 commented Aug 19, 2018

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

@popomore
Copy link
Member

@atian25
Copy link
Member

atian25 commented Aug 24, 2018

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

cc @dead-horse #1086 (comment)

@popomore
Copy link
Member

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

@popomore
Copy link
Member

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

是啊,所以很尴尬。

@atian25
Copy link
Member

atian25 commented Aug 24, 2018

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

@popomore
Copy link
Member

popomore commented Aug 24, 2018

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

@dead-horse
Copy link
Member

new http.UnprocessableEntityError('message')

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

@acodercat
Copy link

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

@acodercat
Copy link

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

@fengmk2
Copy link
Member Author

fengmk2 commented Dec 23, 2018

@104gogo
Copy link

104gogo commented Jan 4, 2019

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

@atian25
Copy link
Member

atian25 commented Feb 14, 2019

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

@atian25
Copy link
Member

atian25 commented Feb 14, 2019

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

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

@nimoc
Copy link

nimoc commented May 25, 2019

我是从 #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 漂亮�的错误页面。

@fengmk2
Copy link
Member Author

fengmk2 commented May 25, 2019

@nimojs 基本就是这个思路。

@whwnow
Copy link

whwnow commented Jun 2, 2019

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

@popomore
Copy link
Member

popomore commented Jun 3, 2019

我继续跟进下

@DiamondYuan
Copy link

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

根据错误,自定义了

BadRequestError 400
UnauthorizedError 401
ForbiddenError 403
NotFoundError 404

等一系列 error。

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

@whwnow
Copy link

whwnow commented Aug 9, 2019

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

@popomore
Copy link
Member

popomore commented Aug 13, 2019

eggjs/egg-onerror#29

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

@songkeys
Copy link

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

@popomore
Copy link
Member

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

@songkeys
Copy link

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

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

@nimoc
Copy link

nimoc commented Mar 10, 2020

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

代码实现如下:

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 检查)

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests