koa灵魂拷问之koa源码

1,171 阅读8分钟

前言

随着前端的生态逐渐变好,前端一定程度上也进入了内卷时代。从目前来看,node.js逐渐成为前端必会的技术栈之一,他可以让我们快速的搭建一个后端,从而让我们不再依赖后端。 而在node.js里面的一个主流框架就是koa框架, koa干的核心的话其实就是将api优雅化以及aop模式;所谓aop就是面向切面编程,koa采用所谓的洋葱模型,使用者可以通过级联的方式顺序调用中间件。

绪论

接下来我们来看koa的源码,首先从github上面克隆一份koa源码,具体目录如下。其中核心文件在lib下面,其中包括application.js、context.js、request.js、response.js。koa源码给我的第一感觉就是难度和vuex源码的难度差不多,相比较vue-router,koa源码的难度要低一些。它们实现的功能分别如下; application主要做的事情就是实例化应用, context主要做的事情就是实例上下文, request.js由原生request事件的http.IncomingMessage类过滤而来; response.js对应ctx.response,由原生request事件的http.ServerResponse类过滤而来。

image.png

1、Application分析

1.1、创建服务

如下代码所示,node里面创建服务的api不多,koa用的是http模块来创建服务。其中this.callback作为参数。

    debug('listen')
    // 用http创建服务
    const server = http.createServer(this.callback())
    return server.listen(...args)


查看callback代码可以看到返回的是handleRequest,这里面是闭包的应用。

    const fn = compose(this.middleware)

    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
  }


1.2中间件实现原理

继续往下面翻application.js源码,会看到use方法,koa注册中间件的时候,会不断向middleware里面进行push。

    // 入参必须是函数
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
    debug('use %s', fn._name || fn.name || '-')
    // koa注册中间件的时候,会不断的向里面push;
    this.middleware.push(fn)
    return this
  }


我们知道,在koa的中间件执行的过程中,await next之前的部分是按顺序执行的,await next之后的部分是按照逆序执行的。这块我们可以通过demo测试可以直观看到。由于有next的分割,一个中间件会分为两部分执行。那么middleware怎么执行的呢,我们还要到callback里面查看源码。在callback里面我们可以看到下面这行代码,其中compose是引入的一个库。我们需要去查看compose的实现。

const fn = compose(this.middleware)

这里我直接从官网复制到我的本地了,也可以去官网上查看。代码以及代码的分析如下。在这里面我们再次可以看到闭包的存在,到这里,顺便说一下,面试的时候如果面试官问我们闭包的应用的时候,我们就可以扩展到koa源码里面的闭包。

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 {
            // 用递归方法执行await next()后面的逻辑;
          return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
        } catch (err) {
          return Promise.reject(err)
        }
      }
    }
  }


整体来看,compose干的事情就是处理middleware数组,而且context贯穿于所有的中间件,接下来我们细说context。

1.3 koa里面如何封装ctx

我们在使用中间件的时候有两个参数,一个是next,一个是ctx,next就是把当前的中间件的执行权力交给了下一个中间件,那么ctx到底是什么东西呢?contex对象其实就是中间件中的ctx对象。koa为了能够简化API,引⼊上下⽂context概念,将原始请求对象req和响应对象res封装并挂载到 context上,并且在context上设置getter和setter,从⽽简化操作。 在构造器里面可以看到如下三行代码 ,通过Object.create方法分别继承context、request、response。

    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)


继续翻callback代码,可以看到在handleRequest里面创建了ctx对象,通过createContext来创建的。

  callback () {
    const fn = compose(this.middleware)

    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
  }


那么这个时候我们就要翻createContext函数的代码

  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
  }


在这里可以看到,ctx是通过Object.create()方法继承了this.context,而context又来源于lib下面的context;与此同时,又挂载了request里面的req和response里面的res到context上面,后面会单独将request和response。

Ps:这里创建context符合单一上下文原则,这使得信息是高内聚的,因此改动的风险很小。

1.4 handleRequest实现

在callback里面我们可以看到this.handleRequest这个方法,根据this执行判定,this执行全局window,接下来继续翻handleRequest源码,源码以及分析如下,到这步,application.js源码我们只剩下reponse和onerror两个方法没有翻了。

return this.handleRequest(ctx, fn)
  handleRequest (ctx, fnMiddleware) {

    const res = ctx.res
    res.statusCode = 404
    // 错误处理
    const onerror = err => ctx.onerror(err)
    // 处理返回结果
    const handleResponse = () => respond(ctx)
    // 添加错误相应处理
    onFinished(res, onerror)
    // 执行中间件的所有函数,在结束时候调用response函数
    return fnMiddleware(ctx).then(handleResponse).catch(onerror)
  }


respond做了什么事

respond源码以及分析如下,respond源码以及分析如下。

function respond (ctx) {
  // allow bypassing koa
  // 允许跳出koa
  if (ctx.respond === false) return
// 检查是否为原生的可写入流
  if (!ctx.writable) return

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

  // ignore body
  // 如果相应的statuses是body为空的类型,这个时候直接将body设置为空
  if (statuses.empty[code]) {
    // strip headers
    // 带响应头
    ctx.body = null
    return res.end()
  }

  if (ctx.method === 'HEAD') {
    // headersSent属性是Node原生的response对象上的,用于检查HTTP响应头是否被发送
    // 如果头未被发送,并且响应头没有Content-Length属性,那么添加length头
    if (!res.headersSent && !ctx.response.has('Content-Length')) {
      const { length } = ctx.response
      if (Number.isInteger(length)) ctx.length = length
    }
    return res.end()
  }

  // 如果body为null
  if (body == null) {
    if (ctx.response._explicitNullBody) {
      // 移出Content-Type 和 Transfer-Encoding 并返回结果
      ctx.response.remove('Content-Type')
      ctx.response.remove('Transfer-Encoding')
      ctx.length = 0
      return res.end()
    }
    // Http为2+的版本的时候,设置body为对应的http状态码
    if (ctx.req.httpVersionMajor >= 2) {
      body = String(code)
    } else {
      body = ctx.message || String(code)
    }
    // 如果headersSent不为真,直接返回ctx.type为text,txt.length为Buffer.byteLength(body)
    if (!res.headersSent) {
      ctx.type = 'text'
      ctx.length = Buffer.byteLength(body)
    }
    return res.end(body)
  }

  // responses
  // body为Buffer或者string类型的时候
  if (Buffer.isBuffer(body)) return res.end(body)
  if (typeof body === 'string') return res.end(body)
  // body为Stream时,开启管道body.pipe
  if (body instanceof Stream) return body.pipe(res)
  // body为json的时候,转化为字符串,并设置ctx.length后返回结果。
  // body: json
  body = JSON.stringify(body)
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body)
  }
  res.end(body)
}


1.6 异常处理

到此为止,application.js里面的逻辑我们就剩下异常处理没有看了,接下来我们看异常处理。
可以看到在koa里面处理错误很简单,在真实业务里面,我们可以做一些额外的操作。

  onerror (err) {
    // When dealing with cross-globals a normal `instanceof` check doesn't work properly.
    // See https://github.com/koajs/koa/issues/1466
    // We can probably remove it once jest fixes https://github.com/facebook/jest/issues/2549.
    const isNativeError =
      Object.prototype.toString.call(err) === '[object Error]' ||
      err instanceof Error
    if (!isNativeError) throw new TypeError(util.format('non-error thrown: %j', err))

    if (err.status === 404 || err.expose) return
    if (this.silent) return

    const msg = err.stack || err.toString()
    console.error(`\n${msg.replace(/^/gm, '  ')}\n`)
  }


继续返回到callback里面,寻找异常处理的代码

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



由此发现,在执行回调函数中,如果application中监听error事件个数大于0,则用我们自己的异常监听,否则用koa的异常监听。
到此为止,application.js已经完整分析了一遍;

2、Context的实现

翻源码发现像inspect、toJSON等方法在application.js里面都有所提到;Context两大核心功能就是委托机制以及Cookie的操作;

2.1委托机制

我们首先来看委托机制。在引入库这一块,我们可以看到如下代码,委托机制其实将其他属性挂载在自身。这里顺便提一下,我在之前面试的时候被问到过Symbol的应用,下次面试官如果再次问我这个问题,那么我就可以扩展到koa源码中。

const delegate = require('delegates')
const COOKIES = Symbol('context#cookies')



翻到底部我们可以看到在context里面将reponse和request的一些属性挂载到proto(ctx)

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set')
  .method('append')
  .method('flushHeaders')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable')

/**
 * Request delegation.
 */

delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  .method('is')
  .access('querystring')
  .access('idempotent')
  .access('socket')
  .access('search')
  .access('method')
  .access('query')
  .access('path')
  .access('url')
  .access('accept')
  .getter('origin')
  .getter('href')
  .getter('subdomains')
  .getter('protocol')
  .getter('host')
  .getter('hostname')
  .getter('URL')
  .getter('header')
  .getter('headers')
  .getter('secure')
  .getter('stale')
  .getter('fresh')
  .getter('ips')
  .getter('ip')


接下来我们翻delegate的源码,我这里直接从官网复制到我自己的本地了
整体构造函数如下,其中创建实例的过程是典型的单例模式。然后下面的几个属性都是数组,用来存放代理的属性名。

 function Delegator(proto, target) {
   if (!(this instanceof Delegator)) return new Delegator(proto, target);
   this.proto = proto;
   this.target = target;
   this.methods = [];
   this.getters = [];
   this.setters = [];
   this.fluents = [];
 }


method是如何进行代理的呢,实现源码以及分析如下;

 Delegator.prototype.method = function(name){
   var proto = this.proto;
   var target = this.target;
//    存入 methods 数组
   this.methods.push(name);
//  以闭包的形式,将对proto方法的调用转为this[target]上相关方法的调用
   proto[name] = function(){
    //    applay改变this指向
     return this[target][name].apply(this[target], arguments);
   };
//    返回delegator实例对象,从而实现链式调用。
   return this;
 };


其中setter和getter和access的源码以及分析如下

   return this.getter(name).setter(name);
 };
 
 /**
  * Delegator getter `name`.
  *
  * @param {String} name
  * @return {Delegator} self
  * @api public
  */
 
 Delegator.prototype.getter = function(name){
   var proto = this.proto;
   var target = this.target;
   this.getters.push(name);//将属性名称存入的对应类型的数组
//  利用__defineGetter__设置proto的getter,使得proto[name]获取的是proto[target[name]]的值
   proto.__defineGetter__(name, function(){
     return this[target][name];
   });
// 返回delegator实例对象,从而实现链式调用。
   return this;
 };
 
 /**
  * Delegator setter `name`.
  *
  * @param {String} name
  * @return {Delegator} self
  * @api public
  */
 
 Delegator.prototype.setter = function(name){
   var proto = this.proto;
   var target = this.target;
   //将属性名称存入的对应类型的数组
   this.setters.push(name);
 //  利用__defineGetter__设置proto的setter,实现给proto[name]赋值时,实际改变的是proto[target[name]]的值
   proto.__defineSetter__(name, function(val){
     return this[target][name] = val;
   });
 // 返回delegator实例对象,实现链式调用。
   return this;
 };


3、request的具体实现

request里面都是一些get、set的操作,比如对header和url的get和set.

  get header () {
    return this.req.headers
  },

  /**
   * Set request header.
   *
   * @api public
   */

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

  /**
   * Return request header, alias as request.header
   *
   * @return {Object}
   * @api public
   */

  get headers () {
    return this.req.headers
  },

  /**
   * Set request header, alias as request.header
   *
   * @api public
   */

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

  /**
   * Get request URL.
   *
   * @return {String}
   * @api public
   */

  get url () {
    return this.req.url
  },

  /**
   * Set request URL.
   *
   * @api public
   */

  set url (val) {
    this.req.url = val
  },


4、response的实现

response的实现也都是各种get、set操作,稍微看看就行了。

结尾

到这里,我们就把koa源码看完了。后序还会手写一个简易版的koa,哈哈哈哈。整体来看,koa源码没有想象中那么难。然而koa在企业的项目应用中往往没有简单。学习源码是为了让我们学习别人的编程思想以及设计思路,然后让我们更加便捷的开发以及丰富自己的武器库。站在巨人的肩膀上,我们才能看的更远。

最后 热爱可抵岁月漫长,一起加油吧!