本文要实现断点续传,点续传,续传,传。。。。。
断点续传是啥!!!戳这里—>百科断点续传
大白话:就是将一个大文件分成好几个小文件,再通过http请求或者webSocket等方式上传到服务器或者下载到本地。
本文主要介绍上传的续传,egg做完服务端,react做完前端
效果图
服务端代码解析
后端代码是在使用egg生成器生成的基础上,进行编写的:
路由
/app/routes.ts
import { Application } from 'egg';
export default (app: Application) => {
const { controller, router } = app;
router.post('/', controller.httpFile.index);
router.post('/chunks_upload', controller.httpFile.chunksUpload);
router.post('/chunks_merge', controller.httpFile.chunksMerge);
router.post('/hash_check', controller.httpFile.hashCheck);
};
控制层
/app/controller/http-file.ts
主要实现上方法
- 上传前检测
存在已上传,上传没有完成,没有上传三种情况 - 保存每次传来的切片
- 合并切片
import { Controller } from 'egg';
import { mkdirsSync, del } from '../public/common';
import { streamMerge } from 'split-chunk-merge';
import path = require('path');
import fs = require('fs');
const uploadPath = path.join(__dirname, '../../uploads');
export default class HomeController extends Controller {
public async index() {
const { ctx } = this;
ctx.body = await ctx.service.test.sayHi('egg');
}
// 上传前检测
public async hashCheck() {
const { ctx } = this;
const { total, chunkSize, hash, name } = ctx.request.body;
// 上传的文件哈希文件夹加名
const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/');
const filePath = path.join(uploadPath, name);
if (fs.existsSync(filePath)) {
// 文件已存在
ctx.status = 200;
ctx.body = {
success: true,
msg: '检查成功,文件在服务器上已存在,不需要重复上传',
data: {
type: 0, // type=0 为文件已上传过
},
};
} else {
if (fs.existsSync(chunksPath)) {
// 存在文件切片文件夹,上传没有上传完
// 上次没有上传完成,找到以及上传的切片
const index: any = [];
const chunks = fs.readdirSync(chunksPath);
if (chunks.length === Number(total)) {
// 切片上传完了,没有合并
ctx.status = 200;
ctx.body = {
success: true,
msg: '切片上传完毕,没有合并',
data: {
type: 1, // type=1 切片上传完毕,没有合并
},
};
} else {
// 切片没有上传完
chunks.forEach(item => {
const chunksNameArr = item.split('-');
index.push(chunksNameArr[chunksNameArr.length - 1]);
});
ctx.status = 200;
ctx.body = {
success: true,
msg: '检查成功,需要断点续传',
data: {
type: 2, // type= 2 需要断点续传
index,
},
};
}
} else {
// 没有这个文件的切片和文件
ctx.status = 200;
ctx.body = {
success: true,
msg: '检查成功,为从未上传',
data: {
type: 3, // type=3 为从未上传
},
};
}
}
}
// 保存切片
public async chunksUpload() {
const { ctx } = this;
const { /* name, total, */ index, /* size, */ chunkSize, hash } = ctx.request.body;
const file = ctx.request.files[0];
const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/');
if (!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);
// 创建读入流
const readStream = fs.createReadStream(file.filepath);
// 创建写入流
const writeStream = fs.createWriteStream(chunksPath + hash + '-' + index);
// 管道输送
readStream.pipe(writeStream);
readStream.on('end', () => {
// 删除临时文件
fs.unlinkSync(file.filepath);
});
ctx.status = 200;
ctx.body = {
success: true,
msg: '上传成功',
data: 200,
};
}
// 合并切片
public async chunksMerge() {
const { ctx } = this;
const { chunkSize, name, total, hash } = ctx.request.body;
// 根据hash值,获取分片文件。
const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/');
const filePath = path.join(uploadPath, name);
// 读取所有的chunks 文件名存放在数组中, 并进行排序
const chunks = fs.readdirSync(chunksPath).sort((a: any, b: any) => (
a.split('-')[1] - b.split('-')[1]
));
const chunksPathList: any = [];
if (chunks.length !== total || chunks.length === 0) {
ctx.status = 200;
ctx.body = {
success: false,
msg: '切片文件数量与请求不符合,无法合并',
data: '',
};
}
chunks.forEach((item: string) => {
chunksPathList.push(path.join(chunksPath, item));
});
try {
await streamMerge(chunksPathList, filePath, chunkSize);
// 递归删除文件
del(chunksPath);
ctx.status = 200;
ctx.body = {
success: true,
msg: '合并成功',
data: '',
};
} catch {
ctx.status = 200;
ctx.body = {
success: false,
msg: '合并失败,请重试',
data: '',
};
}
}
}
客户端端代码解析
create-react-app创建的ts项目,引入antd
逻辑思路:
- 选择好文件,拿到文件的信息,点击上传后先对文件进行hash编码,保证上传的文件的名称唯一性
- 设置好传输切片的大小,通过FIle的slice方法将文件分割成若个片,
- 上传切片前先调用检测接口,根据返回回来的值进行上传
- 上传过的不要上传
- 上传没有完成的,根据服务端返回最后一个切片的序列来接着之后的切片续传
- 没有上传过的就可以直接上传
- 每次上传完最后一个切片后,使用切片合并接口,将服务端中上传的文件的切片文件夹里的切片按照序列进行合并。
主要代码
import React, { useState } from "react";
import { Upload, Button, Progress } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import SparkMD5 from 'spark-md5';
import axios from 'axios';
import {getState, mutate, useStore} from 'stook';
import "./index.css";
interface file{
file: File, // 上传的文件
chunkSize: number; // 文件的切片大小
}
interface chunks_upload{
blockCount: number, // 文件的切片数量
chunkSize: number, // 文件的切片大小
hash: string, // 文件的哈希值
file: File, // 上传的文件
num: number, // 上传的第几个切片
}
interface chunks_merge{
blockCount: number, // 文件的切片数量
chunkSize: number, // 文件的切片大小
hash: string, // 文件的哈希值
name: string, // 文件名字
}
const win: any = window;
// 文件数据的分割方法
const blobSlice = win.File.prototype.slice || win.File.prototype.mozSlice || win.File.prototype.webkitSlice;
let totalNum:number=0;
// 停止操作
let stop: boolean = false;
// 获取文件哈希值
function hashFile({file, chunkSize}: file){
return new Promise((resolve, reject) => {
let currentChunk = 0;
const chunks = Math.ceil(file.size / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
function loadNext() {
const start = currentChunk * chunkSize;
const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
fileReader.onload = (e: any) => {
spark.append(e.target.result); // Append array buffer
currentChunk += 1;
if (currentChunk < chunks) {
loadNext();
} else {
console.log('finished loading');
const result = spark.end();
// 如果单纯的使用result 作为hash值的时候, 如果文件内容相同,而名称不同的时候
// 想保留两个文件无法保留。所以把文件名称加上。
const sparkMd5 = new SparkMD5();
sparkMd5.append(result);
sparkMd5.append(file.name);
const hexHash: string = sparkMd5.end();
resolve(hexHash);
}
};
fileReader.onerror = () => {
console.warn('文件读取失败!');
};
loadNext();
}).catch(err => {
console.log(err);
});
}
// 文件切片请求
async function chunks_upload({blockCount, chunkSize, hash, file, num}: chunks_upload){
let bool:boolean=true;
for (let i = num; i < blockCount; i++) {
if(stop){
return;
}
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
// 构建表单
const form = new FormData();
form.append('file', blobSlice.call(file, start, end));
form.append('name', file.name);
form.append('total', blockCount.toString());
form.append('index', i.toString());
form.append('chunkSize', file.size.toString());
form.append('hash', hash);
// ajax提交 分片,此时 content-type 为 multipart/form-data
const axiosOptions = {
// 文件上传成功的处理
onUploadProgress: (e: any) => {
// 处理上传的进度
console.log(blockCount, i);
mutate('num', getState('num') + 1 );
},
};
let res = await axios.post('http://192.168.15.210:7002/chunks_upload', form, axiosOptions);
if(res.data.data !== 200){
bool = false;
}
}
// 请求切片合并数据
const data = {
chunkSize: file.size,
name: file.name,
blockCount,
hash
};
if(bool){
chunks_merge(data);
}
}
// 切片合并
function chunks_merge({ chunkSize, name, blockCount, hash }: chunks_merge){
axios.post('http://192.168.15.210:7002/chunks_merge', { chunkSize, name, total: blockCount, hash }).then(res => {
console.log('上传成功');
}).catch(err => {
console.log(err);
});
}
function FileUpdate(){
const [fileList, setFileList]: any = useState([]);
const [num, setNum] = useStore('num', 0);
// 切片大小
const [chunkSize, ]= useState(2 * 1024 * 1024);
const props = {
onRemove: (file: any) => {
const index = fileList.indexOf(file);
const newFileList = fileList.slice();
newFileList.splice(index, 1);
setFileList(newFileList);
},
beforeUpload: (file: any) => {
setFileList([...fileList, file]);
return false;
},
fileList,
};
async function handleUpload(){
setNum(0);
const file: File = fileList[0];
stop = false
if (!file) {
alert('没有获取文件');
return;
}
// 文件切片数量
const blockCount = Math.ceil(file.size / chunkSize);
totalNum = blockCount;
// 文件哈希值
const hash: any = await hashFile({file, chunkSize});
// 先检查是否上传过
const check_form = new FormData();
check_form.append('total', blockCount.toString());
check_form.append('hash', hash);
check_form.append('chunkSize', file.size.toString());
check_form.append('name', file.name);
const res = await axios.post('http://192.168.15.210:7002/hash_check', check_form);
const type = res.data.data.type;
if(type === 0){
// 存在了
console.log("存在了");
mutate('num', blockCount );
return;
} else if(type === 1) {
// 切片上传完毕,没有合并
const data = {
chunkSize: file.size,
name: file.name,
blockCount: blockCount,
hash
};
chunks_merge(data);
mutate('num', blockCount );
console.log("切片上传完毕,没有合并");
return;
} else if (type === 2){
// 检查成功,需要断点续传
const sum: number = res.data.data.index.length;
console.log("sum", sum);
mutate('num', sum );
console.log("上次上传没有完成");
chunks_upload({blockCount, chunkSize, file, hash, num: sum});
} else if (type === 3){
console.log("没有上传过");
chunks_upload({blockCount, chunkSize, file, hash, num: 0});
}
}
return <div className="file">
<Upload {...props}>
<Button icon={<UploadOutlined />}>Select File</Button>
</Upload>
<Progress percent={parseInt(`${(num / totalNum) * 100}`)} />
<Button
type="primary"
onClick={handleUpload}
disabled={fileList.length === 0}
style={{ marginTop: 16 }}
>
Start Upload
</Button>
<Button
type="primary"
onClick={()=>{stop=true;}}
disabled={fileList.length === 0}
style={{ marginTop: 16 }}
>
stop
</Button>
</div>
}
export default FileUpdate;
再附上效果图
- 没有上传的效果
- 续传效果
- 已经上传过文件
学习捷径
两端的代码已在码云上,想继续研发的同学可以戳这里:file-slice文件断点续传demo