0


JAVA面试题分享五百一十一:Spring Boot基于WebUploader实现超大文件上传和断点续传

前言

在软件工程里,在处理“大”的时候一直是一个痛点和难点,如并发大、数据量大、文件大,对硬件进行升级可以解决一些问题,但这并不最聪明的办法,而对于老板来说,这也不是成本最小的办法。作为开发人员来说,在面对类似极端的问题时,只可智取,不可硬刚,最大化利用好手上现有的资源,以更加优雅的办法来满足用户多样化的需求才是王道。今天的主题也是一个“大”的问题,就是超大文件如何上传和下载?其实在解决这个问题之前,有一个问题是绕不过去的:什么才是大文件?什么是超大文件?几十兆?几百兆?还是几个G?这其实是一个极具争议的问题,在不同的业务场景下,对于大文件的“大”的理解和定义可能是不同的。但这并不是本文的重点,我想和大家分享的是,面对所谓的大文件时,如何优雅实现上传。

不管多简单的需求,在量级达到一定程度时,就会变得异常复杂。所以这篇文章会有点长,有两部分组成,如果觉得一步一步看太累,想直接看结果,文章的最后面附上了前后端的两个关键文件,可以直接拿到编辑里边调试边一步一步看:

文章示例环境配置信息

jdk版本:1.8

开发工具:Intellij iDEA 2020.1

springboot:2.3.9.RELEASE

目标

实现超大文件的上传和断点续传。

实现思路

对于各种“大”的问题,很多的场景采用是分而治之的理念进行设计,而对于大文件上传来说,从这一理念出发,具体的方法就是把大文件分片上传,分片全部上传后再对分片进行合并。那什么是大文件分片呢?什么又是合并分片呢?如果分片上传过程中,个别分片上传失败了,需要重新分片上传吗?其实文件上传本身是一个很简单的需求,当量级达到一定程度时,就变得异常复杂了,需要考虑的问题点很多,同理其他“大”的问题也是类似的。

大文件分片

大文件分片是将要上传的大文件,按照一定的规则,将整个文件进行切片,即把一个大的数据文件切分成小的数据块,然后再按照一定的策略(串行或并发)进行上传;

合并分片

大文件分片合并是大文件分片上传的后续操作,当一个大文件被切分成小的数据块上传到服务器时,需要把这些小的数据块按照原来的顺序再进行合并,还原成原来的大文件,这就是合并分片;

文件分片和分片合并其实很好理解,类似于现实生活中,如果想把一架大飞机藏在洞口比较小的山洞里,要怎么做呢?肯定是先把飞机拆成小的零件,然后运输到山洞里面,再根据飞机的装配图纸把所有的零件再装好。原理就这么简单。

断点续传

大文件分片上传的时候,个别分片有可能会碰到网络故障,而导致一些分片文件上传失败,如果网络恢复后,可以从上传失败的分片开始上传而直接跳过上传成功的分片文件部分,那就可以节约时间,提高上传效率,这就是断点续传;如下图:一个大文件被切分成了n个分片,分片上传过程中,分片2和分片3因为某种原因上传失败了,再次上传的时候,会直接跳过所有上传成功的分片,直接从失败的分片2和分片3开始上传,合并分片的时候检测到所有的分片都完成了上传,就可以合并还原成大文件本身的面目了;

实现思路很简单,实现原理其实也很简单,具体如下:

1、前端使用百度的WebUploader组件,选中待上传的大文件,然后计算出文件的md5值;

2、WebUploader组件开启分片上传后,选中的待上传文件会按照配置好的分片规则进行分片,分片文件上传前会计算出分片文件的md5值;

3、通过webuploader组件api计算出分片的md5值后,会携带文件md5值和分片文件的md5值,调用后台接口检验当前分片是否已经上传过;若上传过,则直接跳过;若未上传过,则开始分片上传;

4、前端往后端传输分片文件的过程可以是并发执行,这里一定注意传递到后台的分片并不一定是按照分片的顺序来的,后端收到分片文件后,会保存分片文件到硬盘、网盘等存储介质上,同时也要保存分片文件md5值、文件md5值等分片参数信息;

5、待所有的分片上传成功后,会触发WebUploader的uploadSuccess事件,然后在这里再发起合并分片请求;

6、后端收到合并分片的请求后,再次检查所有的分片是否上传完整,若上传完整,则开始合并所有分片;

7、这里要特别注意,合并分片的时候,一定要按钮分片时的切割顺序来合并,否则文件就会打不开或运行不了;合并完所有分片文件后,分片文件就没有用了,可以删除了;

md5消息摘要算法,属Hash算法一类,主要特点是不可逆,相同数据的md5值肯定一样,不同数据的md5值不一样;对于数据文件,不管文件名字是否相同,如果数据文件内容相同,则文件的md值是相同的;webuploader组件提供了文件的md5值的计算方法,其计算过程是异步的;

代码实现

大文件分片上传的实现原理,逻辑比较清晰,那么落地到代码实现上,还有几个问题需要解决:

1、webuploader组件中,分片上传怎么开启?

2、webuploader组件中,文件的md5值如何计算?

3、webuploader组件中,分片文件的md5值如何计算?

4、webuploader组件中,分片上传的的请求在哪里触发?

5、前端、后端如何校验分片是否已经上传?

6、后端如何处理分片上传请求?

7、webuploader组件中,合并文件分片的请求在哪里触发?

8、后端如何合并分片请求?

9、分片上传失败后,如何在断点处继续上传?

10、上传的进度条是怎么实现的?

1、webuploader组件中,分片上传怎么开启?

webuploader的分片上传开启实际上很简单,在创建webuploader对象时,设置chunked为true,即表示开启分片上传;chunkSize可以设置分片大小,即以多大的体积进行分片;chunkRetry可以设置重传次数,有的时候由于网络原因,分片上传的会失败,这里即是失败允许重的次数;threads可以设置允许最大由几个进程发起上传请求;

uploader = WebUploader.create({
    // swf文件路径
    swf: 'http://localhost:8080/lib/Uploader.swf',
    // 分片文件上传接口
    server: 'http://localhost:8080/file/upload',
    // 选择文件的按钮。可选。
    pick: '#picker',
    fileVal: 'multipartFile',//后端用来接收上传文件的参数名称
    chunked: true,//开启分片上传
    chunkSize: 1024 * 1024 * 10,//设置分片大小
    chunkRetry: 2,//设置重传次数,有的时候由于网络原因,分片上传的会失败,这里即是失败允许重的次数
    threads: 3//允许同时最大上传进程数
});

2、webuploader组件中,文件的md5值如何计算?

文件的md5计算可以引用spark-md5.js,据传言是javascript里md5加密计算速度最快的,当然在webuploader.js里也有具体的api可以使用;引入webuploader.js后,调用 WebUploader.Uploader.md5File(...)即可计算文件的md5值,这里需要注意的是md5File(...),有三个参数,分别是file,数据起始位置、数据结束位置,返回的是一个promise对象,要想拿到具体的值还要再调用then(function(val){}),具体步骤如下:

1、当添加完文件后,webuploader的fileQueued事件被触发;

2、fileQueued事件触发后,在回调函数里计算出文件的的md5值,这里注意是要计算出整个文件的md5值,而不是一部分,关键就在md5File(...)方法的后两个参数数据起始位置和数据结束位置,看到很多人实际上是用错了,只计算了文件一部分的md5值,并不是整个文件的md5值;md5的计算过程是异步操作,并且文件越大,计算用时就越长;

3、这里还用到了deferred,deferred的作用就是监控异步计算文件md5值这个异步操作的执行状态;文件md5值计算完成后,更新状态为已完成,这时 deferred.done()会触发;更新md5计算标志位为true,这时再点击开始上传按钮时,就会再有弹窗提示:md5计算中...,请稍侯;

webuploader内部有很多种command,其中有一个叫before-send-file,也可以在文件上传前会触发,此时还没有开始分片,可以用来做文件整体的md5计算;需要注意的是before-send-file的触发时机是要晚于fileQueued事件的;before-send-file是在点击开始上传按钮执行uploader.upload()后才会触发;而fileQueued事件是在选择文件后,立刻触发,不用等到点击开始上传按钮;所以具体在哪里进行计算,可根据实际业务酌情选择;

/**
 * 当有文件被添加进队列后触发
 * 主要逻辑:1、文件被添加到队列后,开始计算文件的md5值;
 * 2、md5的计算过程是异步操作,并且文件越大,计算用时越长;
 * 3、变量md5FlagMap是文件md5值计算的标志位,计算完成后,设置当前文件的md5Flag为true
 */
 //md5FlagMap用于存储文件md5计算完成的标志位;多个文件时,分别设置标志位,key是文件名,value是true或false;
var md5FlagMap = new Map();
uploader.on('fileQueued', function (file) {
    md5FlagMap.set(file.name, false);//文件md5值计算的标志位默认为false
    var deferred = WebUploader.Deferred();//deferred用于监控异步计算文件md5值这个异步操作的执行状态
    uploader.md5File(file, 0, file.size - 1).then(function (fileMd5) {
        file.wholeMd5 = fileMd5;
        file_md5 = fileMd5;
        deferred.resolve(file.name);//文件md5值计算完成后,更新状态为已完成,这时 deferred.done()会触发
    })
    //文件越大,文件的md5值计算用时越长,因此md5的计算搞成异步执行是合理的;如果异步执行比较慢的话,会顺序执行到这里
    $('#thelist').append('<div id="' + file.id + '" class="item">' +
        '<h4 class="info">' + file.name + '</h4>' +
        '<p class="state">开始计算大文件的md5......<br/></p>' +
        '</div>')
    //文件的md5计算完成,会触发这里的回调函数,
    deferred.done(function (name) {
        md5FlagMap.set(name, true);//更新md5计算标志位为true
        $('#' + file.id).find('p.state').append('大文件的md5计算完成<br/>');
    })
    return deferred.promise();
})

3、webuploader组件中,分片文件的md5值如何计算?

webuploader对象中配置好相关的开启分片设置参数后,当有文件被选中添加后,webuploader会帮你对文件按配置参数进行分片;在分片文件发送到后台之前,webuploader内部另一个command(before-send)会触发,before-send的触发时机在分片上传之前,可以用作在分片发送到后端之前计算出分片文件的md5,调用后台接口做分片是否已经上传的验证:如果分片已经上传成功了,直接跳过,不会再调用分片的上传接口;如果分片未上传,则会把分片的md5值赋给分片block上,webuploader的另一个事件‘uploadBeforeSend’在触发的时候,其回调函数的第一个参数中就可以拿到分片的md5值,然后传递到后台,如果下次再上传时分片时,则可以用于是否已经上传的校验;

WebUploader.Uploader.register({
    "add-file": "addFile",
    "before-send-file": "beforeSendFile",
    "before-send": "beforeSend",
    "after-send-file": "afterSendFile"
}, {
    addFile: function (file) {
        console.log('1', file)
    },
    beforeSendFile: function (file) {
        console.log('2', file)
    },
    beforeSend: function (block) {
        console.log(3)
        var file = block.file;
        var deferred = WebUploader.Base.Deferred();
        (new WebUploader.Uploader()).md5File(file, block.start, block.end).then(function (value) {
            $.ajax({
                url: 'http://localhost:8080/file/check',//检查当前分片是否已经上传
                method: 'post',
                data: {chunkMd5: value, fileMd5: file_md5,chunk:block.chunk},
                success: function (res) {
                    if (res) {
                        deferred.reject();
                    } else {
                        deferred.resolve(value);
                    }
                }
            });
        })
        deferred.done(function (value) {
            console.log('分片md5:', value)
            block.chunkMd5 = value;
        })
        return deferred;
    },
    afterSendFile: function (file) {
        console.log('4', file)
    }
})

4、webuploader组件中,分片上传的的请求在哪里触发?

当选择文件后,就开始计算整体文件的md5值了,未计算完成前,点击开始上传按钮,会直接弹出“md5计算中...,请稍侯”;考虑到多文件上传的情况,这里使用了map对象md5FlagMap来存储每个文件的md5是否计算完成标志,key是文件名称,value是true或false,表示文件md5文件是否计算完成;

如果不想用按钮来触发上传,WebUploader有一个参数是auto,默认是false,可以改为true,选中文件后自动开始上传;

//开始上传按钮被点击时触发
$('#ctlBtn').click(function () {
    //md5FlagMap存储有文件md5计算的标志位;   // 同时上传多个文件时,上传前要判断一下已添加文件的md5是否计算完成,
    // 如果有未计算完成的,则继续等待计算结果;   //文件上传标志位,如果多个文件有一个没有完成md5计算则不能开始上传;   //这里在实际业务中可以更换成其他交互样式,酌情优化为哪个文件的md5计算完成,则开始哪个文件的上传;   var uploadFloag = true;
    md5FlagMap.forEach(function (value, key) {
        if (!value) {
            uploadFloag = false;
            alert('md5计算中...,请稍侯')//文件md5计算未完成,会弹出弹窗提示;       }
    })
    if (uploadFloag) {
        uploader.upload();//文件md5计算完成后,开始分片上传;   }
})

添加的文件的md5计算完成后,再次点击开始上传按钮,调用 uploader.upload(),开始分片上传;但是在实际开始上传前,webuploader的command(before-send)和uploadBeforeSend事件会先后触发,这两个地方有一个共同作用就是在分片正式上传前可以再做点事情,根据官方文档介绍,不同的是before-send可以用作分片是否已上传的验证(详见第3个问题里);uploadBeforeSend事件的作用是在分片上传前添加一些附带参数到后端;

// 分片模式下,当文件的分块在发送前触发
uploader.on('uploadBeforeSend', function (block, data) {
 var file = block.file;
 //data可以携带参数到后端
 data.originalFilename = file.originalFilename;//文件名字
 data.md5Value = file.wholeMd5;//文件整体的md5值
 data.start = block.start;//分片数据块在整体文件的开始位置
 data.end = block.end;//分片数据块在整体文件的结束位置
 data.chunk = block.chunk;//分片的索引位置
 data.chunks = block.chunks;//整体文件总共分了多少征
 data.chunkMd5 = block.chunkMd5;//分片文件md5值
});

5、前端、后端如何校验分片是否已经上传?

在webuploader内部的一个command(before-send)已经完成了分片文件的md5计算以及请求后台接口来校验当前分片文件是否已经上传(参见第3个问题),如果已上传,那么会直接跳过当前分片上传接口的调用,uploadBeforeSend事件也不会再触发(当某个分片文件在发送前触发,主要用来询问是否要添加附带参数,大文件在开起分片上传的前提下此事件可能会触发多次);

如果未上传,则uploadBeforeSend事件会触发,携带一些分片的参数信息发起分片上传请求;

那么后端是如何检验分片是否上传呢?如下:

1、在分片文件上传接口中,分片上传成功后,会保存分片的相关信息,如:分片文件md5、文件md5、文件大小、分片存储位置、分片数据块的起始结束位置、总共分片数量等,这里使用redis缓存了这些分片信息,redis用到了hash数据结构,其中key为文件的md5,hashkey是“chunk_md5_”+分片索引,value就是分片文件的md5值;(当然也可以使用数据库或其他存储介质,)

2、接口被调用的时候,根据前端传过来的当前分片的索引位置取出分片的md5与前端传过来的分片文件md5进行比较,如果相同,则说明当前分片已经上传成功;如果不相同,则说明未上传过;

@PostMapping("/check")
public boolean check(String fileMd5,String chunk,String chunkMd5) {
    Object o = redisTemplate.opsForHash().get(fileMd5, "chunk_md5_"+chunk);
    if (chunkMd5.equals(o)) {
        return true;
    }
    return false;
}

6、后端如何处理分片上传请求?

后端在处理分片上传时主要做了两件事:

第一,把分片文件保存在磁盘上或其他的网络存储介质上,这里需要注意一下分片文件的命名规则,尽量有规律一些,方便后面合并分片;这里分片文件的命名规则是:分片md5值+分片索引位置;

第二、保存分片相关的信息,在实际业务开发中可以考虑保存在缓存或数据库里,这里只是作了缓存;缓存的数据结构是hash,key是文件整体的md5值,hashKey与hashValue对应关系如下:

 /**
     * 分片上传接口
     *
     * @param request
     * @param multipartFile
     * @return
     * @throws IOException
     */
    @PostMapping("/upload")
    public String upload(HttpServletRequest request, MultipartFile multipartFile) {
        log.info("分片上传....");
        Map<String, String> requestParam = this.doRequestParam(request);
        String md5Value = requestParam.get("md5Value");//整体文件的md5值
        String chunkIndex = requestParam.get("chunk");//分片在所有分片文件中索引位置
        String start = requestParam.get("start");//当前分片在整个数据文件中的开始位置
        String end = requestParam.get("end");//当前分片在整个数据文件中的结束位置
        String chunks = requestParam.get("chunks");//整体文件总共被分了多少片
        String fileSize = requestParam.get("size");//整体文件大小
        String chunkMd5 = requestParam.get("chunkMd5");//分片文件的md5值
        String userDir = System.getProperty("user.dir");
        String chunkFilePath = userDir + File.separator + chunkMd5 + "_" + chunkIndex;
        File file = new File(chunkFilePath);
        try {
            multipartFile.transferTo(file);
            Map<String, String> map = new HashMap<>();
            map.put("chunk_location_" + chunkIndex, chunkFilePath);//分片存储路径
            map.put("chunk_start_end_" + chunkIndex, start + "_" + end);
            map.put("file_size", fileSize);
            map.put("file_chunks", chunks);
            map.put("chunk_md5_" + chunkIndex, chunkMd5);
            redisTemplate.opsForHash().putAll(md5Value, map);
        } catch (IOException e) {
            e.printStackTrace();
        } catch (IllegalStateException e) {
            e.printStackTrace();
        }
        return "success";
    }

7、webuploader组件中,合并文件分片的请求在哪里触发?

webuploader组件中,有一对事件分别是uploadSuccess和uploadError,当文件上传成功时,uploadSuccess触发;当文件上传失败时,uploadError触发;因此uploadSuccess事件刚好可以用来,向后台发起合并分片文件的请求;

//当文件上传成功时触发
uploader.on('uploadSuccess', function (file) {
    //大文件的所有分片上传成功后,请求后端对分片进行合并
    $.ajax({
        url: 'http://localhost:8080/file/merge',
        method: 'post',
        data: {'md5Value': file.wholeMd5, 'originalFilename': file.name},
        success: function (res) {
            alert('大文件上传成功!')
        }
    })
    $('#' + file.id).find('p.state').append('文件上传成功<br/>');
});

8、后端如何合并分片请求?

当所有的分片文件上传成功时会触发webuploader的uploadSuccess事件触发时机,然后调用后台的合并分片文件接口,合并分片文件接口的主要业务逻辑:

1、检验一下所有的分片是否全部上传完成(当分片上传成功时,会把分片md5值和文件整体总共分了多少片存储在redis里,存储时的hashKey是"chunk_md5_"+分片索引位置和file_chunks,如果存储的分片md5的数量与文件整体分片的数量一致,则表示所有的分片均已上传);

/**
 * 合并分片前检验文件整体的所有分片是否全部上传
 *
 * @param key
 * @return
 */
private boolean checkBeforeMerge(String key) {
    Map map = redisTemplate.opsForHash().entries(key);
    Object file_chunks = map.get("file_chunks");
    int i = 0;
    for (Object hashKey : map.keySet()) {
        if (hashKey.toString().startsWith("chunk_md5_")) {
            ++i;
        }
    }
    if (Integer.valueOf(file_chunks.toString())==(i)) {
        return true;
    }
    return false;
}

2、如果当前文件文件已经上传过,只是名字不同,那么md5值是相同的,直接拿出已经上传的文件按现在名字再复制一份;

3、在开始合并分片文件前,要先从redis中取出分片文件的存储位置,这里要特别注意一下,分片合并的顺序一定与索引位置的升序一致,否则合并的文件是无法打开或运行的;因为分片上传的过程是并发执行的,到达后端的顺序可能每次都不一样,但是各分片的索引位置不会变;因此可以在[0,文件分片总数量-1]之间遍历,从redis中依次取出分片文件的存储路径,并依次写入到一个新的文件里;

4、各个分片文件依次写入完成后,关闭输入流、输出流,并删除分片文件(分片合并成完整文件的时候,分片文件就没有用了,另外缓存的分片其他相关信息也没有用了,也可以删除了,当然在实际业务开发中,可根据具体的需求酌情保留);

/**
 * 合并分片文件接口
 *
 * @param request
 * @return
 * @throws IOException
 */
@PostMapping("/merge")
public String merge(HttpServletRequest request) throws IOException {
    log.info("合并分片...");
    Map<String, String> requestParam = this.doRequestParam(request);
    String md5Value = requestParam.get("md5Value");
    String originalFilename = requestParam.get("originalFilename");
    //校验切片是否己经上传完毕
    boolean flag = this.checkBeforeMerge(md5Value);
    if (!flag) {
        return "切片未完全上传";
    }
    //检查是否已经有相同md5值的文件上传;主要是对名字不同,而实际文件相同的文件,直接对原文件进行复制;
    Object file_location = redisTemplate.opsForHash().get(md5Value, "file_location");
    if (file_location != null) {
        String source = file_location.toString();
        File file = new File(source);
        if (!file.getName().equals(originalFilename)) {
            File target = new File(System.getProperty("user.dir") + File.separator + originalFilename);
            Files.copy(file.toPath(), target.toPath());
            return "success";
        }

    }
    //这里要特别注意,合并分片的时候一定要按照分片的索引顺序进行合并,否则文件无法使用;
    Integer file_chunks = Integer.valueOf(redisTemplate.opsForHash().get(md5Value, "file_chunks").toString());
    String userDir = System.getProperty("user.dir");
    File writeFile = new File(userDir + File.separator + originalFilename);
    OutputStream outputStream = new FileOutputStream(writeFile);
    InputStream inputStream = null;
    for (int i = 0; i < file_chunks; i++) {
        String tmpPath = redisTemplate.opsForHash().get(md5Value,"chunk_location_" + i).toString();
        File readFile = new File(tmpPath);
        inputStream = new FileInputStream(readFile);
        byte[] bytes = new byte[1024 * 1024];
        while ((inputStream.read(bytes) != -1)) {
            outputStream.write(bytes);
        }
        if (inputStream != null) {
            inputStream.close();
        }
    }
    if (outputStream != null) {
        outputStream.close();
    }
    redisTemplate.opsForHash().put(md5Value, "file_location", userDir + File.separator + originalFilename);
    this.delTmpFile(md5Value);
    return "success";
}
private void delTmpFile(String md5Value) throws JsonProcessingException {
    Map map = redisTemplate.opsForHash().entries(md5Value);
    List<String> list = new ArrayList<>();
    for (Object hashKey : map.keySet()) {
        if (hashKey.toString().startsWith("chunk_location")) {
            String filePath = map.get(hashKey).toString();
            File file = new File(filePath);
            boolean flag = file.delete();
            list.add(hashKey.toString());
            log.info("delete:" + filePath + ",:" + flag);
        }
        if (hashKey.toString().startsWith("chunk_start_end_")) {
            list.add(hashKey.toString());
        }
        if (hashKey.toString().startsWith("chunk_md5_")) {
            list.add(hashKey.toString());
        }
    }
    list.add("file_chunks");
    list.add("file_size");
    redisTemplate.opsForHash().delete(md5Value, list.toArray());
}

9、分片上传失败后,如何在断点处继续上传?

在第3个问题、第5个问题中,已经解决了这个问题,webuploader内部一个command(before-send)会触发,这时计算分片文件的md5值,并携带分片文件的md5值调用后台的校验接口;如果已上传,那么会直接跳过当前分片上传接口的调用;如果未上传,则会只上传未上传的的那个分片文件;

10、上传的进度条是怎么实现的?

webuploader的uploadProgress事件在上传过程中触发,会携带上传进度参数;

// 文件上传过程中创建进度条实时显示
uploader.on('uploadProgress', function (file, percentage) {
    var $li = $('#' + file.id),
        $percent = $li.find('.progress .progress-bar');
    if (!$percent.length) {
        $percent = $('<div class="progress progress-striped active">' +
            '<div class="progress-bar" role="progressbar" style="width: 0%">' +
            '</div>' +
            '</div>').appendTo($li).find('.progress-bar');
    }
    $percent.css('width', percentage * 100 + '%');
});

总结

1、对于后端来说,大部分时候写的程序都是同步顺序执行的,但前端的异步执行很常见,通过这篇文章又重新学习了promise、deferred的使用;

2、不要以为看懂了一篇文章,就真的懂了,纸上得来终觉浅,绝知此事须躬行,还是得上手自己验证一翻,别人说的未必是对的,或者说在作者当时的场景下是对的,如何确定你的场景和他的是否相同?所以小编这里希望,大家多提问题,共同讨论,共同进步。

下面附上所有完整的的示例文件以供小伙伴们参考:

FileController.java

@RestController
@RequestMapping("/file")
@Slf4j
public class FileController {
    @Resource
    private RedisTemplate redisTemplate;
    /**
     * 检验分片文件是否已经上传过
     *
     * @param fileMd5  整体文件md5值
     * @param chunk    当前上传分片在所有分片文件中索引位置
     * @param chunkMd5 分片文件的md5值
     * @return
     */
    @PostMapping("/check")
    public boolean check(String fileMd5, String chunk, String chunkMd5) {
        Object o = redisTemplate.opsForHash().get(fileMd5, "chunk_md5_" + chunk);
        if (chunkMd5.equals(o)) {
            return true;
        }
        return false;
    }

    /**
     * 分片上传接口
     *
     * @param request
     * @param multipartFile
     * @return
     * @throws IOException
     */
    @PostMapping("/upload")
    public String upload(HttpServletRequest request, MultipartFile multipartFile) {
        log.info("分片上传....");
        Map<String, String> requestParam = this.doRequestParam(request);
        String md5Value = requestParam.get("md5Value");//整体文件的md5值
        String chunkIndex = requestParam.get("chunk");//分片在所有分片文件中索引位置
        String start = requestParam.get("start");//当前分片在整个数据文件中的开始位置
        String end = requestParam.get("end");//当前分片在整个数据文件中的结束位置
        String chunks = requestParam.get("chunks");//整体文件总共被分了多少片
        String fileSize = requestParam.get("size");//整体文件大小
        String chunkMd5 = requestParam.get("chunkMd5");//分片文件的md5值
        String userDir = System.getProperty("user.dir");
        String chunkFilePath = userDir + File.separator + chunkMd5 + "_" + chunkIndex;
        File file = new File(chunkFilePath);
        try {
            multipartFile.transferTo(file);
            Map<String, String> map = new HashMap<>();
            map.put("chunk_location_" + chunkIndex, chunkFilePath);//分片存储路径
            map.put("chunk_start_end_" + chunkIndex, start + "_" + end);
            map.put("file_size", fileSize);
            map.put("file_chunks", chunks);
            map.put("chunk_md5_" + chunkIndex, chunkMd5);
            redisTemplate.opsForHash().putAll(md5Value, map);
        } catch (IOException e) {
            e.printStackTrace();
        } catch (IllegalStateException e) {
            e.printStackTrace();
        }

        return "success";
    }

    /**
     * 合并分片文件接口
     *
     * @param request
     * @return
     * @throws IOException
     */
    @PostMapping("/merge")
    public String merge(HttpServletRequest request) throws IOException {
        log.info("合并分片...");
        Map<String, String> requestParam = this.doRequestParam(request);
        String md5Value = requestParam.get("md5Value");
        String originalFilename = requestParam.get("originalFilename");
        //校验切片是否己经上传完毕
        boolean flag = this.checkBeforeMerge(md5Value);
        if (!flag) {
            return "切片未完全上传";
        }
        //检查是否已经有相同md5值的文件上传;主要是对名字不同,而实际文件相同的文件,直接对原文件进行复制;
        Object file_location = redisTemplate.opsForHash().get(md5Value, "file_location");
        if (file_location != null) {
            String source = file_location.toString();
            File file = new File(source);
            if (!file.getName().equals(originalFilename)) {
                File target = new File(System.getProperty("user.dir") + File.separator + originalFilename);
                Files.copy(file.toPath(), target.toPath());
                return "success";
            }

        }
        //这里要特别注意,合并分片的时候一定要按照分片的索引顺序进行合并,否则文件无法使用;
        Integer file_chunks = Integer.valueOf(redisTemplate.opsForHash().get(md5Value, "file_chunks").toString());
        String userDir = System.getProperty("user.dir");
        File writeFile = new File(userDir + File.separator + originalFilename);
        OutputStream outputStream = new FileOutputStream(writeFile);
        InputStream inputStream = null;
        for (int i = 0; i < file_chunks; i++) {
            String tmpPath = redisTemplate.opsForHash().get(md5Value,"chunk_location_" + i).toString();
            File readFile = new File(tmpPath);
            inputStream = new FileInputStream(readFile);
            byte[] bytes = new byte[1024 * 1024];
            while ((inputStream.read(bytes) != -1)) {
                outputStream.write(bytes);
            }
            if (inputStream != null) {
                inputStream.close();
            }
        }
        if (outputStream != null) {
            outputStream.close();
        }
        redisTemplate.opsForHash().put(md5Value, "file_location", userDir + File.separator + originalFilename);
        this.delTmpFile(md5Value);
        return "success";
    }

    @GetMapping("/download")
    public String download(String fileName, HttpServletResponse response) throws IOException {
        response.setContentType("application/octet-stream");
        response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
        String userDir = System.getProperty("user.dir");
        File file = new File(userDir + File.separator + fileName);
        InputStream inputStream = new FileInputStream(file);
        byte[] bytes = new byte[1024 * 1024];
        ServletOutputStream outputStream = response.getOutputStream();
        while (inputStream.read(bytes) != -1) {
            outputStream.write(bytes);
        }
        inputStream.close();
        outputStream.close();
        return "success";
    }

    private void delTmpFile(String md5Value) throws JsonProcessingException {
        Map map = redisTemplate.opsForHash().entries(md5Value);
        List<String> list = new ArrayList<>();
        for (Object hashKey : map.keySet()) {
            if (hashKey.toString().startsWith("chunk_location")) {
                String filePath = map.get(hashKey).toString();
                File file = new File(filePath);
                boolean flag = file.delete();
                list.add(hashKey.toString());
                log.info("delete:" + filePath + ",:" + flag);
            }
            if (hashKey.toString().startsWith("chunk_start_end_")) {
                list.add(hashKey.toString());
            }
            if (hashKey.toString().startsWith("chunk_md5_")) {
                list.add(hashKey.toString());
            }
        }
        list.add("file_chunks");
        list.add("file_size");
        redisTemplate.opsForHash().delete(md5Value, list.toArray());
    }

    private Map<String, String> doRequestParam(HttpServletRequest request) {
        Map<String, String> requestParam = new HashMap<>();
        Enumeration<String> parameterNames = request.getParameterNames();
        while (parameterNames.hasMoreElements()) {
            String paramName = parameterNames.nextElement();
            String paramValue = request.getParameter(paramName);
            requestParam.put(paramName, paramValue);
            log.info(paramName + ":" + paramValue);
        }
        log.info("----------------------------");
        return requestParam;
    }

    /**
     * 合并分片前检验文件整体的所有分片是否全部上传
     *
     * @param key
     * @return
     */
    private boolean checkBeforeMerge(String key) {
        Map map = redisTemplate.opsForHash().entries(key);
        Object file_chunks = map.get("file_chunks");
        int i = 0;
        for (Object hashKey : map.keySet()) {
            if (hashKey.toString().startsWith("chunk_md5_")) {
                ++i;
            }
        }
        if (Integer.valueOf(file_chunks.toString())==(i)) {
            return true;
        }
        return false;
    }
}

webuploader2.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
    <script type="text/javascript" src="http://localhost:8080/lib/webuploader.js"></script>
    <link rel="stylesheet" href="lib/style.css"></link>
    <link rel="stylesheet" href="lib/webuploader.css"></link>
    <link rel="stylesheet" href="lib/bootstrap.min.css"></link>
    <link rel="stylesheet" href="lib/bootstrap-theme.min.css"></link>
    <link rel="stylesheet" href="lib/font-awesome.min.css"></link>
    <!--    <script type="text/javascript" src="http://localhost:8080/lib/spark-md5.min.js"></script>-->
</head>
<body>
<div style="width: 60%">
    <div id="uploader" class="wu-example">
        <!--用来存放文件信息-->
        <div id="thelist" class="uploader-list"></div>
        <div class="btns">
            <div id="picker">选择文件</div>
            <button id="ctlBtn" class="btn btn-default">开始上传</button>
        </div>
    </div>
    <div id="log">
    </div>
</div>

</body>
<script type="text/javascript">
    var file_md5 = '';
    var uploader;
    //md5FlagMap用于存储文件md5计算完成的标志位;多个文件时,分别设置标志位,key是文件名,value是true或false;
    var md5FlagMap = new Map();
    WebUploader.Uploader.register({
        "add-file": "addFile",
        "before-send-file": "beforeSendFile",
        "before-send": "beforeSend",
        "after-send-file": "afterSendFile"
    }, {
        addFile: function (file) {
            console.log('1', file)
        },
        beforeSendFile: function (file) {
            console.log('2', file)
            //
            // md5FlagMap.set(file.name, false);//文件md5值计算的标志位默认为false
            // var deferred = WebUploader.Deferred();//deferred用于监控异步计算文件md5值这个异步操作的执行状态
            // uploader.md5File(file, 0, file.size - 1).then(function (fileMd5) {
            //     file.wholeMd5 = fileMd5;
            //     file_md5 = fileMd5;
            //     deferred.resolve(file.name);//文件md5值计算完成后,更新状态为已完成,这时 deferred.done()会触发
            // })
            // //文件越大,文件的md5值计算用时越长,因此md5的计算搞成异步执行是合理的;如果异步执行比较慢的话,会顺序执行到这里
            // $('#thelist').append('<div id="' + file.id + '" class="item">' +
            //     '<h4 class="info">' + file.name + '</h4>' +
            //     '<p class="state">开始计算大文件的md5......<br/></p>' +
            //     '</div>')
            // //文件的md5计算完成,会触发这里的回调函数,
            // deferred.done(function (name) {
            //     md5FlagMap.set(name, true);//更新md5计算标志位为true
            //     $('#' + file.id).find('p.state').append('大文件的md5计算完成<br/>');
            // })
            // return deferred.promise();
        },
        beforeSend: function (block) {
            console.log(3)
            var file = block.file;
            var deferred = WebUploader.Base.Deferred();
            (new WebUploader.Uploader()).md5File(file, block.start, block.end).then(function (value) {
                $.ajax({
                    url: 'http://localhost:8080/file/check',//检查当前分片是否已经上传
                    method: 'post',
                    data: {chunkMd5: value, fileMd5: file_md5, chunk: block.chunk},
                    success: function (res) {
                        if (res) {
                            deferred.reject();
                        } else {
                            deferred.resolve(value);
                        }
                    }
                });

            })
            deferred.done(function (value) {
                block.chunkMd5 = value;

            })
            return deferred;
        },
        afterSendFile: function (file) {
            console.log('4', file)
        }
    })

    uploader = WebUploader.create({
        // swf文件路径
        swf: 'http://localhost:8080/lib/Uploader.swf',
        // 分片文件上传接口
        server: 'http://localhost:8080/file/upload',
        // 选择文件的按钮。可选。
        pick: '#picker',
        fileVal: 'multipartFile',//后端用来接收上传文件的参数名称
        chunked: true,//开启分片上传
        chunkSize: 1024 * 1024 * 10,//设置分片大小
        chunkRetry: 2,//设置重传次数,有的时候由于网络原因,分片上传的会失败,这里即是失败允许重的次数
        threads: 3//允许同时最大上传进程数
    });

    /**
     * 当有文件被添加进队列后触发
     * 主要逻辑:1、文件被添加到队列后,开始计算文件的md5值;
     * 2、md5的计算过程是异步操作,并且文件越大,计算用时越长;
     * 3、变量md5FlagMap是文件md5值计算的标志位,计算完成后,设置当前文件的md5Flag为true
     */
    uploader.on('fileQueued', function (file) {
        md5FlagMap.set(file.name, false);//文件md5值计算的标志位默认为false
        var deferred = WebUploader.Deferred();//deferred用于监控异步计算文件md5值这个异步操作的执行状态
        uploader.md5File(file, 0, file.size - 1).then(function (fileMd5) {
            file.wholeMd5 = fileMd5;
            file_md5 = fileMd5;
            deferred.resolve(file.name);//文件md5值计算完成后,更新状态为已完成,这时 deferred.done()会触发
        })
        //文件越大,文件的md5值计算用时越长,因此md5的计算搞成异步执行是合理的;如果异步执行比较慢的话,会顺序执行到这里
        $('#thelist').append('<div id="' + file.id + '" class="item">' +
            '<h4 class="info">' + file.name + '</h4>' +
            '<p class="state">开始计算大文件的md5......<br/></p>' +
            '</div>')
        //文件的md5计算完成,会触发这里的回调函数,
        deferred.done(function (name) {
            md5FlagMap.set(name, true);//更新md5计算标志位为true
            $('#' + file.id).find('p.state').append('大文件的md5计算完成<br/>');
        })
        return deferred.promise();
    })

    // 分片模式下,当文件的分块在发送前触发
    uploader.on('uploadBeforeSend', function (block, data) {
        var file = block.file;
        //data可以携带参数到后端
        data.originalFilename = file.originalFilename;//文件名字
        data.md5Value = file.wholeMd5;//文件整体的md5值
        data.start = block.start;//分片数据块在整体文件的开始位置
        data.end = block.end;//分片数据块在整体文件的结束位置
        data.chunk = block.chunk;//分片的索引位置
        data.chunks = block.chunks;//整体文件总共分了多少征
        data.chunkMd5 = block.chunkMd5;//分片文件md5值
    });
    // 文件上传过程中创建进度条实时显示
    uploader.on('uploadProgress', function (file, percentage) {
        var $li = $('#' + file.id),
            $percent = $li.find('.progress .progress-bar');
        if (!$percent.length) {
            $percent = $('<div class="progress progress-striped active">' +
                '<div class="progress-bar" role="progressbar" style="width: 0%">' +
                '</div>' +
                '</div>').appendTo($li).find('.progress-bar');
        }
        $percent.css('width', percentage * 100 + '%');
    });
    //当文件上传成功时触发
    uploader.on('uploadSuccess', function (file) {
        //大文件的所有分片上传成功后,请求后端对分片进行合并
        $.ajax({
            url: 'http://localhost:8080/file/merge',
            method: 'post',
            data: {'md5Value': file.wholeMd5, 'originalFilename': file.name},
            success: function (res) {
                alert('大文件上传成功!')
            }
        })
        $('#' + file.id).find('p.state').append('文件上传成功<br/>');
    });
    //当文件上传出错时触发
    uploader.on('uploadError', function (file) {
        $('#' + file.id).find('p.state').text('上传出错<br/>');
    });
    //不管成功或者失败,文件上传完成时触发
    uploader.on('uploadComplete', function (file) {
        $('#' + file.id).find('.progress').fadeOut();
    });
    //开始上传按钮被点击时触发
    $('#ctlBtn').click(function () {
        //md5FlagMap存储有文件md5计算的标志位;
        // 同时上传多个文件时,上传前要判断一下文件的md5是否计算完成,
        // 如果有未计算完成的,则继续等待计算结果;
        //文件上传标志位,如果多个文件有一个没有完成md5计算则不能开始上传;这里在实际业务中可以更换成其他交互样式,酌情优化为哪个文件的md5计算完成,则开始哪个文件的上传;
        var uploadFloag = true;
        md5FlagMap.forEach(function (value, key) {
            if (!value) {
                uploadFloag = false;
                alert('md5计算中...,请稍侯')//文件md5计算未完成,会弹出弹窗提示;
            }
        })
        if (uploadFloag) {
            uploader.upload();//文件md5计算完成后,开始分片上传;
        }
    })
</script>
</html>
标签: java 开发语言

本文转载自: https://blog.csdn.net/qq_45038038/article/details/135982137
版权归原作者 之乎者也· 所有, 如有侵权,请联系我们删除。

“JAVA面试题分享五百一十一:Spring Boot基于WebUploader实现超大文件上传和断点续传”的评论:

还没有评论