接上次挖的坑,對koa2.x
相關的源碼進行分析 第一篇。
不得不說,koa
是一個很輕量、很優雅的http框架,尤爲是在2.x之後移除了co
的引入,使其代碼變得更爲清晰。 javascript
express
和koa
同爲一批人進行開發,與express
相比,koa
顯得很是的迷你。
由於express
是一個大而全的http
框架,內置了相似router
之類的中間件進行處理。
而在koa
中,則將相似功能的中間件所有摘了出來,早期koa
裏邊是內置了koa-compose
的,而如今也是將其分了出來。 koa
只保留一個簡單的中間件的整合,http
請求的處理,做爲一個功能性的中間件框架來存在,自身僅有少許的邏輯。 koa-compose
則是做爲整合中間件最爲關鍵的一個工具、洋蔥模型的具體實現,因此要將二者放在一塊兒來看。html
. ├── application.js ├── request.js ├── response.js └── context.js
關於koa
整個框架的實現,也只是簡單的拆分爲了四個文件。 前端
就象在上一篇筆記中模擬的那樣,建立了一個對象用來註冊中間件,監聽http
服務,這個就是application.js
在作的事情。
而框架的意義呢,就是在框架內,咱們要按照框架的規矩來作事情,一樣的,框架也會提供給咱們一些更易用的方式來讓咱們完成需求。
針對http.createServer
回調的兩個參數request
和response
進行的一次封裝,簡化一些經常使用的操做。
例如咱們對Header
的一些操做,在原生http
模塊中可能要這樣寫:java
// 獲取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
中能夠這樣處理:node
// 獲取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
上。
也就是說,代理後的操做是這樣子的:git
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
之類的操做。github
由application
引入context
,context
中又整合了request
和response
的功能,四個文件的做用已經很清晰了:express
file | desc |
---|---|
applicaiton | 中間件的管理、http.createServer 的回調處理,生成Context 做爲本次請求的參數,並調用中間件 |
request | 針對http.createServer -> request 功能上的封裝 |
response | 針對http.createServer -> response 功能上的封裝 |
context | 整合request 與response 的部分功能,並提供一些額外的功能 |
而在代碼結構上,只有application
對外的koa
是採用的Class
的方式,其餘三個文件均是拋出一個普通的Object
。json
首先,咱們須要建立一個http
服務,在koa2.x
中建立服務與koa1.x
稍微有些區別,要求使用實例化的方式來進行建立:segmentfault
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) }
因此咱們就來看看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) } } }
因此明確了這兩點之後,上邊的代碼就會變得很清晰:
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
建議在最外層的中間件都本身作異常處理,由於默認的錯誤提示有點兒太難看了(純文本),本身處理跳轉到異常處理頁面會好一些,以及避免一些接口由於默認的異常信息致使解析失敗。
在原生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可是,koa
依然是一個很棒的框架,很適合閱讀源碼來進行學習,這些都是一些小細節,無傷大雅。
總結一下koa
與koa-compose
的做用:
koa
註冊中間件、註冊http
服務、生成請求上下文調用中間件、處理中間件對上下文對象的操做、返回數據結束請求koa-compose
將數組中的中間件集合轉換爲串行調用,並提供鑰匙(next
)用來跳轉下一個中間件,以及監聽next
獲取內部中間件執行結束的通知我司如今大量招人咯,前端、Node方向都有HC 公司名:Blued,座標帝都朝陽雙井 主要技術棧是React,也會有機會玩ReactNative和Electron Node方向8.x版本+koa 新項目會以TS爲主 有興趣的小夥伴能夠私聊我,或者: email: jiashunming@blued.com wechat: github_jiasm