Lucas Liao's Blog

Koa2学习笔记

最近公司的技术分享涉及Koa,于是重新刷了一遍,学习过程中主要思考了以下几个问题:

  • koa的应用场景
  • koa的优势
  • koa内部是如何实现的?

一、Koa的应用场景

任何东西的出现都有其合理性, Koa是为了更方便构建http服务而诞生的一个NodeJs框架。没错,只会Js的你也可以curl了。

二、koa的优势

Koa的优势总结下来有两点:

  • 1.更轻量化,小而美。
  • 2.洋葱模型,更好地控制中间件服务的异步逻辑。

在Koa出现之前其实还有Express框架,Koa也是Express原班人马打造的。Express可以理解为http服务的大杂烩,各种功能应有尽有,并且都放在一起实现。既然有前者的经验铺路,Koa的实现更加精简,只负责核心逻辑,想要什么功能,则通过中间件的形式引入就行,好比如打火锅,想吃什么就把什么加进入。

在学习的过程中,也参考了很多学习资料,很多都提起看了koa的洋葱模型,通篇下来解释了一遍,我还是有不少疑惑。

我一直在想,洋葱模型,究竟适合什么业务场景?

asyn await语法的出现在一定程度来说,有推进Koa2的发展,使得异步处理逻辑更加清晰。举个🌰

1
2
3
4
5
6
7
8
9
10

async function log(ctx, next) {
let requestTime = new Date().valueOf();
await next();
console.log(`${ctx.url} duration: ${new Date().valueOf() - requestTime}`)
}

router.get('/', log, ctx => {
// do someting
})

因为存在一些语法糖,通过promise还原上述代码:

1
2
3
4
5
6
7
8
function log() {
return new Promise((reslove, reject) => {
let requestTime = new Date().valueOf();
next().then(_ => {
console.log(`${ctx.url} duration: ${new Date().valueOf() - requestTime}`);
}).then(reslove)
})
}

也就是说,调用next会给我们返回一个promise对象,而promise何时会resolve就是koa内部源码做的处理。

再详细分析下koa的洋葱模型上诉处理流程:

  1. 获取当前时间戳requestTime
  2. 调用next()执行后续的中间件,并监听回调
  3. 第二个中间件可能会调用第三个、第四个、第五个, 但这不是log所关心的,log只关心第二个中间件什么适合resolve,而第二个中间件的resolve则依赖他后边的中间件resolve
  4. 第二个中间件resolve,意味着后面的中间件执行,并且全部resolve了,此时才会执行log中next后面的代码。

本文只是以log为例子,简单介绍了一下koa中间件的一些核心概念,在真正的业务场景当然不会这么简单。

koa中间件存在的真正意义是什么?我又陷入了思考,谈下我个人的理解。客户端发送一次http请求,服务端在响应数据给客户端之前,可以通过中间件的组合,进行拦截处理。比如,整合请求参数、对请求的url进行差异化处理,查询数据等等,因为有asyn await的支持,中间件的编写从代码的优雅性来说,避免了then,callback的写法,总体来说还是挺舒服的~

三、koa内部是如何实现的?

打开koa的源码,发现核心code都放到了lib下,分别有如下几个文件:

  • application.js
  • contenxt.js
  • request.js
  • response.js

koa把NodeJs原生请求与响应重新封装了一遍,通过get, set的方式获取数据以及修改数据,并整合到context中。

1
2
3
4
5
6
7
8
9
10
11
import koa from 'koa'

const app = new koa();

app.use((ctx, next) => {
ctx.body = 'hello world~';
next();
})
app.listen(8000, () => {
// callback
})

通过几行简单的代码就可以开启一个http服务,试想下如果通过原生的createHttpServe, 得花多大的劲,源码不难理解,如果你去刷一遍源码,就可以感觉到前面为什么说koa小而美了。

阅读源码过后,有以下三点我觉得是比较有意思的:

  1. 客户端请求一次,koa内部都会通过const ctx = this.createContext(req, res)重新创建一个context对象, 源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}


  1. koa实际上是继承NodeJs的Emitter事件的,为什么需要这样做? 很重要的一个原因是koa提供订阅/发布功能,如果程序发生异常,就可以调用ctx.onerror(err),监听事件集中处理异常情况,源码如下:
1
2
3
4
5
6
7
8
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);
}

  1. 中间件的处理逻辑

如下,通过use方法,函数作为use方法的参数(函数的参数固定,第一个参数为context,第二个参数为next,表示下一个中间件函数),创建了三个中间件,内部的处理依次调用this.middleware.push(fn),将use的参数塞进数组中。

1
2
3
4
5
6
7
8
9
10
11
12
app.use((ctx, next) => {
ctx.body = '1'
next()
});
app.use((ctx, next) => {
ctx.body = '2'
next()
});
app.use((ctx, next) => {
ctx.body = '3'
next()
});

koa洋葱模型的核心代码就是koa-compose库,代码只有短短的几十行,牛逼~

客户端进行请求,触发compose(this.middleware), 从源码我们发现,此时自动执行第一个中间件,从而顺序遍历middleware所有元素,递归执行dispatch,此时洋葱由外到内,直至所有中间件都执行完成后,resolve, 洋葱从内到外,源码如下:

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
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)
}
}
}
}

上面我们知道,中间件的注册是通过如下的形式

1
2
3
4
5
app.use((ctx, next) => {
//do something

next();
})

敲黑板,留意这行代码Promise.resolve(fn(context, dispatch.bind(null, i + 1))), 其中dispatch.bind(null, i + 1))就是next(), koa内部会将我们手动执行下一个中间件。

感悟

或许有一天专门跑去写NodeJs了呢? 哈哈