Koa2.0原始碼解析-中介軟體的設計

descire發表於2018-07-18

剖析connect.js的中介軟體設計,利於我們更好的理解Koa2.0中介軟體設計原理。

一、前言

    首先對於這兩個框架最起碼要看過文件或者是敲過入門的小demo,因為我們讀原始碼並不只是為了裝X,更重要的是幫助我們理解為什麼要這樣用?為什麼這裡會有坑?

    回憶一下如何用Node建立http服務:

    const http = require('http')
    const app = http.createServer((req, res) => {
        // 處理一個個http請求
        res.end('hello world')
    })
    
    app.listen(3000)
複製程式碼

    而這裡對於如何優雅的處理每一次請求就成了一個值得思考的問題,而在connect.js中你可以這樣處理:

const http = require('http')
const connect = require('connect')
const app = connect()

app.use((req, res, next) => {
  // 中介軟體1
  next()
})

app.use((req, res, next) => {
  // 中介軟體2
  next()
})

app.use((re, res, next) => {
  // 中介軟體3
  // 響應結束
  res.end('hello world')
})

http.createServer(app).listen(3000)
複製程式碼

二、connect中介軟體原理

    理解connect中介軟體實現原理,我們需要從這四個方法入手:

  • createServer: 如何定義處理請求方法?
  • use: 怎樣註冊我們的中介軟體?
  • handle: 中介軟體的執行流程是怎樣的?
  • call: 執行中介軟體方法需要注意什麼?
1、createServer
    function createServer() {
      function app(req, res, next){ app.handle(req, res, next); } 
      merge(app, proto);
      merge(app, EventEmitter.prototype); 
      app.route = '/'; 
      app.stack = []; 
      return app;
    }
複製程式碼

    createServer是connect的入口方法,它返回一個處理請求的方法,內部再呼叫handle來處理這些註冊的中介軟體,也就是中介軟體的處理流程。

    connect並沒有採用建構函式的方式,而將需要用到的屬性方法拷貝到app物件上使用,而對於Koa2.x中則是採用ES6的class實現。

    這裡的route是中介軟體的預設路由(這裡的路由與我們理解的路由有所差別,後面會提到),stack主要用來存放中介軟體。

2、use
    function use(route, fn) {
      var handle = fn;
      var path = route;
    
      // 不傳入route則預設為'/',這種基本是框架處理引數的一種套路
      if (typeof route !== 'string') {
        handle = route;
        path = '/';
      }
    
      ...
      // 儲存中介軟體
      this.stack.push({ route: path, handle: handle });
      
      // 以便鏈式呼叫
      return this;
    }
複製程式碼

    use方法中的核心就是將使用者傳入的引數整合成我們後續要用的layer物件包含路由和執行方法,並且將一個個layer物件儲存在stack中,從這裡我們可以猜測出中介軟體註冊的順序十分重要。

3、handle與call

    這裡我們需要將handle與call結合起來理解,它們可以說是connect的靈魂。

    function handle(req, res, out) {
      var index = 0;
      var stack = this.stack;
      ...
      function next(err) {
        ...
        // 依次取出中介軟體
        var layer = stack[index++]
    
        // 終止條件
        if (!layer) {
          defer(done, err);
          return;
        }
    
        var path = parseUrl(req).pathname || '/';
        var route = layer.route;
    
        // 路由匹配規則
        if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
          return next(err);
        }
        ...
        call(layer.handle, route, err, req, res, next);
      }
    
      next();
    }
複製程式碼

    handle方法的關鍵點在於通過next方法依次檢測當前中介軟體是否應該執行。而next方法中的路由匹配規則可以讓我們清楚的明白這裡並不是完全相等的匹配而是一種包含的關係:

    app.use('/foo', (req, res, next) => next())
    app.use('/foo/bar', (req, res, next) => next())
複製程式碼

    所以當你訪問/foo/bar路由時,這兩個中介軟體都會執行。

    如果不匹配當前中介軟體,那麼會自動呼叫next方法將進行下一個中介軟體的檢測。

    當路由匹配無誤,那麼就會呼叫call方法來執行當前中介軟體的處理函式:

    function call(handle, route, err, req, res, next) {
      var arity = handle.length;
      var error = err;
      var hasError = Boolean(err);
    
      try {
        if (hasError && arity === 4) {
          // 錯誤處理中介軟體
          handle(err, req, res, next);
          return;
        } else if (!hasError && arity < 4) {
          // 請求處理中介軟體
          handle(req, res, next);
          return;
        }
      } catch (e) {
        // 記錄錯誤
        error = e;
      }
    
      // 將錯誤傳遞下去
      next(error);
    }
複製程式碼

    這裡可以看到call內部通過呼叫try/catch捕獲中介軟體錯誤,並且通過引數個數和有無錯誤來決定執行錯誤處理中介軟體還是請求處理中介軟體,其它的情況則是自動呼叫next方法去檢查下一個中介軟體。如果try/catch捕獲到錯誤之後,會一直將這個錯誤傳遞下去,直到遇到錯誤處理中介軟體。

    所以這裡我們可以發現這個handle是有點樸實的,它會一直去檢查中介軟體陣列直到陣列遍歷完或者是next呼叫鏈斷掉(也就是你在中介軟體中沒有手動呼叫next),這裡我們可以通過流程圖看一下hanle與call的處理過程:

Koa2.0原始碼解析-中介軟體的設計

4、小結

    這時我們可以發現connect的幾個特點

  • 當中介軟體發生錯誤時,handle函式並不是立即進入錯誤處理狀態,而是將錯誤逐層傳遞,直到找到錯誤處理中介軟體,並且你的錯誤中介軟體必須是四個引數;
  • 中介軟體的執行流程是通過next連結的;
  • 我們需要手動呼叫res.end結束響應;
  • 當我們使用ES8的async方法時,無法捕獲到錯誤。

三、Koa2.0中介軟體

    Koa2.0中介軟體的實現與connect中介軟體原理基本相似,主要區別就在於中介軟體執行流程上的細節處理。

    首先我們要知道async函式返回的是一個Promise物件,所以當async內部發生錯誤,這個Promise物件就會將狀態轉換為reject。這也是為什麼try/catch無法捕獲它的狀態,所以捕獲async函式的內部錯誤,實際上就是Promise物件的錯誤處理,接下來我們看Koa2.0中next方法的實現:

    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) {
          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)
          }
        }
      }
    }
複製程式碼

    從上述程式碼中可以看出Koa2.0中介軟體處理的流程比connect更加簡單,首先Koa2.0中沒有路由,無需在傳遞的過程中匹配路由。

    而我們通過層層傳遞Promise物件,形成了一條Promise鏈,一旦出現reject狀態,那麼會立即進入catch方法,這也正好解決了connect中需要將錯誤層層傳遞到錯誤中介軟體的缺點。

    而當我們呼叫next方法時,就是呼叫dispatch.bind(null, i + 1),直白一點,就是:

    function next () {
        return dispatch(i + 1)
    }
複製程式碼

    而對於這條Promise鏈,Koa2.0中最後這樣處理:

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

    通過handleResponse方法幫助我們自動呼叫res.end(),這就是為什麼在Koa中我們這樣設定返回值:

    app.use(ctx => {
      ctx.body = 'Hello Koa'
    })
複製程式碼

    並且這裡通過系統自帶的onerror方法幫助我們處理錯誤,並且在onerror內部使用:

    this.app.emit('error', err, this);
複製程式碼

    從而為使用者提供監聽error來集中處理錯誤的功能。

    從connect到koa2.0,希望可以幫助你完全理解中介軟體的實現原理。

四、寫在最後

    這裡可能有人不解,難道講Koa都不提一下洋蔥模型嗎?其實看到這裡,我相信你已經明白next的執行流程實際上就是一個函式遞迴執行的過程,這也就是為什麼我們會用洋蔥模型來形容它。


    喜歡本文的小夥伴,可以gay一下或者關注我的訂閱號,ε=ε=ε=(~ ̄▽ ̄)~

Koa2.0原始碼解析-中介軟體的設計

相關文章