【文件系统】uploader优化文件校验,合并与续传(2)


(1)的问题:

  • 我们建立临时文件夹选择的是使用文件名,这显然不是最优解,因为我们的文件夹名称必须保证唯一性,使用一串唯一标识符号显然是更好的解决方法。分片的校验即完整文件的md5+编号实现分片唯一校验;完整文件的校验即直接md5实现。
  • 我们秒传中只能解决完整文件的秒传,并且并发数设置为1导致的传输效率大打折扣。应提出能够解决高并发传输(利用http的并发允许)并且能够重传非有序分片的方案。这里提出的是后端检验文件的时候,把各分片编号放入数组返回给前端,前端遍历数据寻找未传输的分片进行传输。实现完整文件秒传+高并发+分片秒传+分片重发。

优化一:md5校验

md5,一种对信息的加密,无论多长的内容都能加工成一定长度的字符串。如果文件的字节相同,也就是内容相同,则md5值一定相同。而只要有一个字符不同,md5值都可能有很大差异(一定不同)。

要实现文件校验,包括分片校验和文件校验

  • 分片校验,因为我们的临时文件夹命名为文件的md5值,所以分片会自动分配到对应的文件夹,也就实现了分片校验。
  • 完整文件校验,对于合并文件的时候,对合并好的文件在后端进行一次md5编码,与临时文件夹的文件名进行比较,如果相同则校验成功,如果不同则说明文件有损坏或者出错,直接丢弃并返回信息要求重传所有文件。
  • 续传过程需要优化,因为分片不一定有序,当我们需要续传的时候,返回的应该是已经有的分片编号的数组,而不能默认有序直接返回最新的编号。

1.1 前端

绑定钩子函数

<uploader :options="this.options" 
          @file-added="this.fileAdded" 
          @file-success="this.fileSuccess">

    <uploader-unsupport></uploader-unsupport>
    <uploader-btn class="uploader-btn">
        点击上传
    </uploader-btn>
</uploader>

file是官方组件定义的一个文件对象

  1. 官方提供一个钩子函数fileAdded,即文件被加入到上传队列的之后,我们可以对该文件进行md5的计算
  2. 计算md5首先是需要一个文件流,读取文件的内容进行md5计算
  3. 先暂停file.pause()文件上传的流程,计算完后覆盖请求参数中的identifier参数后file.resume()继续上传
  4. 下面官方是通过异步对文件进行一个文件IO与md5值的计算,通过回调函数去合并md5值结果。也就是先对文件进行分片(单片大小为那个chunkSize参数),一共分为chunks个片进行异步计算,异步能够提高计算md5的速度。也是因为是异步计算md5值,所以需要使用Promise对象,用来表示异步操作的结果。

下面代码官方有提供

GlobalUploader.vue - shady-xia/vue-uploader-solutions - GitHub1s

	methods: {
        fileSuccess() {
            //fileSuccess钩子是在所有分片上传成功的时候激活
            //我们请求一次检索当前文件夹内容的接口去更新当前的展示
            //使用this.$emit是因为当前uploader被封装成一个子组件,需要emit与父组件通信
            //父组件提供一个额外的方法给子组件访问,用于更新父组件的数据。【emit是事件通信,详细看我父子组件通信的博客】
            this.$http.post("/file/showAllFiles", {
                curUrl: this.$store.state.file.curUrl,
            }).then((res) => {
                this.$emit("uploadSuccess",res.data.list)
            });
        },
            
        fileAdded(file) {
            // 添加文件进行MD5校验,并覆盖identify参数
            this.computeMD5(file)
        },
            
        computeMD5(file) {
            //建立Reader流,通过file内容建立md5的内容
            let fileReader = new FileReader()
            let time = new Date().getTime()
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
            let currentChunk = 0
            const chunkSize = 10 * 1024 * 1000
            let chunks = Math.ceil(file.size / chunkSize)
            let spark = new SparkMD5.ArrayBuffer()
            //暂停文件上传
            file.pause()
            loadNext()
            //返回值是一个Promise对象
            //Promise对象用于表示一个异步操作的最终完成(或失败)及其结果值,是js中的一个对象
            return new Promise((resolve, reject) => {
                fileReader.onload = (e) => {
                    spark.append(e.target.result)
                    if (currentChunk < chunks) {
                        currentChunk++
                        loadNext()
                    } else {
                        let md5 = spark.end()
                        // md5计算完毕
                        this.startUpload({md5,file})
                    }
                }

                //出现error异常的时候取消上传
                fileReader.onerror = function () {
                    this.error(`文件${file.name}读取出错,请检查该文件`)
                    file.cancel()
                    reject()
                }
            })

            //方法内定义一个方法,便于区分模块
            function loadNext() {
                let start = currentChunk * chunkSize
                let end = start + chunkSize >= file.size ? file.size : start + chunkSize

                fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
            }
        },

        // md5计算完毕,开始上传
        startUpload({md5,file}) {
            //覆盖文件原来的文件标识
            file.uniqueIdentifier = md5
            //resume是表示文件继续上传
            file.resume()
        }
	}

1.2 后端

1.2.1 Controller

  • 其他不变,这里使用identifier命名文件夹
/**
     * @Author Nineee
     * @Date 2022/8/16 23:47
     * @Description : 上传文件
     * @param file: 需要上传的文件
     * @param request: 请求体获取当前对象
     * @return void
     */
    @PostMapping("/uploadFile")
    @ResponseBody
    public void uploadFile( @RequestParam("file") MultipartFile file,
                            @RequestParam("chunkNumber") Integer chunkNumber,
                            @RequestParam("totalChunks") Integer totalChunks,
                            @RequestParam("totalSize") String totalSize,
                            @RequestParam("identifier") String identifier,
                            @RequestParam("filename") String filename,
                            @RequestParam("curUrl") String curUrl,
                            HttpServletRequest request) {
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();

        String[] strs = filename.split("\\.");
        //其他不变,这里使用identifier命名文件夹
        String localUrl = store + uid + curUrl + "\\" + identifier;
        //不是最后一个分片,不需要合并
        fileService.uploadFile(file, localUrl, strs[1]+chunkNumber, false);
        if(chunkNumber == totalChunks) {
            //否则发起合并服务,merge合并然后校验md5
            fileService.uploadFile(file, localUrl, filename, true);
        }
    }

1.2.2 Service

  • md5编码,与前端的校验
@Override
    /**
     * @Author Nineee
     * @Date 2022/8/16 23:07
     * @Description : 上传文件
     * @param file: multipart二进制文件流,也就是目标文件
     * @param curUrl: 上传的目标地址
     * @return Integer 1表示成功,0表示失败
     */
    public Integer uploadFile(MultipartFile file, String localUrl, String filename, boolean merge) {
        if(!merge) {
            MultipartFileUtil.addFile(file, localUrl, filename);
        }else {
            //合并分片
            MultipartFileUtil.mergeFileByRandomAccessFile(localUrl, filename);
            //校验完整文件,localUrl是xxx/xxx/md5值,完整文件在xxx/xxx/filename
            String target = localUrl.substring(0, localUrl.lastIndexOf("\\")+1)+filename;
            //文件夹名就是前端上传的文件的md5
            String oriMd5 = localUrl.substring(localUrl.lastIndexOf("\\")+1);
            String md5 = "";
            
            //通过spring的工具类获取合并后文件的md5
            try (FileInputStream inputStream = new FileInputStream(target)) {
                md5 = DigestUtils.md5DigestAsHex(inputStream);
            } catch (IOException e) {
                e.printStackTrace();
                log.error("文件md5值计算出错!path:{}; err: {}", target, e.getMessage());
            }
            
            System.out.println("前端发送的md5:"+oriMd5);
            System.out.println("后端校验的md5:"+md5);
            if(!oriMd5.equals(md5)) {
                //如果不相等,重传
                return -1;
            }
            //合并并且也校验后删除tmp文件夹
            try {
                MultipartFileUtil.deleteDirByNio(localUrl);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return 1;
    }

1.3 演示

1.3.1 上传

在这里插入图片描述

1.3.2 自动创建临时文件夹,以md5命名

在这里插入图片描述

1.3.3 合并文件完成并校验md5值
在这里插入图片描述
1.3.4 校验成功后自动删除文件夹

在这里插入图片描述

小插曲:大文件容易出现损坏【已解决】

但是!我发现在上传小文件的时候,前后端的md5是能够对上的

而上传大文件的时候,md5值就开始对不上了
【测试图片,pdf,rar压缩文件等都可以传输】
在这里插入图片描述

经过测试,并不是文件格式问题,而是文件的大小问题。

【40M一下均没问题,90M以上就开始存在合并问题】

在这里插入图片描述

问题描述!

× 猜测一:合并时机不对。已经推翻,因为通过前端决定合并时机也会出现合并错误

猜测是分片有序性的问题,分片少的时候有序性容易保证,而分片多的时候有序性难以保证,因此(1)中的并发数为1的时候一定有序是错误的。网络带宽是不稳定的时候各个分片到达的时间也不是有序的。如果仅仅凭借最后一个分片到达就认为允许合并,这是不可行的。

√ 猜测二:后端合并代码出现问题,即分片数超过两位数的时候会出现问题,是字典序默认排序导致,即1后面是10而不是2

后端合并文件的方法中自定义比较器
保证合并的file[]有序

Arrays.sort(files, 
			(o1,o2) -> 
			Integer.parseInt(o1.getName()) -
			Integer.parseInt(o2.getName()));

在这里插入图片描述

已解决:确定是字典序问题

优化二:提高并发与优化合并时机

  • 前端的test请求发送过来,后端检验文件的时候,把各分片编号放入数组返回给前端,前端遍历数据寻找未传输的分片进行传输。实现完整文件秒传+高并发+分片秒传+分片重发。
  • 合并时机应该由前端决定,因为前端能通过响应码来知道是否所有分片都上传成功
  • 做完上述后,把前端并发设置为3试试结果是否会被影响

2.1 优化合并时机

2.1.1 前端

uploader绑定fileSuccess事件

<uploader :options="this.options" @file-added="this.fileAdded"
    @file-error="this.fileError" @file-success="this.fileSuccess" >

在fileSuccess事件方法中发起请求,对于参数有不懂的,console.log打印出来就知道有什么内容了

这个事件是在所有分片都成功上传【判断标准是后端返回200状态码】后触发。

fileSuccess(rootFile, file, chunk) {
    //这是所有分片都上传成功的钩子,发送合并请求
    //通过输出,得到chunk为最后一个分片实例
    //file为完整文件实例,和rootFile好像
    //打印file去找变量
    this.$http.post("/file/mergeFile", {
        //传送我们需要的参数即可
        curUrl: this.$store.state.file.curUrl,
        identifier: file.uniqueIdentifier,
        filename: file.name,
    }).then((res) => {
        //更新数据
        this.$emit("uploadSuccess", res.data.list)
    });
},

2.1.2 后端

增加一个Controller层的接口就好,service不需要改变。同时其他两个相关接口的代码逻辑有一点改变

  • 删除后端遇到最后一个分片编号的时候进行合并的代码,uploadFile接口不进行合并
  • 前端发起mergeFile请求后,进行合并,同时返回列表文件,省去一次对showAllFiles的访问
/**
     * @Author Nineee
     * @Date 2022/9/22 13:07
     * @Description : 用于test快传 秒传 和 续传 的接口
     * @param chunkNumber: 分片编号
     * @param totalChunks: 总分片数
     * @param totalSize: 总大小
     * @param filename: 文件名
     * @param curUrl: 当前位置
     * @return Map
     */
    @GetMapping("/uploadFile")
    @ResponseBody
    public Map uploadFile( @RequestParam("chunkNumber") String chunkNumber,
                           @RequestParam("totalChunks") String totalChunks,
                           @RequestParam("totalSize") String totalSize,
                           @RequestParam("identifier") String identifier,
                           @RequestParam("filename") String filename,
                           @RequestParam("curUrl") String curUrl,
                           HttpServletRequest request, HttpServletResponse response) {
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();
        Map map = new HashMap();

        boolean isTotalFileExist = Files.exists(Paths.get(store + uid + curUrl + "\\" + filename));
        if(isTotalFileExist) {
            //存在文件,秒传文件
            map.put("skipUpload", true);
        }else {
            //未存在完整文件
            map.put("skipUpload", false);
            String[] strs = filename.split("\\.");
            String localUrl = store + uid + curUrl + "\\" + identifier +"\\";
            long count = fileService.findShards(localUrl);
            map.put("position", count);
        }
        return map;
    }

    /**
     * @Author Nineee
     * @Date 2022/8/16 23:47
     * @Description : 上传文件
     * @param file: 需要上传的文件
     * @param request: 请求体获取当前对象
     * @return void
     */
    @PostMapping("/uploadFile")
    @ResponseBody
    public void uploadFile( @RequestParam("file") MultipartFile file,
                            @RequestParam("chunkNumber") Integer chunkNumber,
                            @RequestParam("totalChunks") Integer totalChunks,
                            @RequestParam("totalSize") String totalSize,
                            @RequestParam("identifier") String identifier,
                            @RequestParam("filename") String filename,
                            @RequestParam("curUrl") String curUrl,
                            HttpServletRequest request) {
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();

        //对于localUrl,如果不是末尾分片,我们应该加上一个tmp文件夹避免文件混乱。
        //只有发起合并请求的时候再合并到源路径后删除tmp文件夹。
        //注意,.需要转义
        String[] strs = filename.split("\\.");
        String localUrl = store + uid + curUrl + "\\" + identifier;
        //不是最后一个分片,不需要合并
        fileService.uploadFile(file, localUrl, ""+chunkNumber, false);
    }

    /**
     * @Author Nineee
     * @Date 2022/9/24 21:45
     * @Description : 合并文件分片的请求
     * @param params:
     * @param request:
     * @return Map
     */
    @PostMapping("/mergeFile")
    @ResponseBody
    public Map mergeFile(@RequestBody Map<String, String> params,
                         HttpServletRequest request) {
        String curUrl = params.get("curUrl");
        String identifier = params.get("identifier");
        String filename = params.get("filename");
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();

        //对于localUrl,如果不是末尾分片,我们应该加上一个tmp文件夹避免文件混乱。
        //只有发起合并请求的时候再合并到源路径后删除tmp文件夹。
        //注意,.需要转义
        String[] strs = filename.split("\\.");
        String localUrl = store + uid + curUrl + "\\" + identifier;
        int res = fileService.uploadFile(null, localUrl, filename, true);

        List<FileInfo> list = fileService.showAllFiles(store + uid + curUrl);
        Map map = new HashMap();
        map.put("list", list);
        map.put("user", user);
        return map;
    }

2.1.3 效果

前端决定合并时机

在这里插入图片描述
后端文件合并正常
在这里插入图片描述

2.2 提高并发

2.2.1 直接在上传器中设置

//options中设置
simultaneousUploads: 3,

2.2.2 效果

一次性三个进行,可以看到还没有状态码。
在这里插入图片描述

然后最后文件也是正常进行合并
在这里插入图片描述

优化三:续传优化

只需要优化test接口即可。

  • test接口去检查所有分片的编号并放入一个int[]里返回

3.1 前端

  • 每次传送分片都会进入该函数,因为test接口返回的skipUpload是false
  • 如果uploaded数组里能找到当前分片的编号【+1是因为编号是从1开始的】,说明不需要传了,也就是return true。
  • 如果在test请求返回的uploaded已上传数组中找不到当前分片编号,则返回false,即需要传送当前分片到后端。
checkChunkUploadedByResponse: function (chunk, message) {
    let objMessage = JSON.parse(message);
    if (objMessage.skipUpload) {
        return true;
    }

    return (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0
},

3.2 后端

Controller

/**
     * @Author Nineee
     * @Date 2022/9/22 13:07
     * @Description : 用于test快传 秒传 和 续传 的接口
     * @param filename: 文件名
     * @param curUrl: 当前位置
     * @return Map
     */
    @GetMapping("/uploadFile")
    @ResponseBody
    public Map uploadFile( @RequestParam("identifier") String identifier,
                           @RequestParam("filename") String filename,
                           @RequestParam("curUrl") String curUrl,
                           HttpServletRequest request) {
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();
        Map map = new HashMap();

        boolean isTotalFileExist = Files.exists(Paths.get(store + uid + curUrl + "\\" + filename));
        if(isTotalFileExist) {
            //存在文件,秒传文件
            map.put("skipUpload", true);
        }else {
            //未存在完整文件
            map.put("skipUpload", false);
            String[] strs = filename.split("\\.");
            String localUrl = store + uid + curUrl + "\\" + identifier +"\\";
            
            //多了这里,获取分片数组
            int[] uploaded = fileService.findShards(localUrl);
            map.put("uploaded", uploaded);
        }
        return map;
    }

Service

  • 如果连文件夹都还需要创建,说明还没开始上传
  • 如果有文件夹了,就获取list的所有文件名,即编号
/**
     * @Author Nineee
     * @Date 2022/9/22 16:41
     * @Description : 找到已有的分片编号
     * @param localUrl:  临时文件夹位置
     * @return int[]
     */
    @Override
    public int[] findShards(String localUrl) {

        File tempDir = new File(localUrl);
        if (!tempDir.exists()) {
            tempDir.mkdirs();
            return new int[]{};
        }
        //应该检查目前到第几个分片,默认分片是有序的
        int[] uploaded = new int[]{};
        try {
            File[] files = tempDir.listFiles();
            uploaded = new int[files.length];
            for(int i = 0; i < files.length; ++i) {
                uploaded[i] = Integer.parseInt(files[i].getName());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return uploaded;
    }

3.3 演示

对于没传送过的文件
在这里插入图片描述
对于有部分分片的文件

  • 只有7个无序分片【通过刷新网页中断上传】
    在这里插入图片描述

  • 获取到7个分片编号
    在这里插入图片描述

  • 成功合并
    在这里插入图片描述

  • 再看这个位置,重传的时候,16个分片,只进行了9次请求,说明之前7个分片没有重新上传,而是实现了续传。

在这里插入图片描述

总结与预告

已优化

  1. md5唯一标识文件,校验分片所属。并且对合并好的文件重新进行md5编码与校验确定文件完整
  2. 合并请求时机由前端决定【使用回调函数】,保证分片全部上传成功再告知后端合并。
  3. 并发数可以提高为3或者自定义,传输过程不需要保证有序性
  4. 解决了后端合并文件的时候分片有序性,其依赖对File[]数组的重排序,避免字典序排序【即1后面是10而不是2】
  5. 优化了test接口,test接口返回是否存在完整文件与已存在的分片编号,前端根据后端返回的uploaded数组判断当前分片是否需要上传,实现可靠和高效的续传。减少重复分片的上传。

未实现:

  1. 保存用户的断点(暂停,或者网络问题导致的断点)
  2. 实现文件上传列表展示,上传进度展示与上传控制(暂停与开始)
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

玖等了

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值