Node.js深入分析Koa源碼
Koa 的主要代碼位于根目錄下的 lib 文件夾中,只有 4 個文件,去掉注釋后的源碼不到 1000 行,下面列出了這 4 個文件的主要功能。
- request.js:對 http request 對象的封裝。
- response.js:對 http response 對象的封裝。
- context.js:將上面兩個文件的封裝整合到 context 對象中
- application.js:項目的啟動及中間件的加載。
1. Koa 的啟動過程
首先回憶一下一個 Koa 應(yīng)用的結(jié)構(gòu)是什么樣子的。
const Koa = require('Koa');
const app = new Koa();
//加載一些中間件
app.use(...);
app.use(....);
app.use(.....);
app.listen(3000);Koa 的啟動過程大致分為以下三個步驟:
- 引入 Koa 模塊,調(diào)用構(gòu)造方法新建一個
app對象。 - 加載中間件。
- 調(diào)用
listen方法監(jiān)聽端口。
我們逐步來看上面三個步驟在源碼中的實現(xiàn)。
首先是類和構(gòu)造函數(shù)的定義,這部分代碼位于 application.js 中。
// application.js
const response = require('./response')
const context = require('./context')
const request = require('./request')
const Emitter = require('events')
const util = require('util')
// ...... 其他模塊
module.exports = class Application extends Emitter {
constructor (options) {
super()
options = options || {}
this.proxy = options.proxy || false
this.subdomainOffset = options.subdomainOffset || 2
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'
this.maxIpsCount = options.maxIpsCount || 0
this.env = options.env || process.env.NODE_ENV || 'development'
if (options.keys) this.keys = options.keys
this.middleware = []
// 下面的 context,request,response 分別是從其他三個文件夾中引入的
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
// util.inspect.custom support for node 6+
/* istanbul ignore else */
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect
}
}
// ...... 其他類方法
}首先我們注意到該類繼承于 Events 模塊,然后當我們調(diào)用 Koa 的構(gòu)造函數(shù)時,會初始化一些屬性和方法,例如以context/response/request為原型創(chuàng)建的新的對象,還有管理中間件的 middleware 數(shù)組等。
2. 中間件的加載
中間件的本質(zhì)是一個函數(shù)。在 Koa 中,該函數(shù)通常具有 ctx 和 next 兩個參數(shù),分別表示封裝好的 res/req 對象以及下一個要執(zhí)行的中間件,當有多個中間件的時候,本質(zhì)上是一種嵌套調(diào)用,就像洋蔥圖一樣。

Koa 和 Express 在調(diào)用上都是通過調(diào)用 app.use() 的方式來加載一個中間件,但內(nèi)部的實現(xiàn)卻大不相同,我們先來看application.js 中相關(guān)方法的定義。
/**
* Use the given middleware `fn`.
*
* Old-style middleware will be converted.
*
* @param {Function} fn
* @return {Application} self
* @api public
*/
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
debug('use %s', fn._name || fn.name || '-')
this.middleware.push(fn)
return this
}Koa 在 application.js 中維持了一個 middleware 的數(shù)組,如果有新的中間件被加載,就 push 到這個數(shù)組中,除此之外沒有任何多余的操作,相比之下,Express 的 use 方法就麻煩得多,讀者可以自行參閱其源碼。
此外,之前版本中該方法中還增加了 isGeneratorFunction 判斷,這是為了兼容 Koa1.x 的中間件而加上去的,在 Koa1.x 中,中間件都是 Generator 函數(shù),Koa2 使用的 async 函數(shù)是無法兼容之前的代碼的,因此 Koa2 提供了 convert 函數(shù)來進行轉(zhuǎn)換,關(guān)于這個函數(shù)我們不再介紹。
if (isGeneratorFunction(fn)) {
// ......
fn = convert(fn)
}
接下來我們來看看對中間件的調(diào)用。
/**
* Return a request handler callback
* for node's native http server.
*
* @return {Function}
* @api public
*/
callback () {
const fn = compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}
return handleRequest
}可以看出關(guān)于中間件的核心邏輯應(yīng)該位于 compose 方法中,該方法是一個名為 Koa-compose 的第三方模塊https://github.com/Koajs/compose,我們可以看看其內(nèi)部是如何實現(xiàn)的。
該模塊只有一個方法 compose,調(diào)用方式為 compose([a, b, c, ...]),該方法接受一個中間件的數(shù)組作為參數(shù),返回的仍然是一個中間件(函數(shù)),可以將這個函數(shù)看作是之前加載的全部中間件的功能集合。
/**
* Compose `middleware` returning
* a fully valid middleware comprised
* of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
* @api public
*/
function compose (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!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}該方法的核心是一個遞歸調(diào)用的 dispatch 函數(shù),為了更好地說明這個函數(shù)的工作原理,這里使用一個簡單的自定義中間件作為例子來配合說明。
function myMiddleware(context, next) {
process.nextTick(function () {
console.log('I am a middleware');
})
next();
}
可以看出這個中間件除了打印一條消息,然后調(diào)用 next 方法之外,沒有進行任何操作,我們以該中間件為例,在 Koa 的 app.js 中使用 app.use 方法加載該中間件兩次。
const Koa = require('Koa');
const myMiddleware = require("./myMiddleware");
app.use(md1);
app.use(dm2);
app.listen(3000);
app 真正實例化是在調(diào)用 listen 方法之后,那么中間件的加載同樣位于 listen 方法之后。
那么 compose 方法的實際調(diào)用為 compose[myMiddleware,myMiddleware],在執(zhí)行 dispatch(0) 時,該方法實際可以簡化為:
function compose(middleware) {
return function (context, next) {
try {
return Promise.resolve(md1(context, function next() {
return Promise.resolve(md2(context, function next() {
}))
}))
} catch (err) {
return Promise.reject(err)
}
}
}
可以看出 compose 的本質(zhì)仍是嵌套的中間件。
3. listen() 方法
這是 app 啟動過程中的最后一步,讀者會疑惑:為什么這么一行也要算作單獨的步驟,事實上,上面的兩步都是為了 app 的啟動做準備,整個 Koa 應(yīng)用的啟動是通過 listen 方法來完成的。下面是 application.js 中 listen 方法的定義。
/**
* Shorthand for:
*
* http.createServer(app.callback()).listen(...)
*
* @param {Mixed} ...
* @return {Server}
* @api public
*/
listen(...args) {
debug('listen')
const server = http.createServer(this.callback())
return server.listen(...args)
}
上面的代碼就是 listen 方法的內(nèi)容,可以看出第 3 行才真正調(diào)用了 http.createServer 方法建立了 http 服務(wù)器,參數(shù)為上節(jié) callback 方法返回的 handleRequest 方法,源碼如下所示,該方法做了兩件事:
- 封裝
request和response對象。 - 調(diào)用中間件對
ctx對象進行處理。
/**
* Handle request in callback.
*
* @api private
*/
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}4. next()與return next()
我們前面也提到過,Koa 對中間件調(diào)用的實現(xiàn)本質(zhì)上是嵌套的 promise.resolve 方法,我們可以寫一個簡單的例子。
let ctx = 1;
const md1 = function (ctx, next) {
next();
}
const md2 = function (ctx, next) {
return ++ctx;
}
const p = Promise.resolve(
mdl(ctx, function next() {
return Promise.resolve(
md2(ctx, function next() {
//更多的中間件...
})
)
})
)
p.then(function (ctx) {
console.log(ctx);
})代碼在第一行定義的變量 ctx,我們可以將其看作 Koa 中的 ctx 對象,經(jīng)過中間件的處理后,ctx 的值會發(fā)生相應(yīng)的變化。
我們定義了 md1 和 md2 兩個中間件,md1 沒有做任何操作,只調(diào)用了 next 方法,md2 則是對 ctx 執(zhí)行加一的操作,那么在最后的 then 方法中,我們期望 ctx 的值為 2。
我們可以嘗試運行上面的代碼,最后的結(jié)果卻是 undefined,在 md1 的 next 方法前加上 return 關(guān)鍵字后,就能得到正常的結(jié)果了。
在 Koa 的源碼 application.js 中,callback 方法的最后一行:
/**
* Return a request handler callback
* for node's native http server.
*
* @return {Function}
* @api public
*/
callback () {
const fn = compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}
return handleRequest
}
/**
* Handle request in callback.
*
* @api private
*/
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}中的 fnMiddleware(ctx) 相當于之前代碼第 8 行聲明的 Promise 對象 p,被中間件方法修改后的 ctx 對象被 then 方法傳給 handleResponse 方法返回給客戶端。
每個中間件方法都會返回一個 Promise 對象,里面包含的是對 ctx 的修改,通過調(diào)用 next 方法來調(diào)用下一個中間件。
fn(context, function next () {
return dispatch(i + 1);
})
再通過 return 關(guān)鍵字將修改后的 ctx 對象作為 resolve 的參數(shù)返回。
如果多個中間件同時操作了 ctx 對象,那么就有必要使用 return 關(guān)鍵字將操作的結(jié)果返回到上一級調(diào)用的中間件里。
事實上,如果讀者去讀 Koa-router 或者 Koa-static 的源碼,也會發(fā)現(xiàn)它們都是使用 return next 方法。
5. 關(guān)于 Can’t set headers after they are sent.
這是使用 Express 或者 Koa 常見的錯誤之一,其原因如字面意思,對于同一個 HTTP 請求重復發(fā)送了 HTTP HEADER 。服務(wù)器在處理HTTP 請求時會先發(fā)送一個響應(yīng)頭(使用 writeHead 或 setHeader 方法),然后發(fā)送主體內(nèi)容(通過 send 或者 end 方法),如果對一個 HTTP 請求調(diào)用了兩次 writeHead 方法,就會出現(xiàn) Can't set headers after they are sent 的錯誤提示,例如下面的例子:
const http = require("http");
http.createServer(function (req, res) {
res.setHeader('Content-Type', 'text/html');
res.end('ok');
resend(req, res); // 在響應(yīng)結(jié)束后再次發(fā)送響應(yīng)信息
}).listen(5000);
function resend(req, res) {
res.setHeader('Content-Type', 'text/html');
res.end('error');
}試著訪問 localhost:5000 就會得到錯誤信息,這個例子太過直白了。下面是一個 Express 中的例子,由于中間件可能包含異步操作,因此有時錯誤的原因比較隱蔽。
const express = require('express');
const app = express();
app.use(function (req, res, next) {
setTimeout(function () {
res.redirect("/bar");
}, 1000);
next();
});
app.get("/foo", function (req, res) {
res.end("foo");
});
app.get("/bar", function (req, res) {
res.end("bar");
});
app.listen(3000);運行上面的代碼,訪問 http://localhost:3000/foo 會產(chǎn)生同樣的錯誤,原因也很簡單,在請求返回之后,setTimeout 內(nèi)部的 redirect 會對一個已經(jīng)發(fā)送出去的 response 進行修改,就會出現(xiàn)錯誤,在實際項目中不會像 setTimeout 這么明顯,可能是一個數(shù)據(jù)庫操作或者其他的異步操作,需要特別注意。
6. Context 對象的實現(xiàn)
關(guān)于 ctx 對象是如何得到 request/response 對象中的屬性和方法的,可以閱讀 context.js 的源碼,其核心代碼如下所示。此外,delegate 模塊還廣泛運用在了 Koa 的各種中間件中。
const delegate = require('delegates')
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('has')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable')delegate 是一個 Node 第三方模塊,作用是把一個對象中的屬性和方法委托到另一個對象上。
讀者可以訪問該模塊的項目地址 https://github.com/tj/node-delegates,然后就會發(fā)現(xiàn)該模塊的主要貢獻者還是TJ Holowaychuk。
這個模塊的代碼同樣非常簡單,源代碼只有 100 多行,我們這里詳細介紹一下。
在上面的代碼中,我們使用了如下三個方法:
- method:用于委托方法到目標對象上。
- access:綜合
getter和setter,可以對目標進行讀寫。 - getter:為目標屬性生成一個訪問器,可以理解成復制了一個只讀屬性到目標對象上。
getter 和 setter 這兩個方法是用來控制對象的讀寫屬性的,下面是 method 方法與 access 方法的實現(xiàn)。
/**
* Delegate method `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.method = function(name){
var proto = this.proto;
var target = this.target;
this.methods.push(name);
proto[name] = function(){
return this[target][name].apply(this[target], arguments);
};
return this;
};method 方法中使用 apply 方法將原目標的方法綁定到目標對象上。
下面是 access 方法的定義,綜合了 getter 方法和 setter 方法。
/**
* Delegator accessor `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};
/**
* Delegator getter `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.getter = function(name){
var proto = this.proto;
var target = this.target;
this.getters.push(name);
proto.__defineGetter__(name, function(){
return this[target][name];
});
return this;
};
/**
* Delegator setter `name`.
*
* @param {String} name
* @return {Delegator} self
* @api public
*/
Delegator.prototype.setter = function(name){
var proto = this.proto;
var target = this.target;
this.setters.push(name);
proto.__defineSetter__(name, function(val){
return this[target][name] = val;
});
return this;
};最后是 delegate 的構(gòu)造函數(shù),該函數(shù)接收兩個參數(shù),分別是源對象和目標對象。
/**
* Initialize a delegator.
*
* @param {Object} proto
* @param {String} target
* @api public
*/
function Delegator(proto, target) {
if (!(this instanceof Delegator)) return new Delegator(proto, target);
this.proto = proto;
this.target = target;
this.methods = [];
this.getters = [];
this.setters = [];
this.fluents = [];
} 可以看出 deletgate 對象在內(nèi)部維持了一些數(shù)組,分別表示委托得到的目標對象和方法。
關(guān)于動態(tài)加載中間件
在某些應(yīng)用場景中,開發(fā)者可能希望能夠動態(tài)加載中間件,例如當路由接收到某個請求后再去加載對應(yīng)的中間件,但在 Koa 中這是無法做到的。原因其實已經(jīng)包含在前面的內(nèi)容了,Koa 應(yīng)用唯一一次加載所有中間件是在調(diào)用 listen 方法的時候,即使后面再調(diào)用 app.use 方法,也不會生效了。
7. Koa 的優(yōu)缺點
通過上面的內(nèi)容,相信讀者已經(jīng)對 Koa 有了大概的認識,和 Express 相比,Koa 的優(yōu)勢在于精簡,它剝離了所有的中間件,并且對中間件的執(zhí)行做了很大的優(yōu)化。
一個經(jīng)驗豐富的 Express 開發(fā)者想要轉(zhuǎn)到 Koa 上并不需要很大的成本,唯一需要注意的就是中間件執(zhí)行的策略會有差異,這可能會帶來一段時間的不適應(yīng)。
現(xiàn)在我們來說說 Koa 的缺點,剝離中間件雖然是個優(yōu)點,但也讓不同中間件的組合變得麻煩起來,Express 經(jīng)過數(shù)年的沉淀,各種用途的中間件已經(jīng)很成熟;而 Koa 不同,Koa2.0 推出的時間還很短,適配的中間件也不完善,有時單獨使用各種中間件還好,但一旦組合起來,可能出現(xiàn)不能正常工作的情況。
舉個例子,如果想同時使用 router 和 views 兩個中間件,就要在 render 方法前加上 return 關(guān)鍵字(和 return next()一個道理),對于剛接觸 Koa 的開發(fā)者可能要花很長時間才能定位問題所在。再例如前面的 koa-session 和 Koa-router,我初次接觸這兩個中間件時也著實花了一些功夫來將他們正確地組合在一塊。雖然中間件概念的引入讓Node開發(fā)變得像搭積木一樣,但積木之間如果不能很順利地拼接在一塊的話,也會增加開發(fā)成本。
到此這篇關(guān)于Node.js深入分析Koa源碼的文章就介紹到這了,更多相關(guān)Node.js Koa內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
node.js中ws模塊創(chuàng)建服務(wù)端與客戶端實例代碼
在Node.js中提供了http模塊與https模塊,專用于創(chuàng)建HTTP服務(wù)器、HTTP客戶端,以及HTTPS服務(wù)器及HTTPS客戶端,同時實現(xiàn)這些服務(wù)器端與客戶端之中所需進行的處理,下面這篇文章主要給大家介紹了關(guān)于node.js中ws模塊創(chuàng)建服務(wù)端與客戶端的相關(guān)資料,需要的朋友可以參考下2023-05-05
使用基于Node.js的構(gòu)建工具Grunt來發(fā)布ASP.NET MVC項目
這篇文章主要介紹了使用基于Node.js的構(gòu)建工具Grunt來發(fā)布ASP.NET MVC項目的教程,自動化構(gòu)建工具Grunt具有編譯壓縮單元測試等功能,十分強大,需要的朋友可以參考下2016-02-02
node.js對于數(shù)據(jù)庫MySQL基本操作實例總結(jié)【增刪改查】
這篇文章主要介紹了node.js對于數(shù)據(jù)庫MySQL基本操作,結(jié)合實例形式總結(jié)分析了node.js針對mysql數(shù)據(jù)庫基本配置、連接與增刪改查相關(guān)操作技巧,需要的朋友可以參考下2023-04-04
nodejs 中模擬實現(xiàn) emmiter 自定義事件
這篇文章主要介紹了Nodejs中自定義事件實例,比較簡單的一個例子,需要的朋友可以參考下。2016-02-02

