• Node.js躬行记(19)——KOA源码分析(上)


      本次分析的KOA版本是2.13.1,它非常轻量,诸如路由、模板等功能默认都不提供,需要自己引入相关的中间件。

      源码的目录结构比较简单,主要分为3部分,__tests__,lib和docs,从名称中就可以了解到。

      __tests__是单元测试,lib是核心代码,docs是文档。在lib目录中只有4个文件。

    ├── __tests__ ------------------------ 单元测试
    ├── docs ----------------------------- 文档
    ├── lib ------------------------------ 源码目录
    │   ├── application.js --------------- 运行
    │   ├── context.js ------------------- 上下文
    │   ├── request.js ------------------- 请求
    │   ├── response.js ------------------ 响应

      阅读源码除了能学到不经常使用的概念之外,还能学到各种软件开发思路,见识到各种类型的第三方库,对于提升自己的日常编码很有帮助。

    一、package.json

      在package.json文件中,可以看到KOA的入口是 application.js。

    "main": "lib/application.js",

      在devDependencies中,依赖的是ESLint和单元测试库。

      在dependencies中,好几个都是与通信有关的库,还有几个工具库,具体包括:

    • accepts:为给定的 req 创建一个新的 Accepts 对象。
    • cache-content-type:与 mime-types 的 contentType 方法相同,但缓存了结果。
    • content-disposition:创建和解析 HTTP Content-Disposition 头。
    • content-type:根据 RFC 7231 创建和解析 HTTP Content-Type 头。
    • cookies:一个用于获取和设置 HTTP(S) cookie 的 node.js 模块。
    • debug:一个模仿 Node.js 核心调试技术的小型 JavaScript 调试实用程序。
    • delegates:创建一个委托实例,让一个对象可以直接访问其属性对象的属性和方法(在下一篇中会详细说明)。
    • destroy销毁一个流,确保流被销毁,处理不同的 API 和 Node.js 错误。
    • encodeurl:将 URL 编码为百分比编码形式,不包括已编码的序列。
    • escape-html:将特殊字符转换成HTML实体。例如 foo & bar =》foo & bar。
    • fresh:HTTP 响应新鲜度测试。
    • http-assert状态码断言,像 Koa 中的 ctx.throw() 一样,但是有一个守卫。
    • http-errors:为 Express、Koa、Connect 等创建 HTTP 错误。
    • koa-compose:组合给定的中间件,KOA的插件。
    • on-finished:当 HTTP 请求关闭、完成或出错时执行回调。
    • only:指定属性白名单,然后只返回这几个属性。
    • parseurl解析给定请求对象的 URL(req.url 属性)并返回结果,结果与 url.parse 相同。在 req.url 不变的同一个 req 上多次调用此函数将返回一个缓存的解析对象。
    • statuses:返回已知 HTTP 状态代码的状态消息字符串。
    • type-is:检查请求的内容类型是否是 content-type 中的一种类型。
    • vary:将给定的头字段添加到 res 的 Vary 响应头中。

    二、application.js

      application.js是KOA的入口文件,在此文件中,会引入lib目录的另外3个文件,以及多个依赖库。

    const debug = require('debug')('koa:application')
    const onFinished = require('on-finished')
    const response = require('./response')
    const compose = require('koa-compose')
    const context = require('./context')
    const request = require('./request')
    const statuses = require('statuses')
    const Emitter = require('events')
    const util = require('util')
    const Stream = require('stream')
    const http = require('http')
    const only = require('only')
    const { HttpError } = require('http-errors')

      在下面的代码中,去掉了大部分的方法体,只留下了方法名和注释。其中Application继承自Emitter,这样就能监听和触发自定义事件了。

    /**
     * 继承自 Emitter.prototype
     */
    module.exports = class Application extends Emitter {
      constructor (options) { }
      /**
       * 简写:
       *    http.createServer(app.callback()).listen(...)
       */
      listen (...args) { }
      /**
       * JSON格式化
       */
      toJSON () { return only(this, ['subdomainOffset', 'proxy', 'env']) }
      /**
       * Inspect implementation.
       */
      inspect () { return this.toJSON() }
      /**
       * 使用给定的中间件 fn
       */
      use (fn) { }
      /**
       * 请求处理程序回调,用于本机 http 服务器
       */
      callback () { }
      /**
       * 在回调中处理请求
       */
      handleRequest (ctx, fnMiddleware) { }
      /**
       * 初始化一个新的上下文
       */
      createContext (req, res) { }
      /**
       * 默认错误处理程序
       */
      onerror (err) { }
      /**
       * 帮助 TS 用户遵守 CommonJS、ESM、bundler mismatch
       * @see https://github.com/koajs/koa/issues/1513
       */
      static get default () { return Application }
    }
    /**
     * 响应助手
     */
    function respond (ctx) { }
    /**
     * 使库的消费者可以使用 HttpError,这样消费者就不会直接依赖于 `http-errors`
     */
    module.exports.HttpError = HttpError

      在看过源码后,再来阅读一段简单的demo,在初始化KOA实例后,调用了Application的 use() 和 listen() 两个方法。

    const Koa = require("koa");
    const app = new Koa();
    app.use(async (ctx, next) => {
      ctx.body = "hello,KOA";
    });
    app.listen(3000);

    1)构造函数

      在构造函数中,会声明各种参数,包括代理信息、环境变量等。

      其中Object.create()用于创建一个新对象,带着指定的原型对象和属性。

      因为在同一个应用中可能会有多个KOA实例,所以为了防止相互污染,通过Object.create()的拷贝将他们不再引用同一个地址。

      constructor (options) {
        super()
        options = options || {} // 参数
        this.proxy = options.proxy || false// 是否代码模式
        this.subdomainOffset = options.subdomainOffset || 2
        this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For' // 代理 IP 头,默认为 X-Forwarded-For
        this.maxIpsCount = options.maxIpsCount || 0 // 从代理 IP 标头读取的最大 IP,默认为 0(表示无穷大)
        this.env = options.env || process.env.NODE_ENV || 'development' // 环境变量
        if (options.keys) this.keys = options.keys
        this.middleware = []
        this.context = Object.create(context) // 创建一个新的context
        this.request = Object.create(request)// 创建一个新的request
        this.response = Object.create(response)// 创建一个新的response
        // util.inspect.custom support for node 6+
        /* istanbul ignore else */
        if (util.inspect.custom) {
          this[util.inspect.custom] = this.inspect
        }
      }

    2)use()

      在KOA实例中,会维护一个中间件数组(middleware),在添加fn之前,会利用typeof判断其是否是函数类型。

      use (fn) {
        if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
        this.middleware.push(fn)
        return this
      }

      KOA的中间件采用的是著名的洋葱模型,后面会细说。

    3)listen()

      listen()内部直接调用http.createServer()创建一个server,监听指定端口,并且每个请求都会回调当前实例的callback()方法。

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

      在callback()方法中,会调用洋葱模型的compose()函数,监听error事件(回调error()函数),最后处理请求调用handleRequest()方法。

      callback () {
        // 包装所有的中间件,返回一个可执行函数,compose()是洋葱模型的实现
        const fn = compose(this.middleware)
        // 若未指定error事件,那么创建error事件监听器
        if (!this.listenerCount('error')) {
          this.on('error', this.onerror)
        }
        const handleRequest = (req, res) => {
          // 为ctx包装Node原生的req和res,并且每个请求都是单独的ctx
          const ctx = this.createContext(req, res)
          // 实例的handleRequest(),并不是递归
          return this.handleRequest(ctx, fn)
        }
        return handleRequest
      }

    4)compose()

      中间件通常用于完成一些全局的特定功能,例如权限验证、错误处理、日志添加等。

      下面是一个简单的中间件示例,用于处理500响应。

    export default () => async (ctx, next) => {
      try {
        await next();
      } catch (error) {
        ctx.status = 500;
        ctx.body = { error: String(error), stack: error.stack };
      }
    };

      compose()引用自koa-compose库,在该库中,中间件会被next()函数分成两部分,先执行next()之前的部分,在请求处理完毕后,再执行next()后面的部分。

      下图是官方给的一张中间件执行顺序示意图。

      

      在下图中,每一层相当于是一个中间件,在request时,处理的是next()的前半部分,在response时,处理的是其后半部分。

      

      下面就是koa-compose库的所有代码,已加注释,为了便于理解,我已经将可执行的代码放到codepen中,在线调试。

    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!')
      }
      /**
      * 返回一个函数
      * context就是ctx
      * next()函数就是下一个中间件函数
      */
      return function (context, next) {
        // 上一个中间件的索引
        let index = -1
        // 启动dispatch()函数,初始值是0
        return dispatch(0)
        function dispatch (i) {
          // 以免在一个中间件内,调用多次next()
          if (i <= index) return Promise.reject(new Error('next() called multiple times'))
          index = i
          // fn就是中间件函数
          let fn = middleware[i]
          // 中间件都已执行过一次,fn是undefined
          if (i === middleware.length) fn = next
          // 终止递归
          if (!fn) return Promise.resolve()
          try {
            // fn是中间件,dispatch()就是下一个中间件的next()函数
            return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
          } catch (err) {
            return Promise.reject(err)
          }
        }
      }
    }

      函数分为几步:

    • 第一步是检查中间件数组和中间件的类型。
    • 第二步是返回一个函数,参数是 ctx 和 next(),其中 next() 就是下一个中间件函数。
    • 第三步是调用 dispatch(0) 启动中间件的运行,并且在一个中间件中,不允许多次调用 next() 函数。
    • 第四步是递归地依次为每一个要执行的中间件传递参数,其第二个参数是下一个 dispatch() 函数。

      递归过程中的 dispatch() 其实就是中间件中的 next() 函数。

      Promise.resolve(fn(context, dispatch.bind(null, i + 1))) 会先运行一次中间件,然后遇到 next(),就去运行下一个中间件,递归终止后,再回溯处理中间件余下的逻辑。

    5)createContext()

      每次HTTP请求都生成一个新的context,与其他请求中的context之间相互隔离。

      createContext (req, res) {
        // 每次HTTP请求都生成一个新的context
        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
        // 挂载Node原生的req和res
        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
        // 可自定义的状态,例如koa-jwt库就使用了该属性
        context.state = {}
        return context
      }

      context具备高内聚的特征,因为它能访问KOA提供的所有数据和方法。

      并且还预留了一个state属性,可用于传递自定义的状态值。

    6)handleRequest()

      在 handleRequest() 函数中,会运行中间件函数,以及处理响应的不同情况。

      /**
       * 在回调中处理请求
       * @param {*} ctx 上下文
       * @param {*} fnMiddleware 可执行的中间件函数
       * @returns
       */
      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)
      }

      respond()函数内容比较多,包括为格式化JSON格式的body,流类型的body调用pipe(),为HEAD请求加 Content-Length 头等。

      官方也提供了属性,来绕开上述这些处理。

    function respond (ctx) {
      // 允许绕过KOA的处理
      if (ctx.respond === false) return
    
      if (!ctx.writable) return
    
      const res = ctx.res
      let body = ctx.body
      const code = ctx.status
    
      // code不是已知的状态码
      if (statuses.empty[code]) {
        // strip headers
        ctx.body = null
        return res.end()
      }
      // HEAD请求
      if (ctx.method === 'HEAD') {
        // 加Content-Lengthh头
        if (!res.headersSent && !ctx.response.has('Content-Length')) {
          const { length } = ctx.response
          if (Number.isInteger(length)) ctx.length = length
        }
        return res.end()
      }
    
      // status body
      if (body == null) {
        if (ctx.response._explicitNullBody) {
          ctx.response.remove('Content-Type')
          ctx.response.remove('Transfer-Encoding')
          ctx.length = 0
          return res.end()
        }
        if (ctx.req.httpVersionMajor >= 2) {
          body = String(code)
        } else {
          body = ctx.message || String(code)
        }
        if (!res.headersSent) {
          ctx.type = 'text'
          ctx.length = Buffer.byteLength(body)
        }
        return res.end(body)
      }
    
      // 对body的三种类型采用不同的处理
      if (Buffer.isBuffer(body)) return res.end(body)
      if (typeof body === 'string') return res.end(body)
      if (body instanceof Stream) return body.pipe(res)
    
      // JSON格式的body
      body = JSON.stringify(body)
      if (!res.headersSent) {
        ctx.length = Buffer.byteLength(body)
      }
      res.end(body)
    }

    参考资料:

    koa源码解析

    高质量 - Koa 源码解析

    超级详细的koa源码解析

  • 相关阅读:
    HDU
    HDU
    西电OJ
    西电OJ
    西电OJ
    USACO 2.1-Healthy Holsteins
    USACO 2.1-Sorting a Three-Valued Sequence
    HDU
    UVA
    codeforces 811A Vladik and Courtesy
  • 原文地址:https://www.cnblogs.com/strick/p/16178207.html
Copyright © 2020-2023  润新知