本项目基于SpringBoot2.0构建,使用Thymeleaf视图解析器,前端使用bootstrap+WebUploader
- springboot官网:https://spring.io/projects/spring-boot/
- thymeleaf官网:https://www.thymeleaf.org/
- webuploader官网:https://fex.baidu.com/webuploader/
- bootstrap官网:https://www.bootcss.com/
-
下载项目,启动并访问:http://localhost:8080/upload/index
-
页面效果
```flow
st=>start: 开始
op=>operation: 选择文件
op1=>operation: 将文件分片并上传每个分片到服务器
op2=>operation: 所有分片上传成功后,通知服务器合并分片
e=>end
st->op->op1->op2()->e
&```
-
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.6.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.github.zyt</groupId> <artifactId>webuploader</artifactId> <version>0.0.1-SNAPSHOT</version> <name>webuploader</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--thymeleaf相关--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!--热部署相关--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> </dependency> <!--lombok--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.12</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <!--热部署相关--> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <!-- 如果没有该配置,热部署的devtools不生效 --> <fork>true</fork> </configuration> </plugin> </plugins> </build> </project>
-
application.properties
server.port=8080 server.tomcat.max-threads=6 server.tomcat.min-spare-threads=3 server.tomcat.accept-count=10 server.tomcat.max-connections=1000 # thymeleaf spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.mode=HTML spring.thymeleaf.encoding=UTF-8 spring.thymeleaf.servlet.content-type=text/html spring.thymeleaf.cache=false #开启静态资源扫描 spring.mvc.static-path-pattern=/** #开启热部署 spring.devtools.restart.enabled=true #设置最大上传大小 spring.servlet.multipart.enabled=true spring.servlet.multipart.max-file-size=5GB spring.servlet.multipart.max-request-size=5GB #文件路径 upload_path=D:\\upload
-
上传下载页面
-
引入css、js
<link rel="stylesheet" th:href="@{/webuploader/webuploader.css}"/> <!-- 最新版本的 Bootstrap 核心 CSS 文件 --> <link rel="stylesheet" th:href="@{/bootstrap/css/bootstrap.min.css}"> <!-- 最新的 Bootstrap 核心 JavaScript 文件 --> <script type="application/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script> <script type="application/javascript" th:src="@{/js/jquery-3.4.1.min.js}"></script> <script type="application/javascript" th:src="@{/webuploader/webuploader.js}">
-
页面HTML
<div class="container"> <div class="row" style="margin-top: 20px;"> <div class="col-md-12"> <div class="panel panel-default"> <div class="panel-heading"> <h3 class="panel-title">上传</h3> </div> <div class="panel-body"> <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> </div> </div> </div> <div class="row" style="margin-top: 20px;"> <div class="col-md-12"> <div class="panel panel-default"> <div class="panel-heading"> <h3 class="panel-title">下载(显示上传路径:<span th:text="${uploadPath}"></span>下的文件)</h3> <br> <button id="btn_refesh" type="button" class="btn btn-info">刷新</button> </div> <div class="panel-body"> <table class="table table-striped table-bordered table-hover"> <thead> <tr> <th>#</th> <th>文件名称</th> <th>修改日期</th> <th>文件类型</th> <th>文件大小</th> <th>操作</th> </tr> </thead> <tbody id="fileList"> <tr> <td colspan="6" class="text-center">暂无数据</td> </tr> </tbody> </table> </div> </div> </div> </div> </div>
-
javascript
<script th:inline="javascript" type="application/javascript"> /*<![CDATA[*/ var $ = jQuery, $list = $('#thelist'), $btn = $('#ctlBtn'), state = 'pending', uploader; var fileMd5;//文件的MD5值 var fileName;//文件名称 var blockSize = 10 * 1024 * 1024; var md5Arr = new Array(); //文件MD5数组 var timeArr = new Array();//文件上传时间戳数组 WebUploader.Uploader.register({ "before-send-file": "beforeSendFile",//整个文件上传前 "before-send": "beforeSend",//每个分片上传前 "after-send-file": "afterSendFile"//分片上传完毕 }, { //1.生成整个文件的MD5值 beforeSendFile: function (file) { var index = file.id.slice(8);//文件下标 var startTime = new Date();//一个文件上传初始化时,开始计时 timeArr[index] = startTime;//将每一个文件初始化时的时间放入时间数组 var deferred = WebUploader.Deferred(); //计算文件的唯一标记fileMd5,用于断点续传 如果.md5File(file)方法里只写一个file参数则计算MD5值会很慢 所以加了后面的参数:10*1024*1024 (new WebUploader.Uploader()) .md5File(file, 0, blockSize) .progress(function (percentage) { $('#' + file.id).find('p.state').text('正在读取文件信息...'); }) .then(function (value) { $("#" + file.id).find('p.state').text('成功获取文件信息...'); fileMd5 = value; var index = file.id.slice(8); md5Arr[index] = fileMd5;//将文件的MD5值放入数组,以便分片合并时能够取到当前文件对应的MD5 uploader.options.formData.guid = fileMd5;//全局的MD5 deferred.resolve(); }); fileName = file.name; return deferred.promise(); }, //2.如果有分快上传,则每个分块上传前调用此函数 beforeSend: function (block) { var deferred = WebUploader.Deferred(); $.ajax({ type: "POST", url: /*[[@{/upload/checkblock}]]*/, //ajax验证每一个分片 data: { //fileName: fileName, //fileMd5: fileMd5, //文件唯一标记 chunk: block.chunk, //当前分块下标 chunkSize: block.end - block.start,//当前分块大小 guid: uploader.options.formData.guid, }, cache: false, async: false, // 与js同步 timeout: 1000, // 超时的话,只能认为该分片未上传过 dataType: "json", success: function (response) { if (response.ifExist) { //分块存在,跳过 deferred.reject(); } else { //分块不存在或不完整,重新发送该分块内容 deferred.resolve(); } } }); this.owner.options.formData.fileMd5 = fileMd5; deferred.resolve(); return deferred.promise(); }, //3.当前所有的分块上传成功后调用此函数 afterSendFile: function (file) { //如果分块全部上传成功,则通知后台合并分块 var index = file.id.slice(8);//获取文件的下标 $('#' + file.id).find('p.state').text('已上传'); $.post(/*[[@{/upload/combine}]]*/, {"guid": md5Arr[index], fileName: file.name}, function (data) { }, "json"); } }); //上传方法 uploader = WebUploader.create({ // swf文件路径 swf: '@{webuploader/Uploader.swf}', // 文件接收服务端。 server: /*[[@{/upload/save}]]*/, // 选择文件的按钮。可选。 // 内部根据当前运行是创建,可能是input元素,也可能是flash. pick: '#picker', chunked: true, //分片处理 chunkSize: 10 * 1024 * 1024, //每片5M threads: 3,//上传并发数。允许同时最大上传进程数。 // 不压缩image, 默认如果是jpeg,文件上传前会压缩一把再上传! resize: false }); // 当有文件被添加进队列的时候 uploader.on('fileQueued', function (file) { //文件列表添加文件页面样式 $list.append('<div id="' + file.id + '" class="item">' + '<div class="row">\n' + '<div class="col-md-11"><h4 class="info">' + file.name + '</h4></div>\n' + '<div class="col-md-1"><button class="btn btn-info delbtn" onclick="delFile(\'' + file.id + '\')">删除</button></div>\n' + '</div>\n' + '<div class="row">\n' + '<div class="col-md-5"><p class="state">等待上传...</p></div>\n' + '<div class="col-md-7"><span class="time"></span></div>\n' + '</div>'); }); // 文件上传过程中创建进度条实时显示 uploader.on('uploadProgress', function (file, percentage) { //计算每个分块上传完后还需多少时间 var index = file.id.slice(8);//文件的下标 var currentTime = new Date(); var timeDiff = currentTime.getTime() - timeArr[index].getTime();//获取已用多少时间 var timeStr; //如果percentage==1说明已经全部上传完毕,则需更改页面显示 if (1 == percentage) { timeStr = "上传用时:" + countTime(timeDiff);//计算总用时 } else { timeStr = "预计剩余时间:" + countTime(timeDiff / percentage * (1 - 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'); } $li.find('p.state').text('上传中'); $li.find('span.time').text(timeStr); $percent.css('width', percentage * 100 + '%'); }); /* uploader.on('uploadSuccess', function (file) { var index = file.id.slice(8); $('#' + file.id).find('p.state').text('已上传'); $.post(/!*[[@{/upload/combine}]]*!/, { "guid": md5Arr[index], fileName: file.name, }, function () { uploader.removeFile(file); }, "json"); });*/ //上传失败时 uploader.on('uploadError', function (file) { $('#' + file.id).find('p.state').text('上传出错'); }); //上传完成时 uploader.on('uploadComplete', function (file) { $('#' + file.id).find('.progress').fadeOut(); }); //上传状态 uploader.on('all', function (type) { if (type === 'startUpload') { state = 'uploading'; } else if (type === 'stopUpload') { state = 'paused'; } else if (type === 'uploadFinished') { state = 'done'; } if (state === 'uploading') { $btn.text('暂停上传'); } else { $btn.text('开始上传'); } }); //开始上传,暂停上传的函数 $btn.on('click', function () { //每个文件的删除按钮不可用 $(".delbtn").attr("disabled", true); if (state === 'uploading') { uploader.stop(true);//暂停 //删除按钮可用 $(".delbtn").removeAttr("disabled"); } else { uploader.upload(); } }); //删除文件 function delFile(id) { //将文件从uploader的文件列表中删除 uploader.removeFile(uploader.getFile(id, true)); //清除页面元素 $("#" + id).remove(); } //获取上传时还需多少时间 function countTime(date) { var str = ""; //计算出相差天数 var days = Math.floor(date / (24 * 3600 * 1000)) if (days > 0) { str += days + " 天 "; } //计算出小时数 var leave1 = date % (24 * 3600 * 1000) //计算天数后剩余的毫秒数 var hours = Math.floor(leave1 / (3600 * 1000)) if (hours > 0) { str += hours + " 小时 "; } //计算相差分钟数 var leave2 = leave1 % (3600 * 1000) //计算小时数后剩余的毫秒数 var minutes = Math.floor(leave2 / (60 * 1000)) if (minutes > 0) { str += minutes + " 分 "; } //计算相差秒数 var leave3 = leave2 % (60 * 1000) //计算分钟数后剩余的毫秒数 var seconds = Math.round(leave3 / 1000) if (seconds > 0) { str += seconds + " 秒 "; } else { /* str += parseInt(date) + " 毫秒"; */ str += " < 1 秒"; } return str; } //刷新 $("#btn_refesh").click(function () { $("#fileList").empty(); $.ajax({ type: "GET", url: /*[[@{/upload/getFiles}]]*/, dataType: "json", success: function (data) { let fileList = data.fileList; if (null != fileList && fileList instanceof Array && fileList.length > 0) { for (let i = 0; i < fileList.length; i++) { $("#fileList") .append("<tr>" + "<td scope='row' class='text-center'>" + (i + 1) + "</td>" + "<td class='text-center'>" + fileList[i].fileName + "</td>" + "<td class='text-center'>" + fileList[i].modifyTime + "</td>" + "<td class='text-center'>" + fileList[i].fileType + "</td>" + "<td class='text-center'>" + fileList[i].fileSize + "</td>" + "<td class='text-center'><a class='btn btn-success' href='/upload/downloadFile?fileName=" + fileList[i].fileName + "'>下载</a> " + "<a class='btn btn-danger' href='javascript:void(0)' onclick='delFile(\"" + fileList[i].fileName + "\")'>删除</a></td>" + "</tr>"); } } else { $("#fileList").append("<tr><td colspan='6' class='text-center'>暂无数据</td></tr>") } } }); }); //删除文件 function delFile(fileName) { if (confirm("确定删除?")) { $.ajax({ type: "GET", url: /*[[@{/upload/delFile}]]*/, data: { "fileName": fileName }, async:true, dataType: "json", success: function (data) { if ("true" == data.result) { alert("删除成功!"); } else { alert("删除失败!"); } } }); setTimeout(function () { $("#btn_refesh").click() }, 1000); } } /*]]>*/ </script>
-
-
后端代码
-
校验
/** * 查看当前分片是否上传 * * @param request * @param response */ @PostMapping("checkblock") @ResponseBody public void checkMd5(HttpServletRequest request, HttpServletResponse response) { //当前分片 String chunk = request.getParameter("chunk"); //分片大小 String chunkSize = request.getParameter("chunkSize"); //当前文件的MD5值 String guid = request.getParameter("guid"); //分片上传路径 String tempPath = uploadPath + File.separator + "temp"; File checkFile = new File(tempPath + File.separator + guid + File.separator + chunk); response.setContentType("text/html;charset=utf-8"); try { //如果当前分片存在,并且长度等于上传的大小 if (checkFile.exists() && checkFile.length() == Integer.parseInt(chunkSize)) { response.getWriter().write("{\"ifExist\":1}"); } else { response.getWriter().write("{\"ifExist\":0}"); } } catch (IOException e) { e.printStackTrace(); } }
-
上传分片
/** * 上传分片 * * @param file * @param chunk * @param guid * @throws IOException */ @PostMapping("save") @ResponseBody public void upload(@RequestParam MultipartFile file, Integer chunk, String guid) throws IOException { String filePath = uploadPath + File.separator + "temp" + File.separator + guid; File tempfile = new File(filePath); if (!tempfile.exists()) { tempfile.mkdirs(); } RandomAccessFile raFile = null; BufferedInputStream inputStream = null; if (chunk == null) { chunk = 0; } try { File dirFile = new File(filePath, String.valueOf(chunk)); //以读写的方式打开目标文件 raFile = new RandomAccessFile(dirFile, "rw"); raFile.seek(raFile.length()); inputStream = new BufferedInputStream(file.getInputStream()); byte[] buf = new byte[1024]; int length = 0; while ((length = inputStream.read(buf)) != -1) { raFile.write(buf, 0, length); } } catch (Exception e) { throw new IOException(e.getMessage()); } finally { if (inputStream != null) { inputStream.close(); } if (raFile != null) { raFile.close(); } } }
-
合并分片
/** * 合并文件 * * @param guid * @param fileName */ @PostMapping("combine") @ResponseBody public void combineBlock(String guid, String fileName) { //分片文件临时目录 File tempPath = new File(uploadPath + File.separator + "temp" + File.separator + guid); //真实上传路径 File realPath = new File(uploadPath + File.separator + "real"); if (!realPath.exists()) { realPath.mkdirs(); } File realFile = new File(uploadPath + File.separator + "real" + File.separator + fileName); FileOutputStream os = null;// 文件追加写入 FileChannel fcin = null; FileChannel fcout = null; try { log.info("合并文件——开始 [ 文件名称:" + fileName + " ,MD5值:" + guid + " ]"); os = new FileOutputStream(realFile, true); fcout = os.getChannel(); if (tempPath.exists()) { //获取临时目录下的所有文件 File[] tempFiles = tempPath.listFiles(); //按名称排序 Arrays.sort(tempFiles, (o1, o2) -> { if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) { return -1; } if (Integer.parseInt(o1.getName()) == Integer.parseInt(o2.getName())) { return 0; } return 1; }); //每次读取10MB大小,字节读取 //byte[] byt = new byte[10 * 1024 * 1024]; //int len; //设置缓冲区为10MB ByteBuffer buffer = ByteBuffer.allocate(10 * 1024 * 1024); for (int i = 0; i < tempFiles.length; i++) { FileInputStream fis = new FileInputStream(tempFiles[i]); /*while ((len = fis.read(byt)) != -1) { os.write(byt, 0, len); }*/ fcin = fis.getChannel(); if (fcin.read(buffer) != -1) { buffer.flip(); while (buffer.hasRemaining()) { fcout.write(buffer); } } buffer.clear(); fis.close(); //删除分片 tempFiles[i].delete(); } os.close(); //删除临时目录 if (tempPath.isDirectory() && tempPath.exists()) { System.gc(); // 回收资源 tempPath.delete(); } log.info("文件合并——结束 [ 文件名称:" + fileName + " ,MD5值:" + guid + " ]"); } } catch (Exception e) { log.error("文件合并——失败 " + e.getMessage()); } }
- 查询上传目录下文件
/** * 查询上传目录下的全部文件 * * @return */ @GetMapping("/getFiles") @ResponseBody public Map getFiles() { Map map = new HashMap(); String realUploadPath = uploadPath + File.separator + "real"; File directory = new File(realUploadPath); File[] files = directory.listFiles(); List<FileEntity> fileList = new ArrayList<>(); if (null != files && files.length > 0) { for (File file : files) { fileList.add(new FileEntity(file.getName(), getDate(file.lastModified()), file.getName().substring(file.getName().lastIndexOf(".")), Math.round(file.length() / 1024) + "KB")); } } map.put("fileList", fileList); return map; }
- 文件下载
/** * 文件下载 * * @param fileName 文件名称 * @param response HttpServletResponse */ @GetMapping("downloadFile") @ResponseBody public void downLoadFile(String fileName, HttpServletResponse response) { File file = new File(uploadPath + File.separator + "real" + File.separator + fileName); if (file.exists()) { InputStream is = null; OutputStream os = null; try { response.reset(); // 设置强制下载不打开 response.setContentType("application/force-download"); //设置下载文件名 response.addHeader("Content-Disposition", "attachment;filename=" + fileName); response.addHeader("Content-Length", "" + file.length()); //定义输入输出流 os = new BufferedOutputStream(response.getOutputStream()); is = new BufferedInputStream(new FileInputStream(file)); byte[] buffer = new byte[1024]; int len; while ((len = is.read(buffer)) > 0) { os.write(buffer, 0, len); os.flush(); } } catch (IOException e) { e.printStackTrace(); } finally { try { is.close(); os.close(); } catch (IOException e) { e.printStackTrace(); } log.info("文件下载成功——文件名:" + fileName); } } }
- 文件删除
/** * 删除文件 * * @param fileName * @return */ @GetMapping("/delFile") @ResponseBody public Map delFile(String fileName) { boolean b = false; File file = new File(uploadPath + File.separator + "real" + File.separator + fileName); if (file.exists() && file.isFile()) { b = file.delete(); } Map map = new HashMap(); map.put("result", b + ""); return map; }
-