学习Koa

6,643 阅读5分钟

原生HTTP服务器

学习过Nodejs的朋友肯定对下面这段代码非常熟悉:

const http = require('http');
let server = http.createServer((req, res) => {
  // ....回调函数,输出hello world
  res.end('hello world!')
})
server.listen(3000)

就这样简单几行代码,就搭建了一个简单的服务器,服务器以回调函数的形式处理HTTP请求。上面这段代码还有一种更加清晰的等价形式,代码如下:

let server = new http.Server();
server.on("request", function(req, res){
  // ....回调函数,输出hello world
  res.end('hello world!')
});
server.listen(3000);

首先创建了一个HttpServer的实例,对该实例进行request事件监听,server在3000端口进行监听。HttpServer继承与net.Server,它使用http_parser对连接的socket对象进行解析,当解析完成http header之后,会触发request事件,body数据继续保存在流中,直到使用data事件接收数据。

req是http.IncomingMessage实例(同时实现了Readable Stream接口),详情请参看文档

res是http.ServerResponse实例(同时实现了Writable Stream接口),详情请参看文档

Koa写HTTP服务器

Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

Koa写http服务器的形式与我们直接通过node http模块写的方式差别很大。第一部分析可知,node的http服务器创建来自于http.createServer等方法,Koa中是如何从原生方法封装成koa形式的服务器呢?搞懂这个原理也就搞懂了Koa框架设计的理念。

Koa源代码解析

要搞懂这个原理,最好的方法就是直接查看Koa的源代码。Koa代码写的非常精简,大约1700多行,难度并非太大,值得一看。 我们以上述demo为例,进行一个分析,我把koa的执行分为两个阶段,第一个阶段:初始化阶段,主要的工作为初始化使用到的中间件(async/await形式)并在指定端口侦听,第二个阶段:请求处理阶段,请求到来,进行请求的处理。

初始化阶段

第一个阶段主要使用的两个函数就是app.use和app.listen。这两个函数存在application.js中。 app.use最主要的功能将中间件推入一个叫middleware的list中。

use(fn) {
    ...
    this.middleware.push(fn);
    return this;
  }

listen的主要作用就是采用我们第一部分的方式创建一个http服务器并在指定端口进行监听。request事件的监听函数为this.callback(),它返回(req, res) => {}类型的函数。

listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
}

分析一下callback函数,代码如下:

/**
   * Return a request handler callback
   * for node's native http server.
   *
   * @return {Function}
   * @api public
   */

  callback() {
    const fn = compose(this.middleware); // 将中间件函数合成一个函数fn
    // ...
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);  // 使用req和res创建一个上下文环境ctx
      return this.handleRequest(ctx, fn); 
    };

    return handleRequest;
  }

至此第一个阶段完成,通过源代码的分析,我们可以知道它实际执行的内容跟我们第一部分使用node http模块执行的大概一致。这里有一个疑问,compose函数是怎么实现的呢?async/await函数返回形式为Promise,怎么保证它的顺序执行呢?一开始我的猜想是将下一个middleware放在上一个middleware执行结果的then方法中,大概思路如下:

compose(middleware) {
        return () => {
          let composePromise = Promise.resolve();   
          middleware.forEach(task => { composePromise = composePromise.then(()=>{return task&&task()}) }) 
          return composePromise; 
        }
    }

最终达到的效果为:f1().then(f2).then(f3).. Koa在koa-compose中用了另外一种方式:

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

它从第一个中间件开始,遇到next,就中断本中间件的代码执行,跳转到对应的下一个中间件执行期内的代码…一直到最后一个中间件,然后逆序回退到倒数第二个中间件next下部分的代码执行,完成后继续会退…一直会退到第一个中间件next下部分的代码执行完成,中间件全部执行结束。从而实现我们所说的洋葱圈模型。

请求处理阶段

当一个请求过来时,它会进入到request事件的回调函数当中,在Koa中被封装在handleRequest中:

handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    // koa默认的错误处理函数,它处理的是错误导致的异常结束
    const onerror = err => ctx.onerror(err);
    // respond函数里面主要是一些收尾工作,例如判断http code为空如何输出,http method是head如何输出,body返回是流或json时如何输出
    const handleResponse = () => respond(ctx);
    // 第三方函数,用于监听 http response 的结束事件,执行回调
    // 如果response有错误,会执行ctx.onerror中的逻辑,设置response类型,状态码和错误信息等
    onFinished(res, onerror);
    
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

请求到来时,首先执行第一个阶段封装的compose函数,然后进入handleResponse中进行一些收尾工作。至此,完成整个请求处理阶段。

总结

Koa是一个设计非常精简的Web框架,源代码本身不含任何中间件,可以使我们根据自身需要去组合一些中间件使用。它结合async/await实现了洋葱模式。