说明

本文仅介绍后端实现逻辑,前端请查看我的另一篇文章:前端使用 vue-simple-uploader 实现文件分片上传、断点续传及秒传

功能分析

分片上传

顾名思义,就是将需要上传的文件,按一定的规则分成多个数据块来上传。在上传大文件时,如果采用全量上传,在遇到网络问题时,上传失败,再次上传时又得从文件起始位置上传,这样用户体验不好,且又影响上传效率。采用分片上传可以避免此类问题,在遇到传输失败时,只需要将上传失败或未上传的数据块上传,而不需要重新上传。

流程

分片上传的流程大致如下:

  1. 前端将需要上传的问题按一定的规则分片;
  2. 前端上传文件数据块,后台处理分片并返回结果;
  3. 前端上传完成后,发送文件合并请求,后台合并文件,分片上传完成。

还有另一种方案(本文基于此方案):

  1. 前端将文件按一定的规则分片;
  2. 前端上传分片数据块,后台根据上传的序号和分片大小计算相应的开始位置并写入该分片数据到文件中。

整体流程:

整体流程

断点续传

由于网络故障或其他原因,导致上传暂停或失败后,再次上传时从上次上传的位置继续上传,这基于分片上传。

秒传

在上传文件时通过文件 MD5 查询是否有该文件已上传,有则直接返回上传结果,不再上传该文件。

功能实现

后端

  1. 上传参数实体类
@Getter
@Setter
@ToString
@Accessors(chain = true)
public class FileChunkParam extends BaseParam {

    @NotNull(message = "当前分片不能为空")
    private Integer chunkNumber;

    @NotNull(message = "分片大小不能为空")
    private Float chunkSize;

    @NotNull(message = "当前分片大小不能为空")
    private Float currentChunkSize;

    @NotNull(message = "文件总数不能为空")
    private Integer totalChunks;

    @NotBlank(message = "文件标识不能为空")
    private String identifier;

    @NotBlank(message = "文件名不能为空")
    private String filename;

    private String fileType;

    private String relativePath;

    @NotNull(message = "文件总大小不能为空")
    private Float totalSize;

    private MultipartFile file;
}
  1. Service 层实现
@Service("fileService")
@Slf4j
public class FileServiceImpl implements IFileService {

    /**
     * 默认的分片大小:20MB
     */
    public static final long DEFAULT_CHUNK_SIZE = 20 * 1024 * 1024;

    public static final String BASE_FILE_SAVE_PATH = "d:/UploadFile/986310747";

    private final IFileChunkService fileChunkService;

    @Autowired
    public FileServiceImpl(IFileChunkService fileChunkService) {
        this.fileChunkService = fileChunkService;
    }

    @Override
    public boolean uploadFile(FileChunkParam param) {
        if (null == param.getFile()) {
            throw new BusinessException(MessageEnum.UPLOAD_FILE_NOT_NULL);
        }
        // 判断目录是否存在,不存在则创建目录
        File savePath = new File(BASE_FILE_SAVE_PATH);
        if (!savePath.exists()) {
            boolean flag = savePath.mkdirs();
            if (!flag) {
                log.error("保存目录创建失败");
                return false;
            }
        }
        // 这里可以使用 uuid 来指定文件名,上传完成后再重命名
        String fullFileName = savePath + File.separator + param.getFilename();
        // 单文件上传
        if (param.getTotalChunks() == 1) {
            return uploadSingleFile(fullFileName, param);
        }
        // 分片上传,这里使用 uploadFileByRandomAccessFile 方法,也可以使用 uploadFileByMappedByteBuffer 方法上传
        boolean flag = uploadFileByRandomAccessFile(fullFileName, param);
        if (!flag) {
            return false;
        }
        // 保存分片上传信息
        fileChunkService.saveFileChunk(param);
        return true;
    }

    private boolean uploadFileByRandomAccessFile(String resultFileName, FileChunkParam param) {
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(resultFileName, "rw")) {
            // 分片大小必须和前端匹配,否则上传会导致文件损坏
            long chunkSize = param.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : param.getChunkSize().longValue();
            // 偏移量
            long offset = chunkSize * (param.getChunkNumber() - 1);
            // 定位到该分片的偏移量
            randomAccessFile.seek(offset);
            // 写入
            randomAccessFile.write(param.getFile().getBytes());
        } catch (IOException e) {
            log.error("文件上传失败:" + e);
            return false;
        }
        return true;
    }

    private boolean uploadFileByMappedByteBuffer(String resultFileName, FileChunkParam param) {
        // 分片上传
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(resultFileName, "rw");
             FileChannel fileChannel = randomAccessFile.getChannel()) {
            // 分片大小必须和前端匹配,否则上传会导致文件损坏
            long chunkSize = param.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : param.getChunkSize().longValue();
            // 写入文件
            long offset = chunkSize * (param.getChunkNumber() -1);
            byte[] fileBytes = param.getFile().getBytes();
            MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, offset, fileBytes.length);
            mappedByteBuffer.put(fileBytes);
            // 释放
            unmap(mappedByteBuffer);
        } catch (IOException e) {
            log.error("文件上传失败:" + e);
            return false;
        }
        return true;
    }

    private boolean uploadSingleFile(String resultFileName, FileChunkParam param) {
        File saveFile = new File(resultFileName);
        try {
            // 写入
            param.getFile().transferTo(saveFile);
        } catch (IOException e) {
            log.error("文件上传失败:" + e);
            return false;
        }
        return true;
    }

    /**
     * 释放 MappedByteBuffer
     * 在 MappedByteBuffer 释放后再对它进行读操作的话就会引发 jvm crash,在并发情况下很容易发生
     * 正在释放时另一个线程正开始读取,于是 crash 就发生了。所以为了系统稳定性释放前一般需要检
     * 查是否还有线程在读或写
     * 来源:https://my.oschina.net/feichexia/blog/212318
     * @param mappedByteBuffer mappedByteBuffer
     */
    public static void unmap(final MappedByteBuffer mappedByteBuffer) {
        try {
            if (mappedByteBuffer == null) {
                return;
            }
            mappedByteBuffer.force();
            AccessController.doPrivileged((PrivilegedAction<Object>) () -> {
                try {
                    Method getCleanerMethod = mappedByteBuffer.getClass()
                            .getMethod("cleaner");
                    getCleanerMethod.setAccessible(true);
                    Cleaner cleaner =
                            (Cleaner) getCleanerMethod
                                    .invoke(mappedByteBuffer, new Object[0]);
                    cleaner.clean();
                } catch (Exception e) {
                    log.error("MappedByteBuffer 释放失败:" + e);
                }
                System.out.println("clean MappedByteBuffer completed");
                return null;
            });
        } catch (Exception e) {
            log.error("unmap error:" + e);
        }
    }
}
  1. Controller 层实现
@RestController
@Slf4j
public class FileUploadController {

    private final IFileService fileService;

    private final IFileChunkService fileChunkService;

    @Autowired
    public FileUploadController(IFileService fileService, IFileChunkService fileChunkService) {
        this.fileService = fileService;
        this.fileChunkService = fileChunkService;
    }

    @GetMapping("/upload")
    public JsonResult<Map<String, Object>> checkUpload(@Valid FileChunkParam param) {
        log.info("文件MD5:" + param.getIdentifier());
        List<FileChunkDTO> list = fileChunkService.listByFileMd5(param.getIdentifier());
        Map<String, Object> data = new HashMap<>(1);
        if (list.size() == 0) {
            data.put("uploaded", false);
            return JsonResult.ok(data);
        }
        // 处理单文件
        if (list.get(0).getTotalChunks() == 1) {
            data.put("uploaded", true);
            // todo 返回 url
            data.put("url", "");
            return JsonResult.ok(data);
        }
        // 处理分片
        int[] uploadedFiles = new int[list.size()];
        int index = 0;
        for (FileChunkDTO fileChunkItem : list) {
            uploadedFiles[index] = fileChunkItem.getChunkNumber();
            index++;
        }
        data.put("uploadedChunks", uploadedFiles);
        return JsonResult.ok(data);
    }

    @PostMapping("/upload")
    public JsonResult<String> chunkUpload(@Valid FileChunkParam param) {
        boolean flag = fileService.uploadFile(param);
        if (!flag) {
            return JsonResult.error(MessageEnum.FAIL);
        }
        return JsonResult.ok();
    }

}

总结

本文仅介绍上传业务的思路及关键代码,更多扩展功能请自己钻研哦。

源码地址:spring-boot-file-upload

参考文献

[1]. Java实现浏览器端大文件分片上传

[2]. 分片上传及断点续传原理深入分析及示例Demo

[3]. springboot实战之文件分片上传、断点续传、秒传

文章目录