玩轉Koa -- 核心原理分析

descire發表於2018-12-16

Koa作為下一代Web開發框架,不僅讓我們體驗到了async/await語法帶來同步方式書寫非同步程式碼的酸爽,而且本身簡潔的特點,更加利於開發者結合業務本身進行擴充套件。

  本文從以下幾個方面解讀Koa原始碼:

  • 封裝建立應用程式函式
  • 擴充套件res和req
  • 中介軟體實現原理
  • 異常處理

一、封裝建立應用程式函式

  利用NodeJS可以很容易編寫一個簡單的應用程式:

const http = require('http')

const server = http.createServer((req, res) => {
  // 每一次請求處理的方法
  console.log(req.url)
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('Hello NodeJS')
})

server.listen(8080)
複製程式碼

注意:當瀏覽器傳送請求時,會附帶請求/favicon.ico。

  而Koa在封裝建立應用程式的方法中主要執行了以下流程:

  • 組織中介軟體(監聽請求之前)
  • 生成context上下文物件
  • 執行中介軟體
  • 執行預設響應方法或者異常處理方法
// application.js
listen(...args) {
  const server = http.createServer(this.callback());
  return server.listen(...args);
}

callback() {
  // 組織中介軟體
  const fn = compose(this.middleware);

  // 未監聽異常處理,則採用預設的異常處理方法
  if (!this.listenerCount('error')) this.on('error', this.onerror);

  const handleRequest = (req, res) => {
    // 生成context上下文物件
    const ctx = this.createContext(req, res);
    return this.handleRequest(ctx, fn);
  };

  return handleRequest;
}

handleRequest(ctx, fnMiddleware) {
  const res = ctx.res;
  // 預設狀態碼為404
  res.statusCode = 404;
  // 中介軟體執行完畢之後 採用預設的 錯誤 與 成功 的處理方式
  const onerror = err => ctx.onerror(err);
  const handleResponse = () => respond(ctx);
  onFinished(res, onerror);
  return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
複製程式碼

二、擴充套件res和req

  首先我們要知道NodeJS中的res和req是http.IncomingMessage和http.ServerResponse的例項,那麼就可以在NodeJS中這樣擴充套件req和res:

Object.defineProperties(http.IncomingMessage.prototype, {
  query: {
    get () {
      return querystring.parse(url.parse(this.url).query)
    }
  }
})

Object.defineProperties(http.ServerResponse.prototype, {
  json: {
    value: function (obj) {
      if (typeof obj === 'object') {
        obj = JSON.stringify(obj)
      }
      this.end(obj)
    }
  }
})
複製程式碼

  而Koa中則是自定義request和response物件,然後保持對res和req的引用,最後通過getter和setter方法實現擴充套件。

// application.js
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; // 儲存原生req物件
    context.res = request.res = response.res = res; // 儲存原生res物件
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    // 最終返回完整的context上下文物件
    return context;
}
複製程式碼

  所以在Koa中要區別這兩組物件:

  • request、response: Koa擴充套件的物件
  • res、req: NodeJS原生物件
// request.js
get header() {
  return this.req.headers;
},
set header(val) {
  this.req.headers = val;
},
複製程式碼

  此時已經可以採用這樣的方式訪問header屬性:

  ctx.request.header
複製程式碼

  但是為了方便開發者呼叫這些屬性和方法,Koa將response和request中的屬性和方法代理到context上。

  通過Object.defineProperty可以輕鬆的實現屬性的代理:

function access (proto, target, name) {
  Object.defineProperty(proto, name, {
    get () {
      return target[name]
    },
    set (value) {
      target[name] = value
    }
  })
}

access(context, request, 'header')
複製程式碼

  而對於方法的代理,則需要注意this的指向:

function method (proto, target, name) {
  proto[name] = function () {
    return target[name].apply(target, arguments)
  }
}
複製程式碼

  上述就是屬性代理和方法代理的核心程式碼,這基本算是一個常用的套路。

  代理這部分詳細的原始碼,可以檢視node-delegates, 不過這個包時間久遠,有一些老方法已經廢除。

   在上述過程的原始碼中涉及到很多JavaScript的基礎知識,例如:原型繼承、this的指向。對於基礎薄弱的同學,還需要先弄懂這些基礎知識。

三、中介軟體實現原理

   首先需要明確是:中介軟體並不是NodeJS中的概念,它只是connect、express和koa框架衍生的概念。

1、connect中介軟體的設計

  在connect中,開發者可以通過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物件,然後將其儲存在一個佇列當中,也就是上述程式碼中的stack。

  connect中介軟體的執行流程主要由handle與call函式決定:

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來檢測layer是否與當前路由相匹配,匹配則執行該layer上的中介軟體函式,否則繼續檢查下一個layer。

  這裡需要注意next中檢查路由的方式可能與想象中的不太一樣,所以預設路由為'/'的中介軟體會在每一次請求處理中都執行。

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捕獲錯誤,這裡有一個特別需要注意的地方是,call內部會根據是否存在錯誤以及中介軟體函式的引數決定是否執行錯誤處理中介軟體。並且一旦捕獲到錯誤,next方法會將錯誤傳遞下去,所以接下來普通的請求處理中介軟體即使通過了next中的路由匹配,仍然會被call方法給過濾掉。

  下面是layer的處理流程圖:

玩轉Koa -- 核心原理分析

  上述就是connect中介軟體設計的核心要點,總結起來有如下幾點:

  • 通過use方法註冊中介軟體;
  • 中介軟體的順序執行是通過next方法銜接的並且需要手動呼叫,在next中會進行路由匹配,從而過濾掉部分中介軟體;
  • 當中介軟體的執行過程中發生異常,則next會攜帶異常過濾掉非錯誤處理中介軟體,也是為什麼錯誤中介軟體會比其他中介軟體多一個error引數;
  • 在請求處理的週期中,需要手動呼叫res.end()來結束響應;
2、Koa中介軟體的設計

  Koa中介軟體與connect中介軟體的設計有很大的差異:

  • Koa中介軟體的執行並不需要匹配路由,所以註冊的中介軟體每一次請求都會執行。(當然還是需要手動呼叫next);
  • Koa中通過繼承event,暴露error事件讓開發者自定義異常處理;
  • Koa中res.end由中介軟體執行完成之後自動呼叫,這樣避免在connect忘記呼叫res.end導致使用者得不到任何反饋。
  • Koa中採用了async/await語法讓開發者利用同步的方式編寫非同步程式碼。

  當然,Koa中也是採用use方法註冊中介軟體,相比較connect省去路由匹配的處理,就顯得很簡潔:

use(fn) {
  this.middleware.push(fn);
  return this;
}
複製程式碼

  並且use支援鏈式呼叫。

  Koa中介軟體的執行流程主要通過koa-compose中的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) {
    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)
      }
    }
  }
}
複製程式碼

  看到這裡本質上connect與koa實現中介軟體的思想都是遞迴,不難看出koa相比較connect實現得更加簡潔,主要原因在於:

  • connect中提供路由匹配的功能,而Koa中則是相當於connect中預設的'/'路徑。
  • connect在捕獲中介軟體的異常時,通過next攜帶error一個個中介軟體驗證,直到錯誤處理中介軟體,而Koa中則是用Promise包裝中介軟體,一旦中介軟體發生異常,那麼會直接觸發reject狀態,直接在Promise的catch中處理就行。

  上述就是connect中介軟體與Koa中介軟體的實現原理,現在在再看Koa中介軟體的這張執行流程圖,應該沒有什麼疑問了吧?!

玩轉Koa -- 核心原理分析

四、異常處理

  對於同步程式碼,通過try/catch可以輕鬆的捕獲異常,在connect中介軟體的異常捕獲則是通過try/catch完成。

  對於非同步程式碼,try/catch則無法捕獲,這時候一般可以構造Promise鏈,在最後的catch方法中捕獲錯誤,Koa就是這樣處理,並且在catch方法中傳送error事件,以便開發者自定義異常處理邏輯。

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

  前面也談到Koa利用async/await語法帶來同步方式書寫非同步程式碼的酸爽,另外也讓錯誤處理更加自然:

// 也可以這樣自定義錯誤處理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.status || 500
    ctx.body = err
  }
})
複製程式碼

五、總結

  相信看到這裡,再回憶一下之前遇到的那些問題,你應該會有新的理解,並且再次使用Koa時會更加得心應手,這也是分析Koa原始碼的目的之一。

相關文章