用Node.js
寫一個web服務器
,我前面已經寫過兩篇文章了:javascript
web服務器
,主要是熟悉Node.js
原生API的使用:使用Node.js原生API寫一個web服務器Express
的基本用法,更主要的是看了下他的源碼:手寫Express.js源碼Express
的源碼仍是比較複雜的,自帶了路由處理和靜態資源支持等等功能,功能比較全面。與之相比,本文要講的Koa
就簡潔多了,Koa
雖然是Express
的原班人馬寫的,可是設計思路卻不同。Express
更可能是偏向All in one
的思想,各類功能都集成在一塊兒,而Koa
自己的庫只有一箇中間件內核,其餘像路由處理和靜態資源這些功能都沒有,所有須要引入第三方中間件庫才能實現。下面這張圖能夠直觀的看到Express
和koa
在功能上的區別,此圖來自於官方文檔:前端
基於Koa
的這種架構,我計劃會分幾篇文章來寫,所有都是源碼解析:java
Koa
的核心架構會寫一篇文章,也就是本文。web服務器
來講,路由是必不可少的,因此@koa/router
會寫一篇文章。bodyparser
等等,具體還沒定,可能會有一篇或多篇文章。本文可運行迷你版Koa代碼已經上傳GitHub,拿下來,一邊玩代碼一邊看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCorenode
我寫源碼解析,通常都遵循一個簡單的套路:先引入庫,寫一個簡單的例子,而後本身手寫源碼來替代這個庫,並讓咱們的例子順利運行。本文也是遵循這個套路,因爲Koa
的核心庫只有中間件,因此咱們寫出的例子也比較簡單,也只有中間件。git
第一個例子是Hello World
,隨便請求一個路徑都返回Hello World
。github
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
吧,就是記錄下處理當前請求花了多長時間:web
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
有幾個明顯的區別:express
ctx
替代了req
和res
async
和await
手寫源碼前咱們看看用到了哪些API,這些就是咱們手寫的目標:json
Koa
這個類了,由於他使用new
進行實例化,因此咱們認爲他是一個類。Koa
的一個實例,app.use
看起來是一個添加中間件的實例方法。Koa
的上下文,看起來替代了之前的req
和res
await next()
,說明next()
返回的極可能是一個promise
。本文的手寫源碼所有參照官方源碼寫成,文件名和函數名儘可能保持一致,寫到具體的方法時我也會貼上官方源碼地址。Koa
這個庫代碼並很少,主要都在這個文件夾裏面:https://github.com/koajs/koa/tree/master/lib,下面咱們開始吧。
從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面向對象還不是很熟悉,能夠先看看這篇文章:http://www.javashuo.com/article/p-txfbwzdy-nm.html。
從咱們前面的使用示例能夠看出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
的做用是用來啓動服務器,看過前面用原生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
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
中間件的結構:
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
上面用到的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
上面的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
如今咱們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
是一個輔助方法,並不在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
我也搞了個公衆號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~