最近想实现一下分片上传的功能,文件分片上传就能实现断点续传了,数据库记录也能保证秒传功能的实现。记录一下宝宝demo。
前端部分可以看看这篇文章:Springboot实现文件分片断点续传功能(前端篇)
项目代码已上传至GitHub。 https://github.com/huiluczP/segment_upload
具体后端实现使用的是springboot+mybatis。
主要就是segment_file
表,用来存储文件对应信息。
其中file_path
表示文件最终的物理路径(未完成的分片文件也会存储在parent文件夹中)。
file_name
是uuid,segment_index
是已经完成的分片数量,segment_total
为总的分片数量。
md5_key
是文件对应的唯一key,实现中是在前端计算的。
CREATE TABLE `segment_file` (
`id` int NOT NULL AUTO_INCREMENT,
`file_path` varchar(200) NOT NULL,
`file_name` varchar(200) DEFAULT NULL,
`size` int DEFAULT NULL,
`segment_index` int DEFAULT NULL,
`segment_size` int DEFAULT NULL,
`segment_total` int DEFAULT NULL,
`md5_key` varchar(200) DEFAULT NULL,
PRIMARY KEY (`id`)
)
持久化类 SegmentFile
public class SegmentFile implements Serializable {
private static final long serialVersionUID = -1937877331354085546L;
private int id;
private String filePath;
private String fileName;
private int size;
private int segmentIndex;
private int segmentSize;
private int segmentTotal;
private String md5Key;
getter…
setter…
}
Mapper接口,这边使用注解进行mybatis sql语句的配置.
@Mapper
public interface SegmentFileMapper {
// 获取对应的分片文件实体类
@Select("select * from segment_file where md5_key = #{key}")
@Results(id="segmentFileResult",value={
@Result(id=true, column = "id",property = "id"),
@Result(column = "file_path",property = "filePath"),
@Result(column = "file_name",property = "fileName"),
@Result(column = "size",property = "size"),
@Result(column = "segment_index",property = "segmentIndex"),
@Result(column = "segment_size",property = "segmentSize"),
@Result(column = "segment_total",property = "segmentTotal"),
@Result(column = "md5_key",property = "md5Key")
})
public List<SegmentFile> getSegmentFileByKey(String key);
// 添加对应的文件实体类
@Insert("insert into segment_file(id,file_path,file_name," +
"size,segment_index,segment_size,segment_total,md5_key) " +
"values(#{id},#{filePath},#{fileName},#{size},#{segmentIndex}," +
"#{segmentSize},#{segmentTotal},#{md5Key})")
public int insertSegmentFile(SegmentFile segmentFile);
// 主要用来更新分片信息
@Update({"update segment_file set " +
"file_path = #{filePath},file_name = #{fileName},size = #{size}," +
"segment_index = #{segmentIndex}, segment_size = #{segmentSize}," +
"segment_total = #{segmentTotal}, md5_key = #{md5Key}" +
"where id = #{id}" })
public int updateSegmentFile(SegmentFile segmentFile);
}
特别的,我们对文件保存路径在配置文件application.yml
中进行设置。同时,为了实现大文件分片的上传,关闭springboot的文件上传大小限制。数据库配置就不赘述了。
spring:
servlet:
multipart:
max-file-size: -1
max-request-size: -1
file:
save-path: E:/file/
FileUtil为文件处理工具类,包括利用uuid和md5码的文件名生成。特别的,我们将原文件的名称后加上#
和分片序号作为对应分片文件的名称。
// 工具类
// 文件名生成
public class FileUtil {
public static String getFileNameWithoutSuffix(String fileName){
int suffixIndex = fileName.lastIndexOf('.');
if(suffixIndex<0)
return fileName;
return fileName.substring(0, suffixIndex);
}
public static String getFileSuffix(String fileName){
int suffixIndex = fileName.lastIndexOf('.');
if(suffixIndex<0)
return "";
return fileName.substring(suffixIndex+1);
}
public static String getSegmentName(String fileName, int segmentIndex){
return fileName + "#" + segmentIndex;
}
public static String createSaveFileName(String key, String fileName){
String suffix = getFileSuffix(fileName);
return key + "." + suffix;
}
public static String createUUIDFileName(String fileName){
String suffix = getFileSuffix(fileName);
String name = UUID.randomUUID().toString();
return name + "." + suffix;
}
}
请求结果返回类,纯纯的简化,根据情况返回成功与否,message字符串也可以携带Json信息给前端使用。
public class ReturnResult {
private boolean success;
private String message;
public ReturnResult(boolean success, String message){
this.success = success;
this.message = message;
}
getter…
setter…
}
比较关键的业务类,包括文件存在确认,文件记录创建,文件信息更新,分片存储,分片合并和分片文件删除功能的实现。
断点续传第一步就是获取文件上传情况,这样才能知道已上传文件要从哪个分片开始续传。
// 该文件存在,返回数据
public SegmentFile checkSegmentFile(String key){
List<SegmentFile> segmentFiles = segmentFileMapper.getSegmentFileByKey(key);
if(segmentFiles!=null&&segmentFiles.size()>0)
return segmentFiles.get(0);
else
return null;
}
简单调用mapper从数据库中找就行,由于文件的md5码是唯一的,所以使用key进行查询。
文件不存在,那么第一步需要创建对应的文件记录,方便分片上传。
// 第一次出现的文件,把数据存到数据库中
// savePath为文件夹绝对位置
public boolean createSegmentFile(String originFileName, String savePath, int size, int segmentSize, String key){
String fileName = FileUtil.createUUIDFileName(originFileName);
String saveFileName = FileUtil.createSaveFileName(key, originFileName);
SegmentFile segmentFile = new SegmentFile();
// filepath为完整路径
segmentFile.setFilePath(savePath + saveFileName);
segmentFile.setFileName(fileName);
segmentFile.setSize(size);
segmentFile.setSegmentIndex(0);
segmentFile.setSegmentSize(segmentSize);
// 计算分片总数
int total = size / segmentSize;
if(size % segmentSize != 0)
total++;
segmentFile.setSegmentTotal(total);
segmentFile.setMd5Key(key);
return segmentFileMapper.insertSegmentFile(segmentFile) > 0;
}
使用file工具类创建各种对应文件名称,savePath在调用时从配置文件中读取。之后根据分片大小和文件总体大小计算分片总数,全部存储到数据库中。(其实因为md5码基本就是唯一的了,uuid并不需要直接用key作为文件名就行了,不过写都写了懒得改了。)
数据库update,没什么好说的。
// 更新总文件的数据库信息
public boolean updateSegmentFileInfo(SegmentFile segmentFile){
int result = segmentFileMapper.updateSegmentFile(segmentFile);
return result>0;
}
包括分片文件名称计算,文件物理存储和数据库中信息修改。
// 存储分片到服务器
public boolean saveSegment(MultipartFile file, String savePath, String key, String originFileName, int segmentIndex){
String saveFileName = FileUtil.createSaveFileName(key, originFileName);
String segmentFileName = FileUtil.getSegmentName(saveFileName, segmentIndex);
// 存储分片,方便之后使用
boolean saveSuccess = upload(file, savePath+segmentFileName);
if(saveSuccess){
// 修改数据库中分片记录
SegmentFile segmentFile = segmentFileMapper.getSegmentFileByKey(key).get(0);
segmentFile.setSegmentIndex(segmentFile.getSegmentIndex()+1);
int row = segmentFileMapper.updateSegmentFile(segmentFile);
return row > 0;
}else
return false;
}
// 存储方法
private boolean upload(MultipartFile file, String path){
System.out.println(path);
File dest = new File(path);
//判断文件父目录是否存在
if (!dest.getParentFile().exists()) {
boolean b = dest.getParentFile().mkdir();
if(!b){
return b;
}
}
//保存文件
try {
file.transferTo(dest);
return true;
} catch (IllegalStateException | IOException e) {
e.printStackTrace();
return false;
}
}
这边upload中直接用spring给的transferTo
方法进行文件的转储。
当所有分片成功上传,segmentIndex
和segmentTotal
相同时,需要将所有分片整合成总文件。总体思路就是根据名称按照index顺序读取所有的分片,同时利用write
方法按顺序写到FileOutputStream
中去,最后输出总文件。
// 将所有的分片联合成同一文件
public boolean mergeSegment(String key) {
SegmentFile segmentFile = segmentFileMapper.getSegmentFileByKey(key).get(0);
int segmentCount = segmentFile.getSegmentTotal();
FileInputStream fileInputStream = null;
FileOutputStream outputStream = null;
byte[] byt = new byte[10 * 1024 * 1024];
try {
// 整合结果文件
File newFile = new File(segmentFile.getFilePath());
outputStream = new FileOutputStream(newFile, true);
int len;
for (int i = 0; i < segmentCount; i++) {
String segmentFilePath = FileUtil.getSegmentName(segmentFile.getFilePath(), i + 1);
fileInputStream = new FileInputStream(new File(segmentFilePath));
while ((len = fileInputStream.read(byt)) != -1) {
outputStream.write(byt, 0, len);
}
}
} catch (IOException e) {
System.out.println("分片合并异常");
return false;
} finally {
try {
if (fileInputStream != null) {
fileInputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
System.out.println("IO流关闭");
} catch (Exception e) {
System.out.println("IO流关闭");
}
}
System.out.println("分片合并成功");
return true;
}
逻辑简单,主要就是出错时的try-catch和返回比较绕。
完成分片整合后需要将不需要的分片文件删除,需要注意
,直接调用删除的话可能会有对象数据流还占用着文件导致删除失败,所以先显示调用下gc回收对象再尝试删除。
之后还有没删干净的文件,利用visit表记录有没有删除同时自旋判断是否删除干净。在外部调用这个方法时需要另开线程并行防止自旋影响正常功能。
// 完成合并,删除分片文件
public void deleteSegments(String key) throws InterruptedException {
// 为了保证不被占用,先回收数据流对象
System.gc();
Thread.sleep(1000);
SegmentFile segmentFile = segmentFileMapper.getSegmentFileByKey(key).get(0);
int segmentCount = segmentFile.getSegmentTotal();
ArrayList<String> remain = new ArrayList<>();
int finished = 0;
int[] visited = new int[segmentCount];
for (int i = 0; i < segmentCount; i++) {
String segmentFilePath = FileUtil.getSegmentName(segmentFile.getFilePath(), i + 1);
remain.add(segmentFilePath);
File file = new File(segmentFilePath);
boolean result = file.delete();
if(result) {
finished++;
visited[i] = 1;
}
System.out.println("分片文件:" + segmentFilePath + "删除" + (result?"成功":"失败"));
}
// visited数组,然后完成了再去除,知道count到达总数
while(finished<segmentCount){
System.gc();
Thread.sleep(1000);
for(int i=0;i<segmentCount;i++){
if(visited[i]==0){
String segmentFilePath = FileUtil.getSegmentName(segmentFile.getFilePath(), i + 1);
remain.add(segmentFilePath);
File file = new File(segmentFilePath);
boolean result = file.delete();
if(result){
visited[i] = 1;
finished++;
}
System.out.println("分片文件:" + segmentFilePath + "删除" + (result?"成功":"失败"));
}
}
}
}
当全部visited之后,就结束自旋,完成分片删除。
主要是两个方法,一个是判断当前文件上传状况,主要就是想在前端选中文件后就调用一下,显示文件上传状态,这样就能实现秒传功能的效果了。第二个就是上传分片功能,下面具体讲一下。
controller中先设置service,json objectMapper,存储地址。
@Autowired
SegmentFileService segmentFileService;
@Autowired
private ObjectMapper mapper;
@Value("${file.save-path}")
private String savePath;
输入参数为文件对应的唯一编码,前端计算。
@RequestMapping("/checkFile")
@ResponseBody
// 检查文件是否已经存在,且返回segment信息
public ReturnResult checkFileExist(String key) throws JsonProcessingException {
SegmentFile segmentFile = segmentFileService.checkSegmentFile(key);
if(segmentFile==null)
return new ReturnResult(false, "该文件未上传");
else{
// 转成json回去用
String fileJson = mapper.writeValueAsString(segmentFile);
return new ReturnResult(true, fileJson);
}
}
返回类中success表示文件是否存在,message中包含文件信息的json结果。
主要分为以下几步:文件状态判断和信息写入;当前分片存储;分片上传完成判断;分片合并;分片删除;
输入参数包括:文件本身,文件原名(用来获取后缀名),文件总大小,分片序号,分片大小和文件对应唯一码。
文件判断和信息写入,就是简单判断文件是否存在,不存在就调用create方法。
public ReturnResult upLoadSegmentFile(MultipartFile file, String originFileName, Integer fileSize, Integer segmentIndex, Integer segmentSize, String key) throws JsonProcessingException{
System.out.println("文件 " + originFileName + " 开始");
// 查找是否存在,不存在就写入
SegmentFile segmentFile = segmentFileService.checkSegmentFile(key);
if(segmentFile==null){
boolean writeSuccess = segmentFileService.createSegmentFile(originFileName, savePath, fileSize, segmentSize, key);
if(!writeSuccess){
// 写入失败,返回错误信息
return new ReturnResult(false, "文件数据库记录创建失败");
}
}
segmentFile = segmentFileService.checkSegmentFile(key);
当前分片存储,存储后进行数据库记录的更新。
// 将当前分片存入
boolean segmentWriteSuccess = segmentFileService.saveSegment(file, savePath, key, originFileName, segmentIndex);
if(!segmentWriteSuccess)
// 分片存储失败
return new ReturnResult(false, "分片文件失败");
segmentFile.setSegmentIndex(segmentIndex);
boolean isUpdateSuccess = segmentFileService.updateSegmentFileInfo(segmentFile);
if(!isUpdateSuccess)
// 更新失败
return new ReturnResult(false, "文件数据库记录更新失败");
内部线程类,用来后续实现删除功能。
class deleteThread implements Runnable{
@Override
public void run() {
try {
segmentFileService.deleteSegments(key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
判断分片是否上传完成,合并并开始删除所有分片文件。
// 判断是否分片齐全,齐全则合并生成究极文件
// 其实考虑这步会不会失败应该在数据库再加一个值,再说吧
if(segmentIndex==segmentFile.getSegmentTotal()){
boolean mergeSuccess = segmentFileService.mergeSegment(key);
if(mergeSuccess) {
// 另开线程去自旋删除
new Thread(new deleteThread()).start();
return new ReturnResult(true, mapper.writeValueAsString(segmentFile));
}
else
return new ReturnResult(false, "文件合并失败");
}
return new ReturnResult(true, mapper.writeValueAsString(segmentFile));
这篇文章主要是记录了springboot框架下的文件断点续传后端流程,觉得有用就看看吧。
因为是默认串行调用,文件已上传分片信息直接用当前上传的分片序号覆盖。如果要并行实现的话,数据库中可能需要存储一个总分片数量大小长度的字符串,用来记录上传进度(状态压缩),比如111011
,表示6个分片,分片4未上传,这样就能并行上传分片了,有机会实现一下并行的。
项目代码已上传至GitHub https://github.com/huiluczP/segment_upload
前端部分可以看看这篇文章:SpringBoot实现文件分片断点续传功能(前端篇)