断点续传和分片上传

断点续传和分片上传.png

先来个总结,我之前和将要写到的文件上传用到的技术和场景描述:

场景描述 使用技术
图片上传前的预览 FileReader 或 createObjectURL
限制用户上传的文件格式和大小 通过文件对象 File 的 size 和 type 属性
强大的原生 Form 表单上传 FileList 对象
虚拟表单上传 FormData
ctrl + v 上传 paste 事件
鼠标拖拽上传 dropover 和 drop 事件, DataTransfer 对象
大文件 分片上传 Blob 的 slice 方法
大文件 分片下载 HTTP 的 Range 技术
体验更好的 断点续传/下载 暂存技术
秒传 MD5 等摘要算法加密

有几项涉及到的技术,在之前的博客有提到过,就是下面这两篇文章,链接如下:

  • 文件点击上传和拖拽上传
  • ctrl + v 实现图片预览和上传

本次重点来写下「分片」和「断点」这两个技术。

写完发现一篇好文章:NodeJS实现简单的HTTP文件断点续传下载功能

一、实现分片上传和断点续传

分片上传又叫切片上传

我们知道使用 元素选择一个文件之后,会得到 File 对象,而 File 对象 又天生继承 Blob,正好 Blob 对象有个方法叫 slice。这个方法和数组的 slice 方法使用基本相同,它可以获取待上传文件的某一部分,经过 slice 方法处理之后得到的结果也是一个 Blob。

我们先来个文件上传案例:

前端代码:





    
    
    分片上传
    



    
    
    
    



后端代码:

const multiparty = require("multiparty");
const bodyParser = require("body-parser");
const path = require("path");
const express = require("express");
const app =  express();
const fs = require("fs");
function resolvePath (dir) {
    return path.join(__dirname, dir);
}


app.use(express.static(resolvePath("/public")));
// https://expressjs.com/en/4x/api.html#req.body
app.use(bodyParser.json({ limit: "50mb" }));
app.use(bodyParser.urlencoded({ extended: true }));

app.post("/upload", function (req, res) {
    const form = new multiparty.Form({ uploadDir: "public" });
    form.parse(req);
    form.on("file", function(name, file) {
        console.log(name, file)
        const { path, originalFilename } = file;
        fs.renameSync(path, `public/${originalFilename}`);
        res.json({
            url: `http://localhost:48488/${originalFilename}`,
            message: "发送成功"
        });
    })
});

const port = 48488;
app.listen(port, function () {
    console.log(`listen port ${port}`);
});

一个简单的文件上传就完成了,现在开始切片上传功能开发,切片上传就是把一个文件切分成很多小文件,本来上传一个大文件,现在改成上传很多小文件

如何切文件,这就很哲学了,流行两种思路:

  • 不管上传文件的大小,切成固定的块数,然后上传。
  • 不管上传文件的大小,每次切的块大小相同,然后上传。

第一种方法的缺点就是,如果文件过小的话,切成固定的块数,明显浪费 HTPP 请求,如果文件过大,切成固定的块数,切的每块可能依然过大,即切片的切片还需要继续切片。所以使用这种方法,需要加上限定条件,假如上传文件的大小为 s,限定条件应该这样写 n <= s <= m

第二种方法的缺点就是,如何确定每次上传文件的大小,定小了,容易出现 HTTP 请求过多,定大了,容易出现切片效果不理想,切片的大小真是让人头疼。

所以,我们常常需要这两种办法结合使用,上传文件的代码逻辑应该这样写:

  1. 文件过小,不用切片,可以直接上传。例如 10kb、190kb、200kb、甚至 1M……。
  2. 文件三四十兆的这种,就固定切片大小就好。
  3. 大于一百兆但是小于 1G 的这种,可以分区间,不同的区间给不同固定的分包数量。
  4. 如果文件再大,可以两者方法结合用,先固定分包数量,然后随机包大小。
  5. 文件超大的那种,应该寻求并行上传方法,简单点前端可以直接禁止上传超大文件。
  6. 重复文件上传应该有秒传功能。

以上是一个非常完善的切片上传逻辑,项目没有要求我当然不会实现的了,毕竟要写好多的判断,不过切片上传的核心功能,我还是得通过代码来实现的,一起来看看。

前端代码:递归实现切片上传





    
    
    分片上传
    



    
    
    
    
    



后端代码:两个重要的路由

app.post("/upload", function (req, res) {
    const form = new multiparty.Form({ uploadDir: "temp" });
    form.parse(req);
    form.on("file", function(name, file) {
        const { path, originalFilename } = file;
        fs.renameSync(path, `temp/${originalFilename}`);
        res.json({
            code: "200",
            message: "发送成功"
        });
    })
});
app.post("/merge", function (req, res) {
    const { fileName, hexHash, extName } = req.body;
    const readDir = fs.readdirSync(resolvePath("./temp"));
    readDir.sort((a, b) => a - b).map(chunkPath => {
        fs.appendFileSync(
            resolvePath(`public/${fileName}`),
            fs.readFileSync(resolvePath(`temp/${chunkPath}`))
        );
        fs.rmSync(resolvePath(`temp/${chunkPath}`));
    });
    // fs.rmdirSync(resolvePath("./temp"));
    res.json({
        url: `http://localhost:48488/${fileName}`,
        message: "发送成功"
    });
});

一个牛叉而又简单的切片上传就完成了。

简单切片上传

你看我们 network 面板里面的 waterfall 你会发现,接口是串行发出的(当然根据前端代码你也能得出来这个结论),聪明的你这时候肯定想到了,这样是不是有点浪费 HTTP 请求,而且速度并没有达到最快,既然串行的方法不太好,我们就并行上传。

并行实现逻辑:略。

这时候应该考虑一个用户体验的问题,一如果文件是在太大,文件还没上传完,用户需要暂时离开,关上电子设备。二网络过差,甚至差到断网。这时候我们应该提供 暂停/继续 上传功能。这个功能后端不需要动代码逻辑,只需要前端记住切片上传的位置就行了,这个很简单,简单的加个变量来控制下就行了,如果想体验更好点,甚至要考虑加上取消请求的功能。

这个暂停/继续上传功能也有局限,就是用户刷新了页面,重新再打开,受浏览器的限制,我们不能用例如 NodeJS 中 fs 模块来主动获取文件,只能用户手动上传,前端来能获取到文件 File,所以暂停/继续上传功能受页面不能刷新影响很大。那问题来了,请问怎么解决?

自然而然的,我们想到把文件对象直接存储在本地不就行了,好主意,那存在哪里呢?存在 localStorage 怎么样?好像不太行,localStorage 大小就能存约 5M 大小,在如今的网络时代下,这怎么能够用。那就没办法了吗?非也,还有一个终极大杀招,那就 IndexDB,我们通过 MD5 来确认文件的唯一性,然后把没有上传的部分放入 IndexDB 里面,一旦上传完,就立刻删除,最大可能的节省空间。哈哈哈,这下算是彻底的解决问题了。

但是此时又有一个问题,就是我在 PC 我上传文件,但是只是上传一半就关闭了网页,此时我换设备了,跑到 iPad 或 手机再次打开上传文件页面。我也想要看到未上传的文件。这下麻烦大了,但是也有解决办法。

首先上传进度,肯定是后端记住,而不是前端了。其次对于设备上没有此文件的上传我们只需要简单的提醒用户,要么使用原设备,要么使用此设备手动重新上传。

如果用户选择了重新上传,后端需要根据此文件的 MD5 检索出来文件已经上传的部分,前端续传,而不是真正的重新开始。

断点续传和分片上传,到此完结撒花。

二、秒传

上面提到了,续传功能,那就没有理由不支持秒传功能,这个更加简单了,就是根据上传文件的 MD5,在数据库中检索已经上传文件的 MD5,一旦检索到直接上传完成。

三、分片下载和断点续载

分片下载又叫切片下载

分片下载和分片上传的原理那是大大的不同,不过思路都是一致的,就是大化小。与分片上传利用 File 对象 不同,分片下载用到的技术是 HTTP 中的知识。

你先猜猜用到的是 HTTP 中的什么知识?

猜不出来吧,那你的去补补 HTTP 的知识了,推荐「图解 HTTP」这本书。答案揭晓其实用到是 Range Requests 的知识,如果你想更加系统的学习,请参考 RFC 7233。

这个技术可能我们前端不经常用到,但是平时我们接触还是非常多的。例如像迅雷这样的多线程下载器,我们平时看视频,进度条随意拖拽只加载部分视频流等等。这么一讲你是不是,有种天灵盖被揭开的柑橘,哦,原来这些功能都是 HTTP 请求的功劳。

好了,废话不多说,直接上硬菜,先来学学 HTTP 知识的内容。

Accept-Ranges

我们知道 HTTP 最早是用来传输文本的,现在想把一个文件切开一部分一部分的传输,那就需要支持更加底层的传输单位,没错就是 字节(byte)。根据规范,在使用 byte 传输的时候,首先验证服务器是否支持这种传输方式。

我们需要在服务器上通过 Accept-Ranges 头部表示是否支持 Range,Accept-Ranges 的格式为:

Accept-Ranges = acceptable-ranges

acceptable-ranges 的值有两个:

  • Accept-Ranges: none 不支持 bytes 请求
  • Accept-Ranges = bytes 支持 bytes 请求

来看下哔哩哔哩网站视频播放时其中一个接口。

哔哩哔哩
Range 请求范围的单位

现在知道服务器支持 byte 请求了,那怎么表示请求范围呢?该是 Range 登场的时候了。来看看 Range 的格式:

 Range = byte-ranges-specifier / other-ranges-specifier

现在假设我们要获取的文件大小为 2000 bytes,那么我们可以按下面步骤获取。

  • 第 1 个 500 字节:bytes=0-499
  • 第 2 个 500 字节 bytes=500-999
  • 第 3 个 500 字节 bytes=500-600,601-999 也可以有重合部分 bytes=500-700,601-999 重合的只会加载一次。注意这种请求叫多重范围请求,它请求头的 Content-Type 比较特殊长成这样Content-Type:multipart/byteranges; boundary=…,有点类似 POST 表单提交的方式。关于 byteranges 请去 MDN 查看教程。
  • 最后 1 个 500 字节:bytes=-500bytes=9500-

看完只会,考你个问题,如果仅要第 1 个和最后 1 个字节,怎么写?

bytes=0-0,-1

OK,这次我们来看看微博上视频播放时 Range 是如何写的。

看完,不知道你发现没,微博和哔哩哔哩请求头响应头的定义有点不同,微博第一个单词都是大写的,哔哩哔哩都是小写的,本人更喜欢微博的做法,严格遵守了 RFC 规定。

Content-Range

服务器收到浏览器的请求了,那么服务器如何返回资源呢?没错就是 Content-Range 了。给个例子来看看它的语法格式:

Content-Range: bytes start-end/total

上面表示一次 HTTP 请求,服务端返回的结果为 start-end 区间,这次请求的资源总大小为 total。看个例子:

哔哩哔哩 Content-Range 例子

还没完,看到我上面标出来的状态码了吧,我们知道一个正常的 HTTP 请求完成之后我们会收到 200 状态码,但是我们用 Range 请求服务器的时候,有所不同,分为以下几种情况:

  • 服务器不支持 Range 请求时,则以 200 返回完整的响应包体
  • 服务器支持 Range 请求的时候,一次正常请求结束返回 206 Partial Content
  • 服务器支持 Range 请求的时候,当请求范围不满足实际资源的大小,返回状态码 416 Range Not Satisfiable ,同时 Content-Range 中的 complete- length 显示完整响应的长度,例如 : Content-Range: bytes */1234
HTTP 的条件请求

平时涉及到 HTTP 条件请求的头部有以下五个。

  • If-Match = "*" / 1#entity-tag
  • If-None-Match = "*" / 1#entity-tag
  • If-Modified-Since = HTTP-date
  • If-Unmodified-Since = HTTP-date
  • If-Range = entity-tag / HTTP-date

If-None-MatchIf-Modified-Since 这两个请求头相信你非常熟悉了,属于协商缓存的内容,相信这时候你马上能想到一个非常经典的前端面试题——说说浏览器的缓存机制。,不懂这个面试题的可以去看看这个博客 图解 HTTP 缓存。

剩下几个我相信你就不太懂了,我们一起来学习下,If-None-MatchIf-Modified-Since 分别取反就是 If-MatchIf-Unmodified-Since。取反的 If-MatchIf-Unmodified-Since 就是给我们前面讲的 Accept-Ranges 使用的,当我们一点点的从服务器获取数据的时候,突然此时已经获取的数据发生了变化,这时我们肯定不能接着获取数据了,而是要从新获取数据。这是我们现在遇到的问题,那怎么解决这个问题呢?

两个方法:

  1. If-MatchIf-Unmodified-Since

通过请求头携带的 If-MatchIf-Unmodified-Since 来判断文件是否被修改,如果文件被修改,直接返回 412 (Precondition Failed)来告诉浏览器,正在请求的资源发生了改变,请重新发送请求进行获取。

使用 NodeJS 来模拟下资源被更新返回 412 状态码这个过程,不过先插播一段知识,关于 NodeJS 获取请求头的方面的,即 express req.headers 大小写问题。

express 中通过 req.query 来获取客户端 Query 参数,客户端上传的参数是严格遵守大小写的,但是 req.headers 来获取请求头时,接收到的全是小写。搞的人很郁闷,为什么这么奇怪呢,原来是和 HTTP 协议有关。详情见:express request.headers 大小写问题,坑!,我是受不了大小写混乱,所以使用 req.get() 来获取请求头,因为这个 API 是忽略大小写的。

我们的 NodeJS 后端代码如下:

app.get("/download", function (req, res) {
    const Range = req.get("Range");
    const clientMatch = req.get("If-Match");
    const clientmodifiedSince = req.get("If-Unmodified-Since");

    const readPath = resolvePath("./test.js");

    const md5 = crypto.createHash("md5");
    md5.update(fs.readFileSync(readPath));
    const serverMatch = md5.digest("hex");

    const { mtime } = fs.statSync(readPath);
    const timeStamp = mtime.getTime();
    const lastModifiedStringDate = new Date(clientmodifiedSince || 0).getTime();

    if ((clientMatch && clientMatch !== serverMatch) || (clientmodifiedSince && lastModifiedStringDate !== timeStamp)) {
        res.sendStatus(412);
    } else {
        const rangeBytes = Range.substring(Range.lastIndexOf("=") + 1);
        const [ start, end ] = rangeBytes.split("-");
        res.setHeader("ETag", serverMatch);
        res.setHeader("Last-Modified", timeStamp);
        fs.createReadStream(readPath, { start: +start, end: +end }).pipe(res);
    };
});

我们要读取的文件 test.js 的内容为:

CondorHero

然后我们采用 CURL 命令工具进行调试,先看看如何获取一段数据:

curl http://localhost:48488/download -H "Range: bytes=0-5"  // Condor

再来获取下请求头,为 HTTP 条件请求做准备:

curl http://localhost:48488/download -H "Range: bytes=0-5" -I
HTTP/1.1 200 OK
X-Powered-By: Express
ETag: 532462711215f93a3206e236e45f894e
LastModified: 1611933312687
Date: Sat, 30 Jan 2021 08:01:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5

然后利用条件请求 If-MatchIf-Unmodified-Since 来做一个正常请求,这个我选择了 If-Match

curl http://localhost:48488/download -H "Range: bytes=0-5" -H "If-Match: 532462711215f93a3206e236e45f894e"  // Condor

依然正常输出。随便更改下 If-Match 的值,再次发送请求:

curl http://localhost:48488/download -H "Range: bytes=0-5" -H "If-Match: 哈哈哈哈"  // 402 Precondition Failed

模拟完成。

上面这个方法有个缺点,那就是数据一旦被改变,浏览需要先获取 412 状态码,然后浏览器再准备发送请求,我们发现多了一个请求来回,如果服务器对比完,发现资源被改变,能直接完整返回最新资源就完美了,省去了一次 HTTP 请求。没错 If-Range 就是用来干这个的。

  1. If-Range

稍微修改下后端的代码:

app.get("/download", function (req, res) {
    const Range = req.get("Range");
    const clientMatch = req.get("If-Range");
    const clientmodifiedSince = req.get("If-Unmodified-Since");

    const readPath = resolvePath("./test.js");

    const md5 = crypto.createHash("md5");
    md5.update(fs.readFileSync(readPath));
    const serverMatch = md5.digest("hex");

    const { mtime } = fs.statSync(readPath);
    const timeStamp = mtime.getTime();
    const lastModifiedStringDate = new Date(clientmodifiedSince || 0).getTime();

    if ((clientMatch && clientMatch !== serverMatch) || (clientmodifiedSince && lastModifiedStringDate !== timeStamp)) {
        res.setHeader("ETag", serverMatch);
        res.setHeader("Last-Modified", timeStamp);
        fs.createReadStream(readPath).pipe(res);
    } else {
        const rangeBytes = Range.substring(Range.lastIndexOf("=") + 1);
        const [ start, end ] = rangeBytes.split("-");
        res.setHeader("ETag", serverMatch);
        res.setHeader("Last-Modified", timeStamp);
        fs.createReadStream(readPath, { start: +start, end: +end }).pipe(res);
    };
});

当我们再次发送请求的时候:

curl http://localhost:48488/download -H "Range: bytes=0-5" -H "If-Range: 哈哈哈哈哈"
// 返回结果为 CondorHero
// 没有返回 412 而是直接返回全部结果

条件请求我们就讲完了,现在直接开始做分片下载的 Demo 好了。

分片下载

现在分片实现最重要的两点就是:

  • 前端 => 递归
  • 后端 => createReadStream 的用法

然后我们后端现在几乎都不要改动什么,简单的加点东西就行了,看下接口。

app.get("/download", function (req, res) {
    const Range = req.get("Range");
    const clientMatch = req.get("If-Range");
    const clientmodifiedSince = req.get("If-Unmodified-Since");

    const readPath = resolvePath("./test.txt");

    const md5 = crypto.createHash("md5");
    md5.update(fs.readFileSync(readPath));
    const serverMatch = md5.digest("hex");

    const { mtime, blksize } = fs.statSync(readPath);
    const timeStamp = mtime.getTime();
    const lastModifiedStringDate = new Date(clientmodifiedSince || 0).getTime();

    if ((clientMatch && clientMatch !== serverMatch) || (clientmodifiedSince && lastModifiedStringDate !== timeStamp)) {
        res.setHeader("ETag", serverMatch);
        res.setHeader("Last-Modified", timeStamp);
        fs.createReadStream(readPath).pipe(res);
    } else {
        const rangeBytes = Range.substring(Range.lastIndexOf("=") + 1);
        const [ start, end ] = rangeBytes.split("-");
        res.setHeader("Accetp-Ranges", "bytes");
        res.setHeader("ETag", serverMatch);
        res.setHeader("Last-Modified", timeStamp);
        res.setHeader("Total-Size", blksize);
        res.setHeader("Content-Range", `bytes ${start}-${end}/${blksize}`);
        res.setHeader("fileName", encodeURIComponent("浣溪沙-晏殊.txt"));
        fs.createReadStream(readPath, { start: +start, end: +end }).pipe(res);
    };
});

我们要接收的文件它长成这样:

浣溪沙·小阁重帘有燕过
    晏殊〔宋代〕
小阁重帘有燕过。晚花红片落庭莎。曲阑干影入凉波。
一霎好风生翠幕,几回疏雨滴圆荷。酒醒人散得愁多。

前端就很好写了,递归分片:





    
    
    分片下载



    
    
    



把网络调慢点,我们看下分片下载的演示效果:

2021-01-30 19-19-50.2021-01-30 19_21_44.gif

这里送你一张分片请求链接的图:

断点下载思路,参考上面断点上传。

完~

四、最后

代码我都是很简单的略写实现,并没有太过深入的精心实现。这是因为我们前端遇到大多数的项目,就是一个简单的文件上传,顶多文件过大加点分片上传。像百度云网页那种完美的实现,应该很少有公司有这种业务场景。所以,我们只要大概的了解个原理,妥妥的应付面试就行了。

当前时间 Saturday, January 30, 2021 19:29:40 北京办公室

你可能感兴趣的:(断点续传和分片上传)