本文围绕koa服务从启动,到处理请求再到回复响应这个过程对源码进行简单的解析
在koa中ctx是贯穿整个请求过程的,它是这次请求原信息的承载体,可以从ctx上获取到request、response、cookie等,方便我们进行后续的计算处理。 ctx在实现上原本就是一个空对象,在koa服务起来时,往上挂载了很多对象和方法。当然开发者也可以自定义挂载的方法。 在context.js
文件中对ctx初始化了一些内置的对象和属性,包括错误处理,设置cookie。
get cookies() { if (!this[COOKIES]) { this[COOKIES] = new Cookies(this.req, this.res, { keys: this.app.keys, secure: this.request.secure }); } return this[COOKIES]; }, set cookies(_cookies) { this[COOKIES] = _cookies; }复制代码
相对于这种写法,还有另外一种较为优雅的挂载方法。
// ./context.jsdelegate(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,也就是最初的ctx对象,属性提供方就是第二个参数"response"。 method是代理方法,getter代理get,access代理set和get. 看delegate如何实现:
function Delegator(proto, target) { if (!(this instanceof Delegator)) return new Delegator(proto, target); this.proto = proto; this.target = target; this.methods = []; this.getters = []; this.setters = []; this.fluents = [];}Delegator.prototype.method = function(name){ var proto = this.proto; var target = this.target; this.methods.push(name); proto[name] = function(){ return this[target][name].apply(this[target], arguments); }; return this;};复制代码
delegate中的method的实现是在调用原属性上指定方法时,转而调用提供方的方法。这里可以发现提供方也被收在了this上,这里的不直接传入一个对象而是将该对象赋值在原对象上的原因,我想应该是存放一个副本在原对象上,这样可以通过原对象直接访问到提供属性的对象。
./context.js
中使用delegates为ctx赋值的过程并不完整,因为这里的属性提供方虽然是request和response, 但是是从./application.js
createContext方法中传入,这样delegates才算完成了工作
到这里我们就可以看下平时用koa时常走的流程。
const Koa = require('koa');const app = new Koa();// responseapp.use(ctx => { ctx.body = 'Hello Koa';});app.listen(3000);复制代码
基本上就是分为三步,实例化Koa,注册中间件再监听端口, 这里正常能让koa服务或者说一个http服务起的来的操作其实是在app.listen(...args)里,是不是和想象中的有点差距, 看下源码实现。
// ./application.js ... listen(...args) { debug('listen'); const server = http.createServer(this.callback()); return server.listen(...args); } ...复制代码
在listen方法里使用了http模块的createServer方法来启动http服务,这里相当于是声明了一个http.Server实例,该实例也继承于EventEmitter,是一个事件类型的服务器,并监听了该实例的request事件,意为当客户端有请求发过来的时候,这个事件将会触发,等价于如下代码
var http = require("http");var server = new http.Server();server.on("request", function(req, res){ // handle request});server.listen(3000);复制代码
这个事件有两个参数req和res,也就是这次事件的请求和响应信息。有点扯远了,回到koa源码, 处理req和res参数的任务就交给了this.callback()的返回值来做,继续看callback里做了什么
// 去除了一些不影响主流程的处理代码 callback() { const fn = compose(this.middleware); const handleRequest = (req, res) => { const ctx = this.createContext(req, res); return this.handleRequest(ctx, fn); }; return handleRequest; } handleRequest(ctx, fnMiddleware) { const handleResponse = () => respond(ctx); return fnMiddleware(ctx).then(handleResponse).catch(onerror); }复制代码
callback返回一个函数由他来处理req和res,这个函数内部做了两件事, 这两件事分别在koa服务的初始化和响应时期完成,上述代码中compose中间件就是在服务初始化完成, 而当request事件触发时,该事件会由callback返回的handleRequest方法处理,这个方法保持了对fn,也就是初始化过后中间件的应用, handleRequest先会初始化贯穿整个事件的ctx对象,这个时候就可以将ctx以此走入到各个中间件中处理了。
可以说koa到这里主流程已经走一大半了,让我们理一理经过简单分析过的源码可以做到哪个地步(忽略错误处理)
- 响应http请求 √
- 生成ctx对象 √
- 运用中间件 √
- 返回请求 ×
如上我们已经可以做到将响应进入readly状态,但还没有返回响应的能力,后续会说道。在前三个过程中有两个点需要注意,ctx和middleware,下面我们依次深入学习下这两个关键点。
ctx是贯穿整个request事件的对象,它上面挂载了如req和res这种描述该次事件信息的属性,开发者也可以根据自己喜好,通过前置中间件挂载一些属性上去。 ctx在koa实例createContext方法上创建并被完善,再由callback返回的handleRequest也就是响应request的处理函数消费。看下createContext源码
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、request和response,分别继承于koa实例的三个静态属性,这三个静态属性由koa自己定义,在上面有一些快捷操作方法,比如在Request静态类上可以获取通过query获取查询参数,通过URL解析url等,可以理解为request的工具库,Response和Context同理。res和rep是node的原生对象,还记得吗,这两个参数是由http.Server()实例触发request事件带来的入参。 res是http.incomingMessage的实例而rep继承于http.ServerResponse, 贴一张图。
箭头指向说明了从属关系,有五个箭头指向ctx表面ctx上有五个这样的的属性,可以很清楚看到ctx上各个属性之间的关系。
接下来我们再来看看koa中的中间件,在koa中使用use方法可以注册中间件.
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; }复制代码
两件事,统一中间件格式,再将中间件推入中间件数组中。 在koa2.0以后middleware都是使用async/await语法,使用generator function也是可以的,2.0以后版本内置了koa-convert,它可以根据 fn.constructor.name == 'GeneratorFunction'
.来判断是legacyMiddleware还是modernMiddleware,并根据结果来做相应的转换。 koa-convert的核心使用是co这个库,它提供了一个自动的执行器,并且返回的是promise,generator function有了这两个特性也就可以直接和async函数一起使用了。
回到koa源码来,callback中是这样处理中间件数组的
const fn = compose(this.middleware);复制代码
这里的compose也就是koa-compose模块,它负责将所有的中间件串联起来,并保证执行顺序。经典的洋葱圈图:
koa-compose模块的介绍只有简单的一句话
Compose the given middleware and return middleware.
言简意赅,就是组合中间件。贴上源码
function compose (middleware) { 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) } } }}复制代码
compose先存入中间件数组,从第一个开始执行依次resolve到最后一个,中间件函数签名为(ctx,next)=>{}
,在内部调用next就会间接唤起下一个中间件,也就是执行dispatch.bind(null, i + 1)
,中间件执行顺序如下(网上扒下来的图)。
图上是遇到yield,和执行next同理。 也不是所有的中间件都需要next,在最后一个middleware执行完毕后可以不调用next,因为这个时候已经走完了所有中间件的前置逻辑。当然这里调用next也是可以的,是为了在所有前置逻辑执行完后有一个回调。我们单独使用koa-compose:
const compose = require("koa-compose");let _ctx = { name: "ctx"};const mw_a = async function (ctx, next) { console.log("this is step 1"); ctx.body = "lewis"; await next(); console.log("this is step 4");}const mw_b = async function (ctx, next) { console.log("this is step 2"); await next(); console.log("this is step 3");}const fn = compose([mw_a, mw_b]);fn(_ctx, async function (ctx) { console.log("Done", ctx)});// => // // this is 1// this is 2// Done {name: "ctx", body: "lewis"}// this is 3// this is 4复制代码
compose返回的函数接受的参数不光是ctx,还可以接受一个函数作为走完所有中间件前置逻辑后的回调。有特殊需求的开发者可以关注一下。 当然整个中间件执行完后会返回一个resolve状态的promise,在这个回调中koa用来告诉客户端“响应已经处理完毕,请查收”,这个时候客户端才结束等待状态,这个过程的源码:
// handleRequest 的返回值// 当中间件已经处理完毕后,交由handleResponse也就是respond方法来最后处理ctxconst handleResponse = () => respond(ctx);fnMiddleware(ctx).then(handleResponse).catch(onerror);/** * 交付response */function respond(ctx) { // allow bypassing koa if (false === ctx.respond) return; const res = ctx.res; if (!ctx.writable) return; let body = ctx.body; const code = ctx.status; // ignore body if (statuses.empty[code]) { // strip headers 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(); } // status 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); if (body instanceof Stream) return body.pipe(res); // body: json body = JSON.stringify(body); if (!res.headersSent) { ctx.length = Buffer.byteLength(body); } res.end(body);}复制代码
以上代码对各种款式的status和ctx.body做了相应的处理,最关键的还是这一句res.end(body)
,它调用了node原生response的end方法,来告诉服务器本次的请求以回传body来结束,也就是告诉服务器此响应的所有报文头及报文体已经发出,服务器在此调用后认为这条信息已经发送完毕,并且这个方法必须对每个响应调用一次。
总结
至此,koa整个流程已经走通,可以看到koa的关键点集中在ctx对象和中间件的运用上。 通过delegate将原生res和req的方法属性代理至ctx上,再挂载koa内置的Request和Reponse,提供koa风格操作底层res和req的实现途径和获取请求信息的工具方法。 中间件则是使用koa-compose库将中间件串联起来执行,并具有可以逆回执行的能力。