Koa引用庫之Koa-compose

wheato發表於2017-09-18

概述

compose 是一個工具函式,Koa.js 的中介軟體通過這個工具函式組合後,按 app.use() 的順序同步執行,也就是形成了 洋蔥圈 式的呼叫。

這個函式的原始碼不長,不到50行,程式碼地址 github.com/koajs/compo…

利用遞迴實現了 Promise 的鏈式執行,不管中介軟體中是同步還是非同步都通過 Promise 轉成非同步鏈式執行。

原始碼解讀

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!')
  }
  ...
}複製程式碼

函式開頭對引數做了型別的判斷,確保輸入的正確性。middleware 必須是一個陣列,陣列中的元素必須是 function

function compose (middleware) {
  //...

  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 {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}複製程式碼

接下來,是返回了一個函式,接受兩個引數,contextnextcontextkoa 中的 ctxnext 是所有中介軟體執行完後,框架使用者來最後處理請求和返回的回撥函式。同時函式是一個閉包函式,儲存了所有的中介軟體,通過遞迴的方式不斷的執行中介軟體。

通過程式碼可以看到,作為中介軟體同樣必須接受兩個引數, contextnext。如果某個中介軟體沒有呼叫 next() , 後面的中介軟體是不會執行的。這是非常常見的將多個非同步函式轉為同步的處理方式。

Middleware函式的寫法

直接看程式碼:

const compose = require('./compose')

function mw1 (context, next) {
  console.log('===== middleware 1 =====')
  console.log(context)
  setTimeout(() => {
    console.log(`inner: ${context}`)
    next()
  }, 1000)
}

function mw2 (context, next) {
  console.log('===== middleware 2 =====')
  console.log(context)
  next()
}

function mw3 (context, next) {
  console.log('===== middleware 3 =====')
  console.log(context)
  setTimeout(() => {
    console.log(`inner: ${context}`)
  }, 1000)
  next()
}

const run = compose([mw1, mw2, mw3])

run('context', function () {
  console.log('all middleware done!')
})複製程式碼

輸出結果是:

===== middleware 1 =====
context
inner: context
===== middleware 2 =====
context
===== middleware 3 =====
context
all middleware done!
inner: context複製程式碼

第三個中介軟體中,故意把 next() 寫在了非同步的外面,會導致中介軟體還完成就直接進入下一個中介軟體的執行了(這裡是所有中介軟體執行完後的回撥函式)。compose() 生成的函式是 thenable 函式,我們改一下最後的執行部分。

run('context').then(() => {
  console.log('all middleware done!')
})複製程式碼

結果是:

===== middleware 1 =====
context
all middleware done!
inner: context
===== middleware 2 =====
context
===== middleware 3 =====
context
inner: context複製程式碼

看起來結果不符合我們的預期,這是因為在 compose 原始碼中,中介軟體執行完後返回的是一個 Promise 物件,如果我們在 Promise 中再使用非同步函式並且不使用then 來處理非同步流程,顯然是不合理的,我們可以改一下上面的中介軟體程式碼。

function mw1 (context, next) {
  console.log('===== middleware 1 =====')
  console.log(context)
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`inner: ${context}`)
      resolve()
    }, 1000)
  }).then(() => {
    return next ()
  })
}

function mw2 (context, next) {
  console.log('===== middleware 2 =====')
  console.log(context)
  return next()
}

function mw3 (context, next) {
  console.log('===== middleware 3 =====')
  console.log(context)
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`inner: ${context}`)
      resolve()
    }, 1000)
  }).then(() => {
    return next ()
  })
}複製程式碼

輸出:

===== middleware 1 =====
context
inner: context
===== middleware 2 =====
context
===== middleware 3 =====
context
inner: context
all middleware done!複製程式碼

這下沒問題了,每一箇中介軟體都會返回一個 thenablePromise 物件。

既然是在研究Koa.js 那麼我們就把上面的程式碼再改改,使用 async/await 改寫一下,把非同步函式改成一個 thenable 函式。

async function sleep (context) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(`inner: ${context}`)
      resolve()
    }, 1000)
  })
}

async function mw1 (context, next) {
  console.log('===== middleware 1 =====')
  console.log(context)
  await sleep(context)
  await next()  
}

async function mw2 (context, next) {
  console.log('===== middleware 2 =====')
  console.log(context)
  return next()
}

async function mw3 (context, next) {
  console.log('===== middleware 3 =====')
  console.log(context)
  await sleep(context)
  await next ()
}複製程式碼

應用場景

在日常的開發中,Node 後臺一般是作為微服務架構中的一個面向終端的 API Gateway。
現在有這樣一個場景:我們從三個其他微服務中獲取資料再聚合成一個 HTTP API,如果三個服務提供的 service 沒有依賴的話,這種情況比較簡單,用 Promise.all() 就可以實現,程式碼如下:

function service1 () {
  return new Promise((resolve, reject) => {
    resolve(1)
  })
}

function service2 () {
  return new Promise((resolve, reject) => {
    resolve(2)
  })
}

function service3 () {
  return new Promise((resolve, reject) => {
    resolve(3)
  })
}

Promise.all([service1(), service2(), service3()])
  .then(res => {
    console.log(res)
  })複製程式碼

那如果 service2 的請求引數依賴 service1 返回的結果, service3 的請求引數又依賴於 Service2 返回的結果,那就得將一系列的非同步請求轉成同步請求,compose 就可以發揮其作用了,當然用 Promise 的鏈式呼叫也是可以實現的,但是程式碼耦合度高,不利於後期維護和程式碼修改,如果 1、2、3 的順序調換一下,程式碼改動就比較大了,另外耦合度太高的程式碼不利於單元測試,這裡有一個文章是通過依賴注入的方式解耦模組,保持模組的獨立性,便於模組的單元測試。

總結

Compose 是一種基於 Promise 的流程控制方式,可以通過這種方式對非同步流程同步化,解決之前的巢狀回撥和 Promise 鏈式耦合。

Promise 的流程控制有很多種,下篇文章再來寫不同應用場景中分別運用的方法。

相關文章