最近看了 Koa 源码, 觉得Koa源码设计得特别巧妙而且也很简单,易读懂。基础的代码只有不到 2000 行。我们知道 Koa 是一个很轻量级的web框架, 里面除了middleware 和 ctx 之外就什么没有了。虽然很简单, 但是功能还是很强大的,它仅仅是靠中间件就是搭建完整的web应用。这篇文章主要记录一下学习的笔记, 供之后翻阅。
读完这篇文章将会了解到:
Koa官方 Koa 中文文档 。Koa的源码可直接从 github 上获取
使用Demo 我们在读源码之前, 我们先看一下如何使用Koa。 通过阅读官方,我们来写一个例子:
创建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 );
启动应用
在项目的根目录下执行:
查看结果
这时候我们 可以打开浏览器输入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) 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' ) const server = http.createServer(this .callback()) return server.listen(...args) }
listen 主要是封装了 node的 http 模块提供的createServer 方法方法来创建一个http服务对象和使用 listen方法来监听。
我们看 http.createServer([options][, requestListener]) 的函数签名不难猜出 this.callback返回的是options 或 requestListener; 那我们来看 callback 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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 }
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 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!' ) } return function (context, next ) { 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) } } } }
dispatch函数,它将遍历整个middleware,然后将context和dispatch(i + 1)传给middleware中的方法。其中的dispatch(i + 1)就是middleware中的方法的next函数。
主要是实现:
context传给中间件
将middleware中的下一个中间件fn作为未来next的返回值。
剖析:
next()返回的是promise,需要使用await去等待promise的resolve值。promise的嵌套就像是洋葱模型的形状就是一层包裹着一层,直到await到最里面一层的promise的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 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。 这时如果多次调用next,i就会>=现在的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 ) => { const ctx = this .createContext(req, res); return this .handleRequest(ctx, fn); }
这函数里面 使用 this.createContext(req, res) 创建 context。这里传入了http模块提供的两个参数req、res,然后声明ctx为this.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); }
fnMiddleware是koa-compose包装后的函数, 函数签名是(context, next) => Promise, 内部会依次调用每个中间件。
onFinished 用于在请求close,finish,error时执行传入的错误回。
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 function respond (ctx ) { if (false === ctx.respond) return ; if (!ctx.writable) return ; const res = ctx.res; let body = ctx.body; const code = ctx.status; if (statuses.empty[code]) { ctx.body = null ; return res.end(); } if ('HEAD' == ctx.method) { if (!res.headersSent && isJSON(body)) { ctx.length = Buffer.byteLength(JSON .stringify(body)); } return res.end(); } 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); } if (Buffer.isBuffer(body)) return res.end(body); if ('string' == typeof body) return res.end(body); if (body instanceof Stream) return body.pipe(res); body = JSON .stringify(body); if (!res.headersSent) { ctx.length = Buffer.byteLength(body); } res.end(body); }
它做的是ctx返回不同情况的处理 ,如method为head时加上content-length字段、body为空时去除content-length等字段,返回相应状态码、body为Stream时使用pipe等