前端点滴
首发于前端点滴

可能是目前市面上比较有诚意的Koa2源码解读

0,前言

阅读本文前,需要对koa2(以下简称Koa)和es6有一定了解。 本文会以如下的顺序:

  • 1,koa是什么;
  • 2,初读koa源码;
  • 3,精读koa源码;
    • 3.1,中间件机制解读
    • 3.2,如何将generator函数转成类async函数
    • 3.3,统一的错误处理机制


结合源码解读Koa框架。 阅读完本文之后,除了可以对Koa有一个全方面的了解,还可以对js的Promise,Generator,Async语法有深入理解。


1,koa是什么

Koa是一个精简的web框架,它主要做了以下几件事:

  • 为request和response对象赋能,并基于它们封装成一个context对象(占了最多的代码篇幅,但很容易理解)
  • 基于async/await的中间件容器机制(最重要,最有价值,代码很精简但较难理解)


2,初读koa源码

koa的源码结构非常简单,共4个文件。

── lib
   ├── application.js
   ├── context.js
   ├── request.js
   └── response.js

以下是四个文件带注释的精简版,读懂它们可以对koa有一个大致的了解:

application.js

//作者注:引入第三方库,实际不仅是下面几个,列出来的几个是比较关键的
const response = require('./response');
const compose = require('koa-compose');//作者注:实现基于async/await的洋葱式调用顺序的中间件容器的关键库,下文会重点介绍
const context = require('./context');
const request = require('./request');
const Emitter = require('events');//作者注:node的基础库,koa应用集成于它,主要用了其事件机制,来实现异常的处理
const http = require('http');//作者注:node实现web服务器功能的核心库
const convert = require('koa-convert');//作者注:为了支持koa1的generator中间件写法,对于使用generator函数实现的中间件函数,需要通过koa-convert转换


module.exports = class Application extends Emitter {

    constructor() {
        super();

        this.middleware = [];//作者注:该数组存放所有通过use函数引入的中间件函数

        //作者注:创建context、request、response对象
        this.context = Object.create(context);
        this.request = Object.create(request);
        this.response = Object.create(response);
    }


    //作者注:创建服务器实例
    listen(...args) {
        debug('listen');
        //作者注:通过执行callback函数返回的函数来作为处理每次请求的回调函数
        const server = http.createServer(this.callback());
        return server.listen(...args);
    }

    /*
      作者注:通过调用koa应用实例的use函数,形如:
      app.use(async (ctx, next) => {
          await next();
      });
      来加入中间件
    */
    use(fn) {
        //作者注:如果是generator函数,则需要通过koa-convert转换成类似类async/await函数
        //其核心原理是将 Generator 函数和自动执行器,包装在一个函数里。后文会重点解释
        if (isGeneratorFunction(fn)) {
            fn = convert(fn);
        }
        //作者注:将中间件加入middleware数组中
        this.middleware.push(fn);
        return this;
    }

    //作者注:返回一个形如(req, res) => {}的函数,该函数会作为参数传递给上文listen函数中的http.createServer函数,作为处理请求的回调函数
    //具体细节会在下文重点解释
    callback() {

        //作者注:将所有中间件函数通过koa-compose组合一下
        const fn = compose(this.middleware);

        //作者注:该函数会作为参数传递给上文listen函数中的http.createServer函数,
        const handleRequest = (req, res) => {

            //作者注:基于req和res,封装一个更强大的context对象
            const ctx = this.createContext(req, res);

            //作者注:当有请求过来时,需要基于办好了request和response信息的ctx和所有中间件函数,来处理请求。
            return this.handleRequest(ctx, fn);
        };

        return handleRequest;
    }

    handleRequest(ctx, fnMiddleware) {
        //作者注:略
    }

    //作者注:基于req和res对象,创建context对象
    createContext(req, res) {
        //作者注:略
    }


};

context.js

const util = require('util');
const createError = require('http-errors');
const httpAssert = require('http-assert');
const delegate = require('delegates');
const statuses = require('statuses');



const proto = module.exports = {

    //作者注:
    //一些不甚重要的函数
};


/*
    作者注:
    在application.js的createContext函数中,
    被创建的context对象上会挂载基于response.js实现的response对象和基于request.js实现的request对象,
    下面两个delegate函数的作用是让context对象代理response和request的部分方法和属性
*/
delegate(proto, 'response')
    .method('attachment')
    ...
    .getter('writable');

/**
 * Request delegation.
 */

var a = delegate(proto, 'request')
    .method('acceptsLanguages')
    ...
    .getter('ip');

request.js

module.exports = {

    //作者注,在application.js中的createContext函数中,会把node服务器的req对象作为request对象的属性,
    //request对象会基于req封装很多便利的函数和属性
    get header() {
        return this.req.headers;
    },

    set header(val) {
        this.req.headers = val;
    },

    //作者注:省略了大量类似的工具属性和方法
}

response.js

与request.js类似,主要是基于node服务器的res对象,封装一系列便利的函数和属性。


3,精读koa源码

上面毕竟是走马观花,本着追根究底的学术精神,还需要对大量细节仔细揣度,下文就会对我认为很重要的细节进行解读,如果彻底读通的话,会对Promise,Generator,Async语法有更深入的理解。

3.1,精简但重要的中间件机制

koa中的中间件本质上就是一个async函数,形如:

async (ctx, next) => {
  await next();
}

该函数接受两个参数,ctx和next,ctx即为application的context属性,其封装了req和res;next函数用于将程序控制权交个下一个中间件。 通过koa应用实例的use函数,可以将中间件加入到koa实例的middleware数组中。 当node服务启动的时候,会通过koa-compose的compose函数,将middleware数组组织成一个fn对象。 当有请求访问时,会调用callback函数内部的handleRequest函数,该函数主要做两件事:

  • 根据req和res创建context对象;
  • 执行koa实例的handleRequest函数(注意区分两个handleRequest函数); koa实例的handleRequest函数通过其最后一行代码,开启了中间件函数的洋葱式调用(具体什么是koa的洋葱式调用,请参见:链接)。 以上描述的对应下面三部分代码如下:
const server = http.createServer(this.callback());
callback() {
    const fn = compose(this.middleware);

    if (!this.listeners('error').length) 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);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

这里有两个细节很关键:

第一是koa-compose对middleware做了什么;

第二是如何实现洋葱式调用的?

答: 以下是koa-compose的精简版源码

module.exports = compose

function compose(middleware) {
    return function (context, next) {
        //略
    }
}

compose函数接收middleware数组作为参数,middleware中每个对象都是一个async函数; 返回一个以context和next作为入参的函数,我们姑且和源码一样,称其为fnMiddleware;

接下来运行中间件:

return fnMiddleware(ctx).then(handleResponse).catch(onerror);

这里就需要关心fnMiddleware的实现了:

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, function next() {
                    return dispatch(i + 1)
                }))
            } catch (err) {
                return Promise.reject(err)
            }
        }
    }

解释前我先做一个假设:假设加入了两个中间件,代码如下:

app.use(async (ctx,next) => {
   console.log("1-start");
   await next();
   console.log("1-end");
});

app.use(async (ctx, next) => {
  console.log("2-start");
  await next();
  console.log("2-end");
});

然后我们逐步执行:

0,fnMiddleware(ctx)运行;

0,执行dispatch(0);

0,进入dispatch函数,执行到

return Promise.resolve(fn(context, function next() {
            return dispatch(i + 1)
        }))

此时fn就是第一个中间件,它是一个async函数,async函数会返回一个Promise对象,Promise.resolve()中若传入一个Promise对象的话,那么Promise.resolve将不做任何修改、原封不动地返回这个Promise对象

0,进入到第一个中间件代码内部: 先执行‘console.log("1-start");’;

0,然后执行'await next();'并开始等待next执行返回;

1,进入到next函数后,主要是执行dispatch(1),于是老的dispatch(0)函数压栈,开始从头开始执行dispatch(1),即把第二个中间件函数付给fn,然后开始执行,这步完成了程序控制权从第一个中间件到第二个中间件的转移

2, 进入到第二个中间件代码内部: 先执行‘console.log("2-start");’; 然后执行'await next();'并开始等待next执行返回;

3,进入到next函数后,主要是执行dispatch(2),于是老的dispatch(1)函数压栈,开始从头开始执行dispatch(2),由于此时程序符合下面的条件:

if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()

所以返回Promise.resolve(),此时第二个中间件的next函数返回了。

2, 所以接下来执行console.log("2-end");

1,由此第二个中间件执行完成,把程序控制权传递给第一个中间件。 第一个中间件执行console.log("1-end");。

0 , 终于完成所有中间件的执行,若中间没有异常,则返回Promise.resolve(),执行handleResponse回调;若有异常,则返回Promise.reject(err),执行onerror回调。

3.2,如何将generator函数转成类async函数

出于对于上个版本的兼容,如果中间件函数是generator函数的话,会使用koa-convert将其转为‘类async’函数。(不过到第三版本,该功能会取消)。

如何转换,这个话题还是很吸引人的。

让我们来想想generator函数和async有啥区别?唯一的区别是async函数会自动执行,而generator每次需要调用next函数。仅此而已。

所以问题转变为:如果让generator函数自执行?

如果不考虑异步的情况的话,非常简单,用个while循环就行,但是如果有异步的话,就比较复杂一些。

回忆一下generator的知识:每次执行generator的next函数,它会返回一个对象 { value: xxx, done: false },返回对象后,如果能再次执行generator的next函数就可以达到自动执行generator的目的了。 请看下面的代码:

function * gen(){
    yield new Promise((resolve,reject){
        //异步函数1
        if(成功){
            resolve()
        }else{
            reject();
        }
    });
    
    yield new Promise((resolve,reject){
        //异步函数2
        if(成功){
            resolve()
        }else{
            reject();
        }
    })
}
let g = gen();
let ret = g.next();

此时ret = {value:promise实例,done:false}; 此时拿到了promise对象,那就可以自己定义成功/失败的回调函数了。即:

ret.value.then(()=>{
        g.next();
    })

上面的代码是不是在第一次异步函数执行完成之后,自动执行了generator函数? 好好读读上面这几行代码,是不是觉得有点眉目了? 问题是如何把代码写得更通用,然后让自动执行一路执行下去呢? 上精简版co源码:

function co(gen) {

  return new Promise(function(resolve, reject) {
    //1,不管三七二十一先执行onFulfilled函数
    onFulfilled();

    function onFulfilled(res) {
      var ret;
      try {
        //2,第一次执行gen函数,返回一个{ value: xxx, done: false }
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      //3,然后执行next函数,并把ret作为入参
      next(ret);
    }

    function onRejected(err) {
    }

    function next(ret) {
      if (ret.done) return resolve(ret.value);
      //4,按照之前的说法,如果返回的是个Promise实例,就可以根据Promise实例的then回调来继续执行generator的next方法了。所以先要把ret.value转换为一个Promise实例
      var value = toPromise.call(ctx, ret.value);
      //5,让成功的回调指向onFulfilled函数,其实就是又从第1步开始执行了
      //这样就实现了自动执行generator
      if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
    }
  });
}

对!好好品味一下,就是这么简单。

除了使用Promise之外,还可以使用Trunk来实现。具体可以参见尊敬的阮一峰老师的文章 我这里就不赘述了,思路和Promise很像,中心思想都是:

1,执行函数cb(我瞎取的名),调用next之后返回一个句柄,

2,可以根据这个句柄为其定制回调函数,将回调函数设为cb。 就这样循环往复。

3.3,统一的错误处理机制

koa框架提供了一种集中式的错误处理机制,只需让koa实例监听error事件,则所有中间件代码逻辑的错误都可以在该回调函数中统一处理,如下

app.on('error', err => {
  log.error('server error', err)
});

这是如何做到的呢? 核心代码如下: 第一部分:application.js:

handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    //1,虽然application.js中也存在onerror函数,但是这里用到的函数是context.js中定义的onerror
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    //2,中间件中出错的话,都能执行onerror函数的关键:
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

看完会有两个疑问: 1,出错执行的回调函数是context.js中的onerror函数,为什么在application实例上监听error事件,就能处理所有中间件中的错误呢? 请看context.js中onerror函数的精简版: context.js:

onerror(err) {
    this.app.emit('error', err, this);
}

其中this.app即是对application的引用,当context.js的onerror触发时,会触发application实例的error事件。该事件机制是基于“Application类继承自EventEmitter”这一事实。

2,如何做到集中处理所有中间件的错误?中间件洋葱式调用的实现逻辑如下:

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, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

主要涉及到下面几个知识点:

  • 1,async函数返回一个 Promise 对象。
  • 2,async函数内部抛出错误,会导致返回的 Promise对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。
  • 3,await命令后面的 Promise 对象如果变为reject状态,则reject的参数会被catch方法的回调函数接收到。

结合源码和上面三点,自己想一想就能想清楚了。


先写这么多吧。

编辑于 2018-03-23

文章被以下专栏收录