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源碼解析

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章