首页 > 编程语言 >Java实现文件分片上传、大文件秒传

Java实现文件分片上传、大文件秒传

时间:2023-12-16 10:45:38浏览次数:35  
标签:文件 dto 分片 new Java 上传 md5

一、说说文件上传

在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

 

参考文章: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/

欢迎入群一起讨论

 

 

标签:文件,dto,分片,new,Java,上传,md5
From: https://www.cnblogs.com/songsu/p/17904574.html

相关文章

  • 前端JavaScript中,对obj对象进行劫持的方式主要有以下几种:
    前端JavaScript中,对obj对象进行劫持的方式主要有以下几种:原型劫持:通过改变对象的原型(prototype)来实现劫持。当一个对象被创建时,它的原型会被存储起来,以便在需要时进行查找。通过将一个对象的原型改为另一个对象或null,可以控制该对象的属性和方法。属性访问劫持:通过在属性访问时......
  • 14. 从零用Rust编写正反向代理, HTTP文件服务器的实现过程及参数
    wmproxywmproxy是由Rust编写,已实现http/https代理,socks5代理,反向代理,静态文件服务器,内网穿透,配置热更新等,后续将实现websocket代理等,同时会将实现过程分享出来,感兴趣的可以一起造个轮子法项目++wmproxy++gite:https://gitee.com/tickbh/wmproxygithub:https://github.com/tic......
  • PowerShell配置文件只Profile.ps1
    PowerShell执行的时候,首先会执行Profile.ps1的内容,如果我们想要执行PowerShell的时候,会获得某些功能,可以将想要的内容放到Profile.ps1中。这个文件默认存放在C:\Windows\system32\WindowsPowerShell\v1.0\Examples\下。该文件默认添加所有的别名,还有部分Function。内容如下:#Copyr......
  • 无涯教程-Java - boolean equalsIgnoreCase(String anotherString)函数
    此方法将此String与另一个String进行比较,而忽略大小写考虑。booleanequalsIgnoreCase-语法publicbooleanequalsIgnoreCase(StringanotherString)这是参数的详细信息-anotherString  - 与该字符串进行比较的字符串。booleanequalsIgnoreCase-返回值如果参数......
  • JavaScript
    您只能在HTML输出中使用document.write。如果您在文档加载后使用该方法,会覆盖整个文档。HTML输出流中使用document.write,相当于添加在原有html代码中添加一串html代码。而如果在文档加载后使用(如使用函数),会覆盖整个文档。Javascript脚本代码可被放置在HTML页面的 <body>......
  • 访问上传至.Net服务器本地的文件。
    1porgame.cs添加以下代码//使用默认静态文件目录wwwrootapp.UseDefaultFiles();app.UseStaticFiles();2在项目跟目录下创建wwwroot文件,需要提供访问的视频放在这个文件夹下面!!!注意,要区分大小写,我之前访问不了,就是创建的是WWWRoot.3访问方式!!!注意是服务器地址+......
  • Java中常见的数据结构
    一、数组二、链表三、栈四、队列五、List类1.ArrayList:底层是数组结构。2.LinkedList:底层结构是链表。六、LinkedList类七、Vector八、HashSet集合九、LinkedHashSet集合......
  • 前端实现无服务文本文件上传和解析的完整指南
    前言在许多前端应用程序中,用户可能需要上传文本文件并对其进行解析,而无需依赖后端服务。本文将详细介绍如何在前端实现无服务的文本文件上传和解析功能,并提供一个完整的指南以及示例代码。1.文件上传1.1HTML文件上传控件在前端实现文件上传功能,我们首先需要一个文件上传控件......
  • 无涯教程-Java - static String copyValueOf(char data)函数
    此方法返回一个String,它表示指定数组中的字符序列。staticStringcopyValueOf-语法publicstaticStringcopyValueOf(char[]data)这是参数的详细信息-data  - 字符数组。staticStringcopyValueOf-返回值此方法返回一个包含字符数组字符的字符串。staticStrin......
  • Java第十一课_内部类,Object类,枚举和异常
    1.内部类一般内部类publicclassPratice{publicstaticvoidmain(String[]args){/*内部类:描述事物内部的事物;就是一个类定义在另一个类的内部当内部类定义在成员变量的位置上时,可以被成员修饰符修饰,修饰后会具备修饰......