Koa 是 Express 的原班开发人马使用 ES2015 中的新特性(主要是 Generator
)重新打造了新的 Web 框架——Koa,Koa 的初衷就是彻底解决在Node Web开发中的异步问题,在 ES2015 还没有被 Node 完全支持的时候,运行 Koa 项目需要在启动 Node 时加上--harmony
参数。
Koa 的理念与 Connect 更加相似,内部没有提供任何中间件,Express 中保留的静态文件和路由也被剔除,仅仅作为中间件的调用的脚手架。
Koa 的发展存在 Koa1.x 和 Koa2 两个阶段,两者之间的区别在于 Koa2 使用了 ES2017 中 async
方法来处理中间件的调用(Koa1.x 使用的是 generator
),该特性已经在 v7.6.0 之后的 Node 版本中提供原生支持。
前面已经提到,Koa1.x 和 Koa2 的主要区别在于前者使用 Generator
,后者使用 async
方法来进行中间件的管理。
在 Web 开发中,尽管 Node 本身是异步的,但我们还是希望能够顺序执行某些操作,而且代码实现要尽可能简洁。例如在收到 HTTP 请求时,我们希望先将请求信息写入日志,接着进行数据库相关的操作,最后返回对应的结果。
在实际开发中,这些操作会抽象为一个个中间件,通常都是异步进行调用的,我们的问题就回到了如何控制中间件的调用顺序上。
在 Koa1.x 的版本中,因此使用了 ES2015 提案中的 Generator
函数来作为异步处理的主要方式。
下面是一个 Koal1x 的例子。
const Koa = require('Koa');
const app = Koa();
app.use(function *(next) {
const start = new Date();
// 调用下一个中间件,即向前端响应 'Hello world'
yield next;
const ms = new Date() - start;
// 打印从请求到响应的耗时
console.log('%s %s - %s', this.method, this.url, ms);
});
app.use(function *() {
this.body = 'Hello world';
});
app.listen(3000);
当用户访问 localhost:3000 时,首先打印出 hello world ,再输出log 信息。
Koa1.x 对中间件的处理基于 co
模块,ES2017 的草案里增加了 async
函数,Koa 为此发布了 2.0 版本,这个版本舍弃了 Genrator
函数和 co
模块,完全是使用 async
函数来实现的,async
函数在 Node v7.6.0 之后才得到了完整的支持,因此要使用 Koa2 进行开发,本地的 Node 环境最好大于 7.6.0。
除此之外,Koa 和 Express 最大的不同之处在于 Koa 剥离了各种中间件,这种做法的优点是可以让框架变得更加轻量,缺点就是 Koa 发展时间还较短,各种中间件质量参差不齐,1.x 和 2.x 的中间件也存在一些兼容性问题,但对于多数常用的中间件来说,都已经实现了对Koa2.0 的支持。
在 Koa 项目的 GitHub 主页 https:/github.com/Koajs 中,列出了 Koa 项目本身和被一些官方整理的中间件列表,开发者也可以在GitHub 中搜索,查找比较活跃的中间件。
在本文中,我们主要介绍 Koa2 的使用,在后面内容里提到的 Koa 均代表 Koa2.0。
首先,我们从最简单的入门例子来看 Koa 的使用。
const Koa = require('Koa');
const app = Koa();
app.use(ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
Node 提供了 request(IncomingMessage)和 response(ServerReponse)两个对象,Koa 把两者封装到了同一个对象中,即 context
,缩写为 ctx
。
context
中封装了许多方法和属性,大部分是从 request
和response
对象中使用委托方式得来的,下面列出了 ctx
对象封装的一些属性以及它们的来源:
ctx.header
ctx.headers
ctx.method
ctx.url
ctx.originalUrl
ctx.origin
ctx.href
ctx.path
ctx.query
ctx.querystring
ctx.host
ctx.hostname
ctx.fresh
ctx.stale
ctx.socket
ctx.protocol
ctx.secure
ctx.ip
ctx.ips
ctx.subdomains
ctx.is()
ctx.accepts()
ctx.acceptsEncodings()
ctx.acceptsCharsets()
ctx.acceptsLanguages()
ctx.get()
ctx.body
ctx.status
ctx.message
ctx.length
ctx.type
ctx.headerSent
ctx.redirect()
ctx.attachment()
ctx.set()
ctx.append()
ctx.remove()
ctx.lastModified
ctx.etag
除了自行封装的属性外,ctx
也提供了直接访问原生对象的手段,ctx.req
和 ctx.res
即代表原生的 request
和 response
对象,例如 ctx.req.url
和 ctx.url
就是同一个对象。
除了上面列出的属性之外,ctx
对象还自行封装了一些对象,例如 ctx.request
和 ctx.response
,它们和原生对象之间的区别在于里面只有一部分常用的属性,我们可以试着将原生对象和 ctx
封装后的对象分别打印出来进行比较:
const Koa = require('Koa');
const app = Koa();
app.use(ctx => {
console.log(ctx.request);
console.log(ctx.response);
});
app.listen(3000);
访问 localhost:3000
,可以看出,二者的结构和原生对象还是有很大区别的,ctx.response
只有最基本的几个属性,上面没有注册任何事件或方法,这表示下面的使用方法是错误的:
fs.createReadStream('foo.txt').pipe(ctx.response);
上面的代码会抛出 TypeError:dest.on is not a function
的错误,原因也很简单,ctx.response
只是一个简单的对象,没有定义任何事件,要使用 pipe
方法,代码要改成:
fs.createReadStream('foo.txt').pipe(ctx.res);
state
属性是官方推荐的命名空间,如有开发者从后端的消息想要传递到前端,可以将属性挂在 ctx.state
下面,这和 react
中的概念有些相似,例如我们从数据库中查找一个用户 id
:
ctx.state.user = await User.find(id);
ctx.app // ctx 对 app 对象的引用
ctx.cookies.get(name, [options]) // 获取 cookie
ctx.cookies.set(name, value, [options]) // 设置 cookie
ctx.throw([msg], [status], [properties]) // 用来抛出异常的方法
// 例如
// ctx.throw('name required', 400);
// 这句代码相当于:
// const err = new Error('name required');
// err.status = 400;
// err.expose = true;
// throw err;
上面的内容也提到,Koa 在 ctx
对象中封装了 request
以及 response
对象,那么在处理 http
请求的时候,使用 ctx
就可以完成所有的处理。
在上面的代码中,我们使用:
ctx.body = "Hello World";
相当于:
res.statusCode = 200;
res.end("Hello World");
ctx
相当于 ctx.request
或者 ctx.response
的别名,判断 http 请求类型可以通过 ctx.method
来进行判断,get
请求的参数可以通过 ctx.query
获取。
例如,当用户访问 localhost:3000?kindName=Node
时,可以设置如下的路由。
app.get('/', async (ctx, next) => {
console.log(ctx.method); // GET
console.log(ctx.query); // { kindName: 'Node' }
// TODO
await next();
});
Koa 处理 get 请求比较简单,直接通过 ctx.req.
就能拿到 get 参数的值,post 请求的处理稍微麻烦一些,通常使用 bodyParser
这一中间件进行处理,但也仅限于普通表单,获取格式为 ctx.request.body.
。
例如我们构造一个简单的 fom
用来输入用户名和密码:
<form action="/login" method="post">
<input name="name">
<input name="password" type="password">
<input type="submit" value="Submit">
form>
服务端相应路由的代码就可以写成:
router.post('/login', (ctx, next) => {
const name = ctx.request.body.name;
const password = ctx.request.body.password;
});
在介绍 Koa 中间件之前,我们暂时先把目光投向 Express ,因为Koa 中间件的设计思想大部分来自 Connect,而 Express 又是基于 Connect 扩展而来的。
Express 本身是由路由和中间件构成的,从本质上来说,Express的运行就是在不断调用各种中间件。
中间件本质上是接收请求并且做出相应动作的函数,该函数通常接收 req
和 res
作为参数,以便对 request
和 response
对象进行操作,在 Web 应用中,客户端发起的每一个请求,首先要经过中间件的处理才能继续向下。
中间件的第三个参数一般写作 next
,它代表一个方法,即下一个中间件。如果我们在中间件的方法体中调用了 next
方法,即表示请求会经过下一个中间件处理。
例如下面的函数就可以拿来做一个中间件。
function md(req, res, next) {
console.log("I am a Middleware");
next();
}
由于中间件仍然是一个函数,那么它就可以做到 Node 代码能做到的任何事情,除此之外还包括了修改 request
和 response
对象、终结请求-响应循环,以及调用下一个中间件等功能,这通常是通过在内部调用 next
方法来实现的。如果在某个中间件中没有调用 next
方法,则表示对请求的处理到此为止,下一个中间件不会被执行。
中间件的加载使用 use
方法来实现,该方法定义在 Express 或者 Koa 对象的实例上,例如加载上面定义的中间件 md
:
const app = express();
app.use(md);
Express 应用可使用如下几种中间件:
上面是官网的分类,实际上这几个概念有一些重合之处。
使用 app.use
方法或者 app.METHOD
(Method表示 http
方法,即 get/post
等)绑定在 app
对象上的中间件。
const app = express();
// 没有挂载路径的中间件,前端每个请求都会经过该中间件
app.use(function (req, res, next) {
console.log('Time:', Date.now())
next()
});
app.use('/user/:id', function (req, res, next) {
console.log('Request Type:', req.method);
});
在第一个中间件中调用了 next
方法,因此会转到第二个中间件,第二个由于没有调用 next
方法,其后的中间件都不会执行。
和 Koa 不同,路由处理是 Express 的一部分,通常通过 router.use
方法来绑定到 router
对象上:
const app = express();
const router = express.Router();
// 将中间件挂载到 /1ogin 路径下,所有访问 /1ogin 的请求都会经过该中间件
router.use('/login', function (req, res, next) {
console.log('Time:', Date.now())
next();
});
错误处理中间件有 4 个参数,即使不需要通过 next
方法来调用下一个中间件,也必须在参数列表中声明它,否则中间件会被识别为一个常规中间件,不能处理错误。
app.use(function (err, req, res, next) {
console.error(err.stack);
res.status(500).send('Something broke!');
});
从 4.x 版本开始,Express 已经不再依赖 Connect 了。除了负责管理静态资源的 static
模块外,Express 以前内置的中间件现在已经全部单独作为模块安装使用。
第三方中间件可以为 Express 应用增加更多功能,通常通过 npm 来安装,例如获取 Cookie 信息常用的 cookie-parser
模块,或者解析表单用的 bodyParser
等。
Koa 没有任何内置的中间件,连路由处理都没有包括在内,所有中间件都要通过第三方模块来实现,比起 Express 来,其实更像是 Connect。.
无论是 Express 还是 Koa,中间件的调用都是通过 next
方法来执行的,该方法最早在 Connect 中提出,并被 Express 和 Koa 沿用。
当我们调用 app.use
方法时,在内部形成了一个中间件数组,在框架内部会将执行下一个中间件的操作放在 next
方法内部,当我们执行 next
方法时,就会执行下一个中间件。如果在一个中间件中没有调用 next
方法,那么中间件的调用会中断,后续的中间件都不会被执行。
对于整个应用来说,next
方法实现的无非就是嵌套调用,也可以理解成一个递归操作,执行完 next
对应的中间件后,还会返回原来的方法内部,继续向下执行后面的方法。
如下图所示,下面这张“洋葱图”很形象地解释了 Koa 中间件的工作原理,对于 request
对象,首先从最外围的中间件开始一层层向下,到达最底层的中间件后,再由内到外一层层返回给客户端。每个中间件都可能对 request
或者 response
对象进行修改。
接下来讲述的是 Koa 设计的核心部分,在 Web 开发中,我们通常希望一些操作能够串行执行,例如等待写入日志完成后再进行数据库操作,最后再进行路由处理。
在技术层面,上面的业务场景表现为串行调用某些异步中间件。比较容易想到的一种做法是把 next
方法放到回调里面。
下面的代码定义了两个 Express 中间件,和之前不同之处在于第二个中间件中调用了 process.nextTick()
表示这是一个异步操作。
const app = require('express')();
app.use(function (req, res, next) {
next();
console.log("I am middleware1 ");
});
app.use(function (req, res, next) {
process.nextTick(function () {
console.log("I am middleware2");
next();
});
});
app.listen(3000);
// 访问localhost:3000的输出结果
// I am middleware1
// I am middleware2
按照上面的原理,next
方法在执行完毕后返回上层的中间件,那么应该先执行 middleware2
,然后再执行 middleware1
;但由于第二个中间件内的 process.nextTick
是一个异步调用,因此马上返回到第一个中间件,继续输出 I am middleware1
,然后中间件二的回调函数执行,输出 I am middleware2
。
我们前面也已经提到了,在有些情况下,我们可能希望等待 middleware2
执行结束之后再输出结果。而在 Koa 中,借助 async / await
方法,事情变得简单了。
const Koa = require("Koa");
const app = new Koa();
app.use(async (ctx, next) => {
await next()
console.log("I am middlewarel ");
});
app.use(async (ctx, next) => {
process.nextTick(function () {
console.log("I am middleware2")
next();
});
});
app.listen(3000);
// 访问localhost:3000的输出结果
// I am middleware2
// I am middleware1
使用 await
关键字后,直到 next
内部的异步方法完成之前,midddlware1
都不会向下执行。
下面我们来看一个具体例子的分析,这个例子反映了一种常见的需求,即设置整个 app
的响应时间。
下面我们来介绍一个更贴近具体业务的例子。在 Web 开发中,我们希望能给长时间得不到响应的请求返回特定的错误信息。
如果是在 Express 中,可以使用 connect-timeout
这一第三方中间件来处理响应超时,该中间件实现很简单,下面是一段使用 connect-timeout
进行超时响应的示意代码。
const express = require('express');
const timeout = require('connect-timeout');
// example of using this top-level; note the use of haltonTimedout
// after every middleware; it will stop the request flow on a timeout
const app = express();
app.use(timeout('5s'));
app.use(some middleware);
app.use(haltOnTimedout);
app.use(some middleware);
app.use(haltOnTimedout);
function haltonTimedout(req, res, next) {
if (!reg.timedout) next();
}
app.listen(3000);
该中间件的实现很简单,timeout
内部定义了一个定时器方法,如果超过定时器规定的时间限制,就会触发错误事件并返回一个 503 状态码,并且 haltOnTimedout
后面的中间件不再执行。如果在定时器触发前完成响应,就会取消定时器。
这种做法虽然看起来能解决超时问题,但仔细想一想缺点也很明显,在 timeout
方法中只定义了一个简单的定时器,如果中间件中包含了一个异步操作,那么容易在调用回调方法时出现问题。
假设 timeout
加载后又引入了一个名为 queryDB
的中间件,该中间件封装了一个异步的数据库操作,并且将查询的结果作为响应消息返回。
queryDB
在大多数状态下很快(1秒内(就能完成,但有时会因为某些原因(例如被其他操作阻塞)导致执行时间变成了 10 秒,这时 timeout
中间件已经将超时信息返回给了客户端,如果queryDB
内部包含了一个 res.send
方法,就会出现 Can't set headers after they are sent
的错误。
要解决这个问题,比较妥当的方式是通过事件监听的方式,如果超时之后触发该事件,那么取消之后的全部操作,或者直接修改res.end
方法,在其中设置一个 flag
用来判断是否已经调用过。
上面问题的根本原因是 connect-time
,或者是 Express 没办法对异步中间件的执行进行很好的控制。
借助 async
方法中间件会按照顺序来执行,这时进行 timeout
管理就比较方便了,目前社区也有 koa-timeout
等一些中间件,读者可以自行去探索使用,也可以考虑自己实现,毕竟这样的中间件实现难度并不大。
下面是我自己实现的一个例子,核心思想是使用 promise.race
方法来比较 setTimeout
和之后的中间件哪个会更快完成。
app.use(async (ctx, next) => {
let tmr = null;
const timeout = 5000; // 设置超时时间
await Promise.race([
new Promise(function (resolve, reject) {
tmr = setTimeout(function () {
const e = new Error('Request timeout');
e.status = 408;
reject(e);
}, timeout);
}),
new Promise(function (resolve, reject) {
// 执行后面加载的中间件
(async function () {
await next();
clearTimeout(tmr);
resolve();
})();
})
])
});
如果我们想用上面的代码管理超时,queryDB
需要返回一个Promise
对象或者是 async
方法。