写在前面
相比express
的保守,koa
则相对激进,目前Node Stable已经是v7.10.0
了,async&await
是在v7.6
加入豪华午餐的,这么好的东西必须用起来
从目前历史来看,以顺序形式编写异步代码是自然选择的结果。微软出品的一系列语言,比如F# 2.0
(2010年)就支持了该特性,C# 5.0
(2012年)也添加了该特性,而JS在ES2016才考虑支持async&await
,期间生态出现了一些过渡产品,比如EventProxy、Step、Wind等异步控制库,ES2015推出的Promise、yield
,以及在此基础上实现的co
模块,都是为了让异步流程控制更简单
async&await
是最自然的方式(顺序形式,与同步代码形式上没区别),也是目前最优的方案
P.S.关于JS异步编程的更多信息,请查看:
- 模拟EventProxy_Node异步流程控制1
- Step源码解读_Node异步流程控制2
- 模拟Promise_Node异步流程控制3
- 向WindJS致敬_Node异步流程控制4
一.中间件
不像PHP内置了查询字符串解析、请求体接收、Cookie解析注入等基本的细节处理支持
Node提供的是赤果果的HTTP连接,没有内置这些细节处理环节,需要手动实现,比如先来个路由分发请求,再解析Cookie、查询字符串、请求体,对应路由处理完毕后,响应请求时要先包装原始数据,设置响应头,处理JSONP支持等等。每过来一个请求,这整个过程中的各个环节处理都必不可少,每个环节都是中间件
中间件的工作方式类似于车间流水线,过来一张订单(原始请求数据),路由分发给对应部门,取出Cookie字段,解析完毕把结果填上去,取出查询字符串,解析出各参数对,填上去,读取请求体,解析包装一下,填上去……根据订单上补充的信息,车间吐出一个产品……添上统一规格的简单包装(包装原始数据),贴上标签(响应头),考虑精装还是平装(处理JSONP支持),最后发货
所以中间件用来封装底层细节,组织基础功能,分离基础设施和业务逻辑
尾触发
最常见的中间件组织方式是尾触发,例如:
代码语言:javascript复制// 一般中间件的结构:尾触发下一个中间件
var middleware = function(err, req, res, next) {
// 把处理结果挂到请求对象上
req.middlewareData = handle(req);
// 通过next传递err,捕获异步错误
if (errorOccurs) {
return next(error);
} next();
};
把所有中间件按顺序串起来,走到业务逻辑环节时,需要的所有输入项都预先准备好并挂在请求对象上了(由请求相关的中间件完成),业务逻辑执行完毕得到响应数据,直接往后抛,走响应相关的一系列中间件,最终请求方得到了符合预期的响应内容,而实际上我们只需要关注业务逻辑,前后的事情都是由一串中间件完成的
尾触发串行执行所有中间件,存在2个问题:
- 缺少并行优化
- 错误捕获机制繁琐
对中间件按依赖关系分组,并行执行,能够提高性能,加一层抽象就能解决。错误需要手动往后抛,沿中间件链手动传递,比较麻烦,不容易解决
koa2.0中间件
看起来很漂亮:
代码语言:javascript复制app.use(async (ctx, next) => {
const start = new Date();
await next();
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
一个简单的响应耗时记录中间件,如果放到中间件队首,就能得到所有中间件执行的总耗时
与上面介绍的尾触发不同,有了await
就可以在任意位置触发后续中间件了,例如上面两个时间戳之间的next()
,这样就不需要按照非常严格的顺序来组织中间件了,灵活很多
之前之所以用尾触发,就是因为异步中间件会立即返回,只能通过回调函数控制,所以约定尾触发顺序执行各中间件
而async&await
能够等待异步操作结束(这里的等待是真正意义上的等待,机制类似于yield
),不用再特别关照异步中间件,尾触发就不那么必要了
二.路由
路由也是一种中间件,负责分发请求,例如:
代码语言:javascript复制router
.get('/', function (ctx, next) {
ctx.body = 'Hello World!';
})
.post('/users', function (ctx, next) {
// ...
})
.put('/users/:id', function (ctx, next) {
// ...
})
.del('/users/:id', function (ctx, next) {
// ...
})
.all('/users/:id', function (ctx, next) {
// ...
});
常见的RESTful API,把请求按method
和url
分发给对应的route
。路由与一般中间件的区别是路由通常与主要业务逻辑紧密相关,可以把请求处理过程分成3段:
请求预处理 -> 主要业务逻辑 -> 响应包装处理
对应到中间件类型:
代码语言:javascript复制请求相关的中间件 -> 路由 -> 响应相关的中间件
虽然功能不同,但从结构上看,路由和一般的中间件没有任何区别。router
是请求分发中间件,用来维护url
到route
的关系,把请求交给对应route
三.错误捕获
await myPromise
方式中reject
的错误能够被外层try...catch
捕获,例如:
(async () => {
try {
await new Promise((resolve, reject) => {
setTimeout(() => {
let err = new Error('err');
reject(err);
}, 100);
});
} catch (ex) {
console.log('caught ' ex);
}
})();
console.log('first log here');
注意,try...catch
错误捕获仅限于reject(err)
,直接throw
的或者运行时异常无法捕获。此外,只有在异步函数创建的那层作用域的try...catch
才能捕获到异常,外层的不行,例如:
try {
(async () => {
await new Promise((resolve, reject) => {
setTimeout(() => {
let err = new Error('err');
reject(err);
}, 100);
});
})();
console.log('first log here');
} catch (ex) {
console.log('caught ' ex);
}
因为异步函数自身执行后立即返回,外层try...catch
无法捕获这样的异步异常,会先看到first log here
,100ms
后抛出未捕获的异常
而Promise
有一个特殊机制:
特殊的:如果resolve的参数是Promise对象,则该对象最终的[[PromiseValue]]会传递给外层Promise对象后续的then的onFulfilled/onRejected
(摘自完全理解Promise)
也就是说通过resolve(nextPromise)
建立的Promise
链上任意一环的reject
错误都会沿着Promise链往外抛,例如:
(async () => {
try {
await new Promise((resolve, reject) => {
resolve(new Promise((rs, rj) => {
rs(new Promise((s, j) => {
setTimeout(() => {
j(new Error('err'));
}, 100);
}))
}))
});
} catch (ex) {
console.log('caught ' ex)
}
})();
仍然能够捕获到最内层的错误
捕获中间件错误
利用这个特性,可以实现用来捕获中间件错误的中间件,如下:
代码语言:javascript复制// middleware/onerror.js
// global error handling for middlewares
module.exports = async (ctx, next) => {
try {
await next();
} catch (err) {
err.status = err.statusCode || err.status || 500;
let errBody = JSON.stringify({
code: -1,
data: err.message
});
ctx.body = errBody;
}
};
把这个中间件放在最前面,就能捕获到后续所有中间件reject
的错误以及同步错误
全局错误捕获
上面捕获了reject
的错误和同步执行过程中产生的错误,但异步throw
的错误(包括异步运行时错误)还是捕获不到
而轻轻一个Uncaught Error
就能让Node服务整个挂掉,所以有必要添上全局错误处理作为最后一道保障:
// global catch
process.on('uncaughtException', (error) => {
console.error('uncaughtException ' error);
});
这个自然要尽量放在所有代码之前执行,而且要保证自身没有错误
粗暴的全局错误捕获不是万能的,比如无法在错误发生后响应一个500
,这部分是错误捕获中间件的职责
四.示例Demo
一个简单的RSS服务,中间件组织如下:
代码语言:javascript复制middleware/
header.js # 设置响应头
json.js # 响应数据转规格统一的JSON
onerror.js # 捕获中间件错误
route/
html.js # /index对应的路由
index.js # /html/:url对应的路由
pipe.js # /pipe对应的路由
rss.js # /rss/:url对应的路由
按顺序应用各中间件:
代码语言:javascript复制// global catch for middles error
app.use(onerror);// router
router
.get('/', function (ctx, next) {
ctx.body = 'RSSHelper';
})
.get('/index', require('./route/index.js'))
.get('/rss/:url', require('./route/rss.js'))
.get('/html/:url', require('./route/html.js'))
.get('/pipe', require('./route/pipe.js'))
app
.use(router.routes())
.use(router.allowedMethods())// custom middlewares
app
.use(header)
.use(json)
请求预处理和响应数据包装都由前后的中间件完成,路由只负责产生输出(原始响应数据),例如:
代码语言:javascript复制// route /html
const fetch = require('../fetch/fetch.js');
module.exports = async (ctx, next) => {
await new Promise((resolve, reject) => {
const url = ctx.params.url; let onsuccess = (data) => {
data = data || {};
ctx.state.data = data;
resolve();
}
let onerror = reject;
fetch('html', url)
.on('success', onsuccess)
.on('error', onerror)
}); next();
};
抓取成功后,把data
挂到ctx.state
上,resolve()
通知等待结束,next()
交由下一个中间件包装响应数据,非常清爽
项目地址:https://github.com/ayqy/RSSHelper/tree/master/node
参考资料
- koa
- koa github
- koa-router 7.x
- express