SpringBoot实现文件分片断点续传功能(后端篇)

最近想实现一下分片上传的功能,文件分片上传就能实现断点续传了,数据库记录也能保证秒传功能的实现。记录一下宝宝demo。
前端部分可以看看这篇文章:Springboot实现文件分片断点续传功能(前端篇)
项目代码已上传至GitHub。 https://github.com/huiluczP/segment_upload

整体思路

  1. 首先利用数据库存储文件信息,包括文件物理地址,分片接收进程和对应的md5码。利用md5码可以判断当前上传文件是否在服务器中存在(实现秒传),利用分片接收Index可以判断现在应该上传。
  2. 前端ajax获取文件存在与否的信息,几种情况:
    (1) 不存在,则创建数据库记录,成功后调用分片1的上传。
    (2) 存在,Index和总分片数量相同,秒传成功显示结果。
    (3) 存在,但index小于总分片数量,调用分片index的上传。
  3. 分片在前端根据分片Index计算起点末尾,slice切割,ajax调用上传传到服务器并存储。当前分片传递成功,ajax接收success信息,串行进行index+1的分片的上传。

具体后端实现使用的是springboot+mybatis。

数据库设计和持久化操作

主要就是segment_file表,用来存储文件对应信息。
其中file_path表示文件最终的物理路径(未完成的分片文件也会存储在parent文件夹中)。
file_name是uuid,segment_index是已经完成的分片数量,segment_total为总的分片数量。
md5_key是文件对应的唯一key,实现中是在前端计算的。

CREATE TABLE `segment_file` (
  `id` int NOT NULL AUTO_INCREMENT,
  `file_path` varchar(200) NOT NULL,
  `file_name` varchar(200) DEFAULT NULL,
  `size` int DEFAULT NULL,
  `segment_index` int DEFAULT NULL,
  `segment_size` int DEFAULT NULL,
  `segment_total` int DEFAULT NULL,
  `md5_key` varchar(200) DEFAULT NULL,
  PRIMARY KEY (`id`)
)

持久化类 SegmentFile

public class SegmentFile implements Serializable {
    private static final long serialVersionUID = -1937877331354085546L;
    private int id;
    private String filePath;
    private String fileName;
    private int size;
    private int segmentIndex;
    private int segmentSize;
    private int segmentTotal;
    private String md5Key;
	
	getter…
	setter…
}

Mapper接口,这边使用注解进行mybatis sql语句的配置.

@Mapper
public interface SegmentFileMapper {

    // 获取对应的分片文件实体类
    @Select("select * from segment_file where md5_key = #{key}")
    @Results(id="segmentFileResult",value={
            @Result(id=true, column = "id",property = "id"),
            @Result(column = "file_path",property = "filePath"),
            @Result(column = "file_name",property = "fileName"),
            @Result(column = "size",property = "size"),
            @Result(column = "segment_index",property = "segmentIndex"),
            @Result(column = "segment_size",property = "segmentSize"),
            @Result(column = "segment_total",property = "segmentTotal"),
            @Result(column = "md5_key",property = "md5Key")
    })
    public List<SegmentFile> getSegmentFileByKey(String key);

    // 添加对应的文件实体类
    @Insert("insert into segment_file(id,file_path,file_name," +
            "size,segment_index,segment_size,segment_total,md5_key) " +
            "values(#{id},#{filePath},#{fileName},#{size},#{segmentIndex}," +
            "#{segmentSize},#{segmentTotal},#{md5Key})")
    public int insertSegmentFile(SegmentFile segmentFile);

    // 主要用来更新分片信息
    @Update({"update segment_file set " +
            "file_path = #{filePath},file_name = #{fileName},size = #{size}," +
            "segment_index = #{segmentIndex}, segment_size = #{segmentSize}," +
            "segment_total = #{segmentTotal}, md5_key = #{md5Key}" +
            "where id = #{id}" })
    public int updateSegmentFile(SegmentFile segmentFile);
}

特别的,我们对文件保存路径在配置文件application.yml中进行设置。同时,为了实现大文件分片的上传,关闭springboot的文件上传大小限制。数据库配置就不赘述了。

spring:
  servlet:
    multipart:
      max-file-size: -1
      max-request-size: -1
file:
  save-path: E:/file/

工具类实现

FileUtil为文件处理工具类,包括利用uuid和md5码的文件名生成。特别的,我们将原文件的名称后加上#和分片序号作为对应分片文件的名称。

// 工具类
// 文件名生成
public class FileUtil {
    public static String getFileNameWithoutSuffix(String fileName){
        int suffixIndex = fileName.lastIndexOf('.');
        if(suffixIndex<0)
            return fileName;
        return fileName.substring(0, suffixIndex);
    }

    public static String getFileSuffix(String fileName){
        int suffixIndex = fileName.lastIndexOf('.');
        if(suffixIndex<0)
            return "";
        return fileName.substring(suffixIndex+1);
    }

    public static String getSegmentName(String fileName, int segmentIndex){
        return fileName + "#" + segmentIndex;
    }

    public static String createSaveFileName(String key, String fileName){
        String suffix = getFileSuffix(fileName);
        return key + "." + suffix;
    }

    public static String createUUIDFileName(String fileName){
        String suffix = getFileSuffix(fileName);
        String name = UUID.randomUUID().toString();
        return name + "." + suffix;
    }
}

请求结果返回类,纯纯的简化,根据情况返回成功与否,message字符串也可以携带Json信息给前端使用。

public class ReturnResult {
    private boolean success;
    private String message;

    public ReturnResult(boolean success, String message){
        this.success = success;
        this.message = message;
    }

	getter…
	setter…
}

service功能实现

比较关键的业务类,包括文件存在确认,文件记录创建,文件信息更新,分片存储,分片合并和分片文件删除功能的实现。

文件存在确认

断点续传第一步就是获取文件上传情况,这样才能知道已上传文件要从哪个分片开始续传。

    // 该文件存在,返回数据
    public SegmentFile checkSegmentFile(String key){
        List<SegmentFile> segmentFiles = segmentFileMapper.getSegmentFileByKey(key);
        if(segmentFiles!=null&&segmentFiles.size()>0)
            return segmentFiles.get(0);
        else
            return null;
    }

简单调用mapper从数据库中找就行,由于文件的md5码是唯一的,所以使用key进行查询。

文件记录创建

文件不存在,那么第一步需要创建对应的文件记录,方便分片上传。

    // 第一次出现的文件,把数据存到数据库中
    // savePath为文件夹绝对位置
    public boolean createSegmentFile(String originFileName, String savePath, int size, int segmentSize, String key){
        String fileName = FileUtil.createUUIDFileName(originFileName);
        String saveFileName = FileUtil.createSaveFileName(key, originFileName);
        SegmentFile segmentFile = new SegmentFile();
        // filepath为完整路径
        segmentFile.setFilePath(savePath + saveFileName);
        segmentFile.setFileName(fileName);
        segmentFile.setSize(size);
        segmentFile.setSegmentIndex(0);
        segmentFile.setSegmentSize(segmentSize);
		
		// 计算分片总数
        int total = size / segmentSize;
        if(size % segmentSize != 0)
            total++;
        segmentFile.setSegmentTotal(total);
        segmentFile.setMd5Key(key);

        return segmentFileMapper.insertSegmentFile(segmentFile) > 0;
    }

使用file工具类创建各种对应文件名称,savePath在调用时从配置文件中读取。之后根据分片大小和文件总体大小计算分片总数,全部存储到数据库中。(其实因为md5码基本就是唯一的了,uuid并不需要直接用key作为文件名就行了,不过写都写了懒得改了。)

文件信息更新

数据库update,没什么好说的。

    // 更新总文件的数据库信息
    public boolean updateSegmentFileInfo(SegmentFile segmentFile){
        int result = segmentFileMapper.updateSegmentFile(segmentFile);
        return result>0;
    }

分片存储

包括分片文件名称计算,文件物理存储和数据库中信息修改。

    // 存储分片到服务器
    public boolean saveSegment(MultipartFile file, String savePath, String key, String originFileName, int segmentIndex){
        String saveFileName = FileUtil.createSaveFileName(key, originFileName);
        String segmentFileName = FileUtil.getSegmentName(saveFileName, segmentIndex);
        // 存储分片,方便之后使用
        boolean saveSuccess = upload(file, savePath+segmentFileName);
        if(saveSuccess){
            // 修改数据库中分片记录
            SegmentFile segmentFile = segmentFileMapper.getSegmentFileByKey(key).get(0);
            segmentFile.setSegmentIndex(segmentFile.getSegmentIndex()+1);
            int row = segmentFileMapper.updateSegmentFile(segmentFile);
            return row > 0;
        }else
            return false;
    }

	// 存储方法
    private boolean upload(MultipartFile file, String path){
        System.out.println(path);
        File dest = new File(path);
        //判断文件父目录是否存在

        if (!dest.getParentFile().exists()) {
            boolean b = dest.getParentFile().mkdir();
            if(!b){
                return b;
            }
        }
        //保存文件
        try {
            file.transferTo(dest);
            return true;
        } catch (IllegalStateException | IOException e) {
            e.printStackTrace();
            return false;
        }
    }

这边upload中直接用spring给的transferTo方法进行文件的转储。

分片整合功能

当所有分片成功上传,segmentIndexsegmentTotal相同时,需要将所有分片整合成总文件。总体思路就是根据名称按照index顺序读取所有的分片,同时利用write方法按顺序写到FileOutputStream中去,最后输出总文件。

    // 将所有的分片联合成同一文件
    public boolean mergeSegment(String key) {
        SegmentFile segmentFile = segmentFileMapper.getSegmentFileByKey(key).get(0);
        int segmentCount = segmentFile.getSegmentTotal();
        FileInputStream fileInputStream = null;
        FileOutputStream outputStream = null;
        byte[] byt = new byte[10 * 1024 * 1024];
        try {
            // 整合结果文件
            File newFile = new File(segmentFile.getFilePath());
            outputStream = new FileOutputStream(newFile, true);
            int len;
            for (int i = 0; i < segmentCount; i++) {
                String segmentFilePath = FileUtil.getSegmentName(segmentFile.getFilePath(), i + 1);
                fileInputStream = new FileInputStream(new File(segmentFilePath));
                while ((len = fileInputStream.read(byt)) != -1) {
                    outputStream.write(byt, 0, len);
                }
            }
        } catch (IOException e) {
            System.out.println("分片合并异常");
            return false;
        } finally {
            try {
                if (fileInputStream != null) {
                    fileInputStream.close();
                }
                if (outputStream != null) {
                    outputStream.close();
                }
                System.out.println("IO流关闭");
            } catch (Exception e) {
                System.out.println("IO流关闭");
            }
        }
        System.out.println("分片合并成功");
        return true;
    }

逻辑简单,主要就是出错时的try-catch和返回比较绕。

分片文件删除功能

完成分片整合后需要将不需要的分片文件删除,需要注意,直接调用删除的话可能会有对象数据流还占用着文件导致删除失败,所以先显示调用下gc回收对象再尝试删除。
之后还有没删干净的文件,利用visit表记录有没有删除同时自旋判断是否删除干净。在外部调用这个方法时需要另开线程并行防止自旋影响正常功能。

    // 完成合并,删除分片文件
    public void deleteSegments(String key) throws InterruptedException {
        // 为了保证不被占用,先回收数据流对象
        System.gc();
        Thread.sleep(1000);
        SegmentFile segmentFile = segmentFileMapper.getSegmentFileByKey(key).get(0);
        int segmentCount = segmentFile.getSegmentTotal();
        ArrayList<String> remain = new ArrayList<>();
        int finished = 0;
        int[] visited = new int[segmentCount];
        for (int i = 0; i < segmentCount; i++) {
            String segmentFilePath = FileUtil.getSegmentName(segmentFile.getFilePath(), i + 1);
            remain.add(segmentFilePath);
            File file = new File(segmentFilePath);
            boolean result = file.delete();
            if(result) {
                finished++;
                visited[i] = 1;
            }
            System.out.println("分片文件:" + segmentFilePath + "删除" + (result?"成功":"失败"));
        }
        // visited数组,然后完成了再去除,知道count到达总数
        while(finished<segmentCount){
            System.gc();
            Thread.sleep(1000);
            for(int i=0;i<segmentCount;i++){
                if(visited[i]==0){
                    String segmentFilePath = FileUtil.getSegmentName(segmentFile.getFilePath(), i + 1);
                    remain.add(segmentFilePath);
                    File file = new File(segmentFilePath);
                    boolean result = file.delete();
                    if(result){
                        visited[i] = 1;
                        finished++;
                    }
                    System.out.println("分片文件:" + segmentFilePath + "删除" + (result?"成功":"失败"));
                }
            }
        }
    }

当全部visited之后,就结束自旋,完成分片删除。

controller控制类实现

主要是两个方法,一个是判断当前文件上传状况,主要就是想在前端选中文件后就调用一下,显示文件上传状态,这样就能实现秒传功能的效果了。第二个就是上传分片功能,下面具体讲一下。
controller中先设置service,json objectMapper,存储地址。

    @Autowired
    SegmentFileService segmentFileService;

    @Autowired
    private ObjectMapper mapper;

    @Value("${file.save-path}")
    private String savePath;

查询文件状态checkFileExist

输入参数为文件对应的唯一编码,前端计算。

    @RequestMapping("/checkFile")
    @ResponseBody
    // 检查文件是否已经存在,且返回segment信息
    public ReturnResult checkFileExist(String key) throws JsonProcessingException {
        SegmentFile segmentFile = segmentFileService.checkSegmentFile(key);
        if(segmentFile==null)
            return new ReturnResult(false, "该文件未上传");
        else{
            // 转成json回去用
            String fileJson = mapper.writeValueAsString(segmentFile);
            return new ReturnResult(true, fileJson);
        }
    }

返回类中success表示文件是否存在,message中包含文件信息的json结果。

分片上传功能

主要分为以下几步:文件状态判断和信息写入;当前分片存储;分片上传完成判断;分片合并;分片删除;
输入参数包括:文件本身,文件原名(用来获取后缀名),文件总大小,分片序号,分片大小和文件对应唯一码。

文件判断和信息写入,就是简单判断文件是否存在,不存在就调用create方法。

    public ReturnResult upLoadSegmentFile(MultipartFile file, String originFileName, Integer fileSize, Integer segmentIndex, Integer segmentSize, String key) throws JsonProcessingException{
        System.out.println("文件 " + originFileName + " 开始");
        // 查找是否存在,不存在就写入
        SegmentFile segmentFile = segmentFileService.checkSegmentFile(key);
        if(segmentFile==null){
            boolean writeSuccess = segmentFileService.createSegmentFile(originFileName, savePath, fileSize, segmentSize, key);
            if(!writeSuccess){
                // 写入失败,返回错误信息
                return new ReturnResult(false, "文件数据库记录创建失败");
            }
        }
        segmentFile = segmentFileService.checkSegmentFile(key);

当前分片存储,存储后进行数据库记录的更新。

        // 将当前分片存入
        boolean segmentWriteSuccess = segmentFileService.saveSegment(file, savePath, key, originFileName, segmentIndex);
        if(!segmentWriteSuccess)
            // 分片存储失败
            return new ReturnResult(false, "分片文件失败");
        segmentFile.setSegmentIndex(segmentIndex);
        boolean isUpdateSuccess = segmentFileService.updateSegmentFileInfo(segmentFile);
        if(!isUpdateSuccess)
            // 更新失败
            return new ReturnResult(false, "文件数据库记录更新失败");

内部线程类,用来后续实现删除功能。

        class deleteThread implements Runnable{
            @Override
            public void run() {
                try {
                    segmentFileService.deleteSegments(key);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

判断分片是否上传完成,合并并开始删除所有分片文件。

        // 判断是否分片齐全,齐全则合并生成究极文件
        // 其实考虑这步会不会失败应该在数据库再加一个值,再说吧
        if(segmentIndex==segmentFile.getSegmentTotal()){
            boolean mergeSuccess = segmentFileService.mergeSegment(key);
            if(mergeSuccess) {
                // 另开线程去自旋删除
                new Thread(new deleteThread()).start();
                return new ReturnResult(true, mapper.writeValueAsString(segmentFile));
            }
            else
                return new ReturnResult(false, "文件合并失败");
        }
        return new ReturnResult(true, mapper.writeValueAsString(segmentFile));

总结

这篇文章主要是记录了springboot框架下的文件断点续传后端流程,觉得有用就看看吧。
因为是默认串行调用,文件已上传分片信息直接用当前上传的分片序号覆盖。如果要并行实现的话,数据库中可能需要存储一个总分片数量大小长度的字符串,用来记录上传进度(状态压缩),比如111011,表示6个分片,分片4未上传,这样就能并行上传分片了,有机会实现一下并行的。
项目代码已上传至GitHub https://github.com/huiluczP/segment_upload
前端部分可以看看这篇文章:SpringBoot实现文件分片断点续传功能(前端篇)

你可能感兴趣的:(spring,java,java,springboot,spring)