玩转Koa -- 核心原理分析

Posted 漫谈大前端

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了玩转Koa -- 核心原理分析相关的知识,希望对你有一定的参考价值。

Koa作为下一代Web开发框架,不仅让我们体验到了async/await语法带来同步方式书写异步代码的酸爽,而且本身简洁的特点,更加利于开发者结合业务本身进行扩展。

本文从以下几个方面分析其核心原理:

  • 封装创建应用程序函数

  • 扩展res和req

  • 中间件原理

  • 异常处理

一、封装创建应用程序函数

利用NodeJS可以很容易地编写一个简单的应用程序:

 
   
   
 
  1. const http = require('http')


  2. const server = http.createServer((req, res) => {

  3.  // 每一次请求处理的方法

  4.  console.log(req.url)

  5.  res.writeHead(200, { 'Content-Type': 'text/plain' })

  6.  res.end('Hello NodeJS')

  7. })


  8. server.listen(8080)

注意:当浏览器发送请求时,会附带请求/favicon.ico,导致每一次请求触发两次。

而Koa在封装创建应用程序的方法中主要执行了以下流程:

  • 组织中间件(监听请求之前)

  • 生成context上下文对象

  • 执行中间件

  • 执行默认响应方法或者异常处理方法


 
   
   
 
  1. // application.js

  2. listen(...args) {

  3.  const server = http.createServer(this.callback());

  4.  return server.listen(...args);

  5. }


  6. callback() {

  7.  // 组织中间件 koa-compose

  8.  const fn = compose(this.middleware);


  9.  // 未监听异常处理,则采用默认的异常处理方法

  10.  if (!this.listenerCount('error')) this.on('error', this.onerror);


  11.  const handleRequest = (req, res) => {

  12.    // 生成context上下文对象

  13.    const ctx = this.createContext(req, res);

  14.    return this.handleRequest(ctx, fn);

  15.  };


  16.  return handleRequest;

  17. }


  18. handleRequest(ctx, fnMiddleware) {

  19.  const res = ctx.res;

  20.  // 默认状态码为404

  21.  res.statusCode = 404;

  22.  // 中间件执行完毕之后 采用默认的 错误 与 成功 的处理方法

  23.  const onerror = err => ctx.onerror(err);

  24.  const handleResponse = () => respond(ctx);

  25.  onFinished(res, onerror);

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

  27. }

二、扩展res和req

首先我们要知道NodeJS中的res和req是http.IncomingMessage和http.ServerResponse的实例,那么就可以在NodeJS中这样扩展req和res:

 
   
   
 
  1. Object.defineProperties(http.IncomingMessage.prototype, {

  2.  query: {

  3.    get () {

  4.      return querystring.parse(url.parse(this.url).query)

  5.    }

  6.  }

  7. })


  8. Object.defineProperties(http.ServerResponse.prototype, {

  9.  json: {

  10.    value: function (obj) {

  11.      if (typeof obj === 'object') {

  12.        obj = JSON.stringify(obj)

  13.      }

  14.      this.end(obj)

  15.    }

  16.  }

  17. })

而Koa中则是自定义request和response对象,然后保持对res和req的引用,最后通过getter和setter方法实现扩展。

 
   
   
 
  1. // application.js

  2. createContext(req, res) {

  3.  const context = Object.create(this.context);

  4.    const request = context.request = Object.create(this.request);

  5.    const response = context.response = Object.create(this.response);

  6.    context.app = request.app = response.app = this;

  7.    context.req = request.req = response.req = req; // 保存原生req对象

  8.    context.res = request.res = response.res = res; // 保存原生res对象

  9.    request.ctx = response.ctx = context;

  10.    request.response = response;

  11.    response.request = request;

  12.    context.originalUrl = request.originalUrl = req.url;

  13.    context.state = {};

  14.    // 构建完成的context上下文对象

  15.    return context;

  16. }

所以在Koa中要区别这两组对象:

  • request、response: Koa扩展的对象

  • res、req: NodeJS原生对象


 
   
   
 
  1. // request.js

  2. get header() {

  3.  return this.req.headers;

  4. },

  5. set header(val) {

  6.  this.req.headers = val;

  7. },

此时已经可以采用这样的方式访问header属性:

 
   
   
 
  1.  ctx.request.header

但是为了方便开发者调用这些属性和方法,Koa将response和request中的属性和方法代理到context上。

通过Object.defineProperty可以轻松的实现属性的代理:

 
   
   
 
  1. function access (proto, target, name) {

  2.  Object.defineProperty(proto, name, {

  3.    get () {

  4.      return target[name]

  5.    },

  6.    set (value) {

  7.      target[name] = value

  8.    }

  9.  })

  10. }


  11. access(context, request, 'header')

而对于方法的代理,则需要注意this的指向:

 
   
   
 
  1. function method (proto, target, name) {

  2.  proto[name] = function () {

  3.    return target[name].apply(target, arguments)

  4.  }

  5. }

上述就是属性代理和方法代理的核心代码,这基本算是框架常用套路之一。

代理这部分详细的源码,可以查看node-delegates, 不过这个包时间久远,有一些老方法已经废除。

在上述过程的源码中涉及到很多javascript的基础知识,例如:原型继承、this的指向。对于基础薄弱的同学,还需要先弄懂这些基础知识。

三、中间件原理

首先需要明确是:中间件并不是NodeJS中的概念,它只是connect、express和koa框架衍生的概念。

1、connect中间件的设计

在connect中,开发者可以通过use方法注册中间件:

 
   
   
 
  1. function use(route, fn) {

  2.  var handle = fn;

  3.  var path = route;


  4.  // 不传入route则默认为'/',这种基本是框架处理多参数的常用套路

  5.  if (typeof route !== 'string') {

  6.    handle = route;

  7.    path = '/';

  8.  }


  9.  ...

  10.  // 存储中间件

  11.  this.stack.push({ route: path, handle: handle });


  12.  // 以便链式调用

  13.  return this;

  14. }

use方法内部获取到中间件的路由信息(默认为'/')和中间件的处理函数之后,构建成layer对象,然后将其存储在一个队列当中,也就是上述代码中的stack。

connect中间件的执行流程主要由handle与call函数决定:

 
   
   
 
  1. function handle(req, res, out) {

  2.  var index = 0;

  3.  var stack = this.stack;

  4.  ...

  5.  function next(err) {

  6.    ...

  7.    // 依次取出中间件

  8.    var layer = stack[index++]


  9.    // 终止条件

  10.    if (!layer) {

  11.      defer(done, err);

  12.      return;

  13.    }


  14.    var path = parseUrl(req).pathname || '/';

  15.    var route = layer.route;


  16.    // 路由匹配规则

  17.    if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {

  18.      return next(err);

  19.    }

  20.    ...

  21.    call(layer.handle, route, err, req, res, next);

  22.  }


  23.  next();

  24. }

handle函数中使用闭包函数next来检测layer是否与当前路由相匹配,匹配则执行该layer上的中间件函数,否则继续检查下一个layer。

这里需要注意next中检查路由的方式可能与想象中的不太一样,默认路由下,中间件会在每一次请求中都执行。

 
   
   
 
  1. function call(handle, route, err, req, res, next) {

  2.  var arity = handle.length; // 参数的数量

  3.  var error = err;

  4.  var hasError = Boolean(err);


  5.  try {

  6.    if (hasError && arity === 4) {

  7.      // 错误处理中间件

  8.      handle(err, req, res, next);

  9.      return;

  10.    } else if (!hasError && arity < 4) {

  11.      // 请求处理中间件

  12.      handle(req, res, next);

  13.      return;

  14.    }

  15.  } catch (e) {

  16.    // 记录错误

  17.    error = e;

  18.  }


  19.  // 将错误传递下去

  20.  next(error);

  21. }

在通过call方法执行中间件时,采用try/catch捕获错误。这里有一个特别需要注意的地方是,call内部会根据是否存在错误以及中间件函数的参数个数决定是否执行错误处理中间件。并且一旦捕获到错误,next方法会将错误传递下去,所以接下来普通的请求处理中间件即使通过了next中的路由匹配,仍然会被call方法给过滤掉。

下面是layer的处理流程图:

上述就是connect中间件设计的核心要点,总结起来有如下几点:

  • 通过use方法注册中间件;

  • 中间件的顺序执行是通过手动调用next方法的递归调用,在next中会进行路由匹配,从而过滤掉部分中间件;

  • 当中间件的执行过程中发生异常,next会携带异常跳过非错误处理中间件的执行,

  • 在请求处理的周期中,需要手动调用res.end()来结束响应;

2、Koa中间件的设计

Koa中间件与connect中间件的差异在于:

  • Koa中间件的执行并不需要匹配路由,所以注册的中间件每一次请求都会执行。(当然还是需要手动调用next);

  • Koa中通过继承event,暴露error事件让开发者自定义异常处理,并不需要错误处理中间件。

  • Koa中res.end由中间件执行完成之后自动调用,这样避免在connect忘记调用res.end导致用户得不到任何反馈。

  • Koa中采用了async/await语法让开发者利用同步的方式编写异步代码。

当然,Koa中也是采用use方法注册中间件,相比较connect省去路由匹配的处理,就显得很简洁:


 
   
   
 
  1. use(fn) {

  2.  this.middleware.push(fn);

  3.  return this;

  4. }

并且use支持链式调用。

Koa中间件的执行流程主要通过koa-compose完成:

 
   
   
 
  1. function compose (middleware) {

  2.  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')

  3.  for (const fn of middleware) {

  4.    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')

  5.  }


  6.  /**

  7.   * @param {Object} context

  8.   * @return {Promise}

  9.   * @api public

  10.   */


  11.  return function (context, next) {

  12.    let index = -1

  13.    return dispatch(0)

  14.    function dispatch (i) {

  15.      if (i <= index) return Promise.reject(new Error('next() called multiple times'))

  16.      index = i

  17.      let fn = middleware[i]

  18.      if (i === middleware.length) fn = next

  19.      if (!fn) return Promise.resolve()

  20.      try {

  21.        // 利用Promise包装中间件

  22.        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

  23.      } catch (err) {

  24.        return Promise.reject(err)

  25.      }

  26.    }

  27.  }

  28. }

本质上connect与koa实现中间件的思想都是递归,不难看出koa相比较connect实现得更加简洁,主要原因在于:

  • connect中提供路由匹配的功能,而Koa中则是相当于connect中默认的'/'路径;

  • connect在捕获中间件的异常时,通过next携带error一个个中间件验证,直到错误处理中间件,而Koa中则是用Promise包装中间件,一旦中间件发生异常,那么会直接触发reject状态,直接在Promise的catch中处理就行。


上述就是connect中间件与Koa中间件的实现原理,现在在再看Koa中间件的这张执行流程图,应该没有什么疑问了吧?!


四、异常处理

对于同步代码,通过try/catch可以轻松的捕获异常。

但是对于异步代码,try/catch则无法捕获,这时候需要构造Promise,在最后的catch方法中捕获异常,Koa就是这样处理,并且在catch方法中发送error事件,以便开发者自定义异常处理逻辑。

 
   
   
 
  1.  this.app.emit('error', err, this);

async/await语法不仅带来同步方式书写异步代码的酸爽,另外也让异常处理变得更加自然,在Koa中也可以通过这样的中间件处理错误:

 
   
   
 
  1. app.use(async (ctx, next) => {

  2.  try {

  3.    await next();

  4.  } catch (err) {

  5.    ctx.status = err.status || 500

  6.    ctx.body = err

  7.  }

  8. })

五、总结

相信看到这里,再结合之前自己遇到的那些似懂非懂的问题,应该会有新的理解,再次使用Koa时也会更加得心应手,这也是分析Koa实现原理的重要目的之一。


帅的人都已经关注了,您还等什么?


以上是关于玩转Koa -- 核心原理分析的主要内容,如果未能解决你的问题,请参考以下文章

玩转Koa -- koa-bodyparser原理解析

玩转Koa -- koa-bodyparser原理解析

koa2中间件koa和koa-compose源码分析原理

从头实现一个koa框架

深入node.js koa原理,实现koa

深入node.js koa原理,实现koa