JAVA 分片上传、断点下载场景

高效上传下载文件-后端处理模式

之前有做一个网盘,采取的是非常原始的基于File-response,文件流形式一对一传输的模式。所以导致在传输一些大文件时,无法实时看到进度,并且总会有各种意外发生:断网、手抖关掉页面等,所以总要想办法解决这个大麻烦嘛😰

文件传输

文件传输在一个简单的Client-Server中,不使用FTP连接文件服务时,由以下行程完成:

  1. 包装MultipartFile发送给Server。
  2. Server接收,包装Server侧保存文件路径
  3. MultipartFile通过IO流形式,写到Server侧
  4. 结束

如果是一张图片,一个小至100KB的文档,那么这个流程可以说是毫无破绽的。
但是作为网盘,肯定有大文件、超大文件、传一半等等这些“极端”的场景存在。
那么我们则需要根据这些个极端场景,定制出属于他们的高效场景出来。

推荐工具

前端: vue-simple-uploader

npm install --save vue-simple-uploader

后端:Redis + JDK.RandomAccessFile

场景处理

在信息传输中,如果实时的保证文件的全传输的可靠,无论是使用Redis或是数据库或是其他中间件,消耗的性能及空间在一次传输中极为庞大。
所以我们需要将这个大型文件,拆分成一个个在系统中进行消耗可以忽略不计的小文件。
这样做的好处有:

  1. 一次传输拆分成多次传输,增加了容错性。
  2. 前台展示,可以根据小文件的传输成功数目,动态变化整个文件的传输进度
  3. 小文件=分片,通过客户端与服务端定义分片规则,可以达到一次文件,多次上传,高效处理的模式。

那么跟着这三点我们可以得到常见的一个名称:分片上传emo

分片上传

在第三点我们提到:小文件 = 分片。
倘若我们有一个1G = 1024MB的文件,通过量级计算,

 1024/5 = 205

我们将一个文件,将这个文件流或二进制数组的存储模式,分成205个大小为5MB的小文件。
每个小文件都是独立的,没有任何的文件属性,仅带着原始文件被偏移的二进制数据以及文件信息。
再通过前端操作,并发的形式传给服务端。
这时候,后端接收的,至少是一份这样的数据:
:::align-center
data.png
:::
后端接收到了一份小文件后,第一时间不是进行存储,而是应该进行安全性的校验:

  1. 这个小文件是否是大文件的分片
  2. 我有没有余力IO通过,处理
  3. 小文件是否为空

而校验的核心,是基于上传文件的一个前置条件的:大文件的特征编码
聊到这,也正好可以解决秒传的一个业务场景

秒传

在一般上传功能中,在正式上传文件前,应该对本次操作进行一些列的判断或申请。
有些可以在前端完成,比如文件的类型、大小、名字等等。
但有一些只能在后端处理,比如上传用户的合法、文件服务器的可用及容量等等。
不管是哪方面的处理,对一个文件而言,都应该有一个标识码,去绑定这个文件。无论这个文件如何变化,只要文件本身内容不变,那么我们都可以通过这个标识码定位到这个文件,像文件拥有一张身份证一样。
根据这个理解,可以非常快速的画出秒传的流程。
:::align-center
jiaoy.png
:::
身份证可以是和文件信息绑定的,随机生成的UUID。
但更推荐,基于文件流通道解析出来的MD5

            MappedByteBuffer byteBuffer = in.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, file.length());
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            md5.update(byteBuffer);
            BigInteger bi = new BigInteger(1, md5.digest());
            String md5str= bi.toString(16);

当然,发现服务端有了这个文件后,是要进行拷贝呢还是啥,纯由业务控制。

合并

在确定这个文件是需要分片,并且已经接收到各类小文件后,我们需要准备的是:

  1. 设置一个唯一路径,当作所有小文件的虚拟目录
  2. 持续接收,直到发现sliceIndex = sliceAll,最后一个分片写入到虚拟目录中。
  3. 合并虚拟目录的所有文件,导出到目标文件中
  4. 清理“后事”

作为程序员,有注释的代码比文字更赏心悦目,所以我直接贴代码了,代码 = 思路

        String userId = upFileDTO.getUserId();
        //本次文件的MD5码
        String fileMD5Value = upFileDTO.getIdentifier();
        AssertUtil.isFalse(StrUtil.isBlank(fileMD5Value), ErrorEnum.FILE_UPLOAD_FILE.getName());

        //获得分片文件存储的临时目录
        String tempPath = FileUtil.resoleFileTempPath(fileMD5Value);
        AssertUtil.isFalse(StrUtil.isBlank(tempPath), ErrorEnum.FILE_UPLOAD_FILE.getName());

        //开始进行切片化上传
        File sliceFile = new File(tempPath + upFileDTO.getChunkNumber());
        //如果这个片在历史中已经完成,则跳过 双重校验
        if (!sliceFile.exists()) {
            FileOutputStream fos = null;
            InputStream inputStream = null;
            try {
                fos = new FileOutputStream(sliceFile);
                //本次上传文件
                inputStream = upFileDTO.getFile().getInputStream();
                //写入文件
                IOUtils.copy(inputStream, fos);

                //如果不是最终分片,但是是总文件的最后一个分片,则放开合并文件线程
                if (!upFileDTO.getChunkNumber().equals(upFileDTO.getTotalChunks()) &&
                        sliceFile.getParentFile().listFiles().length == upFileDTO.getTotalChunks()) {
                    //打开阻塞中的最终分片
                    LockSupport.unpark(ServerCode.threadUpload.get(fileMD5Value));
                }

                //判断本请求是否是最后的分片,如果是最后的分片则进行合并
                if (upFileDTO.getChunkNumber().equals(upFileDTO.getTotalChunks())) {

                    //如果其他分片还没到达,则进挂起
                    if (upFileDTO.getTotalChunks() != sliceFile.getParentFile().listFiles().length) {
                        fos.close();
                        inputStream.close();
                        ServerCode.threadUpload.put(fileMD5Value, Thread.currentThread());
                        LockSupport.park();
                    }
                    //合并文件
                    String filePath = this.mergeSliceFile(tempPath, upFileDTO.getFile().getOriginalFilename());

                    //保存文件信息
                    String fileId = FileInfoE.queryInstance().setFilePath(filePath).
                            setFileSize(upFileDTO.getTotalSize())
                            .setName(upFileDTO.getFilename())
                            .setSaveDt(StrUtil.isEmpty(upFileDTO.getSaveTime()) ? "永久保存" : upFileDTO.getSaveTime()).save();
                    //加载到用户文件列表上
                    FileUserE.queryInstance().setUserId(userId).setFileId(fileId).save();

                    //保存改文件的MD5码记录
                    FileMd5E.queryInstance().setFileId(fileId).setMd5Code(fileMD5Value).save();

                    //计算用户新内存
                    FileUpLogCO fileUpLogCO = FileUpLogE.queryInstance().setUserId(userId).selectOne();
                    AssertUtil.isFalse(ObjectUtil.isEmpty(fileUpLogCO), ErrorEnum.FILE_UPLOAD_FILE.getName());
                    FileUpLogE.queryInstance().setId(fileUpLogCO.getId())
                            .setUpFileTotalSize(fileUpLogCO.getUpFileTotalSize() + upFileDTO.getTotalSize()).update();

                    //上传完成,删除临时目录
                    this.deleteSliceTemp(tempPath);

                    //开启计时保存功能
                    if (StrUtil.isNotBlank(upFileDTO.getSaveTime())) {
                        cacheExe.setSaveTimeFileCache(fileId, userId, upFileDTO.getSaveTime());
                    }

                    //删除分片的记录
                    ServerCode.threadUpload.remove(fileMD5Value);
                }
            } catch (Exception e) {
            }finally {
                if (fos != null) {
                    try {
                        fos.close();
                    } catch (IOException ioException) {
                        ioException.printStackTrace();
                    }
                }
                if (inputStream != null) {
                    try {
                        inputStream.close();
                    } catch (IOException ioException) {
                        ioException.printStackTrace();
                    }
                }
            }
        }
        return DataResponse.buildSuccess();

代码:https://github.com/LeYunone/leyuna-disk/blob/master/disk-core/src/main/java/xyz/leyuna/disk/service/file/FileService.java
其中有很多逻辑是我的数据库业务,或redis业务代码,但是在网盘应用中大多都是通用的,看看也无妨。

断点续传

断点续传,适合对下载功能处理。
但是要注意,由于文件是保存在服务端的,所以断点续传是基于客户端稳定的前提下完成。
大致逻辑和分片相似:

  1. 客户端请求断点续传,将临时/虚拟目录发给服务端。
  2. 服务端接收文件,并且通过定义起始值、偏移量、总值,动态的写出文件。
  3. 实时更新/断开时返回,将起始值、偏移量、总值、文件信息保存在客户端,并且将未写完的文件暂存在客户端指定目录。

看思路,很明显,断点下载非常考验客户端这边的合法及可用的。
比如用户将半下载的文件删除,清理返回信息、时间长久等等
都会导致断点下载不稳定的出现异常。
ddxc.png

小文件处理

以上,不管是断点续传还是分片。
都是高效处理、完美体验,解决文件上传的一些难题。
但是小文件也是有一些自己的处理场景的。
在存储图片、小文档、微小文件时,我们是否可以考虑将这些文件转换成Base64编码,存在第三媒介中呢。
因为即使是再小的文件,在进行文件上传或下载时,都会有IO流:
打开输出流 - 打开输入流 - 关闭输出流 - 关闭输入流
这样频繁的操作,虽说不占用性能,但如果我们将这些极小:几十KB或者1MB,自定义小。存在redis中,是否可以更快速的读、写呢。

总结

本文提出的只是文件上传时,分片、断点的一部分场景。其实在实际生产中,遇到的问题原比网络中理论的要多。比如文件太大,最后分片等待太久,且并发量高,意味着占了一个线程资源不动,并且‘死锁’在这。还比如,文件上传的数据丢失、乱码,数据损坏等待诸多问题。
后端在这些场景中,能做的一是优化、二是更合法的规则校验、三是提前预知。所以说下载场景是需要前、后端高强度配合的业务。

版权声明:本站原创文章,于2022-05-19,乐云一发表
转载请注明:leyuna.xyz

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是Java实现文件分片上传断点续传的完整代码,包括前端页面和后端实现。 前端页面: ```html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>分片上传</title> </head> <body> <form method="post" enctype="multipart/form-data"> <input type="file" id="file" name="file"/> <br> <button type="button" onclick="upload()">上传</button> <br> <progress id="progress" max="100" value="0"></progress> </form> <script> function upload() { const file = document.getElementById('file').files[0]; const chunkSize = 1024 * 1024; // 每个分片大小为 1MB const totalChunks = Math.ceil(file.size / chunkSize); // 总共的分片数 let chunkIndex = 0; // 当前分片索引 // 上传分片 function uploadChunk(start, end) { const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('chunkIndex', chunkIndex); formData.append('file', file.slice(start, end)); xhr.open('POST', '/upload'); xhr.send(formData); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status === 200) { chunkIndex++; const progress = (chunkIndex / totalChunks) * 100; document.getElementById('progress').value = progress; if (chunkIndex < totalChunks) { uploadChunk(chunkIndex * chunkSize, (chunkIndex + 1) * chunkSize); } else { // 完成上传 document.getElementById('progress').value = 100; alert('上传完成'); } } else { alert('上传失败'); } } } } // 开始上传 uploadChunk(0, chunkSize); } </script> </body> </html> ``` 后端实现: ```java import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @Controller public class FileUploadController { @PostMapping("/upload") public void upload(@RequestParam("file") MultipartFile file, @RequestParam("chunkIndex") int chunkIndex) throws IOException { final String UPLOAD_PATH = "/path/to/upload/directory"; // 上传目录 final int CHUNK_SIZE = 1024 * 1024; // 每个分片大小为 1MB // 根据文件名和分片索引生成分片文件路径 Path filePath = Paths.get(UPLOAD_PATH, file.getOriginalFilename() + "." + chunkIndex); // 写入分片文件 Files.write(filePath, file.getBytes()); // 如果是最后一个分片,合并所有分片 if (file.getBytes().length < CHUNK_SIZE) { Path destPath = Paths.get(UPLOAD_PATH, file.getOriginalFilename()); for (int i = 0; i <= chunkIndex; i++) { Path chunkPath = Paths.get(UPLOAD_PATH, file.getOriginalFilename() + "." + i); Files.write(destPath, Files.readAllBytes(chunkPath)); Files.delete(chunkPath); } } } } ``` 注意:这只是一个简单的实现,实际应用中还需要考虑分片上传断点续传过程中的错误处理、并发控制等问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值