express与koa对比

  • Post author:
  • Post category:其他



使用体验

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进行转码。

启动方式

  1. 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的对比分析,二者各有自己的特点,在使用中可以根据需求选择最适合的解决方案。



版权声明:本文为K616358281原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。