# Ant大文件自定义上传
<!-- a-upload -->
:customRequest="customRequest"
# 接口
// 请求的三个接口
// 检查文件是否被上传
interface CheckResponse {
uploaded: boolean;
uploadedList: string[];
url?: string;
}
interface ICheckParams {
ext: string; // 文件扩展 如 mp4
hash: string; // 文件hash
}
export function checkFile(data: ICheckParams) {
return post<CheckResponse>(Api.checkFile, {}, data);
}
// 上传切片文件
export function uploadChunk(formData: FormData, onUploadProgressCallback?: Function) {
return upload<any>(Api.uploadChunk, formData, onUploadProgressCallback);
}
// 合并文件
export function mergeFile(data) {
return post<any>(Api.mergeFile, {},data);
}
# 逻辑代码
import type { UploadRequestOption } from "ant-design-vue/es/vc-upload/interface";
import sparkMD5 from "spark-md5";
const DEFAULT_CHUNK_SIZE: number = 2 * 1024 * 1024; // 默认切片大小为2M
interface FileChunk {
hash: string;
name: string;
index: number;
chunk: Blob;
progress: number;
total: number; // 总块数
}
// 计算文件hash
const calcFileHash = async (file: File): Promise<string> => {
return new Promise(resolve => {
const spark = new sparkMD5.ArrayBuffer();
const reader = new FileReader();
const size = file.size;
const offset = 2 * 1024 * 1024;
const chunks = [file.slice(0, offset)];
let cur = offset;
while (cur < size) {
if (cur + offset >= size) {
// 最后一个区块
chunks.push(file.slice(cur, cur + offset));
} else {
// 中间的区块
const mid = cur + offset / 2;
const end = cur + offset;
chunks.push(file.slice(cur, cur + 2));
chunks.push(file.slice(mid, mid + 2));
chunks.push(file.slice(end - 2, end));
}
cur += offset;
}
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = e => {
spark.append(e?.target?.result as ArrayBuffer);
resolve(spark.end());
};
});
};
// 文件切片
const handleFileChunk = (file: Blob, chunkSize: number = DEFAULT_CHUNK_SIZE, hash: string): FileChunk[] => {
const fileChunkList: FileChunk[] = [];
let cur = 0;
const maxLen = Math.ceil(file.size / chunkSize);
while (cur < maxLen) {
const start = cur * chunkSize;
const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileChunkList.push({ index: cur, chunk: file.slice(start, end), name: `${hash}-${cur}`, progress: 0, hash, total: maxLen });
cur++;
}
return fileChunkList;
};
/**
* 上传切片
* @param {*} uploadedList 已上传切片列表
* @param {*} chunks 所有切片列表
* @param {*} fileInfo {ext:string,hash:string,name:string}
* @param {UploadRequestOption} params a-upload 自定义上传请求对象
*/
const uploadChunks = (uploadedList, chunks, fileInfo, params) => {
const requests = chunks
.filter(chunk => !uploadedList.includes(chunk.name))
.map((chunk, index) => {
const form = new FormData();
form.append("chunk", chunk.chunk);
form.append("hash", chunk.hash);
form.append("name", chunk.name);
form.append("total", chunk.total);
return { form, index: index, error: 0 };
});
const sendRequest = (limit = 1) => {
let count = 0;
let isStop = false;
const len = requests.length;
return new Promise((resolve, reject) => {
const upLoadReq = async () => {
if (isStop) return;
const req = requests.shift();
if (!req) return;
const { form, index } = req;
uploadChunk(form, (p: number = 0) => {
chunks[index].progress = p;
let percent = 0;
if (chunks.length === 1) {
percent = p;
params.onProgress({ percent });
} else {
percent = Math.floor((count / (chunks.length - 1)) * 100);
}
params.onProgress({ percent });
})
.then(() => {
if (count == len - 1) {
resolve(true);
} else {
count++;
upLoadReq();
}
})
.catch(err => {
chunks[index].progress = -1;
if (req.error < 3) {
req.error++;
requests.unshift(req);
upLoadReq();
} else {
isStop = true;
reject();
}
});
};
while (limit > 0) {
setTimeout(() => {
upLoadReq();
}, Math.random() * 2000);
limit--;
}
});
};
sendRequest(3).then(async res => {
const { data } = await mergeFile({ size: DEFAULT_CHUNK_SIZE, ...fileInfo });
});
};
// 自定义上传
const customRequest = async (params: UploadRequestOption) => {
let file = params.file as File;
// 计算文件hash
const hash = await calcFileHash(params.file as File);
// 文件切片
let chunks = handleFileChunk(params.file as File, DEFAULT_CHUNK_SIZE, hash);
// 检查是否已经上传
const ext: string = file.name.split(".").pop() || "";
const { data } = await checkFile({ hash, ext });
if (data.uploaded) {
message.success("秒传成功");
if (params.onSuccess) params.onSuccess(data);
return;
}
chunks = chunks.map((chunk, index) => {
let name = hash + "-" + index;
const isChunkUploaded = data.uploadedList.includes(name) ? true : false;
return {
index,
hash,
name,
chunk: chunk.chunk,
total: chunk.total,
progress: isChunkUploaded ? 100 : 0,
};
});
// 上传未上传的切片
let fileInfo = { ext, name: file.name, hash };
uploadChunks(data.uploadedList, chunks, fileInfo, params);
};
# 后端代码
使用koa-generator 快速生成的项目结构
// app.js
const Koa = require("koa");
const app = new Koa();
const views = require("koa-views");
const json = require("koa-json");
const onerror = require("koa-onerror");
const bodyparser = require("koa-bodyparser");
const logger = require("koa-logger");
const file = require("./routes/file");
// 错误处理
onerror(app);
const multer = require("@koa/multer");
const upload = multer({ dest: "uploads/" });
app.use(upload.single("chunk"));
// 中间件
app.use(
bodyparser({
enableTypes: ["json", "form", "text"],
})
);
app.use(json());
app.use(logger());
app.use(require("koa-static")(__dirname + "/public"));
app.use(
views(__dirname + "/views", {
extension: "pug",
})
);
// 日志
app.use(async (ctx, next) => {
const start = new Date();
await next();
const ms = new Date() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
// 路由
app.use(file.routes(), file.allowedMethods());
// 错误处理
app.on("error", (err, ctx) => {
console.error("server error", err, ctx);
});
module.exports = app;
// routes/file.js
const router = require("koa-router")();
const path = require("path");
// router.prefix("/file");
const fse = require("fs-extra");
const UPLOAD_DIR = path.resolve(__dirname, "../uploads");
// 获取已上传文件列表
const getUploadedList = async (dirPath) => {
return fse.existsSync(dirPath)
? (await fse.readdir(dirPath)).filter((name) => name[0] !== ".")
: [];
};
/**
* @api {post} /file/checkfile 检查文件是否已上传
* @apiName 检查文件是否已上传
* @apiGroup File
*
* @apiBody {String} [ext] 文件扩展名 eg: mp4.
* @apiBody {String} [hash] 文件hash.
*
* @apiSuccess {Number} code 状态码.
* @apiSuccess {Object} data 返回结果.
* @apiSuccess {Boolean} uploaded 标识文件是否已上传
* @apiSuccess {Array} uploadedList 已上传切片文件
*
* @apiParamExample {json} Request Body Example:
* {
* "ext": "",
* "hash": ""
* }
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 200 OK
* {
* "code": 0,
* "data": {
* "uploaded": true,
* "uploadedList": [
* "7d3dc89d0a12fc0bae9dc7c85696a6fc-0",
* "7d3dc89d0a12fc0bae9dc7c85696a6fc-1",
* ]
* }
* }
*
* @apiErrorExample {json} Error Response:
* HTTP/1.1 404 Not Found
* {
* "code": 1,
* "message": "File not found"
* }
*/
router.post("/checkfile", async (ctx) => {
const body = ctx.request.body;
const { ext, hash } = body;
const filePath = path.resolve(UPLOAD_DIR, `${hash}.${ext}`);
let uploaded = false;
let uploadedList = [];
if (fse.existsSync(filePath)) {
uploaded = true;
} else {
uploadedList = await getUploadedList(path.resolve(UPLOAD_DIR, hash));
}
ctx.body = {
code: 0,
data: {
uploaded,
uploadedList,
},
};
});
/**
* @api {post} /file/uploadChunk 上传切片
* @apiName UploadChunk
* @apiGroup File
*
* @apiBody {String} hash 文件哈希值
* @apiBody {String} name 文件名
* @apiBody {Number} total 切片总数
*
* @apiSuccess {Number} code 返回代码(0为成功,-1为失败)
* @apiSuccess {String} message 返回消息
*
* @apiSuccessExample Success-Response:
* HTTP/1.1 200 OK
* {
* "code": 0,
* "message": "切片上传成功"
* }
*
* @apiErrorExample Error-Response:
* HTTP/1.1 200 OK
* {
* "code": -1,
* "message": "所有切片已上传"
* }
*/
router.post("/uploadChunk", async (ctx) => {
const body = ctx.request.body;
const file = ctx.request.file;
const { hash, name, total } = body;
const chunkPath = path.resolve(UPLOAD_DIR, hash);
if (!fse.existsSync(chunkPath)) {
await fse.mkdir(chunkPath);
}
uploadedList = await getUploadedList(chunkPath);
if (uploadedList.length == total) {
return (ctx.body = {
code: -1,
message: `所有切片已上传`,
});
}
await fse.move(file.path, `${chunkPath}/${name}`);
ctx.body = {
code: 0,
message: `切片上传成功`,
};
});
// 合并文件
function mergeChunks(files, dest, CHUNK_SIZE) {
const pipeStream = (filePath, writeStream) => {
return new Promise((resolve, reject) => {
const readStream = fse.createReadStream(filePath);
readStream.on("end", () => {
fse.unlinkSync(filePath);
resolve();
});
readStream.pipe(writeStream);
});
};
const pipes = files.map((file, index) => {
return pipeStream(
file,
fse.createWriteStream(dest, {
start: index * CHUNK_SIZE,
end: (index + 1) * CHUNK_SIZE,
})
);
});
return Promise.all(pipes);
}
async function mergeFile(filePath, size, hash) {
const chunkDir = path.resolve(UPLOAD_DIR, hash);
let chunks = await fse.readdir(chunkDir);
chunks = chunks.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
chunks = chunks.map((cpath) => path.resolve(chunkDir, cpath));
await mergeChunks(chunks, filePath, size);
}
/**
* @api {post} /mergeFile 合并文件
* @apiName MergeFile
* @apiGroup File
*
* @apiBody {String} ext 文件扩展名
* @apiBody {Number} size 文件总大小
* @apiBody {String} hash 文件哈希值
* @apiBody {String} name 文件名
*
* @apiSuccess {Number} code 返回代码(0为成功,-1为失败)
* @apiSuccess {Boolean} data 合并是否成功
*
* @apiSuccessExample Success-Response:
* HTTP/1.1 200 OK
* {
* "code": 0,
* "data": true
* }
*/
router.post("/mergeFile", async (ctx) => {
const body = ctx.request.body;
const { ext, size, hash, name } = body;
//文件最终路径
const filePath = path.resolve(UPLOAD_DIR, `${name}`);
await mergeFile(filePath, size, hash);
ctx.body = {
code: 0,
data: true,
};
});
module.exports = router;