koa是一個輕量級的web應用框架。其實現非常精簡和優雅,核心程式碼僅有區區一百多行,非常值得我們去細細品味和學習。
在開始分析原始碼之前先上demo~
DEMO 1
const Koa = require('../lib/application');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('m1-1');
await next();
console.log('m1-2');
});
app.use(async (ctx, next) => {
console.log('m2-1');
await next();
console.log('m2-2');
});
app.use(async (ctx, next) => {
console.log('m3-1');
ctx.body = 'there is a koa web app';
await next();
console.log('m3-2');
});
app.listen(8001);
複製程式碼
上面程式碼最終會在控制檯依次輸出
m1-1
m2-1
m3-1
m3-2
m2-2
m1-2
複製程式碼
當在中介軟體中呼叫next()
時,會停止當前中介軟體的執行,轉而進行下一個中介軟體。當下一箇中介軟體執行完後,才會繼續執行next()
後面的邏輯。
DEMO 2
我們改一下第一個中介軟體的程式碼,如下所示:
app.use(async (ctx, next) => {
console.log('m1-1');
// await next();
console.log('m1-2');
});
複製程式碼
當把第一個中介軟體的await next()
註釋後,再次執行,在控制檯的輸出如下:
m1-1
m2-1
複製程式碼
顯然,如果不執行next()
方法,程式碼將只會執行到當前的中介軟體,不過後面還有多少箇中介軟體,都不會執行。
這個next
為何會具有這樣的魔力呢,下面讓我們開始愉快地分析koa的原始碼,一探究竟~
程式碼結構
分析原始碼之前我們先來看一下koa的目錄結構,koa的實現檔案只有4個,這4個檔案都在lib目錄中。
application.js
— 定義了一個類,這個類定義了koa例項的方法和屬性context.js
— 定義了一個proto物件,並對proto中的屬性進行代理。中介軟體中使用的ctx物件,其實就是繼承自protorequest.js
— 定義了一個物件,該物件基於原生的req擴充了一些屬性和方法response.js
- 定義了一個物件,該物件基於原生的res擴充了一些屬性和方法
通過package.json檔案得知,koa的入口檔案是lib/application.js,我們先來看一下這個檔案做了什麼。
定義koa類
開啟application.js
檢視原始碼可以發現,這個檔案主要就是定義了一個類,同時定義了一些方法。
module.exports = class Application extends Emitter {
constructor() {
super();
this.middleware = []; // 中介軟體陣列
}
listen (...args) {
// 啟用一個http server並監聽指定埠
const server = http.createServer(this.callback());
return server.listen(...args);
}
use (fn) {
// 把中間新增到中介軟體陣列
this.middleware.push(fn);
return this;
}
}
複製程式碼
我們建立完一個koa物件之後,通常只會使用兩個方法,一個是listen
,一個是use
。listen負責啟動一個http server並監聽指定埠,use用來新增我們的中介軟體。
當呼叫listen
方法時,會建立一個http server,這個http server需要一個回撥函式,當有請求過來時執行。上面程式碼中的this.callback()
就是用來返回這樣的一個函式:這個函式會讀取應用所有的中介軟體,使它們按照傳入的順序依次執行,最後響應請求並返回結果。
callback
方法的核心程式碼如下:
callback() {
const fn = compose(this.middleware);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
複製程式碼
回撥函式callback的執行流程
callback
函式會在應用啟動時執行一次,並且返回一個函式handleRequest
。每當有請求過來時,handleRequest
都會被呼叫。我們將callback
拆分為三個流程去分析:
- 把應用的所有中介軟體合併成一個函式
fn
,在fn
函式內部會依次執行this.middleware
中的中介軟體(是否全部執行,取決於是否有呼叫next
函式執行下一個中介軟體) - 通過
createContext
生成一個可供中介軟體使用的ctx
上下文物件 - 把ctx傳給
fn
,並執行,最後對結果作出響應
koa中介軟體執行原理
const fn = compose(this.middleware);
複製程式碼
原始碼中使用了一個compose
函式,基於所有可執行的中介軟體生成了一個可執行函式。當該函式執行時,每一箇中介軟體將會被依次應用。compose
函式的定義如下:
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!')
}
return function (context, next) {
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 //個人認為對在koa中這裡的fn = next並沒有意義
if (!fn) return Promise.resolve() // 執行到最後resolve出來
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
複製程式碼
它會先執行第一個中介軟體,執行過程中如果遇到next()
呼叫,就會把控制權交到下一個中介軟體並執行,等該中介軟體執行完後,再繼續執行next()
之後的程式碼。這裡的dispatch.bind(null, i + 1)
就是next
函式。到這裡就能解答,為什麼必須要呼叫next
方法,才能讓當前中介軟體後面的中介軟體執行。(有點拗口…)匿名函式的返回結果是一個Promise
,因為要等到中介軟體處理完之後,才能進行響應。
context模組分析
中介軟體執行函式生成好之後,接下來需要建立一個ctx
。這個ctx
可以在中介軟體裡面使用。ctx
提供了訪問req
和res
的介面。
建立上下文物件呼叫了一個createContext
函式,這個函式的定義如下:
/**
* 建立一個context物件,也就是在中介軟體裡使用的ctx,並給ctx新增request, respone屬性
*/
createContext(req, res) {
const context = Object.create(this.context); // 繼承自context.js中export出來proto
const request = context.request = Object.create(this.request); // 把自定義的request作為ctx的屬性
const response = context.response = Object.create(this.response);// 把自定義的response作為ctx的屬性
context.app = request.app = response.app = this;
// 為了在ctx, request, response中,都能使用httpServer回撥函式中的req和res
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {};
return context;
}
複製程式碼
ctx
物件實際上是繼承自context
模組中定義的proto
物件,同時新增了request
和response
兩個屬性。request
和response
也是物件,分別繼承自request.js
和response.js
定義的物件。這兩個模組的功能是基於原生的req
和res
封裝了一些getter
和setter
,原理比較簡單,下面就不再分析了。
我們重點來看看context
模組。
const proto = module.exports = {
inspect() {
if (this === proto) return this;
return this.toJSON();
},
toJSON() {
return {
request: this.request.toJSON(),
response: this.response.toJSON(),
app: this.app.toJSON(),
originalUrl: this.originalUrl,
req: '<original node req>',
res: '<original node res>',
socket: '<original node socket>'
};
},
assert: httpAssert,
throw(...args) {
throw createError(...args);
},
onerror(err) {
if (null == err) return;
if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
let headerSent = false;
if (this.headerSent || !this.writable) {
headerSent = err.headerSent = true;
}
// delegate
this.app.emit('error', err, this);
if (headerSent) {
return;
}
const { res } = this;
// first unset all headers
/* istanbul ignore else */
if (typeof res.getHeaderNames === 'function') {
res.getHeaderNames().forEach(name => res.removeHeader(name));
} else {
res._headers = {}; // Node < 7.7
}
// then set those specified
this.set(err.headers);
// force text/plain
this.type = 'text';
// ENOENT support
if ('ENOENT' == err.code) err.status = 404;
// default to 500
if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;
// respond
const code = statuses[err.status];
const msg = err.expose ? err.message : code;
this.status = err.status;
this.length = Buffer.byteLength(msg);
this.res.end(msg);
},
get cookies() {
if (!this[COOKIES]) {
this[COOKIES] = new Cookies(this.req, this.res, {
keys: this.app.keys,
secure: this.request.secure
});
}
return this[COOKIES];
},
set cookies(_cookies) {
this[COOKIES] = _cookies;
}
};
複製程式碼
context
模組定義了一個proto
物件,該物件定義了一些方法(eg: throw
)和屬性(eg: cookies
)。我們上面通過createContext
函式建立的ctx
物件,就是繼承自proto
。因此,我們可以在中介軟體中直接通過ctx
訪問proto
中定義的方法和屬性。
值得一提的點是,作者通過代理的方式,讓開發者可以直接通過ctx[propertyName]
去訪問ctx.request
或ctx.response
上的屬性和方法。
實現代理的關鍵邏輯
/**
* 代理response一些屬性和方法
* eg: proto.response.body => proto.body
*/
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.access('body')
.access('length')
// other properties or methods
/**
* 代理request的一些屬性和方法
*/
delegate(proto, 'request')
.method('acceptsLanguages')
.method('acceptsEncodings')
.method('acceptsCharsets')
.method('accepts')
.method('get')
// other properties or methods
複製程式碼
實現代理的邏輯也非常簡單,主要就是使用了__defineGetter__
和__defineSetter__
這兩個物件方法,當set
或get
物件的某個屬性時,呼叫指定的函式對屬性值進行處理或返回。
最終的請求與響應
當ctx
(上下文物件)和fn
(執行中介軟體的合成函式)都準備好之後,就能真正的處理請求並響應了。該步驟呼叫了一個handleRequest
函式。
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404; // 狀態碼預設404
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// 執行完中介軟體函式後,執行handleResponse處理結果
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
複製程式碼
handleRequest
函式會把ctx
傳入fnMiddleware
並執行,然後通過respond
方法進行響應。這裡預設把狀態碼設為了404
,如果在執行中介軟體的過程中有返回,例如對ctx.body
進行負責,koa
會自動把狀態碼設成200
,這一部分的邏輯是在response
物件的body
屬性的setter
處理的,有興趣的朋友可以看一下response.js
。
respond
函式會對ctx
物件上的body
或者其他屬性進行分析,然後通過原生的res.end()
方法將不同的結果輸出。
最後
到這裡,koa2的核心程式碼大概就分析完啦。以上是我個人總結,如有錯誤,請見諒。歡迎一起交流學習!