使用体验
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说明。这就意味着koa@2只能在es6以上的环境下运行,低版本可以考虑使用koa@1.x。而express则比较传统,使用的是function的形式导出。
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的对比分析,二者各有自己的特点,在使用中可以根据需求选择最适合的解决方案。