Koa原始碼解析,帶你實現一個迷你版的Koa

WahFung發表於2020-06-09

前言

本文是我在閱讀 Koa 原始碼後,並實現迷你版 Koa 的過程。如果你使用過 Koa 但不知道內部的原理,我想這篇文章應該能夠幫助到你,實現一個迷你版的 Koa 不會很難。

本文會循序漸進的解析內部原理,包括:

  1. 基礎版本的 koa
  2. context 的實現
  3. 中介軟體原理及實現

檔案結構

  • application.js: 入口檔案,裡面包括我們常用的 use 方法、listen 方法以及對 ctx.body 做輸出處理
  • context.js: 主要是做屬性和方法的代理,讓使用者能夠更簡便的訪問到requestresponse的屬性和方法
  • request.js: 對原生的 req 屬性做處理,擴充套件更多可用的屬性和方法,比如:query 屬性、get 方法
  • response.js: 對原生的 res 屬性做處理,擴充套件更多可用的屬性和方法,比如:status 屬性、set 方法

基礎版本

用法:

const Coa = require('./coa/application')
const app = new Coa()

// 應用中介軟體
app.use((ctx) => {
  ctx.body = '<h1>Hello</h1>'
})

app.listen(3000'127.0.0.1')

application.js:

const http = require('http')

module.exports = class Coa {
  use(fn) {
    this.fn = fn
  }
  // listen 只是語法糖  本身還是使用 http.createServer
  listen(...args) {
    const server = http.createServer(this.callback())
    server.listen(...args)
  }
  callback() {
    const handleRequest = (req, res) => {
      // 建立上下文
      const ctx = this.createContext(req, res)
      // 呼叫中介軟體
      this.fn(ctx)
      // 輸出內容
      res.end(ctx.body)
    }
    return handleRequest
  }
  createContext(req, res) {
    let ctx = {}
    ctx.req = req
    ctx.res = res
    return ctx
  }
}

基礎版本的實現很簡單,呼叫 use 將函式儲存起來,在啟動伺服器時再執行這個函式,並輸出 ctx.body 的內容。

但是這樣是沒有靈魂的。接下來,實現 context 和中介軟體原理,Koa 才算完整。

Context

ctx 為我們擴充套件了很多好用的屬性和方法,比如 ctx.queryctx.set()。但它們並不是 context 封裝的,而是在訪問 ctx 上的屬性時,它內部通過屬性劫持將 requestresponse 內封裝的屬性返回。就像你訪問 ctx.query,實際上訪問的是 ctx.request.query

說到劫持你可能會想到 Object.defineProperty,在 Kao 內部使用的是 ES6 提供的物件的 settergetter,效果也是一樣的。所以要實現 ctx,我們首先要實現 requestresponse

在此之前,需要修改下 createContext 方法:

// 這三個都是物件
const context = require('./context')
const request = require('./request')
const response = require('./response')

module.exports = class Coa {
  constructor() {
    this.context = context
    this.request = request
    this.response = response
  }
  createContext(req, res) {
    const ctx = Object.create(this.context)
    // 將擴充套件的 request、response 掛載到 ctx 上
    // 使用 Object.create 建立以傳入引數為原型的物件,避免新增屬性時因為衝突影響到原物件
    const request = ctx.request = Object.create(this.request)
    const response = ctx.response = Object.create(this.response)
    
    ctx.app = request.app = response.app = this;
    // 掛載原生屬性
    ctx.req = request.req = response.req = req
    ctx.res = request.res = response.res = res
    
    request.ctx = response.ctx = ctx;
    request.response = response;
    response.request = request;
    
    return ctx
  }
}

上面一堆花裡胡哨的賦值,是為了能通過多種途徑獲取屬性。比如獲取 query 屬性,可以有 ctx.queryctx.request.queryctx.app.query 等等的方式。

如果你覺得看起來有點冗餘,也可以主要理解這幾行,因為我們實現原始碼時也就用到下面這些:

const request = ctx.request = Object.create(this.request)
const response = ctx.response = Object.create(this.response)

ctx.req = request.req = response.req = req
ctx.res = request.res = response.res = res

request

request.js

const url = require('url')

module.exports = {
 /* 檢視這兩步操作
  * const request = ctx.request = Object.create(this.request)
  * ctx.req = request.req = response.req = req 
  * 
  * 此時的 this 是指向 ctx,所以這裡的 this.req 訪問的是原生屬性 req
  * 同樣,也可以通過 this.request.req 來訪問
  */

  get query() {
    return url.parse(this.req.url).query
  },
  get path() {
    return url.parse(this.req.url).pathname
  },
  get method() {
    return this.req.method.toLowerCase()
  }
}

response

response.js

module.exports = {
  // 這裡的 this.res 也和上面同理 
  get status() {
    return this.res.statusCode
  },
  set status(val) {
    return this.res.statusCode = val
  },
  get body() {
    return this._body
  },
  set body(val) {
    return this._body = val
  }
}

屬性代理

通過上面的實現,我們可以使用 ctx.request.query 來訪問到擴充套件的屬性。但是在實際應用中,更常用的是 ctx.query。不過 query 是在 request 的屬性,通過 ctx.query 是無法訪問的。

這時只需稍微做個代理,在訪問 ctx.query 時,將 ctx.request.query 返回就可以實現上面的效果。

context.js:

module.exports = {
    get query() {
        return this.request.query
    }
}

實際的程式碼中會有很多擴充套件的屬性,總不可能一個一個去寫吧。為了優雅的代理屬性,Koa 使用 delegates 包實現。這裡我不打算用 delegates,直接簡單封裝下代理函式。代理函式主要用到__defineGetter____defineSetter__ 兩個方法。

在物件上都會帶有 __defineGetter____defineSetter__,它們可以將一個函式繫結在當前物件的指定屬性上,當屬性被獲取或賦值時,繫結的函式就會被呼叫。就像這樣:

let obj = {}
let obj1 = {
    name'JoJo'
}
obj.__defineGetter__('name'function(){
    return obj1.name
})

此時訪問 obj.name,獲取到的是 obj1.name 的值。

瞭解這個兩個方法的用處後,接下來開始修改 context.js

const proto = module.exports = {
}

// getter代理
function delegateGetter(prop, name){
  proto.__defineGetter__(name, function(){
    return this[prop][name]
  })
}
// setter代理
function delegateSetter(prop, name){
  proto.__defineSetter__(name, function(val){
    return this[prop][name] = val
  })
}
// 方法代理
function delegateMethod(prop, name){
  proto[name] = function({
    return this[prop][name].apply(this[prop], arguments)
  }
}

delegateGetter('request''query')
delegateGetter('request''path')
delegateGetter('request''method')

delegateGetter('response''status')
delegateSetter('response''status')
delegateMethod('response''set')

中介軟體原理

中介軟體思想是 Koa 最精髓的地方,為擴充套件功能提供很大的幫助。這也是它雖然小,卻很強大的原因。還有一個優點,中介軟體使功能模組的職責更加分明,一個功能就是一箇中介軟體,多箇中介軟體組合起來成為一個完整的應用。

下面是著名的“洋蔥模型”。這幅圖很形象的表達了中介軟體思想的作用,它就像一個流水線一樣,上游加工後的東西傳遞給下游,下游可以繼續接著加工,最終輸出加工結果。

原理分析

在呼叫 use 註冊中介軟體的時候,內部會將每個中介軟體儲存到陣列中,執行中介軟體時,為其提供 next 引數。呼叫 next 即執行下一個中介軟體,以此類推。當陣列中的中介軟體執行完畢後,再原路返回。就像這樣:

app.use((ctx, next) => {
  console.log('1 start')
  next()
  console.log('1 end')
})

app.use((ctx, next) => {
  console.log('2 start')
  next()
  console.log('2 end')
})

app.use((ctx, next) => {
  console.log('3 start')
  next()
  console.log('3 end')
})

輸出結果如下:

1 start
2 start
3 start
3 end
2 end
1 end

有點資料結構知識的同學,很快就想到這是一個“棧”結構,執行的順序符合“先入後出”。

下面我將內部中介軟體實現原理進行簡化,模擬中介軟體執行:

function next1({
  console.log('1 start')
  next2()
  console.log('1 end')
}
function next2({
  console.log('2 start')
  next3()
  console.log('2 end')
}
function next3({
  console.log('3 start')
  console.log('3 end')
}
next1()

執行過程:

  1. 呼叫 next1,將其入棧執行,輸出 1 start
  2. 遇到 next2 函式,將其入棧執行,輸出 2 start
  3. 遇到 next3 函式,將其入棧執行,輸出 3 start
  4. 輸出 3 end,函式執行完畢,next3 彈出棧
  5. 輸出 2 end,函式執行完畢,next2 彈出棧
  6. 輸出 1 end,函式執行完畢,next1 彈出棧
  7. 棧空,全部執行完畢

相信通過這個簡單的例子,都大概明白中介軟體的執行過程了吧。

原理實現

中介軟體原理實現的關鍵點主要就是 ctxnext 的傳遞。

因為中介軟體是可以非同步執行的,最後需要返回 Promise

function compose(middleware{
  return function(ctx{
    return dispatch(0)
    function dispatch(i){
      // 取出中介軟體
      let fn = middleware[i]
      if (!fn) {
        return Promise.resolve()
      }
      // dispatch.bind(null, i + 1) 為應用中介軟體接受到的 next
      // next 即下一個應用中介軟體的函式引用
      try {
        return Promise.resolve( fn(ctx, dispatch.bind(null, i + 1)) )
      } catch (error) {
        return Promise.reject(error)
      }
    }
  }
}

可以看到,實現過程本質是函式的遞迴呼叫。在內部實現時,其實 next 沒有做什麼神奇的操作,它就是下一個中介軟體呼叫的函式,作為引數傳入供使用者呼叫。

下面我們來使用一下 compose,你可以將它貼上到控制檯上執行:

function next1(ctx, next{
  console.log('1 start')
  next()
  console.log('1 end')
}
function next2(ctx, next{
  console.log('2 start')
  next()
  console.log('2 end')
}
function next3(ctx, next{
  console.log('3 start')
  next()
  console.log('3 end')
}

let ctx = {}
let fn = compose([next1, next2, next3])
fn(ctx)

完整實現

application.js:

const http = require('http')
const context = require('./context')
const request = require('./request')
const response = require('./response')

module.exports = class Coa {
  constructor() {
    this.middleware = []
    this.context = context
    this.request = request
    this.response = response
  }

  use(fn) {
    if (typeof fn !== 'function'throw new TypeError('middleware must be a function!');
    this.middleware.push(fn)
    return this
  }

  listen(...args) {
    const server = http.createServer(this.callback())
    server.listen(...args)
  }

  callback() {
    const handleRequest = (req, res) => {
      // 建立上下文
      const ctx = this.createContext(req, res)
      // fn 為第一個應用中介軟體的引用
      const fn = this.compose(this.middleware)
      return fn(ctx).then(() => respond(ctx)).catch(console.error)
    }
    return handleRequest
  }

  // 建立上下文
  createContext(req, res) {
    const ctx = Object.create(this.context)
    // 處理過的屬性
    const request = ctx.request = Object.create(this.request)
    const response = ctx.response = Object.create(this.response)
    // 原生屬性
    ctx.app = request.app = response.app = this;
    ctx.req = request.req = response.req = req
    ctx.res = request.res = response.res = res

    request.ctx = response.ctx = ctx;
    request.response = response;
    response.request = request;

    return ctx
  }

  // 中介軟體處理邏輯實現
  compose(middleware) {
    return function(ctx{
      return dispatch(0)
      function dispatch(i){
        let fn = middleware[i]
        if (!fn) {
          return Promise.resolve()
        }
        // dispatch.bind(null, i + 1) 為應用中介軟體接受到的 next
        // next 即下一個應用中介軟體的函式引用
        try {
          return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)))
        } catch (error) {
          return Promise.reject(error)
        }
      }
    }
  }
}

// 處理 body 不同型別輸出
function respond(ctx{
  let res = ctx.res
  let body = ctx.body
  if (typeof body === 'string') {
    return res.end(body)
  }
  if (typeof body === 'object') {
    return res.end(JSON.stringify(body))
  }
}

寫在最後

本文的簡單實現了 Koa 主要的功能。有興趣最好還是自己去看原始碼,實現自己的迷你版 Koa。其實 Koa 的原始碼不算多,總共4個檔案,全部程式碼包括註釋也就 1800 行左右。而且邏輯不會很難,很推薦閱讀,尤其適合原始碼入門級別的同學觀看。

最後附上完整實現的程式碼:github

相關文章