全栈小白的gravatar头像
全栈小白 2022-12-10 20:02:11
Java实现文件分片上传、大文件秒传

原创声明:本人所发内容及涉及源码,均为亲手所撸,如总结内容有误,欢迎指出

一、说说文件上传

在Servlet阶段,对于文件上传真的算是噩梦,需要我们自己从request请求作用域中解析formItem,判断是不是表单字段,是的话进行文件上传,不是的话当做正常的数据字段

Spring阶段呢,配置文件解析器,我们使用解析好的MultipartFile,很方便,复杂的逻辑Spring帮我们做了

但是这两种方式都没有实现分片机制,说说什么是分片机制吧

不分片,就是把文件当做一个整体,一次性给服务器,让服务器消化,相当于一张很大的饼,一个人吃,假设3分钟吃完

分片,把文件按照大小分成多个,并发给服务器,让服务器的消化,把一张很大的饼分成10分,让10个人吃,时间就不描述了,不到10秒吃完

浏览器再给服务器发送一次请求,服务器接收到请求之后会分配给一个线程去处理,不分片的话一个线程处理很大的一个文件,肯定耗时了,假设文件大小200M,按照10MB分片,分成20个分片,

让服务器的20个线程去处理,这速度可想而知

二、需求

实现两个版本,一个普通的Servlet版本,使用原生的方式处理分片,前段使用WebUploader组件实现分片(自动支持),另一个是SpringBoot版本处理分片,前端使用React+Antd文件上传组件,自己实现分片上传

  1. 实现文件分片上传,传输过程中段,重新上传文件不会重复

  2. 实现文件秒传,原理是不传,通过文件的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

源代码地址:https://gitee.com/whole-stack-of-white/file-upload-senior-demo


打赏

已有1人打赏

最代码官方的gravatar头像
最近浏览
80730176  LV7 2月1日
stedian  LV4 2023年12月20日
Henew168  LV2 2023年11月19日
wangguojie  LV1 2023年6月27日
wmm500  LV3 2023年5月5日
随便取个名字_哈哈  LV27 2023年5月4日
pgl2023 2023年3月17日
暂无贡献等级
林间听风  LV10 2023年3月16日
故事_sun  LV26 2023年3月9日
超维智能编程  LV6 2023年2月28日
顶部 客服 微信二维码 底部
>扫描二维码关注最代码为好友扫描二维码关注最代码为好友