通过手写文件服务器,说说前后端交互

1,815 阅读7分钟

前言

      最近用node写了一个静态文件服务器(已发布),想通过这个小例子说说前后端基于HTTP协议交互过程中的一些常见问题。

代码地址

       https://github.com/alive1541/static-server
       下文中所贴出来的代码都在这个目录下。

安装方法

       npm install static-server2 -g

node版本

       使用了async函数,支持版本7.6以上

用法示例

      按照前言的安装法安装到全局后,命令行执行server-start后,会提示服务启动成功。这时可以访问localhost:8080,程序有以下两个功能:

托管静态文件

      服务启动成功后可以访问localhost:8080查看根目录下的静态文件。
      命令行启动时可以通过server-start -d来改变根目录。还可以通过-o参数配置主机,-p参数配置端口,-h参数查看帮助。

文件上传

      支持上传文件,可以通过暂停进行断点续传。

说说缓存

      下面我就基于这个例子说说前后端交互过程中的几个问题。首先说说缓存,下面先上代码。
      这是例子中根目录下index.js文件中的一个方法。这个方法用来过滤请求,如果命中缓存,返回304,未命中则返回新的资源。
      这个函数处理了强制缓存和对比缓存。

//缓存处理函数
    handleCatch(req, res, fileStat) {
        //强制缓存
        res.setHeader('Expries', new Date(Date.now() + 30 * 1000).toGMTString())
        res.setHeader('Catch-Control', 'private,max-age=30')
        //对比缓存
        let ifModifiedSince = req.headers['if-modified-since']
        let ifNoneMatch = req.headers['if-none-match']
        let lastModified = fileStat.ctime.toGMTString()
        let eTag = fileStat.mtime.toGMTString()
        res.setHeader('Last-Modified', lastModified)
        res.setHeader('ETag', eTag)
        //任何一个对比缓存头不匹配,则不走缓存
        if (ifModifiedSince && ifModifiedSince != lastModified) {
            return false
        }
        if (ifNoneMatch && ifNoneMatch != eTag) {
            return false
        }
        //当请求中存在任何一个对比缓存头,则返回304,否则不走缓存
        if (ifModifiedSince || ifNoneMatch) {
            res.writeHead(304)
            res.end()
            return true
        } else {
            return false
        }
    }

强制缓存

      强制缓存的好处是浏览器不需要发送HTTP请求,一般不常更改的页面都会设置一个较长的强制缓存。
      可以通过清理浏览器缓存和强制刷新页面(ctrl+F5)来跳过它强制请求数据。它主要是靠两个HTTP头来实现。

Cache-Control 和 Expires

      这两个头的作用是一样的。都是告诉浏览器多长时间以内可以不发送请求而是直接使用本地的缓存。Cache-Control是HTTP1.1版本规范,而Expires是HTTP1.0版本规范,所以同时存在的话Catch-Control的优先级更高。
      一般都是像我上面的代码一样,两个都设置。因为低版本浏览器不支持Cache-Control
      此外,Catch-Control还有更加细致的配置项,可以更加精确的进行一些控制,规则如下:

public:客户端和代理服务器都可缓存
private:仅客户端可以缓存,代理服务器不可缓存
no-cache:禁止强制缓存
no-store:禁止强制缓存和对比缓存
must-revalidation/proxy-revalidation:如果缓存的内容失效,请求必须发送到服务器/代理以进行重新验证
max-age=xxx:缓存的内容将在 xxx 秒后失效

对比缓存

Last-Modified/If-Modified-Since

      Last-Modified是服务器携带的头,它代表这个资源的最后更新时间。
      If-Modified-Since是客户端携带的头。在浏览器中,如果不是第一次请求这个资源浏览器就会发送这个头。前提是上一次服务器返回的头中有Last-Modified,它的值也是上次返回的Last-Modified的值。

Etag/If-None-Match

      这两个头和上面的两个头的目的一样,都是校验资源。它们出现的目的是为了解决上面两个头存在的一些问题。例如:

1、在集群服务器上各个服务器上的文件时间可能不同。
2、有可能文件做了更新,但是内容没有变化。
3、last-modified时间精度为秒,如果文件存在毫秒级的修改,last-modified不能识别

      ETag是资源标签。如果资源没有变化它就不会变。这样就解决了上面说的三个问题。
      但是ETag解决问题的同时也创造出了新的问题,计算出ETag读取文件内容,这就会耗费额外的性能和时间。所以它并不能完全取代Last-Modified,需要根据实际需要权衡使用。
      在实际的开发中ETag的算法也各不相同,像我在例子中的直接使用了mtime。

说说压缩

      如图,浏览器每次发送请求都会携带自己支持的压缩类型,最常用的两种是gzip和deflate。
      服务端可以根据Accept-Ecoding头来返回响应的压缩资源,同时设置Content-Encoding头告诉浏览器你用了什么压缩方式,代码如下:

//处理压缩
    handleZlib(req, res) {
        let acceptEncoding = req.headers['accept-encoding']
        if (/\bgzip\b/g.test(acceptEncoding)) {
            res.setHeader('Content-Encoding', 'gzip');
            //zlib是node的一个模块
            return zlib.createGzip()
        } else if (/\bdeflate\b/g.test(acceptEncoding)) {
            res.setHeader('Content-Encoding', 'deflate');
            return zlib.createDeflate()
        } else {
            return null
        }
    }

说说断点续传

      先看代码,断点续传的原理就是利用HTTP头中的Range来告诉服务器我所上传的文件的内容区间。当然断点续传在不同的场景下也有不同的处理方法。这里只是基于这种简单场景做个示范。
      前端逻辑是这样的:
      1、获取用户要上传的文件
      2、切割文件,获取到要上传的第一部分
      3、调用后台的上传文件接口,上传这一部分
      4、接口返回成功后再切割文件,上传第二部分
      5、每次上传用Range头发送文件的字节区间
      下面是切割文件和xhr上传的代码,完整代码在项目目录/src/template/list.html中(使用了handlebars模版引擎)。

    if (end > file.size) {
        end = file.size
    }
    //切割文件
    var blob = file.slice(start, end)
    var formData = new FormData();
    formData.append('filechunk', blob);
    formData.append('filename', file.name);
    //添加Range头
    var range = 'bytes=' + start + '-' + end
    xhr.setRequestHeader('Range', range)
    //发送
    xhr.send(formData);

      下面看一下后端的处理逻辑:
      1、获取文件名
      2、通过Range获取文件位置,如果是0开头,说明是第一次上传,删除之前的文件
      3、写入文件
      下面是核心代码:

let path = require('path')
let fs = require('fs')
function handleFile(req, res, fields, files, filepath) {
    //获取文件名
    let name = fields.filename[0]
    //文件读取路径
    let rdPath = files.filechunk[0].path
    //文件写入路径
    let wsPath = path.join(filepath, name)
    //通过range判断上传文件的位置
    let range = req.headers['range']
    let start = 0
    if (range) {
        start = range.split('=')[1].split('-')[0]
    }
    //从multiparty插件中读取文件内容,然后写入本地文件
    let buf = fs.readFileSync(rdPath)
    fs.exists(wsPath, function (exists) {
        //如果是初次上传,删除public下的同名文件
        if (exists && start == 0) {
            fs.unlink(wsPath, function () {
                fs.writeFileSync(wsPath, buf, { flag: 'a+' })
                res.end()
            })
        } else {
            fs.writeFileSync(wsPath, buf, { flag: 'a+' })
            res.end()
        }
    })

}
module.exports = handleFile

      我这里处理相对粗糙,实际的项目需求可能不止这么简单,但都是基于Range头做相应的处理,希望我的描述能对大家有些帮助。

总结

      文章到这里就结束了,上文引用的都是代码片段,只是为了展示处理逻辑,如果有兴趣可以去gitHub查看,程序运行中出现任何问题也欢迎指正。