springboot 后端大文件分片上传(断点上传)

目录

简介:

1. 数据库表创建:

2. 编写文件记录标识接口:

3. 编写分片是否存在判断接口:

4. 编写文件分片上传接口:

5. 编写文件分片合并接口:


简介:

       什么是分片上传?分片上传就是把一个大的文件分成N个部分,然后一部分一部分的进行上传。

       分片上传有什么好处?分片上传可以实现断网、关浏览器、传输错误等情况发生后,再次进行

此文件的上传的时候,已上传的部分无需继续上传,减少上传文件的流量消耗与用户的等待时间。

       后端如何实现分片上传?后端传统上传,直接开发一个接口,接受文件即可;但是分片上传如

何实现呢,按照分片的解读理解,咱们可以将上传接口进行拆分:

       ①. 首先咱们需要一个接口,来记录标识一个文件,这样才能区分不同文件;

       ②. 然后咱们还需要一个接口,用来判断某个分片是否已经上传完成;

       ③. 再然后,还需要一个接口,用来进行分片文件的上传(其实就是一个简单的上传功能,将文件

分片存放,并且记录到文件分片上传记录中);

       ④. 最后还需要一个最重要的接口,那当然就是分片的合并接口了,调用此接口按照顺序将某个文

件的所有分片合并成真实的对应的文件。

       那么废话不多说,下面咱们就开始编码,有问题可留言评论;断点上传不是断点续传,断点续传是特

指下载(这又是另外的知识了)!

注:ResponseVo为自定义统一返回格式,自行定义;

       接口中用到jackjson包;

       接口中用到CommonException为自定义异常,自行定义,或其他方式处理;

       接口中用到FileExistsResponse为判断文件分片是否存在的返回数据结构,自行定义;

       接口中用到的filemapper等数据库层的内容自行实现(数据库涉及到一张表file_upload);

       FileUpload为文件上传表file_upload的实体;

1. 数据库表创建:

create table file_upload
(
   id                 NUMBER               not null,
   index              NUMBER,
   parent             NUMBER,
   name               VARCHAR2(100 char)        not null,
   path               VARCHAR2(255 char),
   md5                VARCHAR2(200 char)        not null,
   size               NUMBER               not null
   constraint PK_FILE_UPLOAD primary key (id)
);

2. 编写文件记录标识接口:

       此接口每个文件调用一次,调用完成后,需要返回文件记录的信息,主要是返回ID,以便

前端拿到后,作为该文件所有分片的parent。

接口入参:

public class FileNewRequest {

  private String index;
  private String parent;
  private String name;
  private String path;
  private String md5;
  private String size;

  public String getIndex() {
    return index;
  }

  public void setIndex(String index) {
    this.index = index;
  }

  public String getParent() {
    return parent;
  }

  public void setParent(String parent) {
    this.parent = parent;
  }

  public String getName() {
    return name;
  }

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

  public String getPath() {
    return path;
  }

  public void setPath(String path) {
    this.path = path;
  }

  public String getMd5() {
    return md5;
  }

  public void setMd5(String md5) {
    this.md5 = md5;
  }

  public String getSize() {
    return size;
  }

  public void setSize(String size) {
    this.size = size;
  }
}

接口:

    /**
      * 新创建一个文件标识记录
      * @author kevin
      * @param request :
      * @return com.liu.services.system.entity.FileUpload
      * @date 2021/1/8
      */
    @ApiOperation(value = "文件标识记录", notes = "/file/new")
    @PostMapping("/file/new")
    public ResponseVo uploadFile(@Valid @RequestBody FileNewRequest request) {
        String fileName = request.getName();
        String md5 = request.getMd5();
        try {
            //写入文件标识记录
            FileUpload file = (FileUpload) JSONObject.toBean(JSONObject.fromObject(request), FileUpload.class);
            //如果文件标识记录不存在,则插入;如果存在,则更新文件大小
            FileUpload fileUpload = fileMapper.findByMd5AndName(md5, fileName);
            if (ObjectUtils.isNotEmpty(fileUpload)) {
                fileMapper.updateSize(file);
            } else {
                fileMapper.insert(file);
            }
            
            fileUpload = fileMapper.findByMd5AndName(md5, fileName);
            return new ResponseVo.Builder().ok().data(fileUpload).build();
        }catch (Exception e){
            return new ResponseVo.Builder().error().message(String.format("文件:%1$s 上传错误",
                    fileName)).build();
        }
    }

3. 编写分片是否存在判断接口:

此接口前端每个分片处理都需要调用判断,跟第4点中的接口配合使用;此处parent为第2点中接口返回的ID

接口入参:

public class FileExistsRequest {

    private Long parent;
    private String md5;
    private Long size;

    public Long getParent() {
        return parent;
    }

    public void setParent(Long parent) {
        this.parent = parent;
    }

    public String getMd5() {
        return md5;
    }

    public void setMd5(String md5) {
        this.md5 = md5;
    }

    public Long getSize() {
        return size;
    }

    public void setSize(Long size) {
        this.size = size;
    }
}

接口: 

    /**
     * 文件分片上传,判断文件记录或者文件分片是否已经存在
     *
     * @param request :
     * @return com.liu.base.ResponseVo
     * @author kevin
     * @date 2021/1/9
     */
    @PostMapping("/file/exists")
    public ResponseVo fileExists(@Valid @RequestBody FileExistsRequest request) {
        FileUpload file;
        if(null != request.getParent()){
            file = fileMapper.findByParentAndMd5(String.valueOf(request.getParent()), request.getMd5());
        }else{
            List<FileUpload> files = fileMapper.findByMd5(request.getMd5());
            //查找父文件ID不为空的记录(即文件分片记录)
            files = files.stream().filter(item -> item.getParent() != null).collect(Collectors.toList());
            int count = files.size();
            if(count > 1){
                throw new CommonException("找到了不止一个分片/文件记录");
            }else if (count < 1){
                file = null;
            }else {
                file = files.get(0);
            }
        }
        //文件标识或文件片不存在
        if(file == null) {
            return new ResponseVo.Builder().ok().data(FileExistsResponse.nonExistent()).build();
        }
        //无父文件ID,则表示记录为文件标识记录,判断文件是否存在
        if(null == file.getParent() || 0 == file.getParent()){
            List<FileUpload> patchs = fileMapper.findByParent(file.getId());
            Long total = patchs.stream().mapToLong(FileUpload::getSize).sum();
            if(total.equals(request.getSize())) {
                return new ResponseVo.Builder().ok().data(FileExistsResponse.exists(file.getId())).build();
            }else if(total == 0) {
                return new ResponseVo.Builder().ok().data(FileExistsResponse.nonExistent()).build();
            }else {
                return new ResponseVo.Builder().ok().data(
                        FileExistsResponse.partExistent(file.getId(), fileMapper.findPatchIndexByParent(file.getId()))
                ).build();
            }

        }
        //父文件ID存在,需要判断的内容为文件片
        //文件片存在且大小为正确的大小,表示文件片已存在
        if(file.getFileSize().equals(request.getFileSize())) {
            return new ResponseVo.Builder().ok().data(FileExistsResponse.exists(file.getId())).build();
        }
        //文件片大小为0表示文件片还未上传
        if(file.getFileSize() == 0) {
            return new ResponseVo.Builder().ok().data(FileExistsResponse.nonExistent()).build();
        }
        //其他文件片大小不为0又不等于需要上传的文件片大小,表示文件片存在部分
        return new ResponseVo.Builder().ok().data(
                FileExistsResponse.partExistent(file.getId(), fileMapper.findPatchIndexByParent(file.getId()))
        ).build();
    }

4. 编写文件分片上传接口:

此接口在前端调用文件分片是否存在判断分片不存在或只存在部分后调用;此处parent为第2点中接口返回的ID

接口入参:

public class FilePatchUploadRequest {

    private String name;
    private Long index;
    private Long parent;
    private String md5;
    private Long size;

    public String getName() {
        return name;
    }

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

    public Long getIndex() {
        return index;
    }

    public void setIndex(Long index) {
        this.index = index;
    }

    public Long getParent() {
        return parent;
    }

    public void setParent(Long parent) {
        this.parent = parent;
    }

    public String getMd5() {
        return md5;
    }

    public void setMd5(String md5) {
        this.md5 = md5;
    }

    public Long getSize() {
        return size;
    }

    public void setSize(Long size) {
        this.size= size;
    }
}

接口:

    /**
     * 文件分片上传
     *
     * @param request :
     * @param patch   :
     * @return com.liu.base.ResponseVo
     * @author kevin
     * @date 2021/1/7
     */
    @PostMapping("/file/patch/upload")
    public ResponseVo filePatchUpload(@Valid FilePatchUploadRequest request,
                                      @RequestParam("patch") MultipartFile patch){
        Long index = request.getIndex();
        //判断分片文件是否存在
        if(patch.isEmpty()){
            return new ResponseVo.Builder().error().message("第:%1$s 个分片未获取到分片文件!").build();
        }
        Long parent = request.getParent();
        Long size = request.getSize();
        String md5 = request.getMd5();
        String name = request.getName();

        //开始分片保存
        FileUpload file = fileMapper.findByParentAndMd5(String.valueOf(parent), md5);
        try {
            //判断数据库如果分片不存在,或者分片大小与上传的分片的大小不一致,则上传保存分片
            if (file == null || !file.getFileSize().equals(size)) {
                Optional.ofNullable(file).ifPresent(e -> fileMapper.deleteByPk(e.getId()));
                //保存分片文件到服务器,构建文件分片记录信息
                FileUpload fileUpload = new FileUpload(index, parent, name,
                        saveFile(patch, size, "d:/"), md5, size);
                return new ResponseVo.Builder().ok().build();
            }
        }catch (IOException e){
            log.error(SystemConst.PATCH_UPLOAD_FILE_ERROR_LOG, name, index, e);
            throw new CommonException(ResponseState.REQUEST_ERROR.getCode(),
                    String.format(SystemConst.UPLOAD_FILE_PATCH_ERROR, name, index));
        }
        return new ResponseVo.Builder().ok().build() ;
    }

    /**
      * 保存文件块(此方法可提取为一个工具类中的工具方法)
      * @author kevin
      * @param source : 文件块
      * @param size : 文件块大小
      * @return java.lang.String
      * @date 2021/1/7
      */
    private String saveFile(MultipartFile source, Long size, String path) throws IOException {
        String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern(SystemConst.DATE_ONLY_FORMATTER));
        if(null == source || source.isEmpty()) {
            throw new CommonException("未获取到需要保存的分片文件!");
        }
        if(source.getSize() != size) {
            throw new CommonException("分片文件大小与传入的分片大小不一致!");
        }
        path = path + File.separator + now;
        File file = new File(path);
        if(!file.exists() && !file.mkdirs()) {
            throw new CommonException("创建文件夹失败");
        }
        //根据文件名获取文件类型
        String fileType = (null == fileName || !fileName.contains(".")) ? "unknown" :
                fileName.substring(fileName.lastIndexOf("."));
        //保存文件块到磁盘
        while(true) {
            String saveFileName = UUID.randomUUID() + fileType.toLowerCase() + ".tmp";
            File saveFilePath = new File(path, saveFileName);
            //磁盘上块文件名如果已经存在,则重新生成文件名
            if(saveFilePath.exists()) {
                continue;
            }
            source.transferTo(saveFilePath);
            return saveFilePath.getAbsolutePath();
        }
    }

5. 编写文件分片合并接口:

此接口在前端确认所有分片已完成上传后调用。此处parent为第2点中接口返回的ID

接口入参:

public class FileMergeRequest {

    private Long parent;
    private Long fileSize;

    public Long getParent() {
        return parent;
    }

    public void setParent(Long parent) {
        this.parent = parent;
    }

    public Long getFileSize() {
        return fileSize;
    }

    public void setFileSize(Long fileSize) {
        this.fileSize = fileSize;
    }
}

接口:

    /**
      * 文件分片合并
      * @author kevin
      * @param request :
      * @return com.liu.base.ResponseVo
      * @date 2021/1/8
      */
    @ApiOperation(value = "文件分片合并", notes = "/file/merge")
    @PostMapping("/file/merge")
    public ResponseVo filePatchMerge(@Valid @RequestBody FileMergeRequest request) {
        Long parent = request.getParent();
        Long size = request.getFileSize();
        //查询当前文件
        FileUpload fileInfo = fileMapper.findByPk(parent);
        String fileName = null == fileInfo ? "" : fileInfo.getName();
        String md5 = null == fileInfo ? "" : fileInfo.getMd5();
        try {
            //查询当前文件所有分块
            List<FileUpload> patchs = fileMapper.findByParentPatchIndex(parent);
            Long total = patchs.stream().mapToLong(FileUpload::getFileSize).sum();
            if (fileInfo == null || CollectionUtils.isEmpty(patchs) || !total.equals(size)) {
                fileMapper.deleteByParent(parent);
                log.error(String.format("文件合并失败,大小不正确");
            return new ResponseVo.Builder().error().message("文件合并失败,大小不正确!").build();
            }
            //合并文件
            String path = mergeFile(fileName, patchs.stream().map(FileUpload::getPath)
                    .collect(Collectors.toList()), filePath, userName);
            fileMapper.updateByIdSetPathAndSize(String.valueOf(parent), path, String.valueOf(total));
            fileMapper.deleteByParent(parent);
        }catch (Exception e){
            fileMapper.deleteByParent(parent);
            e.printStackTrace();
            return new ResponseVo.Builder().error().message("文件合并失败"+e.getMessage()).build();
        }

        return new ResponseVo.Builder().ok().build();
    }


    /**
      * 合并文件块(此方法可提取到工具类)
      * @author kevin
      * @param fileName : 文件名称
      * @param filePaths : 文件分片路径
      * @param path : 文件存放路径
      * @param directory : 文件存放目录
      * @return java.lang.String
      * @date 2021/1/8
      */
    private String mergeFile(String fileName, List<String> filePaths, String path, String directory) throws IOException {
        String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern(SystemConst.DATE_ONLY_FORMATTER));
        path = path + File.separator + (StringUtils.isEmpty(directory) ? "" : (directory + File.separator))+ now;
        File file = new File(path);
        if(!file.exists() && !file.mkdirs()) {
            throw new CommonException("创建文件夹失败!");
        }
        //生成文件名
        File saveFilePath = new File(path, fileName);
        while (saveFilePath.exists()){
            //生成文件名
            Files.delete(saveFilePath.toPath());
        }

        //创建文件输出流
        try (FileOutputStream fos = new FileOutputStream(saveFilePath)){
            FileChannel out = fos.getChannel();
            //循环每一个文件块对应的文件
            for (String filename : filePaths) {
                File patch = new File(filename);
                //将每一个文件块的内容都追加到新文件中
                try (FileChannel in = new FileInputStream(patch).getChannel()) {
                    in.transferTo(0, in.size(), out);
                }
                //追加完成后,删除文件块
                Files.delete(patch.toPath());
            }
            out.close();
        }
        return saveFilePath.getAbsolutePath();
    }

 

  • 1
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
好的,我可以给您提供一个简单的示例。 首先,您需要在前端使用vue实现文件分片上传。在上传过程中,前端会将文件分成多个小文件,每个小文件分别上传,并记录下每个小文件上传状态。在上传完成后,前端会将这些小文件的信息发送到后端。 接下来,您需要后端使用Springboot实现分片下载。后端会接收到前端传来的文件信息,然后根据这些信息,将大文件分成多个小文件,并将这些小文件的信息返回给前端。前端可以使用这些小文件的信息来进行断点下载,并计算下载进度。 以下是一个简单的前后端示例: 前端示例代码: ```javascript // 文件上传 const uploadFile = (file) => { // 将文件分成多个小文件,每个小文件都是1MB大小 const chunkSize = 1024 * 1024 // 1MB const chunks = Math.ceil(file.size / chunkSize) const promises = [] let currentChunk = 0 while (currentChunk < chunks) { const start = currentChunk * chunkSize const end = Math.min(start + chunkSize, file.size) const chunk = file.slice(start, end) promises.push(uploadChunk(chunk, currentChunk, chunks)) currentChunk++ } return Promise.all(promises) .then((res) => { // 将所有小文件的信息发送给后端 const data = { name: file.name, size: file.size, chunks: chunks, type: file.type, chunksInfo: res, } // 发送文件信息到后端 return axios.post('/api/file', data) }) } // 上传单个小文件 const uploadChunk = (chunk, index, total) => { const formData = new FormData() formData.append('chunk', chunk) formData.append('index', index) formData.append('total', total) // 发送小文件后端 return axios.post('/api/file/chunk', formData) } // 下载文件 const downloadFile = (url) => { // 发送请求,获取文件信息 return axios.get(url) .then((res) => { const data = res.data const name = data.name const size = data.size const chunks = data.chunks const type = data.type const chunksInfo = data.chunksInfo // 计算已经下载的大小 let downloaded = 0 for (let i = 0; i < chunksInfo.length; i++) { if (chunksInfo[i].downloaded) { downloaded += chunksInfo[i].size } } // 计算下载进度 const progress = downloaded / size // 创建一个Blob对象,用于存放文件数据 const blob = new Blob([], { type: type }) // 创建一个URL对象,用于指向Blob对象的URL const url = URL.createObjectURL(blob) // 下载文件 const a = document.createElement('a') a.href = url a.download = name document.body.appendChild(a) a.click() document.body.removeChild(a) }) } ``` 后端示例代码: ```java @RestController @RequestMapping("/api/file") public class FileController { @PostMapping public ResponseEntity<?> uploadFile(@RequestBody FileRequest fileRequest) { // 将文件信息保存到数据库或文件系统 // 返回小文件的信息,包括每个小文件的URL和已上传的大小 return ResponseEntity.ok(chunksInfo); } @PostMapping("/chunk") public ResponseEntity<?> uploadChunk(@RequestParam("chunk") MultipartFile chunk, @RequestParam("index") int index, @RequestParam("total") int total) { // 将小文件保存到临时文件夹中 // 返回小文件的URL和大小(以便后面计算进度) return ResponseEntity.ok(chunkInfo); } @GetMapping("/{id}") public ResponseEntity<?> downloadFile(@PathVariable("id") Long id) { // 获取文件信息,包括每个小文件的URL和已上传的大小 // 将小文件合并成一个大文件,并返回给前端 return ResponseEntity.ok(file); } } ``` 以上代码仅供参考,具体实现可能会有所不同。同时,为了保证安全性,您需要上传和下载文件时进行身份验证和权限控制。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值