阅读 733

Koa 源码浅析

本文围绕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.js

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,也就是最初的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();

// response
app.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, 贴一张图。

al

箭头指向说明了从属关系,有五个箭头指向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' check here.来判断是legacyMiddleware还是modernMiddleware,并根据结果来做相应的转换。 koa-convert的核心使用是co这个库,它提供了一个自动的执行器,并且返回的是promise,generator function有了这两个特性也就可以直接和async函数一起使用了。

回到koa源码来,callback中是这样处理中间件数组的

const fn = compose(this.middleware);
复制代码

这里的compose也就是koa-compose模块,它负责将所有的中间件串联起来,并保证执行顺序。经典的洋葱圈图:

koa mw

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),中间件执行顺序如下(网上扒下来的图)。

mw

图上是遇到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方法来最后处理ctx
const 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库将中间件串联起来执行,并具有可以逆回执行的能力。

关注下面的标签,发现更多相似文章
评论