手撕KOA2源碼

在SF平臺潛水好久了,這也是個人第一篇文章,以前一直以學習爲主。但願往後能經過寫技術文章和回答問題的方式來作一些輸出^ ^ 以前沒有這方面的習慣,因此語言組織方面可能會有點混亂.node

最近面試兩次被問到KOA的洋蔥圈模型(由於以前學校的幾個項目我都是用KOA2來寫的),可是本身沒有深挖過洋蔥圈的原理,感受答得不是很滿意。因此此次特意翻出KOA的源碼看了一下。

目錄結構

├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── package.json

目前咱們下載的node_modules/koa包中的源文件結構就是如此。而koa處理請求的核心也就是以上四個文件,其中git

  • application.js是整個KOA2的入口。最重要的中間件邏輯也是在此進行處理。本次學習的也就是這個文件。
  • context.js負責處理應用上下文
  • request.js處理http請求
  • response.js處理http響應

簡單看看KOA類中封裝了哪些方法,順藤摸瓜看看github

構造函數

constructor(options) {面試

super();
options = options || {};
this.proxy = options.proxy || false;
this.subdomainOffset = options.subdomainOffset || 2;
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
this.maxIpsCount = options.maxIpsCount || 0;
this.env = options.env || process.env.NODE_ENV || 'development';  // 環境變量
if (options.keys) this.keys = options.keys;
this.middleware = []; // **中間件隊列**
this.context = Object.create(context);  // 上下文
this.request = Object.create(request);  // 請求對象格式
this.response = Object.create(response);  // 響應對象格式
if (util.inspect.custom) {
  this[util.inspect.custom] = this.inspect;
}

}json

listen

listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
}

KOA的監聽函數是對原生的createServer作的簡單封裝,傳入的參數也會直接被傳給原生的server.listen。只不過這裏經過KOA類中的callback方法生成了一個配置對象傳入server中,從這裏來看,KOA實際的執行邏輯實際上是經過callback函數來暴露的。數組

callback

callback() {
  const fn = compose(this.middleware);

  if (!this.listenerCount('error')) this.on('error', this.onerror);

  const handleRequest = (req, res) => {
    const ctx = this.createContext(req, res);
    return this.handleRequest(ctx, fn);
  };

  return handleRequest;
}

首先查看第一句app

const fn = compose(this.middleware);

this.midlleware顯然是實例中的中間件隊列。dom

衆所周知,compose是組成的意思,之前咱們學過的一個短語就是be composed of -> 由…所組成。在代碼中的表現形式則大概爲組合函數koa

g() + h() => g(h())

這裏的compose變量來自koa-compose這個包,他的做用是將全部的koa中間件進行合併執行。能夠理解爲以前的middleware數組只是一些零散的洋蔥圈層級,是經過koa-compose處理事後才成爲了一個完整的洋蔥(文章末尾會附上koa-compose的原理)異步

回到上面的方法,得到了中間件合體後的組合函數fn後,聲明瞭一個最終用於輸出的handleRequest函數,在函數中先經過this.createContext初始化報文,能夠理解爲生成了一個完整的請求報文和響應報文,而初始化報文的邏輯就寫在了lib/request.jslib/response.js之中。最後將他們經過this.handleRequest方法對報文進行處理。

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);
}

這裏就是將處理後的報文傳給咱們以前生成的組合函數fn(也就是洋蔥圈),在完成了整個洋蔥圈的處理邏輯以後去作一些後續處理(返回客戶端/錯誤處理)。

衆所周知,洋蔥圈模型的每一層都是一個Promise對象,只有當上遊(官方文檔的說法)的洋蔥圈進入resolved狀態後,線程的使用權纔會向下遊傳遞。(請注意,此時上一層洋蔥圈並無執行完畢)以後當內層的Promise成爲resolved狀態後,JS線程的使用權纔會繼續向上冒泡,去處理外層洋蔥圈resolve以後的邏輯

洋蔥圈函數接收的兩個參數 ctxnext中的next方法就是一個異步方法,表明將當前這層洋蔥圈強制 resolve,控制權指向下游,當前的函數將被阻塞,直到下游全部邏輯處理完以後,才能繼續執行。

洋蔥圈模型

看到這裏咱們已經能夠對目前看到的部分作一個梳理。

  1. KOA實例收集了一些須要的中間件(use方法)
  2. this.callback函數中經過koa-compose將中間件進行組合生成了一個洋蔥圈處理器fn(處理誰呢,固然是處理報文對象啦)
  3. 引用報文格式化方法
  4. 將報文格式化方法和洋蔥圈處理器封裝成一個工廠函數handleRequest。這個工廠函數只負責接受報文,返回結果。

上面說到的use方法其實也是洋蔥圈模型的核心,也就是字面上的註冊中間件方法。

use

use(fn) {
  if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
  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);
  }
  debug('use %s', fn._name || fn.name || '-');
  this.middleware.push(fn);
  return this;
}

use接受的必須是一個函數(接受兩個參數,一個是ctx上下文,一個是next函數),若是這個函數是生成器(*generator),(KOA1中的中間件只接收迭代器方法,KOA2中則所有用async await來實現),那麼就須要作一個優雅降級處理,經過koa-convert函數去進行轉換。(這一起我也尚未看= =)

最後就很簡單了,將這個方法push到KOA實例的middleware隊列中

看到這裏,application文件的內容就結束了,這樣看來KOA的源碼仍是比較少的,可是由於中間件的存在,可擴展性變得很是強,這應該也是爲何Koa目前這麼火的緣由。

PS: koa-compose

koa-compose的代碼量很是少,只有50行不到(49行)。可是設計很是精妙。

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!')
  }
  return function (context, next) {
    // 最後一次被調用的中間件下標
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      // i <= index 說明有中間件被重複調用了,拋出錯誤
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      // fn取對應層的中間件,若是傳入了next函數,那麼next函數的優先級更高
      if (i === middleware.length) fn = next
      // 此處fn指向的就是第i層的中間件函數
      // 若是沒有了,那麼直接resolve
      if (!fn) return Promise.resolve()
      try {
        // 實際的執行步驟在這,執行fn方法,同時將下一層中間件的執行函數也傳遞過去
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

籠統的歸納這個語法就是每一箇中間件執行到await next()語句的時候,都會調用下一層的中間件。你也能夠將代碼理解爲

// 前半部分處理邏輯
await next() 
// 後半部分處理邏輯


/* ================= 等價於 ================= */


// 前半部分處理邏輯
await new Promise([下一層中間件的邏輯])
// 後半部分處理邏輯

而這樣的插入邏輯在下一層中間件的邏輯中又在遞歸的發生着,洋蔥圈的執行邏輯其實存儲在數組koa.middleware中,只有執行到洋蔥圈的第n層的時候,纔會經過下標n+1去取下一層的處理邏輯,而且生成Promise插入到上層洋蔥圈的函數體中,造成了一個不斷重疊的函數做用域。

END

相關文章
相關標籤/搜索