整体流程图
接下来开始具体实现
一、 格式校验
对于上传的文件,一般来说,我们要校验其格式,仅需要获取文件的后缀(扩展名),即可判断其是否符合我们的上传限制:
//文件路径
var filePath = "file://upload/test.png";
//获取最后一个.的位置
var index= filePath.lastIndexOf(".");
//获取后缀
var ext = filePath.substr(index+1);
//输出结果
console.log(ext);
// 输出: png
但是,这种方式有个弊端,那就是我们可以随便篡改文件的后缀名,比如:test.mp4 ,我们可以通过修改其后缀名:test.mp4 -> test.png ,这样即可绕过限制进行上传。那有没有更严格的限制方式呢?当然是有的。
那就是通过查看文件的二进制数据来识别其真实的文件类型,因为计算机识别文件类型时,并不是真的通过文件的后缀名来识别的,而是通过 “魔数”(Magic Number)来区分,对于某一些类型的文件,起始的几个字节内容都是固定的,根据这几个字节的内容就可以判断文件的类型。借助十六进制编辑器,可以查看一下图片的二进制数据,我们还是以test.png为例:
由上图可知,PNG 类型的图片前 8 个字节是 0x89 50 4E 47 0D 0A 1A 0A。基于这个结果,我们可以据此来做文件的格式校验,以vue项目为例:
<template>
<div>
<input
type="file"
id="inputFile"
@change="handleChange"
/>
</div>
</template>
<script>
export default {
name: "HelloWorld",
methods: {
check(headers) {
return (buffers, options = { offset: 0 }) =>
headers.every(
(header, index) => header === buffers[options.offset + index]
);
},
async handleChange(event) {
const file = event.target.files[0];
// 以PNG为例,只需要获取前8个字节,即可识别其类型
const buffers = await this.readBuffer(file, 0, 8);
const uint8Array = new Uint8Array(buffers);
const isPNG = this.check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// 上传test.png后,打印结果为true
console.log(isPNG(uint8Array))
},
readBuffer(file, start = 0, end = 2) {
// 获取文件的二进制数据,因为我们只需要校验前几个字节即可,所以并不需要获取整个文件的数据
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsArrayBuffer(file.slice(start, end));
});
}
}
};
</script>
以上为校验文件类型的方法,对于其他类型的文件,比如mp4,xsl等,大家感兴趣的话,也可以通过工具查看其二进制数据,以此来做格式校验。
以下为汇总的一些文件的二进制标识:
1.JPEG/JPG - 文件头标识 (2 bytes): ff, d8 文件结束标识 (2 bytes): ff, d9
2.TGA - 未压缩的前 5 字节 00 00 02 00 00 - RLE 压缩的前 5 字节 00 00 10 00 00
3.PNG - 文件头标识 (8 bytes) 89 50 4E 47 0D 0A 1A 0A
4.GIF - 文件头标识 (6 bytes) 47 49 46 38 39(37) 61
5.BMP - 文件头标识 (2 bytes) 42 4D B M
6.PCX - 文件头标识 (1 bytes) 0A
7.TIFF - 文件头标识 (2 bytes) 4D 4D 或 49 49
8.ICO - 文件头标识 (8 bytes) 00 00 01 00 01 00 20 20
9.CUR - 文件头标识 (8 bytes) 00 00 02 00 01 00 20 20
10.IFF - 文件头标识 (4 bytes) 46 4F 52 4D
11.ANI - 文件头标识 (4 bytes) 52 49 46 46
二、 文件切片
假设我们要把一个1G的视频,分割为每块1MB的切片,可定义 DefualtChunkSize = 1 * 1024 * 1024,通过 spark-md5来计算文件内容的hash值。那如何分割文件呢,使用文件对象File的方法File.prototype.slice即可。
需要注意的是,切割一个较大的文件,比如10G,那分割为1Mb大小的话,将会生成一万个切片,众所周知,js是单线程模型,如果这个计算过程在主线程中的话,那我们的页面必然会直接崩溃,这时,就该我们的 Web Worker 来上场了。
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。具体的作用,不了解的同学可以自行去学些一下。这里就不展开讲了。
以下为部分关键代码:
// upload.js
// 创建一个worker对象
const worker = new worker('worker.js')
// 向子线程发送消息,并传入文件对象和切片大小,开始计算分割切片
worker.postMessage(file, DefaultChunkSize)
// 子线程计算完成后,会将切片返回主线程
worker.onmessage = (chunks) => {
...
}
子线程代码:
// worker.js
// 接收文件对象及切片大小
onmessage (file, DefualtChunkSize) => {
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunks = Math.ceil(file.size / DefaultChunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = function (e) {
console.log('read chunk nr', currentChunk + 1, 'of');
const chunk = e.target.result;
spark.append(chunk);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
let fileHash = spark.end();
console.info('finished computed hash', fileHash);
// 此处为重点,计算完成后,仍然通过postMessage通知主线程
postMessage({ fileHash, fileReader })
}
};
fileReader.onerror = function () {
console.warn('oops, something went wrong.');
};
function loadNext() {
let start = currentChunk * DefualtChunkSize,
end = ((start + DefaultChunkSize) >= file.size) ? file.size : start + DefaultChunkSize;
let chunk = blobSlice.call(file, start, end);
fileReader.readAsArrayBuffer(chunk);
}
loadNext();
}
以上利用worker线程,我们即可得到计算后的切片,以及md5值。
三、 断点续传 + 秒传 + 上传进度
在拿到切片和md5后,我们首先去服务器查询一下,是否已经存在当前文件。
- 如果已存在,并且已经是上传成功的文件,则直接返回前端上传成功,即可实现"秒传"。
- 如果已存在,并且有一部分切片上传失败,则返回给前端已经上传成功的切片name,前端拿到后,根据返回的切片,计算出未上传成功的剩余切片,然后把剩余的切片继续上传,即可实现"断点续传"。
- 如果不存在,则开始上传,这里需要注意的是,在并发上传切片时,需要控制并发量,避免一次性上传过多切片,导致崩溃。
// 检查是否已存在相同文件
async function checkAndUploadChunk(chunkList, fileMd5Value) {
const requestList = []
// 如果不存在,则上传
for (let i = 0; i < chunkList; i++) {
requestList.push(upload({ chunkList[i], fileMd5Value, i }))
}
// 并发上传
if (requestList?.length) {
await Promise.all(requestList)
}
}
// 上传chunk
function upload({ chunkList, chunk, fileMd5Value, i }) {
current = 0
let form = new FormData()
form.append("data", chunk) //切片流
form.append("total", chunkList.length) //总片数
form.append("index", i) //当前是第几片
form.append("fileMd5Value", fileMd5Value)
return axios({
method: 'post',
url: BaseUrl + "/upload",
data: form
}).then(({ data }) => {
if (data.stat) {
current = current + 1
// 获取到上传的进度
const uploadPercent = Math.ceil((current / chunkList.length) * 100)
}
})
}
所有切片上传完成后,再向后端发送一个上传完成的请求,即通知后端把所有切片进行合并,最终完成整个上传流程。
并发请求的改进
有个改进的地方,目前这么写会一次性把所有请求都发出来吧,但是能浏览器限制了最多6个请求能上传,其它请求只能等待,很容易就超时了,建议加一个请求池机制,一个请求完了,再发送下一个,只有六个请求在请求池内。
Promise.race()
Promise.race()方法的主要意义在于允许你同时观察多个Promise的状态,并且只要其中一个Promise的状态发生变化,它就会采用该Promise的状态和值。
这种功能在编程中很有用,特别是在处理并发任务时。一些常见的应用包括:
- 限时操作: 你可以创建一个Promise数组,其中包含了一系列可能会超时的操作。使用
Promise.race()可以使得只要有一个操作在规定的时间内完成,就立即返回结果,而不用等待所有操作完成或超时。 - 资源竞争: 当多个异步操作需要访问同一资源时,你可能希望只有一个操作能够成功获取资源。通过将多个操作封装成Promise,并使用
Promise.race()来等待第一个成功的操作,你可以实现资源的竞争控制。 - 优化并发请求: 在并发请求的场景中,你可能希望控制同时发送的请求数量。
Promise.race()可以用于动态地监控当前并发请求的数量,并在某个请求完成后立即添加新的请求,以保持并发请求的数量在一个合理的范围内。
//promise并发限制
class PromisePool {
constructor(max, fn) {
this.max = max; //最大并发量
this.fn = fn; //自定义的请求函数
this.pool = []; //并发池
this.urls = []; //剩余的请求地址
}
start(urls) {
this.urls = urls; //先循环把并发池塞满
while (this.pool.length < this.max) {
let url = this.urls.shift();
this.setTask(url);
}
//利用Promise.race方法来获得并发池中某任务完成的信号
let race = Promise.race(this.pool);
return this.run(race);
}
run(race) {
race
.then(res => {
//每当并发池跑完一个任务,就再塞入一个任务
let url = this.urls.shift();
this.setTask(url);
return this.run(Promise.race(this.pool));
})
}
setTask(url) {
if (!url) return
let task = this.fn(url);
this.pool.push(task); //将该任务推入pool并发池中
console.log(`\x1B[43m ${url} 开始,当前并发数:${this.pool.length}`)
task.then(res => {
//请求结束后将该Promise任务从并发池中移除
this.pool.splice(this.pool.indexOf(task), 1);
console.log(`\x1B[43m ${url} 结束,当前并发数:${this.pool.length}`);
})
}
}
//test
const URLS = [
'bytedance.com',
'tencent.com',
'alibaba.com',
'microsoft.com',
'apple.com',
'hulu.com',
'amazon.com'
]
//自定义请求函数
var requestFn = url => {
return new Promise(resolve => {
setTimeout(() => {
resolve(`任务${url}完成`)
}, 1000)
}).then(res => {
console.log('外部逻辑', res);
})
}
const pool = new PromisePool(5, requestFn); //并发数为5
pool.start(URLS)
思路:定义一个 PromisePool 对象,初始化一个 pool 作为并发池,然后先循环把并发池塞满,不断地调用 setTask,当满足最大并发量的限制后,会执行Promise.race 调用第一个执行完的任务的then方法去shift一个任务再push一个任务再去race,保证正在执行的函数始终保持在最大并发量的限制
实现方案:分片slice 、并发(Promise.race)、并发量max
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=s, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script>
</head>
<body>
<input type="file" id="fileInput">
<button id="uploadBtn">上传</button>
</body>
<script>
// 请求基准地址
axios.defaults.baseURL = 'http://localhost:3000'
// 选中的文件
var file = null
// 选择文件
document.getElementById('fileInput').onchange = function({target: {files}}){
file = files[0]
}
// 开始上传
document.getElementById('uploadBtn').onclick = function(){
if (!file) return;
// 创建切片
// let size = 1024 * 1024 * 10; //10MB 切片大小
let size = 1024 * 50; //50KB 切片大小
let fileChunks = [];
let index = 0 //切片序号
for(let cur = 0; cur < file.size; cur += size){
fileChunks.push({
hash: index++,
chunk: file.slice(cur, cur + size)
})
}
// 控制并发和断点续传
const uploadFileChunks = async function(list){
if(list.length === 0){
//所有任务完成,合并切片
await axios({
method: 'get',
url: '/merge',
params: {
filename: file.name
}
});
console.log('上传完成')
return
}
let pool = []//并发池
let max = 3 //最大并发量
let finish = 0//完成的数量
let failList = []//失败的列表
for(let i=0;i<list.length;i++){
let item = list[i]
let formData = new FormData()
formData.append('filename', file.name)
formData.append('hash', item.hash)
formData.append('chunk', item.chunk)
// 上传切片
let task = axios({
method: 'post',
url: '/upload',
data: formData
})
task.then((data)=>{
//请求结束后将该Promise任务从并发池中移除
let index = pool.findIndex(t=> t===task)
pool.splice(index)
}).catch(()=>{
failList.push(item)
}).finally(()=>{
finish++
//所有请求都请求完成
if(finish===list.length){
uploadFileChunks(failList)
}
})
pool.push(task)
if(pool.length === max){
//每当并发池跑完一个任务,就再塞入一个任务
await Promise.race(pool)
}
}
}
uploadFileChunks(fileChunks)
}
</script>
</html>
ps:虽然会一直调用后端请求,但是在满足 pool.length === max的时候会去await直到有响应返回,才会继续调用,所以同时并发的数量始终是max个,从而达到了控制并发的要求
Vue版本
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload"> 上传 </el-button>
<div style="width: 300px">
总进度:
<el-progress :percentage="totalPercent"></el-progress>
切片进度:
<div v-for="item in fileObj.chunkList" :key="item">
<span>{{ item.chunkName }}:</span>
<el-progress :percentage="item.percent"></el-progress>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "",
data() {
return {
fileObj: {
file: null,
chunkList: [],
},
};
},
computed: {
totalPercent() {
const fileObj = this.fileObj;
if (fileObj.chunkList.length === 0) return 0;
const loaded = fileObj.chunkList
.map(({ size, percent }) => size * percent)
.reduce((pre, next) => pre + next);
return parseInt((loaded / fileObj.file.size).toFixed(2));
},
},
methods: {
axiosRequest({
url,
method = "post",
data,
headers = {},
onUploadProgress = (e) => e, // 进度回调
}) {
return new Promise((resolve, reject) => {
axios[method](url, data, {
headers,
onUploadProgress, // 传入监听进度回调
})
.then((res) => {
resolve(res);
})
.catch((err) => {
reject(err);
});
});
},
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
this.fileObj.file = file;
},
async handleUpload() {
const fileObj = this.fileObj;
if (!fileObj.file) return;
const { shouldUpload } = await this.verifyUpload(fileObj.file.name);
if (!shouldUpload) {
alert("秒传:上传成功");
return;
}
const chunkList = this.createChunk(fileObj.file);
console.log(chunkList); // 看看chunkList长什么样子
this.fileObj.chunkList = chunkList.map(({ file }, index) => ({
file,
size: file.size,
percent: 0,
chunkName: `${fileObj.file.name}-${index}`,
fileName: fileObj.file.name,
index,
}));
console.log(this.fileObj);
this.uploadChunks(); // 执行上传切片的操作
},
createChunk(file, size = 2 * 1024 * 1024) {
const chunkList = [];
let cur = 0;
while (cur < file.size) {
// 使用slice方法切片
chunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return chunkList;
},
async uploadChunks() {
const requestList = this.fileObj.chunkList
.map(({ file, fileName, index, chunkName }) => {
const formData = new FormData();
formData.append("file", file);
formData.append("fileName", fileName);
formData.append("chunkName", chunkName);
console.log({ formData, index });
return { formData, index };
})
.map(({ formData, index }) =>
this.axiosRequest({
url: "http://localhost:3000/upload",
data: formData,
onUploadProgress: this.createProgressHandler(
this.fileObj.chunkList[index]
), // 传入监听上传进度回调
})
);
const result = await Promise.all(requestList); // 使用Promise.all进行请求
console.log(result);
this.mergeChunks();
},
async verifyUpload(fileName) {
const { data } = await this.axiosRequest({
url: "http://localhost:3000/verify",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
fileName,
}),
});
return data;
},
createProgressHandler(item) {
return (e) => {
// 设置每一个切片的进度百分比
item.percent = parseInt(String((e.loaded / e.total) * 100));
};
},
mergeChunks(size = 2 * 1024 * 1024) {
this.axiosRequest({
url: "http://localhost:3000/merge",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
size,
fileName: this.fileObj.file.name,
}),
});
},
},
};
</script>
<style scoped></style>
文件下载
文件分断下载的好处
分段下载和不分段下载的主要区别在于传输数据的方式。
在不分段下载中,服务器会将整个文件一次性发送给客户端进行下载。这种方式对于小文件来说是没有问题的,但是对于大文件来说就存在一些问题,比如下载速度慢、占用带宽多、容易出现网络错误等。
而分段下载则可以将一个大文件分成若干个较小的片段进行下载,每个片段可以独立进行下载,并且可以同时下载多个片段,从而大大提高下载速度,减少出错的概率。
此外,分段下载还可以实现断点续传功能。如果下载过程中因为网络原因或其他问题中断了,可以从已经下载的部分继续下载,而不需要从头开始下载整个文件。这对于下载大文件来说,是非常有用的功能。