接上次挖的坑,對koa2.x
相關的原始碼進行分析 第一篇。
不得不說,koa
是一個很輕量、很優雅的http框架,尤其是在2.x以後移除了co
的引入,使其程式碼變得更為清晰。
express
和koa
同為一批人進行開發,與express
相比,koa
顯得非常的迷你。
因為express
是一個大而全的http
框架,內建了類似router
之類的中介軟體進行處理。
而在koa
中,則將類似功能的中介軟體全部摘了出來,早期koa
裡邊是內建了koa-compose
的,而現在也是將其分了出來。
koa
只保留一個簡單的中介軟體的整合,http
請求的處理,作為一個功能性的中介軟體框架來存在,自身僅有少量的邏輯。
koa-compose
則是作為整合中介軟體最為關鍵的一個工具、洋蔥模型的具體實現,所以要將兩者放在一起來看。
koa基本結構
.
├── application.js
├── request.js
├── response.js
└── context.js
複製程式碼
關於koa
整個框架的實現,也只是簡單的拆分為了四個檔案。
就象在上一篇筆記中模擬的那樣,建立了一個物件用來註冊中介軟體,監聽http
服務,這個就是application.js
在做的事情。
而框架的意義呢,就是在框架內,我們要按照框架的規矩來做事情,同樣的,框架也會提供給我們一些更易用的方式來讓我們完成需求。
針對http.createServer
回撥的兩個引數request
和response
進行的一次封裝,簡化一些常用的操作。
例如我們對Header
的一些操作,在原生http
模組中可能要這樣寫:
// 獲取Content-Type
request.getHeader('Content-Type')
// 設定Content-Type
response.setHeader('Content-Type', 'application/json')
response.setHeader('Content-Length', '18')
// 或者,忽略前邊的statusCode,設定多個Header
response.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': '18'
})
複製程式碼
而在koa
中可以這樣處理:
// 獲取Content-Type
context.request.get('Content-Type')
// 設定Content-Type
context.response.set({
'Content-Type': 'application/json',
'Content-Length': '18'
})
複製程式碼
簡化了一些針對request
與response
的操作,將這些封裝在了request.js
和response.js
檔案中。
但同時這會帶來一個使用上的困擾,這樣封裝以後其實獲取或者設定header
變得層級更深,需要通過context
找到request
、response
,然後才能進行操作。
所以,koa
使用了node-delegates來進一步簡化這些步驟,將request.get
、response.set
通通代理到context
上。
也就是說,代理後的操作是這樣子的:
context.get('Content-Type')
// 設定Content-Type
context.set({
'Content-Type': 'application/json',
'Content-Length': '18'
})
複製程式碼
這樣就變得很清晰了,獲取Header
,設定Header
,再也不會擔心寫成request.setHeader
了,一氣呵成,通過context.js
來整合request.js
與response.js
的行為。
同時context.js
也會提供一些其他的工具函式,例如Cookie
之類的操作。
由application
引入context
,context
中又整合了request
和response
的功能,四個檔案的作用已經很清晰了:
file | desc |
---|---|
applicaiton | 中介軟體的管理、http.createServer 的回撥處理,生成Context 作為本次請求的引數,並呼叫中介軟體 |
request | 針對http.createServer -> request 功能上的封裝 |
response | 針對http.createServer -> response 功能上的封裝 |
context | 整合request 與response 的部分功能,並提供一些額外的功能 |
而在程式碼結構上,只有application
對外的koa
是採用的Class
的方式,其他三個檔案均是丟擲一個普通的Object
。
拿一個完整的流程來解釋
建立服務
首先,我們需要建立一個http
服務,在koa2.x
中建立服務與koa1.x
稍微有些區別,要求使用例項化的方式來進行建立:
const app = new Koa()
複製程式碼
而在例項化的過程中,其實koa
只做了有限的事情,建立了幾個例項屬性。
將引入的context
、request
以及response
通過Object.create
拷貝的方式放到例項中。
this.middleware = [] // 最關鍵的一個例項屬性
// 用於在收到請求後建立上下文使用
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
複製程式碼
在例項化完成後,我們就要進行註冊中介軟體來實現我們的業務邏輯了,上邊也提到了,koa
僅用作一箇中介軟體的整合以及請求的監聽。
所以不會像express
那樣提供router.get
、router.post
之類的操作,僅僅存在一個比較接近http.createServer
的use()
。
接下來的步驟就是註冊中介軟體並監聽一個埠號啟動服務:
const port = 8000
app.use(async (ctx, next) => {
console.time('request')
await next()
console.timeEnd('request')
})
app.use(async (ctx, next) => {
await next()
ctx.body = ctx.body.toUpperCase()
})
app.use(ctx => {
ctx.body = 'Hello World'
})
app.use(ctx => {
console.log('never output')
})
app.listen(port, () => console.log(`Server run as http://127.0.0.1:${port}`))
複製程式碼
在翻看application.js
的原始碼時,可以看到,暴露給外部的方法,常用的基本上就是use
和listen
。
一個用來載入中介軟體,另一個用來監聽埠並啟動服務。
而這兩個函式實際上並沒有過多的邏輯,在use
中僅僅是判斷了傳入的引數是否為一個function
,以及在2.x版本針對Generator
函式的一些特殊處理,將其轉換為了Promise
形式的函式,並將其push
到建構函式中建立的middleware
陣列中。
這個是從1.x
過渡到2.x
的一個工具,在3.x
版本將直接移除Generator
的支援。
其實在koa-convert
內部也是引用了co
和koa-compose
來進行轉化,所以也就不再贅述。
而在listen
中做的事情就更簡單了,只是簡單的呼叫http.createServer
來建立服務,並監聽對應的埠之類的操作。
有一個細節在於,createServer
中傳入的是koa
例項的另一個方法呼叫後的返回值callback
,這個方法才是真正的回撥處理,listen
只是http
模組的一個快捷方式。
這個是為了一些用socket.io
、https
或者一些其他的http
模組來進行使用的。
也就意味著,只要是可以提供與http
模組一致的行為,koa
都可以很方便的接入。
listen(...args) {
debug('listen')
const server = http.createServer(this.callback())
return server.listen(...args)
}
複製程式碼
使用koa-compose合併中介軟體
所以我們就來看看callback
的實現:
callback() {
const fn = compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}
return handleRequest
}
複製程式碼
在函式內部的第一步,就是要處理中介軟體,將一個陣列中的中介軟體轉換為我們想要的洋蔥模型格式的。
這裡就用到了比較核心的koa-compose
其實它的功能上與co
類似,只不過把co
處理Generator
函式那部分邏輯全部去掉了,本身co
的程式碼也就是一兩百行,所以精簡後的koa-compose
程式碼僅有48行。
我們知道,async
函式實際上剝開它的語法糖以後是長這個樣子的:
async function func () {
return 123
}
// ==>
function func () {
return Promise.resolve(123)
}
// or
function func () {
return new Promise(resolve => resolve(123))
}
複製程式碼
所以拿上述use
的程式碼舉例,實際上koa-compose
拿到的是這樣的引數:
[
function (ctx, next) {
return new Promise(resolve => {
console.time('request')
next().then(() => {
console.timeEnd('request')
resolve()
})
})
},
function (ctx, next) {
return new Promise(resolve => {
next().then(() => {
ctx.body = ctx.body.toUpperCase()
resolve()
})
})
},
function (ctx, next) {
return new Promise(resolve => {
ctx.body = 'Hello World'
resolve()
})
},
function (ctx, next) {
return new Promise(resolve => {
console.log('never output')
resolve()
})
}
]
複製程式碼
就像在第四個函式中輸出表示的那樣,第四個中介軟體不會被執行,因為第三個中介軟體並沒有呼叫next
,所以實現類似這樣的一個洋蔥模型是很有意思的一件事情。
首先拋開不變的ctx
不談,洋蔥模型的實現核心在於next
的處理。
因為next
是你進入下一層中介軟體的鑰匙,只有手動觸發以後才會進入下一層中介軟體。
然後我們還需要保證next
要在中介軟體執行完畢後進行resolve
,返回到上一層中介軟體:
return function (context, next) {
// last called middleware #
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)
}
}
}
複製程式碼
所以明確了這兩點以後,上邊的程式碼就會變得很清晰:
- next用來進入下一個中介軟體
- next在當前中介軟體執行完成後會觸發回撥通知上一個中介軟體,而完成的前提是內部的中介軟體已經執行完成(
resolved
)
可以看到在呼叫koa-compose
以後實際上會返回一個自執行函式。
在執行函式的開頭部分,判斷當前中介軟體的下標來防止在一箇中介軟體中多次呼叫next
。
因為如果多次呼叫next
,就會導致下一個中介軟體的多次執行,這樣就破壞了洋蔥模型。
其次就是compose
實際上提供了一個在洋蔥模型全部執行完畢後的回撥,一個可選的引數,實際上作用與呼叫compose
後邊的then
處理沒有太大區別。
以及上邊提到的,next
是進入下一個中介軟體的鑰匙,可以在這一個柯里化函式的應用上看出來:
Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
複製程式碼
將自身繫結了index
引數後傳入本次中介軟體,作為呼叫函式的第二個引數,也就是next
,效果就像呼叫了dispatch(1)
,這樣就是一個洋蔥模型的實現。
而fn
的呼叫如果是一個async function
,那麼外層的Promise.resolve
會等到內部的async
執行resolve
以後才會觸發resolve
,例如這樣:
Promise.resolve(new Promise(resolve => setTimeout(resolve, 500))).then(console.log) // 500ms以後才會觸發 console.log
複製程式碼
P.S. 一個從koa1.x
切換到koa2.x
的暗坑,co
會對陣列進行特殊處理,使用Promise.all
進行包裝,但是koa2.x
沒有這樣的操作。
所以如果在中介軟體中要針對一個陣列進行非同步操作,一定要手動新增Promise.all
,或者說等草案中的await*
。
// koa1.x
yield [Promise.resolve(1), Promise.resolve(2)] // [1, 2]
// koa2.x
await [Promise.resolve(1), Promise.resolve(2)] // [<Promise>, <Promise>]
// ==>
await Promise.all([Promise.resolve(1), Promise.resolve(2)]) // [1, 2]
await* [Promise.resolve(1), Promise.resolve(2)] // [1, 2]
複製程式碼
接收請求,處理返回值
經過上邊的程式碼,一個koa
服務已經算是執行起來了,接下來就是訪問看效果了。
在接收到一個請求後,koa
會拿之前提到的context
與request
、response
來建立本次請求所使用的上下文。
在koa1.x
中,上下文是繫結在this
上的,而在koa2.x
是作為第一個引數傳入進來的。
個人猜測可能是因為Generator
不能使用箭頭函式,而async
函式可以使用箭頭函式導致的吧:) 純屬個人YY
總之,我們通過上邊提到的三個模組建立了一個請求所需的上下文,基本上是一通兒賦值,程式碼就不貼了,沒有太多邏輯,就是有一個小細節比較有意思:
request.response = response
response.request = request
複製程式碼
讓兩者之間產生了一個引用關係,既可以通過request
獲取到response
,也可以通過response
獲取到request
。
而且這是一個遞迴的引用,類似這樣的操作:
let obj = {}
obj.obj = obj
obj.obj.obj.obj === obj // true
複製程式碼
同時如上文提到的,在context
建立的過程中,將一大批的request
和response
的屬性、方法代理到了自身,有興趣的可以自己翻看原始碼(看著有點暈):koa.js | context.js
這個delegate的實現也算是比較簡單,通過取出原始的屬性,然後存一個引用,在自身的屬性被觸發時呼叫對應的引用,類似一個民間版的Proxy
吧,期待後續能夠使用Proxy
代替它。
然後我們會將生成好的context
作為引數傳入koa-compose
生成的洋蔥中去。
因為無論何種情況,洋蔥肯定會返回結果的(出錯與否),所以我們還需要在最後有一個finished
的處理,做一些類似將ctx.body
轉換為資料進行輸出之類的操作。
koa
使用了大量的get
、set
訪問器來實現功能,例如最常用的ctx.body = 'XXX'
,它是來自response
的set body
。
這應該是request
、response
中邏輯最複雜的一個方法了。
裡邊要處理很多東西,例如在body
內容為空時幫助你修改請求的status code
為204,並移除無用的headers
。
以及如果沒有手動指定status code
,會預設指定為200
。
甚至還會根據當前傳入的引數來判斷content-type
應該是html
還是普通的text
:
// string
if ('string' == typeof val) {
if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text'
this.length = Buffer.byteLength(val)
return
}
複製程式碼
以及還包含針對流(Stream
)的特殊處理,例如如果要用koa
實現靜態資源下載的功能,也是可以直接呼叫ctx.body
進行賦值的,所有的東西都已經在response.js
中幫你處理好了:
// stream
if ('function' == typeof val.pipe) {
onFinish(this.res, destroy.bind(null, val))
ensureErrorHandler(val, err => this.ctx.onerror(err))
// overwriting
if (null != original && original != val) this.remove('Content-Length')
if (setType) this.type = 'bin'
return
}
// 可以理解為是這樣的程式碼
let stream = fs.createReadStream('package.json')
ctx.body = stream
// set body中的處理
onFinish(res, () => {
destory(stream)
})
stream.pipe(res) // 使response接收流是在洋蔥模型完全執行完以後再進行的
複製程式碼
onFinish用來監聽流是否結束、destory用來關閉流
其餘的訪問器基本上就是一些常見操作的封裝,例如針對querystring
的封裝。
在使用原生http
模組的情況下,處理URL中的引數,是需要自己引入額外的包進行處理的,最常見的是querystring
。
koa
也是在內部引入的該模組。
所以對外丟擲的query
大致是這個樣子的:
get query() {
let query = parse(this.req).query
return qs.parse(query)
}
// use
let { id, name } = ctx.query // 因為 get query也被代理到了context上,所以可以直接引用
複製程式碼
parse為parseurl庫,用來從request中提出query引數
亦或者針對cookies
的封裝,也是內建了最流行的cookies
。
在第一次觸發get cookies
時才去例項化Cookie
物件,將這些繁瑣的操作擋在使用者看不到的地方:
get cookies() {
if (!this[COOKIES]) {
this[COOKIES] = new Cookies(this.req, this.res, {
keys: this.app.keys,
secure: this.request.secure
})
}
return this[COOKIES]
}
set cookies(_cookies) {
this[COOKIES] = _cookies
}
複製程式碼
所以在koa
中使用Cookie
就像這樣就可以了:
this.cookies.get('uid')
this.cookies.set('name', 'Niko')
// 如果不想用cookies模組,完全可以自己賦值為自己想用的cookie
this.cookies = CustomeCookie
this.cookies.mget(['uid', 'name'])
複製程式碼
這是因為在get cookies
裡邊有判斷,如果沒有一個可用的Cookie例項,才會預設去例項化。
洋蔥模型執行完成後的一些操作
koa
的一個請求流程是這樣的,先執行洋蔥裡邊的所有中介軟體,在執行完成以後,還會有一個回撥函式。
該回撥用來根據中介軟體執行過程中所做的事情來決定返回給客戶端什麼資料。
拿到ctx.body
、ctx.status
這些引數進行處理。
包括前邊提到的流(Stream
)的處理都在這裡:
if (body instanceof Stream) return body.pipe(res) // 等到這裡結束後才會呼叫我們上邊`set body`中對應的`onFinish`的處理
複製程式碼
同時上邊還有一個特殊的處理,如果為false則不做任何處理,直接返回:
if (!ctx.writable) return
複製程式碼
其實這個也是response
提供的一個訪問器,這裡邊用來判斷當前請求是否已經呼叫過end
給客戶端返回了資料,如果已經觸發了response.end()
以後,則response.finished
會被置為true
,也就是說,本次請求已經結束了,同時訪問器中還處理了一個bug
,請求已經返回結果了,但是依然沒有關閉套接字:
get writable() {
// can't write any more after response finished
if (this.res.finished) return false
const socket = this.res.socket
// There are already pending outgoing res, but still writable
// https://github.com/nodejs/node/blob/v4.4.7/lib/_http_server.js#L486
if (!socket) return true
return socket.writable
}
複製程式碼
這裡就有一個koa
與express
對比的劣勢了,因為koa
採用的是一個洋蔥模型,對於返回值,如果是使用ctx.body = 'XXX'
來進行賦值,這會導致最終呼叫response.end
時在洋蔥全部執行完成後再進行的,也就是上邊所描述的回撥中,而express
就是在中介軟體中就可以自由控制何時返回資料:
// express.js
router.get('/', function (req, res) {
res.send('hello world')
// 在傳送資料後做一些其他處理
appendLog()
})
// koa.js
app.use(ctx => {
ctx.body = 'hello world'
// 然而依然發生在傳送資料之前
appendLog()
})
複製程式碼
不過好在還是可以通過直接呼叫原生的response
物件來進行傳送資料的,當我們手動呼叫了response.end
以後(response.finished === true
),就意味著最終的回撥會直接跳過,不做任何處理。
app.use(ctx => {
ctx.res.end('hello world')
// 在傳送資料後做一些其他處理
appendLog()
})
複製程式碼
異常處理
koa的整個請求,實際上還是一個Promise
,所以在洋蔥模型後邊的監聽不僅僅有resolve
,對reject
也同樣是有處理的。
期間任何一環出bug都會導致後續的中介軟體以及前邊等待回撥的中介軟體終止,直接跳轉到最近的一個異常處理模組。
所以,如果有類似介面耗時統計的中介軟體,一定要記得在try-catch
中執行next
的操作:
app.use(async (ctx, next) => {
try {
await next()
} catch (e) {
console.error(e)
ctx.body = 'error' // 因為內部的中介軟體並沒有catch 捕獲異常,所以丟擲到了這裡
}
})
app.use(async (ctx, next) => {
let startTime = new Date()
try {
await next()
} finally {
let endTime = new Date() // 丟擲異常,但是不影響這裡的正常輸出
}
})
app.use(ctx => Promise.reject(new Error('test')))
複製程式碼
P.S. 如果異常被捕獲,則會繼續執行後續的response
:
app.use(async (ctx, next) => {
try {
throw new Error('test')
} catch (e) {
await next()
}
})
app.use(ctx => {
ctx.body = 'hello'
})
// curl 127.0.0.1
// > hello
複製程式碼
如果自己的中介軟體沒有捕獲異常,就會走到預設的異常處理模組中。
在預設的異常模組中,基本上是針對statusCode的一些處理,以及一些預設的錯誤顯示:
const code = statuses[err.status]
const msg = err.expose ? err.message : code
this.status = err.status
this.length = Buffer.byteLength(msg)
this.res.end(msg)
複製程式碼
statuses是一個第三方模組,包括各種http code的資訊: statuses
建議在最外層的中介軟體都自己做異常處理,因為預設的錯誤提示有點兒太難看了(純文字),自己處理跳轉到異常處理頁面會好一些,以及避免一些介面因為預設的異常資訊導致解析失敗。
redirect的注意事項
在原生http
模組中進行302
的操作(俗稱重定向),需要這麼做:
response.writeHead(302, {
'Location': 'redirect.html'
})
response.end()
// or
response.statusCode = 302
response.setHeader('Location', 'redirect.html')
response.end()
複製程式碼
而在koa
中也有redirect
的封裝,可以通過直接呼叫redirect
函式來完成重定向,但是需要注意的是,呼叫完redirect
之後並沒有直接觸發response.end()
,它僅僅是新增了一個statusCode
及Location
而已:
redirect(url, alt) {
// location
if ('back' == url) url = this.ctx.get('Referrer') || alt || '/'
this.set('Location', url)
// status
if (!statuses.redirect[this.status]) this.status = 302
// html
if (this.ctx.accepts('html')) {
url = escape(url)
this.type = 'text/html charset=utf-8'
this.body = `Redirecting to <a href="${url}">${url}</a>.`
return
}
// text
this.type = 'text/plain charset=utf-8'
this.body = `Redirecting to ${url}.`
}
複製程式碼
後續的程式碼還會繼續執行,所以建議在redirect
之後手動結束當前的請求,也就是直接return
,不然很有可能後續的status
、body
賦值很可能會導致一些詭異的問題。
app.use(ctx => {
ctx.redirect('https://baidu.com')
// 建議直接return
// 後續的程式碼還在執行
ctx.body = 'hello world'
ctx.status = 200 // statusCode的改變導致redirect失效
})
複製程式碼
小記
koa
是一個很好玩的框架,在閱讀原始碼的過程中,其實也發現了一些小問題:
- 多人合作維護一份程式碼,確實能夠看出各人都有不同的編碼風格,例如
typeof val !== 'string'
和'number' == typeof code
,很顯然的兩種風格。2333 - delegate的呼叫方式在屬性特別多的時候並不是很好看,一大長串的鏈式呼叫,如果換成迴圈會更好看一下
但是,koa
依然是一個很棒的框架,很適合閱讀原始碼來進行學習,這些都是一些小細節,無傷大雅。
總結一下koa
與koa-compose
的作用:
koa
註冊中介軟體、註冊http
服務、生成請求上下文呼叫中介軟體、處理中介軟體對上下文物件的操作、返回資料結束請求koa-compose
將陣列中的中介軟體集合轉換為序列呼叫,並提供鑰匙(next
)用來跳轉下一個中介軟體,以及監聽next
獲取內部中介軟體執行結束的通知