Stories

Detail Return Return

50行代碼串行Promise,koa洋葱模型原來是這麼實現? - Stories Detail

1. 前言

大家好,我是若川。歡迎關注我的公眾號若川視野,最近組織了源碼共讀活動《1個月,200+人,一起讀了4周源碼》,感興趣的可以加我微信 ruochuan12 參與,長期交流學習。

之前寫的《學習源碼整體架構系列》 包含jQueryunderscorelodashvuexsentryaxiosreduxkoavue-devtoolsvuex4十餘篇源碼文章。其中最新的兩篇是:

Vue 3.2 發佈了,那尤雨溪是怎麼發佈 Vue.js 的?

初學者也能看懂的 Vue3 源碼中那些實用的基礎工具函數

寫相對很難的源碼,耗費了自己的時間和精力,也沒收穫多少閲讀點贊,其實是一件挺受打擊的事情。從閲讀量和讀者受益方面來看,不能促進作者持續輸出文章。

所以轉變思路,寫一些相對通俗易懂的文章。其實源碼也不是想象的那麼難,至少有很多看得懂

之前寫過 koa 源碼文章學習 koa 源碼的整體架構,淺析koa洋葱模型原理和co原理比較長,讀者朋友大概率看不完,所以本文從koa-compose50行源碼講述。

本文涉及到的 koa-compose 倉庫 文件,整個index.js文件代碼行數雖然不到 50 行,而且測試用例test/test.js文件 300 餘行,但非常值得我們學習。

歌德曾説:讀一本好書,就是在和高尚的人談話。 同理可得:讀源碼,也算是和作者的一種學習交流的方式。

閲讀本文,你將學到:

1. 熟悉 koa-compose 中間件源碼、可以應對面試官相關問題
2. 學會使用測試用例調試源碼
3. 學會 jest 部分用法

2. 環境準備

2.1 克隆 koa-compose 項目

本文倉庫地址 koa-compose-analysis,求個star~

# 可以直接克隆我的倉庫,我的倉庫保留的 compose 倉庫的 git 記錄
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose/compose
npm i

順帶説下:我是怎麼保留 compose 倉庫的 git 記錄的。

# 在 github 上新建一個倉庫 `koa-compose-analysis` 克隆下來
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose-analysis
git subtree add --prefix=compose https://github.com/koajs/compose.git main
# 這樣就把 compose 文件夾克隆到自己的 git 倉庫了。且保留的 git 記錄

關於更多 git subtree,可以看這篇文章用 Git Subtree 在多個 Git 項目間雙向同步子項目,附簡明使用手冊

接着我們來看怎麼根據開源項目中提供的測試用例調試源碼。

2.2 根據測試用例調試 compose 源碼

VSCode(我的版本是 1.60 )打開項目,找到 compose/package.json,找到 scriptstest 命令。

// compose/package.json
{
    "name": "koa-compose",
    // debug (調試)
    "scripts": {
        "eslint": "standard --fix .",
        "test": "jest"
    },
}

scripts上方應該會有debug或者調試字樣。點擊debug(調試),選擇 test

VSCode 調試

接着會執行測試用例test/test.js文件。終端輸出如下圖所示。

koa-compose 測試用例輸出結果

接着我們調試 compose/test/test.js 文件。
我們可以在 45行 打上斷點,重新點擊 package.json => srcipts => test 進入調試模式。
如下圖所示。

koa-compose 調試

接着按上方的按鈕,繼續調試。在compose/index.js文件中關鍵的地方打上斷點,調試學習源碼事半功倍。

更多 nodejs 調試相關 可以查看官方文檔

順便詳細解釋下幾個調試相關按鈕。

    1. 繼續(F5): 點擊後代碼會直接執行到下一個斷點所在位置,如果沒有下一個斷點,則認為本次代碼執行完成。
    1. 單步跳過(F10):點擊後會跳到當前代碼下一行繼續執行,不會進入到函數內部。
    1. 單步調試(F11):點擊後進入到當前函數的內部調試,比如在 compose 這一行中執行單步調試,會進入到 compose 函數內部進行調試。
    1. 單步跳出(Shift + F11):點擊後跳出當前調試的函數,與單步調試對應。
    1. 重啓(Ctrl + Shift + F5):顧名思義。
    1. 斷開鏈接(Shift + F5):顧名思義。

接下來,我們跟着測試用例學源碼。

3. 跟着測試用例學源碼

分享一個測試用例小技巧:我們可以在測試用例處加上only修飾。

// 例如
it.only('should work', async () => {})

這樣我們就可以只執行當前的測試用例,不關心其他的,不會干擾調試。

3.1 正常流程

打開 compose/test/test.js 文件,看第一個測試用例。

// compose/test/test.js
'use strict'

/* eslint-env jest */

const compose = require('..')
const assert = require('assert')

function wait (ms) {
  return new Promise((resolve) => setTimeout(resolve, ms || 1))
}
// 分組
describe('Koa Compose', function () {
  it.only('should work', async () => {
    const arr = []
    const stack = []

    stack.push(async (context, next) => {
      arr.push(1)
      await wait(1)
      await next()
      await wait(1)
      arr.push(6)
    })

    stack.push(async (context, next) => {
      arr.push(2)
      await wait(1)
      await next()
      await wait(1)
      arr.push(5)
    })

    stack.push(async (context, next) => {
      arr.push(3)
      await wait(1)
      await next()
      await wait(1)
      arr.push(4)
    })

    await compose(stack)({})
    // 最後輸出數組是 [1,2,3,4,5,6]
    expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))
  })
}

大概看完這段測試用例,context是什麼,next又是什麼。

koa的文檔上有個非常代表性的中間件 gif 圖。

中間件 gif 圖

compose函數作用就是把添加進中間件數組的函數按照上面 gif 圖的順序執行。

3.1.1 compose 函數

簡單來説,compose 函數主要做了兩件事情。

    1. 接收一個參數,校驗參數是數組,且校驗數組中的每一項是函數。
    1. 返回一個函數,這個函數接收兩個參數,分別是contextnext,這個函數最後返回Promise
/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */
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){
      // 省略,下文講述
    }
  }
}

接着我們來看 dispatch 函數。

3.1.2 dispatch 函數

function dispatch (i) {
  // 一個函數中多次調用報錯
  // await next()
  // await next()
  if (i <= index) return Promise.reject(new Error('next() called multiple times'))
  index = i
  // 取出數組裏的 fn1, fn2, fn3...
  let fn = middleware[i]
  // 最後 相等,next 為 undefined
  if (i === middleware.length) fn = next
  // 直接返回 Promise.resolve()
  if (!fn) return Promise.resolve()
  try {
    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
  } catch (err) {
    return Promise.reject(err)
  }
}

值得一提的是:bind函數是返回一個新的函數。第一個參數是函數裏的this指向(如果函數不需要使用this,一般會寫成null)。
這句fn(context, dispatch.bind(null, i + 1)i + 1 是為了 let fn = middleware[i]middleware中的下一個函數。
也就是 next 是下一個中間件裏的函數。也就能解釋上文中的 gif圖函數執行順序。
測試用例中數組的最終順序是[1,2,3,4,5,6]

3.1.3 簡化 compose 便於理解

自己動手調試之後,你會發現 compose 執行後就是類似這樣的結構(省略 try catch 判斷)。

// 這樣就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = stack;
const fnMiddleware = function(context){
    return Promise.resolve(
      fn1(context, function next(){
        return Promise.resolve(
          fn2(context, function next(){
              return Promise.resolve(
                  fn3(context, function next(){
                    return Promise.resolve();
                  })
              )
          })
        )
    })
  );
};
也就是説koa-compose返回的是一個Promise,從中間件(傳入的數組)中取出第一個函數,傳入context和第一個next函數來執行。

第一個next函數裏也是返回的是一個Promise,從中間件(傳入的數組)中取出第二個函數,傳入context和第二個next函數來執行。

第二個next函數裏也是返回的是一個Promise,從中間件(傳入的數組)中取出第三個函數,傳入context和第三個next函數來執行。

第三個...

以此類推。最後一箇中間件中有調用next函數,則返回Promise.resolve。如果沒有,則不執行next函數。
這樣就把所有中間件串聯起來了。這也就是我們常説的洋葱模型。

洋葱模型圖如下圖所示:

不得不説非常驚豔,“玩還是大神會玩”

3.2 錯誤捕獲

it('should catch downstream errors', async () => {
  const arr = []
  const stack = []

  stack.push(async (ctx, next) => {
    arr.push(1)
    try {
      arr.push(6)
      await next()
      arr.push(7)
    } catch (err) {
      arr.push(2)
    }
    arr.push(3)
  })

  stack.push(async (ctx, next) => {
    arr.push(4)
    throw new Error()
  })

  await compose(stack)({})
  // 輸出順序 是 [ 1, 6, 4, 2, 3 ]
  expect(arr).toEqual([1, 6, 4, 2, 3])
})

相信理解了第一個測試用例和 compose 函數,也是比較好理解這個測試用例了。這一部分其實就是對應的代碼在這裏。

try {
    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
  return Promise.reject(err)
}

3.3 next 函數不能調用多次

it('should throw if next() is called multiple times', () => {
  return compose([
    async (ctx, next) => {
      await next()
      await next()
    }
  ])({}).then(() => {
    throw new Error('boom')
  }, (err) => {
    assert(/multiple times/.test(err.message))
  })
})

這一塊對應的則是:

index = -1
dispatch(0)
function dispatch (i) {
  if (i <= index) return Promise.reject(new Error('next() called multiple times'))
  index = i
}

調用兩次後 iindex 都為 1,所以會報錯。

compose/test/test.js文件中總共 300餘行,還有很多測試用例可以按照文中方法自行調試。

4. 總結

雖然koa-compose源碼 50行 不到,但如果是第一次看源碼調試源碼,還是會有難度的。其中混雜着高階函數、閉包、Promisebind等基礎知識。

通過本文,我們熟悉了 koa-compose 中間件常説的洋葱模型,學會了部分 jest 用法,同時也學會了如何使用現成的測試用例去調試源碼。

相信學會了通過測試用例調試源碼後,會覺得源碼也沒有想象中的那麼難

開源項目,一般都會有很全面的測試用例。除了可以給我們學習源碼調試源碼帶來方便的同時,也可以給我們帶來的啓發:自己工作中的項目,也可以逐步引入測試工具,比如 jest

此外,讀開源項目源碼是我們學習業界大牛設計思想和源碼實現等比較好的方式。

看完本文,非常希望能自己動手實踐調試源碼去學習,容易吸收消化。另外,如果你有餘力,可以繼續看我的 koa-compose 源碼文章:學習 koa 源碼的整體架構,淺析koa洋葱模型原理和co原理

user avatar zaotalk Avatar linlinma Avatar freeman_tian Avatar jingdongkeji Avatar razyliang Avatar inslog Avatar anchen_5c17815319fb5 Avatar hard_heart_603dd717240e2 Avatar u_17443142 Avatar dunizb Avatar jiavan Avatar xiaolei_599661330c0cb Avatar
Favorites 123 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.