陆陆续续用了
koa
和co
也算差不多用了大半年了,大部分的场景都是在服务端使用koa
来作为restful服务器用,使用场景比较局限,这里总结一下。
异常处理其实是一个程序非常重要的部分,我自己以前写前端代码的时候很不注意这个,用node来写后台并被焦作人很多次了之后才真正明白了它的重要性。因为用node来写后台时,常常涉及到大量的网络异步请求、用户提交、数据库读写等操作。许多场景你即使做了充足的数据校验和多种情况考虑,但因为是基于网络和用户的,必然存在你无法掌控的情况,如果你没有做好try catch,那么一个bug就可能让你的node server倒下。虽然借助于pm2等等工具,你可以让他倒了再立马爬起来,但是倒下的代价是巨大的,而且如果没有try catch,没有日志记录的话,你甚至都不知道倒在哪了。所以错误处理对于提升程序的健壮性来说是必不可少的。",
<p class="tip">这篇文章需要koa和co以及generator的原理知识,因co和generator的处理流程有一点绕(捋顺了其实巨好懂),如果你对co/koa/generaotor
不太熟悉,最好先看看阮一峰老师es6入门的第十五章和第十七章。当然这篇文章也会讲到co的原理过程,也可能解释你的一些困惑。</p>
在co
里处理异常
co
是koa
(1)的核心,整个koa可以说就是http.listen + koa compress + co +context,co
保证了你能在koa的中间件generator里进行各种yield,完成异步函数的串行化书写,所以在koa
里处理错误有相当大的比重其实是在co
里处理错误,我们先说说怎么在co
里处理错误,这里几乎涵盖了90%的处理场景。
我以前的两种写法
首先说说我以前是怎么在使用co
时处理错误的。
- 第一种:
co(function* () {
let abcd = yield promiseAbcd.catch(function(err){
//错误处理写在这
})
})
- 第二种:
co(function* () {
let abcd
try{
abcd = yield promiseAbcd
}catch(err){
//错误处理写在这
}
})
第二种写法其实是明显优于第一种的,主要原因有三个:
- 第一种的错误处理写在回调函数里,这就意味着你没办法对函数外部的代码进行流程控制,你无法在错误了之后改变外部代码的执行流程,你没法
return
、break
、continue
,这是最大的问题。而能在异步处理中进行try/catch我觉得co
/koa
的一个非常大的优势(对TJ真是大写的服),要知道你对一个异步函数try/catch是并不能捕捉到他的错误的,如下,这是很棘手的问题。
try{
setTimeout(function(){
throw new Error()
},0)
}catch(e){
//这里根本捕捉不到错误的哈
}
- 第二点需要说的是你如果用这种方式写的话,你需要很清楚你现在yield出去的是promiseAbcd.catch()方法生成的promise,而不是promiseAbcd,这有什么问题?我们知道.catch会生成一个新的promise,并且会以你在.catch绑定的回调函数中的返回值或者throw的error,作为这个新的promise的成功原因或者失败error。所以,当promiseAbcd出错,首先执行你写的这个.catch()的绑定回调,然后如果你在绑定回调里面的返回值会被co接住,并扔回到generatorFunction,于是上面的这句代码中:
let abcd = yield promiseAbcd.catch(function(err){})
变量abcd
就会是function(err){}
的返回值,如果你没有注意到这个细节,没有返回值,那么abcd
就会得到默认返回值undefined
,然后继续往下执行,于是很有可能后续代码就会报错。
- 此外第一种写法的问题也依然还是出在回调函数上,既然是函数,作用域就是新的作用域,所以要想访问回调函数外层的变量的和
this
的话,需要使用箭头函数,所以这种写法就和箭头函数这种语法糖(其实箭头函数不是语法糖)绑定起来了,也就带来了隐患。 - 第四点问题在于代码的可读性上,明显写在
yield
后方的.catch
让你不会明显的注意到,而改为try catch
的方式,在横向长度上的减少带来的是纵向方向上的清晰,如果遇到yield promise.then(function(){......})
,本身就已经很长了,你再后面再加个catch
,这这这....
所以,第一种方式是很优雅的,但是很多同学可能会怀有疑问,为什么yield出去的异步任务竟然还能在原来的generator
上catch
住,那具体什么时候哪些情况可以catch
住,这其实是对于co
源码和处理过程的不熟悉导致的。
而且如果是并行的多个任务呢,比如你要并行的查多个sql,如果你希望在每个sql后面都绑定自己的错误处理,那么第一种方式的一个try catch
肯定不够,比如例子如下。
let promiseArr = []
promiseArr.push(sqlExecPromise('sql A'))
promiseArr.push(sqlExecPromise('sql B'))
try{
let [resultA, resultB] = yield promiseArr
}catch(e){
//这里能捕捉到上面的错误吗?
//如果上面的两个promise都报错呢?
//如果我想为各个promise绑定自己的错误处理,这种写法也不能满足需求吧?
}
所以我们需要首先来理清楚co
里面的异常控制,结合co
的源码,弄明白co
的处理过程,才能知道我们到底可以怎么处理错误。
说一嘴 iterator和generator
首先说一点点基础概念:generator
函数,执行后的返回值是这个generator
函数的iterator
(遍历器),然后对这个iterator
执行.next()
方法,可以让generator
函数往下执行,并且.next()
的返回值是generator
函数yield
出来的值。而对这个iterator
执行.throw(err)
方法,这将err
“传到”generator
函数当中:
function * generatorFunction (){
try {
yield 'hello'
yield 'world'
} catch (err) {
console.log(err)
}
}
// 执行generator函数,拿到它的iterator
let iterator = generatorFunction()
// 返回一个对象,对象的value是你yield出来的值
iterator.next() // { value: \"hello\", done: false }
iterator.next() // { value: \"world\", done: false }
iterator.throw(new Error('some thing wrong'))
// 此时这个err被\"传入\" generator函数,
// 并被其generator的try catch捕捉到
// log: Error: some thing wrong
结合源码看看co
的错误上报
要说的已经在图里面的(抱歉图片左侧.catch
里面的代码应该写console.log('outer error caught')
,写错了),我们在co里面其实遇到的错误有两种情况:
- 一种是yield的出去的这个promise,出了问题
co(function*(){ yield Promise.reject(new Error()) })
- 另外一种是generatorFunction的内部流程代码里出了问题:
co(function*(){ throw new Error() })
如果是第一种,我们看上面那幅我制作了好半天的图,co给promise绑定了回调,因此,当promise失败时,会执行蓝色框里的那句代码iterator.throw(err)
那么这个时候,err就被返回到了generator中,于是let a = Promise.reject(new Error('出错啦'))
就“变成”了throw new Error('出错啦')
,因此这就使得yield出去的promise发生错误时,也依然可以在内部的try catch捕捉到。
那如果情况是第二种,那也就是顺着图片里面的左侧代码继续执行,这个throw的err会“流落”到哪去呢?,我们需要知道iterator.thow(err)
不仅把错误"塞"回到了原来的generatorFunction里,还让generatorFunction继续执行了,它和iterator.next(data)
的功能都是一样的,我们可以把他们看做一个函数,这个函数在内部调用了generatorFunction继续执行的函数,generatorFunction继续执行的函数出错,这个函数必然就会像外抛出错误,所以iterator.thow(err)
和iterator.next(data)
不仅会把err和data塞回generatorFunction,还会继续generatorFunction的执行,并且在执行报错是捕捉到错误。
而在其实图片里面,我们可以看到co
在iterator.throw(err)
的代码外侧是用try catch包裹起来的,在catch里面,使用了reject(err)
,在iterator.next(data)
的外侧其实也有相似处理,其实在co里,所有的iterator.next(data)
和throw外侧都是用这个try catch包起来的,这就保证了,任何generatorFunction执行时候的错误都能被catch捕捉到,并将这个错误reject(err),也就是转移到co最开始return的那个promise上,因此如果是第二种情况也就是generatorFunction的内部流程代码里出了问题,那么这个error会报告到co函数返回的promise上。
于是co(function*(){}).catch()
的这个.catch就能起到捕捉这个错误的作用。所以大家应该明白为什么可以try catch 我们yield出去的promise上发生的错误,也知道其中的一整套原理,更明白如果是generatorFunction内部错误,那么我们应该怎么去捕捉。
好了,现在基本算是回答了之前两个问题中的一个。
那,并发的情况呢?
大家先试试下面这段代码:
co(function * () {
let promiseArr = []
promiseArr.push(Promise.reject(new Error('error 1')))
promiseArr.push(Promise.reject(new Error('error 2')))
try {
yield promiseArr
} catch (err) {
console.log('inner caught')
console.log(err.message)
}
//inner caught
//error 1
})
首先我们要知道当我们yield 出去一个填充了promise的array或者object的时候,co帮我们做了什么?我在上面那张图里面介绍了一个小细节,当co通过value下标从iterator.next()返回的对象中取出你yield出来的东西时,这个东西可能的情况有很多,绝大部分的情况可能是promise或者是thunk,他调用了一个内部的函数toPromise,这个函数会把你传出来的东西转换成promise,如果是一个数组,对数组执行promise.all,那么会得到一个新的promise,然后在这个promise上绑定成功和失败回调,因此,在generatorFunction内对yield进行try catch,会捕捉到这个父promise上的异常。对于promise.all返回的这个父promise,如果所有的子promise都成功了,他才会成功,如果任意一个子promise失败了,那么会导致他的失败,而且最关键的是,如果一个子promise失败了,那么这个子promise的失败原因(error)会作为父promise的失败原因(error),引起父promise的失败回调执行,而后续的子promise的失败都不会在父promise上产生效果,失败回调都不会执行(其实成功回调也不会执行),所以我们上面只能捕捉到一个error。
插一句,promise.all的这个机制很好理解,我虽然不清楚其内部具体实现,但是其实类似一个普通的promise,当你对它的reject或者resolve执行过一次后,不管你接下来再执行多少次resolve或者reject,都不会导致这个promise上绑定的成功回调或失败回调继续执行。
上面说明的这种情况导致只有第一个出现错误的子promise的error会被iterator.throw(error),从而被generatorFunction的try catch捕捉到,而后续的错误都不会被throw回去,也不会有任何的处理。generatorFunction当catch到第一个error就继续往后执行了,也不会停下来进行等待。导致的情况就是第一个子promise出bug以后,其他的子promise的就被遗忘在了陨落的赛博坦星球,他们不管成功或者失败,他们的data或者err我们都拿不到,而且很多时候我们甚至都无法终止他们(也就setTimeout和setInteval这种返回了句柄的可以终止),所以他们成了毫无意义的任务,一方面依然在执行,我们没法终止,另一方面执行的结果和错误我们都根本拿不到,成了占用着网络资源和计算能力却又没任何作用的吸血虫,如果他们中存在闭包,而且这个任务又有可能一直卡住的话,那么你可能要小心一点了,他可能会造成内存泄露。
那我们应该怎么去写并发情况下的错误控制,上面这种写法的一个唯一的好处在于你可以在结果拿到之后第一时间得到错误信息,如果你在此处就是希望all or nothing
,而且你不关心出错的原因是什么,连日志记录都不想要,就只希望出错了不要继续往下执行,或者你的并发的代码极少出错,那么也许上面的写法你会采用。但是其中的风险你应该已经明白了。
如果你没有那么强烈的快速知道错误发生并立即停止往下执行的需求(这种也许可以让你的结果返回得更快那么一点点,然并卵,子promise任务并没有被中断),那么我觉得最好的方式还是等所有的子任务的执行完毕,不管他是成功或者失败(因为它反正都在执行),这是一种无法终止已经开启的异步任务和promise.all的回调只能执行一次的回退方案,至少保险。而且这样的话,至少,你能有办法记录他出错的原因,也能针对整个并行任务的完成情况,执行后续的处理策略。
既然子任务的error会导致父promise的执行失败,那么就不能让子promise的error直接抛出去,所以子promise yield出去前先绑好.catch是肯定需要的,而且要处理好.catch绑定的错误回调里的返回值,不然我们虽然接住了错误,但是co
返回到generatorFunciton里面的却是undefined,我们不知道错误在哪了。
举个例子,我们可以写一个包装函数,然后为我们的每一个promise绑定好成功回调和错误回调,并借助闭包,让他们成功或失败后修改一个对象里面的对应属性,我们根据这个对象去判断是否执行成功。
function protect (promiseArr) {
//这个数组用于存储最后的结果
let resultArr = []
//为每个promise绑定成功和失败回调
promiseArr.forEach((promise,index) => {
//在resultArr的对应位置先插入一个对象
resultArr[index] = {
success: true,
}
promiseArr[index] = promise.then((data) => {
//如果成功,那么把结果写回结果数组中的对象
resultArr[index].data = data
},(err) => {
//失败就写入失败原因
resultArr[index].error = err
resultArr[index].success = false
})
})
// 这一步绑定.then必可不能少
return Promise.all(promiseArr).then(() => {
return resultArr
})
}
function generateRandomPromise() {
//这个函数随机产生一个promise,这个promise在100毫秒内可能成功or失败
let randomNum = Math.random() * 100
return new Promise (function(resolve, reject) {
setTimeout( function() {
if(randomNum > 50){
resolve('data!')
}else{
reject(new Error('error'))
}
},randomNum)
})
}
co(function * () {
let promiseArr = []
for (var i = 0; i < 10; i++) {
promiseArr.push(generateRandomPromise())
}
//下面这一句不用包裹try catch
let missionResults = yield protect(promiseArr)
console.log(missionResults)
})
上面的代码中,我们最后拿到的这个missionResults是一个数组,里面的每一个元素包含了我们的成功和失败信息,这种方式因为每个子promise都不会失败,所以也就不用在yield protect(promiseArr)外层包裹try catch,你要做的就是在拿到这个missionResult之后对里面的元素判断成功或失败,接着完成相关的处理,写日志、向用户返回500、尝试重新执行错误的任务之类的,这些都比你一行直接的console.log('internal error')
要好得多吧。
如果你的某一个子任务在卡住时会很长都不会reject,你觉得你可能难以接受所有的promise都必须成功或失败时才执行完这个父promise,那么你大可以为这个子promise包裹一层Promise.race()
,让他和一个setTimeout并行执行,时间太长就当做错误了去处理,让setTimeout优先返回(但是这依然是无法解决任务继续在后台执行的问题的)。总之这些都是你的细节处理。但是关于并发情况下的我们该怎么写,各种写法有什么隐患都说完了。我觉得虽然没有找到完美的方法解决并行时候的错误处理,但是我觉得其实我们自己已经能够根据自己的使用场景找到了应该还算是不错的处理方法。其实如果你的并发非常容易出错,出错情况非常多,错误可能会长期卡住没返回之类的,我觉得你可能需要思考和优化的是你的并发任务本身。(另外,我正在写关于co使用过程中的小技巧的文章,里面会包含并发数控制的内容,写好后会替换这个括号)。
在koa
里处理异常
在koa
里,我们通过app.use(function*(){})
,绑定了许多的中间件generatorFunction,app.use就是负责把你传入的generatorFunction放到一个中间件数组middleware
里,接下来用koa-compose对这个中间件数组处理了一下,使得koa对中间件的执行流程成为了我们大家所熟知的洋葱结构:
koa-compose的源码如下,巨短,就是先拿到后一个generatorFunction的iterator,然后把这个iterator作为当前generatorFunction的next,这样你在当前中间件里执行yield next,就可以执行后续的中间件流程,后续的中间件流程执行完之后又回到当前的中间件。而这一整套串好的中间件最终通过co包裹起来。
function compose(middleware){
return function *(next){
if (!next) next = noop();
var i = middleware.length;
while (i--) {
next = middleware[i].call(this, next);
}
return yield *next;
}
}
function *noop(){}
所以大概会像这样:
function out() {
function mid(){
function in() {
//....
}
}
}
也正是如此,内层的中间件中发生的错误,是能被外层的中间件给捕获到的,也就是你先app.use()的中间件能捕捉到后app.use()的中间件的错误。
所以:
var app = new Koa()
app.use(function* (next){
try{
yield next
}catch(err){
//打印错误
//日志记录
}
})
app.use(function* (next){
//业务逻辑
})
同时,我们说了,这一整套串好的中间件最终通过co包裹起来(其实是co.wrap),因此co会返回一个promise(我在前一节说过哈),因此如果这一整套串好的中间件在执行过程中出了什么错没有被catch住,那么最终会导致co返回的这个promise的reject,而koa在这个promise上通过.catch绑定了一个默认的错误回调,错误回调就是设置http status为500,然后把错误信息this.res.end(msg)
发送出去。因此出错时,浏览器会收到一个说明错误的500报文。
同时,这个错误回调里执行了this.app.emit('error', err, this)
,koa的app是继承自event
模块的EventEmitter
,所以可以在app上触发error事件,而你也可以在app上面监听错误回调,完成最差情况下的错误处理。
koa下的错误处理方式还是比较齐全的,另有koa-onerror
等npm包可供使用,使用了koa-onerror之后,你可以在中间件里直接this.throw(500,\"错误原因\")
,它会自动根据request的header的accept字段,返回客户端能accept的返回类型(比如客户端要application/json,那么返回的body就是{error:\"错误原因\"}
)。npm上有较多类似的包,不再赘述。
此外就是万一还是出现了你没有捕捉到的错误,在node里如果有未捕捉的错误时,会在process上触发事件uncaughtException,所以我们可以在process上监听此事件,但是并不应该是单纯的把error log记录好就完了,很明显如果这个错误也是卡住着的,内存是不会回收的,那么很有可能会发生内存泄露的错误,对于koa这种需要长期跑着的程序,这是相当大的风险的。所以最好的方法是把这个server关掉。
你听到这可能就有点崩溃了,“什么?我想方设法不让服务器挂掉,结果你主动给我关了?”所以为了让你的服务还能继续运行,比较好的方法就是用node的cluster
模块起一个master,然后master里面fork出几个child_process,每个child_process就是你对应的koa server,当child_process遇到错误时,那么应该记录日志,然后把koa给停了,并通过disconnect()告诉master“我关闭了,你再fork一个新的”,再接着等连接都关闭了,把自己给 process.exit()了。但是如果你想实现更加优雅的退出,想实现当前的连接都关闭之后再关闭服务器,那应该怎么做?
我们是
二手转转前端(大转转FE)
知乎专栏:https://zhuanlan.zhihu.com/zhuanzhuan
官方微信公共号:zhuanzhuanfe
微信公众二维码:
关注我们,我们会定期分享一些团队对前端的想法与沉淀