Koa01 中间件

Koa 的最大特色,也是最重要的一个设计,就是中间件(middleware)。基本上,Koa 所有的功能都是通过中间件实现的。

概念

Koa中间件的最大特色就是中间件(middleware)的设计。

中间件是一个函数,它处在HTTP Request和HTTP Response中间,用来实现某种中间功能,通过app.use()来加载中间件。

1
2
3
4
5
6
7
8
9
10
11
const Koa = require('koa');

const app = new Koa();

app.use(async (ctx) => {
ctx.response.body = 'GO'
});

app.listen(8080, () => {
console.log('app is listening 8080...');
});

中间件的执行顺序

多个中间件会形成栈结构,以先进后出的顺序执行:

  1. 最外层的中间件首先执行
  2. 代用next函数,把执行权交给下一个中间件
  3. ……
  4. 最内层的中间件最后执行
  5. 执行结束后,把执行权交回上一层的中间件
  6. ……
  7. 最外层的中间件收回执行权后,执行next函数后面的代码

看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
app.use(async (ctx, next) => {
console.log(1-1);
ctx.response.body = 'GO';
next();
console.log(1-2);
});

app.use(async (ctx, next) => {
console.log(2-1);
next();
console.log(2-2);
});

app.use(async (ctx, next) => {
console.log(3-1);
next();
console.log(3-2);
});

app.listen(8080, () => {
console.log('app is listening 8080...');
});

执行结果是:

1
2
3
4
5
6
1-1
2-1
3-1
3-2
2-2
1-2

这种先进后出的加载模型也可以叫做洋葱圈的模型:

如果中间件内部没有调用next函数,那么执行权就不会传递下去。

异步中间件

当中间件中包含异步操作时,中间件应该写成Async函数:

1
2
3
app.use(async (ctx, next) => {
ctx.response.body = await fse.readFile('../demo3/test.txt', 'utf-8');
});

注意,如果调用next,必须等待完成

1
2
3
4
5
6
7
8
app.use(async (ctx, next) => {
console.log(1);
next();
});

app.use((ctx) => {
ctx.response.body = await fse.readFile('../demo3/test.txt', 'utf-8');
});

如果是上面的形式,返回的body中将没有任何内容,这是因为Koa在Promise链被机械系了之后就结束了请求,这意味着我们在设置ctx.response.body之前,response就已经被发送了给客户端,正确的做法应该是在第一个中间件的next之前添加await

1
2
3
4
5
6
7
8
app.use(async (ctx, next) => {
console.log(1);
await next();
});

app.use((ctx) => {
ctx.response.body = await fse.readFile('../demo3/test.txt', 'utf-8');
});

当使用纯粹的Promise(不使用Async/Await)应该写成这样:

1
2
3
4
5
6
app.use((ctx, next) => {
ctx.status = 200
console.log('Setting status')
// need to return here, not using async-await
return next()
})

处理中间件中的错误

为了方便处理错误,最好使用try...catch将其捕获。但是为每个中间件写try...catch太麻烦,可以让最外层的中间件负责所有中间件的错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const handler = async (ctx, next) => {
try {
await next();
} catch (e) {
ctx.response.status = e.statusCode || e.status || 500;
ctx.response.body = {
message: e.message
}
}
};

app.use(handler);

app.use(async (ctx, next) => {
ctx.response.body = await fse.readFile('../demo3/test.txt', 'utf-8');
await next();
});

app.use((ctx) => {
ctx.throw(500)
});

运行中,没有被catch的错误都会触发Koa的error时间,监听这个事件,也可以处理错误:

1
2
3
4
5
6
7
const main = ctx => {
ctx.throw(500);
};

app.on('error', (err, ctx) =>
console.error('server error', err);
);

但是如果错误被catch捕获,就不会触发error事件,这时候必须调用ctx.app.emit()手动释放error事件,才能使监听函数生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// demos/18.js`
const handler = async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.app.emit('error', err, ctx);
}
};

const main = ctx => {
ctx.throw(500);
};

app.on('error', function(err) {
console.log('logging error ', err.message);
console.log(err);
});

中间件的开发

generator中间件的开发

Koa1中的异步流程控制使用的是Generator函数,所以Koa1的中间件都是基于generator的。

Generator中间件返回的是function *(){}函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function log(ctx) {
console.log(ctx.method, ctx.header.host + ctx.url);
}

module.exports = function () {
return function* f(next) {

// 执行中间件的操作
log(this);

if (next) {
yield next;
}
}
};

Generator中间件在Koa1中可以直接使用,在Koa2中需要使用koa-convert转换后进行使用

1
app.use(convert(loggerGenerator()));

Async中间件的开发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function log(ctx) {
console.log(2, ctx.method, ctx.header.host + ctx.url);
}

module.exports = function () {
return async function f(ctx, next) {
// 执行中间件的操作
log(ctx);

if (next) {
await next();
}
}
};

Async中间件在Koa2中可以直接使用:

1
app.use(loggerAsync());

中间件引擎

简单实现

Koa中的中间件的加载和解析主要是通过Koa的中间件引擎koa-compose模块来实现的,也是Koa实现洋葱模型的核心引擎。

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
35
36
37
38
const Koa = require('koa');
let app = new Koa();

const middleware1 = async (ctx, next) => {
console.log(1);
await next();
console.log(6);
}

const middleware2 = async (ctx, next) => {
console.log(2);
await next();
console.log(5);
}

const middleware3 = async (ctx, next) => {
console.log(3);
await next();
console.log(4);
}

app.use(middleware1);
app.use(middleware2);
app.use(middleware3);
app.use(async(ctx, next) => {
ctx.body = 'hello world'
})

app.listen(3001)

// 启动访问浏览器
// 控制台会出现以下结果
// 1
// 2
// 3
// 4
// 5
// 6

上面await next前后的操作,很像数据结构的一种场景——栈,先进后出,并且各个中间件有着统一的上下文,便于管理、操作数据,所以Koa的中间件具有以下特性:

  • 有统一的上下文对象context
  • 执行顺序先进后出
  • 通过next来控制先进后出的机制
  • 有提前结束机制

可以简单的用Promise来实现

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
* Created By zh on 2019-05-05
*/
// 所以 Koa 的中间件具有以下特性:
// 1 有统一的上下文对象 context
// 2 执行顺序先进后出
// 3 通过 next 来控制先进后出的机制
// 4 有提前结束机制
// 可以使用 Promise 来做一个简单的实现

let context = {
data: []
};

class MyKoa {
constructor() {
this.middlewares = [];
this.context = {
data: []
}
}
use(middleware) {
this.middlewares.push(middleware)
}
compose() {
let count = -1;
const dispatch = () => {
count += 1;
return Promise.resolve(this.middlewares[count](this.context, async () => {
if (this.middlewares.length - 1 === count) {
return Promise.resolve()
}
return dispatch()
}))
};
return dispatch().then(() => {
console.log('end');
console.log('context = ', this.context);
});
}
}

async function middleware1(ctx, next) {
console.log('action 001');
ctx.data.push(1);
await next();
console.log('cation 006');
ctx.data.push(6)
}

async function middleware2(ctx, next) {
console.log('action 002');
ctx.data.push(2);
await next();
console.log('cation 005');
ctx.data.push(5)
}

async function middleware3(ctx, next) {
console.log('action 003');
ctx.data.push(3);
await next();
console.log('cation 004');
ctx.data.push(4)
}

const app = new MyKoa();

app.use(middleware1);
app.use(middleware2);
app.use(middleware3);

app.compose();

// action 001
// action 002
// action 003
// cation 004
// cation 005
// cation 006
// end
// context = { data: [ 1, 2, 3, 4, 5, 6 ] }

源码解读

核心原理就如同上面的compose方法所示,洋葱模型的先进后出顺序,对应Promise.resolve的前后操作,来看一下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
35
36
37
38
39
40
const 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')
}
}
return function (context, next) {
let index = -1;
return dispatch(0);

function dispatch(i) {
console.log(index, 888);
console.log(i, 999);
// 理论上 i 会大于 index,因为每次执行一次都会把 i 递增,
// 如果相等或者小于,则说明 next() 执行了多次
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 () {
return dispatch(i + 1);
}))
} catch (err) {
return Promise.reject(err);
}
}
}
};

一个中间件中是不能够调用两次next,这是通过if (i <= index)这条代码来判断的,我想了好一会,才理解这是什么原理。先把它放在这里,把主题逻辑理清楚再回过头说它。

compose返回了一个匿名函数,匿名函数里定义了dispatch函数,并传入0作为初始函数。

dispatch函数中,i用于标识当前的中间件的下标(中间件通过use方法收集到了middleware这个数组中)。

然后判断next是否在一个中间件中多次调用(暂时略过),然后将当前的i赋值给indexindex的唯一的作用也是用来记录当前中间件的下标,判断next方法调用的次数,后面再说。

接下来对fn赋值,获得中间件,在定义中间件时传入了两个参数,第一个就是上下文对象ctx,第二个参数是用来控制流程的next方法,这个next方法中我们通过执行dispatch(i + 1)来递归调用,执行下一个中间件。

这也是为什么我们在自己编写中间件时需要手动执行await next(),只有执行了next函数,才能正确的执行下一个中间件

在多个中间件级联执行时,第一个中间件需要等待第二个中间件返回一个resolved的Promise,也就是在await next()后才能继续执行剩余代码,第二个中间件同样需要等待下一个中间件返回resolved的Promise,这样就实现了洋葱圈模型的执行顺序。

所以如果要写一个Koa2的插件就应该如同上面说的一样:

1
2
3
4
5
6
7
8
9
10
async function koaMiddleware(ctx, next){
try{
// do something
await next()
// do something
}
.catch(err){
// handle err
}
}

使用时:

1
app.use(koaMiddleware)

多次next的判定

虽然只有一行代码用来判断如果在一个中间件中执行了多次next方法,却真让我想了好一会才理解,还是我太笨了。

1
if (i <= index) return Promise.reject(new Error('next() called multiple times'))

正常情况下,index必然会小于i,在执行dispatch(i+1)时,实际上可以认为将当前中间件改变为了下一个中间件,每一个中间件都有着自己的闭包作用域,闭包中的i是固定的,而index是在闭包之外的变量,当执行到下一个中间件时index的值会发生改变。

如果执行了两次next,每个中间件的i是固定的,但是index一直在增大,出现了i <= index的情况,拿下面的情况举例吧:

有两个中间件:

1
2
3
4
5
6
7
8
9
10
11
async function middleware1(ctx, next) {
console.log('action 001');
await next();
console.log('action 004');
}

async function middleware2(ctx, next) {
console.log('action 002');
await next();
console.log('action 003');
}

同时我们在dispatch中打印出indexi的值:

1
2
3
4
5
function dispatch(i) {
console.log('index: ', index);
console.log('i: ', i);
// ...
}

在正常情况下打印的结果:

1
2
3
4
5
6
7
8
9
10
11
12
index:  -1
i: 0
action 001

index: 0
i: 1
action 002

index: 1
i: 2
action 003
action 004

如果在第一个中间件中执行两个next,执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
index:  -1
i: 0
action 001

index: 0
i: 1
action 002

index: 1
i: 2
action 003

index: 2
i: 1
something wrong-- Error: next() called multiple times
at dispatch (/Users/duola/projects/node-learning/demo4/koa-compose.js:33:31)
at /Users/duola/projects/node-learning/demo4/koa-compose.js:46:18
at middleware1 (/Users/duola/projects/node-learning/demo4/koa-compose.js:87:9)
at <anonymous>
at process._tickCallback (internal/process/next_tick.js:188:7)

执行时,进入“洋葱圈”的过程是不变的,但是在执行完action3之后,在第一个中间件中再次执行了next,而在第一个中间件中,i是一个闭包中固定的值,为0,所以在执行的dispatchdispatch(1),在执行完action3之后,index已经变成了2,这时候在判断时,i <= index相当于1 <= 2是成立的,抛出了错误。

参考