koa2 compose理解及模擬實現


介紹

Koa 是一個新的 web 框架,由 Express 幕后的原班人馬打造, 致力於成為 web 應用和 API 開發領域中的一個更小、更富有表現力、更健壯的基石。 通過利用 async 函數,Koa 幫你丟棄回調函數,並有力地增強錯誤處理。 Koa 並沒有捆綁任何中間件, 而是提供了一套優雅的方法,幫助您快速而愉快地編寫服務端應用程序。

學習koa-compose之前,先看一下這兩張圖

基本使用

const Koa = require('../../lib/application');

// const Koa = require('koa');
const app = new Koa();

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// logger

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

  • 創建一個跟蹤響應時間的日期
  • 等待下一個中間件的控制
  • 創建另一個日期跟蹤持續時間
  • 等待下一個中間件的控制
  • 將響應主體設置為“Hello World”
  • 計算持續時間
  • 輸出日志行
  • 計算響應時間
  • 設置 X-Response-Time 頭字段
  • 交給 Koa 處理響應

看完這個gif圖,也可以思考下如何實現的。根據表現,可以猜測是next是一個函數,而且返回的可能是一個promise,被await調用。

閱讀koa-compose源碼

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];

      // next的值為undefined,當沒有中間件的時候直接結束
      // 其實這里可以去掉next參數,直接在下面fn = void 0,和之前的代碼效果一樣
      // if (i === middleware.length) fn = void 0;
      if (i === middleware.length) fn = next;

      if (!fn) return Promise.resolve();

      try {
        // fn就是中間件函數, dipatch(i)調用的就是第i個中間件函數
        // eg :                app.use((ctx,next) => { next()})

        // 第 1 次 reduce 的返回值,下一次將作為 a
        // arg => fn1(() => fn2(arg));

        // 第 2 次 reduce 的返回值,下一次將作為 a
        //         arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));
        // 等價於...
        //         arg => fn1(() => fn2(() => fn3(arg)));

        // 執行最后返回的函數連接中間件,返回值等價於...
        // fn1(() => fn2(() => fn3(() => {})));
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

上面的代碼等價於

// 這樣就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = this.middleware;
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();
                  })
              )
          })
        )
    })
  );
};


fnMiddleware(ctx).then(handleResponse).catch(onerror);

也就是說koa-compose返回的是一個PromisePromise中取出第一個函數(app.use添加的中間件),傳入context和第一個next函數來執行。

第一個next函數里也是返回的是一個PromisePromise中取出第二個函數(app.use添加的中間件),傳入context和第二個next函數來執行。

第二個next函數里也是返回的是一個PromisePromise中取出第三個函數(app.use添加的中間件),傳入context和第三個next函數來執行。

第三個...

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

模擬實現

同步實現

文件app.js

// 模擬 Koa 創建的實例
class app {
    constructor(){
        this.middlewares = []
    }

    use(fn){
        this.middlewares.push(fn)
    }

    compose() {
        // 遞歸函數
        let self = this;
        function dispatch(index) {
             // 如果所有中間件都執行完跳出
             if (index === self.middlewares.length) return;
    
             // 取出第 index 個中間件並執行
             const midFn = self.middlewares[index];
             return midFn(() => dispatch(index + 1));
         }

         取出第一個中間件函數執行
         dispatch(0);
    }
};

module.exports = new app();

上面是同步的實現,通過遞歸函數 dispatch 的執行取出了數組中的第一個中間件函數並執行,在執行時傳入了一個函數,並遞歸執行了 dispatch,傳入的參數 +1,這樣就執行了下一個中間件函數,依次類推,直到所有中間件都執行完畢,不滿足中間件執行條件時,會跳出,這樣就按照上面案例中 1 3 5 6 4 2 的情況執行,測試例子如下(同步上、異步下)。

文件sync-test.js

const app = require("./app");

app.use(next => {
    console.log(1);
    next();
    console.log(2);
});

app.use(next => {
    console.log(3);
    next();
    console.log(4);
});

app.use(next => {
    console.log(5);
    next();
    console.log(6);
});

app.compose();
// 1
// 3
// 5
// 6
// 4
// 2

文件async-test.js

const app = require("./app");

// 異步函數
function fn() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
            console.log("hello");
        }, 3000);
    });
}

app.use(async next => {
    console.log(1);
    await next();
    console.log(2);
});

app.use(async next => {
    console.log(3);
    await fn(); // 調用異步函數
    await next();
    console.log(4);
});

app.use(async next => {
    console.log(5);
    await next();
    console.log(6);
});

app.compose();
// 1
// 3
// hello
// 5
// 6
// 4
// 2

我們發現如果案例中按照 Koa 的推薦寫法,即使用 async 函數,都會通過,但是在給 use 傳參時可能會傳入普通函數或 async 函數,我們要將所有中間件的返回值都包裝成 Promise 來兼容兩種情況,其實在 Koacompose 最后返回的也是 Promise,是為了后續的邏輯的編寫,但是現在並不支持,下面來解決這兩個問題。

注意:后面 compose 的其他實現方式中,都是使用 sync-test.js 和 async-test.js 驗證,所以后面就不再重復了。

升級為異步,其實就是koa-compose的實現(簡化版)
compose() {
        // 遞歸函數
        let self = this;
        function dispatch(index) {
            // 異步實現
            // 如果所有中間件都執行完跳出,並返回一個 Promise
            if (index === self.middlewares.length) return Promise.resolve();

            // 取出第 index 個中間件並執行
            const route = self.middlewares[index];

            // 執行后返回成功態的 Promise
            return Promise.resolve(route(() => dispatch(index + 1)));
        }

        // 取出第一個中間件函數執行
        dispatch(0);
}

我們知道 async 函數中 await 后面執行的異步代碼要實現等待,待異步執行后繼續向下執行,需要等待 Promise,所以我們將每一個中間件函數在調用時最后都返回了一個成功態的 Promise,使用 async-test.js進行測試,發現結果為 1 3 hello(3s后) 5 6 4 2

reduceRight實現(Redux舊版使用逆序歸並)
  • 同步實現
compose () {
    return self.middlewares.reduceRight((a, b) => () => b(a), () => {})();
};

上面的代碼看起來不太好理解,我們不妨根據案例把這段代碼拆解開,假設 middlewares 中存儲的三個中間件函數分別為 fn1fn2fn3
由於使用的是 reduceRight 方法,所以是逆序歸並,第一次 a 代表初始值(空函數),b代表fn3,而執行 fn3 返回了一個函數,這個函數再作為下一次歸並的 a,而 fn2作為b`,依次類推,過程如下:

// 第 1 次 reduceRight 的返回值,下一次將作為 a
() => fn3(() => {});

// 第 2 次 reduceRight 的返回值,下一次將作為 a
() => fn2(() => fn3(() => {}));

// 第 3 次 reduceRight 的返回值,下一次將作為 a
() => fn1(() => fn2(() => fn3(() => {})));

由上面的拆解過程可以看出,如果我們調用了這個函數會先執行 fn1,如果調用 next 則會執行 fn2,如果同樣調用 next 則會執行 fn3fn3 已經是最后一個中間件函數了,再次調 next 會執行我們最初傳入的空函數,這也是為什么要將 reduceRight初始值設置成一個空函數,就是防止最后一個中間件調用 next 而報錯。經過測試上面的代碼不會出現順序錯亂的情況,但是在 compose 執行后,我們希望進行一些后續的操作,所以希望返回的是 Promise,而我們又希望傳入給 use 的中間件函數既可以是普通函數,又可以是 async 函數,這就要我們的 compose 完全支持異步

  • 異步實現
compose() {
    // reduceRight, 逆序歸並
    return Promise.resolve(
        self.middlewares.reduceRight(
            (a, b) => () => Promise.resolve(b(a)),
            () => Promise.resolve()
        )()
    )
}

參考同步的分析過程,由於最后一個中間件執行后執行的空函數內一定沒有任何邏輯,但為遇到異步代碼可以繼續執行(比如執行 next 后又調用了 then),都處理成了 Promise,保證了 reduceRight 每一次歸並的時候返回的函數內都返回了一個 Promise,這樣就完全兼容了 async普通函數,當所有中間件執行完畢,也返回了一個 Promise,這樣 compose 就可以調用 then 方法執行后續邏輯。

reduce(Redux新版使用正序歸並)
  • 同步實現
compose () {
    return self.middlewares.reduce((a, b) => arg => a(() => b(arg)))(() => {});
};

Redux 新版本中將 compose 的邏輯做了些改動,將原本的 reduceRight 換成 reduce,也就是說將逆序歸並改為了正序,我們不一定和 Redux 源碼完全相同,
是根據相同的思路來實現串行中間件的需求。個人覺得改成正序歸並后更難理解,所以還是將上面代碼結合案例進行拆分,中間件依然是 fn1fn2fn3,由於reduce並沒有傳入初始值,所以此時 afn1,b 為 fn2

// 第 1 次 reduce 的返回值,下一次將作為 a
arg => fn1(() => fn2(arg));

// 第 2 次 reduce 的返回值,下一次將作為 a
arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));

// 等價於...
arg => fn1(() => fn2(() => fn3(arg)));

// 執行最后返回的函數連接中間件,返回值等價於...
fn1(() => fn2(() => fn3(() => {})));

所以在調用 reduce 最后返回的函數時,傳入了一個空函數作為參數,其實這個參數最后傳遞給了 fn3,也就是第三個中間件,這樣保證了在最后一個中間件調用 next 時不會報錯。

  • 異步實現
compose() {
      // reduce版本
      return Promise.resolve(
          self.middlewares.reduce((a, b) => arg =>
              Promise.resolve(a(() => b(arg)))
          )(() => Promise.resolve())
      );
}
使用async函數實現(僅記錄)
compose() {
     return (async function () {
    // 定義默認的 next,最后一個中間件內執行的 next
    let next = async () => Promise.resolve();

    // middleware 為每一個中間件函數,oldNext 為每個中間件函數中的 next
    // 函數返回一個 async 作為新的 next,async 執行返回 Promise,解決異步問題
    function createNext(middleware, oldNext) {
        return async () => {
            await middleware(oldNext);
        }
    }

    // 反向遍歷中間件數組,先把 next 傳給最后一個中間件函數
    // 將新的中間件函數存入 next 變量
    // 調用下一個中間件函數,將新生成的 next 傳入
    for (let i = self.middlewares.length - 1; i >= 0; i--) {
        next = createNext(self.middlewares[i], next);
    }

    await next();
})();
}

參考文章

KOA2 compose 串聯中間件實現(洋蔥模型)
學習 koa 源碼的整體架構,淺析koa洋蔥模型原理和co原理


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM