【Java】 Springboot+Vue 大文件断点续传

 

同事在重构老系统的项目时用到了这种大文件上传

 

第一篇文章是简书的这个:

https://www.jianshu.com/p/b59d7dee15a6

 

是夏大佬写的vue-uploader组件:

https://www.cnblogs.com/xiahj/p/15950975.html

然后晚上看完才发现,没有后台接口...

 

然后我找了下网上的资料也不多,然后参考的是这位来实现:

https://github.com/LuoLiangDSGA/spring-learning/tree/bc60e349b4c573fb624230d068f7bb66a9e64736/boot-uploader

 

我的Springboot接口实现:

1、Chuck分块对象实体:

package cn.cloud9.server.struct.file.dto;

import com.alibaba.fastjson.annotation.JSONField;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.data.annotation.Transient;
import org.springframework.web.multipart.MultipartFile;

import java.io.Serializable;

/**
 * @author OnCloud9
 * @description
 * @project tt-server
 * @date 2022年11月18日 下午 11:02
 */

@Data
@EqualsAndHashCode(callSuper = true)
@TableName("chuck")
public class Chuck extends ChuckFile implements Serializable {

    /**
     * 当前文件块,从1开始
     */
    @TableField("CHUNK_NUMBER")
    private Integer chunkNumber;
    /**
     * 分块大小
     */
    @TableField("CHUNK_SIZE")
    private Long chunkSize;
    /**
     * 当前分块大小
     */
    @TableField("CURRENT_CHUNK_SIZE")
    private Long currentChunkSize;

    /**
     * 相对路径
     */
    @TableField("RELATIVE_PATH")
    private String relativePath;
    /**
     * 总块数
     */
    @TableField("TOTAL_CHUNKS")
    private Integer totalChunks;

    /**
     * form表单的file对象,为了不让Fastjson序列化,注解设置false
     */
    @Transient
    @JSONField(serialize = false)
    @TableField(exist = false)
    private MultipartFile file;
}

 

2、分块文件实体:

package cn.cloud9.server.struct.file.dto;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.data.annotation.Id;

import java.io.Serializable;

/**
 * @author OnCloud9
 * @description
 * @project tt-server
 * @date 2022年11月18日 下午 11:00
 */
@Data
@TableName("chuck_file")
public class ChuckFile implements Serializable {
    @TableId(value = "ID", type = IdType.AUTO)
    protected Long id;

    @TableField("FILENAME")
    protected String filename;

    @TableField("IDENTIFIER")
    protected String identifier;

    @TableField("TOTAL_SIZE")
    protected Long totalSize;

    @TableField("TYPE")
    protected String type;

    @TableField("LOCATION")
    protected String location;
}

  

3、保存分块信息,判断分块是否上传了

package cn.cloud9.server.struct.file.service.impl;

import cn.cloud9.server.struct.file.dto.Chuck;
import cn.cloud9.server.struct.file.mapper.ChuckMapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * @author OnCloud9
 * @description
 * @project tt-server
 * @date 2022年11月18日 下午 11:08
 */
@Slf4j
@Service
public class ChuckService extends ServiceImpl<ChuckMapper, Chuck> {

    /**
     * 保存分块信息
     * @param chuck
     */
    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public Chuck saveChuck(Chuck chuck) {
        final int insert = baseMapper.insert(chuck);
        return 1 == insert ? chuck : null;
    }

    /**
     * 判断该分块是否上传过
     * @param chuck
     * @return
     */
    public boolean checkHasChucked(Chuck chuck) {
        final List<Chuck> chucks = lambdaQuery()
                .eq(Chuck::getIdentifier, chuck.getIdentifier())
                .eq(Chuck::getChunkNumber, chuck.getChunkNumber())
                .list();
        return CollectionUtils.isNotEmpty(chucks);
    }
}

  

4、分块文件服务Bean就两个作用:合并分块,写入合并后的文件信息:

这里原封不动照抄作者的逻辑,就是排序作者写错了用倒序,害我检查半天哪没写对,文件一直合成是坏的

package cn.cloud9.server.struct.file.service.impl;

import cn.cloud9.server.struct.file.dto.ChuckFile;
import cn.cloud9.server.struct.file.mapper.ChuckFileMapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

/**
 * @author OnCloud9
 * @description
 * @project tt-server
 * @date 2022年11月18日 下午 11:08
 */
@Slf4j
@Service
public class ChuckFileService extends ServiceImpl<ChuckFileMapper, ChuckFile> {

    public void mergeChuckFile(String storagePath, ChuckFile chuckFile) {
        try {
            final String finalFile = storagePath + File.separator + chuckFile.getFilename();

            Files.createFile(Paths.get(finalFile));
            Files.list(Paths.get(storagePath))
                    /* 1、过滤非合并文件 */
                    .filter(path -> path.getFileName().toString().contains("-"))
                    /* 2、升序排序 0 - 1 - 2 。。。 */
                    .sorted((o1, o2) -> {
                        String p1 = o1.getFileName().toString();
                        String p2 = o2.getFileName().toString();
                        int i1 = p1.lastIndexOf("-") + 1;
                        int i2 = p2.lastIndexOf("-") + 1;
                        return Integer.valueOf(p1.substring(i1)).compareTo(Integer.valueOf(p2.substring(i2)));
                    })
                    /* 3、合并文件 */
                    .forEach(path -> {
                        try {
                            /* 以追加的形式写入文件 */
                            Files.write(Paths.get(finalFile), Files.readAllBytes(path), StandardOpenOption.APPEND);
                            /* 合并后删除该块 */
                            Files.delete(path);
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    });
        } catch (Exception e) {
            log.error("{}合并写入异常:{}", chuckFile.getFilename(), e.getMessage());
        }
    }


    public void addChuckFile(ChuckFile chuckFile) {
        baseMapper.insert(chuckFile);
    }
}

  

5、Controller的三个接口

这里Chuck接口的逻辑就没封装到服务了

package cn.cloud9.server.struct.file.controller;

import cn.cloud9.server.struct.controller.BaseController;
import cn.cloud9.server.struct.file.FileProperty;
import cn.cloud9.server.struct.file.FileUtil;
import cn.cloud9.server.struct.file.dto.Chuck;
import cn.cloud9.server.struct.file.dto.ChuckFile;
import cn.cloud9.server.struct.file.service.impl.ChuckFileService;
import cn.cloud9.server.struct.file.service.impl.ChuckService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.File;

/**
 * @author OnCloud9
 * @description 大型文件上传
 * @project tt-server
 * @date 2022年11月18日 下午 10:58
 */
@Slf4j
@RestController
@RequestMapping("/chuck-file")
public class ChuckFileController extends BaseController {

    private static final String CHUCK_DIR = "chuck";

    @Resource
    private FileProperty fileProperty;

    @Resource
    private ChuckFileService chuckFileService;

    @Resource
    private ChuckService chuckService;

    /**
     * 上传分块文件
     * @param chuck 分块文件
     * @return 响应结果
     */
    @PostMapping(value = "/chuck", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public Object uploadFileChuck(@ModelAttribute Chuck chuck) {
        final MultipartFile mf = chuck.getFile();
        log.info("收到文件块 文件块名:{} 块编号:{}", mf.getOriginalFilename(), chuck.getChunkNumber());
        try {

            /* 1、准备存储位置  根目录 + 分块目录 + 无类型文件名的目录  */
            final String pureName = FileUtil.getFileNameWithoutTypeSuffix(mf.getOriginalFilename());
            String storagePath = fileProperty.getBaseDirectory() + File.separator + CHUCK_DIR + File.separator + pureName;
            final File storePath = new File(storagePath);
            if (!storePath.exists()) storePath.mkdirs();

            /* 2、准备分块文件的规范名称 [无类型文件名 -分块号] */
            String chuckFilename = pureName + "-" + chuck.getChunkNumber();

            /* 3、向存储位置写入文件 */
            final File targetFile = new File(storePath, chuckFilename);
            mf.transferTo(targetFile);
            log.debug("文件 {} 写入成功, uuid:{}", chuck.getFilename(), chuck.getIdentifier());

            chuck = chuckService.saveChuck(chuck);
        } catch (Exception e) {
            log.error("文件上传异常:{}, {}", chuck.getFilename(), e.getMessage());
            return e.getMessage();
        }
        return chuck;
    }

    /**
     * 检查该分块文件是否上传了
     * @param chuck 分块文件
     * @return 304 | 分块文件 has-chucked
     */
    @GetMapping("/chuck")
    public Object checkHasChucked(@ModelAttribute Chuck chuck) {
        final boolean hasChucked = chuckService.checkHasChucked(chuck);
        if (hasChucked) response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
        return chuck;
    }

    /**
     * 文件合并
     * @param chuckFile 分块文件合并信息
     */
    @PostMapping("/merge")
    public void mergeChuckFile(@ModelAttribute ChuckFile chuckFile) {
        /* 获取存储位置 */
        final String pureName = FileUtil.getFileNameWithoutTypeSuffix(chuckFile.getFilename());
        String storagePath = fileProperty.getBaseDirectory() + File.separator + CHUCK_DIR + File.separator + pureName;
        chuckFileService.mergeChuckFile(storagePath, chuckFile);

        chuckFile.setLocation(storagePath);
        chuckFileService.addChuckFile(chuckFile);
    }
}

  

6、根目录的配置bean:

package cn.cloud9.server.struct.file;

import lombok.Data;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * @author OnCloud9
 * @description
 * @project tt-server
 * @date 2022年11月15日 下午 11:34
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "file")
public class FileProperty {

    /* 文件基础目录位置 */
    private String baseDirectory;
}

 

配置文件写法:

file:
  base-directory: F:\\tt-file

  

文件工具类代码:

package cn.cloud9.server.struct.file;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.tika.Tika;
import sun.misc.BASE64Encoder;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.Month;

/**
 * @author OnCloud9
 * @description
 * @project tt-server
 * @date 2022年11月15日 下午 11:27
 */
@Slf4j
public class FileUtil {
    
    /**
     * 根据当前年月创建目录
     * @return yyyy_mm
     */
    public static String createDirPathByYearMonth() {
        final LocalDateTime now = LocalDateTime.now();
        final int year = now.getYear();
        final Month month = now.getMonth();
        final int monthValue = month.getValue();
        return year + "_" + monthValue;
    }

    /**
     * 获取文件类型后缀
     * @param filename 文件名称
     * @return 文件类型
     */
    public static String getFileTypeSuffix(String filename) {
        final int pointIndex = filename.lastIndexOf(".");
        final String suffix = filename.substring(pointIndex + 1);
        return StringUtils.isBlank(suffix) ? "" : suffix;
    }

    /**
     * 获取不带类型后缀的文件名
     * @param filename 文件名称
     * @return 不带类型后缀的文件名
     */
    public static String getFileNameWithoutTypeSuffix(String filename) {
        final int pointIndex = filename.lastIndexOf(".");
        final String pureName = filename.substring(0, pointIndex);
        return StringUtils.isBlank(pureName) ? "" : pureName;
    }

    /**
     * 设置文件下载的响应信息
     * @param response 响应对象
     * @param file 文件
     * @param originFilename 源文件名
     */
    public static void setDownloadResponseInfo(HttpServletResponse response, File file, String originFilename) {
        try {
            response.setCharacterEncoding("UTF-8");
            /* 文件名 */
            final String fileName = StringUtils.isBlank(originFilename) ? file.getName() : originFilename;

            /* 获取要下载的文件类型, 设置文件类型声明 */
            String mimeType = new Tika().detect(file);
            response.setContentType(mimeType);

            /* 设置响应头,告诉该文件用于下载而非展示  attachment;filename 类型:附件,文件名称 */
            final String header = response.getHeader("User-Agent");
            if (StringUtils.isNotBlank(header) && header.contains("Firefox")){
                /* 对火狐浏览器单独设置 */
                response.setHeader("Content-Disposition","attachment;filename==?UTF-8?B?"+ new BASE64Encoder().encode(fileName.getBytes(StandardCharsets.UTF_8))+"?=");
            }
            else response.setHeader("Content-Disposition","attachment;filename="+ URLEncoder.encode(fileName,"UTF-8"));
        } catch (Exception e) {
            log.info("设置响应信息异常:{}", e.getMessage());
        }
    }
    
}

  

获取MimeType类型需要这个Tika组件支持:

        <!-- https://mvnrepository.com/artifact/org.apache.tika/tika-core -->
        <dependency>
            <groupId>org.apache.tika</groupId>
            <artifactId>tika-core</artifactId>
            <version>2.6.0</version>
        </dependency>

 

 

Web接口组件:

直接克隆作者的项目

 

1、把接口改成你写的接口地址 一个chuck 一个merge (chuck会被组件用Post和Get区分请求好像,post就传分块,get检查分块上传了没有)

<template>
  <uploader :options="options" :file-status-text="statusText" class="uploader-example" ref="uploader" @file-complete="fileComplete" @complete="complete"></uploader>
</template>

<script>
import axios from 'axios'
import qs from 'qs'
export default {
  data () {
    return {
      options: {
        target: 'http://localhost:8080/chuck-file/chuck', // '//jsonplaceholder.typicode.com/posts/',
        testChunks: false
      },
      attrs: {
        accept: 'image/*'
      },
      statusText: {
        success: '成功了',
        error: '出错了',
        uploading: '上传中',
        paused: '暂停中',
        waiting: '等待中'
      }
    }
  },
  methods: {
    complete () {
      console.log('complete', arguments)
    },
    fileComplete () {
      console.log('file complete', arguments)
      const file = arguments[0].file
      axios.post('http://localhost:8080/chuck-file/merge', qs.stringify({
        filename: file.name,
        identifier: arguments[0].uniqueIdentifier,
        totalSize: file.size,
        type: file.type
      })).then(function (response) {
        console.log(response)
      }).catch(function (error) {
        console.log(error)
      })
    }
  },
  mounted () {
    this.$nextTick(() => {
      window.uploader = this.$refs.uploader.uploader
    })
  }
}
</script>

<style>
  .uploader-example {
    width: 880px;
    padding: 15px;
    margin: 40px auto 0;
    font-size: 12px;
    box-shadow: 0 0 10px rgba(0, 0, 0, .4);
  }
  .uploader-example .uploader-btn {
    margin-right: 4px;
  }
  .uploader-example .uploader-list {
    max-height: 440px;
    overflow: auto;
    overflow-x: hidden;
    overflow-y: auto;
  }
</style>

  

2、区分后台的端口:

在config / index.js 里面重新配置web的端口:

8080 改成 8081

// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')

module.exports = {
  build: {
    env: require('./prod.env'),
    index: path.resolve(__dirname, '../dist/index.html'),
    assetsRoot: path.resolve(__dirname, '../dist'),
    assetsSubDirectory: '',
    assetsPublicPath: './',
    productionSourceMap: true,
    // Gzip off by default as many popular static hosts such as
    // Surge or Netlify already gzip all static assets for you.
    // Before setting to `true`, make sure to:
    // npm install --save-dev compression-webpack-plugin
    productionGzip: false,
    productionGzipExtensions: ['js', 'css'],
    // Run the build command with an extra argument to
    // View the bundle analyzer report after build finishes:
    // `npm run build --report`
    // Set to `true` or `false` to always turn it on or off
    bundleAnalyzerReport: process.env.npm_config_report
  },
  dev: {
    env: require('./dev.env'),
    port: 8081,
    autoOpenBrowser: true,
    assetsSubDirectory: '',
    assetsPublicPath: '/',
    proxyTable: {},
    // CSS Sourcemaps off by default because relative paths are "buggy"
    // with this option, according to the CSS-Loader README
    // (https://github.com/webpack/css-loader#sourcemaps)
    // In our experience, they generally work as expected,
    // just be aware of this issue when enabling this option.
    cssSourceMap: false
  }
}

  

这里可以看到就两个依赖:

  "dependencies": {
    "axios": "^1.1.3",
    "simple-uploader.js": "^0.5.6"
  },

  

 

 

分块文件表记录:

mysql> SELECT * FROM chuck_file;
+----+------------------------------------------+----------------------------------------------+------------+--------------------+--------------------------------------------------------+
| ID | FILENAME                                 | IDENTIFIER                                   | TOTAL_SIZE | TYPE               | LOCATION                                               |
+----+------------------------------------------+----------------------------------------------+------------+--------------------+--------------------------------------------------------+
|  1 | charles中文破解版.rar                    | 135963354-charlesrar                         |  135963354 |                    | F:\\tt-file\chuck\charles中文破解版                    |
|  2 | charles中文破解版.rar                    | 135963354-charlesrar                         |  135963354 |                    | F:\\tt-file\chuck\charles中文破解版                    |
|  3 | charles中文破解版.rar                    | 135963354-charlesrar                         |  135963354 |                    | F:\\tt-file\chuck\charles中文破解版                    |
|  4 | charles中文破解版.rar                    | 135963354-charlesrar                         |  135963354 |                    | F:\\tt-file\chuck\charles中文破解版                    |
|  5 | charles中文破解版.rar                    | 135963354-charlesrar                         |  135963354 |                    | F:\\tt-file\chuck\charles中文破解版                    |
|  6 | charles中文破解版.rar                    | 135963354-charlesrar                         |  135963354 |                    | F:\\tt-file\chuck\charles中文破解版                    |
|  7 | mysql-8.0.29-1.el8.x86_64.rpm-bundle.tar | 793722880-mysql-8029-1el8x86_64rpm-bundletar |  793722880 | application/x-tar  | F:\\tt-file\chuck\mysql-8.0.29-1.el8.x86_64.rpm-bundle |
|  8 | phoenix-hbase-2.4-5.1.2-bin.tar.gz       | 207440936-phoenix-hbase-24-512-bintargz      |  207440936 | application/x-gzip | F:\\tt-file\chuck\phoenix-hbase-2.4-5.1.2-bin.tar      |
+----+------------------------------------------+----------------------------------------------+------------+--------------------+--------------------------------------------------------+
8 rows in set (0.02 sec)

  

表结构:

CREATE TABLE `chuck_file` (
  `ID` int NOT NULL AUTO_INCREMENT,
  `FILENAME` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `IDENTIFIER` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `TOTAL_SIZE` bigint DEFAULT NULL,
  `TYPE` varchar(24) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `LOCATION` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

  

CREATE TABLE `chuck` (
  `ID` int NOT NULL AUTO_INCREMENT,
  `FILENAME` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
  `IDENTIFIER` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
  `TOTAL_SIZE` bigint DEFAULT NULL,
  `TYPE` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
  `LOCATION` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
  `CHUNK_NUMBER` int DEFAULT NULL,
  `CHUNK_SIZE` int DEFAULT NULL,
  `CURRENT_CHUNK_SIZE` int DEFAULT NULL,
  `RELATIVE_PATH` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `TOTAL_CHUNKS` int DEFAULT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=1734 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

  

最后几点事项:

1、分块文件的命名规则最好是严谨点,影响合并操作,一般中文无特殊符号文件名不会有影响

2、不建议立即删除分块,可以先对合并后的文件进行校验,如果不存在或者大小不一致,可以继续拿分块重新合并

 

posted @ 2022-11-19 15:13  emdzz  阅读(778)  评论(0编辑  收藏  举报