用 Node.js web伺服器
寫一個
,我前面已經寫過兩篇文章了:
- 第一篇是不使用任何架構也能搭建一個
,主要是熟悉web伺服器
原生API的使用:使用Node.js原生API寫一個web伺服器Node.js
- 第二篇文章是看了
的基本用法,更主要的是看了下他的源碼:手寫Express.js源碼Express
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://juejin.im/post/6844904069887164423。
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
提供的API不好用,是以他在這個基礎上擴充了一些API,其實就是一些文法糖,擴充後的req
就變成了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
-
使用了JS的新API,比如Koa
async
await
-
的架構和Koa
有很大差別。Express
-
的思路是大而全,内置了很多功能,比如路由,靜态資源等,而且Express
的中間件也是使用路由同樣的機制實作的,整個代碼更複雜。Express
源碼可以看我之前這篇文章:手寫Express.js源碼Express
-
的思路看起來更清晰,Koa
本身的庫隻是一個核心,隻有中間件功能,來的請求會依次經過每一個中間件,然後再出來傳回給請求者,這就是大家經常聽說的“洋蔥模型”。Koa
- 想要
支援其他功能,必須手動添加中間件。作為一個Koa
,路由可以算是基本功能了,是以下一遍文章我們會來看看web伺服器
官方的路由庫Koa
,敬請關注。@koa/router
參考資料
Koa官方文檔:https://github.com/koajs/koa
Koa源碼位址:https://github.com/koajs/koa/tree/master/lib
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝啬你的贊和GitHub小星星,你的支援是作者持續創作的動力。
歡迎關注我的公衆号進擊的大前端第一時間擷取高品質原創~
“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd
“前端進階知識”系列文章源碼GitHub位址: https://github.com/dennis-jiang/Front-End-Knowledges