express與koa對比
使用體驗
koa
const Koa = require('koa');
const app = new Koa();
app.use(ctx => {
ctx.body = 'Hello Koa';
});
app.listen(3000);
express
const app = require("express")();
app.use((req,res,next)=>{
res.status(200).send("<h1>headers ...</h1>");
});
app.listen(3001);
注意:本文全部採用es6語法編寫,如果環境不支援請自行升級node或者使用babel進行轉碼。
啟動方式
- koa採用了
new Koa()
的方式,而express採用傳統的函式形式,對比原始碼如下:
//koa
const Emitter = require('events');
module.exports = class Application extends Emitter {
...
}
//express
exports = module.exports = createApplication;
function createApplication() {
...
}
可以看到koa@2採用了es6的語法實現,繼承了Emitter
類,具體資訊可以參考Emitter說明
2. 中介軟體形式二者不一樣,這是由二者處理中介軟體的邏輯差異導致的,實際上這也是二者最根本的差別,具體的分析留作後面進行對比,這裡主要對比二者的使用上的差別,如下所示:
express處理多箇中間件
const app = require("express")();
app.use((req,res,next)=>{
console.log("first");
//next();
});
app.use((req,res,next)=>{
console .log("second");
//next();
});
app.use((req,res,next)=>{
console.log("third");
res.status(200).send("<h1>headers ...</h1>");
});
app.listen(3001);
如果寫成這樣,終端只會打印出first,而且不會反悔,前端請求會一直等待到超時,導致這一問題的原因是:express必須主動呼叫next()
才能讓中間價繼續執行,放開註釋即可。這也保證了我們可以自主控制如何響應請求。
koa處理多箇中間件
const Koa = require('koa');
const app = new Koa();
app.use((ctx,next) => {
ctx.body = 'Hello Koa-1';
next();
});
app.use((ctx,next) => {
ctx.body = 'Hello Koa-2';
next();
});
app.use((ctx,next) => {
ctx.body = 'Hello Koa-3';
next();
});
app.listen(3000);
與express類似,koa中介軟體的入參也有兩個,後一個就是next。next的功能與express一樣,這裡不再贅述。
上面介紹了koa的
next()
的功能,這裡的next()
需要同步呼叫,千萬不要採用非同步呼叫,不要寫成下面的形式,這樣相當於未呼叫next()
,具體原因後面原始碼部分會分析:
app.use((ctx,next) => {
ctx.body = 'Hello Koa-2';
setTimeout(()=>next(),3000);
//next();
});
雖然上面分析了二者的使用邏輯不一樣,但是由於koa在入參處給出了context,而該結構體包含了我們返回請求的所有資訊,所以我們仍然可以寫出下面的程式碼:
const Koa = require('koa');
const app = new Koa();
app.use((ctx)=>{
const res = ctx.res;
res.writeHead(200, {'Content-Type': 'text/html;charset=utf-8','Accept-Language':'zh-CN,zh;q=0.8,en;q=0.6'});
res.end('<h1>標題</h1>');
});
// response
app.use(ctx => {
ctx.body = 'Hello Koa';
});
app.listen(3000);
這樣的邏輯就和express很類似了,原理也一樣。這樣寫以後,前端的請求得到的結果就是<h1>標題</h1>
,而後續的app.use
實際並沒有得到執行。
express分路由處理
express的程式碼一般如下:
const app = require("express")();
app.use("/first",(req,res,next)=>{
console.log("first");
res.status(200).send("<h1>headers-first ...</h1>");
});
app.use("/second",(req,res,next)=>{
console.log("second");
res.status(200).send("<h1>headers-second ...</h1>");
});
app.use("/third",(req,res,next)=>{
console.log("third");
res.status(200).send("<h1>headers-third ...</h1>");
});
app.listen(3001);
這很好理解,根據請求路徑返回不同結果,koa呢?
koa分路由處理
const Koa = require('koa');
const app = new Koa();
app.use("/",ctx => {
ctx.body = 'Hello Koa';
});
app.listen(3000);
這麼寫會報錯,因為koa本身並不支援按路由相應,如果需要這麼做,可以通過引入第三方包實現。在koajs中有一個簡單的router包。
具體寫法如下:
//摘抄自Koa Trie Router
const Koa = require('koa')
const Router = require('koa-trie-router')
let app = new Koa()
let router = new Router()
router
.use(function(ctx, next) {
console.log('* requests')
next()
})
.get(function(ctx, next) {
console.log('GET requests')
next()
})
.put('/foo', function (ctx) {
ctx.body = 'PUT /foo requests'
})
.post('/bar', function (ctx) {
ctx.body = 'POST /bar requests'
})
app.use(router.middleware())
app.listen(3000)
在具體應用中也可以採用其他的路由包來做,在github上能搜到不少。
另外,由於實現的原因,下面介紹一個有意思的現象,看下面兩段程式碼,初衷是列印請求處理耗時。
koa版本
const Koa = require('koa');
const app = new Koa();
app.use((ctx,next) => {
ctx.body = 'Hello Koa-1';
let start = new Date();
next().then(()=>{
console.log("time cost:",new Date()-start);
});
});
app.use((ctx,next) => {
ctx.body = 'Hello Koa-2';
next();
});
app.use((ctx,next) => {
ctx.body = 'Hello Koa-3';
next();
});
app.listen(3000);
由於koa採用了promise的方式處理中介軟體,next()
實際上返回的是一個promise物件,所以可以用上面簡單的方式記錄處理耗時。如果在es7下,可以採用更簡單的寫法:
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx,next) => {
ctx.body = 'Hello Koa-1';
let start = new Date();
await next();
console.log("time cost:",new Date()-start);
});
app.use(async (ctx,next) => {
ctx.body = 'Hello Koa-2';
//這裡用了一個定時器表示實際的操作耗時
await new Promise((resolve,reject)=>setTimeout(()=>{next();resolve();},3000));
});
app.use((ctx,next) => {
ctx.body = 'Hello Koa-3';
next();
});
app.listen(3000);
這樣只需要在入口放置一箇中間件即可完成耗時記錄。
express版本
由於express並沒有使用promise而是採用了回撥的方式處理中介軟體,所以無法採用上面這樣便利的方式獲取耗時。即便是對next()進行封裝,也無濟於事,因為必須保證後續的next()全部都被封裝才能得到正確的結果。
下面給出一個參考實現:
let time = null;
.use('/', (req, res, next) => {
time = Date.now();
next()
})
.use('/eg', bidRequest)
.use('/', (req, res, next) => {
console.log(`<= time cost[${req.baseUrl}] : `, Date.now() - time, 'ms');
})
總結
koa和express的區別還是比較大的,koa的內容很少,就是對nodejs本身的createServer函式做了簡單的封裝,沒有做很多的延伸;而express主要是比koa多了router。二者的的程式碼思路還是很不一樣的,不過實際使用中並不會有太大障礙。
原始碼分析
koa
koa的原始碼主要有四個檔案:application.js, context.js, request.js, response.js
context.js
context沒有實際功能性程式碼,只是一些基礎函式和變數,下面是程式碼片段。
inspect() {
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>'
};
},
request.js
該檔案中主要是一堆set和get函式,主要是用於獲取請求結構體的特定欄位或者修改特定欄位,比如下面獲取ip的函式,程式碼很好理解:
get ips() {
const proxy = this.app.proxy;
const val = this.get('X-Forwarded-For');
return proxy && val
? val.split(/\s*,\s*/)
: [];
},
response.js
response與request對應,主要是一些處理res的工具類,下面是程式碼片段,用於設定和獲取res的content-length:
set length(n) {
this.set('Content-Length', n);
},
get length() {
const len = this.header['content-length'];
const body = this.body;
if (null == len) {
if (!body) return;
if ('string' == typeof body) return Buffer.byteLength(body);
if (Buffer.isBuffer(body)) return body.length;
if (isJSON(body)) return Buffer.byteLength(JSON.stringify(body));
return;
}
return ~~len;
},
其中用到了~~len
,這個有點意思,兩次取反,可以保證輸出為數字,如果len為字串則返回0。(第一次見…)
application.js
上文中用到的app就是在該檔案中定義的,也是koa的核心所在,這裡挑選幾個成員函式進行分析(整個檔案程式碼也就不到250行,自己看完壓力也不大)。
module.exports = class Application extends Emitter {
/*
建構函式:把req,res,env等常用的變數全都塞進了context,所以我們在中介軟體中拿到context以後,就可以隨心所欲地操作req和res了。
*/
constructor() {
super();
this.proxy = false;
this.middleware = [];
this.subdomainOffset = 2;
this.env = process.env.NODE_ENV || 'development';
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
/*
實際就是呼叫了nodejs本身的createServer,沒有任何區別。
*/
listen() {
debug('listen');
const server = http.createServer(this.callback());
return server.listen.apply(server, arguments);
}
//下面分析
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn);
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}
//下面分析
callback() {
const fn = compose(this.middleware);
if (!this.listeners('error').length) this.on('error', this.onerror);
const handleRequest = (req, res) => {
res.statusCode = 404;
const ctx = this.createContext(req, res);
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fn(ctx).then(handleResponse).catch(onerror);
};
return handleRequest;
}
//新建context
createContext(req, res) {
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
context.app = request.app = response.app = this;
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.cookies = new Cookies(req, res, {
keys: this.keys,
secure: request.secure
});
request.ip = request.ips[0] || req.socket.remoteAddress || '';
context.accept = request.accept = accepts(req);
context.state = {};
return context;
}
//下面分析
function respond(ctx) {
// allow bypassing koa
if (false === ctx.respond) return;
const res = ctx.res;
if (!ctx.writable) return;
let body = ctx.body;
const code = ctx.status;
// ignore body
if (statuses.empty[code]) {
// strip headers
ctx.body = null;
return res.end();
}
if ('HEAD' == ctx.method) {
if (!res.headersSent && isJSON(body)) {
ctx.length = Buffer.byteLength(JSON.stringify(body));
}
return res.end();
}
// status body
if (null == body) {
body = ctx.message || String(code);
if (!res.headersSent) {
ctx.type = 'text';
ctx.length = Buffer.byteLength(body);
}
return res.end(body);
}
// responses
if (Buffer.isBuffer(body)) return res.end(body);
if ('string' == typeof body) return res.end(body);
if (body instanceof Stream) return body.pipe(res);
// body: json
body = JSON.stringify(body);
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body);
}
res.end(body);
}
下面重點看use,callback,respond這三個函式,實際上理解koa的資訊流看這三個函式的原始碼就差不多足夠了。
use : 內容不多,其中第一個if
用於安全檢查,第二個if
用於實現對generator函式的相容,具體實現過程在is-generator-function
這個包裡面,有興趣可以看看,還是挺有技巧的,參考借用。use最終僅僅就是把中介軟體push
到了this.middleware
數組裡,並沒有任何實質的邏輯操作。
respond : 該函式就是響應請求的地方,這也是為什麼我們可以不用主動地響應請求。函式裡做了很多判斷,主要是防止二次響應以及特殊特定的響應的請求。
callback : callback用於生成createServer
函式的回撥,即handleRequest
函式。handleRequest
的返回值正是一個promise物件。注意這裡呼叫了一個compose
方法,該方法的作用就是把中介軟體陣列轉換成一個函式,以方便使用。具體的實現在koa-compose
這個包裡,這裡摘抄其中的一段來分析。
//這就是compose(...)返回的函式
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, function next () {
return dispatch(i + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
}
可以看到,實際上這裡就是用閉包實現了對中介軟體陣列的遍歷。具體思路會把第i+1箇中間件作為next
傳給第i箇中間件,這也就是為什麼必須主動呼叫next
的原因,以為如果不主動呼叫next
這一迴圈就會提前結束了,後續的中介軟體就無法得到執行。
到此為止,koa原始碼分析就結束了,koa原始碼很少,沒有多餘的東西,甚至連路由都需要引入其他的包。
express
express的原始碼比koa多了不少東西,這裡僅僅對比核心部分,忽略其他部分的內容。
//express.js
function createApplication() {
var app = function(req, res, next) {
app.handle(req, res, next);
};
mixin(app, EventEmitter.prototype, false);
mixin(app, proto, false);
// expose the prototype that will get set on requests
app.request = Object.create(req, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})
// expose the prototype that will get set on responses
app.response = Object.create(res, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})
app.init();
return app;
}
這裡的express就是我們上文中引入的express物件,可以看到,實際上該函式就是把一些常用的功能和變數繫結到了app物件中去,我們在程式碼中使用的app.eg_funcs
之類的方法都是從這裡繼承得到的。實際上該物件並不侷限於使用app.listen()
來啟動一個服務,下面是listen
函式的程式碼。
//application.js
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};
呼叫app.listen
可以啟動一個伺服器,實際上我們也可以直接手動寫出這兩句程式碼來啟動一個服務。在socket.io
和https
服務中就需要自己來完成這一過程。
下面是app.use
的原始碼:
app.use = function use(fn) {
var offset = 0;
var path = '/';
// default path to '/'
// disambiguate app.use([fn])
if (typeof fn !== 'function') {
var arg = fn;
while (Array.isArray(arg) && arg.length !== 0) {
arg = arg[0];
}
// first arg is the path
if (typeof arg !== 'function') {
offset = 1;
path = fn;
}
}
var fns = flatten(slice.call(arguments, offset));
if (fns.length === 0) {
throw new TypeError('app.use() requires middleware functions');
}
// setup router
this.lazyrouter();
var router = this._router;
fns.forEach(function (fn) {
// non-express app
if (!fn || !fn.handle || !fn.set) {
return router.use(path, fn);
}
debug('.use app under %s', path);
fn.mountpath = path;
fn.parent = this;
// restore .app property on req and res
router.use(path, function mounted_app(req, res, next) {
var orig = req.app;
fn.handle(req, res, function (err) {
setPrototypeOf(req, orig.request)
setPrototypeOf(res, orig.response)
next(err);
});
});
// mounted an app
fn.emit('mount', this);
}, this);
return this;
}
這裡有一些對引數判斷的邏輯,比如第一個引數如果是路徑還是函式,不過平時很少這麼寫。
從中可以看到實際上express是呼叫了router的use方法對中介軟體進行處理。router.use定義在/router/index.js中, 原始碼如下:
proto.use = function use(fn) {
var offset = 0;
var path = '/';
// default path to '/'
// disambiguate router.use([fn])
if (typeof fn !== 'function') {
var arg = fn;
while (Array.isArray(arg) && arg.length !== 0) {
arg = arg[0];
}
// first arg is the path
if (typeof arg !== 'function') {
offset = 1;
path = fn;
}
}
var callbacks = flatten(slice.call(arguments, offset));
if (callbacks.length === 0) {
throw new TypeError('Router.use() requires middleware functions');
}
for (var i = 0; i < callbacks.length; i++) {
var fn = callbacks[i];
if (typeof fn !== 'function') {
throw new TypeError('Router.use() requires middleware function but got a ' + gettype(fn));
}
// add the middleware
debug('use %o %s', path, fn.name || '<anonymous>')
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);
layer.route = undefined;
this.stack.push(layer);
}
return this;
};
其中前大半段主要是一些準備工作(這種寫法在express貌似很常見)。後面看到與koa直接把中介軟體push到陣列的做法不同的是,express會把中介軟體封裝成一個Layer,這樣做也是為了更好地控制中介軟體的執行。Layer的程式碼在/router/layer.js中。(這裡不再分析)
下面開始分析express是怎麼響應請求的,從上面listen部分的程式碼可以看到,我們給createServer
傳了一個this,而這個this正是express()
的返回值,定義在application.js裡,原始碼如下:
var app = function(req, res, next) {
app.handle(req, res, next);
};
可以看到實際上app是呼叫了handle方法,而該方法是從application物件繼承過來的,而檢視application.js發現了下面程式碼:
//初始化 this._router
this._router = new Router({
caseSensitive: this.enabled('case sensitive routing'),
strict: this.enabled('strict routing')
});
this._router.use(query(this.get('query parser fn')));
this._router.use(middleware.init(this));
//使用 this._router
app.handle = function handle(req, res, callback) {
var router = this._router;
// final handler
var done = callback || finalhandler(req, res, {
env: this.get('env'),
onerror: logerror.bind(this)
});
// no routes
if (!router) {
debug('no routes defined on app');
done();
return;
}
router.handle(req, res, done);
};
可以看到實際上這裡呼叫的是router.handle,下面看router的原始碼:
proto.handle = function handle(req, res, out) {
var self = this;
debug('dispatching %s %s', req.method, req.url);
var idx = 0;
var protohost = getProtohost(req.url) || ''
var removed = '';
var slashAdded = false;
var paramcalled = {};
// store options for OPTIONS request
// only used if OPTIONS request
var options = [];
// middleware and routes
var stack = self.stack;
// manage inter-router variables
var parentParams = req.params;
var parentUrl = req.baseUrl || '';
var done = restore(out, req, 'baseUrl', 'next', 'params');
// setup next layer
req.next = next;
// for options requests, respond with a default if nothing else responds
if (req.method === 'OPTIONS') {
done = wrap(done, function(old, err) {
if (err || options.length === 0) return old(err);
sendOptionsResponse(res, options, old);
});
}
// setup basic req values
req.baseUrl = parentUrl;
req.originalUrl = req.originalUrl || req.url;
next();
function next(err) {
var layerError = err === 'route'
? null
: err;
// remove added slash
if (slashAdded) {
req.url = req.url.substr(1);
slashAdded = false;
}
// restore altered req.url
if (removed.length !== 0) {
req.baseUrl = parentUrl;
req.url = protohost + removed + req.url.substr(protohost.length);
removed = '';
}
// signal to exit router
if (layerError === 'router') {
setImmediate(done, null)
return
}
// no more matching layers
if (idx >= stack.length) {
setImmediate(done, layerError);
return;
}
// get pathname of request
var path = getPathname(req);
if (path == null) {
return done(layerError);
}
// find next matching layer
var layer;
var match;
var route;
while (match !== true && idx < stack.length) {
layer = stack[idx++];
match = matchLayer(layer, path);
route = layer.route;
if (typeof match !== 'boolean') {
// hold on to layerError
layerError = layerError || match;
}
if (match !== true) {
continue;
}
if (!route) {
// process non-route handlers normally
continue;
}
if (layerError) {
// routes do not match with a pending error
match = false;
continue;
}
var method = req.method;
var has_method = route._handles_method(method);
// build up automatic options response
if (!has_method && method === 'OPTIONS') {
appendMethods(options, route._options());
}
// don't even bother matching route
if (!has_method && method !== 'HEAD') {
match = false;
continue;
}
}
// no match
if (match !== true) {
return done(layerError);
}
// store route for dispatch on change
if (route) {
req.route = route;
}
// Capture one-time layer values
req.params = self.mergeParams
? mergeParams(layer.params, parentParams)
: layer.params;
var layerPath = layer.path;
// this should be done for the layer
self.process_params(layer, paramcalled, req, res, function (err) {
if (err) {
return next(layerError || err);
}
if (route) {
return layer.handle_request(req, res, next);
}
trim_prefix(layer, layerError, layerPath, path);
});
}
function trim_prefix(layer, layerError, layerPath, path) {
if (layerPath.length !== 0) {
// Validate path breaks on a path separator
var c = path[layerPath.length]
if (c && c !== '/' && c !== '.') return next(layerError)
// Trim off the part of the url that matches the route
// middleware (.use stuff) needs to have the path stripped
debug('trim prefix (%s) from url %s', layerPath, req.url);
removed = layerPath;
req.url = protohost + req.url.substr(protohost.length + removed.length);
// Ensure leading slash
if (!protohost && req.url[0] !== '/') {
req.url = '/' + req.url;
slashAdded = true;
}
// Setup base URL (no trailing slash)
req.baseUrl = parentUrl + (removed[removed.length - 1] === '/'
? removed.substring(0, removed.length - 1)
: removed);
}
debug('%s %s : %s', layer.name, layerPath, req.originalUrl);
if (layerError) {
layer.handle_error(layerError, req, res, next);
} else {
layer.handle_request(req, res, next);
}
}
};
這個函式很長,但是其實大部分內容都是匹配路由,型別檢測等操作,實際的操作集中在next()
函式中,與koa一樣,這裡也是採用閉包來迴圈遍歷中介軟體陣列。看next()
中的執行部分可以看到,正常情況下,實際的操作是由layer.handle_request
完成的,下面看layer.js
原始碼:
//初始化
function Layer(path, options, fn) {
if (!(this instanceof Layer)) {
return new Layer(path, options, fn);
}
debug('new %o', path)
var opts = options || {};
this.handle = fn;
this.name = fn.name || '<anonymous>';
this.params = undefined;
this.path = undefined;
this.regexp = pathRegexp(path, this.keys = [], opts);
// set fast path flags
this.regexp.fast_star = path === '*'
this.regexp.fast_slash = path === '/' && opts.end === false
}
//處理單元
Layer.prototype.handle_request = function handle(req, res, next) {
var fn = this.handle;
if (fn.length > 3) {
// not a standard request handler
return next();
}
try {
fn(req, res, next);
} catch (err) {
next(err);
}
};
bingo,這裡就是我們在呼叫use
的時候初始化的Layer結構體,可以看到,實際上我們把中介軟體函式賦給了layer.handle,而在實際的處理函式handle_request
中,正是呼叫了this.handle
,總算找到了資料處理的根源了….
這裡看到實際上router
中的next()
只是啟動了中介軟體回撥的過程,然後把自己傳給下一個中介軟體,後續的中介軟體主動呼叫next()
這樣就可以傳遞下去了。
在處理中介軟體的邏輯上express可以理解為每次一個中介軟體執行完畢就去主動去通知“中心”去啟動下一個中介軟體;而koa可以理解為鏈式過程,每一箇中間件會啟動後一箇中間件。
到此為止,我們基本完成了koa和express的對比分析,二者各有自己的特點,在使用中可以根據需求選擇最適合的解決方案。