阅读Koa源码小记

102 阅读5分钟

前置工作

上Github找到相应的Koa版本,下载过来并解压

笔者用的是2.13.1版本

初窥全貌

浏览Koa的package.json文件,发现

{
  "main": "lib/application.js",
  "exports": {
    ".": {
      "require": "./lib/application.js",
      "import": "./dist/koa.mjs"
    }
  }
}

由此不难看出,lib文件夹下的application.js正是Koa的主体所在。此外,Koa的源码全部在lib文件夹下,而lib文件夹十分简洁,只有四个文件

  • application.js Koa主体
  • context.js Koa上下文
  • request.js 封装request
  • response.js 封装response

总的来看application.js的代码,能发现Koa的实现其实并不复杂。其核心部分如下

const Emitter = require('events')

module.exports = class Application extends Emitter {
    constructor(options) {
        super()
        
        // initalize...
    }
    
    // 起一个服务器
    listen(...args) { }
    
    // 注册middleware
    use(fn) { }
}

是的,就是这么简单,和我们使用Koa一样简单!

整个Koa应用是一个基于Node里面的事件触发器的类。它在Node中有着相当重要的地位,我们看看官网是怎么说的

Node.js 的大部分核心 API 都是围绕惯用的异步事件驱动架构构建的,在该架构中,某些类型的对象(称为"触发器")触发命名事件,使 Function 对象("监听器")被调用。

例如:net.Server 对象在每次有连接时触发事件;fs.ReadStream 在打开文件时触发事件;在每当有数据可供读取时触发事件。

所有触发事件的对象都是 EventEmitter 类的实例。这些对象暴露了 eventEmitter.on() 函数,允许将一个或多个函数绑定到对象触发的命名事件。

以下示例展示了使用单个监听器的简单的 EventEmitter 实例。 eventEmitter.on() 方法用于注册监听器,eventEmitter.emit() 方法用于触发事件。

const EventEmitter = require('node:events');

class MyEmitter extends EventEmitter {}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
myEmitter.emit('event');

这种经典的.on式的回调写法,在Node中无处不在,如http.Server等。可以说EventEmitter是Node异步IO机制的基石。

中间件模式

提到Koa,就免不了要提到其中间件模式。它正是Koa设计上的精髓所在。请求到了服务器,依次按序被注册的中间件所处理。其相关实现并不复杂

class Application extends Emitter {
    constructor(options) {
        super()
        this.middleware = []    // 存放中间件
        // ...
    }
    
    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        // 提供链式调用的能力
    }
}

处理中间件模式的Server结构是这样的

const compose = require('koa-compose')
const onFinished = require('on-finished')

class Application extends Emitter {
    listen (...args) {
        debug('listen')
        const server = http.createServer(this.callback())   // 通过原生的http.createServer创建
        return server.listen(...args)
    }
    
    // 为Node的原生http server返回一个request handler
    callback() {
        // 将所有的中间件组合成一个函数,该函数返回一个Promise
        const fn = compose(this.middleware)

        // 如果没有注册error回调,则注册默认的onerror
        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
    }
    
    handleRequest (ctx, fnMiddleware) {
        const res = ctx.res
        res.statusCode = 404
        const onerror = err => ctx.onerror(err)
        const handleResponse = () => respond(ctx)
        // on-finished包的作用是
        // Execute a callback when a HTTP request closes, finishes, or errors.
        // 这行代码会在res出错时,执行onerror函数
        onFinished(res, onerror)
        // ctx会作为最初的context,传给中间件
        return fnMiddleware(ctx).then(handleResponse).catch(onerror)
  	}
    
    // 根据请求与响应,创建上下文
    createContext(req, res) {
        
    }
    
    //默认error handler
    onerror(err) {
        
    }
}

// response helper
function respond(ctx) { 
	//...
}

koa-compose

接上,我们先来看koa-compose这个工具,它用于将中间件整合起来。其源码只有短短48行,相当精简。直接贴上附带注释的源码

'use strict'

/**
 * Expose compositor.
 */

module.exports = compose

/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */

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!')
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  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)
      }
    }
  }
}

其核心就在于dispatch函数。该函数依次取出middleware中的函数,并将其通过Promise.resolve()串联在一起。只要middleware中的函数执行了next(),下一个函数也将紧跟着执行,直到遍历完整个middleware数组。得益于Event LoopPromise.resolve()使所有异步函数依次在微任务队列里执行完。这里还用index指向上一个被调用的middleware function,所以出现了闭包结构。

Settings

Koa还支持设置实例的一些属性,如app.env,app.keys等。

这个就比较简单了,其相关实现如下

/**
    *
    * @param {object} [options] Application options
    * @param {string} [options.env='development'] Environment
    * @param {string[]} [options.keys] Signed cookie keys
    * @param {boolean} [options.proxy] Trust proxy headers
    * @param {number} [options.subdomainOffset] Subdomain offset
    * @param {string} [options.proxyIpHeader] Proxy IP header, defaults to X-Forwarded-For
    * @param {number} [options.maxIpsCount] Max IPs read from proxy IP header, default to 0 (means infinity)
    *
    */

  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
  }

request,response,context

在Node的原生http模块中,http.createServer接受requestListener回调函数作为其参数。该函数的两个参数requestresponse,是分别基于http.IncomingMessage类和http.ServerResponse类的。在Koa中,为了简化、方便开发者对其的处理,Koa自己封装了requestresponse,可以理解为对原生IncomingMessageServerResponse的一层抽象。

context则是将requestresponse对象封装成一个对象,为开发提供了很多有用的属性与API

lib文件夹的三个相应的文件,正是书写了这三者的Prototype,在Koa主体中以Object.create(proto)的方式使用。

context.js中还利用了delegates这个年久失修的包,凭借委托的设计模式来控制context上的requestresponse的行为。

例如

const delegate = require('delegates')

const proto = module.exports = { }

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')

源代码里居然还出现了用proto.__defineGetter__来改写[[ Get ]]行为的写法。17年有位老哥提了个用Object.defineProperty代替的PR,也没人管...

详细内容,读者若有兴趣可自行查阅,文档与源码照着一起看。

小结

这应该是笔者首次尝试去阅读一个开源项目的源码,受益良多。希望日后能不断地阅读优秀源码,不断变强