好久没有动手写文章,最近碰上了一个文件上传功能,使用mac机器做服务器且只能使用无线,文件超过100m后经常上传失败,于是第一次真实开发大文件上传,在此记录一下流程和思路,供大家参考,本文章涉及技术包括前后端,纯前端的同学可以参考实现,后面碰上类似需求,心中也能对整个需求的逻辑有个完整的认知
思路
先列一下要解决的根本问题
-
大文件上传失败
-
上传失败后无法从之前已上传的地方继续上传
解决上述根本问题,能想到的办法
-
将文件切分成多个切片后单个上传到服务器
-
服务器对文件切片进行整合
由此引发出的技术点
-
如何切片文件
-
服务器如何判断文件切片的归属
-
如何组合文件
归纳出节点,接下来就可以跟着这些节点,逐个实现他们
开干!!!
**文件切片
**
既然是对文件进行分片上传,那么第一个问题,如何对文件进行分片,**
**
我这里最初选择的方案是使用FileReader,将文件转成ArrayBuffer再进行切割,但实际上
根据类型提示看到,FileReader读出来是string,到后面上传的时候,request body就很大,还得处理一系列由此引发的问题,所以这个方案被放弃了,这个坑你们也就不用再来踩了
后来发现,File的原型链上本身是有slice方法的,并且切分出来还是Blob,通过formdata发送对服务端很友好,不墨迹,贴上分割文件的代码
import Mywork from '../worker/getmdWork.js?worker'
// 分割文件
export type BufferItem = { file: Blob, hash: string, idx: number, fileName: string }
/**
* 切割文件
* @param file // 目标文件
* @param size // 切割大小,单位:mb
* @returns BufferItem[]
*/
const splitFile: (file: File, size: number) => Promise<BufferItem[]> = (file, size = 5) => {
return new Promise((resolve, reject) => {
// webwork里获取文件md5,防止文件太大时,计算md5影响GUI线程
const mywork = new Mywork()
mywork.postMessage(file)
mywork.onmessage = (data) => {
if (data.data.success) {
const bufferArr: BufferItem[] = []
let cur = 0
let idx = 0
while (cur < file.size) {
bufferArr.push({ file: file.slice(cur, cur + (size * 1024 * 1024)), hash: data.data.md5, idx, fileName: file.name })
cur += size * 1024 * 1024
idx++
}
resolve(bufferArr)
} else {
reject(data.data.err)
}
}
})
}
注意promise中的注释,为了保证服务器能知道当前切片是归属于哪个文件,所以我获取了文件md5,但在最初的一版代码中,发现在计算md5时,页面会很卡,于是引入了webworker做了优化
// getmdWork.js
import BMF from 'browser-md5-file'
onmessage = (e) => {
const bmf = new BMF()
bmf.md5(e.data, (err, md5) => {
if (err) {
postMessage({
success: false,
err
})
} else {
postMessage({
success: true,
md5
})
}
})
}
注意看切割文件后返回的类型
type BufferItem = { file: Blob, hash: string, idx: number, fileName: string }
说下几个字段的作用
{ file: 文件切片 hash: 计算出的文件MD5值,用做服务端校验 idx: 当前切片的序号 fileName: 文件名称}
到了这里,切割文件基本就结束了,下一步就是考虑如何把这些切片文件一个个上传了,这里说下思路,两种办法,第一种即是定义个请求栈,依次进栈出栈,另外一种就是,对文件切片数组进行分割,一批一批的上传,我选择了第二种方式,心智模型稍微简单点,但无论选哪种方式,都需要先将第一个切片发到服务器去获得对应已上传信息,才能进行接下来的操作
// 点击上传方法
const uploadHandler = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files ? e.target.files[0] : null
if (!file) {
message.error('请选择文件')
return false
}
const fileType = file.name.split('.').pop()
if (fileType !== 'apk' && fileType !== 'aab' && !isMac) {
message.error('仅支持apk/aab上传')
return false
}
try {
setLoading(true)
// 分割文件
const bArr = await splitFile(file, 10)
// 投石问路-先取出第一个切片到服务器获取信息
const fir = bArr[0]
const firFm = new FormData()
// 放入切片
firFm.append('file', fir.file)
// 告知服务器该切片的排序
firFm.append('idx', fir.idx.toString())
// 告知服务器切片总数量
firFm.append('length', bArr.length.toString())
// 告知服务器原文件的md5
firFm.append('md5', fir.hash)
// 告知服务器源文件的名称
firFm.append('fileName', fir.fileName)
const { data: res } = await httpApi({
// ...请求参数
}).request
if (res.status === 0) {
// 服务器返回的已上传idx集合
const { already } = res.data
if (res.data.complete) {
// 之前已上传成功,不再执行重复上传
message.success('上传成功')
} else {
// 计算还需要上传的切片
const needUploadIdxs = bArr.filter(v => !already.includes(v.idx))
// 走完人生路-将还需要上传的切片逐个上传,uploadPiece方法在下面
const pieceResult: number = needUploadIdxs.length > 0 && await uploadPiece(needUploadIdxs, bArr.length)
// pieceResult即为本次上传失败的切片集合,根据它来计算出已上传百分比
degRef.current = 1 - (pieceResult / bArr.length)
if (pieceResult === 0) {
message.success('上传成功')
} else {
throw new Error('上传结束,但是有部分分片上传失败')
}
}
} else {
throw new Error(res.message)
}
} catch (e) {
Modal.confirm({
title: `已上传${(degRef.current * 100).toFixed(0)}%`,
content: '上传文件失败,是否从断开处继续上传?',
okText: '重新上传',
cancelText: '取消',
onOk: () => {
if (uploadRef.current) {
uploadRef.current.value = ''
}
uploadRef.current!.click()
}
})
} finally {
if (uploadRef.current) {
uploadRef.current.value = ''
}
setLoading(false)
}
}
const uploadPiece = async (list: BufferItem[], length: number) => {
let cur = 0
const failList: any = []
while (cur < list.length) {
const rqList = list.slice(cur, cur + 5)
const pieceResList = await Promise.allSettled(rqList.map(v => {
const fm = new FormData()
fm.append('file', v.file)
fm.append('fileName', v.fileName)
fm.append('length', String(length))
fm.append('idx', String(v.idx))
fm.append('md5', v.hash)
return httpApi({
apiId: 'pieceupload',
state,
method: 'POST',
timeout: 120000,
data: fm
}).request
}))
const fail = pieceResList.filter(v => v.status === 'rejected' || v.value?.data?.status !== 0)
failList.push(...fail)
cur += 5
}
return failList.length
}
至此,前端部分完成
SERVER
先祭出我的思维导图
controller
@PostMapping(value = "/uploadSource", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AvalonHttpResp<?> uploadSource (SourceUploadDto dto) throws AvalonException,IOException {
App app = appService.getById(dto.getAppId());
if (null == app) {
throw new AvalonException(PARAM_ERROR, "无效的appId");
}
return AvalonHttpResp.ok(uploadFileService.UploadSourcePiece(dto, app.getAppId()));
}
impl
@Override
public UploadResDto UploadSourcePiece (
SourceUploadDto dto,
String appCode
) throws AvalonException, IOException {
UploadResDto result = new UploadResDto();
// 存放目录
String rootPath = PathUtil.isWindows() ? winPath : unixPath;
rootPath = rootPath.endsWith("/") ? rootPath : rootPath + "/";
String path = rootPath + motherPath + appCode + "/";
// 验证map种是否已有对应Md5
SourceRepoItem target = SourceUploadRepo.getValue(dto.getMd5());
if (target == null) { // map中没有对应md5key
// 查询是否有md5目录,有则说明上次未上传成功,且中途map被清理过
File md5File = new File(path + dto.getMd5());
dealFileWithOutKey(md5File, dto.getLength(), dto.getMd5(), dto.getFileName(), appCode);
// 此时map中已经有了对应md5key
}
// 校验当前片是否上传过
target = SourceUploadRepo.getValue(dto.getMd5());
if (target.length == 0) { // 之前已上传成功
result.setSuccess(true);
result.setComplete(true);
result.setAlready(new HashSet<>());
return result;
} else {
boolean hasSave = target.getAlready().contains(Integer.parseInt(dto.getIdx()));
if (!hasSave) {
// 存放切片
// 判断目录是否存在
File dir = new File(path + dto.getMd5());
if (!dir.exists()) {
dir.mkdirs();
}
// 写入文件
FileUtils.byteFileToFile(dto.getFile(), path + dto.getMd5() + "/" + dto.getIdx());
// 录入Map
Set<Integer> before = target.getAlready();
before.add(Integer.parseInt(dto.getIdx()));
target.setAlready(before);
SourceUploadRepo.setSourceMap(dto.getMd5(), target);
}
result.setSuccess(true);
result.setAlready(target.getAlready());
result.setComplete(target.getLength() == target.getAlready().size());
}
// 上传完成操作
if (target.length == target.getAlready().size()) {
dealFileComplete(dto.getMd5(), dto.getFileName(), target.getLength(), appCode);
SourceUploadRepo.delSourceMapKey(dto.getMd5());
motherPackagesService.saveMotherPackageInfo(dto.getFileName(), appCode);
}
return result;
}
/**
* 处理分片上传时,map中无法找到对应key的情况
* @param file
*/
public void dealFileWithOutKey (File file, String length, String md5, String fileName, String appCode) throws IOException {
// 存放目录
String rootPath = PathUtil.isWindows() ? winPath : unixPath;
rootPath = rootPath.endsWith("/") ? rootPath : rootPath + "/";
String path = rootPath + motherPath + appCode + "/";
Set<Integer> set = new HashSet<>(); // already
SourceRepoItem saveItem = new SourceRepoItem(); // md5: value<-
if (file.exists()) {
File[] fileList = file.listFiles();
if (Objects.requireNonNull(fileList).length > 0) {
for (File item : fileList) {
try {
Integer idx = Integer.parseInt(item.getName());
set.add(idx);
} catch (NumberFormatException e) {
log.error(file.getName() + "非碎片文件");
}
}
}
saveItem.setLength(Integer.parseInt(length));
saveItem.setAlready(set);
} else {
File alreadyFile = new File(path + fileName);
if (alreadyFile.exists()) {
String alreadyMd5 = DigestUtils.md5DigestAsHex(new FileInputStream(alreadyFile));
if (alreadyMd5.equals(md5)) {
saveItem.setLength(0);
} else {
saveItem.setLength(Integer.parseInt(length));
}
} else {
saveItem.setLength(Integer.parseInt(length));
}
saveItem.setAlready(new HashSet<>());
}
SourceUploadRepo.setSourceMap(md5, saveItem);
}
/**
* 上传完成组合文件
*/
public void dealFileComplete (String md5, String fileName, Integer length, String appCode) throws IOException {
// 存放目录
String rootPath = PathUtil.isWindows() ? winPath : unixPath;
rootPath = rootPath.endsWith("/") ? rootPath : rootPath + "/";
String path = rootPath + motherPath + appCode + "/";
File dir = new File(path + md5);
if (dir.exists()) { // md5目录存在,才进行文件组合,如果不在,则说明本次服务启动期间,已经上传完成
RandomAccessFile raf = null;
raf = new RandomAccessFile(new File(path + fileName), "rw");
try {
for (int i = 0; i < length; i++) {
File file = new File(path + md5 + "/" + i);
if (!file.exists()) {
throw new AvalonException(UPLOAD_ERROR, i + "文件丢失");
}
RandomAccessFile reader = new RandomAccessFile(file, "r");
byte[] b = new byte[1024];
int n = 0;
while((n = reader.read(b)) != -1){
raf.write(b, 0, n);//一边读,一边写
}
reader.close();
}
} finally {
try {
raf.close();
FileUtils.deleteDir(path + md5);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
需要注意的是,内存中有个map结构,存放了以md5为key的属性,这就是server判断文件上传状态的依据,比较复杂的在于,当上传到一半,服务器突然断电了,那么重启后的处理逻辑就比较绕,我在导图和代码中也做了对应表述