Koa源碼十分精簡,只有不到2k行的代碼,總共由4個模塊文件組成,非常適合我們來學習。
參考代碼: learn-koa2
我們先來看段原生Node實現Server服務器的代碼:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world');
});
server.listen(3000, () => {
console.log('server start at 3000');
});
非常簡單的幾行代碼,就實現了一個服務器Server。createServer
方法接收的callback
回調函數,可以對每次請求的req
res
對象進行各種操作,最後返回結果。不過弊端也很明顯,callback
函數非常容易隨着業務邏輯的複雜也變得臃腫,即使把callback
函數拆分成各個小函數,也會在繁雜的異步回調中漸漸失去對整個流程的把控。
另外,Node原生提供的一些API,有時也會讓開發者疑惑:
res.statusCode = 200;
res.writeHead(200);
修改res
的屬性或者調用res
的方法都可以改變http
狀態碼,這在多人協作的項目中,很容易產生不同的代碼風格。
我們再來看段Koa實現Server:
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('1-start');
await next();
console.log('1-end');
});
app.use(async (ctx, next) => {
console.log('2-start');
ctx.status = 200;
ctx.body = 'Hello World';
console.log('2-end');
});
app.listen(3000);
// 最後輸出內容:
// 1-start
// 2-start
// 2-end
// 1-end
Koa使用了中間件的概念來完成對一個http請求的處理,同時,Koa採用了async和await的語法使得異步流程可以更好的控制。ctx
執行上下文代理了原生的res
和req
,這讓開發者避免接觸底層,而是通過代理訪問和設置屬性。
看完兩者的對比後,我們應該會有幾個疑惑:
-
ctx.status
爲什麼就可以直接設置狀態碼了,不是根本沒看到res
對象嗎? - 中間件中的
next
到底是啥?爲什麼執行next
就進入了下一個中間件? - 所有中間件執行完成後,爲什麼可以再次返回原來的中間件(洋蔥模型)?
現在讓我們帶着疑惑,進行源碼解讀,同時自己實現一個簡易版的Koa吧!
封裝http Server
參考代碼: step-1
// Koa的使用方法
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
我們首先模仿koa的使用方法,搭建一個最簡易的骨架:
新建kao/application.js
(特意使用了Kao,區別Koa
,並非筆誤!!!)
const http = require('http');
class Application {
constructor() {
this.callbackFn = null;
}
use(fn) {
this.callbackFn = fn;
}
callback() {
return (req, res) => this.callbackFn(req, res)
}
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
module.exports = Application;
新建測試文件kao/index.js
const Kao = require('./application');
const app = new Kao();
app.use(async (req, res) => {
res.writeHead(200);
res.end('hello world');
});
app.listen(3001, () => {
console.log('server start at 3001');
});
我們已經初步封裝好http server:通過new
實例一個對象,use
註冊回調函數,listen
啓動server並傳入回調。
注意的是:調用new
時,其實沒有開啓server服務器,真正開啓是在listen
調用時。
不過這段代碼有明顯的不足:
- use傳入的回調函數,接收的參數依舊是原生的
req
和res
- 多次調用use,會覆蓋上一個中間件,並不是依次執行多箇中間件
我們先來解決第一個問題
封裝req和res對象,構造context
參考代碼: step-2
先來介紹下ES6中的get和set 參考
基於普通對象的get和set
const demo = {
_name: '',
get name() {
return this._name;
},
set name(val) {
this._name = val;
}
};
demo.name = 'deepred';
console.log(demo.name);
基於Class
的get和set
class Demo {
constructor() {
this._name = '';
}
get name() {
return this._name;
}
set name(val) {
this._name = val;
}
}
const demo = new Demo();
demo.name = 'deepred';
console.log(demo.name);
基於Object.defineProperty
的get和set
const demo = {
_name: ''
};
Object.defineProperty(demo, 'name', {
get: function() {
return this._name
},
set: function(val) {
this._name = val;
}
});
基於Proxy
的get和set
const demo = {
_name: ''
};
const proxy = new Proxy(demo, {
get: function(target, name) {
return name === 'name' ? target['_name'] : undefined;
},
set: function(target, name, val) {
name === 'name' && (target['_name'] = val)
}
});
還有__defineSetter__
和__defineGetter__
的實現,不過現已廢棄。
const demo = {
_name: ''
};
demo.__defineGetter__('name', function() {
return this._name;
});
demo.__defineSetter__('name', function(val) {
this._name = val;
});
主要區別是,Object.defineProperty
__defineSetter__
Proxy
可以動態設置屬性,而其他方式只能在定義時設置。
Koa源碼中 request.js
和response.js
就使用了大量的get
和set
來代理
新建kao/request.js
module.exports = {
get header() {
return this.req.headers;
},
set header(val) {
this.req.headers = val;
},
get url() {
return this.req.url;
},
set url(val) {
this.req.url = val;
},
}
當訪問request.url
時,其實就是在訪問原生的req.url
。需要注意的是,this.req
原生對象此時還沒有注入!
同理新建kao/response.js
module.exports = {
get status() {
return this.res.statusCode;
},
set status(code) {
this.res.statusCode = code;
},
get body() {
return this._body;
},
set body(val) {
// 源碼裏有對val類型的各種判斷,這裏省略
/* 可能的類型
1. string
2. Buffer
3. Stream
4. Object
*/
this._body = val;
}
}
這裏對body進行操作並沒有使用原生的this.res.end,因爲在我們編寫koa代碼的時候,會對body進行多次的讀取和修改,所以真正返回瀏覽器信息的操作是在application.js
裏進行封裝和操作
同樣需要注意的是,this.res
原生對象此時還沒有注入!
新建kao/context.js
const delegate = require('delegates');
const proto = module.exports = {
// context自身的方法
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>'
};
},
}
// delegates 原理就是__defineGetter__和__defineSetter__
// method是委託方法,getter委託getter,access委託getter和setter。
// proto.status => proto.response.status
delegate(proto, 'response')
.access('status')
.access('body')
// proto.url = proto.request.url
delegate(proto, 'request')
.access('url')
.getter('header')
context.js
代理了request
和response
。ctx.body
指向ctx.response.body
。但是此時ctx.response
ctx.request
還沒注入!
可能會有疑問,爲什麼response.js
和request.js
使用get set
代理,而context.js
使用delegate
代理? 原因主要是: set
和get
方法裏面還可以加入一些自己的邏輯處理。而delegate
就比較純粹了,只代理屬性。
{
get length() {
// 自己的邏輯
const len = this.get('Content-Length');
if (len == '') return;
return ~~len;
},
}
// 僅僅代理屬性
delegate(proto, 'response')
.access('length')
因此context.js
比較適合使用delegate
,僅僅是代理request
和response
的屬性和方法。
真正注入原生對象,是在application.js
裏的createContext
方法中注入的!!!
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');
class Application {
constructor() {
this.callbackFn = null;
// 每個Kao實例的context request respones
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
use(fn) {
this.callbackFn = fn;
}
callback() {
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx)
};
return handleRequest;
}
handleRequest(ctx) {
const handleResponse = () => respond(ctx);
// callbackFn是個async函數,最後返回promise對象
return this.callbackFn(ctx).then(handleResponse);
}
createContext(req, res) {
// 針對每個請求,都要創建ctx對象
// 每個請求的ctx request response
// ctx代理原生的req res就是在這裏代理的
let ctx = Object.create(this.context);
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
ctx.app = ctx.request.app = ctx.response.app = this;
return ctx;
}
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
module.exports = Application;
function respond(ctx) {
// 根據ctx.body的類型,返回最後的數據
/* 可能的類型,代碼刪減了部分判斷
1. string
2. Buffer
3. Stream
4. Object
*/
let content = ctx.body;
if (typeof content === 'string') {
ctx.res.end(content);
}
else if (typeof content === 'object') {
ctx.res.end(JSON.stringify(content));
}
}
代碼中使用了Object.create
的方法創建一個全新的對象,通過原型鏈繼承原來的屬性。這樣可以有效的防止污染原來的對象。
createContext
在每次http請求時都會調用,每次調用都新生成一個ctx
對象,並且代理了這次http請求的原生的對象。
respond
纔是最後返回http響應的方法。根據執行完所有中間件後ctx.body
的類型,調用res.end
結束此次http請求。
現在我們再來測試一下:kao/index.js
const Kao = require('./application');
const app = new Kao();
// 使用ctx修改狀態碼和響應內容
app.use(async (ctx) => {
ctx.status = 200;
ctx.body = {
code: 1,
message: 'ok',
url: ctx.url
};
});
app.listen(3001, () => {
console.log('server start at 3001');
});
中間件機制
參考代碼: step-3
const greeting = (firstName, lastName) => firstName + ' ' + lastName
const toUpper = str => str.toUpperCase()
const fn = compose([toUpper, greeting]);
const result = fn('jack', 'smith');
console.log(result);
函數式編程有個compose
的概念。比如把greeting
和toUpper
組合成一個複合函數。調用這個複合函數,會先調用greeting
,然後把返回值傳給toUpper
繼續執行。
實現方式:
// 命令式編程(面向過程)
function compose(fns) {
let length = fns.length;
let count = length - 1;
let result = null;
return function fn1(...args) {
result = fns[count].apply(null, args);
if (count <= 0) {
return result
}
count--;
return fn1(result);
}
}
// 聲明式編程(函數式)
function compose(funcs) {
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
Koa的中間件機制類似上面的compose
,同樣是把多個函數包裝成一個,但是koa的中間件類似洋蔥模型,也就是從A中間件執行到B中間件,B中間件執行完成以後,仍然可以再次回到A中間件。
Koa使用了koa-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!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
// 一箇中間件裏多次調用next
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// fn就是當前的中間件
let fn = middleware[i]
if (i === middleware.length) fn = next // 最後一箇中間件如果也next時進入(一般最後一箇中間件是直接操作ctx.body,並不需要next了)
if (!fn) return Promise.resolve() // 沒有中間件,直接返回成功
try {
/*
* 使用了bind函數返回新的函數,類似下面的代碼
return Promise.resolve(fn(context, function next () {
return dispatch(i + 1)
}))
*/
// dispatch.bind(null, i + 1)就是中間件裏的next參數,調用它就可以進入下一個中間件
// fn如果返回的是Promise對象,Promise.resolve直接把這個對象返回
// fn如果返回的是普通對象,Promise.resovle把它Promise化
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
// 中間件是async的函數,報錯不會走這裏,直接在fnMiddleware的catch中捕獲
// 捕獲中間件是普通函數時的報錯,Promise化,這樣才能走到fnMiddleware的catch方法
return Promise.reject(err)
}
}
}
}
const context = {};
const sleep = (time) => new Promise(resolve => setTimeout(resolve, time));
const test1 = async (context, next) => {
console.log('1-start');
context.age = 11;
await next();
console.log('1-end');
};
const test2 = async (context, next) => {
console.log('2-start');
context.name = 'deepred';
await sleep(2000);
console.log('2-end');
};
const fn = compose([test1, test2]);
fn(context).then(() => {
console.log('end');
console.log(context);
});
遞歸調用棧的執行情況:
弄懂了中間件機制,我們應該可以回答之前的問題:
next
到底是啥?洋蔥模型是怎麼實現的?
next就是一個包裹了dispatch的函數
在第n箇中間件中執行next,就是執行dispatch(n+1),也就是進入第n+1箇中間件
因爲dispatch返回的都是Promise,所以在第n箇中間件await next(); 進入第n+1箇中間件。當第n+1箇中間件執行完成後,可以返回第n箇中間件
如果在某個中間件中不再調用next,那麼它之後的所有中間件都不會再調用了
修改kao/application.js
class Application {
constructor() {
this.middleware = []; // 存儲中間件
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
use(fn) {
this.middleware.push(fn); // 存儲中間件
}
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)
}
}
}
}
callback() {
// 合成所有中間件
const fn = this.compose(this.middleware);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn)
};
return handleRequest;
}
handleRequest(ctx, fnMiddleware) {
const handleResponse = () => respond(ctx);
// 執行中間件並把最後的結果交給respond
return fnMiddleware(ctx).then(handleResponse);
}
createContext(req, res) {
// 針對每個請求,都要創建ctx對象
let ctx = Object.create(this.context);
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
ctx.app = ctx.request.app = ctx.response.app = this;
return ctx;
}
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
module.exports = Application;
function respond(ctx) {
let content = ctx.body;
if (typeof content === 'string') {
ctx.res.end(content);
}
else if (typeof content === 'object') {
ctx.res.end(JSON.stringify(content));
}
}
測試一下
const Kao = require('./application');
const app = new Kao();
app.use(async (ctx, next) => {
console.log('1-start');
await next();
console.log('1-end');
})
app.use(async (ctx) => {
console.log('2-start');
ctx.body = 'hello tc';
console.log('2-end');
});
app.listen(3001, () => {
console.log('server start at 3001');
});
// 1-start 2-start 2-end 1-end
錯誤處理機制
參考代碼: step-4
因爲compose
組合之後的函數返回的仍然是Promise對象,所以我們可以在catch
捕獲異常
kao/application.js
handleRequest(ctx, fnMiddleware) {
const handleResponse = () => respond(ctx);
const onerror = err => ctx.onerror(err);
// catch捕獲,觸發ctx的onerror方法
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
kao/context.js
const proto = module.exports = {
// context自身的方法
onerror(err) {
// 中間件報錯捕獲
const { res } = this;
if ('ENOENT' == err.code) {
err.status = 404;
} else {
err.status = 500;
}
this.status = err.status;
res.end(err.message || 'Internal error');
}
}
const Kao = require('./application');
const app = new Kao();
app.use(async (ctx) => {
// 報錯可以捕獲
a.b.c = 1;
ctx.body = 'hello tc';
});
app.listen(3001, () => {
console.log('server start at 3001');
});
現在我們已經實現了中間件的錯誤異常捕獲,但是我們還缺少框架層發生錯誤的捕獲機制。我們可以讓Application
繼承原生的Emitter
,從而實現error
監聽
kao/application.js
const Emitter = require('events');
// 繼承Emitter
class Application extends Emitter {
constructor() {
// 調用super
super();
this.middleware = [];
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
}
kao/context.js
const proto = module.exports = {
onerror(err) {
const { res } = this;
if ('ENOENT' == err.code) {
err.status = 404;
} else {
err.status = 500;
}
this.status = err.status;
// 觸發error事件
this.app.emit('error', err, this);
res.end(err.message || 'Internal error');
}
}
const Kao = require('./application');
const app = new Kao();
app.use(async (ctx) => {
// 報錯可以捕獲
a.b.c = 1;
ctx.body = 'hello tc';
});
app.listen(3001, () => {
console.log('server start at 3001');
});
// 監聽error事件
app.on('error', (err) => {
console.log(err.stack);
});
至此我們可以瞭解到Koa異常捕獲的兩種方式:
- 中間件捕獲(Promise catch)
- 框架捕獲(Emitter error)
// 捕獲全局異常的中間件
app.use(async (ctx, next) => {
try {
await next()
} catch (error) {
return ctx.body = 'error'
}
}
)
// 事件監聽
app.on('error', err => {
console.log('error happends: ', err.stack);
});
總結
Koa整個流程可以分成三步:
初始化階段:
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
new
初始化一個實例,use
蒐集中間件到middleware數組,listen
合成中間件fnMiddleware
,返回一個callback函數給http.createServer
,開啓服務器,等待http請求。
請求階段:
每次請求,createContext
生成一個新的ctx
,傳給fnMiddleware
,觸發中間件的整個流程
響應階段:
整個中間件完成後,調用respond
方法,對請求做最後的處理,返回響應給客戶端。
參考下面的流程圖: