用Node.js
寫一個web伺服器
,我前面已經寫過兩篇文章了:
- 第一篇是不使用任何框架也能搭建一個
web伺服器
,主要是熟悉Node.js
原生API的使用:使用Node.js原生API寫一個web伺服器 - 第二篇文章是看了
Express
的基本用法,更主要的是看了下他的原始碼:手寫Express.js原始碼
Express
的原始碼還是比較複雜的,自帶了路由處理和靜態資源支援等等功能,功能比較全面。與之相比,本文要講的Koa
就簡潔多了,Koa
雖然是Express
的原班人馬寫的,但是設計思路卻不一樣。Express
更多是偏向All in one
的思想,各種功能都整合在一起,而Koa
本身的庫只有一箇中介軟體核心,其他像路由處理和靜態資源這些功能都沒有,全部需要引入第三方中介軟體庫才能實現。下面這張圖可以直觀的看到Express
和koa
在功能上的區別,此圖來自於官方文件:
基於Koa
的這種架構,我計劃會分幾篇文章來寫,全部都是原始碼解析:
Koa
的核心架構會寫一篇文章,也就是本文。- 對於一個
web伺服器
來說,路由是必不可少的,所以@koa/router
會寫一篇文章。 - 另外可能會寫一些常用中介軟體,靜態檔案支援或者
bodyparser
等等,具體還沒定,可能會有一篇或多篇文章。
本文可執行迷你版Koa程式碼已經上傳GitHub,拿下來,一邊玩程式碼一邊看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore
簡單示例
我寫原始碼解析,一般都遵循一個簡單的套路:先引入庫,寫一個簡單的例子,然後自己手寫原始碼來替代這個庫,並讓我們的例子順利執行。本文也是遵循這個套路,由於Koa
的核心庫只有中介軟體,所以我們寫出的例子也比較簡單,也只有中介軟體。
Hello World
第一個例子是Hello World
,隨便請求一個路徑都返回Hello World
。
const Koa = require("koa");
const app = new Koa();
app.use((ctx) => {
ctx.body = "Hello World";
});
const port = 3001;
app.listen(port, () => {
console.log(`Server is running on http://127.0.0.1:${port}/`);
});
logger
然後再來一個logger
吧,就是記錄下處理當前請求花了多長時間:
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
注意這個中介軟體應該放到Hello World
的前面。
從上面兩個例子的程式碼來看,Koa
跟Express
有幾個明顯的區別:
ctx
替代了req
和res
- 可以使用JS的新API了,比如
async
和await
手寫原始碼
手寫原始碼前我們看看用到了哪些API,這些就是我們手寫的目標:
- new Koa():首先肯定是
Koa
這個類了,因為他使用new
進行例項化,所以我們認為他是一個類。 - app.use:app是
Koa
的一個例項,app.use
看起來是一個新增中介軟體的例項方法。 - app.listen:啟動伺服器的例項方法
- ctx:這個是
Koa
的上下文,看起來替代了以前的req
和res
- async和await:支援新的語法,而且能使用
await next()
,說明next()
返回的很可能是一個promise
。
本文的手寫原始碼全部參照官方原始碼寫成,檔名和函式名儘量保持一致,寫到具體的方法時我也會貼上官方原始碼地址。Koa
這個庫程式碼並不多,主要都在這個資料夾裡面:https://github.com/koajs/koa/tree/master/lib,下面我們開始吧。
Koa類
從Koa
專案的package.json
裡面的main
這行程式碼可以看出,整個應用的入口是lib/application.js
這個檔案:
"main": "lib/application.js",
lib/application.js
這個檔案就是我們經常用的Koa
類,雖然我們經常叫他Koa
類,但是在原始碼裡面這個類叫做Application
。我們先來寫一下這個類的殼吧:
// application.js
const Emitter = require("events");
// module.exports 直接匯出Application類
module.exports = class Application extends Emitter {
// 建構函式先執行下父類的建構函式
// 再進行一些初始化工作
constructor() {
super();
// middleware例項屬性初始化為一個空陣列,用來儲存後續可能的中介軟體
this.middleware = [];
}
};
這段程式碼我們可以看出,Koa
直接使用class
關鍵字來申明類了,看過我之前Express
原始碼解析的朋友可能還有印象,Express
原始碼裡面還是使用的老的prototype
來實現物件導向的。所以Koa
專案介紹裡面的Expressive middleware for node.js using ES2017 async functions
並不是一句虛言,它不僅支援ES2017
新的API,而且在自己的原始碼裡面裡面也是用的新API。我想這也是Koa
要求執行環境必須是node v7.6.0 or higher
的原因吧。所以到這裡我們其實已經可以看出Koa
和Express
的一個重大區別了,那就是:Express
使用老的API,相容性更強,可以在老的Node.js
版本上執行;Koa
因為使用了新API,只能在v7.6.0
或者更高版本上執行了。
這段程式碼還有個點需要注意,那就是Application
繼承自Node.js
原生的EventEmitter
類,這個類其實就是一個釋出訂閱模式,可以訂閱和釋出訊息,我在另一篇文章裡面詳細講過他的原始碼。所以他有些方法如果在application.js
裡面找不到,那可能就是繼承自EventEmitter
,比如下圖這行程式碼:
這裡有this.on
這個方法,看起來他應該是Application
的一個例項方法,但是這個檔案裡面沒有,其實他就是繼承自EventEmitter
,是用來給error
這個事件新增回撥函式的。這行程式碼if
裡面的this.listenerCount
也是EventEmitter
的一個例項方法。
Application
類完全是JS物件導向的運用,如果你對JS物件導向還不是很熟悉,可以先看看這篇文章:https://segmentfault.com/a/1190000023201844。
app.use
從我們前面的使用示例可以看出app.use
的作用就是新增一箇中介軟體,我們在建構函式裡面也初始化了一個變數middleware
,用來儲存中介軟體,所以app.use
的程式碼就很簡單了,將接收到的中介軟體塞到這個陣列就行:
use(fn) {
// 中介軟體必須是一個函式,不然就報錯
if (typeof fn !== "function")
throw new TypeError("middleware must be a function!");
// 處理邏輯很簡單,將接收到的中介軟體塞入到middleware陣列就行
this.middleware.push(fn);
return this;
}
注意app.use
方法最後返回了this
,這個有點意思,為什麼要返回this
呢?這個其實我之前在其他文章講過的:類的例項方法返回this
可以實現鏈式呼叫。比如這裡的app.use
就可以連續點點點了,像這樣:
app.use(middlewaer1).use(middlewaer2).use(middlewaer3)
為什麼會有這種效果呢?因為這裡的this
其實就是當前例項,也就是app
,所以app.use()
的返回值就是app
,app
上有個例項方法use
,所以可以繼續點app.use().use()
。
app.use
的官方原始碼看這裡: https://github.com/koajs/koa/blob/master/lib/application.js#L122
app.listen
在前面的示例中,app.listen
的作用是用來啟動伺服器,看過前面用原生API實現web伺服器
的朋友都知道,要啟動伺服器需要呼叫原生的http.createServer
,所以這個方法就是用來呼叫http.createServer
的。
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
這個方法本身其實沒有太多可說的,只是呼叫http
模組啟動服務而已,主要的邏輯都在this.callback()
裡面了。
app.listen
的官方原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L79
app.callback
this.callback()
是傳給http.createServer
的回撥函式,也是一個例項函式,這個函式必須符合http.createServer
的引數形式,也就是
http.createServer(function(req, res){})
所以this.callback()
的返回值必須是一個函式,而且是這種形式function(req, res){}
。
除了形式必須符合外,this.callback()
具體要幹什麼呢?他是http
模組的回撥函式,所以他必須處理所有的網路請求,所有處理邏輯都必須在這個方法裡面。但是Koa
的處理邏輯是以中介軟體的形式存在的,對於一個請求來說,他必須一個一個的穿過所有的中介軟體,具體穿過的邏輯,你當然可以遍歷middleware
這個陣列,將裡面的方法一個一個拿出來處理,當然也可以用業界更常用的方法:compose
。
compose
一般來說就是將一系列方法合併成一個方法來方便呼叫,具體實現的形式並不是固定的,有面試中常見的用reduce
實現的compose
,也有像Koa
這樣根據自己需求單獨實現的compose
。Koa
的compose
也單獨封裝了一個庫koa-compose
,這個庫原始碼也是我們必須要看的,我們一步一步來,先把this.callback
寫出來吧。
callback() {
// compose來自koa-compose庫,就是將中介軟體合併成一個函式
// 我們需要自己實現
const fn = compose(this.middleware);
// callback返回值必須符合http.createServer引數形式
// 即 (req, res) => {}
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
這個方法先用koa-compose
將中介軟體都合成了一個函式fn
,然後在http.createServer
的回撥裡面使用req
和res
建立了一個Koa
常用的上下文ctx
,然後再呼叫this.handleRequest
來真正處理網路請求。注意這裡的this.handleRequest
是個例項方法,和當前方法裡面的區域性變數handleRequest
並不是一個東西。這幾個方法我們一個一個來看下。
this.callback
對應的官方原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L143
koa-compose
koa-compose
雖然被作為了一個單獨的庫,但是他的作用卻很關鍵,所以我們也來看看他的原始碼吧。koa-compose
的作用是將一箇中介軟體組成的陣列合併成一個方法以便外部呼叫。我們先來回顧下一個Koa
中介軟體的結構:
function middleware(ctx, next) {}
這個陣列就是有很多這樣的中介軟體:
[
function middleware1(ctx, next) {},
function middleware2(ctx, next) {}
]
Koa
的合併思路並不複雜,就是讓compose
再返回一個函式,返回的這個函式會開始這個陣列的遍歷工作:
function compose(middleware) {
// 引數檢查,middleware必須是一個陣列
if (!Array.isArray(middleware))
throw new TypeError("Middleware stack must be an array!");
// 陣列裡面的每一項都必須是一個方法
for (const fn of middleware) {
if (typeof fn !== "function")
throw new TypeError("Middleware must be composed of functions!");
}
// 返回一個方法,這個方法就是compose的結果
// 外部可以通過呼叫這個方法來開起中介軟體陣列的遍歷
// 引數形式和普通中介軟體一樣,都是context和next
return function (context, next) {
return dispatch(0); // 開始中介軟體執行,從陣列第一個開始
// 執行中介軟體的方法
function dispatch(i) {
let fn = middleware[i]; // 取出需要執行的中介軟體
// 如果i等於陣列長度,說明陣列已經執行完了
if (i === middleware.length) {
fn = next; // 這裡讓fn等於外部傳進來的next,其實是進行收尾工作,比如返回404
}
// 如果外部沒有傳收尾的next,直接就resolve
if (!fn) {
return Promise.resolve();
}
// 執行中介軟體,注意傳給中介軟體接收的引數應該是context和next
// 傳給中介軟體的next是dispatch.bind(null, i + 1)
// 所以中介軟體裡面呼叫next的時候其實呼叫的是dispatch(i + 1),也就是執行下一個中介軟體
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}
上面程式碼主要的邏輯就是這行:
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
這裡的fn
就是我們自己寫的中介軟體,比如文章開始那個logger
,我們稍微改下看得更清楚:
const logger = async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
};
app.use(logger);
那我們compose
裡面執行的其實是:
logger(context, dispatch.bind(null, i + 1));
也就是說logger
接收到的next
其實是dispatch.bind(null, i + 1)
,你呼叫next()
的時候,其實呼叫的是dispatch(i + 1)
,這樣就達到了執行陣列下一個中介軟體的效果。
另外由於中介軟體在返回前還包裹了一層Promise.resolve
,所以我們所有自己寫的中介軟體,無論你是否用了Promise
,next
呼叫後返回的都是一個Promise
,所以你可以使用await next()
。
koa-compose
的原始碼看這裡:https://github.com/koajs/compose/blob/master/index.js
app.createContext
上面用到的this.createContext
也是一個例項方法。這個方法根據http.createServer
傳入的req
和res
來構建ctx
這個上下文,官方原始碼長這樣:
這段程式碼裡面context
,ctx
,response
,res
,request
,req
,app
這幾個變數相互賦值,頭都看暈了。其實完全沒必要陷入這堆麵條裡面去,我們只需要將他的思路和骨架拎清楚就行,那怎麼來拎呢?
- 首先搞清楚他這麼賦值的目的,他的目的其實很簡單,就是為了使用方便。通過一個變數可以很方便的拿到其他變數,比如我現在只有
request
,但是我想要的是req
,怎麼辦呢?通過這種賦值後,直接用request.req
就行。其他的類似,這種麵條式的賦值我很難說好還是不好,但是使用時確實很方便,缺點就是看原始碼時容易陷進去。 - 那
request
和req
有啥區別?這兩個變數長得這麼像,到底是幹啥的?這就要說到Koa
對於原生req
的擴充套件,我們知道http.createServer
的回撥裡面會傳入req
作為請求物件的描述,裡面可以拿到請求的header
啊,method
啊這些變數。但是Koa
覺得這個req
提供的API不好用,所以他在這個基礎上擴充套件了一些API,其實就是一些語法糖,擴充套件後的req
就變成了request
。之所以擴充套件後還保留的原始的req
,應該也是想為使用者提供更多選擇吧。所以這兩個變數的區別就是request
是Koa
包裝過的req
,req
是原生的請求物件。response
和res
也是類似的。 - 既然
request
和response
都只是包裝過的語法糖,那其實Koa
沒有這兩個變數也能跑起來。所以我們拎骨架的時候完全可以將這兩個變數踢出去,這下骨架就清晰了。
那我們踢出response
和request
後再來寫下createContext
這個方法:
// 建立上下文ctx物件的函式
createContext(req, res) {
const context = Object.create(this.context);
context.app = this;
context.req = req;
context.res = res;
return context;
}
這下整個世界感覺都清爽了,context
上的東西也一目瞭然了。但是我們的context
最初是來自this.context
的,這個變數還必須看下。
app.createContext
對應的官方原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L177
context.js
上面的this.context
其實就是來自context.js
,所以我們先在Application
建構函式裡面新增這個變數:
// application.js
const context = require("./context");
// 建構函式裡面
constructor() {
// 省略其他程式碼
this.context = context;
}
然後再來看看context.js
裡面有啥,context.js
的結構大概是這個樣子:
const delegate = require("delegates");
module.exports = {
inspect() {},
toJSON() {},
throw() {},
onerror() {},
};
const proto = module.exports;
delegate(proto, "response")
.method("set")
.method("append")
.access("message")
.access("body");
delegate(proto, "request")
.method("acceptsLanguages")
.method("accepts")
.access("querystring")
.access("socket");
這段程式碼裡面context
匯出的是一個物件proto
,這個物件本身有一些方法,inspect
,toJSON
之類的。然後還有一堆delegate().method()
,delegate().access()
之類的。嗯,這個是幹啥的呢?要知道這個的作用,我們需要去看delegates
這個庫:https://github.com/tj/node-delegates,這個庫也是tj
大神寫的。一般使用是這樣的:
delegate(proto, target).method("set");
這行程式碼的作用是,當你呼叫proto.set()
方法時,其實是轉發給了proto[target]
,實際呼叫的是proto[target].set()
。所以就是proto
代理了對target
的訪問。
那用在我們context.js
裡面是啥意思呢?比如這行程式碼:
delegate(proto, "response")
.method("set");
這行程式碼的作用是,當你呼叫proto.set()
時,實際去呼叫proto.response.set()
,將proto
換成ctx
就是:當你呼叫ctx.set()
時,實際呼叫的是ctx.response.set()
。這麼做的目的其實也是為了使用方便,可以少寫一個response
。而且ctx
不僅僅代理response
,還代理了request
,所以你還可以通過ctx.accepts()
這樣來呼叫到ctx.request.accepts()
。一個ctx
就囊括了response
和request
,所以這裡的context
也是一個語法糖。因為我們前面已經踢了response
和request
這兩個語法糖,context
作為包裝了這兩個語法糖的語法糖,我們也一起踢掉吧。在Application
的建構函式裡面直接將this.context
賦值為空物件:
// application.js
constructor() {
// 省略其他程式碼
this.context = {};
}
現在語法糖都踢掉了,整個Koa
的結構就更清晰了,ctx
上面也只有幾個必須的變數:
ctx = {
app,
req,
res
}
context.js
對應的原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/context.js
app.handleRequest
現在我們ctx
和fn
都構造好了,那我們處理請求其實就是呼叫fn
,ctx
是作為引數傳給他的,所以app.handleRequest
程式碼就可以寫出來了:
// 處理具體請求
handleRequest(ctx, fnMiddleware) {
const handleResponse = () => respond(ctx);
// 呼叫中介軟體處理
// 所有處理完後就呼叫handleResponse返回請求
return fnMiddleware(ctx)
.then(handleResponse)
.catch((err) => {
console.log("Somethis is wrong: ", err);
});
}
我們看到compose
庫返回的fn
雖然支援第二個引數用來收尾,但是Koa
並沒有用他,如果不傳的話,所有中介軟體執行完返回的就是一個空的promise
,所以可以用then
接著他後面處理。後面要進行的處理就只有一個了,就是將處理結果返回給請求者的,這也就是respond
需要做的。
app.handleRequest
對應的原始碼看這裡:https://github.com/koajs/koa/blob/master/lib/application.js#L162
respond
respond
是一個輔助方法,並不在Application
類裡面,他要做的就是將網路請求返回:
function respond(ctx) {
const res = ctx.res; // 取出res物件
const body = ctx.body; // 取出body
return res.end(body); // 用res返回body
}
大功告成
現在我們可以用自己寫的Koa
替換官方的Koa
來執行我們開頭的例子了,不過logger
這個中介軟體執行的時候會有點問題,因為他下面這行程式碼用到了語法糖:
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
這裡的ctx.method
和ctx.url
在我們構建的ctx
上並不存在,不過沒關係,他不就是個req
的語法糖嘛,我們從ctx.req
上拿就行,所以上面這行程式碼改為:
console.log(`${ctx.req.method} ${ctx.req.url} - ${ms}ms`);
總結
通過一層一層的抽絲剝繭,我們成功拎出了Koa
的程式碼骨架,自己寫了一個迷你版的Koa
。
這個迷你版程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore
最後我們再來總結下本文的要點吧:
Koa
是Express
原班人馬寫的一個新框架。Koa
使用了JS的新API,比如async
和await
。Koa
的架構和Express
有很大區別。Express
的思路是大而全,內建了很多功能,比如路由,靜態資源等,而且Express
的中介軟體也是使用路由同樣的機制實現的,整個程式碼更復雜。Express
原始碼可以看我之前這篇文章:手寫Express.js原始碼Koa
的思路看起來更清晰,Koa
本身的庫只是一個核心,只有中介軟體功能,來的請求會依次經過每一箇中介軟體,然後再出來返回給請求者,這就是大家經常聽說的“洋蔥模型”。- 想要
Koa
支援其他功能,必須手動新增中介軟體。作為一個web伺服器
,路由可以算是基本功能了,所以下一遍文章我們會來看看Koa
官方的路由庫@koa/router
,敬請關注。
參考資料
Koa官方文件:https://github.com/koajs/koa
Koa原始碼地址:https://github.com/koajs/koa/tree/master/lib
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges
我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~