Koa 源码阅读学习笔记

最近看了 Koa 源码, 觉得Koa源码设计得特别巧妙而且也很简单,易读懂。基础的代码只有不到 2000 行。我们知道 Koa 是一个很轻量级的web框架, 里面除了middlewarectx 之外就什么没有了。虽然很简单, 但是功能还是很强大的,它仅仅是靠中间件就是搭建完整的web应用。这篇文章主要记录一下学习的笔记, 供之后翻阅。

读完这篇文章将会了解到:

  • 著名的洋葱模型
  • 上下文如何构建

Koa官方 Koa 中文文档Koa的源码可直接从 github 上获取

使用Demo

我们在读源码之前, 我们先看一下如何使用Koa。 通过阅读官方,我们来写一个例子:

  1. 创建js 文件并且初始化,然后安装koa
1
touch index.js && npm init && npm i koa

安装 nodemon

1
npm install --save-dev nodemon

nodemon 是一个当我们的代码变动之后, 能够及时的帮助我们重启服务,而不需要我们手动的启动了

index.js 文件直接使用官方提供的例子:

1
2
3
4
5
6
7
8
const Koa = require('koa');
const app = new Koa();

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

app.listen(3000);
  1. 启动应用

在项目的根目录下执行:

1
nodemon index.js

  1. 查看结果

这时候我们 可以打开浏览器输入localhost:3000 查看结构。或者直接使用如下命令在终端看结果:

1
2
curl http://localhost:3000
Hello World

结果返回了了”Hello World”文案

我们可以看到我们的项目中index.js 引用了Koa。然后用new来实例化一个app。然后使用app.use传入一个async函数(又称:koa中间件)。 最后调用app.listen方法,这样一个web应用就跑起来了。

如果有需要看例子的可到: 官方的例子

代码结构

Koa的代码结构很简单,总的只有四个文件:application.js, context.js ,request.js, response.js ,这四个文件在lib目录下:

其实从文件名上,我们就能猜到每个文件是做什么的了, 接下来我们根据流程来看每个文件的代码逻辑。

启动流程

启动的入口在哪里呢? 我们找到package.json文件的nain字段,可知道Koa 的入口是 lib下的application.js。 那我们就先来看看该文件。

application.js

在该文件中 , 我们可以看到是暴露了一个类Application, 该类继承Emitter

至于 Emitter 是什么, 我们下面会讲到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 = []
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
}
}
...
}

这里的constructor 主要是做了一些配置的处理, 主要的是:

1
2
3
4
this.middlewar-e = [] // 配置了中间件数组
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)

定义了中间件数组middleware; Koa 最大的特点就是可以有无数的中间件(app.use(fn)),所以得定义一个数组把这些中间件放到一起。其次使用Object.create来拷贝context, request,response。这三个都是lib目录下对应的文件。

思考: 为什么要拷贝(Object.create)呢?

原因:这是因为在一个应用中, 可能会有多个 Koa 实例(new Koa)。为了防止多个实例之间的污染,所以使用拷贝的方式来让其引用不指向同一个地址。

use 方法

当我们的实例new 好之后, 我们就可以使用app.use 来使用不同的中间件。我们来看看use方法:

1
2
3
4
5
6
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
}

首先判断传进来的参数是不是函数,如果不是则抛出错误。如果是函数, 则把该中间件(函数)添加到中间件数组中(middleware

listen 方法

看我们的简单实用案例,我们知道在 new Koa 的时候是并没有启动 Server的,是在listen 启动的。我们来看看逻辑:

1
2
3
4
5
6
7
8
listen (...args) {
debug('listen')
// 调用Node原生的http.createServer([requestListener])
// 参数requestListener是请求处理函数,用来响应request事件;
//此函数有两个参数req,res。当有请求进入的时候就会执行this.callback函数
const server = http.createServer(this.callback())
return server.listen(...args)
}

listen 主要是封装了 nodehttp 模块提供的createServer 方法方法来创建一个http服务对象和使用 listen方法来监听。

我们看 http.createServer([options][, requestListener])的函数签名不难猜出 this.callback返回的是optionsrequestListener; 那我们来看 callback 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
callback () {
// koa-compose 是Koa中间件洋葱模型的核心,具体讲解在下面
// 核心:中间件的管理和next的实现
const fn = compose(this.middleware)

// listenerCount和on均是父类Emitter中的成员
// 判断我们自己代码是否有自己写监听,如果没有就直接用 Koa 的 this.onerror 方法
if (!this.listenerCount('error')) this.on('error', this.onerror)

// Koa 的委托模式主要在这个函数体现
const handleRequest = (req, res) => {
// 每个请求过来时,都创建一个context
const ctx = this.createContext(req, res)

// 注意: 这里的 this.handleRequest 是父类上的 handleRequest。不是本 handleRequest
return this.handleRequest(ctx, fn)
}
return handleRequest
}

callback 中我们遇到了 Koa 的核心库:koa-compose 洋葱模型。 它用于精心组合所有middleware,并按照期望的顺序调用。

koa-compose(洋葱模型)

使用Demo

在看 koa-compose 之前,我们先看一下它的具体体现,看一个例子:

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

const app = new Koa();

app.use(async(ctx, next) => {
console.log("----start1----");
await next();
console.log("----end1----");
});

app.use(async(ctx, next) => {
console.log("----start2----");
await next();
console.log("----end2----");
});

app.use(async(ctx, next) => {
console.log("----start3----");
await next();
console.log("----end3----");
});

app.use(async(ctx, next)=> {
console.log("----start4----");
await next();
console.log("----end4----");
});

app.listen(3000);

执行 nodemon index.js 之后在命令行书输入: curl http://localhost:3000 可到结果为:

1
2
3
4
5
6
7
8
----start1----
----start2----
----start3----
----start4----
----end4----
----end3----
----end2----
----end1----

主要原理

我们来回忆一下上面这个Demo的过程: new 一个 app 之后,使用use(fn) 将函数push this.middleware。然后lisen创建了http.createServer(requestListener)参数requestListener是请求处理函数,用来响应request事件,而这里的requestListener 是我们的this.callback, 而this.callback的第一行代码就是我们的koa-compose

注意:这个过程就是上面那些源码的解释

而从得出的结果来看我们知道 :当程序运行到await next()的时候就会暂停当前程序,进入下一个中间件,处理完之后才会回过头来继续处理。而koa-compose就是用来做这个处理的—>next实现的。

那接下来我们就来看看koa-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
// middleware 就是我们使用app.use(fn) 传传入的函数数组(中间件)
function compose (middleware) {

// 判断 middleware 是否是数组
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')

// 判断 middleware 数组的每一个函数是否是函数
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}

// compose 函数返回一个函数
return function (context, next) {
let index = -1
return dispatch(0)
function dispatch (i) {
// 为什么会有这个? 是因为可能开发者在中间件函数中对此调用next函数, 是为了解决这个问题的, 具体看下面的问题2
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
// 当循环完中间件数组的函数,直接 return Promise.resolve(
if (!fn) return Promise.resolve()
try {
// 核心代码:返回Promise
// next时,交给下一个dispatch(下一个中间件方法)
// 同时,当前同步代码挂起,直到中间件全部完成后继续
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}

dispatch函数,它将遍历整个middleware,然后将contextdispatch(i + 1)传给middleware中的方法。其中的dispatch(i + 1)就是middleware中的方法的next函数。

主要是实现:

  1. context传给中间件
  2. middleware中的下一个中间件fn作为未来next的返回值。

剖析:

next()返回的是promise,需要使用await去等待promiseresolve值。promise的嵌套就像是洋葱模型的形状就是一层包裹着一层,直到await到最里面一层的promiseresolve值返回。

代码解析如下:

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
const middleware1 = async (context, next) => {
console.log("----start1----");
await next();
console.log("----end1----");
}
const middleware2 = async (context, next) => {
console.log("----start2----");
await next();
console.log("----end2----");
}
const middleware3 = async (context, next) => {
console.log("----start3----");
await next();
console.log("----end3----");
}
const middleware4 = async (context, next) => {
console.log("----start4----");
await next();
console.log("----end4----");
}
const MyCompose = () => {
return Promise.resolve(middleware1(this, ()=>{
return Promise.resolve(middleware2(this, () => {
return Promise.resolve(middleware3(this, () => {
return Promise.resolve(middleware4(this, () => {
return Promise.resolve();
}));
}))
}))
} ))
}
MyCompose();

结果:

1
2
3
4
5
6
7
8
----start1----
----start2----
----start3----
----start4----
----end4----
----end3----
----end2----
----end1----

问题1

以上的例子是符合的我们的预期的,但是会有人觉得奇怪,造成这样的预期是因为我们在中间件函数中使用了async函数, 但是真的是async函数起的作用吗?显然是不是的:,async并不是koa洋葱模型的必要条件

问题2

如果在中间件函数多次调用了next 函数会怎么样? Koa 如何处理呢?

Koa做了一个标记 i, 默认为 -1。 每一次调用dispatch i都会加1,在每个dispatch内部判断这个index是否小于等于现在的i(也就是在middleware的index),然后将更新这个index为i。 这时如果多次调用nexti就会>=现在的index,抛出错误。

也就是这段代码:

1
2
3
4
5
6
7
// ...
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// ...

当前的Koa例子代码: 洋葱模型

createContext

callback 函数中, 洋葱模型之后, 返回了一个处理request 的函数(handleRequest):

1
2
3
4
5
const handleRequest = (req, res) => { 
// 将req, res包装成一个ctx返回
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
}

这函数里面 使用 this.createContext(req, res) 创建 context。这里传入了http模块提供的两个参数reqres,然后声明ctxthis.createContext(req, res)的返回值。看下这个this.createContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
createContext(req, res) {
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
context.app = request.app = response.app = this;
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {};
return context;
}

这个函数的目的就是包装出一个全局唯一的 context

上面的constructor 里面声明了

1
2
3
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);

而在这里使用Object.create 又包装了一层

1
2
3
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);

目的是让每一次的http请求都生成全局唯一,相互之间隔离的context,request, response

最后总结一下 callback 函数: 首先koa-compose生成统一的中间件,然后handleRequest被调用。handleRequest 里面生成全局唯一的且包装好context。然后调用this.handleRequest 传入包装好context和中间件函数。

handleRequest

接下来就是this.handleRequest 了。 这个函数主要是用于真正的进行业务逻辑处理了。

1
2
3
4
5
6
7
8
9

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

fnMiddlewarekoa-compose包装后的函数, 函数签名是(context, next) => Promise, 内部会依次调用每个中间件。

onFinished 用于在请求closefinisherror时执行传入的错误回。

respond 函数用于将中间件处理后的结果通过res.end返回给客户端:

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
// Response helper.
function respond(ctx) {
// allow bypassing koa
if (false === ctx.respond) return; // ctx.respond = false用于设置自定义的Response策略

if (!ctx.writable) return;

const res = ctx.res;
let body = ctx.body;
const code = ctx.status;

// ignore body
if (statuses.empty[code]) {
// strip headers
ctx.body = null;
return res.end();
}

// HEAD请求不返回body
if ('HEAD' == ctx.method) {
// headersSent表示是否发送过header
if (!res.headersSent && isJSON(body)) {
ctx.length = Buffer.byteLength(JSON.stringify(body));
}
return res.end();
}

// status body
if (null == body) {
if (ctx.req.httpVersionMajor >= 2) {
body = String(code);
} else {
body = ctx.message || String(code);
}
if (!res.headersSent) {
ctx.type = 'text';
ctx.length = Buffer.byteLength(body);
}
return res.end(body);
}

// responses
if (Buffer.isBuffer(body)) return res.end(body);
if ('string' == typeof body) return res.end(body);
if (body instanceof Stream) return body.pipe(res); // 流式响应使用pipe,更好的利用缓存

// body: json
body = JSON.stringify(body);
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body);
}
res.end(body);
}

它做的是ctx返回不同情况的处理,如methodhead时加上content-length字段、body为空时去除content-length等字段,返回相应状态码、bodyStream时使用pipe

文章作者: 舒小琦
文章链接: https://shuliqi.github.io/2022/01/04/Koa-源码阅读学习笔记/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 舒小琦的Blog