持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第29天,点击查看活动详情
Koa 源码剖析
本文主要从源码的角度来讲述 Koa,尤其是其中间件系统是如何实现的。
跟 Express 相比,Koa 的源码异常简洁,Express 因为把路由相关的代码嵌入到了主要逻辑中,因此读 Express 的源码可能长时间不得要领,而直接读 Koa 的源码几乎没有什么障碍。
Koa 的主要代码位于根目录下的 lib 文件夹中,只有 4 个文件,去掉注释后的源码不到 1000 行,下面列出了这 4 个文件的主要功能。
- request.js:对 http request 对象的封装。
- response.js:对 http response 对象的封装。
- context.js:将上面两个文件的封装整合到 context 对象中
- application.js:项目的启动及中间件的加载。
1. Koa 的启动过程
首先回忆一下一个 Koa 应用的结构是什么样子的。
const Koa = require('Koa');
const app = new Koa();
//加载一些中间件
app.use(...);
app.use(....);
app.use(.....);
app.listen(3000);
Koa 的启动过程大致分为以下三个步骤:
- 引入 Koa 模块,调用构造方法新建一个
app
对象。 - 加载中间件。
- 调用
listen
方法监听端口。
我们逐步来看上面三个步骤在源码中的实现。
首先是类和构造函数的定义,这部分代码位于 application.js 中。
// application.js
const response = require('./response')
const context = require('./context')
const request = require('./request')
const Emitter = require('events')
const util = require('util')
// ...... 其他模块
module.exports = class Application extends Emitter {
constructor (options) {
super()
options = options || {}
this.proxy = options.proxy || false
this.subdomainOffset = options.subdomainOffset || 2
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'
this.maxIpsCount = options.maxIpsCount || 0
this.env = options.env || process.env.NODE_ENV || 'development'
if (options.keys) this.keys = options.keys
this.middleware = []
// 下面的 context,request,response 分别是从其他三个文件夹中引入的
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
// util.inspect.custom support for node 6+
/* istanbul ignore else */
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect
}
}
// ...... 其他类方法
}
首先我们注意到该类继承于 Events
模块,然后当我们调用 Koa 的构造函数时,会初始化一些属性和方法,例如以context/response/request
为原型创建的新的对象,还有管理中间件的 middleware
数组等。
2. 中间件的加载
中间件的本质是一个函数。在 Koa 中,该函数通常具有 ctx
和 next
两个参数,分别表示封装好的 res/req
对象以及下一个要执行的中间件,当有多个中间件的时候,本质上是一种嵌套调用,就像洋葱图一样。
Koa 和 Express 在调用上都是通过调用 app.use()
的方式来加载一个中间件,但内部的实现却大不相同,我们先来看application.js 中相关方法的定义。
/**
* Use the given middleware `fn`.
*
* Old-style middleware will be converted.
*
* @param {Function} fn
* @return {Application} self
* @api public
*/
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
debug('use %s', fn._name || fn.name || '-')
this.middleware.push(fn)
return this
}
Koa 在 application.js 中维持了一个 middleware
的数组,如果有新的中间件被加载,就 push
到这个数组中,除此之外没有任何多余的操作,相比之下,Express 的 use
方法就麻烦得多,读者可以自行参阅其源码。
此外,之前版本中该方法中还增加了 isGeneratorFunction
判断,这是为了兼容 Koa1.x 的中间件而加上去的,在 Koa1.x 中,中间件都是 Generator
函数,Koa2 使用的 async
函数是无法兼容之前的代码的,因此 Koa2 提供了 convert
函数来进行转换,关于这个函数我们不再介绍。
if (isGeneratorFunction(fn)) {
// ......
fn = convert(fn)
}
接下来我们来看看对中间件的调用。
/**
* Return a request handler callback
* for node's native http server.
*
* @return {Function}
* @api public
*/
callback () {
const fn = compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}
return handleRequest
}
可以看出关于中间件的核心逻辑应该位于 compose
方法中,该方法是一个名为 Koa-compose
的第三方模块github.com/Koajs/compo…,我们可以看看其内部是如何实现的。
该模块只有一个方法 compose
,调用方式为 compose([a, b, c, ...])
,该方法接受一个中间件的数组作为参数,返回的仍然是一个中间件(函数),可以将这个函数看作是之前加载的全部中间件的功能集合。
/**
* Compose `middleware` returning
* a fully valid middleware comprised
* of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
* @api public
*/
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}
该方法的核心是一个递归调用的 dispatch
函数,为了更好地说明这个函数的工作原理,这里使用一个简单的自定义中间件作为例子来配合说明。
function myMiddleware(context, next) {
process.nextTick(function () {
console.log('I am a middleware');
})
next();
}
可以看出这个中间件除了打印一条消息,然后调用 next
方法之外,没有进行任何操作,我们以该中间件为例,在 Koa 的 app.js 中使用 app.use
方法加载该中间件两次。
const Koa = require('Koa');
const myMiddleware = require("./myMiddleware");
app.use(md1);
app.use(dm2);
app.listen(3000);
app
真正实例化是在调用 listen
方法之后,那么中间件的加载同样位于 listen
方法之后。
那么 compose
方法的实际调用为 compose[myMiddleware,myMiddleware]
,在执行 dispatch(0)
时,该方法实际可以简化为:
function compose(middleware) {
return function (context, next) {
try {
return Promise.resolve(md1(context, function next() {
return Promise.resolve(md2(context, function next() {
}))
}))
} catch (err) {
return Promise.reject(err)
}
}
}
可以看出 compose
的本质仍是嵌套的中间件。
3. listen() 方法
这是 app
启动过程中的最后一步,读者会疑惑:为什么这么一行也要算作单独的步骤,事实上,上面的两步都是为了 app
的启动做准备,整个 Koa 应用的启动是通过 listen
方法来完成的。下面是 application.js 中 listen
方法的定义。
/**
* Shorthand for:
*
* http.createServer(app.callback()).listen(...)
*
* @param {Mixed} ...
* @return {Server}
* @api public
*/
listen(...args) {
debug('listen')
const server = http.createServer(this.callback())
return server.listen(...args)
}
上面的代码就是 listen
方法的内容,可以看出第 3 行才真正调用了 http.createServer
方法建立了 http
服务器,参数为上节 callback
方法返回的 handleRequest
方法,源码如下所示,该方法做了两件事:
- 封装
request
和response
对象。 - 调用中间件对
ctx
对象进行处理。
/**
* Handle request in callback.
*
* @api private
*/
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
4. next() 与 return next()
我们前面也提到过,Koa 对中间件调用的实现本质上是嵌套的 promise.resolve
方法,我们可以写一个简单的例子。
let ctx = 1;
const md1 = function (ctx, next) {
next();
}
const md2 = function (ctx, next) {
return ++ctx;
}
const p = Promise.resolve(
mdl(ctx, function next() {
return Promise.resolve(
md2(ctx, function next() {
//更多的中间件...
})
)
})
)
p.then(function (ctx) {
console.log(ctx);
})
代码在第一行定义的变量 ctx
,我们可以将其看作 Koa 中的 ctx
对象,经过中间件的处理后,ctx
的值会发生相应的变化。
我们定义了 md1
和 md2
两个中间件,md1
没有做任何操作,只调用了 next
方法,md2
则是对 ctx
执行加一的操作,那么在最后的 then
方法中,我们期望 ctx
的值为 2。
我们可以尝试运行上面的代码,最后的结果却是 undefined
,在 md1
的 next
方法前加上 return
关键字后,就能得到正常的结果了。
在 Koa 的源码 application.js 中,callback
方法的最后一行:
/**
* Return a request handler callback
* for node's native http server.
*
* @return {Function}
* @api public
*/
callback () {
const fn = compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}
return handleRequest
}
/**
* Handle request in callback.
*
* @api private
*/
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
中的 fnMiddleware(ctx)
相当于之前代码第 8 行声明的 Promise
对象 p
,被中间件方法修改后的 ctx
对象被 then
方法传给 handleResponse
方法返回给客户端。
每个中间件方法都会返回一个 Promise
对象,里面包含的是对 ctx
的修改,通过调用 next
方法来调用下一个中间件。
fn(context, function next () {
return dispatch(i + 1);
})
再通过 return
关键字将修改后的 ctx
对象作为 resolve
的参数返回。
如果多个中间件同时操作了 ctx
对象,那么就有必要使用 return
关键字将操作的结果返回到上一级调用的中间件里。
事实上,如果读者去读 Koa-router
或者 Koa-static
的源码,也会发现它们都是使用 return next
方法。
5. 关于 Can't set headers after they are sent.
这是使用 Express 或者 Koa 常见的错误之一,其原因如字面意思,对于同一个 HTTP 请求重复发送了 HTTP HEADER 。服务器在处理HTTP 请求时会先发送一个响应头(使用 writeHead
或 setHeader
方法),然后发送主体内容(通过 send
或者 end
方法),如果对一个 HTTP 请求调用了两次 writeHead
方法,就会出现 Can't set headers after they are sent
的错误提示,例如下面的例子:
const http = require("http");
http.createServer(function (req, res) {
res.setHeader('Content-Type', 'text/html');
res.end('ok');
resend(req, res); // 在响应结束后再次发送响应信息
}).listen(5000);
function resend(req, res) {
res.setHeader('Content-Type', 'text/html');
res.end('error');
}
试着访问 localhost:5000
就会得到错误信息,这个例子太过直白了。下面是一个 Express 中的例子,由于中间件可能包含异步操作,因此有时错误的原因比较隐蔽。
const express = require('express');
const app = express();
app.use(function (req, res, next) {
setTimeout(function () {
res.redirect("/bar");
}, 1000);
next();
});
app.get("/foo", function (req, res) {
res.end("foo");
});
app.get("/bar", function (req, res) {
res.end("bar");
});
app.listen(3000);
运行上面的代码,访问 http://localhost:3000/foo
会产生同样的错误,原因也很简单,在请求返回之后,setTimeout
内部的 redirect
会对一个已经发送出去的 response
进行修改,就会出现错误,在实际项目中不会像 setTimeout
这么明显,可能是一个数据库操作或者其他的异步操作,需要特别注意。
6. Context 对象的实现
关于 ctx
对象是如何得到 request/response
对象中的属性和方法的,可以阅读 context.js 的源码,其核心代码如下所示。此外,delegate
模块还广泛运用在了 Koa 的各种中间件中。
const delegate = require('delegates')
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('has')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable')
delegate
是一个 Node 第三方模块,作用是把一个对象中的属性和方法委托到另一个对象上。
读者可以访问该模块的项目地址 https://github.com/tj/node-delegates
,然后就会发现该模块的主要贡献者还是TJ Holowaychuk。
这个模块的代码同样非常简单,源代码只有 100 多行,我们这里详细介绍一下。
在上面的代码中,我们使用了如下三个方法:
- method:用于委托方法到目标对象上。
- access:综合
getter
和setter
,可以对目标进行读写。 - getter:为目标属性生成一个访问器,可以理解成复制了一个只读属性到目标对象上。
getter
和 setter
这两个方法是用来控制对象的读写属性的,下面是 method
方法与 access
方法的实现。
/**
* Delegate method `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.method = function(name){
var proto = this.proto;
var target = this.target;
this.methods.push(name);
proto[name] = function(){
return this[target][name].apply(this[target], arguments);
};
return this;
};
method
方法中使用 apply
方法将原目标的方法绑定到目标对象上。
下面是 access
方法的定义,综合了 getter
方法和 setter
方法。
/**
* Delegator accessor `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};
/**
* Delegator getter `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.getter = function(name){
var proto = this.proto;
var target = this.target;
this.getters.push(name);
proto.__defineGetter__(name, function(){
return this[target][name];
});
return this;
};
/**
* Delegator setter `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.setter = function(name){
var proto = this.proto;
var target = this.target;
this.setters.push(name);
proto.__defineSetter__(name, function(val){
return this[target][name] = val;
});
return this;
};
最后是 delegate
的构造函数,该函数接收两个参数,分别是源对象和目标对象。
/**
* Initialize a delegator.
*
* @param {Object} proto
* @param {String} target
* @api public
*/
function Delegator(proto, target) {
if (!(this instanceof Delegator)) return new Delegator(proto, target);
this.proto = proto;
this.target = target;
this.methods = [];
this.getters = [];
this.setters = [];
this.fluents = [];
}
可以看出 deletgate
对象在内部维持了一些数组,分别表示委托得到的目标对象和方法。
关于动态加载中间件
在某些应用场景中,开发者可能希望能够动态加载中间件,例如当路由接收到某个请求后再去加载对应的中间件,但在 Koa 中这是无法做到的。原因其实已经包含在前面的内容了,Koa 应用唯一一次加载所有中间件是在调用 listen
方法的时候,即使后面再调用 app.use
方法,也不会生效了。
7. Koa 的优缺点
通过上面的内容,相信读者已经对 Koa 有了大概的认识,和 Express 相比,Koa 的优势在于精简,它剥离了所有的中间件,并且对中间件的执行做了很大的优化。
一个经验丰富的 Express 开发者想要转到 Koa 上并不需要很大的成本,唯一需要注意的就是中间件执行的策略会有差异,这可能会带来一段时间的不适应。
现在我们来说说 Koa 的缺点,剥离中间件虽然是个优点,但也让不同中间件的组合变得麻烦起来,Express 经过数年的沉淀,各种用途的中间件已经很成熟;而 Koa 不同,Koa2.0 推出的时间还很短,适配的中间件也不完善,有时单独使用各种中间件还好,但一旦组合起来,可能出现不能正常工作的情况。
举个例子,如果想同时使用 router
和 views
两个中间件,就要在 render
方法前加上 return
关键字(和 return next()
一个道理),对于刚接触 Koa 的开发者可能要花很长时间才能定位问题所在。再例如前面的 koa-session
和 Koa-router
,我初次接触这两个中间件时也着实花了一些功夫来将他们正确地组合在一块。虽然中间件概念的引入让Node开发变得像搭积木一样,但积木之间如果不能很顺利地拼接在一块的话,也会增加开发成本。