最近看了 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
等