koa源碼閱讀[0] Node.js也是寫了兩三年的時間了,剛開始學習Node的時候,hello world就是創建一個HttpServer,後來在工作中也是經歷過Express、Koa1.x、Koa2.x以及最近還在研究的結合著TypeScript的routing-controllers(驅動依然 ...
koa源碼閱讀[0]
Node.js也是寫了兩三年的時間了,剛開始學習Node
的時候,hello world
就是創建一個HttpServer
,後來在工作中也是經歷過Express
、Koa1.x
、Koa2.x
以及最近還在研究的結合著TypeScript
的routing-controllers
(驅動依然是Express
與Koa
)。
用的比較多的還是Koa
版本,也是對它的洋蔥模型比較感興趣,所以最近抽出時間來閱讀其源碼,正好近期可能會對一個Express
項目進行重構,將其重構為koa2.x
版本的,所以,閱讀其源碼對於重構也是一種有效的幫助。
Koa是怎麼來的
首先需要確定,Koa是什麼。
任何一個框架的出現都是為瞭解決問題,而Koa則是為了更方便的構建http服務而出現的。
可以簡單的理解為一個HTTP服務的中間件框架。
使用http模塊創建http服務
相信大家在學習Node時,應該都寫過類似這樣的代碼:
const http = require('http') const serverHandler = (request, response) => { response.end('Hello World') // 返回數據 } http .createServer(serverHandler) .listen(8888, _ => console.log('Server run as http://127.0.0.1:8888'))
一個最簡單的示例,腳本運行後訪問http://127.0.0.1:8888
即可看到一個Hello World
的字元串。
但是這僅僅是一個簡單的示例,因為我們不管訪問什麼地址(甚至修改請求的Method),都總是會獲取到這個字元串:
> curl http://127.0.0.1:8888 > curl http://127.0.0.1:8888/sub > curl -X POST http://127.0.0.1:8888
所以我們可能會在回調中添加邏輯,根據路徑、Method來返回給用戶對應的數據:
const serverHandler = (request, response) => { // default let responseData = '404' if (request.url === '/') { if (request.method === 'GET') { responseData = 'Hello World' } else if (request.method === 'POST') { responseData = 'Hello World With POST' } } else if (request.url === '/sub') { responseData = 'sub page' } response.end(responseData) // 返回數據 }
類似Express的實現
但是這樣的寫法還會帶來另一個問題,如果是一個很大的項目,存在N多的介面。
如果都寫在這一個handler
裡邊去,未免太過難以維護。
示例只是簡單的針對一個變數進行賦值,但是真實的項目不會有這麼簡單的邏輯存在的。
所以,我們針對handler
進行一次抽象,讓我們能夠方便的管理路徑:
class App { constructor() { this.handlers = {} this.get = this.route.bind(this, 'GET') this.post = this.route.bind(this, 'POST') } route(method, path, handler) { let pathInfo = (this.handlers[path] = this.handlers[path] || {}) // register handler pathInfo[method] = handler } callback() { return (request, response) => { let { url: path, method } = request this.handlers[path] && this.handlers[path][method] ? this.handlers[path][method](request, response) : response.end('404') } } }
然後通過實例化一個Router對象進行註冊對應的路徑,最後啟動服務:
const app = new App() app.get('/', function (request, response) { response.end('Hello World') }) app.post('/', function (request, response) { response.end('Hello World With POST') }) app.get('/sub', function (request, response) { response.end('sub page') }) http .createServer(app.callback()) .listen(8888, _ => console.log('Server run as http://127.0.0.1:8888'))
Express中的中間件
這樣,就實現了一個代碼比較整潔的HttpServer
,但功能上依舊是很簡陋的。
如果我們現在有一個需求,要在部分請求的前邊添加一些參數的生成,比如一個請求的唯一ID。
將代碼重覆編寫在我們的handler
中肯定是不可取的。
所以我們要針對route
的處理進行優化,使其支持傳入多個handler
:
route(method, path, ...handler) { let pathInfo = (this.handlers[path] = this.handlers[path] || {}) // register handler pathInfo[method] = handler } callback() { return (request, response) => { let { url: path, method } = request let handlers = this.handlers[path] && this.handlers[path][method] if (handlers) { let context = {} function next(handlers, index = 0) { handlers[index] && handlers[index].call(context, request, response, () => next(handlers, index + 1) ) } next(handlers) } else { response.end('404') } } }
然後針對上邊的路徑監聽添加其他的handler:
function generatorId(request, response, next) { this.id = 123 next() } app.get('/', generatorId, function(request, response) { response.end(`Hello World ${this.id}`) })
這樣在訪問介面時,就可以看到Hello World 123
的字樣了。
這個就可以簡單的認為是在Express
中實現的 中間件。
中間件是Express
、Koa
的核心所在,一切依賴都通過中間件來進行載入。
更靈活的中間件方案-洋蔥模型
上述方案的確可以讓人很方便的使用一些中間件,在流程式控制制中調用next()
來進入下一個環節,整個流程變得很清晰。
但是依然存在一些局限性。
例如如果我們需要進行一些介面的耗時統計,在Express
有這麼幾種可以實現的方案:
function beforeRequest(request, response, next) { this.requestTime = new Date().valueOf() next() } // 方案1. 修改原handler處理邏輯,進行耗時的統計,然後end發送數據 app.get('/a', beforeRequest, function(request, response) { // 請求耗時的統計 console.log( `${request.url} duration: ${new Date().valueOf() - this.requestTime}` ) response.end('XXX') }) // 方案2. 將輸出數據的邏輯挪到一個後置的中間件中 function afterRequest(request, response, next) { // 請求耗時的統計 console.log( `${request.url} duration: ${new Date().valueOf() - this.requestTime}` ) response.end(this.body) } app.get( '/b', beforeRequest, function(request, response, next) { this.body = 'XXX' next() // 記得調用,不然中間件在這裡就終止了 }, afterRequest )
無論是哪一種方案,對於原有代碼都是一種破壞性的修改,這是不可取的。
因為Express
採用了response.end()
的方式來向介面請求方返回數據,調用後即會終止後續代碼的執行。
而且因為當時沒有一個很好的方案去等待某個中間件中的非同步函數的執行。
function a(_, _, next) { console.log('before a') let results = next() console.log('after a') } function b(_, _, next) { console.log('before b') setTimeout(_ => { this.body = 123456 next() }, 1000) } function c(_, response) { console.log('before c') response.end(this.body) } app.get('/', a, b, c)
就像上述的示例,實際上log的輸出順序為:
before a
before b
after a
before c
這顯然不符合我們的預期,所以在Express
中獲取next()
的返回值是沒有意義的。
所以就有了Koa
帶來的洋蔥模型,在Koa1.x
出現的時間,正好趕上了Node支持了新的語法,Generator
函數及Promise
的定義。
所以才有了co
這樣令人驚嘆的庫,而當我們的中間件使用了Promise
以後,前一個中間件就可以很輕易的在後續代碼執行完畢後再處理自己的事情。
但是,Generator
本身的作用並不是用來幫助我們更輕鬆的使用Promise
來做非同步流程的控制。
所以,隨著Node7.6版本的發出,支持了async
、await
語法,社區也推出了Koa2.x
,使用async
語法替換之前的co
+Generator
。
Koa
也將co
從依賴中移除(2.x版本使用koa-convert將Generator
函數轉換為promise
,在3.x版本中將直接不支持Generator
)
ref: remove generator supports
由於在功能、使用上Koa
的兩個版本之間並沒有什麼區別,最多就是一些語法的調整,所以會直接跳過一些Koa1.x
相關的東西,直奔主題。
在Koa
中,可以使用如下的方式來定義中間件並使用:
async function log(ctx, next) { let requestTime = new Date().valueOf() await next() console.log(`${ctx.url} duration: ${new Date().valueOf() - requestTime}`) } router.get('/', log, ctx => { // do something... })
因為一些語法糖的存在,遮蓋了代碼實際運行的過程,所以,我們使用Promise
來還原一下上述代碼:
function log() { return new Promise((resolve, reject) => { let requestTime = new Date().valueOf() next().then(_ => { console.log(`${ctx.url} duration: ${new Date().valueOf() - requestTime}`) }).then(resolve) }) }
大致代碼是這樣的,也就是說,調用next
會給我們返回一個Promise
對象,而Promise
何時會resolve
就是Koa
內部做的處理。
可以簡單的實現一下(關於上邊實現的App類,僅僅需要修改callback
即可):
callback() { return (request, response) => { let { url: path, method } = request let handlers = this.handlers[path] && this.handlers[path][method] if (handlers) { let context = { url: request.url } function next(handlers, index = 0) { return new Promise((resolve, reject) => { if (!handlers[index]) return resolve() handlers[index](context, () => next(handlers, index + 1)).then( resolve, reject ) }) } next(handlers).then(_ => { // 結束請求 response.end(context.body || '404') }) } else { response.end('404') } } }
每次調用中間件時就監聽then
,並將當前Promise
的resolve
與reject
處理傳入Promise
的回調中。
也就是說,只有當第二個中間件的resolve
被調用時,第一個中間件的then
回調才會執行。
這樣就實現了一個洋蔥模型。
就像我們的log
中間件執行的流程:
- 獲取當前的時間戳
requestTime
- 調用
next()
執行後續的中間件,並監聽其回調 - 第二個中間件裡邊可能會調用第三個、第四個、第五個,但這都不是
log
所關心的,log
只關心第二個中間件何時resolve
,而第二個中間件的resolve
則依賴他後邊的中間件的resolve
。 - 等到第二個中間件
resolve
,這就意味著後續沒有其他的中間件在執行了(全都resolve
了),此時log
才會繼續後續代碼的執行
所以就像洋蔥一樣一層一層的包裹,最外層是最大的,是最先執行的,也是最後執行的。(在一個完整的請求中,next
之前最先執行,next
之後最後執行)。
小記
最近抽時間將Koa
相關的源碼翻看一波,看得挺激動的,想要將它們記錄下來。
應該會拆分為幾段來,不一篇全寫了,上次寫了個裝飾器的,太長,看得自己都困了。
先占幾個坑:
- 核心模塊 koa與koa-compose
- 熱門中間件 koa-router與koa-views
- 雜七雜八的輪子 koa-bodyparser/multer/better-body/static