express.js 源码三探 —— response篇

本篇

本篇是这个系列的第三篇,谢谢各位的建议

环境

保持与之前的版本一致,源码版本是express.js 4.14.1


response源码

Object.create

打开response.js,从上往下看代码,代码具体戳这里

var res = module.exports = {  
  __proto__: http.ServerResponse.prototype
};

上一篇我们讲到request对象和http.IncomingMessage的关系,那么类似的response和ServerResponse也是一样的
但是注意最新的express代码中已经不用这个,采用Object.create(),看最新的代码

var res = Object.create(http.ServerResponse.prototype)  

Object.create是E5中提出的一种新的对象创建方式,本质上也是把res.__proto__指向ServerResponse.prototype

res.status
res.status = function status(code) {  
  this.statusCode = code;
  return this;
};

这个比较简单,就是设置http 返回头的状态码

这个是设置http 头中links字段

res.links = function(links){  
  var link = this.get('Link') || '';
  if (link) link += ', ';
  return this.set('Link', link + Object.keys(links).map(function(rel){
    return '<' + links[rel] + '>; rel="' + rel + '"';
  }).join(', '));
};

至于为什么会有link这样的http头字段,我之前没听过,搜索看到了 RFC 5988,link字段是要表达资源的关系

res.send
  switch (typeof chunk) {
    // string defaulting to html
    case 'string':
      if (!this.get('Content-Type')) {
        this.type('html');
      }
      break;
    case 'boolean':
    case 'number':
    case 'object':
      if (chunk === null) {
        chunk = '';
      } else if (Buffer.isBuffer(chunk)) {
        if (!this.get('Content-Type')) {
          this.type('bin');
        }
      } else {
        return this.json(chunk);
      }
      break;
  }

res.send通常来讲的参数有三种Buffer、json格式、string

  • string类型,如果没有定义Content-Type,就设置返回类型为html
  • Buffer,就把类型定义为bin(二进制)
  • json格式,会去调用app.json函数

紧接着下面的代码重新计算etag的值

  // populate ETag
  var etag;
  var generateETag = len !== undefined && app.get('etag fn');
  if (typeof generateETag === 'function' && !this.get('ETag')) {
    if ((etag = generateETag(chunk, encoding))) {
      this.set('ETag', etag);
    }
  }

然后就是查看缓存

  // freshness
  if (req.fresh) this.statusCode = 304;

获取req.fresh值,get的属性

defineGetter(req, 'fresh', function(){  
  var method = this.method;
  var s = this.res.statusCode;

  // GET or HEAD for weak freshness validation only
  if ('GET' !== method && 'HEAD' !== method) return false;

  // 2xx or 304 as per rfc2616 14.26
  if ((s >= 200 && s < 300) || 304 === s) {
    return fresh(this.headers, (this.res._headers || {}));
  }

  return false;
});

可以看看库fresh的代码,主要是判断两点

  • 请求头中["if-none-match"]能不能匹配*,或者res对象中["etag"]
  • res对象中['last-modified']是否小于等于请求头["if-modified-since"]

能匹配上,fresh都会为true,也就是head.status = 304

res.json

主要是返回json格式的内容,当把json数据序列化成string格式后,函数的结尾还是调用的res.send发送字符串

  // settings
  var app = this.app;
  var replacer = app.get('json replacer');
  var spaces = app.get('json spaces');
  var body = stringify(val, replacer, spaces);

如上面所示,val是key-value的js对象,stringify本质上调用了JSON.stringify的函数
1 参数replacer是函数,是对val里面的对应key变换val的
比方说

var val = { name: 'tobi', _id: 12345 };  
var str = JSON.stringify(val, (key, value) => {  
    return '_' == key[0]
        ? undefined
        :  val;
});
console.log(str);  
//输出为字符串: {name:'tobi'} 

2 参数spaces是对输出字符串的添加一些缩进、空格、换行、

var str = JSON.stringify({name : "w"}, undefined, 2);  
console.log(str);  
//输出
{
   name : "w"
}
res.jsonp

发送jsonp格式的数据,什么是jsonp
就是解决跨域问题,ajax请求是不能跨域的,但是js标签的请求是可以跨域的<\scrpit>
是不是同一个域主要看三个:域名,端口,协议

var app = express();  
app.use(function(req, res){  
  res.jsonp({ count: 1 });
});
request(app)  
.get('/?callback=something')
.expect('Content-Type', 'text/javascript; charset=utf-8')
.expect(200, /something\(\{"count":1\}\);/, done);

通过url中的callback的参数去请求跨域的资源

res.sendStatus

设置返回状态码并 把状态码作为文本返回

res.sendStatus = function sendStatus(statusCode) {  
  var body = statusCodes[statusCode] || String(statusCode);

  this.statusCode = statusCode;
  this.type('txt');

  return this.send(body);
};
res.sendFile/sendfile

注意看一下,除了res.sendFile之外,还有一个res.sendfile的函数定义,可见已经不建议使用res.sendfile

res.sendFile = function sendFile(path, options, callback) {  
  ...
  // create file stream
  var pathname = encodeURI(path);
  var file = send(req, pathname, opts);

  // transfer
  sendfile(res, file, opts, function (err) {
    if (done) return done(err);
    if (err && err.code === 'EISDIR') return next();

    // next() all but write errors
    if (err && err.code !== 'ECONNABORTED' && err.syscall !== 'write') {
      next(err);
    }
  });
};


res.sendfile = function (path, options, callback) {  
    ...
}
res.sendfile = deprecate.function(res.sendfile,  
  'res.sendfile: Use res.sendFile instead');

上面的res.sendFile里面有调用了一个senfile函数,具体实现如下

function sendfile(res, file, options, callback) {  
  var done = false;
  var streaming;
  ...
  file.on('directory', ondirectory);
  file.on('end', onend);
  file.on('error', onerror);
  file.on('file', onfile);
  file.on('stream', onstream);
  onFinished(res, onfinish);
  ...
  // pipe
  file.pipe(res);
}

这个函数里面有大量的事件驱动,这个事件驱动的信号是在哪里触发的呢?
如下,翻源码,是在依赖的库./node_modules/send/index.js
函数的开头fs.stat尝试去读取变量path这个文件,如果能读到文件,那么Line676 就发出"file"的信号,接着调用self.send
如果读取不到文件,也有定义了的next函数,在next中Line687 会尝试对文件进行拼接各种不同的后缀名再去尝试读取
(这里有个逻辑Line 692,可以明白如果找到相应name.extension的目录而非文件name.extension,是直接跳过的) sendFile.png

然后代码Line 677,Line 694可以调用了self.send,看看它的实现

SendStream.prototype.send = function send (path, stat) {  
  ...
  // 如果有缓存的话,就不需要服务器重新发送
  if (this.isConditionalGET() && this.isCachable() && this.isFresh()) {
    this.notModified()
    return
  }
  ...
  this.stream(path, opts)
}


当数据配置处理完,存在opts对象里面,比如start、end等属性,然后通过this.stream(path, opts)来调用,继续看this.stream的实现

SendStream.prototype.stream = function stream (path, options) {  
  // TODO: this is all lame, refactor meeee
  var finished = false
  var self = this
  var res = this.res

  // 创建读入流对象
  var stream = fs.createReadStream(path, options)
  // 发送'stream'的信号,跟上面response.js里面file.on('stream', onstream);
  this.emit('stream', stream)
  //利用管道把数据流入res
  stream.pipe(res)

  // response finished, done with the fd
  onFinished(res, function onfinished () {
    finished = true
    destroy(stream)
  })

  // error handling code-smell
  stream.on('error', function onerror (err) {
    // request already finished
    if (finished) return

    // clean up stream
    finished = true
    destroy(stream)

    // error
    self.onStatError(err)
  })

  // 数据流完结,发送'end'
  stream.on('end', function onend () {
    self.emit('end')
  })
}

可见几个最核心的事件驱动信号都在这,error、end、stream等
stream.pipe(res)通过管道的数据流给res

res.download

这个函数最核心的地方也就是调用上面的res.sendFile

res.contentType/res.type

设置返回头的content_type字段

res.format

根据客户端可接受的mime-type格式,执行不同的函数
参数obj,格式举例如下

{
  text: function(){
    body = statusCodes[status] + '. Redirecting to ' + address;
  },

  html: function(){
    var u = escapeHtml(address);
    body = '<p>' + statusCodes[status] + '. Redirecting to <a href="' + u + '">' + u + '</a></p>';
  },

  default: function(){
    body = '';
  }
}

1.当客户端接受text格式的报文,那么就调用text属性的函数,构造body
2.如果客户端不接受上面任意报文格式,那么尝试执行Obj对象的default属性函数
3.如果上面两种情况都没有触发,那么返回状态码406

res.attachment
res.attachment = function attachment(filename) {  
  if (filename) {
    this.type(extname(filename));
  }

  this.set('Content-Disposition', contentDisposition(filename));

  return this;
};


这个跟Content-Disposition header字段有关
attachment 触发浏览器弹出下载框
inline 触发浏览器内嵌显示
具体的http头格式如下

Content-disposition: inline; filename=filename.txt  
Content-disposition: attachment; filename=filename.txt  
res.append

这个函数比较简单,就是给返回http中添加头字段
如果先前设置过该field,那么把这个value就跟先前value拼接成数组作为新的value,举例如下

res.append('Link', ['<http://localhost/>', '<http://localhost:3000/>']);  
res.append('Set-Cookie', 'foo=bar; Path=/; HttpOnly');  
res.append('Warning', '199 Miscellaneous warning');  

函数核心是调用res.set方法

res.set/header/get/clearCookie/location/redirect

set/header(field,value) 设置http某个具体字段
get(field) 获取http某个具体的字段
clearCookie(name) 删除cookie中的某个字段(这里是cookie的字段,不是Http头字段)
location 设置http头中location字段
redirect 302重定向到新的url

res.cookie(name, value, options)

这个是往原有的Set-Cookie(更新客户端的cookie)里面添加(注意不是覆盖)新的field-value
参数name、value分别代表field-value
options是具有三个属性的对象(不强制一定要三个),具体属性格式如下

`maxage`   设置最大过期时间,也可以使用expires  
`signed`   true or false,代表这个cookie是否是signed的cookie
`path`     默认路径是"/"

函数使用方式如下  
httponly是告诉浏览器这个cookie不可以让js这类脚本获取到的,可有效缓解xss攻击  
res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true });  
res.cookie('rememberme', '1', { maxAge: 900000, httpOnly: true })  

这里面最让人迷惑的是signed cookie

res.cookie = function (name, value, options) {  
  ...
  var secret = this.req.secret;
  var signed = opts.signed;
  ...
  if (signed) {
    val = 's:' + sign(val, secret);
  }
  ...
  this.append('Set-Cookie', cookie.serialize(name, String(val), opts));
  ...
};

可见代码中signed cookie的value是以s:开头的,具体作用是什么?
看看测试样例里面的代码

var cookieParser = require('cookie-parser')  
...
app.use(cookieParser('secret'));

app.use(function(req, res){  
  if ('/set' == req.path) {
    //第一次请求返回的signed cookie信息
    res.cookie('obj', { foo: 'bar' }, { signed: true });
    res.end();
  } else {
    res.send(req.signedCookies); //第二次返回从req cookie中解析出来的数据
  }
});

request(app)  
.get('/set') //第一次请求
.end(function(err, res){
  if (err) return done(err);
  var cookie = res.header['set-cookie'];

  request(app)
  .get('/')
  .set('Cookie', cookie)
  .end(function(err, res){
    if (err) return done(err); 
    res.body.should.eql({ obj: { foo: 'bar' } }); //第二次请求返回的内容
    done();
  });
});

从这里我们可以了解到,结合cookeParse库,我们可以把数据加密放在signed cookie里面,而且这个密钥是由开发者放进去的
当客户端携带signed cookie再次请求的时候,cookeparse中间件解析signed cookie,开发者可以通过属性req.signedCookies把之前数据拿出

res.vary

对返回http报文中的字段vary设置
vary这个字段主要是告诉缓存服务器,如果同一个 URL 有着不同http返回资源时,需要缓存和筛选合适的版本
例如在函数res.format调用了this.vary("Accept")
这是因为函数format就是要对不同格式的Accpet返回不同的Content-Type格式

Accept    告知服务器发送何种媒体类型   Content-Type  
Accept-Language    告知服务器发送何种语言 Content-Language  
Accept-Charset    告知服务器发送何种字符集    Content-Type  
Accept-Encoding    告知服务器采用何种压缩方式   Content-Encoding  

当然如果我们的资源对于不同的浏览器返回是不一样的,也就是浏览器的UA
那么我们服务器返回http中应该含有这样的字段

Vary: User-Agent, Cookie  
res.render

这个函数主要调用了app.render,后面分析application.js的时候再介绍


未经同意,禁止转载
by chainhelen