Plupload 文件断点续传,文件分块上传

Plupload 介绍

Plupload 是一款由著名的 Web 编辑器 TinyMCE 团队开发的上传组件,简单易用且功能强大。Plupload 会自动侦测当前的浏览器环境,选择最合适的上传方式,并且会优先使用 HTML5 的方式。该前端插件实现原理简单来说就是将文件按照指定分块大小切割成 n 块,然后依次将这 n 块文件数据上传至服务端,可实现暂停、继续上传。这也算是断点续传的一种实现方式。

本人在使用过程中对于这个插件也遇到了一些坑,比如,上传暂停后重新开始,Plupload 会重复上传上次最后一块文件块;该问题的原因在于调用 Plupload 的 stop() 方法后 Plupload 会立刻中断本次请求,这时服务器正在处理请求,还未及时将处理结果返回至前端请求被中断了,造成服务器实际已保存该文件块,但 Plupload 认为该文件块未上传完成,导致暂停后重新开始,服务端文件块重复,服务端接收到的文件与实际不同。在本文中我将会解决这个问题。

Plupload 获取

正确的途径当然要从官网下载
本人下载版本 Plupload 2.3.6 AGPLv3,里面包含需要用到的 js,还有 Custom example 和 Events example 两个 Demo 页面。接下来将改写 Custom example 页面,实现需要的功能。

前端 HTML 页面

前端使用比较简单,通过一些简单参数配置即可使用,具体参数设置可查看官方文档,其中主要用到 Plupload 插件的 start() 和 stop() 两个方法

修改后的 Custom example 如下,附上源代码:
avatar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" dir="ltr">

<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>

<title>Plupload - Custom example</title>

<!-- production -->
<script type="text/javascript" src="../js/plupload.full.min.js"></script>

<!-- debug
<script type="text/javascript" src="../js/moxie.js"></script>
<script type="text/javascript" src="../js/plupload.dev.js"></script>
-->

</head>

<body style="font: 13px Verdana; background: #eee; color: #333">

<h1>Custom example</h1>

<p>Shows you how to use the core plupload API.</p>

<div id="filelist">Your browser doesn't have Flash, Silverlight or HTML5 support.</div>
<br/>

<div id="container">
<a id="pickfiles" href="javascript:;">[Select files]</a>
<a id="start" href="javascript:;">[Upload Start]</a>
<a id="stop" href="javascript:;">[Upload Pause]</a>
</div>

<br/>
<pre id="console"></pre>

<script type="text/javascript">
// Custom example logic
var uploader = new plupload.Uploader({
runtimes: 'html5,flash,silverlight,html4',
browse_button: 'pickfiles', // you can pass an id...
container: document.getElementById('container'), // ... or DOM Element itself
url: 'http://127.0.0.1:8001/demo/upload', //服务器端上传处理程序的URL
flash_swf_url: '../js/Moxie.swf',
silverlight_xap_url: '../js/Moxie.xap',
chunk_size: '2mb', //分块大小
unique_names: true, //生成唯一文件名
send_file_name: true, //发送文件名
max_retries: 3, //在触发Error事件之前重试块或文件的次数
// multipart_params: { gmtCreate : new Date().getTime() }, //每次上传文件时要发送的键/值对的哈希值

filters: {
max_file_size: '4096mb',
mime_types: [
// {title : "Image files", extensions : "jpg,gif,png"},
// {title : "Zip files", extensions : "zip"},
// {title : "Video files", extensions : "mp4"}
{title: "All files", extensions: "*"}
]
},

init: {
PostInit: function () {
// uploader.setOption('multipart_params', {gmtCreate: new Date().getTime()});

document.getElementById('filelist').innerHTML = '';

document.getElementById('start').onclick = function () {
uploader.start();
return false;
};

document.getElementById('stop').onclick = function () {
uploader.stop();
return false;
};

},

FilesAdded: function (up, files) {
plupload.each(files, function (file) {
document.getElementById('filelist').innerHTML += '<div id="' + file.id + '">' + file.name + ' (' + plupload.formatSize(file.size) + ') <b></b></div>';
});
},

UploadProgress: function (up, file) {
document.getElementById(file.id).getElementsByTagName('b')[0].innerHTML = '<span>' + file.percent + "%</span>";
},

ChunkUploaded: function (up, file, info) {
// Called when file chunk has finished uploading
console.log('[ChunkUploaded] File:', file, "Info:", info);
},

Error: function (up, err) {
document.getElementById('console').appendChild(document.createTextNode("\nError #" + err.code + ": " + err.message));
}
}
});

uploader.init();

</script>
</body>
</html>

服务端,以 Spring Boot 1.5.14.RELEASE 为例

Plupload 对象

Plupload 上传文件时会附带 name, chunks, chunk 三个参数,这里我通过 Plupload 对象接收参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* Plupload 实体类,建议不要修改
*
* @author Leaves
* @version 1.0.0
* @date 2018/7/26
*/
public class Plupload {

/**
* 文件名
*/
private String name;

/**
* 上传文件总块数
*/
private int chunks = -1;

/**
* 当前块数,从0开始计数
*/
private int chunk = -1;

/**
* 上传时间戳,自定义
*/
private Long gmtCreate;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getChunks() {
return chunks;
}

public void setChunks(int chunks) {
this.chunks = chunks;
}

public int getChunk() {
return chunk;
}

public void setChunk(int chunk) {
this.chunk = chunk;
}

public Long getGmtCreate() {
return gmtCreate;
}

public void setGmtCreate(Long gmtCreate) {
this.gmtCreate = gmtCreate;
}

@Override
public String toString() {
return "Plupload{" +
"name='" + name + '\'' +
", chunks=" + chunks +
", chunk=" + chunk +
", gmtCreate=" + gmtCreate +
'}';
}
}

upload(),接收 Plupload 上传数据

本文提到上传暂停后重新开始,Plupload 会重复上传上次最后一块文件数据这一问题,这里给出一种解决办法:每次收到 Plupload 上传的文件块,处理成功后将当前文件块编号记录到缓存(缓存需要设置过期时间,防止永久驻留),暂停后重新开始,服务端比较本次上传文件块是否已在上次上传处理,如果已处理则抛弃改文件块。

如果需要支持文件秒传,可以通过验证文件 MD5 值简单实现,Java 可通过 commons-codec 包下的 DigestUtils.md5Hex(new FileInputStream(filePath))); 方法获取文件 MD5 值,该 Demo 未做文件秒传。

具体实现,请看源码:

/**
 * 文件上传,断点续传,分块上传
 *
 * @param request
 * @param response
 * @param plupload
 * @return
 */
public boolean upload(HttpServletRequest request, HttpServletResponse response, Plupload plupload) {
    //按日期文件夹保存
    Calendar calendar = Calendar.getInstance();
    File serverDir = new File(System.getProperty("user.dir") + File.separator
            + "uploads" + File.separator
            + calendar.get(Calendar.YEAR) + File.separator
            + (calendar.get(Calendar.MONTH) + 1));
    //mkdirs() 可创建多级目录,mkdir() 只能创建一级目录
    if (!serverDir.exists()) {
        if (serverDir.mkdirs()) {
            //文件夹创建失败返回403
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            return false;
        }
    }

    //文件名
    String fileName = plupload.getName();
    //上传文件总块数
    int chunks = plupload.getChunks();
    //当前上传块,从 0 开始
    int nowChunk = plupload.getChunk();

    //文件块重复检查
    ValueOperations operations = redisTemplate.opsForValue();
    Object lastChunk = operations.get("UPLOAD_" + plupload.getName());
    if (lastChunk != null) {
        if (Objects.equals(Integer.parseInt(lastChunk.toString()), nowChunk)) {
            logger.warn("文件块重复,chunks: {}, now chunk: {}, last chunk: {}", chunks, nowChunk, lastChunk);
            return true;
        }
    }

    //获取文件
    MultipartHttpServletRequest multipartHttpServletRequest = (MultipartHttpServletRequest) request;
    MultiValueMap<String, MultipartFile> map = multipartHttpServletRequest.getMultiFileMap();
    if (map == null || map.size() <= 0) {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        return false;
    }

    for (String key : map.keySet()) {
        List<MultipartFile> multipartFileList = map.get(key);
        File targetFile = new File(serverDir + File.separator + fileName);

        for (MultipartFile multipartFile : multipartFileList) {
            try {
                //上传文件总块数 > 1,则为分块上传,需要进行合并
                if (chunks > 1) {
                    this.writePartFile(multipartFile.getInputStream(), targetFile, nowChunk != 0);
                } else {
                    //上传文件总块数 = 1,直接拷贝文件内容
                    multipartFile.transferTo(targetFile);
                }
            } catch (IOException e) {
                logger.error(e.getMessage());
                e.printStackTrace();
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                return false;
            }
        }
    }

    //记录上传块数
    if (nowChunk == chunks - 1) {
        redisTemplate.delete("UPLOAD_" + plupload.getName());
    } else {
        operations.set("UPLOAD_" + plupload.getName(), String.valueOf(nowChunk), 86400L, TimeUnit.SECONDS);
    }
    return true;
}

/**
 * 分块写入
 *
 * @param inputStream
 * @param file
 * @param append
 */
private void writePartFile(InputStream inputStream, File file, boolean append) {
    OutputStream outputStream = null;
    try {
        if (!append) {
            //从头开始写
            outputStream = new BufferedOutputStream(new FileOutputStream(file));
        } else {
            //追加写入
            outputStream = new BufferedOutputStream(new FileOutputStream(file, true));
        }
        byte[] bytes = new byte[1024];
        int len = 0;
        while ((len = (inputStream.read(bytes))) > 0) {
            outputStream.write(bytes, 0, len);
        }
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            if (outputStream != null) {
                outputStream.flush();
                outputStream.close();
            }
            if (inputStream != null) {
                inputStream.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

断点续传效果图

avatar

If these articles are helpful to you, you can donate comment here.