详解《send》源码中NodeJs静态文件托管服务实现原理

2,265 阅读9分钟

本文正在参与技术专题征文Node.js进阶之路,点击查看详情

send是一个用于从文件系统以流的方式读取文件作为http响应结果的库。说的再更通俗一些,就是在Node中提供静态文件的托管服务,比如像expressstatic服务。还有像熟知的serve-static中间件背后也是依赖send进行的中间件封装。

本文将基于send1.0.0-beta.1版本的源码做如下几个方面的讲解:

  • send库的基本使用
  • 静态文件托管服务的核心实现原理
  • 基于sendserve-static中间件的核心实现

源码/原理解析类的文章代码会比较多,小伙伴要耐心哦!!! 精华都在代码里!!! 下面👇我们先看看该库是如何使用的吧。

基本使用

下面演示一个在Node中利用send对于所有http请求都返回根目录下的static/index.html文件资源的例子:

const http = require('http');
const path = require('path');
const send = require('send')

// 初始化一个http服务
const server = http.createServer(function onRequest (req, res) {
  send(req, './index.html', {
    // 指定返回资源的根路径
    root: path.join(process.cwd(), 'static'),
  }).pipe(res);
});

server.listen(3000, () => {
  console.log('server is running at port 3000.');
});

除了这个示例外,比如像live-server库中也是利用send提供了静态文件托管服务,对live-server源码实现感兴趣的小伙伴可以看看我的这边掘金文章详解《Live-Server》源码中的NodeJS技巧。学会了基本使用,下面看看send静态文件托管服务的实现原理吧。

源码分析

send库对外暴露一个send方法,该方法内初始化一个SendStream类,SendStream类继承Stream模块,同时实现pipe等实例方法。主体代码结构如下:

var path = require('path')
var Stream = require('stream')

/**
 * Path 模块一些方法的快捷引用
 */
var extname = path.extname
var join = path.join
var normalize = path.normalize
var resolve = path.resolve
var sep = path.sep

/**
 * 对外暴露的send函数
 * 没有直接暴露SendStream类的原因主要是去掉new的调用
 * @public
 */

module.exports = send

/**
 * 对外暴露的send方法,接收req请求,返回`SendStream`得到的文件流
 * @param {object} req http模块等req请求
 * @param {string} path 要匹配的静态资源路径
 * @param {object} [options] 可选参数
 */
function send (req, path, options) {
  return new SendStream(req, path, options)
}

function SendStream (req, path, options) {
  // ES5方式继承Stream模块
  Stream.call(this)

  var opts = options || {}

  this.options = opts
  this.path = path
  this.req = req

  // ... 其他一些初始化参数赋值的操作

  this._root = opts.root
    ? resolve(opts.root)
    : null
}

SendStream.prototype.pipe = function pipe (res) {}

// ES5方式继承Stream模块
util.inherits(SendStream, Stream)

这里注意Node中老语法实现继承的方法:

// 构造函数内调用call
Stream.call(this);

// 构造方法外部调用util.inherits
util.inherits(SendStream, Stream)

通过一开始的使用示例,我们知道在使用send库时,主要是通过调用send函数得到的实例pipe方法,下面看下pipe的实现:

SendStream.prototype.pipe = function pipe (res) {
  // 根路径
  var root = this._root

  // 保存res引用
  this.res = res

  // 对path进行decodeURIComponent解码
  var path = decode(this.path)
  // 解码失败直接返回res
  if (path === -1) {
    this.error(400)
    return res
  }

  // null byte(s)
  if (~path.indexOf('\0')) {
    this.error(400)
    return res
  }

  var parts
  if (root !== null) {
    // 将path规范化成./path
    if (path) {
      path = normalize('.' + sep + path)
    }

    // malicious path
    if (UP_PATH_REGEXP.test(path)) {
      debug('malicious path "%s"', path)
      this.error(403)
      return res
    }

    // 根据路径符合分割path
    parts = path.split(sep)

    // join / normalize from optional root dir
    // 将根路径拼接起来
    path = normalize(join(root, path))
  } else {
    // ".." is malicious without "root"
    if (UP_PATH_REGEXP.test(path)) {
      debug('malicious path "%s"', path)
      this.error(403)
      return res
    }

    // normalize用于规范化path,可以解析..或.等路径符合
    // sep提供特定于平台的路径片段分隔符
    // parts得到的是根据路径分隔符分割到的字符串数组
    parts = normalize(path).split(sep)

    // resolve the path
    // 系列化为绝对路径
    path = resolve(path)
  }

  // 处理点开通的文件,例如.cache
  if (containsDotFile(parts)) {
    debug('%s dotfile "%s"', this._dotfiles, path)
    switch (this._dotfiles) {
      case 'allow':
        break
      case 'deny':
        this.error(403)
        return res
      case 'ignore':
      default:
        this.error(404)
        return res
    }
  }

  // 处理pathname以"/"结尾的情况
  if (this._index.length && this.hasTrailingSlash()) {
    this.sendIndex(path)
    return res
  }

  this.sendFile(path)
  return res
}
  • pipe方法主要作用是根据用户参数格式化path参数
  • 根据path参数的值:
    • /结尾则调用sendIndex方法
    • 否则调用sendFile方法处理

这里有一个小细节需要注意,就是按路径分隔符分割url路径时,没有直接使用/符合,而是使用了跨平台的path.sep:

parts = path.split(sep)

接下来我们继续往后看,sendIndex方法的主要逻辑是根据要匹配的path参数为/结尾时,尝试匹配path/index.html或以用户设置的index值优先。

/**
 * 尝试从path转换成index值
 * Eg:path/ => path/index.html
 * @param {String} path
 * @api private
 */
SendStream.prototype.sendIndex = function sendIndex (path) {
  var i = -1
  var self = this

  function next (err) {
    // 如果用户设置的所有index值都没有匹配到,则抛出错误
    // index默认值是["index.html"],即当访问path/时,指定到path/index.html
    if (++i >= self._index.length) {
      if (err) return self.onStatError(err)
      return self.error(404)
    }

    // path拼接index
    var p = join(path, self._index[i])

    debug('stat "%s"', p)
    // 判断新的index路径是否存在
    fs.stat(p, function (err, stat) {
      // 不存在则继续尝试下一个index
      if (err) return next(err)
      // 如果新的index路径是文件夹,继续尝试下一个index
      if (stat.isDirectory()) return next()
      // 如果是文件,则emit file事件
      self.emit('file', p, stat)
      // 调用send返回流数据
      self.send(p, stat)
    })
  }

  next()
}

sendIndex内部在尝试拼接path/index后,如果资源存在,则判断是文件夹还是文件资源:

  • 文件夹资源则继续根据index值尝试拼接path路径
  • 若是文件资源,则调用实例的send方法继续处理资源,同时emit一个file事件

在确定路径最终映射到资源后,最终调用send进行处理的,那么我们接着看send方法实现:

SendStream.prototype.send = function send (path, stat) {
  var len = stat.size
  var options = this.options
  var opts = {}
  var res = this.res
  var req = this.req
  var ranges = req.headers.range
  var offset = options.start || 0

  // 无法发送的抛错处理
  if (res.headersSent) {
    // impossible to send now
    this.headersAlreadySent()
    return
  }

  debug('pipe "%s"', path)

  // 设置res的headers请求头相关字段
  this.setHeader(path, stat)

  // 设置请求头的Content-Type值
  this.type(path)

  // conditional GET support
  if (this.isConditionalGET()) {
    if (this.isPreconditionFailure()) {
      this.error(412)
      return
    }

    if (this.isCachable() && this.isFresh()) {
      this.notModified()
      return
    }
  }

  // adjust len to start/end options
  len = Math.max(0, len - offset)
  if (options.end !== undefined) {
    var bytes = options.end - offset + 1
    if (len > bytes) len = bytes
  }

  // Range support
  if (this._acceptRanges && BYTES_RANGE_REGEXP.test(ranges)) {
    // parse
    ranges = parseRange(len, ranges, {
      combine: true
    })

    // If-Range support
    if (!this.isRangeFresh()) {
      debug('range stale')
      ranges = -2
    }

    // unsatisfiable
    if (ranges === -1) {
      debug('range unsatisfiable')

      // Content-Range
      res.setHeader('Content-Range', contentRange('bytes', len))

      // 416 Requested Range Not Satisfiable
      return this.error(416, {
        headers: { 'Content-Range': res.getHeader('Content-Range') }
      })
    }

    // valid (syntactically invalid/multiple ranges 
    // are treated as a regular response)
    if (ranges !== -2 && ranges.length === 1) {
      debug('range %j', ranges)

      // Content-Range
      res.statusCode = 206
      res.setHeader(
        'Content-Range',
        contentRange('bytes', len, ranges[0])
      )

      // adjust for requested range
      offset += ranges[0].start
      len = ranges[0].end - ranges[0].start + 1
    }
  }

  // clone options
  for (var prop in options) {
    opts[prop] = options[prop]
  }

  // set read options
  opts.start = offset
  opts.end = Math.max(offset, offset + len - 1)

  // 设置Content-Length
  res.setHeader('Content-Length', len)

  /**
   * 支持HEAD请求
   * HEAD请求也是用于请求资源,但是服务器不会返回请求资源的实体数据,
   * 只会传回响应头,也就是元信息
   */
  if (req.method === 'HEAD') {
    res.end()
    return
  }

  // 调用stream方法返回文件流数据
  this.stream(path, opts)
}

send方法代码稍微长了些,首先是设置了返回资源的请求头相关字段:

  • 根据用户参数设置Cache-ControlLast-Modified
  • 设置Content-Type字段,如果返回的资源已经包含了Content-Type则使用原有的,否则根据文件后缀名,通过mime库获取Content-Type

这里有意思的是,send内部支持了HEAD请求,HEAD请求与GET请求的区别在于HEAD只返回请求头相关信息,不返回资源的实体数据:

/**
 * 支持HEAD请求
 * HEAD请求也是用于请求资源,但是服务器不会返回请求资源的实体数据,
 * 只会传回响应头,也就是元信息
 */
if (req.method === 'HEAD') {
  // 注意在此之前的代码只处理了响应头相关数据,但是并未处理响应体数据
  // 因此在调用end之后是没有实体数据的
  res.end()
  return
}

send方法内部最后调用stream方法返回文件流数据,下面看下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

  /**
   * 创建一个可读流
   * emit一个stream事件,让外部可以在该事件钩子中继续处理stream
   */
  var stream = fs.createReadStream(path, options)
  this.emit('stream', stream)
  // 将流传递给res响应
  stream.pipe(res)

  // response finished, done with the fd
  // 响应结束,销毁流
  onFinished(res, function onfinished () {
    finished = true
    destroy(stream)
  })

  // 错误处理,销毁流
  stream.on('error', function onerror (err) {
    // request already finished
    if (finished) return

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

    // error
    self.onStatError(err)
  })

  // 流读取结束
  stream.on('end', function onend () {
    self.emit('end')
  })
}

stream内部的实现才是本库的核心部分,首先通过fs模块创建一个可读流读取文件内容,同时对外暴露一个stream事件,让外部有机会在创建流后做一些处理逻辑:

/**
 * 创建一个可读流
 * emit一个stream事件,让外部可以在该事件钩子中继续处理stream
 */
var stream = fs.createReadStream(path, options)
this.emit('stream', stream)
// 将流传递给res响应
stream.pipe(res)

最后在流出错或者响应结束时销毁流,在流读取结束时暴露一个end事件。

下面我们回到pipe方法内部,对于path不是/结尾的调用sendFile逻辑:

SendStream.prototype.pipe = function pipe (res) {
  // ... 省略前面的代码
    
  // 处理pathname以"/"结尾的情况
  if (this._index.length && this.hasTrailingSlash()) {
    this.sendIndex(path)
    return res
  }

  this.sendFile(path)
  return res
}

下面看下sendFile逻辑:

SendStream.prototype.sendFile = function sendFile (path) {
  var i = 0
  var self = this

  debug('stat "%s"', path)
  fs.stat(path, function onstat (err, stat) {
    // 如果文件资源不存在,且没有文件后缀名,
    // 则调用next方法拼接.html等后缀名继续尝试尝试
    if (err && err.code === 'ENOENT'
        && !extname(path)
        && path[path.length - 1] !== sep
    ) {
      // not found, check extensions
      return next(err)
    }
    if (err) return self.onStatError(err)
    // 如果是文件夹,则重定向
    if (stat.isDirectory()) return self.redirect(path)
    // 如果是文件,则emit file事件,
    self.emit('file', path, stat)
    // 利用send方法返回流
    self.send(path, stat)
  })

  function next (err) {
    if (self._extensions.length <= i) {
      return err
        ? self.onStatError(err)
        : self.error(404)
    }

    var p = path + '.' + self._extensions[i++]

    debug('stat "%s"', p)
    fs.stat(p, function (err, stat) {
      if (err) return next(err)
      if (stat.isDirectory()) return next()
      self.emit('file', p, stat)
      self.send(p, stat)
    })
  }
}

这时的主要做法是判断path对应的资源是否存在:

  • 如果不存在,且不存在文件后缀名,则尝试拼接后缀名再查看资源是否存在。
  • 如果资源存在,则判断是文件夹还是文件,是文件夹则继续尝试匹配,是文件则调用send做后续处理,逻辑同之前的send

send静态服务原理总结

send库的核心还是在于根据path路径映射的资源,通过fs.createReadStream进行读取流,然后通过stream.pipe(res)进行消费流。

另一个比较有意思的点就是实现了HEAD请求,只返回请求头,不返沪请求的实体数据。

结束语

如果你喜欢这篇文章,欢迎小伙伴们点赞收藏转发哈~~~

同时推荐你阅读我的其他掘金文章:

我是愣锤,我和前端的故事还在继续......