扣丁書屋

Node進階——之事無巨細手寫Koa源碼

作者 rocYoung

Koa是一個基于Node.js的Web開發框架,特點是小而精,對比大而全的Express(編者按:此處是相對來說,國內當然是有Egg.js和ThinkJS),兩者雖然由同一團隊開發,但各有其更適合的應用場景: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…(https://github.com/koajs/koa/tree/master/lib)

所以,學習Koa源碼并不是一個痛苦的過程。豪不夸張的說,搞定這四個文件,手寫下面的100多行代碼,你就能完全理解Koa。為了防止大段代碼的出現,我會講的很詳細。

準備工作

模仿官方,我們建立一個koa文件夾,并創建四個文件:application.js,context.js,request.js,response.js。 通過查看package.json可以發現,application.js為入口文件。

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 request = {}
module.exports = request

開始寫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') // 引入stream
handleRequest(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方法跳到下一個中間件,在此之前,我們先了解一個概念:“洋蔥模型”。

當我們多次使用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以實現異步。

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

最多閱讀

為Electron程序添加運行時日志 3年以前  |  14867次閱讀
Node.js下通過配置host訪問URL 3年以前  |  4765次閱讀
js動態創建類和實例化 3年以前  |  3902次閱讀
wordpress標簽頁的制作 3年以前  |  3739次閱讀
初探 React 組件 3年以前  |  3712次閱讀
用 esbuild 讓你的構建壓縮性能翻倍 2年以前  |  3699次閱讀
500行PHP代碼搞定富文本安全過濾 3年以前  |  3606次閱讀
10 種跨域解決方案(附終極方案) 2年以前  |  3508次閱讀
22個HTML5的初級技巧 3年以前  |  3480次閱讀
MBTI免費在線測試 3年以前  |  3428次閱讀
使用 SRI 增強 localStorage 代碼安全 3年以前  |  3357次閱讀
淺談瀏覽器的原生拖拽事件 3年以前  |  3352次閱讀
CSS清除浮動 3年以前  |  3348次閱讀
第三版主題上線 3年以前  |  3285次閱讀
利用服務器返回header來傳輸數據 3年以前  |  3256次閱讀

手機掃碼閱讀
18禁止午夜福利体验区,人与动人物xxxx毛片人与狍,色男人窝网站聚色窝
<蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>