为什么需要文件分片上传
-
大文件上传中断
:假如我们有一个5G的文件,上传过程中突然中断我们该怎么办? -
上文件上传响应时间长
:假如我们有个10G的文件,单次上传时间长,用户体验长,该怎么办? -
大文件上传重复上传
:某些大文件,我们已经上传过了,我们不想再一次上传,该怎么办?
(实践)分片上传
设计思路概述
如下图,我们会将一个大文件进行切片,然后调用文件上传接口,将分片base64数据、源文件名称、分片大小、分片个数、索引号传到服务器上。
分片上传表设计
为了保存分片上传进度,笔者创建了下面这样一张表,这张表记录了上传的文件以及文件分片上传的进度。
当我们上传的分片是第一个分片时,我们就会插入一条数据,记录文件名、分片索引号等信息。
后续上传成功的分片则都是更新信息shard_index
这个字段。
DROP TABLE IF EXISTS `file`;
CREATE TABLE `file` (
`id` char(8) NOT NULL DEFAULT '' COMMENT 'id',
`path` varchar(100) NOT NULL COMMENT '相对路径',
`name` varchar(100) DEFAULT NULL COMMENT '文件名',
`suffix` varchar(10) DEFAULT NULL COMMENT '后缀',
`size` int(11) DEFAULT NULL COMMENT '大小|字节B',
`use` char(1) DEFAULT NULL COMMENT '用途|枚举[FileUseEnum]:COURSE("C", "讲师"), TEACHER("T", "课程")',
`created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime(3) DEFAULT NULL COMMENT '修改时间',
`shard_index` int(11) DEFAULT NULL COMMENT '已上传分片',
`shard_size` int(11) DEFAULT NULL COMMENT '分片大小|B',
`shard_total` int(11) DEFAULT NULL COMMENT '分片总数',
`key` varchar(32) DEFAULT NULL COMMENT '文件标识',
PRIMARY KEY (`id`),
UNIQUE KEY `path_unique` (`path`),
UNIQUE KEY `key_unique` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件';
接口设计
注意,以下参数都是必传参数:
-
name:
文件名称(包含文件后缀)
。 -
shard
:文件base64
值。 -
size:
文件总大小。 -
shardTotal
:文件总分片数。 -
shardSize:
每个分片最大值,注意这里笔者说的分片最大值,原因很简单,如果我们文件分片大小。为10M
,很可能最后一个分片大小不足10M
,所以这个参数传的就是切割分片后的最大值。 -
suffix
:文件后缀,视频后缀为mp4
,图片则为jpg
等。
分片上传接口实现思路
- 必传参数校验。
- 获取文件
base64
值,并转为MultipartFile
。 - 获取本地存储路径,并判断该路径是否存在,如果不存在,则创建路径。
- 根据分片索引创建本地分片全路径,将
MultipartFile
写到这个路径中。 - 写入完成,记录文件上传进度,若这个文件之前都没有上传过则插入一条新数据,反之更新索引字段那一栏的数据。
- 判断本次上传的图片的索引号是否和文件总大小一致,如果一致说明上传完成开始进行文件合并,合并完成后,删除临时分片。
后端代码
在进行分片上传逻辑编写前,我们必须配置一下本地存储路径和映射地址,如下所示,笔者将file.path
设置为本地文件存储路径。将file.domain
设置为file.path
对外的映射地址。
file.path=F:/video/
file.domain=http://127.0.0.1:9000/file/f/
为了做到这一点,笔者创建了一个SpringMvcConfig
配置,这样以来,我们在浏览器中键入http://127.0.0.1:9000/file/f/1.jpg
就相当于访问文件服务器的F:/video/1.jpg
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
@Value("${file.path}")
private String FILE_PATH;
/**
* 文件查看的映射
* @param registry
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/f/**").addResourceLocations("file:"+FILE_PATH);
}
}
完成这个步骤之后我们就可以开始编写后端代码了,为了方便读者理解,笔者基于上述编码思路一段一段讲述代码实现思路
首先是参数校验,这一步无非是判空,所以笔者就补贴出完整代码,这里就把方法贴出来让读者了解一下即可 。
//1. 参数非空检查
checkParams(fileDto);
参数校验无误后,将base64
转为MultipartFile
//将base64转MultipartFile
MultipartFile multipartFile = Base64ToMultipartFile.base64ToMultipart(fileDto.getShard());
这个工具类实现思路也很简单Base64ToMultipartFile
,根据base64
封号切割成数组,数组[0]
是base64
的描述信息,数组[1]
则是具体内容,进行转码组装,具体参见下属代码:
public class Base64ToMultipartFile implements MultipartFile {
private final byte[] imgContent;
private final String header;
public Base64ToMultipartFile(byte[] imgContent, String header) {
this.imgContent = imgContent;
this.header = header.split(";")[0];
}
......略
@Override
public void transferTo(File dest) throws IOException, IllegalStateException {
new FileOutputStream(dest).write(imgContent);
}
public static MultipartFile base64ToMultipart(String base64) {
try {
String[] baseStrs = base64.split(",");
BASE64Decoder decoder = new BASE64Decoder();
byte[] b = new byte[0];
b = decoder.decodeBuffer(baseStrs[1]);
for(int i = 0; i < b.length; ++i) {
if (b[i] < 0) {
b[i] += 256;
}
}
return new Base64ToMultipartFile(b, baseStrs[0]);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
}
base64
数据结构如下图所示:
获取本地存储路径,若不存在则创建
//获取本地文件夹地址,拼接传入的参数use,得到一个本地文件夹路径,并判断是否存在,若不存在则直接创建
String localDirPath = FILE_PATH + FileUseEnum.getByCode(fileDto.getUse());
logger.info("本地文件夹地址:{}", localDirPath);
File dirFile = new File(localDirPath);
//如果目标文件夹不存在,则直接创建一个
if (!dirFile.exists() && !dirFile.mkdirs()) {
logger.error("文件夹创建失败,待创建路径:{}", localDirPath);
throw new Exception("文件夹创建失败,创建路径:" + localDirPath);
}
组装文件路径和分片路径,并将文件数据写入到分片路径中。
//本地文件全路径
String fileFullPath = localDirPath +
File.separator +
fileDto.getKey() +
"." +
fileDto.getSuffix();
//创建文件分片全路径,将multipartFile写入到这个路径中
String fileShardFullPath = fileFullPath +
"." +
fileDto.getShardIndex();
multipartFile.transferTo(new File(fileShardFullPath));
成功完成文件上传后,更新文件信息表上传进入,如果表中没有这个文件的信息,则插入一条数据。反之更新文件信息表分片索引号字段。
//更新文件表信息,无上传过这个文件则插入一条,反之直接更新索引值
String relaPath = FileUseEnum.getByCode(fileDto.getUse()) + "/" + fileDto.getKey() + "." + fileDto.getSuffix();
fileDto.setPath(relaPath);
fileService.save(fileDto);
可以看到save的逻辑如下所示
/**
* 如果这个文件之前都没上传过则插入一条数据,反之更新索引只即可
*/
public void save(FileDto fileDto) {
File file = CopyUtil.copy(fileDto, File.class);
File fileDb = selectByKey(fileDto.getKey());
if (fileDb == null) {
this.insert(file);
} else {
fileDb.setShardIndex(fileDto.getShardIndex());
this.update(fileDb);
}
}
如果当前上传的分片为最后一个分片,则进行文件合并
//判断当前分片索引是否等于分片总数,如果等于分片总数则执行文件合并
if (fileDto.getShardIndex().equals(fileDto.getShardTotal())) {
fileDto.setPath(fileFullPath);
//文件合并
merge(fileDto);
}
组装结果并返回
ResponseDto responseDto = new ResponseDto();
FileDto result = new FileDto();
//设置文件映射地址给前端
result.setPath(FILE_DOMAIN + "/" + relaPath);
responseDto.setContent(result);
logger.info("文件分片上传结束,请求结果:{}", JSON.toJSONString(responseDto));
return responseDto;
文件合并逻辑,这里就比较简单了,创建输入流和输出流,以追加的形式将每个分片都写到输出文件中。
private void merge(FileDto fileDto) {
logger.info("文件分片合并开始,请求参数:{}", JSON.toJSONString(fileDto));
String path = fileDto.getPath();
try (OutputStream outputStream = new FileOutputStream(path, true)) {
for (Integer i = 1; i <= fileDto.getShardTotal(); i++) {
try (FileInputStream inputStream = new FileInputStream(path + "." + i);) {
byte[] bytes = new byte[10 * 1024 * 1024];
int len;
while ((len = inputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0, len);
}
}
}
} catch (Exception e) {
logger.error("文件合并失败,失败原因:{}", e.getMessage(), e);
}
System.gc();
//删除所有分片
for (Integer i = 1; i <= fileDto.getShardTotal(); i++) {
File file = new File(path + "." + i);
file.delete();
}
}
注意,这里有时需要考虑一个文件,就是文件合并后无论流如何关闭,在JVM进行gc回收之前,这些文件的资源在进行删除操作时可能会失败,所以稳妥起见,笔者尝试过调用System.gc()这
个方法进行兜底。
这种方法不太符合编码规范,所以笔者建议使用定时任务等方式每日按时按点进行删除。
完整代码
@RequestMapping("/upload")
public ResponseDto uploadShard(@RequestBody FileDto fileDto) throws Exception {
logger.info("文件分片上传请求开始,请求参数: {}", JSON.toJSONString(fileDto));
//1. 参数非空检查
checkParams(fileDto);
//将base64转MultipartFile
MultipartFile multipartFile = Base64ToMultipartFile.base64ToMultipart(fileDto.getShard());
//获取本地文件夹地址,拼接传入的参数use,得到一个本地文件夹路径,并判断是否存在,若不存在则直接创建
String localDirPath = FILE_PATH + FileUseEnum.getByCode(fileDto.getUse());
logger.info("本地文件夹地址:{}", localDirPath);
File dirFile = new File(localDirPath);
//如果目标文件夹不存在,则直接创建一个
if (!dirFile.exists() && !dirFile.mkdirs()) {
logger.error("文件夹创建失败,待创建路径:{}", localDirPath);
throw new Exception("文件夹创建失败,创建路径:" + localDirPath);
}
//本地文件全路径
String fileFullPath = localDirPath +
File.separator +
fileDto.getKey() +
"." +
fileDto.getSuffix();
//创建文件分片全路径,将multipartFile写入到这个路径中
String fileShardFullPath = fileFullPath +
"." +
fileDto.getShardIndex();
multipartFile.transferTo(new File(fileShardFullPath));
//更新文件表信息,无上传过这个文件则插入一条,反之直接更新索引值
String relaPath = FileUseEnum.getByCode(fileDto.getUse()) + "/" + fileDto.getKey() + "." + fileDto.getSuffix();
fileDto.setPath(relaPath);
fileService.save(fileDto);
//判断当前分片索引是否等于分片总数,如果等于分片总数则执行文件合并
if (fileDto.getShardIndex().equals(fileDto.getShardTotal())) {
fileDto.setPath(fileFullPath);
//文件合并
merge(fileDto);
}
ResponseDto responseDto = new ResponseDto();
FileDto result = new FileDto();
//设置文件映射地址给前端
result.setPath(FILE_DOMAIN + "/" + relaPath);
responseDto.setContent(result);
logger.info("文件分片上传结束,请求结果:{}", JSON.toJSONString(responseDto));
return responseDto;
}
/**
* 文件合并
*
* @param fileDto
*/
private void merge(FileDto fileDto) {
logger.info("文件分片合并开始,请求参数:{}", JSON.toJSONString(fileDto));
String path = fileDto.getPath();
try (OutputStream outputStream = new FileOutputStream(path, true)) {
for (Integer i = 1; i <= fileDto.getShardTotal(); i++) {
try (FileInputStream inputStream = new FileInputStream(path + "." + i);) {
byte[] bytes = new byte[10 * 1024 * 1024];
int len;
while ((len = inputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0, len);
}
}
}
} catch (Exception e) {
logger.error("文件合并失败,失败原因:{}", e.getMessage(), e);
}
//删除所有分片
for (Integer i = 1; i <= fileDto.getShardTotal(); i++) {
File file = new File(path + "." + i);
file.delete();
}
}
(完善)断点续传和极速妙传
极速妙传设计思路
因为是大文件,我们很可能文件传一半就因为各种原因导致中断,我们的实现思路是和前端约定好为系统的文件都生成一个key,后端用这个key到数据库中查询是否存在上传记录。
如果有结果则结果返回给前端。
然后前端进行如下操作:
- 如果返回结果不为空,则记录中已上传索引值等于文件分片数,则说明之前上传过相同的文件,直接告知用户极速妙传成功。
- 如果返回结果不为空,已上传的索引值不等于文件分片数,则基于这个
索引值+1
完成断点续传。
后端代码实现
/**
* 文件上传进度检查接口
*
* @param key
* @return
*/
@GetMapping("/check/{key}")
public ResponseDto check(@PathVariable String key) {
logger.info("文件上传进度检查接口请求开始,请求参数:{}", key);
if (StringUtils.isEmpty(key)) {
throw new BusinessException(BusinessExceptionCode.ILLEGAL_ARGUMENT_EXCEPTION);
}
FileDto fileDto = fileService.findByKey(key);
ResponseDto responseDto = new ResponseDto();
//如果不为空,则返回映射地址以及文件上传进度
if (fileDto != null) {
//将文件映射地址告知前端
fileDto.setPath(FILE_DOMAIN + "/" + fileDto.getPath());
responseDto.setContent(fileDto);
}
return responseDto;
}
对接,前端代码实现
前端文件分片计算代码逻辑(ps:笔者基于Vue写的)
getFileShard (shardIndex, shardSize) {
let _this = this;
let file = _this.$refs.file.files[0];
let start = (shardIndex - 1) * shardSize; //当前分片起始位置
let end = Math.min(file.size, start + shardSize); //当前分片结束位置
let fileShard = file.slice(start, end); //从文件中截取当前的分片数据
return fileShard;
},
调用极速妙传和断点续传的逻辑
/**
* 检查文件状态,是否已上传过?传到第几个分片?
*/
check (param) {
let _this = this;
_this.$ajax.get(process.env.VUE_APP_SERVER + '/file/admin/check/' + param.key).then((response)=>{
let resp = response.data;
if (resp.success) {
let obj = resp.content;
//如果不存在则从第一个分片开始上传
if (!obj) {
param.shardIndex = 1;
console.log("没有找到文件记录,从分片1开始上传");
_this.upload(param);
} else if (obj.shardIndex === obj.shardTotal) {
// 已上传分片 = 分片总数,说明已全部上传完,不需要再上传
Toast.success("文件极速秒传成功!");
_this.afterUpload(resp);
$("#" + _this.inputId + "-input").val("");
} else {
param.shardIndex = obj.shardIndex + 1;
console.log("找到文件记录,从分片" + param.shardIndex + "开始上传");
_this.upload(param);
}
} else {
Toast.warning("文件上传失败");
$("#" + _this.inputId + "-input").val("");
}
})
},
/**
* 将分片数据转成base64进行上传
*/
upload (param) {
let _this = this;
let shardIndex = param.shardIndex;
let shardTotal = param.shardTotal;
let shardSize = param.shardSize;
let fileShard = _this.getFileShard(shardIndex, shardSize);
// 将图片转为base64进行传输
let fileReader = new FileReader();
Progress.show(parseInt((shardIndex - 1) * 100 / shardTotal));
fileReader.onload = function (e) {
let base64 = e.target.result;
// console.log("base64:", base64);
param.shard = base64;
_this.$ajax.post(process.env.VUE_APP_SERVER + '/file/admin/upload', param).then((response) => {
let resp = response.data;
console.log("上传文件成功:", resp);
Progress.show(parseInt(shardIndex * 100 / shardTotal));
if (shardIndex < shardTotal) {
// 上传下一个分片
param.shardIndex = param.shardIndex + 1;
_this.upload(param);
} else {
Progress.hide();
_this.afterUpload(resp);
$("#" + _this.inputId + "-input").val("");
}
});
};
fileReader.readAsDataURL(fileShard);
},
常见问题
能不能给我介绍一下MultipartFile
答: 这个是Spring
框架自带的一个类,便于用户更好操作网络传输的文件,这个类为我们提供了很多便捷操作的API。
getName():获取文件名
getOriginalFilename():返回客户端系统中原始文件名。
getContentType():获取文件内容类型。
isEmpty():判断文件是否为空。
getSize():获取文件大小,以字节为单位。
getBytes():获取文件的字节数组。
getInputStream():获取文件输入流。
transferTo(File dest):将目标文件传输到目标文件中。
transferTo(Path dest) :将目标文件传输到目标地址中。
以笔者为例,笔者为了将前端传入的base64
字符串转为MultipartFile
,于是继承了MultipartFile
编写了一个工具类
这个类首先声明两个成员属性,imgContent
记录文件内容,header
记录base64
文件标识。
private final byte[] imgContent;
private final String header;
获取文件名以及获取文件类型等相关方法的重写
@Override
public String getName() {
// TODO - implementation depends on your requirements
return System.currentTimeMillis() + Math.random() + "." + header.split("/")[1];
}
@Override
public String getOriginalFilename() {
// TODO - implementation depends on your requirements
return System.currentTimeMillis() + (int) Math.random() * 10000 + "." + header.split("/")[1];
}
@Override
public String getContentType() {
// TODO - implementation depends on your requirements
return header.split(":")[1];
}
重点:base64
转MultipartFile
逻辑,如下所示,将base64
封号后面的内容转为数组b ,然后将这个数据赋给imgContent
,文件描述信息赋给header
。
public static MultipartFile base64ToMultipart(String base64) {
try {
String[] baseStrs = base64.split(",");
BASE64Decoder decoder = new BASE64Decoder();
byte[] b = null;
b = decoder.decodeBuffer(baseStrs[1]);
for(int i = 0; i < b.length; ++i) {
if (b[i] < 0) {
b[i] += 256;
}
}
//将文件内容赋值给imgContent,文件描述信息baseStrs[0]赋给header
return new Base64ToMultipartFile(b, baseStrs[0]);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
public Base64ToMultipartFile(byte[] imgContent, String header) {
this.imgContent = imgContent;
this.header = header.split(";")[0];
}
base64又是什么类型的数据
答: base64
是为了在网络传输中避免中文乱码、媒体文件二进制传输数据不可打印等情况诞生的一种编码技术。它的编码过程也很简单,就是将每个字符串的二进制编码值全部合在一起,每6位算一个字符,再配合base64
编码表得出最终结果。
base64编码表
我们就以字符串A为例子,它的ASCII
码值为65
,那么二进制数值就为:1000001
,按照base64的规则,将字符串中24位为一组(不足24位则算做空位)
。所以A的二进制值为01 00 00 01 00 00 后12位全空
,每6位算出一个十进制的值,根据base64
编码表得出最终的值,01 00 00
得到一个Q,01 00 00
又得到一个Q,后面全空的6位算一个=,最终结果为QQ==
。
为了让读者加深对base64
编码的理解,我们再以字符串Man
为例,我们得出它的ASCII
码值为77 97 110
它的计算过程如下图所示
对此我们可以用Java代码来验证一下
public static void main(String[] args) {
String man = "Man";
String a = "A";
BASE64Encoder encoder = new BASE64Encoder();
System.out.println("Man base64结果为:" + encoder.encode(man.getBytes()));
System.out.println("A base64结果为:" + encoder.encode(a.getBytes()));
}
输出结果
Man base64结果为:TWFu
A base64结果为:QQ==
能不能基于上述接口给我会绘制一张流程图
关于文件分片上传有没有考虑过OSS
答: 有的,某些场景下我们的项目会使用Amazon S3
,相比自建文件服务器,减少了很多运维压力,而且Amazon S3
也为我们提供了multipart upload API
,最大支持上传5TB
的文件。
为了保证扩展,我们也留了一个策略类实现Amazon S3
的文件上传。
给我说说你们文件上传的场景吧
答: 我们某个系统需要调用另一个服务的RPC
接口进行文件上传,由于某些文件属于大文件,所以经常出现文件上传途中导致文件超时等问题,对此我们采用了文件分片上传的方案解决问题超时问题。
你觉得你这个设计还有没有提高的空间
- 输入输出流可以加一个
buffer
缓冲区。 - 如果上传的文件并不是第一时刻要用的话,合并逻辑可以异步执行,即加个
Async
注解。 - 分片大小参数化,根据生产环境服务器性能进行动态调整。
如果需要进行并发上传的话
可以的,并发的逻辑交给前端实现,但是后端的表结构可能需要修改,还记得我们之前有一张专门记录文件分片上传进度的数据表嘛,建表SQL
如下所示:
DROP TABLE IF EXISTS `file`;
CREATE TABLE `file` (
`id` char(8) NOT NULL DEFAULT '' COMMENT 'id',
`path` varchar(100) NOT NULL COMMENT '相对路径',
`name` varchar(100) DEFAULT NULL COMMENT '文件名',
`suffix` varchar(10) DEFAULT NULL COMMENT '后缀',
`size` int(11) DEFAULT NULL COMMENT '大小|字节B',
`use` char(1) DEFAULT NULL COMMENT '用途|枚举[FileUseEnum]:COURSE("C", "讲师"), TEACHER("T", "课程")',
`created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime(3) DEFAULT NULL COMMENT '修改时间',
`shard_index` int(11) DEFAULT NULL COMMENT '已上传分片',
`shard_size` int(11) DEFAULT NULL COMMENT '分片大小|B',
`shard_total` int(11) DEFAULT NULL COMMENT '分片总数',
`key` varchar(32) DEFAULT NULL COMMENT '文件标识',
PRIMARY KEY (`id`),
UNIQUE KEY `path_unique` (`path`),
UNIQUE KEY `key_unique` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件';
如果需要并发shard_index
就不能代表当前已上传的分片数了,取而代之的是我们必须为这个文件上传完成的每一个分片进行日志记录。所以我们可以建立下面这样一张表。
可以看到我们用file
表的id
和这张表进行关联,每一个分片上传完成后就将分片索引号插入到这张表中。
DROP TABLE IF EXISTS `file_shard`;
CREATE TABLE `file_shard` (
`id` char(8) NOT NULL DEFAULT '' COMMENT 'id',
`shard_index` int(11) DEFAULT NULL COMMENT '分片索引'
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件分片';
同步的我们合并的逻辑也得修改。
//从file_shard获取已上传完成的分片数
List<Integer> indexList=fileShradServive.selectById(fileDto.getId())
//如果分片表的数据数和file表的total一样则可以合并
if (indexList.size().equals(fileDto.getShardTotal())) {
fileDto.setPath(fileFullPath);
//文件合并
merge(fileDto);
}
断点续传同理,需要遍历file_shard
表的分片索引,for
循环看看缺哪个分片就把哪个分片返回给前端
List<Integer> indexList=fileShradServive.selectById(fileDto.getId())
//记录未上传的分片
List<Integer> unfinishUploadShardList=new ArrayList<>();
//分片索引排序
Collections.sort(indexList);
//记录未上传的分片
List<Integer> unfinishUploadShardList = new ArrayList<>();
for (int i = 0; i < indexList.size(); i++) {
if (!indexList.contains(i)) {
unfinishUploadShardList.add(i);
}
}
参考文献
MultipartFile实现文件上传与下载
一篇文章彻底弄懂Base64编码原理