为啥要有断点续传
传统的文件传输方式一旦遇到网络问题或者浏览器做重刷的话,那么对于上传过程是灾难性的。那么为了解决这个问题,就出现了断点续传。在之前已传输的地方再接着传输!今天我们这里就用springboot来实现断点续传!
断点续传的大致流程图
这里的核心在于如何分片,记录分片,然后再获取分片重新从分片处接着传输!
创建工程
添加依赖
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0modelVersion> <parent> <groupId>org.springframework.bootgroupId> <artifactId>spring-boot-starter-parentartifactId> <version>2.3.1.RELEASEversion> <relativePath/> parent> <groupId>com.example.demogroupId> <artifactId>uploadartifactId> <version>0.0.1-SNAPSHOTversion> <name>uploadname> <description>Demo project for Spring Bootdescription> <properties> <java.version>1.8java.version> properties> <dependencies> <dependency> <groupId>org.springframework.bootgroupId> <artifactId>spring-boot-starter-thymeleafartifactId> dependency> <dependency> <groupId>org.springframework.bootgroupId> <artifactId>spring-boot-starter-webartifactId> dependency> <dependency> <groupId>org.springframework.bootgroupId> <artifactId>spring-boot-starter-testartifactId> <scope>testscope> <exclusions> <exclusion> <groupId>org.junit.vintagegroupId> <artifactId>junit-vintage-engineartifactId> exclusion> exclusions> dependency> <dependency> <groupId>org.projectlombokgroupId> <artifactId>lombokartifactId> <optional>trueoptional> dependency> <dependency> <groupId>commons-iogroupId> <artifactId>commons-ioartifactId> <version>2.4version> dependency> <dependency> <groupId>commons-fileuploadgroupId> <artifactId>commons-fileuploadartifactId> <version>1.3.3version> dependency> <dependency> <groupId>mysqlgroupId> <artifactId>mysql-connector-javaartifactId> <scope>runtimescope> <version>5.1.47version> dependency> <dependency> <groupId>com.baomidougroupId> <artifactId>mybatis-plus-boot-starterartifactId> <version>3.3.2version> dependency> dependencies> <build> <plugins> <plugin> <groupId>org.springframework.bootgroupId> <artifactId>spring-boot-maven-pluginartifactId> plugin> plugins> build>project>
配置文件
#spring.resources.static-locations=classpath:/staticserver.port=8000#文件上传路径file.basepath=E:/BaiduNetdiskDownload/spring.servlet.multipart.max-file-size= 50MBspring.servlet.multipart.max-request-size= 50MBspring.datasource.driver-class-name=com.mysql.jdbc.Driverspring.datasource.url=jdbc:mysql://localhost:3306/leotemp?characterEncoding=utf8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghaispring.datasource.username=rootspring.datasource.password=123456# templates文件夹的路径spring.thymeleaf.prefix=classpath:/templates/# templates中的所有文件后缀名,如/templates/main.htmlspring.thymeleaf.suffix=.html
创建数据库
create table file( id INTEGER primary key AUTO_INCREMENT comment 'id', path varchar(100) not null COMMENT '相对路径', name varchar(100) COMMENT '文件名', suffix varchar(10) COMMENT '文件后缀', size int COMMENT '文件大小|字节B', created_at BIGINT(20) COMMENT '文件创建时间', updated_at bigint(20) COMMENT '文件修改时间', shard_index int comment '已上传分片', shard_size int COMMENT '分片大小|B', shard_total int COMMENT '分片总数', file_key varchar(100) COMMENT '文件标识')
创建实体类
package com.example.demo.upload.entity;import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;@Data@TableName(value = "file")public class FileDTO { /** * id */ @TableId(value = "id", type = IdType.AUTO) private Integer id; /** * 相对路径 */ private String path; /** * 文件名 */ private String name; /** * 后缀 */ private String suffix; /** * 大小|字节B */ private Integer size; /** * 创建时间 */ private Long createdAt; /** * 修改时间 */ private Long updatedAt; /** * 已上传分片 */ private Integer shardIndex; /** * 分片大小|B */ private Integer shardSize; /** * 分片总数 */ private Integer shardTotal; /** * 文件标识 */ private String fileKey;}
mapper层
package com.example.demo.upload.dao;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.demo.upload.entity.FileDTO;import org.springframework.stereotype.Repository;@Repositorypublic interface FileMapper extends BaseMapper<FileDTO> {}
响应工具类
package com.example.demo.upload.utils;import lombok.Data;@Datapublic class Result { // 成功状态码 public static final int SUCCESS_CODE = 200; // 请求失败状态码 public static final int FAIL_CODE = 500; // 查无资源状态码 public static final int NOTF_FOUNT_CODE = 404; // 无权访问状态码 public static final int ACCESS_DINE_CODE = 403; /** * 状态码 */ private int code; /** * 提示信息 */ private String msg; /** * 数据信息 */ private Object data; /** * 请求成功 * * @return */ public static Result ok() { Result r = new Result(); r.setCode(SUCCESS_CODE); r.setMsg("请求成功!"); r.setData(null); return r;} /** * 请求失败 * * @return */ public static Result fail() { Result r = new Result(); r.setCode(FAIL_CODE); r.setMsg("请求失败!"); r.setData(null); return r;} /** * 请求成功,自定义信息 * * @param msg * @return */ public static Result ok(String msg) { Result r = new Result(); r.setCode(SUCCESS_CODE); r.setMsg(msg); r.setData(null); return r;} /** * 请求失败,自定义信息 * * @param msg * @return */ public static Result fail(String msg) { Result r = new Result(); r.setCode(FAIL_CODE); r.setMsg(msg); r.setData(null); return r;} /** * 请求成功,自定义信息,自定义数据 * * @param msg * @return */ public static Result ok(String msg, Object data) { Result r = new Result(); r.setCode(SUCCESS_CODE); r.setMsg(msg); r.setData(data); return r;} /** * 请求失败,自定义信息,自定义数据 * * @param msg * @return */ public static Result fail(String msg, Object data) { Result r = new Result(); r.setCode(FAIL_CODE); r.setMsg(msg); r.setData(data); return r;} public Result code(Integer code){ this.setCode(code); return this;} public Result data(Object data){ this.setData(data); return this;} public Result msg(String msg){ this.setMsg(msg); return this;}}
服务层
package com.example.demo.upload.service;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import com.example.demo.upload.dao.FileMapper;import com.example.demo.upload.entity.FileDTO;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.util.List;@Servicepublic class FileService { @Autowired private FileMapper fileMapper; //保存文件 public void save(FileDTO file1){ //根据 数据库的 文件标识来查询 当前视频 是否存在 LambdaQueryWrapper<FileDTO> lambda = new QueryWrapper<FileDTO>().lambda(); lambda.eq(FileDTO::getFileKey,file1.getFileKey()); List<FileDTO> fileDTOS = fileMapper.selectList(lambda); //如果存在就话就修改 if(fileDTOS.size()!=0){ //根据key来修改 LambdaQueryWrapper<FileDTO> lambda1 = new QueryWrapper<FileDTO>().lambda(); lambda1.eq(FileDTO::getFileKey,file1.getFileKey()); fileMapper.update(file1,lambda1); }else { //不存在就添加 fileMapper.insert(file1); } } //检查文件 public List<FileDTO> check(String key){ LambdaQueryWrapper<FileDTO> lambda = new QueryWrapper<FileDTO>().lambda(); lambda.eq(FileDTO::getFileKey,key); List<FileDTO> dtos = fileMapper.selectList(lambda); return dtos; }}
控制层
package com.example.demo.upload.controller;import com.example.demo.upload.entity.FileDTO;import com.example.demo.upload.service.FileService;import com.example.demo.upload.utils.Result;import lombok.extern.slf4j.Slf4j;import org.apache.commons.io.FilenameUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.ResponseBody;import org.springframework.web.multipart.MultipartFile;import java.io.*;import java.util.List;import java.util.UUID;@Controller@RequestMapping("/file")@Slf4jpublic class FileController { @Autowired FileService fileService; public static final String BUSINESS_NAME = "普通分片上传"; // 设置图片上传路径 @Value("${file.basepath}") private String basePath; @RequestMapping("/show") public String show(){ return "file"; } /** * 上传 * @param file * @param suffix * @param shardIndex * @param shardSize * @param shardTotal * @param size * @param key * @return * @throws IOException * @throws InterruptedException */ @RequestMapping("/upload") @ResponseBody public String upload(MultipartFile file, String suffix, Integer shardIndex, Integer shardSize, Integer shardTotal, Integer size, String key) throws IOException, InterruptedException { log.info("上传文件开始"); //文件的名称 String name = UUID.randomUUID().toString().replaceAll("-", ""); // 获取文件的扩展名 String ext = FilenameUtils.getExtension(file.getOriginalFilename()); //设置图片新的名字 String fileName = new StringBuffer().append(key).append(".").append(suffix).toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4 //这个是分片的名字 String localfileName = new StringBuffer(fileName) .append(".") .append(shardIndex) .toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1 // 以绝对路径保存重名命后的图片 File targeFile=new File(basePath,localfileName); if(!targeFile.exists()){ targeFile.mkdirs(); } //上传这个图片 file.transferTo(targeFile); //数据库持久化这个数据 FileDTO file1=new FileDTO(); file1.setPath(basePath+localfileName); file1.setSuffix(suffix); file1.setName(name); file1.setSuffix(ext); file1.setSize(size); file1.setCreatedAt(System.currentTimeMillis()); file1.setUpdatedAt(System.currentTimeMillis()); file1.setShardIndex(shardIndex); file1.setShardSize(shardSize); file1.setShardTotal(shardTotal); file1.setFileKey(key); //插入到数据库中 //保存的时候 去处理一下 这个逻辑 fileService.save(file1); //判断当前是不是最后一个分页 如果不是就继续等待其他分页 合并分页 if(shardIndex.equals(shardTotal) ){ file1.setPath(basePath+fileName); this.merge(file1); } return "上传成功"; } @RequestMapping("/check") @ResponseBody public Result check(String key){ List<FileDTO> check = fileService.check(key); //如果这个key存在的话 那么就获取上一个分片去继续上传 if(check.size()!=0){ return Result.ok("查询成功",check.get(0)); } return Result.fail("查询失败,可以添加"); } /** * @author fengxinglie * 合并分页 */ private void merge(FileDTO fileDTO) throws FileNotFoundException, InterruptedException { //合并分片开始 log.info("分片合并开始"); String path = fileDTO.getPath(); //获取到的路径 没有.1 .2 这样的东西 //截取视频所在的路径 path = path.replace(basePath,""); Integer shardTotal= fileDTO.getShardTotal(); File newFile = new File(basePath + path); FileOutputStream outputStream = new FileOutputStream(newFile,true); // 文件追加写入 FileInputStream fileInputStream = null; //分片文件 byte[] byt = new byte[10 * 1024 * 1024]; int len; try { for (int i = 0; i < shardTotal; i++) { // 读取第i个分片 fileInputStream = new FileInputStream(new File(basePath + path + "." + (i + 1))); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1 while ((len = fileInputStream.read(byt)) != -1) { outputStream.write(byt, 0, len); } } } catch (IOException e) { log.error("分片合并异常", e); } finally { try { if (fileInputStream != null) { fileInputStream.close(); } outputStream.close(); log.info("IO流关闭"); } catch (Exception e) { log.error("IO流关闭", e); } } log.info("分片结束了"); //告诉java虚拟机去回收垃圾 至于什么时候回收 这个取决于 虚拟机的决定 System.gc(); //等待100毫秒 等待垃圾回收去 回收完垃圾 Thread.sleep(100); log.info("删除分片开始"); for (int i = 0; i < shardTotal; i++) { String filePath = basePath + path + "." + (i + 1); File file = new File(filePath); boolean result = file.delete(); log.info("删除{},{}", filePath, result ? "成功" : "失败"); } log.info("删除分片结束"); }}
页面
<html lang="en"><head> <meta charset="UTF-8"> <title>Titletitle>head><script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js">script><script type="text/javascript" src="/md5.js">script><script type="text/javascript" src="/tool.js">script><body><table border="1px solid red"> <tr> <td>文件1td> <td> <input name="file" type="file" id="inputfile"/> td> tr> <tr> <td>td> <td> <button onclick="check()">提交button> td> tr>table>body><script type="text/javascript"> //上传文件的话 得 单独出来 function test1(shardIndex) { console.log(shardIndex); //以formData的方式提交 var fd = new FormData(); //获取表单中的file var file = $('#inputfile').get(0).files[0]; //文件分片 以20MB为一分片 var shardSize = 20 * 1024 * 1024; //定义分片索引 var shardIndex = shardIndex; //定义分片的起始位置 var start = (shardIndex - 1) * shardSize; //定义分片结束的位置 var end = Math.min(file.size, start + shardSize); //按大小切割文件段 var fileShard = file.slice(start, end); //分片的大小 var size = file.size; //总片数 var shardTotal = Math.ceil(size / shardSize); //文件的后缀名 var fileName = file.name; var suffix = fileName.substring(fileName.lastIndexOf(".") + 1, fileName.length).toLowerCase(); //把视频的信息存储为一个字符串 var filedetails = file.name + file.size + file.type + file.lastModifiedDate; //使用当前文件的信息用md5加密生成一个key 这个加密是根据文件的信息来加密的 如果相同的文件 加的密还是一样的 var key = hex_md5(filedetails); var key10 = parseInt(key, 16); //把加密的信息 转为一个64位的 var key62 = Tool._10to62(key10); //前面的参数必须和controller层定义的一样 fd.append('file', fileShard); fd.append('suffix', suffix); fd.append('shardIndex', shardIndex); fd.append('shardSize', shardSize); fd.append('shardTotal', shardTotal); fd.append('size', size); fd.append("key", key62) $.ajax({ url: "/file/upload", type: "post", cache: false, data: fd, processData: false,//很重要,告诉jquery不要对form进行处理 contentType: false,//很重要,指定为false才能形成正确的Content-Type success: function (data) { //这里应该是一个递归调用 if (shardIndex < shardTotal) { var index = shardIndex + 1; test1(index); } else { alert(data) } }, error: function () { //请求出错处理 } }) //发送ajax请求把参数传递到后台里面 } //判断这个加密文件存在不存在 function check() { var file = $('#inputfile').get(0).files[0]; //把视频的信息存储为一个字符串 var filedetails = file.name + file.size + file.type + file.lastModifiedDate; //使用当前文件的信息用md5加密生成一个key 这个加密是根据文件的信息来加密的 如果相同的文件 加的密还是一样的 var key = hex_md5(filedetails); var key10 = parseInt(key, 16); //把加密的信息 转为一个64位的 var key62 = Tool._10to62(key10); //检查这个key存在不存在 $.ajax({ url: "/file/check", type: "post", data: {'key': key62}, success: function (data) { console.log(data); if (data.code == 500) { //这个方法必须抽离出来 test1(1); } else { if (data.data.shardIndex == data.data.shardTotal) { alert("极速上传成功"); } else { //找到这个是第几片 去重新上传 test1(parseInt(data.data.shardIndex)); } } } }) }script>html>
这里面注重点就是前端分片的时候,起始片和重点片的计算问题!因为我们知道如果一个文件35M,以20M为一分片的话,他其实是两片的。简单的除法计算1余15!
码云传送门
https://gitee.com/thirtyleo/FragmentUpload.git