动手实现一个 Koa 框架(万字实战好文)
用Node.js
写一个web服务器
,我前面已经写过两篇文章了:
•第一篇是不使用任何框架也能搭建一个web服务器
,主要是熟悉Node.js
原生API的使用:使用Node.js原生API写一个web服务器[1]•第二篇文章是看了Express
的基本用法,更主要的是看了下他的源码:手写Express.js源码[2]
Express
的源码还是比较复杂的,自带了路由处理和静态资源支持等等功能,功能比较全面。与之相比,本文要讲的Koa
就简洁多了,Koa
虽然是Express
的原班人马写的,但是设计思路却不一样。Express
更多是偏向All in one
的思想,各种功能都集成在一起,而Koa
本身的库只有一个中间件内核,其他像路由处理和静态资源这些功能都没有,全部需要引入第三方中间件库才能实现。下面这张图可以直观的看到Express
和koa
在功能上的区别,此图来自于官方文档[3]:
基于Koa
的这种架构,我计划会分几篇文章来写,全部都是源码解析:
•Koa
的核心架构会写一篇文章,也就是本文。•对于一个web服务器
来说,路由是必不可少的,所以@koa/router
会写一篇文章。•另外可能会写一些常用中间件,静态文件支持或者bodyparser
等等,具体还没定,可能会有一篇或多篇文章。
本文可运行迷你版Koa代码已经上传GitHub,拿下来,一边玩代码一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore
简单示例
我写源码解析,一般都遵循一个简单的套路:先引入库,写一个简单的例子,然后自己手写源码来替代这个库,并让我们的例子顺利运行。本文也是遵循这个套路,由于Koa
的核心库只有中间件,所以我们写出的例子也比较简单,也只有中间件。
Hello World
第一个例子是Hello World
,随便请求一个路径都返回Hello World
。
const Koa = require("koa");
const app = new Koa();
app.use((ctx) => {
ctx.body = "Hello World";
});
const port = 3001;
app.listen(port, () => {
console.log(`Server is running on http://127.0.0.1:${port}/`);
});
logger
然后再来一个logger
吧,就是记录下处理当前请求花了多长时间:
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
注意这个中间件应该放到Hello World
的前面。
从上面两个例子的代码来看,Koa
跟Express
有几个明显的区别:
•ctx
替代了req
和res
•可以使用JS的新API了,比如async
和await
手写源码
手写源码前我们看看用到了哪些API,这些就是我们手写的目标:
•new Koa():首先肯定是Koa
这个类了,因为他使用new
进行实例化,所以我们认为他是一个类。•app.use:app是Koa
的一个实例,app.use
看起来是一个添加中间件的实例方法。•app.listen:启动服务器的实例方法•ctx:这个是Koa
的上下文,看起来替代了以前的req
和res
•async和await:支持新的语法,而且能使用await next()
,说明next()
返回的很可能是一个promise
。
本文的手写源码全部参照官方源码写成,文件名和函数名尽量保持一致,写到具体的方法时我也会贴上官方源码地址。Koa
这个库代码并不多,主要都在这个文件夹里面:https://github.com/koajs/koa/tree/master/lib,下面我们开始吧。
Koa类
从Koa
项目的package.json
里面的main
这行代码可以看出,整个应用的入口是lib/application.js
这个文件:
"main": "lib/application.js",
lib/application.js
这个文件就是我们经常用的Koa
类,虽然我们经常叫他Koa
类,但是在源码里面这个类叫做Application
。我们先来写一下这个类的壳吧:
// application.js
const Emitter = require("events");
// module.exports 直接导出Application类
module.exports = class Application extends Emitter {
// 构造函数先运行下父类的构造函数
// 再进行一些初始化工作
constructor() {
super();
// middleware实例属性初始化为一个空数组,用来存储后续可能的中间件
this.middleware = [];
}
};
这段代码我们可以看出,Koa
直接使用class
关键字来申明类了,看过我之前Express
源码解析的朋友可能还有印象,Express
源码里面还是使用的老的prototype
来实现面向对象的。所以Koa
项目介绍里面的Expressive middleware for node.js using ES2017 async functions
并不是一句虚言,它不仅支持ES2017
新的API,而且在自己的源码里面里面也是用的新API。我想这也是Koa
要求运行环境必须是node v7.6.0 or higher
的原因吧。所以到这里我们其实已经可以看出Koa
和Express
的一个重大区别了,那就是:Express
使用老的API,兼容性更强,可以在老的Node.js
版本上运行;Koa
因为使用了新API,只能在v7.6.0
或者更高版本上运行了。
这段代码还有个点需要注意,那就是Application
继承自Node.js
原生的EventEmitter
类,这个类其实就是一个发布订阅模式,可以订阅和发布消息,我在另一篇文章里面详细讲过他的源码[4]。所以他有些方法如果在application.js
里面找不到,那可能就是继承自EventEmitter
,比如下图这行代码:
这里有this.on
这个方法,看起来他应该是Application
的一个实例方法,但是这个文件里面没有,其实他就是继承自EventEmitter
,是用来给error
这个事件添加回调函数的。这行代码if
里面的this.listenerCount
也是EventEmitter
的一个实例方法。
Application
类完全是JS面向对象的运用,如果你对JS面向对象还不是很熟悉,可以先看看这篇文章:https://juejin.im/post/6844904069887164423。
app.use
从我们前面的使用示例可以看出app.use
的作用就是添加一个中间件,我们在构造函数里面也初始化了一个变量middleware
,用来存储中间件,所以app.use
的代码就很简单了,将接收到的中间件塞到这个数组就行:
use(fn) {
// 中间件必须是一个函数,不然就报错
if (typeof fn !== "function")
throw new TypeError("middleware must be a function!");
// 处理逻辑很简单,将接收到的中间件塞入到middleware数组就行
this.middleware.push(fn);
return this;
}
注意app.use
方法最后返回了this
,这个有点意思,为什么要返回this
呢?这个其实我之前在其他文章讲过的[5]:类的实例方法返回this
可以实现链式调用。比如这里的app.use
就可以连续点点点了,像这样:
app.use(middlewaer1).use(middlewaer2).use(middlewaer3)
为什么会有这种效果呢?因为这里的this
其实就是当前实例,也就是app
,所以app.use()
的返回值就是app
,app
上有个实例方法use
,所以可以继续点app.use().use()
。
app.use
的官方源码看这里: https://github.com/koajs/koa/blob/master/lib/application.js#L122
app.listen
在前面的示例中,app.listen
的作用是用来启动服务器,看过前面用原生API实现web服务器
的朋友都知道,要启动服务器需要调用原生的http.createServer
,所以这个方法就是用来调用http.createServer
的。
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
这个方法本身其实没有太多可说的,只是调用http
模块启动服务而已,主要的逻辑都在this.callback()
里面了。
app.listen
的官方源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L79
app.callback
this.callback()
是传给http.createServer
的回调函数,也是一个实例函数,这个函数必须符合http.createServer
的参数形式,也就是
http.createServer(function(req, res){})
所以this.callback()
的返回值必须是一个函数,而且是这种形式function(req, res){}
。
除了形式必须符合外,this.callback()
具体要干什么呢?他是http
模块的回调函数,所以他必须处理所有的网络请求,所有处理逻辑都必须在这个方法里面。但是Koa
的处理逻辑是以中间件的形式存在的,对于一个请求来说,他必须一个一个的穿过所有的中间件,具体穿过的逻辑,你当然可以遍历middleware
这个数组,将里面的方法一个一个拿出来处理,当然也可以用业界更常用的方法:compose
。
compose
一般来说就是将一系列方法合并成一个方法来方便调用,具体实现的形式并不是固定的,有面试中常见的用reduce
实现的compose
[6],也有像Koa
这样根据自己需求单独实现的compose
。Koa
的compose
也单独封装了一个库koa-compose
,这个库源码也是我们必须要看的,我们一步一步来,先把this.callback
写出来吧。
callback() {
// compose来自koa-compose库,就是将中间件合并成一个函数
// 我们需要自己实现
const fn = compose(this.middleware);
// callback返回值必须符合http.createServer参数形式
// 即 (req, res) => {}
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
这个方法先用koa-compose
将中间件都合成了一个函数fn
,然后在http.createServer
的回调里面使用req
和res
创建了一个Koa
常用的上下文ctx
,然后再调用this.handleRequest
来真正处理网络请求。注意这里的this.handleRequest
是个实例方法,和当前方法里面的局部变量handleRequest
并不是一个东西。这几个方法我们一个一个来看下。
this.callback
对应的官方源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L143
koa-compose
koa-compose
虽然被作为了一个单独的库,但是他的作用却很关键,所以我们也来看看他的源码吧。koa-compose
的作用是将一个中间件组成的数组合并成一个方法以便外部调用。我们先来回顾下一个Koa
中间件的结构:
function middleware(ctx, next) {}
这个数组就是有很多这样的中间件:
[
function middleware1(ctx, next) {},
function middleware2(ctx, next) {}
]
Koa
的合并思路并不复杂,就是让compose
再返回一个函数,返回的这个函数会开始这个数组的遍历工作:
function compose(middleware) {
// 参数检查,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!");
}
// 返回一个方法,这个方法就是compose的结果
// 外部可以通过调用这个方法来开起中间件数组的遍历
// 参数形式和普通中间件一样,都是context和next
return function (context, next) {
return dispatch(0); // 开始中间件执行,从数组第一个开始
// 执行中间件的方法
function dispatch(i) {
let fn = middleware[i]; // 取出需要执行的中间件
// 如果i等于数组长度,说明数组已经执行完了
if (i === middleware.length) {
fn = next; // 这里让fn等于外部传进来的next,其实是进行收尾工作,比如返回404
}
// 如果外部没有传收尾的next,直接就resolve
if (!fn) {
return Promise.resolve();
}
// 执行中间件,注意传给中间件接收的参数应该是context和next
// 传给中间件的next是dispatch.bind(null, i + 1)
// 所以中间件里面调用next的时候其实调用的是dispatch(i + 1),也就是执行下一个中间件
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}
上面代码主要的逻辑就是这行:
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
这里的fn
就是我们自己写的中间件,比如文章开始那个logger
,我们稍微改下看得更清楚:
const logger = async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
};
app.use(logger);
那我们compose
里面执行的其实是:
logger(context, dispatch.bind(null, i + 1));
也就是说logger
接收到的next
其实是dispatch.bind(null, i + 1)
,你调用next()
的时候,其实调用的是dispatch(i + 1)
,这样就达到了执行数组下一个中间件的效果。
另外由于中间件在返回前还包裹了一层Promise.resolve
,所以我们所有自己写的中间件,无论你是否用了Promise
,next
调用后返回的都是一个Promise
,所以你可以使用await next()
。
koa-compose
的源码看这里:https://github.com/koajs/compose/blob/master/index.js
app.createContext
上面用到的this.createContext
也是一个实例方法。这个方法根据http.createServer
传入的req
和res
来构建ctx
这个上下文,官方源码长这样:
这段代码里面context
,ctx
,response
,res
,request
,req
,app
这几个变量相互赋值,头都看晕了。其实完全没必要陷入这堆面条里面去,我们只需要将他的思路和骨架拎清楚就行,那怎么来拎呢?
1.首先搞清楚他这么赋值的目的,他的目的其实很简单,就是为了使用方便。通过一个变量可以很方便的拿到其他变量,比如我现在只有request
,但是我想要的是req
,怎么办呢?通过这种赋值后,直接用request.req
就行。其他的类似,这种面条式的赋值我很难说好还是不好,但是使用时确实很方便,缺点就是看源码时容易陷进去。2.那request
和req
有啥区别?这两个变量长得这么像,到底是干啥的?这就要说到Koa
对于原生req
的扩展,我们知道http.createServer
的回调里面会传入req
作为请求对象的描述,里面可以拿到请求的header
啊,method
啊这些变量。但是Koa
觉得这个req
提供的API不好用,所以他在这个基础上扩展了一些API,其实就是一些语法糖,扩展后的req
就变成了request
。之所以扩展后还保留的原始的req
,应该也是想为用户提供更多选择吧。所以这两个变量的区别就是request
是Koa
包装过的req
,req
是原生的请求对象。response
和res
也是类似的。3.既然request
和response
都只是包装过的语法糖,那其实Koa
没有这两个变量也能跑起来。所以我们拎骨架的时候完全可以将这两个变量踢出去,这下骨架就清晰了。
那我们踢出response
和request
后再来写下createContext
这个方法:
// 创建上下文ctx对象的函数
createContext(req, res) {
const context = Object.create(this.context);
context.app = this;
context.req = req;
context.res = res;
return context;
}
这下整个世界感觉都清爽了,context
上的东西也一目了然了。但是我们的context
最初是来自this.context
的,这个变量还必须看下。
app.createContext
对应的官方源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L177
context.js
上面的this.context
其实就是来自context.js
,所以我们先在Application
构造函数里面添加这个变量:
// application.js
const context = require("./context");
// 构造函数里面
constructor() {
// 省略其他代码
this.context = context;
}
然后再来看看context.js
里面有啥,context.js
的结构大概是这个样子:
const delegate = require("delegates");
module.exports = {
inspect() {},
toJSON() {},
throw() {},
onerror() {},
};
const proto = module.exports;
delegate(proto, "response")
.method("set")
.method("append")
.access("message")
.access("body");
delegate(proto, "request")
.method("acceptsLanguages")
.method("accepts")
.access("querystring")
.access("socket");
这段代码里面context
导出的是一个对象proto
,这个对象本身有一些方法,inspect
,toJSON
之类的。然后还有一堆delegate().method()
,delegate().access()
之类的。嗯,这个是干啥的呢?要知道这个的作用,我们需要去看delegates
这个库:https://github.com/tj/node-delegates,这个库也是tj
大神写的。一般使用是这样的:
delegate(proto, target).method("set");
这行代码的作用是,当你调用proto.set()
方法时,其实是转发给了proto[target]
,实际调用的是proto[target].set()
。所以就是proto
代理了对target
的访问。
那用在我们context.js
里面是啥意思呢?比如这行代码:
delegate(proto, "response")
.method("set");
这行代码的作用是,当你调用proto.set()
时,实际去调用proto.response.set()
,将proto
换成ctx
就是:当你调用ctx.set()
时,实际调用的是ctx.response.set()
。这么做的目的其实也是为了使用方便,可以少写一个response
。而且ctx
不仅仅代理response
,还代理了request
,所以你还可以通过ctx.accepts()
这样来调用到ctx.request.accepts()
。一个ctx
就囊括了response
和request
,所以这里的context
也是一个语法糖。因为我们前面已经踢了response
和request
这两个语法糖,context
作为包装了这两个语法糖的语法糖,我们也一起踢掉吧。在Application
的构造函数里面直接将this.context
赋值为空对象:
// application.js
constructor() {
// 省略其他代码
this.context = {};
}
现在语法糖都踢掉了,整个Koa
的结构就更清晰了,ctx
上面也只有几个必须的变量:
ctx = {
app,
req,
res
}
context.js
对应的源码看这里:https://github.com/koajs/koa/blob/master/lib/context.js
app.handleRequest
现在我们ctx
和fn
都构造好了,那我们处理请求其实就是调用fn
,ctx
是作为参数传给他的,所以app.handleRequest
代码就可以写出来了:
// 处理具体请求
handleRequest(ctx, fnMiddleware) {
const handleResponse = () => respond(ctx);
// 调用中间件处理
// 所有处理完后就调用handleResponse返回请求
return fnMiddleware(ctx)
.then(handleResponse)
.catch((err) => {
console.log("Somethis is wrong: ", err);
});
}
我们看到compose
库返回的fn
虽然支持第二个参数用来收尾,但是Koa
并没有用他,如果不传的话,所有中间件执行完返回的就是一个空的promise
,所以可以用then
接着他后面处理。后面要进行的处理就只有一个了,就是将处理结果返回给请求者的,这也就是respond
需要做的。
app.handleRequest
对应的源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L162
respond
respond
是一个辅助方法,并不在Application
类里面,他要做的就是将网络请求返回:
function respond(ctx) {
const res = ctx.res; // 取出res对象
const body = ctx.body; // 取出body
return res.end(body); // 用res返回body
}
大功告成
现在我们可以用自己写的Koa
替换官方的Koa
来运行我们开头的例子了,不过logger
这个中间件运行的时候会有点问题,因为他下面这行代码用到了语法糖:
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
这里的ctx.method
和ctx.url
在我们构建的ctx
上并不存在,不过没关系,他不就是个req
的语法糖嘛,我们从ctx.req
上拿就行,所以上面这行代码改为:
console.log(`${ctx.req.method} ${ctx.req.url} - ${ms}ms`);
总结
通过一层一层的抽丝剥茧,我们成功拎出了Koa
的代码骨架,自己写了一个迷你版的Koa
。
这个迷你版代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore
最后我们再来总结下本文的要点吧:
1.Koa
是Express
原班人马写的一个新框架。2.Koa
使用了JS的新API,比如async
和await
。3.Koa
的架构和Express
有很大区别。4.Express
的思路是大而全,内置了很多功能,比如路由,静态资源等,而且Express
的中间件也是使用路由同样的机制实现的,整个代码更复杂。Express
源码可以看我之前这篇文章:手写Express.js源码[7]5.Koa
的思路看起来更清晰,Koa
本身的库只是一个内核,只有中间件功能,来的请求会依次经过每一个中间件,然后再出来返回给请求者,这就是大家经常听说的“洋葱模型”。6.想要Koa
支持其他功能,必须手动添加中间件。作为一个web服务器
,路由可以算是基本功能了,所以下一遍文章我们会来看看Koa
官方的路由库@koa/router
,敬请关注。
参考资料
Koa官方文档:https://github.com/koajs/koa
Koa源码地址:https://github.com/koajs/koa/tree/master/lib
文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。
作者博文GitHub项目地址:https://github.com/dennis-jiang/Front-End-Knowledges
作者掘金文章汇总:https://juejin.im/post/5e3ffc85518825494e2772fd
我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~
References
[1]
使用Node.js原生API写一个web服务器: https://juejin.im/post/6887797543212843016[2]
手写Express.js源码: https://juejin.im/post/6890358903960240142[3]
此图来自于官方文档: https://github.com/koajs/koa/blob/master/docs/koa-vs-express.md[4]
我在另一篇文章里面详细讲过他的源码: https://juejin.im/post/6844904101331877895[5]
这个其实我之前在其他文章讲过的: https://juejin.im/post/6844904084571439118#heading-7[6]
面试中常见的用reduce
实现的compose
: https://juejin.im/post/6844904061821517832[7]
手写Express.js源码: https://juejin.im/post/6890358903960240142
·END·
汇聚精彩的免费实战教程
喜欢本文,点个“在看”告诉我