Koa源代码分析

Koa是最为常见和广泛使用的Node平台框架之一,阿里开源的Egg.js也是基于Koa扩展。那么Koa到底做了什么呢?洋葱头模型又是怎么一回事?Koa有哪些关键的组件?

本文为你娓娓道来。

Koa的代码,要从createServer说起

刀工火种 VS Koa

使用Node.js提供的http库创建WebServer

我们想实现一个简单的WebServer,要求实现以下功能:

  • 简单记录请求和返回结果
  • 统计每次请求所需要花费的事件
  • 执行一个时间1s的任务,并将结果返回
  • 监听3000端口。

不使用框架的条件下,我们的代码是这样的:

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

const server = http.createServer(async (request, response) => {

console.log(`Starting processing request: ${request} with url: ${request.url}`);

const header = request.headers || {};
const method = request.method;
const contentType = header['Content-Type'];
const length = header['Content-Length'] || 0;

const start = Date.now();
const result = await doTask();
const time = Date.now() - start;
console.log(`Task is finished within ${time}ms.`);


const body = `Hello World ${result}.`;
response.end(body);
console.log(`Finishing processing with response: ${response}`);
});

server.listen(3000);

async function doTask() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 1000);
});
}

执行之后,结果是这样的:

1
2
3
4
// Terminal
Starting processing request: [object Object]
Task is finished within 1004ms.
Finishing processing with response: [object Object]

网页输出

1
Hello World 1.

很简单吧,可是思考一下这样几个问题?

  • 记录请求和返回结果,占用了大量的磁盘空间,我想把这个功能下掉,该怎么办呢?
  • 我需要启动多个Server,是不是每个Server实例都需要把上面代码Copy一份呢?

Koa就是为了解决以上问题而设计的,如果我们使用Koa,该怎么写代码呢?

使用Koa创建Server

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
'use strict';

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
const request = ctx.request;
console.log(`Starting processing request: ${request} with url: ${request.url}`);
await next();
const response = ctx.response;
console.log(`Finishing processing with response: ${response}`);
});

app.use(async (ctx, next) => {
const start = Date.now();
await next();
const time = Date.now() - start;
console.log(`Task is finished within ${time}ms.`);
});

app.use(async ctx => {
const result = await doTask();
ctx.body = `Hello World ${result}.`;
});

app.listen(3000);

async function doTask() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 1000);
});
}

回过来思考上述的代码,解答最初提到的两个问题:

  1. 需要下掉日志记录的功能,怎么处理呢?
    答:删掉以下代码
1
2
3
4
5
6
7
8
9
/*
app.use(async (ctx, next) => {
const request = ctx.request;
console.log(`Starting processing request: ${request} with url: ${request.url}`);
await next();
const response = ctx.response;
console.log(`Finishing processing with response: ${response}`);
});
*/
  1. 启动多个Server,每个Server都需要具备以上基本功能
    答: 只要启动一个Koa,并注册对应功能代码块(Middleware)即可
1
2
3
const app2 = new Koa();
app2.use(xxx);
app2.listen(3001);

为什么要使用Koa?

我们来分析一下使用http库或者使用Koa分别实现一个简单的WebServer,其区别在哪里?

学习过Koa的同学都了解,Koa源代码主要有4个js文件

  • application.js
  • context.js
  • request.js
  • response.js

这4个文件,分别对应了Koa的四大组件: Application,Context,Request,Response,其中Request、Response是对应node中http.IncomingMessagehttp.ServerResponse ;Context顾名思义,为上下文,通过Context将请求处理过程中需要用的各种资源汇总联系起来;Application则是Koa程序的主要入口。

需要注意的是,Koa通过 koa-compose 模块还实现了非常重要的中间件机制(即非常有明的洋葱头模型),后面会详细分析。

将Koa的各个模块映射到原生的http实现的WebServer上,整体上是以下的分布:

四大组件

Application

Application是Koa中最为核心的模块,即整个程序的入口。前面的Demo中,涉及Application代码如下:

1
2
3
4
const Koa = require('koa');
const app = new Koa();
app.use((ctx, next) => {});
app.listen(3000);

在上述代码中,我们实现了以下逻辑:

1
2
3
4
const Koa = require('koa'); // 导入koa模块,实际上导入了application.js
const app = new Koa(); // 创建了一个Koa实例
app.use((ctx, next) => {}); // 添加了一个中间件,用于添加日志,统计时间,响应请求
app.listen(3000); // 启动监听

1. 创建实例

通过new Koa()创建了一个Koa实例,框架中,构造函数中的逻辑并不多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = class Application extends Emitter {
constructor() {
super();

this.proxy = false;
this.middleware = [];
this.subdomainOffset = 2;
this.env = process.env.NODE_ENV || 'development';
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect;
}
}
}
  1. 首先需要明确的是,Application继承了Emitter,也就是可以通过Application的实例监听各种事件,Koa用的多了,我们经常看见各种app.on(event, () => {})等事件监听处理,其出处就在这里。
  2. 创建了一个middleware的数组,这里保存了我们注册的各种中间件
  3. 创建属性env,来表示当前的运行环境,默认是 development
  4. 创建了几个实例,context, request, response

2. app.use(middleware)

通过app.use(middleware)可以实现Koa中间件的注册,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn);
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}

代码中可以发现,这一块逻辑特别简单,一句话说 将中间件函数放入middleware中

  1. 判断fn的属性,保证其为函数
  2. 如果是GeneratorFunction,那么保留对其兼容,并通过 koa-convert 包,依赖 co 将函数包装为返回Promise的普通函数
  3. 至此,fn应该为普通的函数,或者是async异步函数,均压栈保存即可。

3. app.listen(3000)

通过listen()方法的调用,就真正的把WebServer启动起来,其中的逻辑具体来看

1
2
3
4
5
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}

乍一看,特别简单,通过 http.createServer启动了一个WebServer,将 this.callback()作为入参传入Server,监听各个请求并处理。
所以,所有的业务逻辑,都在 callback

1
2
3
4
5
6
7
8
9
10
11
12
callback() {
const fn = compose(this.middleware); // 将middleware组装为一个function

if (!this.listenerCount('error')) this.on('error', this.onerror); // 如果当前app没有绑定error事件,则绑定默认的监听

const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
}; // 构造请求处理Handler

return handleRequest;
}

callback中有四处玄机

  • 将middleware中间件通过compose组装为一个function
  • 如果app没有配置error监听,那么第一次会手动绑定一个error事件
  • 收到请求的时候,创建新的ctx实例
  • 将请求交给this.handleRequest处理,并将生成的中间件function和上下文context传入

koa-compose 洋葱头模型

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
module.exports = compose

function compose (middleware) {
// 首先是两个断言,保证传入的middleware参数是一个只保存了function的数组
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) {
// last called middleware #
let index = -1
// 返回的function为dispatch(0),即指向第一个压栈的中间件
return dispatch(0)
function dispatch (i) {
// 保护,避免重复调用导致堆栈溢出
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
// 更新索引,指向当前调用的中间件
index = i
// 取出当前要调用的中间件
let fn = middleware[i]
// 如果middleware数组遍历完了,那么就指向外部传入的next方法
if (i === middleware.length) fn = next
// 如果fn不存在,也就是所有的中间件、包括传入的next方法都执行完成,Promise.resolve返回成功
// 前面的中间件,await next()方法就已经执行完成,可以继续处理后面的业务逻辑,也就是洋葱头的后面部分。
if (!fn) return Promise.resolve()
try {
// 当前的中间件可以处理,同时将指向下个中间件的dispatch函数作为next参数传入
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
// 如果出错了,则抛出异常
return Promise.reject(err)
}
}
}
}

代码大体的脉络如下:

  • compose函数为入口,首先做了两个假设:
    • 假设传入的middleware参数是个数组
    • 假设传入的middleware中的每个元素都是function
  • 递归实现: 中间件的依次调用+洋葱头模型
    • 从第一个中间件开始调用dispatch(0), index指向当前调用的第index个中间件
    • 如果index已经越界,说明前面的中间件已经处理完成,待处理function指向传入的next方法
    • 如果已经没有待处理的方法了,直接Promise.resolve()确认结果
    • 如果待处理function存在,则调用,并将disptch(index + 1)指向下一个中间件的处理函数作为next参数传入
    • Koa中间件要求显示调用await方法,因此对于每个中间件逻辑都是如下:
      • 处理当前中间件的一部分逻辑,即『前置逻辑』
      • 调用await next()执行下一个中间件
      • 下一个中间件处理完成之后,继续处理当前中间件剩下的部分,即『后置逻辑』。
      • 如此递归处理,实现洋葱头模型。

官方洋葱头模型介绍

官方中间件调用顺序介绍

洋葱头的中间件模型,是Koa最引以为豪的一个设计,基于大量的中间件实现,可以实现丰富的功能。在前面的代码分析中,我们已经详尽的了解了koa-compose的逻辑,如果你还不理解,还有个思路:
大家知道,对于Java同学来说,Spring的AOP模型是再也熟悉不过的了,而这里的中间件设计,和切面再相似不过,其中有一种切面是 Around advice,典型的代码是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.ProceedingJoinPoint;

@Aspect
public class AroundExample {

@Around("com.xyz.myapp.SystemArchitecture.businessService()")
public Object doBasicProfiling(ProceedingJoinPoint pjp) throws Throwable {
// start stopwatch
Object retVal = pjp.proceed();
// stop stopwatch
return retVal;
}

}

是不是基本上一样?当然实际上AOP的功能要强大的多

总结

  • 返回一个function,指向第一个中间件
  • 通过递归调用和中间件的 await next()实现洋葱头模型

创建Context, this.createContext

createContext(),顾名思义,其作用就是为这次建立的请求创建上下文,那么上下文Context是什么呢?

A Koa Context encapsulates node’s request and response objects into a single object which provides many helpful methods for writing web applications and APIs. These operations are used so frequently in HTTP server development that they are added at this level instead of a higher level framework, which would force middleware to re-implement this common functionality.

  • Context是用来把请求Request和响应Response封装在一起的一个对象
  • Context提供了大量常见、通用、频繁调用的基础功能,无需再次开发
  • Context是中间件的第一个参数,观察请求的整个链路。
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
createContext(req, res) {
// 基于Koa内置模板,生成context,request, response对象
// 需要注意的是,每个请求,都有一个新的context对象
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
// 将app, ctx, request, 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;

// 将请求的url配置为属性originaUrl,并挂载在context和request中
context.originalUrl = request.originalUrl = req.url;
// 生成cookie对象
context.cookies = new Cookies(req, res, {
keys: this.keys,
secure: request.secure
});
request.ip = request.ips[0] || req.socket.remoteAddress || '';
context.state = {};
return context;
}

通过上面的代码,我们进一步了解到了:

  • 每一次请求,都会创建新的context、request、response对象
  • context、request、response之间相互持有引用,可以互相访问得到
  • context中保存了一些常见的方法和请求参数可以使用

处理请求 this.handleRequest

handleRequest方法是真正处理请求的函数,其具体的业务逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
// 请求最终会转发至respond方法
const handleResponse = () => respond(ctx);
// 这是一个开源库,会自动添加一个请求完成或者失败的listener
onFinished(res, onerror);
// 先调用中间件,然后请求交给handleResponse, 如果出错则通知onerror处理
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

关键的就这一句fnMiddleware(ctx).then(handleResponse).catch(onerror),其中fnMiddleware为前面通过koa-compose组合的中间件。也就是说,所有的请求,都会一一流经所有的中间件,然后交给handleResponse做收尾处理,那么handleResponserespond(ctx)做了什么操作呢?老规矩,代码粘过来,简单分析下:

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
function respond(ctx) {
// 校验下链路是否已经断开,是否不需要处理
...

// 如果请求为空,那么清空数据,并返回
if (statuses.empty[code]) {
// strip headers
ctx.body = null;
return res.end();
}

// 如果请求的方法为HEAD,那么将长度修正为内容真正的长度
// 同时HEAD请求不会把真正的请求数据返回
if ('HEAD' == ctx.method) {
if (!res.headersSent && isJSON(body)) {
ctx.length = Buffer.byteLength(JSON.stringify(body));
}
return res.end();
}

// status body 如果body为空,那么就把状态码作为body返回
if (null == body) {
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);
// 如果响应是Stream流,那么直接Pipe到res
if (body instanceof Stream) return body.pipe(res);

// body: json 默认情况下,则认为是JSON,直接按照JSON格式输出
body = JSON.stringify(body);
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body);
}
res.end(body);
}

上面的代码主要做了以下几件事情:

  • 响应之前,保证链路是通的,链路没有问题
  • 根据响应状态码,如果状态码为204,修正body,body无数据
  • 根据请求Method,如果是HEAD请求,那么只返回内容长度,body不返回
  • 根据body的格式,响应结果
    • body为空,那么就把状态码同步到body中
    • body为二进制,直接返回
    • body为字符串,直接返回
    • body为Stream流,pipe
    • body为JSON(默认),将结果封装为JSON格式

至此,Application的关键业务逻辑已经梳理完成了,我们总结一下:

总结

Application主要做了以下事情:

  • 创建Application实例,保存了应用的基本信息,包括中间件的配置
  • 创建了一个Http WebServer,监听请求并响应
    • 请求建立的时候,首先将用户配置的中间件compose为一个新的方法,实现洋葱头的中间件模型,使用中间件处理请求
    • 根据处理的结果,请求的参数和方法,响应的数据格式,包装为用户需要的类型

题外话1

Koa在处理每一个请求,都会处理对应的ctx, req, res。如下面的代码:

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

有意思的一个地方时, 新对象实例的创建,是基于Koa在启动默认创建的已有对象,而不是内置的Context.prototype,这样做什么深意呢?
实际上,无论是下面两种写法,都可以实现创建一个context实例:

1
2
const context = Object.create(Context.prototype); // 方法A
const context = Object.create(this.context); // 方法B

方法B的好处在哪里?

  • Koa的使用者可以根据需要动态的在this.context添加属性和方法,而每次请求创建的context实例会自动继承这些属性和方法
  • 如果直接基于Context.prototype创建实例

app.context是所有请求的context的prototype,也就是说,所有请求的上下文,都基于app.context实现。其好处就是,可以灵活方便的动态在app.context上下文中添加属性,所有请求均可使用

Request

顾名思义,Request即请求类,Koa会将所有的请求都给包装成Request,那么有疑问了,Koa的Requesthttp.IncomingMessage有什么区别呢?

A Koa Request object is an abstraction on top of node’s vanilla request object, providing additional functionality that is useful for every day HTTP server development.

Koa的Request其是就是http.IncomingMessage的一个包装类,主要有以下两个特点:

  • http.IncomingMessage中常用的属性和方法直接代理
  • 提供了许多额外的便利方法

Request中常用的方法属性列一下:

  • header/headers: 代理 req.headers
  • url: 代理 req.url
  • origin: ${this.protocol}://${this.host}
  • href: 请求的完整路径,如 http://example.com/foo
  • method: 代理 req.method
  • path: 根据路径返回path,比如http://example.com/foo的path为 foo
  • query: 查询字符串 query-string
  • search: 问号+query-string ==> ?${query-string}
  • host: 不是很理解,先放在这里,代码上其实解析的是header中的Host字段,没有用过,后面查下资料。 Parse the “Host” header field host and support X-Forwarded-Host when a proxy is enabled.
  • hostname: 基本上同上
  • URL: 获取WHATWG规范的URL,参考: https://url.spec.whatwg.org/#example-url-parsing
  • fresh: 判断用户端Cache是否有效
  • stale: !fresh
  • idempotent: 判断支持幂等的请求,也就是说,请求可以多次发生而数据保持一致,Koa中主要根据Method即请求的方法来判断: http://restcookbook.com/HTTP%20Methods/idempotency/
  • socket: 原始的socket
  • charset: Header中Content-Type指定的charset
  • length: Header中Content-Length指定的
  • protocol: 请求为https还是http
  • secure: 是否为http
  • accept: 工具类,判断指定的type是否接收
  • get: 重要此方法为特别常用的方法,可以直接用来获取req.header中指定字段内容

总体来说,Request主要封装了响应相关的常见方法,最典型的是请求参数的处理和Header的处理,方法很多,但方法都很简单,大概看下即可。

Response

与Request类似,Response也是Koa的封装类,包装了http.ServerResponse, 具体来看看有用的方法:

  • socket: 指向这次请求的socket
  • header: 指向res.header,这里针对node老版本做了兼容
  • get/set: 针对header提供的遍历操作方法,Response.get/set直接操作对应的header
    • get: 直接读取res.header中对应字段的内容
    • set: 直接将<Key, Value>字段写入header中
  • status: get/set,如果get方法,那么读取res当前的status值,set方法则是变更当前res的status
  • message: 同status
  • body: get方法直接读取,没什么好说的,set方法有很多逻辑,需要说明一下:
    • 如果body设置为空,那么则清空header中对应的 Content-Type Content-Length Transfer-Encoding字段
    • 如果body不为空,则没有stausCode的情况下,修正为默认的200
    • 如果body的value为字符串
      • 字符串为 </ 开头,则认为是Html语言,设置type=html
      • 反之,认为是text,设置type=text
    • 如果body为二进制,设置type=bin
    • 如果body为流,且存在pipe方法,那么type=bin,同时去除Content-Length
      • 同时配置ErrorHandler和FinishHandler,用于异常处理
    • 默认情况下,body为JSON格式(此时this.body = {} 对象),type=json
  • length: 即长度,同样根据type返回不同的值
  • headerSent: res.headersSent
  • redirect(url, alt): 重定向逻辑
    • 如果指定url为back,那么则读取Header中’Referrer’的值,或者使用alt
    • 配置statusCode = 302
    • 将重定向文本写入body
  • attachment: 为响应添加附件
  • type: 读取或者设置返回的数据类型,比如 json、bin、html等
  • get/set: 同Request,其实指向了res.header,并提供直接的get和set
  • append: 这个比较有意思,Header中某些字段其实本质上数组,或者类似与数组的概念,有多个数据,append则是不改变之前数据的基础之上,将新的数据追加上去 this.append(‘Set-Cookie’, ‘foo=bar; Path=/; HttpOnly’);
  • remove: 移出某个Header
  • writable: 数据是否仍然可以写入
    • res.finished: 请求已经处理完成了,则无法再写入
    • socket.writeable
  • flushHeaders: 完成header处理,res.flushHeaders

总体来说,Response主要封装了响应相关的常见方法,最典型的是body的处理和Header的处理,方法很多,但方法都很简单,大概看下即可。

Context

在前面,我们学习了Koa的三个核心概念,ApplicationRequestResponse,现在来谈谈Context
Context即上下文,一般用来贯穿整个请求。实际上在Koa中,Context也是这样使用的。

A Koa Context encapsulates node’s request and response objects into a single object which provides many helpful methods for writing web applications and APIs. These operations are used so frequently in HTTP server development that they are added at this level instead of a higher level framework, which would force middleware to re-implement this common functionality.

如官网所说,Context一共做了以下的几件事情:

  • 将request和response对象集合在一起
  • 其中提供了很多处理网络请求方便的方法

将Request和Response对象集合在一起
这一个特点,是通过以下两个方面来实现的:

  1. Application#createContext(req, res)
    在Application的代码中,每次请求建立的时候,会自动调用createContext来创建上下文,如前面分析,Koa会在这个地方将req、res、ctx相互绑定在一起,再回顾下关键代码
1
2
3
4
5
6
7
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;
  1. Context中大量的方法代理至对应的req和res中
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
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable');

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

提供了很多处理网络请求方便的方法
Context中提供了大量的方便方法,可以在处理请求的时候

  • assert: httpAssert
  • throw: 抛出异常,实际上调用http-errors的createError,会返回对应的错误码给客户端
  • onerror: 默认的error监听