目录
简介:
什么是分片上传?分片上传就是把一个大的文件分成N个部分,然后一部分一部分的进行上传。
分片上传有什么好处?分片上传可以实现断网、关浏览器、传输错误等情况发生后,再次进行
此文件的上传的时候,已上传的部分无需继续上传,减少上传文件的流量消耗与用户的等待时间。
后端如何实现分片上传?后端传统上传,直接开发一个接口,接受文件即可;但是分片上传如
何实现呢,按照分片的解读理解,咱们可以将上传接口进行拆分:
①. 首先咱们需要一个接口,来记录标识一个文件,这样才能区分不同文件;
②. 然后咱们还需要一个接口,用来判断某个分片是否已经上传完成;
③. 再然后,还需要一个接口,用来进行分片文件的上传(其实就是一个简单的上传功能,将文件
分片存放,并且记录到文件分片上传记录中);
④. 最后还需要一个最重要的接口,那当然就是分片的合并接口了,调用此接口按照顺序将某个文
件的所有分片合并成真实的对应的文件。
那么废话不多说,下面咱们就开始编码,有问题可留言评论;断点上传不是断点续传,断点续传是特
指下载(这又是另外的知识了)!
注:ResponseVo为自定义统一返回格式,自行定义;
接口中用到jackjson包;
接口中用到CommonException为自定义异常,自行定义,或其他方式处理;
接口中用到FileExistsResponse为判断文件分片是否存在的返回数据结构,自行定义;
接口中用到的filemapper等数据库层的内容自行实现(数据库涉及到一张表file_upload);
FileUpload为文件上传表file_upload的实体;
1. 数据库表创建:
create table file_upload
(
id NUMBER not null,
index NUMBER,
parent NUMBER,
name VARCHAR2(100 char) not null,
path VARCHAR2(255 char),
md5 VARCHAR2(200 char) not null,
size NUMBER not null
constraint PK_FILE_UPLOAD primary key (id)
);
2. 编写文件记录标识接口:
此接口每个文件调用一次,调用完成后,需要返回文件记录的信息,主要是返回ID,以便
前端拿到后,作为该文件所有分片的parent。
接口入参:
public class FileNewRequest {
private String index;
private String parent;
private String name;
private String path;
private String md5;
private String size;
public String getIndex() {
return index;
}
public void setIndex(String index) {
this.index = index;
}
public String getParent() {
return parent;
}
public void setParent(String parent) {
this.parent = parent;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getMd5() {
return md5;
}
public void setMd5(String md5) {
this.md5 = md5;
}
public String getSize() {
return size;
}
public void setSize(String size) {
this.size = size;
}
}
接口:
/**
* 新创建一个文件标识记录
* @author kevin
* @param request :
* @return com.liu.services.system.entity.FileUpload
* @date 2021/1/8
*/
@ApiOperation(value = "文件标识记录", notes = "/file/new")
@PostMapping("/file/new")
public ResponseVo uploadFile(@Valid @RequestBody FileNewRequest request) {
String fileName = request.getName();
String md5 = request.getMd5();
try {
//写入文件标识记录
FileUpload file = (FileUpload) JSONObject.toBean(JSONObject.fromObject(request), FileUpload.class);
//如果文件标识记录不存在,则插入;如果存在,则更新文件大小
FileUpload fileUpload = fileMapper.findByMd5AndName(md5, fileName);
if (ObjectUtils.isNotEmpty(fileUpload)) {
fileMapper.updateSize(file);
} else {
fileMapper.insert(file);
}
fileUpload = fileMapper.findByMd5AndName(md5, fileName);
return new ResponseVo.Builder().ok().data(fileUpload).build();
}catch (Exception e){
return new ResponseVo.Builder().error().message(String.format("文件:%1$s 上传错误",
fileName)).build();
}
}
3. 编写分片是否存在判断接口:
此接口前端每个分片处理都需要调用判断,跟第4点中的接口配合使用;此处parent为第2点中接口返回的ID
接口入参:
public class FileExistsRequest {
private Long parent;
private String md5;
private Long size;
public Long getParent() {
return parent;
}
public void setParent(Long parent) {
this.parent = parent;
}
public String getMd5() {
return md5;
}
public void setMd5(String md5) {
this.md5 = md5;
}
public Long getSize() {
return size;
}
public void setSize(Long size) {
this.size = size;
}
}
接口:
/**
* 文件分片上传,判断文件记录或者文件分片是否已经存在
*
* @param request :
* @return com.liu.base.ResponseVo
* @author kevin
* @date 2021/1/9
*/
@PostMapping("/file/exists")
public ResponseVo fileExists(@Valid @RequestBody FileExistsRequest request) {
FileUpload file;
if(null != request.getParent()){
file = fileMapper.findByParentAndMd5(String.valueOf(request.getParent()), request.getMd5());
}else{
List<FileUpload> files = fileMapper.findByMd5(request.getMd5());
//查找父文件ID不为空的记录(即文件分片记录)
files = files.stream().filter(item -> item.getParent() != null).collect(Collectors.toList());
int count = files.size();
if(count > 1){
throw new CommonException("找到了不止一个分片/文件记录");
}else if (count < 1){
file = null;
}else {
file = files.get(0);
}
}
//文件标识或文件片不存在
if(file == null) {
return new ResponseVo.Builder().ok().data(FileExistsResponse.nonExistent()).build();
}
//无父文件ID,则表示记录为文件标识记录,判断文件是否存在
if(null == file.getParent() || 0 == file.getParent()){
List<FileUpload> patchs = fileMapper.findByParent(file.getId());
Long total = patchs.stream().mapToLong(FileUpload::getSize).sum();
if(total.equals(request.getSize())) {
return new ResponseVo.Builder().ok().data(FileExistsResponse.exists(file.getId())).build();
}else if(total == 0) {
return new ResponseVo.Builder().ok().data(FileExistsResponse.nonExistent()).build();
}else {
return new ResponseVo.Builder().ok().data(
FileExistsResponse.partExistent(file.getId(), fileMapper.findPatchIndexByParent(file.getId()))
).build();
}
}
//父文件ID存在,需要判断的内容为文件片
//文件片存在且大小为正确的大小,表示文件片已存在
if(file.getFileSize().equals(request.getFileSize())) {
return new ResponseVo.Builder().ok().data(FileExistsResponse.exists(file.getId())).build();
}
//文件片大小为0表示文件片还未上传
if(file.getFileSize() == 0) {
return new ResponseVo.Builder().ok().data(FileExistsResponse.nonExistent()).build();
}
//其他文件片大小不为0又不等于需要上传的文件片大小,表示文件片存在部分
return new ResponseVo.Builder().ok().data(
FileExistsResponse.partExistent(file.getId(), fileMapper.findPatchIndexByParent(file.getId()))
).build();
}
4. 编写文件分片上传接口:
此接口在前端调用文件分片是否存在判断分片不存在或只存在部分后调用;此处parent为第2点中接口返回的ID
接口入参:
public class FilePatchUploadRequest {
private String name;
private Long index;
private Long parent;
private String md5;
private Long size;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Long getIndex() {
return index;
}
public void setIndex(Long index) {
this.index = index;
}
public Long getParent() {
return parent;
}
public void setParent(Long parent) {
this.parent = parent;
}
public String getMd5() {
return md5;
}
public void setMd5(String md5) {
this.md5 = md5;
}
public Long getSize() {
return size;
}
public void setSize(Long size) {
this.size= size;
}
}
接口:
/**
* 文件分片上传
*
* @param request :
* @param patch :
* @return com.liu.base.ResponseVo
* @author kevin
* @date 2021/1/7
*/
@PostMapping("/file/patch/upload")
public ResponseVo filePatchUpload(@Valid FilePatchUploadRequest request,
@RequestParam("patch") MultipartFile patch){
Long index = request.getIndex();
//判断分片文件是否存在
if(patch.isEmpty()){
return new ResponseVo.Builder().error().message("第:%1$s 个分片未获取到分片文件!").build();
}
Long parent = request.getParent();
Long size = request.getSize();
String md5 = request.getMd5();
String name = request.getName();
//开始分片保存
FileUpload file = fileMapper.findByParentAndMd5(String.valueOf(parent), md5);
try {
//判断数据库如果分片不存在,或者分片大小与上传的分片的大小不一致,则上传保存分片
if (file == null || !file.getFileSize().equals(size)) {
Optional.ofNullable(file).ifPresent(e -> fileMapper.deleteByPk(e.getId()));
//保存分片文件到服务器,构建文件分片记录信息
FileUpload fileUpload = new FileUpload(index, parent, name,
saveFile(patch, size, "d:/"), md5, size);
return new ResponseVo.Builder().ok().build();
}
}catch (IOException e){
log.error(SystemConst.PATCH_UPLOAD_FILE_ERROR_LOG, name, index, e);
throw new CommonException(ResponseState.REQUEST_ERROR.getCode(),
String.format(SystemConst.UPLOAD_FILE_PATCH_ERROR, name, index));
}
return new ResponseVo.Builder().ok().build() ;
}
/**
* 保存文件块(此方法可提取为一个工具类中的工具方法)
* @author kevin
* @param source : 文件块
* @param size : 文件块大小
* @return java.lang.String
* @date 2021/1/7
*/
private String saveFile(MultipartFile source, Long size, String path) throws IOException {
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern(SystemConst.DATE_ONLY_FORMATTER));
if(null == source || source.isEmpty()) {
throw new CommonException("未获取到需要保存的分片文件!");
}
if(source.getSize() != size) {
throw new CommonException("分片文件大小与传入的分片大小不一致!");
}
path = path + File.separator + now;
File file = new File(path);
if(!file.exists() && !file.mkdirs()) {
throw new CommonException("创建文件夹失败");
}
//根据文件名获取文件类型
String fileType = (null == fileName || !fileName.contains(".")) ? "unknown" :
fileName.substring(fileName.lastIndexOf("."));
//保存文件块到磁盘
while(true) {
String saveFileName = UUID.randomUUID() + fileType.toLowerCase() + ".tmp";
File saveFilePath = new File(path, saveFileName);
//磁盘上块文件名如果已经存在,则重新生成文件名
if(saveFilePath.exists()) {
continue;
}
source.transferTo(saveFilePath);
return saveFilePath.getAbsolutePath();
}
}
5. 编写文件分片合并接口:
此接口在前端确认所有分片已完成上传后调用。此处parent为第2点中接口返回的ID
接口入参:
public class FileMergeRequest {
private Long parent;
private Long fileSize;
public Long getParent() {
return parent;
}
public void setParent(Long parent) {
this.parent = parent;
}
public Long getFileSize() {
return fileSize;
}
public void setFileSize(Long fileSize) {
this.fileSize = fileSize;
}
}
接口:
/**
* 文件分片合并
* @author kevin
* @param request :
* @return com.liu.base.ResponseVo
* @date 2021/1/8
*/
@ApiOperation(value = "文件分片合并", notes = "/file/merge")
@PostMapping("/file/merge")
public ResponseVo filePatchMerge(@Valid @RequestBody FileMergeRequest request) {
Long parent = request.getParent();
Long size = request.getFileSize();
//查询当前文件
FileUpload fileInfo = fileMapper.findByPk(parent);
String fileName = null == fileInfo ? "" : fileInfo.getName();
String md5 = null == fileInfo ? "" : fileInfo.getMd5();
try {
//查询当前文件所有分块
List<FileUpload> patchs = fileMapper.findByParentPatchIndex(parent);
Long total = patchs.stream().mapToLong(FileUpload::getFileSize).sum();
if (fileInfo == null || CollectionUtils.isEmpty(patchs) || !total.equals(size)) {
fileMapper.deleteByParent(parent);
log.error(String.format("文件合并失败,大小不正确");
return new ResponseVo.Builder().error().message("文件合并失败,大小不正确!").build();
}
//合并文件
String path = mergeFile(fileName, patchs.stream().map(FileUpload::getPath)
.collect(Collectors.toList()), filePath, userName);
fileMapper.updateByIdSetPathAndSize(String.valueOf(parent), path, String.valueOf(total));
fileMapper.deleteByParent(parent);
}catch (Exception e){
fileMapper.deleteByParent(parent);
e.printStackTrace();
return new ResponseVo.Builder().error().message("文件合并失败"+e.getMessage()).build();
}
return new ResponseVo.Builder().ok().build();
}
/**
* 合并文件块(此方法可提取到工具类)
* @author kevin
* @param fileName : 文件名称
* @param filePaths : 文件分片路径
* @param path : 文件存放路径
* @param directory : 文件存放目录
* @return java.lang.String
* @date 2021/1/8
*/
private String mergeFile(String fileName, List<String> filePaths, String path, String directory) throws IOException {
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern(SystemConst.DATE_ONLY_FORMATTER));
path = path + File.separator + (StringUtils.isEmpty(directory) ? "" : (directory + File.separator))+ now;
File file = new File(path);
if(!file.exists() && !file.mkdirs()) {
throw new CommonException("创建文件夹失败!");
}
//生成文件名
File saveFilePath = new File(path, fileName);
while (saveFilePath.exists()){
//生成文件名
Files.delete(saveFilePath.toPath());
}
//创建文件输出流
try (FileOutputStream fos = new FileOutputStream(saveFilePath)){
FileChannel out = fos.getChannel();
//循环每一个文件块对应的文件
for (String filename : filePaths) {
File patch = new File(filename);
//将每一个文件块的内容都追加到新文件中
try (FileChannel in = new FileInputStream(patch).getChannel()) {
in.transferTo(0, in.size(), out);
}
//追加完成后,删除文件块
Files.delete(patch.toPath());
}
out.close();
}
return saveFilePath.getAbsolutePath();
}