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