帶你走進 koa2 的世界(koa2 原始碼淺談)

小深刻的秋鼠發表於2017-05-12

最近使用koa2搭建部落格,async/await非同步流程控制確實比較優雅,但是在使用koa2過程中也遇到不少的問題,如何編寫中介軟體,如何替換express中介軟體為koa中介軟體,還有在實現服務端渲染的時候由於koa對於response有自己的封裝,當時也花了很多時間去調bug。覺得以上問題很多也是歸根於自己對框架的不熟悉引起的,所以花了點時間閱讀了下koa原始碼,分享下閱讀時收穫的東西以及koa2框架相關的分析。

koa2劃分淺析

下圖是我從node_modules目錄下截的,koa核心就在這兩部分,一個koa本身,一箇中介軟體的合成流程控制koa-compose

帶你走進 koa2 的世界(koa2 原始碼淺談)
koa關鍵目錄

這裡先簡略介紹koa2的各部分吧,後面再講流程

application.js

這個就是koa的入口主要檔案,暴露應用的class, 這個class繼承自node自帶的events,這裡就可以看出跟koa1.x很大的不同,koa2大量使用es6的語法,這裡就是一個例子,呼叫的時候就跟koa1.x有區別

var koa = require('koa');
// koa 1.x
var app = koa();
// koa 2.x
// 使用class必須使用new來呼叫
var app = new koa();複製程式碼

application就是應用,暴露了一些公用的api,比如兩個常見的,一個是listen,一個是use, listen就是呼叫http.createServer,傳入callback,當然這個callback就是核心,它裡面包含了中介軟體的合併,上下文的處理,對res的特殊處理(後面說流程會細說,這裡先粗略講講),use的話用得就更多了,中介軟體往往是web框架的主要部分,但是use其實就是很簡單起到收集中介軟體的作用而已,重點在於如何組合它們,如何設計請求到來時如何呼叫中介軟體,這些東西其實都在koa-compose

context.js

這部分就是koa的應用上下文ctx,其實就一個簡單的物件暴露,裡面的重點在delegate,這個就是代理,這個就是為了開發者方便而設計的,比如我們要訪問ctx.repsponse.status但是我們通過delegate,可以直接訪問ctx.status訪問到它(這個實現也不是很複雜,後面再講)

request.js、response.js

這兩部分就是對原生的res、req的一些操作了,大量使用es6的getset的一些語法,去取headers或者設定headers、還有設定body等等,這些就不詳細介紹了,有興趣的讀者可以自行看原始碼

koa2流程控制/中介軟體

一個簡單的koa應用

我們就先從簡單的koa應用開始

// 這裡就先不用async/await
// 它們並不是必須的
var koa = require('koa');
var app = new koa();

app.use((ctx, next) => {
  console.log(1)
  next();
  console.log(5)
});

app.use((ctx, next) => {
  console.log(2)
  next();
  console.log(4)
});

app.use((ctx, next) => {
  console.log(3)
  ctx.body = 'Hello World';
});

app.listen(3000);
// 訪問http://localhost:3000
// 列印出1、2、3、4、5複製程式碼

上述簡單的應用列印出1、2、3、4、5,這個其實就是koa中介軟體控制的核心,一個洋蔥結構,從上往下一層一層進來,再從下往上一層一層回去,乍一看很複雜,為什麼不直接一層一層下來就結束呢,就像express/connect一樣,我們就只要next就去下一個中介軟體,幹嘛還要回來?

其實這就是為了解決複雜應用中頻繁的回撥而設計的級聯程式碼,並不直接把控制權完全交給下一個中介軟體,而是碰到next去下一個中介軟體,等下面都執行完了,還會執行next以下的內容

解決頻繁的回撥,這又有什麼依據呢?舉個簡單的例子,假如我們需要知道穿過中介軟體的時間,我們使用koa可以輕鬆地寫出來,但是使用express呢,可以去看下express reponse-time的原始碼,它就只能通過監聽header被write out的時候然後觸發回撥函式計算時間,但是koa完全不用寫callback,我們只需要在next後面加幾行程式碼就解決了(直接使用.then()都可以)

// koa-guide v1的示例程式碼就是計算中介軟體穿越時間

var koa = require('koa');
var app = koa();

// x-response-time
app.use(function *(next){
  // (1) 進入路由
  var start = new Date;
  yield next;
  // (5) 再次進入 x-response-time 中介軟體,記錄2次通過此中介軟體「穿越」的時間
  var ms = new Date - start;
  this.set('X-Response-Time', ms + 'ms');
  // (6) 返回 this.body
});

// logger
app.use(function *(next){
  // (2) 進入 logger 中介軟體
  var start = new Date;
  yield next;
  // (4) 再次進入 logger 中介軟體,記錄2次通過此中介軟體「穿越」的時間
  var ms = new Date - start;
  console.log('%s %s - %s', this.method, this.url, ms);
});

// response
app.use(function *(){
  // (3) 進入 response 中介軟體,沒有捕獲到下一個符合條件的中介軟體,傳遞到 upstream
  this.body = 'Hello World';
});

app.listen(3000);複製程式碼

koa-compose原始碼分析

這裡你應該就對如何實現感興趣了,這裡目光就得轉到koa-compose,
其實程式碼就這麼點

const Promise = require('any-promise')
// 這裡使用any-promise是為了相容低版本node
module.exports = compose
function compose (middleware) {
    // 傳入的middleware必須是一個陣列
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
    // 傳入的middleware的每一個元素都必須是函式
  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 // 這裡維護一個index的閉包
    return dispatch(0) // 從陣列的第一個元素開始`dispatch`
    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()  //這兩行就是來處理最後一箇中介軟體還有next的情況的,其實是可以直接resolve出來的
      try {
            // 這裡就是傳入next執行中介軟體程式碼了
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}複製程式碼

我稍微註釋了一下程式碼

其實這部分要跟application.js中的callback 結合起來看

/**
   * Return a request handler callback
   * for node's native http server.
   *
   * @return {Function}
   * @api public
   */

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

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

    const handleRequest = (req, res) => {
      res.statusCode = 404;
      const ctx = this.createContext(req, res);
      const onerror = err => ctx.onerror(err);
      const handleResponse = () => respond(ctx);
      onFinished(res, onerror);
      return fn(ctx).then(handleResponse).catch(onerror);
    };

    return handleRequest;
  }複製程式碼

而callback的作用就是http.createServer(app.callback()).listen(...)

這裡開始講重點,koa-compose從語義上看就是組合,其實就是對koa中介軟體的組合,它返回了一個promise,執行完成後就執行koa2對res的特殊處理,最後res.end()

當然我們關心的是如何對中介軟體組合,其實就是傳入一個middleware陣列
然後第一次取出陣列的第一個元素,傳入context和next程式碼,執行當前這個元素(這個中介軟體)

// 這裡就是傳入next執行中介軟體程式碼了
return Promise.resolve(fn(context, function next () {
    return dispatch(i + 1)
}))複製程式碼

其實後面根本沒用到resolve的內容,這部分程式碼等價於

fn(context, function next () {
    return dispatch(i + 1)
})
return Promise.resolve()複製程式碼

核心就在於dispatch(i + 1),不過也很好理解嘛,就是將陣列指標移向下一個,執行下一個中介軟體的程式碼
然後一直這樣到最後一箇中介軟體,假如最後一箇中介軟體還有next那麼下面這兩段程式碼就起作用了

 if (i === middleware.length) fn = next 
 if (!fn) return Promise.resolve()複製程式碼

因為middleware沒有下一個了,並且其實外面那個next是空的,所以其實就可以return結束了

這裡其實直接return就行了,這裡的return Promise.resolve()其實是沒有用的,真正return出外面的是呼叫第一個中介軟體的resolve

嗯嗯,這樣其實就結束了,一整套的中介軟體呼叫
讀者可能還想問,不是洋蔥結構嗎?那怎麼回去的呢?其實回去的程式碼其實就是函式壓棧和出棧

看完以下程式碼你就懂了(其實這就是koa的中介軟體原理)

function a() {
    console.log(1)
    b();
    console.log(5)
    return Promise.resolve();
}
function b() {
    console.log(2)
    c();
    console.log(4)
}

function c() {
    console.log(3)
    return;
}
a();
// 輸出1、2、3、4、5複製程式碼

koa2處理流程

一圖勝千言

帶你走進 koa2 的世界(koa2 原始碼淺談)
koa2核心設計/流程

我就粗淺劃分為幾部分吧

  • 初始化應用
  • 請求到來-建立上下文部分
  • 請求到來-中介軟體執行部分
  • 返回res特殊處理部分

初始化應用部分

首先就是我們的app.js程式碼,初始的時候就是我們new了個koa例項,然後開始寫各種use,寫個app.listen(3000);
use其實就是把你寫的函式一個一個收集到一個middleware陣列,listen的話就是http.createServer(app.callback()).listen(...)

請求到來-建立上下文部分

當一個請求過來的時候,由http.createServer的callback知道,它是可以傳入req、res的,所以其實從這個入口可以拿到req、res,koa拿到後就createContext建立應用上下文,根據context.js、request.js、response.js建立,並且進行屬性代理delegate

請求到來-中介軟體執行部分

請求到來執行了中介軟體的一系列流程,使用koa-compose將傳入的middleware組合起來,然後返回了一個promise, 其實真正傳入http.createServer callback的就下面(我簡寫了)

http.createServer((req, res) => {
 // ... 通過req,res建立上下文
 // fn是`koa-compose`返回的promise
 return fn(ctx).then(handleResponse).catch(onerror);
})複製程式碼

返回res特殊處理部分

我們上一部分可以看到一個handleResponse,它是什麼?其實我們到這裡還沒有res.end(), koa前面其實都是使用ctx.body = xxx,那它是怎麼write回res的呢,這部分邏輯就在function respond(){},handleResponse就以下一句

const handleResponse = () => respond(ctx);複製程式碼

respond到底做了什麼呢,其實它就是判斷你之前中介軟體寫的body的型別,做一些處理,然後使用res.end(body)
到這裡就結束了,返回了頁面

讀者到這裡可以再看看那張圖就比較清晰了

小插曲

Object.create(X.prototype) VS new X

在原始碼閱讀時大量看到Object.create()的用法,這個又跟new X()有什麼區別呢

引用下stackoverflow答案
stackoverflow.com/questions/4…

new Test():

1.create new Object() obj
2.set obj.__proto__ to Test.prototype
3.return Test.call(obj) || obj;
// normally obj is returned but constructors in JS can return a value

Object.create( Test.prototype )

1.create new Object() obj
2.set obj.__proto__ to Test.prototype
3.return obj;

可以看到其實new和Object.create前兩個步驟都是一樣,區別在第三步,其實就是Object.create不會執行建構函式,我們來兩段更直接的程式碼

var a = new A();
var b = Object.create(B.prototype)
function A() {
    console.log('a')
}
function B() {
    console.log('b')
}
// 我們可以看到只輸出了a,但是b並沒有輸出複製程式碼
var a = new A();
var b = Object.create(B.prototype)
function A() {
  return {}
}
function B() {
  return {}
}
console.log(a)
console.log(b)
// 可以看到a就是{}
// b是一個物件,它的__proto__指向B.prototype複製程式碼

這樣應該就很清晰了,Object.create()特殊還在於它的第二個引數,就像Object.defineProperties()可以定義新的屬性或修改現有屬性

delegates

koa2裡有一堆屬性代理,為了方便開發者更容易訪問到一些屬性,koa設計了一些屬性代理,可以用ctx.body之類的去訪問ctx.response.body,呼叫也很簡單,諸如以下

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove'
  .method('vary')
  .method('set')
  .method('append')
  .method('flushHeaders')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');複製程式碼

其實這個實現很簡單
我們簡單來說下method代理,其他同理
這裡我們用obj.foo來替代obj.request.foo

function method(proto, target, name) {
    proto[name] = function() {
        return proto[target][name].apply(proto[target], arguments)
    }
}
var obj = {};
obj.request = {
    foo: function(bar) {
       console.log(bar)
       console.log(this)
       return bar;
    }
}
method(obj, 'request', 'foo')
obj.foo('123')
// 輸出123
// this輸出obj.request複製程式碼

最後

謝謝閱讀~
歡迎follow我哈哈github.com/BUPT-HJM
歡迎繼續觀光我的新部落格~(老部落格近期可能遷移)
我的部落格也是關於koa2的一個實踐,歡迎star?
github.com/BUPT-HJM/vu…

歡迎關注

相關文章