Java实现文件分片上传、大文件秒传
一、说说文件上传
在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/
欢迎入群一起讨论
posted on 2023-12-16 10:35 Xproer-松鼠 阅读(1375) 评论(0) 编辑 收藏 举报