1. 程式人生 > 程式設計 >Koa 梳理分析【二:非同步中介軟體】

Koa 梳理分析【二:非同步中介軟體】

部落格原文地址 歡迎交流、star

上一章koa例項建立和處理請求的流程梳理了一遍,其中很多細節沒有分析,比如生成洋蔥中介軟體的compose函式,contextrequestresponed物件是怎麼構建的等。這一章就來梳理一下這些細節,學習koa的思想和程式設計技巧。

洋蔥模型中介軟體

在原始碼中,是引入了koa-compose工具函式來處理中介軟體的,最終合併成一個。

const fn = compose(this.middleware);
複製程式碼

先看一下compose的簡單結構:

function compose (middleware) {
  ...
  return function
(context,next)
{ ... } } 複製程式碼

可以看到compose函式是一個接受middleware中間陣列並返回一個入參為contextnext的函式。這裡在koa原始碼中把這個返回的函式稱作為fnMiddleware,它的外部呼叫形式為:

fnMiddleware(ctx).then(handleResponse).catch(onerror);
複製程式碼

koa中呼叫的時候傳入一個ctxnext並沒有傳入。可以看到這個函式的返回值是一個promise。接下來來看看它的內部實現。

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,dispatch.bind(null,i + 1))); } catch (err) { return Promise.reject(err) } } } } 複製程式碼
  1. 首先宣告一個變數index用來記錄當前準備執行的中介軟體。
  2. 宣告dispatch函式,接受一個下標,用來執行物件下標的中介軟體。
  3. 執行第一個中介軟體。dispatch(0)

函式內部的核心在於dispatch函式,這個函式的主要幾個步驟如下:

  1. 根據傳入的下標獲取對應的中介軟體函式,這裡會對下標的邊界做一些相容和查錯。

  2. 使用Promise.resolve決策一個promise,這個promise就是中介軟體執行後的返回值。例如我們有個中介軟體如下:

app.use(async function (ctx,next) {
  return null
})
複製程式碼

一個async函式已經會返回一個promise,那麼在dispatch函式內部為什麼還需要使用Promise.resolve去包裹呢?因為我們傳入的中介軟體有可能就是普通的函式,所有這裡是做了一個相容。

  1. 重點放在fn(context,dispatch.bind(null,i + 1)),被執行的中介軟體,傳入了context物件,這裡是直接傳入,這就是為什麼所有的中介軟體用到的ctx是全域性唯一同一個引用。第二個引數就是next函式,這裡把dispatch綁定了下一個下標作為引數傳入。如果我們在我們的中介軟體中不執行next函式,也就是沒有呼叫dispatch(i+1),下一個中介軟體也就不會被執行了。

  2. 使用try/catch來包裹中介軟體的執行,有錯誤就直接返回一個Promise.reject

相容生成器函式

在看koa的原始碼的時候,可以看到在使用use新增中介軟體的時候,會先對函式進行判斷,目前2.*的版本下,會將生成器函式轉成async/await函式。

// use
if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
}
複製程式碼

首先簡單的瞭解一下generator函式。

特點:一是,function關鍵字與函式名之間有一個星號;二是,函式體內部使用yield表示式,定義不同的內部狀態(yield在英語裡的意思就是“產出”)。(引用自《ECMAScript 6 入門》) 當執行生產器的時候,會獲取一個遍歷物件,通過呼叫物件的next()方法就會返回一個有著 value 和done兩個屬性的物件{ value: x,done: true/false }value 屬性表示當前的內部狀態的值,是 yield 表示式後面那個表示式的值;done 屬性是一個布林值,表示是否遍歷結束。

簡單的介紹之後,可以看到generator的執行,需要通過不停的呼叫next()函式,但是async/await是可以自動執行的。所以想要將generator轉成類async/await就需要有個輔助方法來自動執行generatornext函式。

這裡舉這樣的例子(來自可能是目前最全的koa原始碼解析指南)。

function* gen() {
  yield new Promise(function (resolve,reject) {
    // 做一些非同步操作
    if (true) { // 成功
      resolve()
    } else {
      reject()
    }
  })

  yield new Promise(function (resolve,reject) {
    // 做一些非同步操作
    if (true) { // 成功
      resolve()
    } else {
      reject()
    }
  })
}

let g = gen()
let ret = g.next() // 拿到第一個`promise`
複製程式碼

怎麼讓next()繼續執行下去呢?看看如下程式碼。

let p = ret.value // 第一個 promise
p.then(() => { g.next() })
複製程式碼

如上,只需要使用一定的方式在每一個promise的決策中再次呼叫next方法,直到生成器被執行完。 如果想通過上面的方式去實現轉換,最終要的一步就是使每一個yield後面返回都應該是一個promise

koa裡面的convert函式,最終是呼叫的是co這庫來進行轉換的,所以來看看它是怎麼處理的。

function co(gen) {
  var ctx = this;
  var args = slice.call(arguments,1);
  ...
  return new Promise(function(resolve,reject) {
    if (typeof gen === 'function') gen = gen.apply(ctx,args);
    if (!gen || typeof gen.next !== 'function') return resolve(gen);

    onFulfilled();
    ...
    function onFulfilled(res) {
      var ret;
      try {
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(ret);
      return null;
    }
    ...
    function onRejected(err) {
      var ret;
      try {
        ret = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }
    ...
    function next(ret) {
      if (ret.done) return resolve(ret.value);
      var value = toPromise.call(ctx,ret.value);
      if (value && isPromise(value)) return value.then(onFulfilled,onRejected);
      return onRejected(new TypeError('You may only yield a function,promise,generator,array,or object,'
        + 'but the following object was passed: "' + String(ret.value) + '"'));
    }
  });
}
複製程式碼
  1. 整個函式返回一個promise物件,這與async/await一致。
  2. 在返回的promise中,首先判斷是否為可執行的生成器函式,然後呼叫函式,獲取到遍歷物件
  3. 然後第一次手動執行onFulfilled函式,這個函式就是來呼叫next()方法的。
  4. 宣告onRejected函式,主要用來呼叫生成器的throw()方法來結束執行和報錯的。
  5. 在3、4步驟中,只要呼叫了g.next()方法,最終都會呼叫co自己宣告的next函式,這個函式的主要工作就是將給promisevalue轉成promise,然後再在promise的下一個非同步去呼叫onFulfilledonRejected函式,以此往復。

以上就是如何把generator函式轉為類async的邏輯了。

每個請求的獨立context

在閱讀原始碼的過程中,在處理請求的回撥函式中, 都會呼叫createContext函式來建立一個獨立且整個處理過程中唯一的context物件。

const handleRequest = (req,res) => {
      const ctx = this.createContext(req,res);
      return this.handleRequest(ctx,fn);
}
複製程式碼

在中介軟體處理請求的時候,上一章說過,他們是共享這個context物件的,在處理完之後統一交給response物件去將結果響應給請求方。

// createContext
createContext(req,res) {
    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;
    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;
    context.state = {};
    return context;
}
複製程式碼

createContext函式通過Objectet.create方法建立了一個原型為從context.js檔案匯出的物件的一個新物件,然後在將requestresponse這兩個koa自己擴充套件的物件和http原生的reqres掛載上去了。所以我們在中介軟體處理響應的時候就可以直接通過ctx訪問到原生的reqres物件了,並且能訪問到requestresponse上的擴充套件方法了。

委託模式

在處理請求的的時候,可以直接通過訪問ctx.headerctx.urlctx.method來獲取一些請求頭或者去設定響應頭等。能夠這樣操作,主要是因為ctx.requestctx.response的一些屬性和方法被委託到了ctx這個物件上。如果對vue比較熟悉的人,也會感受到vue裡面的一些datamethod可以直接在vue的物件中訪問到,這些也是委託模式,將其他物件的屬性委託在了最上層的物件屬性上。

看一下,context.js裡面委託的實現,這裡擷取其中幾行程式碼

delegate(proto,'request')
  .method('acceptsLanguages')
  .method('get')
  .method('is')
  .access('querystring')
  .access('idempotent')
...
複製程式碼

這裡通過delegate函式,將proto物件裡面的request屬性下面的方法委託到proto物件上。這裡的delegate函式是引入的一個庫const delegate = require('delegates');原始碼

function Delegator(proto,target) {
  if (!(this instanceof Delegator)) return new Delegator(proto,target);
  this.proto = proto;
  this.target = target;
  this.methods = [];
  this.getters = [];
  this.setters = [];
  this.fluents = [];
}
...
Delegator.prototype.method = function(name){
  var proto = this.proto;
  var target = this.target;
  this.methods.push(name);

  proto[name] = function(){
    return this[target][name].apply(this[target],arguments);
  };

  return this;
};
...

Delegator.prototype.getter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.getters.push(name);

  proto.__defineGetter__(name,function(){
    return this[target][name];
  });

  return this;
};
複製程式碼

通過建構函式初始化委託的物件和被委託的目標物件,這裡的methodgetter方法都是很簡單的,當訪問委託物件上的屬性時,就是去呼叫了target上的屬性,只是需要注意this的執行。然後getter的呼叫也就是直接訪問了相應的值。當然這個庫還有其他一些方法,有興趣的可以去了解。

小結

通過分析,對koa原始碼裡面的一些細節更加的清晰了,中介軟體的合併和co函式的實現思想有了足夠的認識。分析原始碼還是收益不少,委託模式的使用可以應用在一些通用的庫上面,讓使用者能夠最直接的訪問到物件的屬性值,適當的減少了使用難度。

參考文章:

  1. 可能是目前最全的koa原始碼解析指南
  2. ECMAScript 6 入門