【Springboot文件上传】前后端双开,大文件秒传、断点续传的解决方案和优雅实现

效果图

Demo体验地址:http://easymall.ysqorz.top/file/upload(不保证长期有效)

思路和解决方案探讨

秒传

这里指的 “秒传”,是指:当用户选择上传一个文件时,服务端检测该文件之前是否已经被上传过,如果服务器已经存有该文件(完全一样),就立马返回前端 “文件已上传成功”。前端随即将进度条更新至100%。 这样给用户的感觉就是 “秒传” 的感觉。

对于每一个上传到服务器的资源,我们都需要往数据库的 db_file 表插入一条记录,每条记录都包含文件的MD5值、已上传的字节数等等,当然还包含其他文件信息字段。表的结构在后面会完全给出。

要实现秒传,我们需要考虑两个问题:

1、如何唯一标识一个文件?

计算整个文件的MD5值。这里,我找到了一个计算文件MD5值的在线工具:http://www.metools.info/other/o21.html。

2、如何计算整个文件的MD5值?

(1)首先计算整个文件的MD5值这个工作必须是由前端来完成,因为要由服务端计算文件MD5值,就必须先把整个文件上传到服务端。这不就违背了 “秒传” 的想法了吗?

(2)计算MD5值,我们需要借助一个js插件 spark-md5.js 。Github地址:https://github.com/satazor/js-spark-md5

上面的在线工具应该也是借助了该js插件。如何使用这个插件呢?Github的 README.md 里面提供了 demo,它的demo通过分块可计算大文件(数G)文件的MD5值。我试过,如果不分块,没办法计算大文件的MD5值。因此我们只需要把它的demo拷贝过来,改一下就行了。

【Springboot文件上传】前后端双开,大文件秒传、断点续传的解决方案和优雅实现_第1张图片

断点续传(断点上传)

断点续传是什么样的效果呢? 用户正在上传某个大文件,中途点击了 “取消” 。下次再次上传该文件时,能够从上次中断的地方继续上传,而不会从头开始上传。这个有点复杂,实现逻辑涉及到了前后端。

1、一般的文件上传的实现流程

一般的文件上传,服务端用MultipartFile来接收,而前端用ajax异步上传文件。假如文件很大,比如达到了数G,首先服务端肯定要设置最大的上传大小。文件上传无疑是个费时操作,这表示前端的文件发送是个费时操作,与此同时服务端的MultipartFile接收也是个费时操作。服务端在接收过程中,会产生一个临时文件,默认会在web应用服务器的一个临时目录,当然也可以指定。当文件上传完成后或者接收过程中发生错误,临时文件会被自动删除。你甚至可以自己验证,来观察到这个现象,这里我就不多说了。

2、如何取消文件上传呢?

用 ajax 异步上传文件,要想达到终止上传的效果,只能调用 XMLHttpRequest 的的 abort() 方法。这个方法会直接中断客户端和服务端的连接,导致服务端的流读取异常(SpringMVC抛出)。异常抛出之后,controller层以及后续的逻辑都不会执行。接收到一半生成的临时文件也会被自动删除。这也就意味着:上传进度没办法保存到数据库!

3、取消文件后如何保存文件上传进度?

如果文件上传被终止,无论是通过XMLHttpRequest 的的 abort() 方法 还是 网页突然关闭 、断网等等,都是前端单方面断开连接,服务端会抛出异常,导致临时文件被删除,无法保存上传进度。为了解决这个问题,我们可以使用分块上传的解决方案。

在前端,通过js将整个大文件的未上传的部分划分为 等大小的 n 块,每块的大小定义为 chunkSize(例如:2 MB) 。如果最后一块不足 chunkSize,则最后两块合并为一块。每一块的上传都发起一次 ajax 请求,每一块成功上传之后,服务端会通过NIO将这一块追加到自己创建的文件末尾,同时更新数据库中该文件的 “已上传字节数”。

如果某次ajax请求被突然中断,也是仅仅导致这个分块的上传失败而已,不影响前面已经成功上传的分块。那么下次再次上传时,前端接收服务端返回的文件 “已上传的字节数”。然后前端js,将可以据此定位到文件未上传的部分,然后将未上传的部分重新分块上传。

后端接口说明

数据返回格式封装

【Springboot文件上传】前后端双开,大文件秒传、断点续传的解决方案和优雅实现_第2张图片   

接口介绍 请求方式 请求路径 请求参数说明 请求参数备注 成功时返回的data
检查服务器中是否已有文件资源 post /file/check

fileMd5:整个文件的MD5值

totalBytes:整个文件的总字节数

suffix:文件的后缀

3个请求参数都是必须的

FileCheckRspVo { 

uploadToken:该接口签发的token(Jwt),上传时需要

uploadedBytes:该文件已上传的字节数

}

分块上传 post /file/upload

file:需要上传的分块

uploadToken:上传需要携带的token

2个参数都是必须的 已上传的字节数

为什么 /file/check 接口(下面简称 check接口)返回的data包含一个 uploadToken ?请求 /file/upload 接口(下面简称 upload接口)为什么要携带 /file/check 签发的 uploadToken?

upload 接口不能被随便请求,必须要在请求 check 接口之后!为了确保这个先后次序,所以请求 upload 接口必须携带 check 接口签发的令牌(jwt,可自行百度)。这个jwt令牌是没有办法伪造的,携带错误的或者过期的令牌访问upload接口,都会被检验出来!!!

关键代码

下面贴出关键代码和注释,只需要关注实现的具体逻辑的即可。如果需要Demo的完整代码:https://download.csdn.net/download/qq_43290318/13139165

前端代码

upload.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>


	
		
		
		
		大文件断点续传
		
		
		
		
		
		
		
		
		
	
	
		
0%

upload.js


// 真正上传文件的ajax请求
var uploadAjax = null;
var fileMd5; // 文件md5值

function downloadQRCode() {
	if (fileMd5 != null) {
		var url = '/file/qrcode/generate?fileMd5=' + fileMd5 + '&seconds=900';
		$('#downloadQRcode').attr('src', url);
	}
}

function upload() {
	// 文件限制检查
	var file = $('#i-file')[0].files[0];
	if (file == null) {
		return;
	}
	
	var suffix = file.name.substr(file.name.lastIndexOf('.'));
	var type = file.type;
	var totalBytes = file.size;
	var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
	console.log(suffix, type, totalBytes);
	
	// 开始。通过回调函数,进行链式调用
	calculteFileMd5();
	
	// 计算文件的MD5值,分块计算,支持大文件
	function calculteFileMd5() {
		var chunkSize = 2097152,    // Read in chunks of 2MB 。每一块的大小
	        chunks = Math.ceil(file.size / chunkSize), // 整个文件可分为多少块,向下取整
	        currentChunk = 0,	// 当前加载的块。初始化为0
	        spark = new SparkMD5.ArrayBuffer(),
	        fileReader = new FileReader();
		
		// fileReader加载文件数据到内存之后会执行此回调函数
		fileReader.onload = function (e) {
			refreshMsg('read chunk nr ' + (currentChunk + 1) + ' of ' + chunks);
			spark.append(e.target.result);                   // Append array buffer
			currentChunk++;
	
			if (currentChunk < chunks) {
				loadNext();
			} else {
				refreshMsg('finished loading');
				// 计算出文件的md5值
				fileMd5 = spark.end();
				refreshMsg('computed hash: ' + fileMd5);  // Compute hash
				
				// 服务器检查文件是否存在
				requestCheckFile();
			}
		};
		
		// 开始计算
		loadNext();
		
		function loadNext() {
			var start = currentChunk * chunkSize,
				end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
		
			fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
		}
	}
	
	// 请求服务器验证文件
	function requestCheckFile() {
		$.ajax({
			url: '/file/check',    // 提交到controller的url路径
			type: "POST",    // 提交方式
			dataType: "json",    
			data: {
				fileMd5: fileMd5,
				totalBytes: totalBytes,
				suffix: suffix
			}, 
			success: function (res) {    
				console.log(res);
				if (res.code === 2000) {
					var percentage = parseFloat(res.data.uploadedBytes) / totalBytes * 100; 
					refreshStatus(percentage);
					if (res.data.uploadedBytes < totalBytes) {
						requestRealUpload(res.data);
					}
				} 
			}
		});
	}
	
	// 分块上传
	function requestRealUpload(params) {
		var chunkSize = 2097152;    // 每一块的大小。2 M
	    //var chunks = Math.ceil((totalBytes - params.uploadedBytes) / chunkSize); // 尚未上传的部分可分为几块,取下整
	    //var currentChunk = 0;	// 当前加载的块。初始化为0

		uploadChunk(params.uploadedBytes);
		
		// 请求服务端,上传一块
		function uploadChunk(uploadedBytes) {
			var formData = new FormData();
			var start = uploadedBytes;
			var end = Math.min(start + chunkSize, totalBytes);
			console.log(start, end);
			formData.append('file', blobSlice.call(file, start, end)); // [start, end)
			formData.append('uploadToken', params.uploadToken); // 携带token
			var preLoaded = 0; // 当前块的上一次加载的字节数,用于计算速度
			var preTime = new Date().getTime(); // 上一次回调进度的时间
			uploadAjax = $.ajax({
			    url:  '/file/upload',
			    type: "POST",
			    data: formData,
				cache: false,
			    contentType: false,  // 必须 不设置内容类型
			    processData: false,  // 必须 不处理数据
			    xhr: function() {
			        //获取原生的xhr对象
			        var xhr = $.ajaxSettings.xhr();
			        if (xhr.upload) {
			            //添加 progress 事件监听
						//console.log(xhr.upload);
						xhr.upload.onprogress = function(e) {
							// e.loaded 应该是指当前块,已经加载到内存的字节数
							// 这里的上传进度是整个文件的上传进度,并不是指当前这一块
							var percentage = (start + e.loaded) / totalBytes * 100; 
							refreshStatus(percentage); // 更新百分比
							
							// 计算速度
							var now = new Date().getTime();
							var duration = now - preTime; // 毫秒
							var speed = ((e.loaded - preLoaded) / duration).toFixed(2); // KB/s
							preLoaded = e.loaded;
							preTime = now;
							//if (duration > 1000) {
								// 隔1秒才更新速度
								refreshMsg('正在上传:' + speed + ' KB/s');
							//}
						};
						xhr.upload.onabort = function() {
							refreshMsg('已取消上传,服务端已保存上传完成的分块,下次重传可续传');
						};
			        }
			        return xhr;
			    },
			    success: function(res) {
			        //成功回调 
					console.log(res);   
					if (res.code === 2000) {
						if (res.data < totalBytes) {
							uploadChunk(res.data); // 上传下一块
						} else {
							refreshMsg('上传完成!'); //所有块上传完成
						}
					} else {
						refreshMsg(res.msg); // 当前块上传失败,提示错误,后续块停止上传
					}
			    }
			});		
		}
	}
	
	// 刷新进度条
	function refreshStatus(percentage) {
		var per = (percentage).toFixed(2);
		console.log(per);
		$('#progressbar').text(per + '%');
		$('#progressbar').css({
			width: per + '%'
		});
	}
	// 更新提示信息
	function refreshMsg(msg) {
		$('#proccess-msg').text(msg);
	}
}

// 直接终端上传的ajax请求,后端会抛出异常
function cancel() {
	if (uploadAjax != null) {
		console.log(uploadAjax);
		uploadAjax.abort();
	}
}

服务端代码

FileController.java

package net.ysq.easymall.controller;

import java.io.IOException;

import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

import com.auth0.jwt.interfaces.DecodedJWT;

import net.ysq.easymall.common.JwtUtils;
import net.ysq.easymall.common.ResultModel;
import net.ysq.easymall.common.StatusCode;
import net.ysq.easymall.po.DbFile;
import net.ysq.easymall.po.User;
import net.ysq.easymall.service.FileService;
import net.ysq.easymall.vo.FileCheckRspVo;

/**
 * @author	passerbyYSQ
 * @date	2020-11-13 17:42:40
 */
@Controller
@RequestMapping("/file")
public class FileController {
	
	@Autowired
	private FileService fileService;
	
	@GetMapping("/upload")
	public String uploadPage() {
		return "upload";
	}
	
	@PostMapping("/check")
	@ResponseBody
	public ResultModel checkFileExist(String fileMd5, long totalBytes, 
			String suffix, HttpSession session) {
		// 简单的参数检查,之后再全局处理优化
		if (StringUtils.isEmpty(fileMd5) || totalBytes <= 0
				|| StringUtils.isEmpty(suffix)) {
			return ResultModel.error(StatusCode.PARAM_IS_INVALID);
		}
		
		/*
		// 检查大小
        DataSize size = DataSize.of(totalBytes, DataUnit.BYTES);
        // 限制100 M
        DataSize limit = DataSize.of(100, DataUnit.MEGABYTES);
        if (size.compareTo(limit) > 0) {
            String msg = String.format("当前文件大小为 %d MB,最大允许大小为 %d MB",
                    size.toMegabytes(), limit.toMegabytes());
            return ResultModel.error(StatusCode.FILE_SIZE_EXCEEDED.getCode(), msg);
        }
        */
		User user = (User) session.getAttribute("user");
		
		// 根据md5去数据库查询是否已存在文件
		DbFile dbFile = fileService.checkFileExist(fileMd5);
		// 如果不存在,则创建文件,并插入记录。如果已存在,返回结果
		if (ObjectUtils.isEmpty(dbFile)) {
			dbFile = fileService.createFile(fileMd5, totalBytes, suffix, user);
		}
		
		FileCheckRspVo fileCheckRspVo = new FileCheckRspVo();
		fileCheckRspVo.setUploadedBytes(dbFile.getUploadedBytes());
		if (dbFile.getUploadedBytes() < dbFile.getTotalBytes()) { // 未上传完,返回token
			String uploadToken = fileService.generateUploadToken(user.getEmail(), dbFile);
			fileCheckRspVo.setUploadToken(uploadToken);
		}
		
		return ResultModel.success(fileCheckRspVo);
	}
	
	@PostMapping("/upload")
	@ResponseBody
	public ResultModel uploadFile(MultipartFile file, String uploadToken) {
		
		// 解析过程可能会抛出异常,全局进行捕获
		DecodedJWT decodedJWT = JwtUtils.verifyJwt(uploadToken);
		String fileMd5 = decodedJWT.getClaim("fileMd5").asString();
		// 如果token验证通过(没有异常抛出),则肯定能找得到
		DbFile dbFile = fileService.checkFileExist(fileMd5);
		
		// 上传文件
		long uploadedBytes = fileService.transfer(file, dbFile);
		System.out.println("已上传:" + uploadedBytes);
		//System.out.println("总大小:" + dbFile.getTotalBytes());
		
		return ResultModel.success(uploadedBytes);
	}
	
	@GetMapping("/qrcode/generate")
	public void downloadByQrcode(String fileMd5, long seconds, 
			HttpServletResponse response) throws IOException, Exception {
		if (ObjectUtils.isEmpty(fileMd5)) {
			throw new Exception("fileMd5为空");
		}
		if (ObjectUtils.isEmpty(seconds) || seconds <= 0) {
			seconds = 60 * 15; // 15分钟
		}
		DbFile dbFile = fileService.checkFileExist(fileMd5);
		if (ObjectUtils.isEmpty(dbFile)) {
			throw new Exception("fileMd5错误");
		}
		
		fileService.generateDownloadQRCode(seconds, dbFile, response.getOutputStream());
	}
	
	@GetMapping("/qrcode/download")
	public void downloadByQrcode(String downloadToken, HttpSession session, 
			HttpServletResponse response) {
		System.out.println("download!!");
		
		DecodedJWT decodedJWT = JwtUtils.verifyJwt(downloadToken);
		String fileMd5 = decodedJWT.getClaim("fileMd5").asString();
		DbFile dbFile = fileService.checkFileExist(fileMd5);
		
		// 设置响应头
		response.setHeader("Content-Type", "application/x-msdownload");
		response.setHeader("Content-Disposition", "attachment; filename=" + dbFile.getRandName());
		
		fileService.download(dbFile, response);
	}
}

FileService.java

package net.ysq.easymall.service;

import java.io.OutputStream;

import javax.servlet.http.HttpServletResponse;

import org.springframework.web.multipart.MultipartFile;

import net.ysq.easymall.po.DbFile;
import net.ysq.easymall.po.User;

/**
 * @author	passerbyYSQ
 * @date	2020-11-13 17:54:06
 */
public interface FileService {
	// 下载
	void download(DbFile dbFile, HttpServletResponse response);
	
	// 生成下载的token
	void generateDownloadQRCode(long seconds, DbFile dbFile, OutputStream outStream) throws Exception;
	
	// 根据id查找
	DbFile findById(Integer fileId);
	
	// 根据fileMd5检查文件是否已存在
	DbFile checkFileExist(String fileMd5);
	
	// 在磁盘上创建文件,并将记录插入数据库
	DbFile createFile(String fileMd5, long totalBytes, String suffix, User user);
	
	// 生成上传文件的token
	String generateUploadToken(String email, DbFile dbFile); 
	
	// 复制到目标目录
	long transfer(MultipartFile file, DbFile dbFile);
}

FileServiceImpl.java

package net.ysq.easymall.service.impl;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.ResourceUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import net.ysq.easymall.common.CloseUtils;
import net.ysq.easymall.common.JwtUtils;
import net.ysq.easymall.common.QRCodeUtils;
import net.ysq.easymall.dao.DbFileMapper;
import net.ysq.easymall.po.DbFile;
import net.ysq.easymall.po.User;
import net.ysq.easymall.service.FileService;

/**
 * @author	passerbyYSQ
 * @date	2020-11-13 17:55:09
 */
@Service
public class FileServiceImpl implements FileService {
	
	@Autowired
	private DbFileMapper dbFileMapper;
	
	@Override
	public DbFile findById(Integer fileId) {
		DbFile record = new DbFile();
		record.setId(fileId);
		return dbFileMapper.selectOne(record);
	}

	@Override
	public DbFile checkFileExist(String fileMd5) {
		DbFile record = new DbFile();
		// 设置查询条件
		record.setFileMd5(fileMd5);
		// 找不到返回null
		DbFile dbFile = dbFileMapper.selectOne(record);
		//System.out.println(dbFile);
		return dbFile;
	}

	@Override
	public DbFile createFile(String fileMd5, long totalBytes, String suffix, User user) {
		try {
			// 创建目标目录
			File classpath = ResourceUtils.getFile("classpath:");
	        File destDir = new File(classpath, "upload/" + user.getEmail());
	        if (!destDir.exists()) {
	            destDir.mkdirs(); // 递归创建创建多级
	            System.out.println("创建目录成功:" + destDir.getAbsolutePath());
	        }
	        // 利用UUID生成随机文件名
	     	String randName = UUID.randomUUID().toString().replace("-", "") + suffix;
			File destFile = new File(destDir, randName);
			// 创建目标
			destFile.createNewFile();
			
			String path = user.getEmail() + "/" + randName;
			DbFile dbFile = new DbFile();
			dbFile.setFileMd5(fileMd5);
			dbFile.setRandName(randName);
			dbFile.setPath(path);
			dbFile.setTotalBytes(totalBytes);
			dbFile.setUploadedBytes(0L);
			dbFile.setCreatorId(user.getId());
			int count = dbFileMapper.insertSelective(dbFile);
			
			return count == 1 ? dbFile : null;
		} catch (IOException e) {
			e.printStackTrace();
		}
		return null;
	}

	@Override
	public String generateUploadToken(String email, DbFile dbFile) {
		Map claims = new HashMap<>();
		claims.put("fileMd5", dbFile.getFileMd5());
		// 5分钟后过期
		String jwt = JwtUtils.generateJwt(claims, 60 * 1000 * 5);
		return jwt;
	}
	
	@Override
	public void generateDownloadQRCode(long seconds, DbFile dbFile, OutputStream outStream) throws Exception {
		Map claims = new HashMap<>();
		claims.put("fileMd5", dbFile.getFileMd5());
		long millis = Duration.ofSeconds(seconds).toMillis();
		String downloadToken = JwtUtils.generateJwt(claims, millis);
		String downloadUrl = ServletUriComponentsBuilder
	        .fromCurrentContextPath()
	        .path("/file/qrcode/download")
	        .queryParam("downloadToken", downloadToken)
	        .toUriString();
		
		QRCodeUtils.encode(downloadUrl, outStream);
		//QRCodeUtil.generateWithStr(downloadUrl, outStream);
	}

	@Override
	public long transfer(MultipartFile file, DbFile dbFile) {
		
		InputStream inStream = null;
		ReadableByteChannel inChannel = null;
		FileOutputStream outStream = null;
		FileChannel outChannel = null;
		try {
			inStream = file.getInputStream();
			inChannel = Channels.newChannel(inStream);
			
			File classpath = ResourceUtils.getFile("classpath:");
			File destFile = new File(classpath, "upload/" + dbFile.getPath());
			outStream = new FileOutputStream(destFile, true); // 注意,第二个参数为true,否则无法追加
			outChannel = outStream.getChannel();
			
			long count = outChannel.transferFrom(inChannel, outChannel.size(), file.getSize());
			//long count = inChannel.transferTo(dbFile.getUploadedBytes(), inChannel.size(), outChannel);
			
			DbFile record = new DbFile();
			record.setId(dbFile.getId());
			record.setUploadedBytes(dbFile.getUploadedBytes() + count);
			// 更新已上传的字节数到数据库
			dbFileMapper.updateByPrimaryKeySelective(record);
			
			return record.getUploadedBytes();
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			CloseUtils.close(inChannel, inStream, outChannel, outStream);
		}
		return dbFile.getUploadedBytes();
	}

	@Override
	public void download(DbFile dbFile, HttpServletResponse response) {
		FileInputStream inStream = null;
		FileChannel inChannel = null;
		OutputStream outStream = null;
		WritableByteChannel outChannel = null;
		try {
			File classpath = ResourceUtils.getFile("classpath:");
			File destFile = new File(classpath, "upload/" + dbFile.getPath());
			inStream = new FileInputStream(destFile);
	        inChannel = inStream.getChannel();
	        outStream = response.getOutputStream();
	        outChannel = Channels.newChannel(outStream);
	        inChannel.transferTo(0, inChannel.size(), outChannel);
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			CloseUtils.close(outChannel, outStream, inChannel, inStream);
		}
	}

}

 

你可能感兴趣的:(#,SpringBoot,秒传,断点续传,文件上传,ajax,spring,boot)