写点什么

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

作者:CRMEB
  • 2022 年 3 月 18 日
  • 本文字数:6261 字

    阅读完需:约 21 分钟

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

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提供了静态文件托管服务。学会了基本使用,下面看看send静态文件托管服务的实现原理吧。

源码分析

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

var path = require('path')var Stream = require('stream')
/** * Path 模块一些方法的快捷引用 */var extname = path.extnamevar join = path.joinvar normalize = path.normalizevar resolve = path.resolvevar 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 中老语法实现继承的方法:

// 构造函数内调用callStream.call(this);
// 构造方法外部调用util.inheritsutil.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请求,只返回请求头,不返沪请求的实体数据。

最后

如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163 相互学习,我们会有专业的技术答疑解惑

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点 star:http://github.crmeb.net/u/defu不胜感激 !

PHP 学习手册:https://doc.crmeb.com

技术交流论坛:https://q.crmeb.com

用户头像

CRMEB

关注

还未添加个人签名 2021.11.02 加入

CRMEB就是客户关系管理+营销电商系统实现公众号端、微信小程序端、H5端、APP、PC端用户账号同步,能够快速积累客户、会员数据分析、智能转化客户、有效提高销售、会员维护、网络营销的一款企业应用

评论

发布
暂无评论
详解《send》源码中NodeJs静态文件托管服务实现原理_CRMEB_InfoQ写作平台