先来个总结,我之前和将要写到的文件上传用到的技术和场景描述:
场景描述 | 使用技术 |
---|---|
图片上传前的预览 | FileReader 或 createObjectURL |
限制用户上传的文件格式和大小 | 通过文件对象 File 的 size 和 type 属性 |
强大的原生 Form 表单上传 |
FileList 对象 |
虚拟表单上传 | FormData |
ctrl + v 上传 | paste 事件 |
鼠标拖拽上传 | dropover 和 drop 事件, DataTransfer 对象 |
大文件 分片上传 | Blob 的 slice 方法 |
大文件 分片下载 | HTTP 的 Range 技术 |
体验更好的 断点续传/下载 | 暂存技术 |
秒传 | MD5 等摘要算法加密 |
有几项涉及到的技术,在之前的博客有提到过,就是下面这两篇文章,链接如下:
本次重点来写下「分片」和「断点」这两个技术。
写完发现一篇好文章:NodeJS实现简单的HTTP文件断点续传下载功能
一、实现分片上传和断点续传
分片上传又叫切片上传
我们知道使用 <input type="file" name="file" />
元素选择一个文件之后,会得到 File 对象,而 File 对象 又天生继承 Blob,正好 Blob 对象有个方法叫 slice。这个方法和数组的 slice 方法使用基本相同,它可以获取待上传文件的某一部分,经过 slice 方法处理之后得到的结果也是一个 Blob。
我们先来个文件上传案例:
前端代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分片上传</title>
<style>
html, body {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
</head>
<body>
<script src="./axios.min.js"></script>
<input type="file" name="file" id="ipt" />
<button onclick="upload()">上传</button>
<script>
function upload() {
const file = ipt.files[0];
const vform = new FormData();
vform.append("vform", file, file.name);
axios.post("/upload", vform).then(res => {
console.log(vform, res);
});
}
</script>
</body>
</html>
后端代码:
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 请求过多,定大了,容易出现切片效果不理想,切片的大小真是让人头疼。
所以,我们常常需要这两种办法结合使用,上传文件的代码逻辑应该这样写:
- 文件过小,不用切片,可以直接上传。例如 10kb、190kb、200kb、甚至 1M……。
- 文件三四十兆的这种,就固定切片大小就好。
- 大于一百兆但是小于 1G 的这种,可以分区间,不同的区间给不同固定的分包数量。
- 如果文件再大,可以两者方法结合用,先固定分包数量,然后随机包大小。
- 文件超大的那种,应该寻求并行上传方法,简单点前端可以直接禁止上传超大文件。
- 重复文件上传应该有秒传功能。
以上是一个非常完善的切片上传逻辑,项目没有要求我当然不会实现的了,毕竟要写好多的判断,不过切片上传的核心功能,我还是得通过代码来实现的,一起来看看。
前端代码:递归实现切片上传
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分片上传</title>
<style>
html, body {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
</head>
<body>
<script src="./axios.min.js"></script>
<script src="./spark-md5.min.js"></script>
<input type="file" name="file" id="ipt" />
<button onclick="upload(0)">上传</button>
<script>
const chunkSize = 1024 * 1024; // 默认分片大小为1兆。1kb = 1024byte, 1m = 1024 kb
let fileFingerprint = undefined;
const file = ipt.files[0];
function upload(index) {
const { name, type, size } = file;
// 生成文件指纹
const spark = fileFingerprint || new SparkMD5.ArrayBuffer();
spark.append(file);
const hexHash = spark.end();
const extName = name.substring(name.lastIndexOf("."));
const startIndex = chunkSize * index;
// 文件上传完,终止递归同时合并文件
if ( startIndex > size ) {
axios.post("/merge", {
fileName: name,
hexHash,
extName
}).then(res => {
fileFingerprint = undefined;
console.log(res);
});
return;
};
const endIndex = startIndex + chunkSize > size ? size : startIndex + chunkSize;
const blobPart = file.slice(startIndex, endIndex, type);
// FormData 直接上传切片后的文件,文件名默认为 blob( filename="blob")
// 这里通过 File 给个文件名
const blobFile = new File([blobPart], `${hexHash}-${index}${extName}`, { type });
// 创建虚拟表单进行文件上传
const vform = new FormData();
vform.append("vform", blobFile);
axios.post("/upload", vform).then(res => {
// 分片 => 通过递归实现
upload(++index);
});
}
</script>
</body>
</html>
后端代码:两个重要的路由
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=-500
或bytes=9500-
看完只会,考你个问题,如果仅要第 1 个和最后 1 个字节,怎么写?
bytes=0-0,-1
OK,这次我们来看看微博上视频播放时 Range 是如何写的。
看完,不知道你发现没,微博和哔哩哔哩请求头响应头的定义有点不同,微博第一个单词都是大写的,哔哩哔哩都是小写的,本人更喜欢微博的做法,严格遵守了 RFC 规定。
Content-Range
服务器收到浏览器的请求了,那么服务器如何返回资源呢?没错就是 Content-Range 了。给个例子来看看它的语法格式:
Content-Range: bytes start-end/total
上面表示一次 HTTP 请求,服务端返回的结果为 start-end 区间,这次请求的资源总大小为 total。看个例子:
还没完,看到我上面标出来的状态码了吧,我们知道一个正常的 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-Match
和 If-Modified-Since
这两个请求头相信你非常熟悉了,属于协商缓存的内容,相信这时候你马上能想到一个非常经典的前端面试题——说说浏览器的缓存机制。,不懂这个面试题的可以去看看这个博客 图解 HTTP 缓存。
剩下几个我相信你就不太懂了,我们一起来学习下,If-None-Match
和 If-Modified-Since
分别取反就是 If-Match
和 If-Unmodified-Since
。取反的 If-Match
和 If-Unmodified-Since
就是给我们前面讲的 Accept-Ranges 使用的,当我们一点点的从服务器获取数据的时候,突然此时已经获取的数据发生了变化,这时我们肯定不能接着获取数据了,而是要从新获取数据。这是我们现在遇到的问题,那怎么解决这个问题呢?
两个方法:
-
If-Match
和If-Unmodified-Since
通过请求头携带的 If-Match
或 If-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-Match
或 If-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 就是用来干这个的。
- 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);
};
});
我们要接收的文件它长成这样:
浣溪沙·小阁重帘有燕过
晏殊〔宋代〕
小阁重帘有燕过。晚花红片落庭莎。曲阑干影入凉波。
一霎好风生翠幕,几回疏雨滴圆荷。酒醒人散得愁多。
前端就很好写了,递归分片:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分片下载</title>
</head>
<body>
<script src="./axios.min.js"></script>
<button onclick="download(0, 1000)">下载</button>
<script>
let downloadText = "", totalSize = 0, fileName;
function download(startRang, endRang) {
// 下载
if ( totalSize && totalSize <= startRang) {
const url = window.URL.createObjectURL(new Blob([downloadText]));
const link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.setAttribute("download", decodeURIComponent(fileName));
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
totalSize=0;
downloadText="";
fileName="fileName";
return;
}
// totalSize 4096
const ajaxConfig = {
headers: {
responseType: "arraybuffer",
Range: `bytes=${startRang}-${endRang}`
}
};
const downRes = axios.get("/download", ajaxConfig);
downRes.then(res => {
!totalSize && (totalSize = res.headers["total-size"]);
!fileName && (fileName = res.headers["filename"]);
const data = res.data
downloadText += data;
startRang = endRang;
if (totalSize - endRang > 1000) {
endRang += 1000;
} else {
endRang = totalSize;
};
// 分片 => 递归
download(startRang, endRang);
});
}
</script>
</body>
</html>
把网络调慢点,我们看下分片下载的演示效果:
这里送你一张分片请求链接的图:
断点下载思路,参考上面断点上传。
完~
四、最后
代码我都是很简单的略写实现,并没有太过深入的精心实现。这是因为我们前端遇到大多数的项目,就是一个简单的文件上传,顶多文件过大加点分片上传。像百度云网页那种完美的实现,应该很少有公司有这种业务场景。所以,我们只要大概的了解个原理,妥妥的应付面试就行了。
当前时间 Saturday, January 30, 2021 19:29:40 北京办公室