主要思路
在之前,我们给大家介绍了普通文件上传下载的思路,普通的文件其实在Spring体系之中,没有太大的难度,使用Post请求,然后请求设置成为form-data
的格式,前端的处理就完成了,对于后端而言,一个文件,我们使用一个MultipartFile
接口接收就行。这个是普通文件上传的思路,对于大文件,它和普通文件的主要区别,就是文件的体积大,体积一大,如果一次性加载,可能会导致占满内存,或者是导致浏览器崩溃,所以就要想办法把一个大文件分开,一部分一部分的传,最后合并成一个完整的文件,而且这个文件还必须与上传之前的文件的内容是一致的。这个就是大文件上传的思路。
上面的大文件上传的思路用简单的话描述了一下,基本上涵盖了所有的重点,但是不是非常直观,简单说,把大文件分开——分片,最后合并就是要的结果,合并之后与原先的文件内容一致,就是需要检测,检测的方法就是对文件提取摘要,一般使用的就是MD5编码。所以整个大文件上传的重点,其实就是文件的分片。这个分片,其实也不用太过忧愁,所有的文件底层都是01代码,既然如此,就可以一块一块切分或者是组装,切分就是分片,组装就是合并。在Java里面,给我们提供了RandomAccessFile
,我们借助这个类,就可以实现文件的随机存储和读取。
开发步骤
-
前端文件分片
-
分片上传后端接收
-
并且存取每一个分片的信息
-
当是最后一个分片时合并文件,进行摘要检查
-
返回上传结果
构造上传对象
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>
完成了上述的功能,我们大文件上传的功能就完成了。
秒传检测与断点续传
上述的代码,完成了大文件上传,逻辑就是上传大文件,我就分片,然后分片我就记录每一个分片,收集齐所有分片,我就合并,然后记录,其实就是上面两个类,FileChunkRecord
和FileRecord
的作用,前者记录分片的信息,后者记录文件的信息,当所有的分片记录完成之后,相当于整个上传完成,我再去记录文件的存储信息。
那么就有一个问题,如果某一个文件,我上传过,但是忘记了是否上传,或者说我是一个网盘项目,有很多人存储相同的电影,那么,我就可以使用秒传检测,去检测这个文件是否已经存在,如果存在我就不用去再次上传了,这样既节省宽带,也可以节省存储空间(这个需要设置一些映射和关联关系,以及文件的存在的检测,考虑是否删除,文件的移动等情况)。按照上面的代码,其实我们已经具备了这个能力,流程就是我上传一个文件,先去用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