一步一步來:手寫Koa2

山頭人漢波發表於2022-04-12

之前講過Koa2從零到腳手架,以及從淺入深瞭解Koa2原始碼

這篇文章講解如何手寫一個 Koa2

Step 1:封裝 HTTP 服務和建立 Koa 建構函式

之前閱讀 Koa2 的原始碼得知, Koa 的服務應用是基於 Node 原生的 HTTP 模組,對其進行封裝形成的,我們先用原生 Node 實現 HTTP 服務

const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200)
  res.end('hello world')
})

server.listen(3000, () => {
  console.log('監聽3000埠')
})

再看看用 Koa2 實現 HTTP 服務

const Koa = require('Koa')
const app = new Koa()

app.use((ctx, next) => {
  ctx.body = 'hello world'
})

app.listen(3000, () => {
  console.log('3000請求成功')
})

實現 Koa 的第一步,就是對 原生 HTTP 服務進行封裝,我們按照 Koa 原始碼的結構,新建 lib/application.js 檔案,程式碼如下:

const http = require('http')

class Application {
  constructor() {
    this.callbackFunc
  }
  listen(port) {
    const server = http.createServer(this.callback())
    server.listen(port)
  }
  use(fn) {
    this.callbackFunc = fn
  }
  callback() {
    return (req, res) => this.callbackFunc(req, res)
  }
}

module.exports = Application

我們引入手寫的 Koa,並寫個 demo

const Koa = require('./lib/application')

const app = new Koa()

app.use((req, res) => {
  res.writeHead(200)
  res.end('hello world')
})

app.listen(3000, () => {
  console.log('3000請求成功')
})

啟動服務後,在瀏覽器中輸入 http://localhost:3000,內容顯示”Hello,World“

接著我們有兩個方向,一是簡化 res.writeHead(200)、res.end('Hello world') ;二是做塞入多箇中介軟體。要想做第一個點需要先寫 context,response,request 檔案。做第二點其實做到後面也需要依賴 context,所以我們先做簡化原生 response、request,以及將它整合到 context(ctx)物件上

Step 2:構建 request、response、context 物件

request、response、context 物件分別對應 request.js、response.js、context.js,request.js 處理請求體,response.js 處理響應體,context 整合了 request 和 response

// request
let url = require('url')
module.exports = {
  get query() {
    return url.parse(this.req.url, true).query
  },
}
// response
module.exporrs = {
  get body() {
    return this._body
  },
  set body(data) {
    this._body = data
  },
  get status() {
    return this.res.statusCode
  },
  set status(statusCode) {
    if (typeof statusCode !== 'number') {
      throw new Error('statusCode must be a number')
    }
    this.res.statusCode = statusCode
  },
}

這裡我們在 request 中只做了 query 處理,在 response 中只做了 body、status 的處理。無論是 request 還是 response,我們都使用了 ES6 的 get、set,簡單來說,get/set 就是能對一個 key 進行取值和賦值

現在我們已經實現了 request、response,獲取了 request、response 物件和它們的封裝方法,接下來我們來寫 context。我們在原始碼分析時曾經說過,context 繼承了 request 和 response 物件的引數,既有請求體中的方法,又有響應體中的方法,例如既能 ctx.query 查詢請求體中 url 上的引數,又能通過 ctx.body 返回資料。

module.exports = {
  get query() {
    return this.request.query
  },
  get body() {
    return this.response.body
  },
  set body(data) {
    this.response.body = data
  },
  get status() {
    return this.response.status
  },
  set status(statusCode) {
    this.response.status = statusCode
  },
}

在原始碼中使用了 delegate,把 context 中的 context.request、context.response 上的方法代理到了 context 上,即 context.request.query === context.query; context.response.body === context.body。而 context.request,context.response 則是在 application 中掛載

總結一下:request.js 負責簡化請求體的程式碼,response.js 負責簡化響應體的程式碼,context.js 把請求體和響應體整合在一個物件上,並且都在 application 上生成,修改 application.js 檔案,新增程式碼如下:

const http = require('http');
const context = require('context')
const request = require('request')
const response = require('response')
class Application {
    constructor() {
        this.callbackFunc
          this.context = context
        this.request = request
        this.response = response
    }
    ...
    createConext(req, res) {
        const ctx = Object.create(this.context)
        ctx.request = Object.create(this.request)
        ctx.response = Object.create(this.response)
        ctx.req = ctx.request.req = req
        ctx.res = ctx.response.res = res
        return ctx
    }
    ...
}

因為 context、request、response 在其他方法中要用到,所以我們在構造器中就把他們分別賦值為 this.context、this.request、this.response 。我們實現了上下文 ctx ,現在我們回到之前的問題,簡寫 res.writeHead(200)、res.end('Hello world')

我們要想把 res.writeHead(200)、res.end('Hello world') 簡化為 ctx.body = 'Hello world',該怎麼做呢?

res.writeHead(200)、res.end('Hello world') 是原生的, ctx.body = 'Hello world' 是 Koa 的使用方法,我們要對 ctx.body = 'Hello world' 做解析並轉換為 res.writeHead(200)、res.end('Hello world') 。好在 ctx 已經通過 createContext 獲取,那麼再建立一個方法來封裝 res.end,用 ctx.body 來表示

  responseBody(ctx) {
    let context = ctx.body
    if (typeof context === 'string') {
      ctx.res.end(context)
    } else if (typeof context === 'object') {
      ctx.res.end(JSON.stringify(context))
    }
  }

最後我們修改 callback 方法

//   callback() {
//     return (req, res) => this.callbackFunc(req, res)
//   }
callback() {
    return (req, res) => {
      // 把原生 req,res 封裝為 ctx
      const ctx = this.createContext(req, res)
      // 執行 use 中的函式, ctx.body 賦值
      this.callbackFunc(ctx)
      // 封裝 res.end,用 ctx.body 表示
      return this.responseBody(ctx)
    }
}
PS:具體程式碼:請看倉庫中的 Step 2

Step 3:中介軟體機制和洋蔥模型

我們知道, Koa2 中最重要的功能是中介軟體,它的表現形式是可以用多個 use,每一個 use 方法中的函式就是一箇中介軟體,通過第二個引數 next 來表示傳遞給下一個中介軟體,例如

app.use(async (ctx, next) => {
  console.log(1)
  await next()
  console.log(6)
})

app.use(async (ctx, next) => {
  console.log(2)
  await next()
  console.log(5)
})

app.use(async (ctx, next) => {
  console.log(3)
  ctx.body = 'hello world'
  console.log(4)
})
// 結果 123456

所以,我們的中介軟體是個陣列,其次,通過 next ,執行和暫停執行。一 next ,就暫停本中介軟體的執行,去執行下一個中介軟體。

Koa 的洋蔥模型在 Koa1 中是用 generator + co.js 實現的, Koa2 則使用了 async/await + Promise 去實現。這次我們也是用 async/await + Promise 來實現

在原始碼分析時,我們就說了 Koa2 的中介軟體合成是獨立成一個庫,即 koa-compose,它的核心程式碼如下:

function compose(middleware) {
  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)
      }
    }
  }
}

具體解讀可以去原始碼分析上檢視,這裡我們不做探究

這裡貼兩種解決方案,其實都是遞迴它

componse() {
    return async (ctx) => {
      function createNext(middleware, oldNext) {
        return async () => {
          await middleware(ctx, oldNext)
        }
      }
      let len = this.middlewares.length
      let next = async () => {
        return Promise.resolve()
      }
      for (let i = len - 1; i >= 0; i--) {
        let currentMiddleware = this.middlewares[i]
        next = createNext(currentMiddleware, next)
      }
      await next()
    }
}

還有一種就是原始碼,關於 compose 函式,筆者還不能很好的寫出個所以然,讀者們請自行理解

Step 4:錯誤捕獲與監聽機制

中介軟體中的錯誤程式碼如何捕獲,因為中介軟體返回的是 Promise 例項,所以我們只需要 catch 錯誤處理就好,新增 onerror 方法

onerror(err, ctx) {
    if (err.code === 'ENOENT') {
      ctx.status = 404
    } else {
      ctx.status = 500
    }
    let msg = ctx.message || 'Internal error'
    ctx.res.end(msg)
    this.emit('error', err)
}
callback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      const respond = () => this.responseBody(ctx)
      + const onerror = (err) => this.onerror(err, ctx)
      let fn = this.componse()
      + return fn(ctx).then(respond).catch(onerror)
    }
}

我們現在只是對中介軟體部分做了錯誤捕獲,但是如果其他地方寫錯了程式碼,怎麼知道以及通知給開發者,Node 提供了一個原生模組——events,我們的 Application 類繼承它就能獲取到監聽功能,這樣,當伺服器上有錯誤發生時就能全部捕獲

總結

我們先讀了 Koa2 的原始碼,知道後其資料結構及使用方式後,再漸進式手寫了一個,這裡特別感謝第一名小蝌蚪的 KOA2 框架原理解析和實現,他的這篇文章是我寫 Koa2 文章的依據。說回 Koa2,它的功能特別簡單,就是對原生 req,res 做了處理,讓開發者能更容易地寫程式碼;除此之外,引入中介軟體概念,這就像外掛,引入即可使用,不需要時能減少程式碼,輕量大概就是 Koa2 的關鍵字吧

GitHub 地址:https://github.com/johanazhu/...

參考資料

相關文章