前言
本文是我在閱讀 Koa
原始碼後,並實現迷你版 Koa
的過程。如果你使用過 Koa
但不知道內部的原理,我想這篇文章應該能夠幫助到你,實現一個迷你版的 Koa
不會很難。
本文會循序漸進的解析內部原理,包括:
基礎版本的 koa context 的實現 中介軟體原理及實現
檔案結構
application.js
: 入口檔案,裡面包括我們常用的use
方法、listen
方法以及對ctx.body
做輸出處理context.js
: 主要是做屬性和方法的代理,讓使用者能夠更簡便的訪問到request
和response
的屬性和方法request.js
: 對原生的req
屬性做處理,擴充套件更多可用的屬性和方法,比如:query
屬性、get
方法response.js
: 對原生的res
屬性做處理,擴充套件更多可用的屬性和方法,比如:status
屬性、set
方法
基礎版本
用法:
const Coa = require('./coa/application')
const app = new Coa()
// 應用中介軟體
app.use((ctx) => {
ctx.body = '<h1>Hello</h1>'
})
app.listen(3000, '127.0.0.1')
application.js
:
const http = require('http')
module.exports = class Coa {
use(fn) {
this.fn = fn
}
// listen 只是語法糖 本身還是使用 http.createServer
listen(...args) {
const server = http.createServer(this.callback())
server.listen(...args)
}
callback() {
const handleRequest = (req, res) => {
// 建立上下文
const ctx = this.createContext(req, res)
// 呼叫中介軟體
this.fn(ctx)
// 輸出內容
res.end(ctx.body)
}
return handleRequest
}
createContext(req, res) {
let ctx = {}
ctx.req = req
ctx.res = res
return ctx
}
}
基礎版本的實現很簡單,呼叫 use
將函式儲存起來,在啟動伺服器時再執行這個函式,並輸出 ctx.body
的內容。
但是這樣是沒有靈魂的。接下來,實現 context
和中介軟體原理,Koa
才算完整。
Context
ctx
為我們擴充套件了很多好用的屬性和方法,比如 ctx.query
、ctx.set()
。但它們並不是 context
封裝的,而是在訪問 ctx
上的屬性時,它內部通過屬性劫持將 request
和 response
內封裝的屬性返回。就像你訪問 ctx.query
,實際上訪問的是 ctx.request.query
。
說到劫持你可能會想到 Object.defineProperty
,在 Kao
內部使用的是 ES6
提供的物件的 setter
和 getter
,效果也是一樣的。所以要實現 ctx
,我們首先要實現 request
和 response
。
在此之前,需要修改下 createContext
方法:
// 這三個都是物件
const context = require('./context')
const request = require('./request')
const response = require('./response')
module.exports = class Coa {
constructor() {
this.context = context
this.request = request
this.response = response
}
createContext(req, res) {
const ctx = Object.create(this.context)
// 將擴充套件的 request、response 掛載到 ctx 上
// 使用 Object.create 建立以傳入引數為原型的物件,避免新增屬性時因為衝突影響到原物件
const request = ctx.request = Object.create(this.request)
const response = ctx.response = Object.create(this.response)
ctx.app = request.app = response.app = this;
// 掛載原生屬性
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
}
}
上面一堆花裡胡哨的賦值,是為了能通過多種途徑獲取屬性。比如獲取 query
屬性,可以有 ctx.query
、ctx.request.query
、ctx.app.query
等等的方式。
如果你覺得看起來有點冗餘,也可以主要理解這幾行,因為我們實現原始碼時也就用到下面這些:
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
request.js
:
const url = require('url')
module.exports = {
/* 檢視這兩步操作
* const request = ctx.request = Object.create(this.request)
* ctx.req = request.req = response.req = req
*
* 此時的 this 是指向 ctx,所以這裡的 this.req 訪問的是原生屬性 req
* 同樣,也可以通過 this.request.req 來訪問
*/
get query() {
return url.parse(this.req.url).query
},
get path() {
return url.parse(this.req.url).pathname
},
get method() {
return this.req.method.toLowerCase()
}
}
response
response.js
:
module.exports = {
// 這裡的 this.res 也和上面同理
get status() {
return this.res.statusCode
},
set status(val) {
return this.res.statusCode = val
},
get body() {
return this._body
},
set body(val) {
return this._body = val
}
}
屬性代理
通過上面的實現,我們可以使用 ctx.request.query
來訪問到擴充套件的屬性。但是在實際應用中,更常用的是 ctx.query
。不過 query
是在 request
的屬性,通過 ctx.query
是無法訪問的。
這時只需稍微做個代理,在訪問 ctx.query
時,將 ctx.request.query
返回就可以實現上面的效果。
context.js
:
module.exports = {
get query() {
return this.request.query
}
}
實際的程式碼中會有很多擴充套件的屬性,總不可能一個一個去寫吧。為了優雅的代理屬性,Koa
使用 delegates
包實現。這裡我不打算用 delegates
,直接簡單封裝下代理函式。代理函式主要用到__defineGetter__
和 __defineSetter__
兩個方法。
在物件上都會帶有 __defineGetter__
和 __defineSetter__
,它們可以將一個函式繫結在當前物件的指定屬性上,當屬性被獲取或賦值時,繫結的函式就會被呼叫。就像這樣:
let obj = {}
let obj1 = {
name: 'JoJo'
}
obj.__defineGetter__('name', function(){
return obj1.name
})
此時訪問 obj.name
,獲取到的是 obj1.name
的值。
瞭解這個兩個方法的用處後,接下來開始修改 context.js
:
const proto = module.exports = {
}
// getter代理
function delegateGetter(prop, name){
proto.__defineGetter__(name, function(){
return this[prop][name]
})
}
// setter代理
function delegateSetter(prop, name){
proto.__defineSetter__(name, function(val){
return this[prop][name] = val
})
}
// 方法代理
function delegateMethod(prop, name){
proto[name] = function() {
return this[prop][name].apply(this[prop], arguments)
}
}
delegateGetter('request', 'query')
delegateGetter('request', 'path')
delegateGetter('request', 'method')
delegateGetter('response', 'status')
delegateSetter('response', 'status')
delegateMethod('response', 'set')
中介軟體原理
中介軟體思想是 Koa
最精髓的地方,為擴充套件功能提供很大的幫助。這也是它雖然小,卻很強大的原因。還有一個優點,中介軟體使功能模組的職責更加分明,一個功能就是一箇中介軟體,多箇中介軟體組合起來成為一個完整的應用。
下面是著名的“洋蔥模型”。這幅圖很形象的表達了中介軟體思想的作用,它就像一個流水線一樣,上游加工後的東西傳遞給下游,下游可以繼續接著加工,最終輸出加工結果。
原理分析
在呼叫 use
註冊中介軟體的時候,內部會將每個中介軟體儲存到陣列中,執行中介軟體時,為其提供 next
引數。呼叫 next
即執行下一個中介軟體,以此類推。當陣列中的中介軟體執行完畢後,再原路返回。就像這樣:
app.use((ctx, next) => {
console.log('1 start')
next()
console.log('1 end')
})
app.use((ctx, next) => {
console.log('2 start')
next()
console.log('2 end')
})
app.use((ctx, next) => {
console.log('3 start')
next()
console.log('3 end')
})
輸出結果如下:
1 start
2 start
3 start
3 end
2 end
1 end
有點資料結構知識的同學,很快就想到這是一個“棧”結構,執行的順序符合“先入後出”。
下面我將內部中介軟體實現原理進行簡化,模擬中介軟體執行:
function next1() {
console.log('1 start')
next2()
console.log('1 end')
}
function next2() {
console.log('2 start')
next3()
console.log('2 end')
}
function next3() {
console.log('3 start')
console.log('3 end')
}
next1()
執行過程:
呼叫 next1
,將其入棧執行,輸出1 start
遇到 next2
函式,將其入棧執行,輸出2 start
遇到 next3
函式,將其入棧執行,輸出3 start
輸出 3 end
,函式執行完畢,next3
彈出棧輸出 2 end
,函式執行完畢,next2
彈出棧輸出 1 end
,函式執行完畢,next1
彈出棧棧空,全部執行完畢
相信通過這個簡單的例子,都大概明白中介軟體的執行過程了吧。
原理實現
中介軟體原理實現的關鍵點主要就是 ctx
和 next
的傳遞。
因為中介軟體是可以非同步執行的,最後需要返回 Promise
。
function compose(middleware) {
return function(ctx) {
return dispatch(0)
function dispatch(i){
// 取出中介軟體
let fn = middleware[i]
if (!fn) {
return Promise.resolve()
}
// dispatch.bind(null, i + 1) 為應用中介軟體接受到的 next
// next 即下一個應用中介軟體的函式引用
try {
return Promise.resolve( fn(ctx, dispatch.bind(null, i + 1)) )
} catch (error) {
return Promise.reject(error)
}
}
}
}
可以看到,實現過程本質是函式的遞迴呼叫。在內部實現時,其實 next
沒有做什麼神奇的操作,它就是下一個中介軟體呼叫的函式,作為引數傳入供使用者呼叫。
下面我們來使用一下 compose
,你可以將它貼上到控制檯上執行:
function next1(ctx, next) {
console.log('1 start')
next()
console.log('1 end')
}
function next2(ctx, next) {
console.log('2 start')
next()
console.log('2 end')
}
function next3(ctx, next) {
console.log('3 start')
next()
console.log('3 end')
}
let ctx = {}
let fn = compose([next1, next2, next3])
fn(ctx)
完整實現
application.js
:
const http = require('http')
const context = require('./context')
const request = require('./request')
const response = require('./response')
module.exports = class Coa {
constructor() {
this.middleware = []
this.context = context
this.request = request
this.response = response
}
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
this.middleware.push(fn)
return this
}
listen(...args) {
const server = http.createServer(this.callback())
server.listen(...args)
}
callback() {
const handleRequest = (req, res) => {
// 建立上下文
const ctx = this.createContext(req, res)
// fn 為第一個應用中介軟體的引用
const fn = this.compose(this.middleware)
return fn(ctx).then(() => respond(ctx)).catch(console.error)
}
return handleRequest
}
// 建立上下文
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.app = request.app = response.app = this;
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(middleware) {
return function(ctx) {
return dispatch(0)
function dispatch(i){
let fn = middleware[i]
if (!fn) {
return Promise.resolve()
}
// dispatch.bind(null, i + 1) 為應用中介軟體接受到的 next
// next 即下一個應用中介軟體的函式引用
try {
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)))
} catch (error) {
return Promise.reject(error)
}
}
}
}
}
// 處理 body 不同型別輸出
function respond(ctx) {
let res = ctx.res
let body = ctx.body
if (typeof body === 'string') {
return res.end(body)
}
if (typeof body === 'object') {
return res.end(JSON.stringify(body))
}
}
寫在最後
本文的簡單實現了 Koa
主要的功能。有興趣最好還是自己去看原始碼,實現自己的迷你版 Koa
。其實 Koa
的原始碼不算多,總共4個檔案,全部程式碼包括註釋也就 1800 行左右。而且邏輯不會很難,很推薦閱讀,尤其適合原始碼入門級別的同學觀看。
最後附上完整實現的程式碼:github