send是一个用于从文件系统以流的方式读取文件作为http响应结果的库。说的再更通俗一些,就是在Node中提供静态文件的托管服务,比如像express的static服务。还有像熟知的serve-static中间件背后也是依赖send进行的中间件封装。
本文将基于send库1.0.0-beta.1版本的源码做如下几个方面的讲解:
send库的基本使用
静态文件托管服务的核心实现原理
基于send的serve-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.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-Control、Last-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