SpringBoot大文件上传(大小文件上传皆可)

主要思路

在之前,我们给大家介绍了普通文件上传下载的思路,普通的文件其实在Spring体系之中,没有太大的难度,使用Post请求,然后请求设置成为form-data的格式,前端的处理就完成了,对于后端而言,一个文件,我们使用一个MultipartFile接口接收就行。这个是普通文件上传的思路,对于大文件,它和普通文件的主要区别,就是文件的体积大,体积一大,如果一次性加载,可能会导致占满内存,或者是导致浏览器崩溃,所以就要想办法把一个大文件分开,一部分一部分的传,最后合并成一个完整的文件,而且这个文件还必须与上传之前的文件的内容是一致的。这个就是大文件上传的思路。

上面的大文件上传的思路用简单的话描述了一下,基本上涵盖了所有的重点,但是不是非常直观,简单说,把大文件分开——分片,最后合并就是要的结果,合并之后与原先的文件内容一致,就是需要检测,检测的方法就是对文件提取摘要,一般使用的就是MD5编码。所以整个大文件上传的重点,其实就是文件的分片。这个分片,其实也不用太过忧愁,所有的文件底层都是01代码,既然如此,就可以一块一块切分或者是组装,切分就是分片,组装就是合并。在Java里面,给我们提供了RandomAccessFile,我们借助这个类,就可以实现文件的随机存储和读取。

开发步骤

  1. 前端文件分片

  2. 分片上传后端接收

  3. 并且存取每一个分片的信息

  4. 当是最后一个分片时合并文件,进行摘要检查

  5. 返回上传结果

构造上传对象

 1public class MultipartFileParam {
2    /**
3     * 是否分片
4     */

5    @NotNull(message = "是否分片不能为空")
6    private boolean chunkFlag;
7
8    /**
9     * 当前为第几块分片
10     */

11    private int chunk;
12
13    /**
14     * 总分片数量
15     */

16    private int totalChunk;
17
18    /**
19     * 文件总大小, 单位是byte
20     */

21    private long totalSize;
22
23    /**
24     * 文件名
25     */

26    @NotBlank(message = "文件名不能为空")
27    private String name;
28
29    /**
30     * 文件
31     */

32    @NotNull(message = "文件不能为空")
33    private MultipartFile file;
34
35    /**
36     * md5值
37     */

38    @NotBlank(message = "文件md5值不能为空")
39    private String md5;

上面是对应的POJO的类,可以作为一个POJO类对待,也可以当作是一个请求参数对待,其中的private MultipartFile file就是用来装载文件的,totalSize是文件的总大小,chunk是当前分片的id。

上传分片记录

上传分片记录是用来记录当前上传分片的文件的信息的,我们也要对其记录,其中的md5,就是一个完整的文件对应的,这个需要前端在上传之前,和参数一起,给我们传过来,这个需要保证其正确性,id是这个分片在整个记录之中的排序,chunk就是当前分片的分片的顺序,这个也是上传时的参数决定的,uploadStatus就是记录这个分片是否上传成功的标志,可以按照自己的实际情况进行设置。其中两个重要的参数,就是md5和chunk,我们使用md5建立了和上传文件的唯一联系,使用chunk记录了这个分片和这个文件的位置的关系,也就是我这个分片存在于这个文件之中的到底什么位置location。

 1public class FileChunkRecord implements Serializable {
2
3    private static final long serialVersionUID = 1L;
4    private Long id;
5    private String md5;
6    private Integer chunk;
7    /**
8     * 0-fail, 1-okay
9     */

10    private Integer uploadStatus;
11
12}
13
14public class FileRecord implements Serializable {
15
16    private static final long serialVersionUID = 1L;
17    private Long id;
18    private String fileName;
19    private String fileMd5;
20    private String filePath;
21    private String fileSize;
22    /**
23     * 0-fail, 1-okay
24     */

25    private Integer uploadStatus;
26    private Date createTime;
27    private Date updateTime;
28
29}

功能实现

项目配置
 1server.port=10015
2# 开启上传和下载
3spring.servlet.multipart.enabled=true
4#文件上传大小限制50M
5spring.servlet.multipart.max-file-size=50MB
6spring.servlet.multipart.max-request-size=-1
7server.tomcat.max-swallow-size=-1
8
9# db
10spring.datasource.username=root
11spring.datasource.password=123456
12spring.datasource.url=jdbc:mysql://localhost:3306/bigfile?serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8
13spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
14spring.datasource.platform=mysql
15
16# druid
17spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
18spring.datasource.druid.initial-size=5
19spring.datasource.druid.min-idle=5
20spring.datasource.druid.max-active=20
21## 单位是毫秒,此处设置为2分钟
22spring.datasource.druid.max-wait=120000
23spring.datasource.druid.time-between-eviction-runs-millis=60000
24spring.datasource.druid.min-evictable-idle-time-millis=300000
25spring.datasource.druid.validationQuery=SELECT 1 FROM DUAL
26spring.datasource.druid.testWhileIdle=true
27spring.datasource.druid.test-on-borrow=false
28spring.datasource.druid.test-on-return=false
29spring.datasource.druid.pool-prepared-statements=true
30## 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
31spring.datasource.druid.filters=stat,wall
32spring.datasource.druid.maxPoolPreparedStatementPerConnectionSize=20
33spring.datasource.druid.seGlobalDataSourceStat=true
34spring.datasource.druid.connectionProperties=druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500
35
36# mybatis-plus
37mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
38# 配置逻辑删除, 配置删除为1,没有删除为0
39mybatis-plus.global-config.db-config.logic-delete-value=1
40mybatis-plus.global-config.db-config.logic-not-delete-value=0
41mybatis-plus.configuration.map-underscore-to-camel-case=true
42#mybatis-plus.mapper-locations=classpath*:/mapper/**/*.xml
43mybatis-plus.mapper-locations=classpath:com/zgy/learn/bigfileupzipdown/mapper/*.xml
44mybatis-plus.type-aliases-package=com.zgy.learn.bigfileupzipdown.pojo
45mybatis-plus.global-config.db.type=MYSQL
46
47# 文件存储路径
48file.upload.dir=d:/test
49# 文件存储临时路径
50file.download.tmp.dir=d:/test/tmp
51# 1M=1024*1024B
52file.upload.chunkSize=10485760
对应的Controller
 1@RestController
2@RequestMapping("file")
3public class FileUploadController {
4    @Resource
5    private FileUploadService fileUploadService;
6
7    /**
8     * 文件上传
9     *
10     * @param fileParam
11     * @return
12     */

13    @PostMapping(value = "upload")
14    public String upload(MultipartFileParam fileParam) throws IOException {
15        return fileUploadService.fileUpload(fileParam);
16    }
17
18}
对应的Service
  1@Service
2@Slf4j
3public class FileUploadService {
4    @Value("${file.upload.dir}")
5    private String FILE_UPLOAD_DIR;
6    @Value("${file.upload.chunkSize}")
7    private Integer CHUNK_SIZE;
8
9    @Resource
10    private FileChunkRecordMapper fileChunkRecordMapper;
11    @Resource
12    private FileRecordMapper fileRecordMapper;
13
14    public String fileUpload(MultipartFileParam fileParam) throws IOException {
15        boolean chunkFlag = fileParam.isChunkFlag();
16        if (!chunkFlag) {
17            return singleUpload(fileParam);
18        }
19        return chunkUpload(fileParam);
20    }
21
22    private String singleUpload(MultipartFileParam fileParam) {
23        MultipartFile file = fileParam.getFile();
24        File baseFile = new File(FILE_UPLOAD_DIR);
25        if (!baseFile.exists()) {
26            baseFile.mkdirs();
27        }
28        try {
29            file.transferTo(new File(baseFile, fileParam.getName()));
30            Date now = new Date();
31            FileRecord fileRecord = new FileRecord();
32            String filePath = FILE_UPLOAD_DIR + File.separator + fileParam.getName();
33            long size = FileUtil.size(new File(filePath));
34            String sizeStr = size / (1024 * 1024) + "Mb";
35            fileRecord.setFileName(fileParam.getName()).setFilePath(filePath).setUploadStatus(1)
36                    .setFileMd5(fileParam.getMd5()).setCreateTime(now).setUpdateTime(now).setFileSize(sizeStr);
37            fileRecordMapper.insert(fileRecord);
38        } catch (IOException e) {
39            log.error("单独上传文件错误, 问题是:{}, 时间是:{}", e.getMessage(), DateUtil.now());
40        }
41
42        return "success";
43    }
44
45    private String chunkUpload(MultipartFileParam fileParam) throws IOException {
46        // 是否为最后一片
47        boolean lastFlag = false;
48
49        int currentChunk = fileParam.getChunk();
50        int totalChunk = fileParam.getTotalChunk();
51        long totalSize = fileParam.getTotalSize();
52        String fileName = fileParam.getName();
53        String fileMd5 = fileParam.getMd5();
54        MultipartFile multipartFile = fileParam.getFile();
55
56        String parentDir = FILE_UPLOAD_DIR + File.separator + fileMd5 + File.separator;
57        String tempFileName = fileName + "_tmp";
58
59        // 写入到临时文件
60        File tmpFile = tmpFile(parentDir, tempFileName, multipartFile, currentChunk, totalSize, fileMd5);
61        // 检测是否为最后一个分片
62        QueryWrapper<FileChunkRecord> wrapper = new QueryWrapper<>();
63        wrapper.eq("md5", fileMd5);
64        Integer count = fileChunkRecordMapper.selectCount(wrapper);
65        if (count == totalChunk) {
66            lastFlag = true;
67        }
68
69        if (lastFlag) {
70            // 检查md5是否一致
71            if (!checkFileMd5(tmpFile, fileMd5)) {
72                cleanUp(tmpFile, fileMd5);
73                throw new RuntimeException("文件md5检测不符合要求, 请检查!");
74            }
75            // 文件重命名, 成功则新增文件的记录
76            if (!renameFile(tmpFile, fileName)) {
77                throw new RuntimeException("文件重命名失败, 请检查!");
78            }
79            recordFile(fileName, fileMd5, parentDir);
80            log.info("文件上传完成, 时间是:{}, 文件名称是:{}", DateUtil.now(), fileName);
81        }
82
83        return "success";
84    }
85
86    public String check(String md5) {
87        QueryWrapper queryWrapper = new QueryWrapper<>();
88        queryWrapper.eq("file_md5", md5);
89        FileRecord record = fileRecordMapper.selectOne(queryWrapper);
90        if (Objects.nonNull(record)) {
91            return "文件已经上传过";
92        }
93        List<FileChunkRecord> records = fileChunkRecordMapper.queryByMd5(md5);
94        StringBuilder result = new StringBuilder();
95        records.stream().forEach(x -> result.append(x.getChunk()));
96        return result.toString();
97
98    }
99
100    private File tmpFile(String parentDir, String tempFileName, MultipartFile file,
101                         int currentChunk, long totalSize, String fileMd5)
 throws IOException 
{
102        log.info("开始上传文件, 时间是:{}, 文件名称是:{}", DateUtil.now(), tempFileName);
103        long position = (currentChunk - 1) * CHUNK_SIZE;
104        File tmpDir = new File(parentDir);
105        File tmpFile = new File(parentDir, tempFileName);
106        if (!tmpDir.exists()) {
107            tmpDir.mkdirs();
108        }
109
110        RandomAccessFile tempRaf = new RandomAccessFile(tmpFile, "rw");
111        if (tempRaf.length() == 0) {
112            tempRaf.setLength(totalSize);
113        }
114
115        // 写入该分片数据
116        FileChannel fc = tempRaf.getChannel();
117        MappedByteBuffer map = fc.map(FileChannel.MapMode.READ_WRITE, position, file.getSize());
118        map.put(file.getBytes());
119        clean(map);
120        fc.close();
121        tempRaf.close();
122
123        // 记录已经完成的分片
124        FileChunkRecord fileChunkRecord = new FileChunkRecord();
125        fileChunkRecord.setMd5(fileMd5).setUploadStatus(1).setChunk(currentChunk);
126        fileChunkRecordMapper.insert(fileChunkRecord);
127        log.info("文件上传完成, 时间是:{}, 文件名称是:{}", DateUtil.now(), tempFileName);
128        return tmpFile;
129    }
130
131    private static void clean(MappedByteBuffer map) {
132        try {
133            Method getCleanerMethod = map.getClass().getMethod("cleaner");
134            Cleaner.create(map, null);
135            getCleanerMethod.setAccessible(true);
136            Cleaner cleaner = (Cleaner) getCleanerMethod.invoke(map);
137            cleaner.clean();
138        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
139            e.printStackTrace();
140        }
141    }
142
143    private boolean checkFileMd5(File file, String fileMd5) throws IOException {
144        FileInputStream fis = new FileInputStream(file);
145        String checkMd5 = DigestUtils.md5DigestAsHex(fis).toUpperCase();
146        fis.close();
147        if (checkMd5.equals(fileMd5.toUpperCase())) {
148            return true;
149        }
150        return false;
151    }
152
153    private void cleanUp(File file, String md5) {
154        if (file.exists()) {
155            file.delete();
156        }
157        // 删除上传记录
158        QueryWrapper queryWrapper = new QueryWrapper<>();
159        queryWrapper.eq("md5", md5);
160        fileChunkRecordMapper.delete(queryWrapper);
161    }
162
163    private boolean renameFile(File toBeRenamed, String toFileNewName) {
164        // 检查要重命名的文件是否存在,是否是文件
165        if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {
166            log.info("File does not exist: " + toBeRenamed.getName());
167            return false;
168        }
169        String parentPath = toBeRenamed.getParent();
170        File newFile = new File(parentPath + File.separatorChar + toFileNewName);
171        // 如果存在, 先删除
172        if (newFile.exists()) {
173            newFile.delete();
174        }
175        return toBeRenamed.renameTo(newFile);
176    }
177
178    public void recordFile(String fileName, String fileMd5, String parentDir) {
179        Date now = new Date();
180        String filePath = parentDir + fileName;
181        long size = FileUtil.size(new File(filePath));
182        String sizeStr = size / (1024 * 1024) + "Mb";
183        // 更新文件记录
184        FileRecord record = new FileRecord();
185        record.setFileName(fileName).setFileMd5(fileMd5).setFileSize(sizeStr).setFilePath(filePath)
186                .setUploadStatus(1).setCreateTime(now).setUpdateTime(now);
187        fileRecordMapper.insert(record);
188        // 删除分片文件的记录
189        fileChunkRecordMapper.deleteByMd5(fileMd5);
190    }
191
192}
Mapper接口与Mapper.xml
 1@Repository
2public interface FileChunkRecordMapper extends BaseMapper<FileChunkRecord{
3
4    List<FileChunkRecord> queryByMd5(@Param("md5") String md5);
5
6    @Delete("delete from file_chunk_record where md5 = #{md5}")
7    int deleteByMd5(@Param("md5") String md5);
8
9}
10
11<?xml version="1.0" encoding="UTF-8"?>
12<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
13        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
14<mapper namespace="com.zgy.learn.bigfileupzipdown.mapper.FileChunkRecordMapper">
15
16    <select id="queryByMd5" resultType="com.zgy.learn.bigfileupzipdown.pojo.FileChunkRecord">
17        SELECT
18            id,
19            md5,
20            chunk,
21            upload_status
22        FROM
23            file_chunk_record
24        WHERE
25            md5 = #{md5};
26    </select>
27</mapper>
28
29@Repository
30public interface FileRecordMapper extends BaseMapper<FileRecord{
31}
32
33<?xml version="1.0" encoding="UTF-8"?>
34<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
35        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
36<mapper namespace="com.zgy.learn.bigfileupzipdown.mapper.FileRecordMapper">
37
38</mapper>

完成了上述的功能,我们大文件上传的功能就完成了。

秒传检测与断点续传

上述的代码,完成了大文件上传,逻辑就是上传大文件,我就分片,然后分片我就记录每一个分片,收集齐所有分片,我就合并,然后记录,其实就是上面两个类,FileChunkRecordFileRecord的作用,前者记录分片的信息,后者记录文件的信息,当所有的分片记录完成之后,相当于整个上传完成,我再去记录文件的存储信息。

那么就有一个问题,如果某一个文件,我上传过,但是忘记了是否上传,或者说我是一个网盘项目,有很多人存储相同的电影,那么,我就可以使用秒传检测,去检测这个文件是否已经存在,如果存在我就不用去再次上传了,这样既节省宽带,也可以节省存储空间(这个需要设置一些映射和关联关系,以及文件的存在的检测,考虑是否删除,文件的移动等情况)。按照上面的代码,其实我们已经具备了这个能力,流程就是我上传一个文件,先去用Md5查询,查询FileRecord表,如果有这个对应的文件,返回已经存在或者上传成功,这个具体的显示情况由前端决定,如果没有查询到,就去FileChunkRecord里面按照Md5查询,如果有部分的分片,我们返回已有的分片的序号,让前端在上传的时候,只传送没有上传的分片,这样就可以实现断点续传的功能了。如果没有那就表明这个文件确实是没有上传的,那么我们完全重新上传即可。

 1    /**
2     * 秒传检测
3     *
4     * @param md5
5     * @return
6     */

7    @GetMapping(value = "check")
8    public String check(String md5) {
9        return fileUploadService.check(md5);
10    }

需要特别注意,就是我们的分片的大小,前后端必须保持一致

普通文件的上传与下载:SpringBoot文件上传与下载



本篇文章来源于微信公众号: 疾风小虎牙

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/13925.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!