Context 上下文对象

ctx -> Koa Context
ctx.req -> req -> IncommingMessage
ctx.res -> res -> ServerResponse

ctx.req 是 node 原生的,ctx.request 是 koa 封装的请求对象,提供了更高级的 API 和更友好的方法(内部实现是 ctx.req

在 lib/context.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* Request delegation.
*/

delegate(proto, 'request')
.method('acceptsLanguages')
.method('acceptsEncodings')
.method('acceptsCharsets')
.method('accepts')
.method('get')
.method('is')
.access('querystring')
.access('idempotent')
.access('socket')
.access('search')
.access('method')
.access('query')
.access('path')
.access('url')
.access('accept')
.getter('origin')
.getter('href')
.getter('subdomains')
.getter('protocol')
.getter('host')
.getter('hostname')
.getter('URL')
.getter('header')
.getter('headers')
.getter('secure')
.getter('stale')
.getter('fresh')
.getter('ips')
.getter('ip')

koa 将 request 对象上的方法、访问器、getter 代理到 context 对象上,这使得开发者可以直接使用 ctx.xx 来访问 request.xx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 方法委托
.method('accepts')
.method('get')
.method('is')

// 这两种写法效果相同
ctx.request.accepts('json');
ctx.accepts('json'); // 更简洁的写法

// 访问器委托(读写)
.access('method')
.access('query')
.access('path')
.access('url')

// 这些都是等价的
ctx.request.path
ctx.path

ctx.request.method = 'POST'
ctx.method = 'POST'

// getter 委托(只读)
.getter('protocol')
.getter('host')
.getter('hostname')
.getter('header')
.getter('ip')

// 这两种写法效果相同
ctx.request.ip
ctx.ip

洋葱圈的实现

我们这样使用中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const Koa = require('koa');
const app = new Koa();

// logger

app.use(async (ctx, next) => {
await next(); // 1
const rt = ctx.response.get('X-Response-Time'); // 2
console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

// x-response-time

app.use(async (ctx, next) => {
const start = Date.now(); // 3
await next(); // 4
const ms = Date.now() - start; // 5
ctx.set('X-Response-Time', `${ms}ms`);
});

// response

app.use(async ctx => {
ctx.body = 'Hello World'; // 6
});

app.listen(3000);

中间件都是async函数,koa 能够保证这些中间件是按照你加入中间件的顺序执行,这到底是怎么做到的?app.use的方法将所有的函数放入了自身维护的middleware数组。app.listen创建了一个httpServer并将onRequest回调指定为app.callbackapp.callback主要做了2件事:添加onerror,处理用户请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
callback() {
// 组合中间件
const fn = compose(this.middleware);
// 处理onerror
if (!this.listeners('error').length) 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 模块中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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, function next() {
return dispatch(i + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
}
}

以上的高阶函数内部递归调用了dispatch函数,一直迭代所有的中间件。本文开篇的那个例子中1处的await next将代码的执行权交给了2处记录下开始时间,接着执行4处的await,将代码的执行权交给了6接下来4处的await执行完了执行5,最后1处的await执行完了,至此中间完成了。

原理如下:

首先,我们在写中间时会有await next()的用法(注意,await会等到后面的Promise resolvereject后才厚向下继续执行),那么执行await next()就会转而执行dispatch(i + 1) ,直到最后一个中间件;当执行到最后一个再执行dispatch时,会触发if(!fn) return Promise.resolve(), 最后一个中间件开始执行await next()后面的逻辑,完成后,执行倒数第二个,依次执行到第一个中间件。

注意,当中间件中有两处await next()时,会触发if(i <= index) return Promise.reject(new Error('next() called multiple times')), 抛出错误。

这个过程有点像深度优先遍历,递归到树的最深处,然后逐层返回,最后的结果是初始调用第一层的结果(对应在koa中是我们use的第一个中间件)。其实和下面的代码是一个意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function fn1(next) {
console.log('fn1 start');
next();
console.log('fn1 end');
return 1;
}

function fn2(next) {
console.log('fn2 start');
next();
console.log('fn2 end');
return 2;
}

function fn3() {
console.log('fn3');
return 3;
}

const ret = fn1(() => fn2(() => fn3()));
console.log(ret);

输出:

1
2
3
4
5
6
fn1 start
fn2 start
fn3
fn2 end
fn1 end
1

整个函数的返回值是最外层函数的值。改写成递归就是如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const fns = [fn1, fn2, fn3];

function compose(fns) {

function dfs(i) {
let fn = fns[i];
if (!fn) return;
return fn(function next() {

return dfs(i + 1);
});
// 可以进一步简化
// return fn(dfs.bind(null,i+1));
}

return dfs(0);
}

compose(fns);

Koa 和 Express 中间件的区别

  1. Express 中间件是基于回调函数的,使用 next() 控制流程;中间件按顺序同步执行,直到调用 next() 才会进入下一个中间件;通过 next(err) 传递错误,需要在专门的错误处理中间件中捕获。
  2. Koa 中间件是基于 async/await 的,使用 await next() 控制流程;中间件按顺序执行,但支持“洋葱模型”,即先进入的中间件最后退出(请求在经过中间件的时候会执行 2 次,可以很方便进行前置和后置的处理);通过 try/catch 捕获错误,或使用 Koa 的全局错误处理机制。
  3. Express 使用 req(请求对象)和 res(响应对象)分别处理请求和响应, 需要在 req 或 res 上挂载数据,手动传递;Koa 使用 ctx(上下文对象)统一处理请求和响应,可以直接在 ctx 上挂载数据(例如:traceId 这种需贯穿整个请求(之后在任何地方进行其他调用都需使用)的属性便可挂载上去),无需手动传递。