跳到主要內容

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

前言


本文是我在閱讀 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 來訪問
*/
// 請求的 query 參數
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
},
// 設置頭部
set(filed, val) {
if (typeof filed === 'string') {
this.res.setHeader(filed, val)
}
if (toString.call(filed) === '[object Object]') {
for (const key in filed) {
this.set(key, filed[key])
}
}
}
}

屬性代理


通過上面的實現,我們可以使用 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 包實現。這裏我就直接簡單封裝下代理函數,代理函數主要用到__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')
delegateGetter('response', 'body')
delegateSetter('response', 'body')
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 的傳遞。


function compose(middleware) {
return function(ctx) {
return dispatch(0)
function dispatch(i){
// 取出中間件
let fn = middleware[i]
if (!fn) {
return
}
// dispatch.bind(null, i + 1) 為應用中間件接受到的 next
// next 即下一個應用中間件
fn(ctx, dispatch.bind(null, i + 1))
}
}
}

可以看到,實現過程本質是函數的遞歸調用。在內部實現時,其實 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)

最後,因為 Koa 中間件是可以使用 async/await 異步執行的,所以還需要修改下 compose 返回 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)
}
}
}
}

應用


實現完成中間件的邏輯后,將它應用到迷你版Koa中,原來的代碼邏輯要做一些修改(部分代碼忽略)


application.js:


module.exports = class Coa {
constructor() {
// 存儲中間件的數組
this.middleware = []
}

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)
// 在所有中間件執行完畢后 respond 函數用於處理 ctx.body 輸出
return fn(ctx).then(() => respond(ctx)).catch(console.error)
}
return handleRequest
}

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
try {
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)))
} catch (error) {
return Promise.reject(error)
}
}
}
}
}

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))
}
}

完整實現


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

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】



※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益



※別再煩惱如何寫文案,掌握八大原則!



※教你寫出一流的銷售文案?



※超省錢租車方案



※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益



※產品缺大量曝光嗎?你需要的是一流包裝設計!



※回頭車貨運收費標準



Orignal From: Koa源碼解析,帶你實現一個迷你版的Koa

留言

這個網誌中的熱門文章

有了四步解題法模板,再也不害怕動態規劃!(看不懂算我輸)

導言 動態規劃問題一直是算法面試當中的重點和難點,並且動態規劃這種通過空間換取時間的算法思想在實際的工作中也會被頻繁用到,這篇文章的目的主要是解釋清楚 什麼是動態規劃 ,還有就是面對一道動態規劃問題,一般的 思考步驟 以及其中的注意事項等等,最後通過幾道題目將理論和實踐結合。 什麼是動態規劃 如果你還沒有聽說過動態規劃,或者僅僅只有耳聞,或許你可以看看 Quora 上面的這個 回答 。 How to explain dynamic 用一句話解釋動態規劃就是 " 記住你之前做過的事 ",如果更準確些,其實是 " 記住你之前得到的答案 "。 我舉個大家工作中經常遇到的例子。 在軟件開發中,大家經常會遇到一些系統配置的問題,配置不對,系統就會報錯,這個時候一般都會去 Google 或者是查閱相關的文檔,花了一定的時間將配置修改好。 過了一段時間,去到另一個系統,遇到類似的問題,這個時候已經記不清之前修改過的配置文件長什麼樣,這個時候有兩種方案,一種方案還是去 Google 或者查閱文檔,另一種方案是借鑒之前修改過的配置,第一種做法其實是萬金油,因為你遇到的任何問題其實都可以去 Google,去查閱相關文件找答案,但是這會花費一定的時間,相比之下,第二種方案肯定會更加地節約時間,但是這個方案是有條件的,條件如下: 之前的問題和當前的問題有着關聯性,換句話說,之前問題得到的答案可以幫助解決當前問題 需要記錄之前問題的答案 當然在這個例子中,可以看到的是,上面這兩個條件均滿足,大可去到之前配置過的文件中,將配置拷貝過來,然後做些細微的調整即可解決當前問題,節約了大量的時間。 不知道你是否從這些描述中發現,對於一個動態規劃問題,我們只需要從兩個方面考慮,那就是 找出問題之間的聯繫 ,以及 記錄答案 ,這裏的難點其實是找出問題之間的聯繫,記錄答案只是順帶的事情,利用一些簡單的數據結構就可以做到。 概念 上面的解釋如果大家可以理解的話,接    動態規劃 算法是通過拆分問題,定義問題狀態和狀態之間的關係,使得問題能夠以遞推(或者說分治)的方式去解決。它的幾個重要概念如下所述。    階段: 對於一個完整的問題過程,適當的切分為若干個相互聯繫的子問題,每次在求解一個子問題...

計算機本地文件快要滅絕了

   編者按: 文件是数字世界的基石,是我們基本的工作單位。但是,隨着互聯網的雲化、平台化、服務化,文件日益變得可有可無。這樣一種改變究竟好不好呢?喜歡懷舊的 Simon Pitt 開始回顧各種文件的好處,哪怕這讓他顯得不合時宜。原文發表在 medium 上,標題是:Computer Files Are Going Extinct   我喜歡文件。我喜歡對文件重命名、移動、排序,改變它們在文件夾中的显示方式,去備份文件,將之上傳到互聯網,恢復它們,對其進行複製,甚至還可以對文件進行碎片整理。作為信息存儲方式的一種隱喻,在我看來文件是很出色的。我喜歡把文件當作一個工作單位。如果我要寫篇文章,文章會放在文件裏面。如果我要生成圖像,圖像會保存進文件裏面。    謳歌 files.doc   文件是擬物化的。這是個很花哨的詞,只是用來表示文件是反映現實物品的一個数字概念。比方說,Word 文檔就像一張紙,躺在你的辦公桌上(desktop)。JPEG 就像一幅畫,等等。它們每個都有一個小圖標,圖標的樣子看起來像它們所代表的現實物品。一堆紙,一個畫框,一個馬尼拉文件夾。真的挺很迷人的。   我喜歡文件的一點是,不管裏面有什麼,跟文件的交互方式總是一致的。我上面提到的那些東西——複製、排序、碎片整理——我可以對任何文件進行那些處理。文件可能是圖像、遊戲的一部分、也可能是我最喜歡的餐具清單。碎片整理程序不在乎它是什麼。它不會去判斷內容。   自從我開始在 Windows 95 裏面創建文件以來,我就一直都很喜歡文件。但是我注意到我們已經開始慢慢地遠離把文件當作基本工作單位的做法。 Windows95。我的計算機    services.mp3 的興起   十幾歲的時候,我開始痴迷於收集和管理数字音樂:我收藏 MP3 文件。一大堆的 128 kbps MP3 文件。如果你足夠幸運,有自己的 CD 刻錄機的話,就可以將它們刻錄到 CD 上,然後在朋友之間傳遞。一張 CD 可以容納 700 MB。這相當於將近 500 張軟盤!   我會仔細端詳我的收藏,然後煞費苦心地給它們添加上 IDv1 和 IDv2 音樂標籤。隨着時間的流逝,大家開始開發可以在雲端自動獲取曲目列表的工具,這樣你就可以檢查和驗證 MP3 的質量。有時候我甚至會去聽那些該死的東西,儘管...

純電動 Mini Cooper SE 將成為中國國產車,年產 16 萬輛

BMW 集團與中國長城汽車合資,將於江蘇建立新廠,專門投入生產 MINI Cooper SE 和部分長城品牌電動車,預計於 2022 年完工並投入生產,每年將可生產 16 萬輛電動車。 靈動可愛的 Mini Cooper,在許多車迷心中都有著特殊的地位,今年 7 月發表了首款純電動版本的 Mini Cooper SE 之後,獲得熱烈迴響,預訂數量已接近 8 萬台,顯示大家對於純電 Mini 的熱愛,因為油電版的 Mini Cooper Countryman 的全球總銷售量也才 3 萬出頭。 Mini Cooper SE 之前公布了官方定價,最低從 27,900 歐元起算,美國售價約 29,900 美元。相比現有的三門款,只貴了一成左右。然而,三年後,中國消費者將有機會買到最便宜的電動 Mini。 電動 Mini Cooper SE 最低價是 27,900 歐元,扣掉全額補助最低可以到 24,400 歐元。 BMW 集團與中國長城汽車集團於 2018 年宣布,將組建合資公司光束汽車,投入在中國的電動車生產計畫,而現在他們正式宣布啟動計畫,於江蘇張家港打造一個新工廠,全部投入電動車的製造,包括了 Mini Cooper SE 和其他長城汽車旗下的電動車。 目前的電動 Mini 只在英國牛津工廠製造,不難想像當產能轉移到中國後,Mini Cooper SE 的價格將有機會進一步調降,來競爭全球最大的電動車市場。這座屬於合資公司光束汽車的新工廠,採用一個新的產銷模式,由 BMW 和長城共同合作開發、設計、製造新產品,但是銷售通路完全沿用原本的品牌渠道。 換句話說,2020 年到 2022 年銷售的電動 Mini,將會是英國製造,而 2022 年後就會有中國製造版本開賣,考量到 Mini 在中國每年約有 30 萬輛的銷售額,同時油電版的 Coutryman 銷量更佔了全球將近五分之一,無怪乎 BMW 會想在最接近主要市場的地方蓋工廠囉。 外型完美復刻油車版 最後,簡單介紹一下 Mini Cooper SE 這台車。Mini 在電動化的路上,盡力保持著跟經典造型一致的設計,畢竟大家愛的就是它的設計。電動版的 Mini 車頭、車身跟車屁股都多了一個黃色的插頭標誌,車頭的氣壩則變成封閉式設計,除此之外,幾乎看不出來差別,連馬達...