阅读 237

koa2框架源码简析

前言

之前了解了koa2框架的用法后,但是存在一些疑问:

  • 每个中间件的ctx参数是怎么来的
  • 每个中间件的next函数是什么
  • 为什么调用next()后可以把执行控制权交给下一个中间件 于是决定研究下源码,找寻答案。

中心思想

koa2是一个node.js的web开发框架。
koa定义了一个Application的类,该类提供了一些属性和方法。实例化一个Application类对象app后,先app.ues方法加载一系列中间件,然后调用app.listen来创建服务(这里本质是用的原生http包,然后将callback方法作为http.createServer的回调函数)。在callback方法中的主要逻辑是:

  • compose(this.middleware),使用了koa-compose模块的compose方法将所有中间件合并成一个大的中间件fn
  • (req, res) => {const ctx = this.createContext(req, res)},每次监听到新的请求,创建一个新的ctx(对原生req 和 res 进行封装)
  • fn(ctx),中间件执行机制,按照顺序,每个中间件拿到ctx处理对应函数逻辑

每次请求都会创建一个ctx,这个ctx里能拿到请求信息,操作ctx对象向用户做出响应。就是通过context.js 、request.js、 response.js里模型定义、属性代理等方法来对原生http请求 和 响应对象进行封装。

整体架构

koa官网下载源码 核心文件只有4个,在lib文件夹下:

  1. application.js——koa框架的入口文件
  2. context.js——创建网络请求的上下文对象
  3. request.js——用于包装koa的request对象
  4. response.js——用于包装koa的response对象

在koa包的package.json文件的main配置项中有设置

"main": "lib/application.js",
复制代码

application.js是koa的核心文件,context.js 、request.js、 response.js 是通过模型定义、属性代理等方法来对原生http请求 和 响应对象进行封装。

这里主要看application.js里的逻辑。

application.js

application.js中定义了Application类,该类继承 Emitter 类

class Application extends Emitter { //... }
复制代码

有一系列属性和6个核心方法。

  • 构造方法constructor() Application类的构造函数很简单
 constructor(options) {
    // 因为继承于 EventEmitter,这里需要调用 super
    super();  
    options = options || {};
    // 代理设置,为true时表示获取真正的客户端ip地址
    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;
    
    // 数组,用于存储所有的中间件函数的,所有的app.use(fn)的fn(中间件)都会被push进去
    this.middleware = [];
    
    // 声明koa的几个核心对象
    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;
    }
  }
复制代码

middleware——koa的中间件函数的串联是通过数组来逐个执行的
context——上下文对象
request——封装了http请求
response——封装了http响应

  • use(fn)
 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 || '-');
    // 将中间件函数存放于实例对象middleware数组中
    this.middleware.push(fn);
    return this;
  }
复制代码

use方法,其接受的参数是一个function use的功能主要是判断参数fn是否为function,如果是,直接push到this.middleware数组中;如果不是,通过generator函数转化包装成Promise,将参数包装成的fn后在push进this.middleware数组中。

  • listen(...args)
  listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
复制代码

基于node中原生内置的http模块创建http server,并传入回调函数,然后开始监听端口。这里回调函数this.callback()是重点。
如果只用http模块实现一个http服务器程序,可以这样写

'use strict';

// 导入http模块:
var http = require('http');

// 创建http server,并传入回调函数:
var server = http.createServer(function (request, response) {
    // 回调函数接收request和response对象,
    // 获得HTTP请求的method和url:
    console.log(request.method + ': ' + request.url);
    // 将HTTP响应200写入response, 同时设置Content-Type: text/html:
    response.writeHead(200, {'Content-Type': 'text/html'});
    // 将HTTP响应的HTML内容写入response:
    response.end('<h1>Hello world!</h1>');
});

// 让服务器监听8080端口:
server.listen(8080);
复制代码

对比之下,我们可以看到this.callback()的返回值应该是一个接收request和response对象的函数。

  • callback()
callback() {
    // 把this.middleware数组中的所有中间件函数进行合并成一个大的函数
    const fn = compose(this.middleware);

   // 如果没有对 error 事件进行监听,绑定 error 事件监听处理
    if (!this.listenerCount('error')) this.on('error', this.onerror);
   // handleRequest函数,就是前面listen方法中http.createServer的回调函数this.callback()
   // 有req和res两个参数,代表原生的 request,response对象
    const handleRequest = (req, res) => {
      // 服务器监听,每次接受到一个新的请求就是生成一次全新的 context
      // 创建一个context对象,建立koa中context、request、response属性之间和原生http对象的关系
      // 然后将创建的context对象带入一系列中间件函数依次执行
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }
复制代码

compose方法是从npm包——koa-compose模块引入的

const compose = require('koa-compose');
复制代码

可以将多个中间件函数合并成一个大的中间件函数,然后调用这个大的中间件函数就可以依次执行其中的中间件函数,执行一系列的任务。执行顺序是先进先执行。

  • createContext(req, res)
  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对象,并建立koa中context、request、response属性之间的联系

  • handleRequest(ctx, fnMiddleware)
  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    // 默认状态码是404
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err); // 调用context.js中的onerror方法进行错误处理
    const handleResponse = () => respond(ctx);  // 调用respond方法的响应处理
    onFinished(res, onerror);
    // 执行中间件数组中的所有函数,并结束时调用上面的respond函数
    // 中间件基于ctx做了什么事情
    // respond函数基于ctx又做了什么事情
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
复制代码

handleRequest只是执行一系列中间件函数。

fnMiddleware先拿到ctx处理一些逻辑后,返回结果

  • response
function respond(ctx) {
  // allow bypassing koa
  if (false === ctx.respond) return;

  if (!ctx.writable) return;

  const res = ctx.res;
  let body = ctx.body;
  const code = ctx.status;

  // ignore body
  if (statuses.empty[code]) {
    // strip headers
    ctx.body = null;
    return res.end();
  }
  // 如果是HEAD方法
  if ('HEAD' === ctx.method) {
    // headersSent 属性是Node原生的 response对象上的,用于检查 http 响应头不是否已经被发送
    // 如果没有被发送,那么添加 length 头部
    if (!res.headersSent && !ctx.response.has('Content-Length')) {
      const { length } = ctx.response;
      if (Number.isInteger(length)) ctx.length = length;
    }
    return res.end();
  }

  // status body
  // 如果body为null时
  if (null == body) {
  // httpVersionMajor是node原生对象response上的一个属性,用于返回当前http的版本。这里是对http2版本以上做的一个兼容
    if (ctx.req.httpVersionMajor >= 2) {
      body = String(code);
    } else {
      body = ctx.message || String(code);
    }
    // 如果响应报文头还没有被发送出去,就为ctx添加一个length属性,length属性记录这当前报文主体body的字节长度
    if (!res.headersSent) {
      ctx.type = 'text';
      ctx.length = Buffer.byteLength(body);
    }
    return res.end(body);
  }

  // responses
  // 如果body不为null,根据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
  // 最后将为Json格式的body进行字符串处理,将其转化成字符串
  // 同时添加length头部信息
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);
}
复制代码

基于koa 的 context对象的属性,通过node原生http模块的response对象中的end方法 设置最终response对象内容。

request.js

request.js主要是对http模块中的request.js对象进行封装

response.js

response.js和request.js一样,主要是对http模块中的response对象进行封装

context.js

context.js将的request对象和response对象上的属性和方法代理到context对象上 context.js里的代码量很少,阅读起来较为轻松

koa-compose源码解析

koa-compose包的代码量很少,就是定义了一个compose方法,并通过module.export导出

module.exports = compose
function compose (middleware) {...}
复制代码

compose的源码

function compose (middleware) {
  // 保证middleware是一个数组,并且数组里的每个元素是一个方法
  // middleware不是数组,报错
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    // middleware的子元素不是方法,报错
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  // 返回Promise对象
  return function (context, next) {
    // last called middleware #
    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)
      }
    }
  }
}
复制代码
  1. 传入的参数必须是数组:中间件数组

  2. 数组的元素必须是函数

  3. 返回一个闭包函数,这个闭包函数就是将中间件数组的一系列中间件合并成一个大中间件的结果,参数也是有两个:context 和 next函数

这里结合koa源码中的调用情况来理解

const fn = compose(this.middleware); // 合并成大中间件
fnMiddleware(ctx)
复制代码

执行大中间件函数fn,第一个参数是ctx就是每次请求创建的ctx,第二个参数next函数是null。所以每个中间件的ctx参数其实就是从这里拿到的

fnMiddleware(ctx)的内部执行过程:

  1. dispatch(0)

    index = 0
    let fn = middleware[0]
    return Promise.resolve(fn(context, dispatch(1)))
    复制代码

    middleware[0]表示是从第一个中间件开始执行,参数context = fnMiddleware的参数ctx,参数next函数 = dispatch(1) 。所以next参数 是当前中间件函数的下一个中间件函数

  2. 当调用next函数时,即 dispatch(1) ,表示 middleware[1]开始执行,如此开始递归了dispatch方法。所以调用next()后可以把执行控制权交给下一个中间件

  3. 当执行到最后一个中间件后,其中间件的next函数 = fnMiddleware的参数next函数= null,所以最后一个中间件的next函数调用或者不调用,都是一样的。当 当前中间件的next函数执行完毕后,继续执行当前中间件next函数后的逻辑,执行完毕后逐层往上交回执行控制权。

    if (i === middleware.length) fn = next
    if (!fn) return Promise.resolve()
    复制代码

总结

  • koa的源码很少,只有4个文件
  • 每个中间件的ctx参数是
  • 每次请求创建的ctx对象引用,通过compose方法返回的大中间函数fn的参数拿到 每个中间件的next参数是 下一个中间件函数
  • koa-compose模块的compose方法实现了koa的中间件执行机制

参考文档

koa官网:koa.bootcss.com/
源码地址:github.com/koajs/koa
koa2源码解析:www.jianshu.com/p/bc0e1f46f… chenshenhai.github.io/koa2-note/