一、说说文件上传
在Servlet阶段,对于文件上传真的算是噩梦,需要我们自己从request请求作用域中解析formItem,判断是不是表单字段,是的话进行文件上传,不是的话当做正常的数据字段
Spring阶段呢,配置文件解析器,我们使用解析好的MultipartFile,很方便,复杂的逻辑Spring帮我们做了
但是这两种方式都没有实现分片机制,说说什么是分片机制吧
不分片,就是把文件当做一个整体,一次性给服务器,让服务器消化,相当于一张很大的饼,一个人吃,假设3分钟吃完
分片,把文件按照大小分成多个,并发给服务器,让服务器的消化,把一张很大的饼分成10分,让10个人吃,时间就不描述了,不到10秒吃完
浏览器再给服务器发送一次请求,服务器接收到请求之后会分配给一个线程去处理,不分片的话一个线程处理很大的一个文件,肯定耗时了,假设文件大小200M,按照10MB分片,分成20个分片,
让服务器的20个线程去处理,这速度可想而知
二、需求
实现两个版本,一个普通的Servlet版本,使用原生的方式处理分片,前段使用WebUploader组件实现分片(自动支持),另一个是SpringBoot版本处理分片,前端使用React+Antd文件上传组件,自己实现分片上传
实现文件分片上传,传输过程中段,重新上传文件不会重复
实现文件秒传,原理是不传,通过文件的md5,判断分拣在服务器存在,直接返回上传成功
三、主要代码介绍
3.1 Servlet版本
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 分片序号
Integer chunk = null;
// 总分片数
Integer chunks = null;
// 文件名
String name = null;
// 合并文件需要的流
BufferedOutputStream os = null;
try {
request.setCharacterEncoding(UTF8);
response.setCharacterEncoding(UTF8);
// 创建一个文件工厂
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setRepository(new File(BASEPATH));
factory.setSizeThreshold(1024);
// 这个类可以帮我们解析request
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setFileSizeMax(100L * 1024L * 1024L);
upload.setSizeMax(1000L * 1024L * 1024L);
// FileItem 包含表单字段及文件字段
List<FileItem> fileItems = upload.parseRequest(request);
for (FileItem fileItem : fileItems) {
// 获取本次请求的分片、总分片、文件名
if (fileItem.isFormField()) {
// 是正常的表单字段
if (null != fileItem.getFieldName() && "chunk".equals(fileItem.getFieldName())) {
// 获取到分片字段
chunk = Integer.parseInt(fileItem.getString(UTF8));
}
if (null != fileItem.getFieldName() && "chunks".equals(fileItem.getFieldName())) {
// 获取到分片总数字段
chunks = Integer.parseInt(fileItem.getString(UTF8));
}
if (null != fileItem.getFieldName() && "name".equals(fileItem.getFieldName())) {
// 获取到文件名
name = fileItem.getString(UTF8);
}
}
}
// 上述循环结束,表单字段全部读取完毕
// 假设它是整个文件,没有分片上传
String currentFileName = name;
for (FileItem fileItem : fileItems) {
if (!fileItem.isFormField()) {
// 是文件字段
if (null != chunk && null != chunks) {
// 是分片上传,文件名起个独特的名字,方便后续合并
currentFileName = chunk + "_" + name;
// 存当前文件
File currentFile = new File(BASEPATH, currentFileName);
// 如果文件不存在,进行存储,否则假入客户端中断后重新上传会重复
if (!currentFile.exists()) {
fileItem.write(currentFile);
}
}
}
}
// 是分片上传时,当上传至最后一个分片时,处理文件合并,chunk 的值 0 - chunks - 1
if (chunk != null && chunks != null && chunk.equals(chunks - 1)) {
// 是最后一个分片,准备合并
File realFile = new File(BASEPATH, name);
os = new BufferedOutputStream(new FileOutputStream(realFile));
for (int i = 0; i < chunks; i++) {
// 文件名规则是我们自己定义的
File temp = new File(BASEPATH, i + "_" + name);
// 因为分片上传时并发操作,tomcat拿到请求之后会分配给一个线程去处理,我们不能保证哪个分片先到
// 如果不存在就一直等
while (!temp.exists()) {
// 等100ms
Thread.sleep(100);
}
// 说明已经到了
os.write(FileUtils.readFileToByteArray(temp));
os.flush();
temp.delete();
}
// 循环结束后再刷新一次流,防止缓冲区未满导致的部分数据缺失
os.flush();
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
} catch (FileUploadException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
// 关闭流
if (os != null) {
os.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.2 Boot版本
3.2.1 前段主要代码
使用spark-md5生成文件的md5值
const getMD5 = (file, fileListID) => {
return new Promise((resove, reject) => {
// 使用sparkMD5的ArrayBuffer类,读取二进制文件
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
// 异步操作,读完后的结果
fileReader.onload = (e) => {
// 把文件开始传入spark
spark.append(e.target.result)
// spark计算出MD5后的结果
const _md5 = spark.end()
resove(_md5)
// 下面可以写一些自己需要的业务代码, 例如 fileItem.fileMD5 = _md5
}
// fileReader读取二进制文件
fileReader.readAsArrayBuffer(file)
})
}
如果文件过大,生成md5的时间会很长,测试了一下700MB超过5分钟,所以在去md5的时候,取了文件的第一个分片和最后一个分片
// 获取文件的总分片数
const chunkNum = Math.ceil(fileList[i].size / chunkSize)
// 取两个md5值作为整体文件的唯一标识
let fileMd5 = ''
if (chunkNum >= 2) {
let startMd5 = await getMD5(fileList[i].slice(0, 1 * chunkSize))
let endMd5 = await getMD5(fileList[i].slice((chunkNum-1) * chunkSize, chunkNum * chunkSize))
fileMd5 = startMd5 + endMd5;
} else {
fileMd5 = await getMD5(fileList[i])
}
根据生成的文件md5值判断是否存在,存在直接响应用户上传成功
// 判断文件是否存在
const res = await checkFileExist(fileMd5);
const { code, data, msg } = res;
if(data) {
message.success('文件秒传成功')
console.log('文件在服务器已存在,文件上传成功(大文件秒传原理就是不传)')
// 跳过这个文件,不传了
continue;
} else {
// 文件不存在,准备上传
}
开始分片传文件,使用循环将文件打散,多分片上传
if(chunkFlag) {
let start = new Date()
for(let currentChunk = 0; currentChunk < chunkNum; currentChunk++) {
let formData = new FormData();
// 分片上传
formData.append("chunkFlag", chunkFlag);
// 分片总数
formData.append("chunks", chunkNum);
// 当前分片数
formData.append("currentChunk", currentChunk);
// 分片大小
formData.append("chunkSize", chunkSize);
// 文件类型
formData.append('type', fileList[i].type)
// 文件总大小
formData.append("size", fileList[i].size);
// 文件名
formData.append("name", fileList[i].name);
// 整个文件的id值,及md5值
formData.append("fileMd5", fileMd5);
// 计算当前文件分片的md5值
let currentChunkMd5 = await getMD5(fileList[i].slice(currentChunk * chunkSize, (currentChunk + 1) * chunkSize));
formData.append("currentChunkMd5", currentChunkMd5);
formData.append("file", fileList[i].slice(currentChunk * chunkSize, (currentChunk + 1) * chunkSize));
fileUpload(formData).then(res => {
console.log(fileList[i].name + ",分片:" + currentChunk + "上传成功")
}).catch(err => {
})
}
let end = new Date()
console.log(fileList[i].name + "上传完成,耗时:" + (end - start))
} else {
// 不分片
}
3.2.2 Java端
Form表单参数DTO
package com.cxs.dto;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/*
* @Project:file-upload-senior
* @Author:cxs
* @Motto:放下杂念,只为迎接明天更好的自己
* */
@Data
public class FileUploadDTO {
/**
* 是否是分片上传
*/
@NotNull(message = "是否分片不能为空")
private Boolean chunkFlag;
/**
* 文件
*/
@NotNull(message = "文件不能为空")
private MultipartFile file;
/**
* 文件名
*/
@NotBlank(message = "文件名不能为空")
private String name;
/**
* 文件总大小
*/
private Long size;
/**
* 文件md5
*/
@NotBlank(message = "文件md5不能为空")
private String fileMd5;
/**
* 文件类型
*/
private String type;
/**
* 当前分片
*/
private Integer currentChunk;
/**
* 分片长度
*/
private Integer chunkSize;
/**
* 总分片数量
*/
private Integer chunks;
/**
* 分片文件md5
*/
private String currentChunkMd5;
}
根据文件的md5值检查文件是否存在,存在就秒传
@GetMapping("/checkFileExist")
public Result checkFileExist(@RequestParam(value = "fileMd5Id", required = true) String fileMd5Id){
// 根据fileMd5Id查询文件是否存在
LambdaQueryWrapper<SysFile> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysFile::getFileMd5, fileMd5Id.trim());
SysFile one = sysFileService.getOne(wrapper);
return Result.success(null != one);
}
实现两个方法,根据入参判断是否分片,兼容整个文件上传的方式
@PostMapping("/upload")
public Result upload(FileUploadDTO dto){
if (ObjectUtils.isEmpty(dto)) {
return Result.error("入参错误,文件上传失败");
}
Result result = Result.success("文件上传成功");
if (dto.getChunkFlag()) {
fileUploadService.chunkFileUpload(dto, result);
} else {
fileUploadService.singleFileUpload(dto, result);
}
return result;
}
根据文件分片的顺序写入文件
RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
if (randomAccessFile.length() == 0l) {
randomAccessFile.setLength(dto.getSize());
}
// 计算分片文件的位置
int pos = dto.getCurrentChunk() * dto.getChunkSize();
FileChannel channel = randomAccessFile.getChannel();
MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_WRITE, pos, multipartFile.getSize());
map.put(multipartFile.getBytes());
cleanBuffer(map);
channel.close();
randomAccessFile.close();
将文件存到数据库
// 存分片数据
String chunkKid = saveSysChunkRecord(file, dto);
vo.setChunkKid(chunkKid).setUploaded(Boolean.TRUE);
if (dto.getCurrentChunk() == dto.getChunks() - 1) {
LambdaQueryWrapper<SysChunkRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysChunkRecord::getFileMd5, dto.getFileMd5());
Integer integer = sysChunkRecordMapper.selectCount(wrapper);
int flag = 0;
// 循环等待10次,如果还有没到就退出,上传失败
while (integer != dto.getChunks() && flag < 10) {
Thread.sleep(100);
integer = sysChunkRecordMapper.selectCount(wrapper);
flag++;
}
if(integer == dto.getChunks()) {
// 存文件
SysFile fileInfo = buildSysFile(dto, file);
int insert = sysFileMapper.insert(fileInfo);
if (insert == 1) {
// 清除分片数据
cleanChunkData(dto.getFileMd5());
}
vo.setFileKid(fileInfo.getKid()).setUploaded(Boolean.TRUE);
} else {
// 清除分片数据
cleanChunkData(dto.getFileMd5());
// 文件上传失败
result.setCode(HttpStatus.ERROR);
result.setMsg("文件上传失败");
return;
}
}
四、模块说明
file-upload-senior-base Servlet版本
file-upload-senior-boot Boot版本
React前段运行说明
注:需要nodejs环境
在resources目录下的file-upload-senior打开终端
npm install 或者 yarn install
npm start
参考文章:http://blog.ncmem.com/wordpress/2023/12/16/java%e5%ae%9e%e7%8e%b0%e6%96%87%e4%bb%b6%e5%88%86%e7%89%87%e4%b8%8a%e4%bc%a0%e3%80%81%e5%a4%a7%e6%96%87%e4%bb%b6%e7%a7%92%e4%bc%a0/
欢迎入群一起讨论