koa2 中的错误处理以及中间件设计原理

其实这不是一个问题,因为就 koa2 而言,他已经帮我做好了统一错误处理入口 app.onerror 方法。
我们只要覆盖这个方法,就可以统一处理包括 中间件,事件,流 等出现的错误。

但我们始终会看到 UnhandledPromiseRejectionWarning: 类型的错误。
当然,这不一定就是 koa 导致,有可能是其他异步未处理错误导致的,但这都不重要。
让我们来看看 koa 是如何处理全局错误的。

koa2 中间件

官网例子:

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

const app = new Koa();

app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

app.use(ctx => {
ctx.body = 'Hello Koa';
});

app.listen(3000);

由于 koa2 设计原理,让我们很容易的就实现了一个请求日志中间件。
这里就不上洋葱图了,因为这不是入门教程。

官网上也说了,中间件的 async 可以改写为普通函数。

1
2
3
4
5
6
7
app.use((ctx, next) => {
const start = Date.now();
return next().then(() => {
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
});

和上面效果一致。

但你知道为什么要加 return?如果不加 return 会发生什么吗?

多中间件

删除 return 测试后会发现,好像没问题,一切正常。
我们来看个例子:

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

const app = new Koa();

app.use((ctx, next) => {
ctx.msg = 'hello';
next();
});

app.use((ctx, next) => {
ctx.msg += ' ';
next();
});

app.use((ctx, next) => {
ctx.msg += 'world';
next();
});

app.use(ctx => {
ctx.body = ctx.msg;
});

app.listen(3000);

打开页面后,如果你看到 hello world 那恭喜你,一切正常。

中间件中的异常

如果我们不小心把 ctx.msg += 'world'; 写成了 cxt.msg += 'world'; 这种手误相信大家都会遇到吧。
或者干脆直接抛出个错误算了,方便测试。

1
2
3
4
5
app.use((ctx, next) => {
throw Error('炸了');
ctx.msg += 'world';
next();
});

恭喜得到 UnhandledPromiseRejectionWarning: Error: 炸了 错误一枚。
让我们加上 app.onerror 来和谐这个错误吧。

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
const Koa = require('koa');

const app = new Koa();

app.use((ctx, next) => {
ctx.msg = 'hello';
next();
});

app.use((ctx, next) => {
ctx.msg += ' ';
next();
});

app.use((ctx, next) => {
throw Error('炸了');
ctx.msg += 'world';
next();
});

app.use(ctx => {
ctx.body = ctx.msg;
});

app.onerror = (err) => {
console.log('捕获到了!', err.message);
}

app.listen(3000);

再次运行,遇到哲学问题了,为什么他没捕获到。
再试试官网中记载的错误处理方法 Error Handling.

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
const Koa = require('koa');

const app = new Koa();

app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = err.message;
ctx.app.emit('error', err, ctx);
}
});

app.on('error', (err, ctx) => {
console.log('捕获到了!', err.message);
});

app.use((ctx, next) => {
ctx.msg = 'hello';
next();
});

app.use((ctx, next) => {
ctx.msg += ' ';
next();
});

app.use((ctx, next) => {
throw Error('炸了');
ctx.msg += 'world';
next();
});

app.use(ctx => {
ctx.body = ctx.msg;
});

app.listen(3000);

再次运行,,神了,依然也没捕获到,难道官网例子是假的?还是我们下了个假的 koa ?

中间件关联的纽带

其实吧,我们违反了 koa 的设计,有两种方法处理这个问题。
如果不想改成 async 函数,那就在所有 next() 前面加上 return 即可。
如果是 async 函数,那所有 next 前面加 await 即可。

先来看看结果:

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
const Koa = require('koa');

const app = new Koa();

app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = err.message;
ctx.app.emit('error', err, ctx);
}
});

app.on('error', (err, ctx) => {
console.log('捕获到了!', err.message);
});

app.use((ctx, next) => {
ctx.msg = 'hello';
return next();
});

app.use((ctx, next) => {
ctx.msg += ' ';
return next();
});

app.use((ctx, next) => {
throw Error('炸了');
ctx.msg += 'world';
return next();
});

app.use(ctx => {
ctx.body = ctx.msg;
});

app.listen(3000);

再次运行,可以完美的捕获到错误。

自定义错误处理

如果是自定义异步操作异常呢。

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

const app = new Koa();

app.use(ctx => {
new Promise(() => {
throw Error('炸了');
});
ctx.body = 'Hello Koa';
});

app.onerror = (err) => {
console.log('捕获到了!', err.message);
}

app.listen(3000);

由于是用户自定义操作,什么时候发生错误其实是未知的。
但我们只要把错误引导到 koa 层面报错,即可利用 app.onerror 统一处理。

1
2
3
4
5
6
app.use(async ctx => {
await new Promise(() => {
throw Error('炸了');
});
ctx.body = 'Hello Koa';
});

这样他的错误其实是在 koa 的控制下 throw 的,可以被 koa 统一捕获到。

中间件原理

说了这么多错误处理方法,还没说为什么要这处理。
当然如果你对原理不感兴趣,其实上面就够了,下面的原理可以忽略。

koa 的中间件其实就是一个平行函数(函数数组)转为嵌套函数的过程。
用到了 koa-compose,除去注释源码就20行左右。

功底扎实的就不需要我多解释了,如果看不懂,那就大致理解为下面这样。

1
2
3
4
5
6
7
8
9
10
// 我们定义的中间件
fn1(ctx, next);
fn2(ctx, next);
fn3(ctx);
// 组合成
fn1(ctx, () => {
fn2(ctx, () => {
fn3(ctx);
})
});

是不是看的一脸懵逼,那就对了,因为我也不知道怎么表达。
看个类似的问题的,从本质问题出发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function fn(ctx) {
return new Promise(resolve => {
setTimeout(() => resolve(ctx), 0);
});
}

const ctx = { a: 1 };
fn(ctx).then((ctx) => {
ctx.b = 1;
fn(ctx).then((ctx) => {
ctx.c = 1;
fn(ctx).then((ctx) => {
ctx.d = 1;
fn(ctx).then((ctx) => {
fn(ctx).then(console.log);
});
});
});
}).catch(console.error);

执行后输出 { a: 1, b: 1, c: 1, d: 1 }
如果在内层回调中加个错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function fn(ctx) {
return new Promise(resolve => {
setTimeout(() => resolve(ctx), 0);
});
}

const ctx = { a: 1 };
fn(ctx).then((ctx) => {
ctx.b = 1;
fn(ctx).then((ctx) => {
ctx.c = 1;
throw Error('err');
fn(ctx).then((ctx) => {
ctx.d = 1;
fn(ctx).then((ctx) => {
fn(ctx).then(console.log);
});
});
});
}).catch(console.error);

跟 koa 中的情况一样,无法捕获,而且抛出 UnhandledPromiseRejectionWarning: 错误。
我们只需要加上 return 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function fn(ctx) {
return new Promise(resolve => {
setTimeout(() => resolve(ctx), 0);
});
}

const ctx = { a: 1 };
fn(ctx).then((ctx) => {
ctx.b = 1;
return fn(ctx).then((ctx) => {
ctx.c = 1;
throw Error('err');
return fn(ctx).then((ctx) => {
ctx.d = 1;
return fn(ctx).then((ctx) => {
return fn(ctx).then(console.log);
});
});
});
}).catch(console.error);

这次执行,发现捕获到了。为什么会发生这样的情况呢?
简单说吧,就是 promise 链断掉了。我们只要让他连接起来,不要断掉即可。
所以内层需要 return 否则就相当于 return undefined 导致链断掉了,自然无法被外层 catch 到。

1
2
3
4
5
6
7
8
9
10
11
const ctx = { a: 1 };
fn(ctx).then(async () => {
await fn(ctx).then(async () => {
await fn(ctx).then(async () => {
await fn(ctx).then(async () => {
throw Error('123');
await fn(ctx);
});
});
});
}).catch(console.error);

当然改成 async/await 也可以。

中间件设计

官网 issue 中 I can’t catch the error ~ 就有人问了,为什么我捕获不到错误。

回答中说,必须 await 或 return。
但也有人修改了源码,加了个类似 Promise.try 的实现。
然后被人说了,为什么你要违反他本来的设计。

其实没看到这个之前,我也打算自己修改源码的。
很多时候当我们看到代码为什么不那样写的时候,其实人家已经从全局考虑了这个问题。
而我们只是看到了这一个“问题”的解决方法,而没有在更高层面统筹看待问题。