node進階——之事無鉅細手寫koa原始碼

rocYoung發表於2018-09-21

koa是一個基於nodejs的web開發框架,特點是小而精,對比大而全的express,兩者雖然由同一團隊開發,但各有其更適合的應用場景:express適合開發較大的企業級應用,而koa致力於成為web開發中的基石,例如egg.js就是基於koa開發的。

關於兩個框架的區別和聯絡,後期我會再寫一篇express原始碼解析,這裡不贅述。本文的主要目的如下:

koa官網上說:“koa提供了一套優雅的方法,幫助您快速而愉快地編寫服務端應用程式”。這套優雅的方法是什麼?是如何實現的?讓我們一探究竟,並手寫原始碼。

過去我不瞭解太陽,那時我過的是冬天——聶魯達

傻瓜式用法

koa的用法可以說非常傻瓜,我們快速過一下:

首先映入眼簾的不是假山,是hello world

const Koa = require('koa');
const app = new Koa();
app.use((ctx, next) =>
{
ctx.body = 'Hello World';

});
app.listen(3000);
複製程式碼

不用框架時的寫法

let http = require('http')let server = http.createServer((req, res) =>
{
res.end('hello world')
})server.listen(4000)複製程式碼

對比發現,相對原生,koa多了兩個例項上的use、listen方法,和use回撥中的ctx、next兩個引數。這四個不同,幾乎就是koa的全部了,也是這四個不同讓koa如此強大。

listen

簡單!http的語法糖,實際上還是用了http.createServer(),然後監聽了一個埠。

ctx

比較簡單!利用 上下文(context) 機制,將原來的req,res物件合二為一,並進行了大量擴充,使開發者可以方便的使用更多屬性和方法,大大減少了處理字串、提取資訊的時間,免去了許多引入第三方包的過程。(例如ctx.query、ctx.path等)

use

重點!koa的核心 —— 中介軟體(middleware)。解決了非同步程式設計中回撥地獄的問題,基於Promise,利用 洋蔥模型 思想,使巢狀的、糾纏不清的程式碼變得清晰、明確,並且可擴充,可定製,藉助許多第三方中介軟體,可以使精簡的koa更加全能(例如koa-router,實現了路由)。其原理主要是一個極其精妙的 compose 函式。在使用時,用 next() 方法,從上一個中介軟體跳到下一個中介軟體。

注:以上加粗部分,下面都有詳細介紹。

原始碼

koa有多簡單?簡單到只有四個檔案,算上大量的空行和註釋,加起來不到1800行程式碼(有用的也就幾百行)。

github.com/koajs/koa/t…

node進階——之事無鉅細手寫koa原始碼
node進階——之事無鉅細手寫koa原始碼

所以,學習koa原始碼並不是一個痛苦的過程。豪不誇張的說,搞定這四個檔案,手寫下面的100多行程式碼,你就能完全理解koa。為了防止大段程式碼的出現,我會講的很詳細。

準備工作

模仿官方,我們建立一個koa資料夾,並建立四個檔案:application.js,context.js,request.js,response.js。通過檢視package.json可以發現,application.js為入口檔案。

node進階——之事無鉅細手寫koa原始碼

context.js是上下文物件相關,request.js是請求物件相關,response.js是響應物件相關。

  • 首先,梳理一下思路,原理無非就是use的時候拿到一個回撥函式,listen的時候執行這個函式。

  • 此外,use回撥函式的引數ctx擴充了很多功能,這個ctx其實就是原生的req、res經過一系列處理產生的。

  • 其實,第一句不準確,use可以多次,所以是多個回撥函式,使用者第二個引數next()跳到下一個,把多個use的回撥函式按照規則順序執行。

  • 那麼,看起來就很簡單了,難點只有兩個:一個是如何將原生req和res加工成ctx,另一個是如何實現中介軟體。

  • 第一個,ctx其實就是一個上下文物件,request和response兩個檔案用來擴充屬性,context檔案實現代理,我們會手寫相關原始碼。

  • 第二個,原始碼中的中介軟體由一箇中介軟體執行模組koa-compose實現,這裡我們會手寫一個。

application.js

結合上面hello world,可以明確,koa是一個類,例項上主要兩個方法,use和listen。

上面說過,listen是http的語法糖,所以要引入http模組。

Koa有一套錯誤處理機制,需要監聽例項的error事件。所以要引入events模組繼承EventEmitter。再引入另外三個自定義模組。

let http = require('http')let EventEmitter = require('events')let context = require('./context')let request = require('./request')let response = require('./response')class Koa extends EventEmitter { 
constructor () {
super()
} use () {

} listen () {

}
}module.exports = Koa複製程式碼

這三個模組,其實都是一個物件,為了程式碼能跑通,這裡先簡單匯出一下。

context.js

let proto = {
} // proto同原始碼定義的變數名module.exports = proto複製程式碼

request.js

let request = {
}module.exports = request複製程式碼

response.js

let response = {
}module.exports = response複製程式碼

開始寫Koa類裡面的程式碼,先實現建立服務的功能:1、listen方法建立一個http服務並監聽一個埠。2、use方法把回撥傳入。

class Koa extends EventEmitter { 
constructor () {
super() this.fn
} use (fn) {
this.fn = fn // 使用者使用use方法時,回撥賦給this.fn
} listen (...args) {
let server = http.createServer(this.fn) // 放入回撥 server.listen(...args) // 因為listen方法可能有多引數,所以這裡直接解構所有引數就可以了
}
}複製程式碼

這樣就可以啟動一個服務了,測試一下:

let Koa = require('./application')let app = new Koa()app.use((req, res) =>
{
// 還沒寫中介軟體,所以這裡還不是ctx和next res.end('hello world')
})app.listen(3000)複製程式碼

下面先解決ctx,ctx是一個上下文物件,裡面繫結了很多請求和相應相關的資料和方法,例如ctx.path、ctx.query、ctx.body()等等等等,極大的為開發提供了便利。

思路是這樣的:使用者呼叫use方法時,把這個回撥fn存起來,建立一個createContext函式用來建立上下文,建立一個handleRequest函式用來處理請求,使用者listen時將handleRequest放進createServer回撥中,在函式內呼叫fn並將上下文物件傳入,使用者就得到了ctx。

class Koa extends EventEmitter { 
constructor () {
super() this.fn this.context = context // 將三個模組儲存,全域性的放到例項上 this.request = request this.response = response
} use (fn) {
this.fn = fn
} createContext(req, res){
// 這是核心,建立ctx // 使用Object.create方法是為了繼承this.context但在增加屬性時不影響原物件 const ctx = Object.create(this.context) 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.ctx = response.ctx = ctx request.response = response response.request = request return ctx
} handleRequest(req,res){
// 建立一個處理請求的函式 let ctx = this.createContext(req, res) // 建立ctx this.fn(ctx) // 呼叫使用者給的回撥,把ctx還給使用者使用。 res.end(ctx.body) // ctx.body用來輸出到頁面,後面會說如何繫結資料到ctx.body
} listen (...args) {
let server = http.createServer(this.handleRequest.bind(this))// 這裡使用bind呼叫,以防this丟失 server.listen(...args)
}
}複製程式碼

如果不理解Object.create可以看這個例子:

let o1 = {a: 'hello'
}let o2 = Object.create(o1)o2.b = 'world'console.log('o1:', o1.b) // 建立出的物件不會影響原物件console.log('o2:', o2.a) // 建立出的物件會繼承原物件的屬性複製程式碼

o1: undefined
o2: hello


經過上面的操作,使用者在ctx上可以用各種姿勢取到想要的值。

例如url,可以用ctx.req.url、ctx.request.req.url、ctx.response.req.url取到。

app.use((ctx) =>
{
console.log(ctx.req.url) console.log(ctx.request.req.url) console.log(ctx.response.req.url) console.log(ctx.request.url) console.log(ctx.request.path) console.log(ctx.url) console.log(ctx.path)
})複製程式碼

訪問localhost:3000/abc

/abc
/abc
/abc
/undefined
/undefined
/undefined
/undefined

姿勢多,不一定爽,要想爽,我們希望能實現以下兩點:

  • 從自定義的request上取值、擴充除了 原生屬性外的更多屬性,例如query path等。
  • 能夠直接通過ctx.url的方式取值,上面都不夠方便。

1 修改request

request.js

let url = require('url')let request = { 
get url() {
// 這樣就可以用ctx.request.url上取值了,不用通過原生的req return this.req.url
}, get path() {
return url.parse(this.req.url).pathname
}, get query() {
return url.parse(this.req.url).query
} // 。。。。。。
}module.exports = request複製程式碼

非常簡單,使用物件get訪問器返回一個處理過的資料就可以將資料繫結到request上了,這裡的問題是如何拿到資料,由於前面ctx.request這一步,所以this就是ctx,那this.req就是原生的req,再利用一些第三方模組對req進行處理就可以了,原始碼上擴充了非常多,這裡只舉例幾個,看懂原理即可。

訪問localhost:3000/abc?id=1

/abc?id=1
/abc?id=1
/abc?id=1
/abc?id=1
/abc
undefined
undefined

2 接下來要實現ctx直接取值,這裡是通過一個代理來實現的

context.js

let proto = {
}function defineGetter(prop, name){
// 建立一個defineGetter函式,引數分別是要代理的物件和物件上的屬性 proto.__defineGetter__(name, function(){
// 每個物件都有一個__defineGetter__方法,可以用這個方法實現代理,下面詳解 return this[prop][name] // 這裡的this是ctx(原因下面解釋),所以ctx.url得到的就是this.request.url
})
}defineGetter('request', 'url')defineGetter('request', 'path')// .......module.exports = proto複製程式碼

訪問localhost:3000/abc?id=1

/abc?id=1
/abc?id=1
/abc?id=1
/abc?id=1
/abc
/abc?id=1
/abc

__defineGetter__方法可以將一個函式繫結在當前物件的指定屬性上,當那個屬性的值被讀取時,你所繫結的函式就會被呼叫,第一個引數是屬性,第二個是函式,由於ctx繼承了proto,所以當ctx.url時,觸發了__defineGetter__方法,所以這裡的this就是ctx。這樣,當呼叫defineGetter方法,就可以將引數一的引數二屬性代理到ctx上了。

有個問題,要代理多少個屬性就要呼叫多少遍defineGetter函式麼?是的,如果想優雅一點,可以模仿官方原始碼,提出一個delegates模組,批量代理(其實也沒優雅到哪去),這裡為了方便展示,還是看懂即可吧。

3 修改response。根據koa的api,輸出資料到頁面不是res.end(‘xx’)也不是res.send(‘xx’),而是ctx.body = ‘xx’。我們要實現設定ctx.body,還要實現獲取ctx.body。

response.js

let response = { 
get body(){
return this._body // get時返回出去
}, set body(value){
this.res.statusCode = 200 // 只要設定了body,就應該把狀態碼設定為200 this._body = value // set時先儲存下來
}
}module.exports = response複製程式碼

這樣得到的是ctx.response.body,並不是ctx.body,同樣,通過context代理一下

修改context

let proto = {
}function defineGetter (prop, name) {
proto.__defineGetter__(name, function(){
return this[prop][name]
})
}function defineSetter (prop, name) {
proto.__defineSetter__(name, function(val){
// 用__defineSetter__方法設定值 this[prop][name] = val
})
}defineGetter('request', 'url')defineGetter('request', 'path')defineGetter('response', 'body') // 同樣代理response的body屬性defineSetter('response', 'body') // 同理module.exports = proto複製程式碼

測試一下

app.use((ctx) =>
{
ctx.body = 'hello world' console.log(ctx.body)
})複製程式碼

訪問localhost:3000

node控制檯輸出:

hello world

網頁顯示:hello world

接下來解決一下body的問題,上面說了,一旦給body設定值,狀態碼就改成200,那麼沒設定值就應該是404。還有,使用者不光會輸出字串,還有可能是檔案、頁面、json等,這裡都要處理,所以改一下handleRequest函式:

let Stream = require('stream') // 引入streamhandleRequest(req,res){ 
res.statusCode = 404 // 預設404 let ctx = this.createContext(req, res) this.fn(ctx) if(typeof ctx.body == 'object'){
// 如果是個物件,按json形式輸出 res.setHeader('Content-Type', 'application/json;
charset=utf8') res.end(JSON.stringify(ctx.body))
} else if (ctx.body instanceof Stream){
// 如果是流 ctx.body.pipe(res)
} else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {
// 如果是字串或buffer res.setHeader('Content-Type', 'text/htmlcharset=utf8') res.end(ctx.body)
} else {
res.end('Not found')
}
}複製程式碼

這樣上下文相關就實現了,接下來看重中之重:中介軟體

現在只能use一次,我們要實現use多次,並可以在use的回撥函式中使用next方法跳到下一個中介軟體,在此之前,我們先了解一個概念:“洋蔥模型”。

node進階——之事無鉅細手寫koa原始碼

當我們多次使用use時

    app.use((crx, next) =>
{
console.log(1) next() console.log(2)
}) app.use((crx, next) =>
{
console.log(3) next() console.log(4)
}) app.use((crx, next) =>
{
console.log(5) next() console.log(6)
})複製程式碼

它的執行順序是這樣的:

1
3
5
6
4
2

next方法會呼叫下一個use,next下面的程式碼會在下一個use執行完再執行,我們可以把上面的程式碼想象成這樣:

app.use((ctx, next) =>
{
console.log(1) // next() 被替換成下一個use裡的程式碼 console.log(3) // next() 又被替換成下一個use裡的程式碼 console.log(5) // next() 沒有下一個use了,所以這個無效 console.log(6) console.log(4) console.log(2)
})複製程式碼

這樣的話,理所應當輸出135642

這就是洋蔥模型了,通過next把執行權交給下一個中介軟體。

這樣,開發者手中的請求資料會像儀仗隊一樣,乖乖的經過每一層中介軟體的檢閱,最後響應給使用者。

既應付了複雜的操作,又避免了混亂的巢狀。

除此之外,koa的中介軟體還支援非同步,可以使用async/await

app.use(async (ctx, next) =>
{
console.log(1) await next() console.log(2)
})app.use(async (ctx, next) =>
{
console.log(3) let p = new Promise((resolve, roject) =>
{
setTimeout(() =>
{
console.log('3.5') resolve()
}, 1000)
}) await p.then() await next() console.log(4) ctx.body = 'hello world'
})複製程式碼

1
3
// 一秒後
3.5
4
2

async函式返回的是一個promise,當上一個use的next前加上await關鍵字,會等待下一個use的回撥resolve了再繼續執行程式碼。

所有現在要做的事有兩步:

第一步,讓多個use的回撥按照順序排列成串。

這裡用到了陣列和遞迴,每次use將當前函式存到一個陣列中,最後按順序執行。執行這一步用到一個compose函式,這個函式是重中之重。

constructor () { 
super() // this.fn 改成: this.middlewares = [] // 需要一個陣列將每個中介軟體按順序存放起來 this.context = context this.request = request this.response = response
}use (fn) {
// this.fn = fn 改成: this.middlewares.push(fn) // 每次use,把當前回撥函式存進陣列
}compose(middlewares, ctx){
// 簡化版的compose,接收中介軟體陣列、ctx物件作為引數 function dispatch(index){
// 利用遞迴函式將各中介軟體串聯起來依次呼叫 if(index === middlewares.length) return // 最後一次next不能執行,不然會報錯 let middleware = middlewares[index] // 取當前應該被呼叫的函式 middleware(ctx, () =>
dispatch(index + 1)) // 呼叫並傳入ctx和下一個將被呼叫的函式,使用者next()時執行該函式
} dispatch(0)
}handleRequest(req,res){
res.statusCode = 404 let ctx = this.createContext(req, res) // this.fn(ctx) 改成: this.compose(this.middlewares, ctx) // 呼叫compose,傳入引數 if(typeof ctx.body == 'object'){
res.setHeader('Content-Type', 'application/json;
charset=utf8') res.end(JSON.stringify(ctx.body))
} else if (ctx.body instanceof Stream){
ctx.body.pipe(res)
} else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {
res.setHeader('Content-Type', 'text/htmlcharset=utf8') res.end(ctx.body)
} else {
res.end('Not found')
}
}複製程式碼

再次測試上面列印123456的例子,可以正確的得到135642

第二步,把每個回撥包裝成Promise以實現非同步。

最後一步,用Promise.resolve將每個回撥包裝成Promise,並在呼叫時then,不懂Promise的可以去看我的另一篇文章[juejin.im/post/5ab20c…]

compose(middlewares, ctx){ 
function dispatch(index){
if(index === middlewares.length) return Promise.resolve() // 若最後一箇中介軟體,返回一個resolve的promise let middleware = middlewares[index] return Promise.resolve(middleware(ctx, () =>
dispatch(index + 1))) // 用Promise.resolve把中介軟體包起來
} return dispatch(0)
}handleRequest(req,res){
res.statusCode = 404 let ctx = this.createContext(req, res) let fn = this.compose(this.middlewares, ctx) fn.then(() =>
{
// then了之後再進行判斷 if(typeof ctx.body == 'object'){
res.setHeader('Content-Type', 'application/json;
charset=utf8') res.end(JSON.stringify(ctx.body))
} else if (ctx.body instanceof Stream){
ctx.body.pipe(res)
} else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {
res.setHeader('Content-Type', 'text/htmlcharset=utf8') res.end(ctx.body)
} else {
res.end('Not found')
}
}).catch(err =>
{
// 監控錯誤發射error,用於app.on('error', (err) =>
{
}) this.emit('error', err) res.statusCode = 500 res.end('server error')
})
}複製程式碼

完整application程式碼

let http = require('http')let EventEmitter = require('events')let context = require('./context')let request = require('./request')let response = require('./response')let Stream = require('stream')class Koa extends EventEmitter {constructor () { 
super() this.middlewares = [] this.context = context this.request = request this.response = response
}use (fn) {
this.middlewares.push(fn)
}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.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(middlewares, ctx){
function dispatch (index) {
if (index === middlewares.length) return Promise.resolve() let middleware = middlewares[index] return Promise.resolve(middleware(ctx, () =>
dispatch(index + 1)))
} return dispatch(0)
}handleRequest(req,res){
res.statusCode = 404 let ctx = this.createContext(req, res) let fn = this.compose(this.middlewares, ctx) fn.then(() =>
{
if (typeof ctx.body == 'object') {
res.setHeader('Content-Type', 'application/json;
charset=utf8') res.end(JSON.stringify(ctx.body))
} else if (ctx.body instanceof Stream) {
ctx.body.pipe(res)
} else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {
res.setHeader('Content-Type', 'text/htmlcharset=utf8') res.end(ctx.body)
} else {
res.end('Not found')
}
}).catch(err =>
{
this.emit('error', err) res.statusCode = 500 res.end('server error')
})
}listen (...args) {
let server = http.createServer(this.handleRequest.bind(this)) server.listen(...args)
}
}module.exports = Koa複製程式碼

總結

這樣就完成了全部核心功能的編寫,通過本文你就可以足夠了解koa了,如果對你有幫助,不妨點個贊。

來源:https://juejin.im/post/5ba48fc4e51d450e704277fa#comment

相關文章