一、大文件上传简介
1、秒传
通俗的说,你把要上传的东西上传,服务器会先做MD5校验,如果服务器上有一样的东西,它就直接给你个新地址,其实你下载的都是服务器上的同一个文件,想要不秒传,其实只要让MD5改变,就是对文件本身做一下修改(改名字不行),例如一个文本文件,你多加几个字,MD5就变了,就不会秒传了
2、分片上传
2.1 介绍
分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件
2.2 应用场景
-
大文件上传
-
网络环境环境不好,存在需要重传风险的场景
3、断点续传
3.1 介绍
断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而没有必要从头开始上传或者下载。
3.2 应用场景
断点续传可以看成是分片上传的一个衍生,因此可以使用分片上传的场景,都可以使用断点续传
3.3 核心逻辑
在分片上传的过程中,如果因为系统崩溃或者网络中断等异常因素导致上传中断,这时候客户端需要记录上传的进度。在之后支持再次上传时,可以继续从上次上传中断的地方进行继续上传。为了避免客户端在上传之后的进度数据被删除而导致重新开始从头上传的问题,服务端也可以提供相应的接口便于客户端对已经上传的分片数据进行查询,从而使客户端知道已经上传的分片数据,从而从下一个分片数据开始继续上传。
3.4 实现流程步骤
方案一,常规步骤
-
将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
-
初始化一个分片上传任务,返回本次分片上传唯一标识;
-
按照一定的策略(串行或并行)发送各个分片数据块;
-
发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件。
方案二、更高效
-
前端(客户端)需要根据固定大小对文件进行分片,请求后端(服务端)时要带上分片序号和大小
-
服务端创建conf文件用来记录分块位置,conf文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认的0,已上传的就是Byte.MAX_VALUE 127(这步是实现断点续传和秒传的核心步骤)
-
服务器按照请求数据中给的分片序号和每片分块大小(分片大小是固定且一样的)算出开始位置,与读取到的文件片段数据,写入文件。
二、普通方式
1、整体思路
1.1 前端部分思路
所有请求都使用ajax
-
文件控件选择后,计算文件唯一码,调用接口查询文件是否存在。文件存在则判断分片是否上传完成,已完成显示秒传信息;
-
点击上传按钮后,再查询一次文件是否存在,来获取文件分片信息。文件不存在,那么起始分片为1;文件存在,那么获取起始分片为已上传+1;
-
ajax串行调用分片上传方法,成功后进行分片序号+1的分片上传,直到最终已上传分片序号和总分片数量相同。
1.2 后端部分思路
-
首先利用数据库存储文件信息,包括文件物理地址,分片接收进程和对应的md5码。利用md5码可以判断当前上传文件是否在服务器中存在(实现秒传),利用分片接收Index可以判断现在应该上传。
-
前端ajax获取文件存在与否的信息,几种情况:
-
不存在,则创建数据库记录,成功后调用分片1的上传
-
存在,Index和总分片数量相同,秒传成功显示结果
-
存在,但index小于总分片数量,调用分片index的上传
-
-
分片在前端根据分片Index计算起点末尾,slice切割,ajax调用上传传到服务器并存储。当前分片传递成功,ajax接收success信息,串行进行index+1的分片的上传
2、环境准备
本次Demo项目是前后端一起,前端部分使用了内嵌的thymeleaf
,根据链接跳转自动访问resource/static/
下的静态文件,如果前后端分离可以参考,首先引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
配置文件
spring:
datasource:
url: jdbc:mysql://localhost:3306/test?serverTimezone=GMT%2B8&autoReconnect=true&useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: root
driverClassName: com.mysql.cj.jdbc.Driver
thymeleaf:
cache: false
servlet:
multipart:
max-file-size: -1
max-request-size: -1
mvc:
static-path-pattern: /**
session:
store-type: jdbc
jdbc:
initialize-schema: always
file:
save-path: F:/file/
temp: F:/file/temp/
segment: 2*1024*1024
max-file-size: 500
logging:
level:
root: INFO
数据库设计
DROP TABLE IF EXISTS `segment_file`;
CREATE TABLE `segment_file` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`file_path` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件保存位置(用处不大)',
`file_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件名',
`size` bigint NULL DEFAULT NULL COMMENT '文件大小,单位B',
`segment_index` int NULL DEFAULT NULL COMMENT '已上传分片位置',
`segment_size` int NULL DEFAULT NULL COMMENT '分片大小',
`segment_total` int NULL DEFAULT NULL COMMENT '分片数量',
`md5_key` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'MD5用来识别文件的唯一码',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
项目结构图
3、前端实现
上传界面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>segment upload</title>
<script src="http:///libs/jquery/2.1.4/jquery.min.js"></script>
<script type="text/javascript" src="/js/md5.js"></script>
<script type="text/javascript" src="/js/tool.js"></script>
<script src="/js/upload.js"></script>
</head>
<body>
<div id="top" class="center">
<p id="message"></p>
</div>
<div id="upload" class="center">
<h1>Segment File Upload</h1>
<input type="file" name="filename" id="filename" onchange="checkFile()"/>
<input type="button" id="submit" onclick="upload()" value="submit"/>
<span id="output">等待中</span>
</div>
<span id="uuid">uuid_name:</span>
<span id="md5" style="margin-left:20px;">md5_key:</span>
</body>
</html>
上传Js代码
// 控制文件分片和上传
// 不要忘记控制前端的显示结果
// 简单尝试直接使用串行
var key = ''
var segmentIndex = 0
var segmentSize = 2 * 1024 * 1024; // 先2MB用着
// 文件key计算
function calFileKey(file){
//把文件的信息存储为一个字符串
var filedetails= file.name + file.size + file.type + file.lastModifiedDate;
//使用当前文件的信息用md5加密生成一个key
var key = hex_md5(filedetails);
console.log(key)
var key10 = parseInt(key,16);
console.log(key10)
//把加密的信息 转为一个62位的
var key62 = Tool._10to62(key10);
console.log("cal key:" + key62)
return key62
}
// 计算分片数量
// 注意分片序号从1开始
function calTotalSegmentSize(file){
var size = file.size
var segmentTotal = Math.ceil(size / segmentSize)
return segmentTotal;
}
// 计算分片的开始
function calSegmentStartAndEnd(segmentIndex, file){
var start = (segmentIndex - 1) * segmentSize;
var end = Math.min(start + segmentSize, file.size);
return [start, end];
}
// 检测当前文件是否存在,存在且完成上传则输出秒传信息
// 存在但未完成,则将upload的segmentIndex修改,等待后续上传(把前端信息也修改一下)
// 不存在则md5码(key),等待后续上传(把前端信息也修改一下)
function checkFile(){
var file = $('#filename').get(0).files[0]
key = calFileKey(file)
$('#md5').html('md5_key: ' + key)
console.log(file.name)
// ajax请求找下数据库中该文件是否存在
$.ajax({
url:"/checkFile",
type:"post",
cache: false,
data: {
'key': key
},
dataType: 'json',
success:function(data){
var result = data.success
if(!result){
$('#uuid').html('uuid_name:')
$('#output').html('该文件未上传')
}else{
var segmentFile = JSON.parse(data.message)
var segmentIndexNow = segmentFile.segmentIndex
var segmentTotal = segmentFile.segmentTotal
var uuid = segmentFile.fileName
$('#uuid').html('uuid_name: ' + uuid)
if(segmentIndexNow===segmentTotal){
// 完成上传
$('#output').html('该文件已完成上传')
}else{
$('#output').html(segmentIndexNow + '/' +segmentTotal)
segmentIndex = segmentIndexNow + 1
}
}
},
error:function(){
$('#output').html("check请求错误")
console.log("check请求错误")
}
})
}
// 总的上传方法,中间递归上传分片
function upload(){
var file = $('#filename').get(0).files[0]
key = calFileKey(file)
$('#md5').html('md5_key:' + key)
// ajax请求找下数据库中该文件是否存在
$.ajax({
url:"/checkFile",
type:"post",
cache: false,
data: {
'key': key
},
dataType: 'json',
success:function(data){
var result = data.success
if(!result){
var segmentIndexNow = 0
var segmentTotal = calTotalSegmentSize(file)
$('#uuid').html('uuid_name:')
$('#output').html(segmentIndexNow + '/' +segmentTotal)
var segmentIndex = segmentIndexNow + 1
// 开始上传分片
uploadSegment(segmentIndex, file, key)
}else{
var segmentFile = JSON.parse(data.message)
var segmentIndexNow = segmentFile.segmentIndex
var segmentTotal = segmentFile.segmentTotal
var uuid = segmentFile.fileName
$('#uuid').html('uuid_name: ' + uuid)
if(segmentIndexNow==segmentTotal){
// 完成上传
$('#output').html('该文件已完成上传')
}else{
$('#output').html(segmentIndexNow + '/' +segmentTotal)
var segmentIndex = segmentIndexNow + 1
// 开始上传分片
uploadSegment(segmentIndex, file, key)
}
}
},
error:function(){
console.log("check请求错误")
}
})
}
// 上传分片
function uploadSegment(segmentIndex, file, key){
var fd = new FormData();
var segmentIndex = segmentIndex;
var sAe = calSegmentStartAndEnd(segmentIndex, file)
var segmentStart = sAe[0]
var segmentEnd = sAe[1]
var segment = file.slice(segmentStart, segmentEnd)
var segmentTotal = calTotalSegmentSize(file)
var originFileName = file.name
fd.append('file', segment)
fd.append('fileSize', file.size)
fd.append('segmentIndex', segmentIndex)
fd.append('key', key)
fd.append('segmentSize', segmentSize)
fd.append('originFileName', originFileName)
$.ajax({
url:"/uploadSegment",
type:"post",
cache: false,
data:fd,
processData: false,
contentType: false,
success:function(data){
var result = data.success
if(!result){
$('#output').html(data.message)
}else{
var segmentFile = JSON.parse(data.message)
var uuid = segmentFile.fileName
$('#uuid').html('uuid_name: ' + uuid)
// 递归调用
$('#output').html(segmentIndex + "/" + segmentTotal)
if(segmentIndex < segmentTotal)
uploadSegment(segmentIndex+ 1, file, key)
}
},error:function(){
console.log("分片" + segmentIndex + "上传失败")
}
})
}
4、后端实现
4.1 持久化类与全局返回类
@Data
public class SegmentFile {
private int id;
private String filePath;
private String fileName;
private long size;
private int segmentIndex;
private int segmentSize;
private int segmentTotal;
private String md5Key;
}
public class ReturnResult {
private boolean success;
private String message;
public ReturnResult(boolean success, String message){
this.success = success;
this.message = message;
}
}
4.2 Mapper接口
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")
})
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})")
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}" })
int updateSegmentFile(SegmentFile segmentFile);
}
4.3 文件工具类
// 工具类
// 文件名生成
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;
}
}
4.4 Service分片服务类
比较关键的业务类,包括文件存在确认,文件记录创建,文件信息更新,分片存储,分片合并和分片文件删除功能的实现
// 分片存储
// 文件存在确认
// 文件整合
@Service
@Slf4j
public class SegmentFileService {
@Value("${file.temp}")
private String tempFileDir;
private final SegmentFileMapper segmentFileMapper;
public SegmentFileService(SegmentFileMapper segmentFileMapper) {
this.segmentFileMapper = segmentFileMapper;
}
/**
* 该文件存在,返回数据
*/
public SegmentFile checkSegmentFile(String key){
List<SegmentFile> segmentFiles = segmentFileMapper.getSegmentFileByKey(key);
if(segmentFiles!=null&&segmentFiles.size()>0) {
return segmentFiles.get(0);
} else {
return null;
}
}
/**
* 第一次出现的文件,把数据存到数据库中
* savePath为文件夹绝对位置
*/
public boolean createSegmentFile(String originFileName, String savePath, long size, int segmentSize, String key){
String saveFileName = FileUtil.createSaveFileName(key, originFileName);
SegmentFile segmentFile = new SegmentFile();
// filepath为完整路径
segmentFile.setFilePath(savePath + saveFileName);
segmentFile.setFileName(saveFileName);
segmentFile.setSize(size);
segmentFile.setSegmentIndex(0);
segmentFile.setSegmentSize(segmentSize);
int total = (int) (size / segmentSize);
if(size % segmentSize != 0) {
total++;
}
segmentFile.setSegmentTotal(total);
segmentFile.setMd5Key(key);
return segmentFileMapper.insertSegmentFile(segmentFile) > 0;
}
/**
* 存储分片到服务器
*/
public boolean saveSegment(MultipartFile file,String key, String originFileName, int segmentIndex){
String saveFileName = FileUtil.createSaveFileName(key, originFileName);
String segmentFileName = FileUtil.getSegmentName(saveFileName, segmentIndex);
// 存储分片,方便之后使用
boolean saveSuccess = upload(file, tempFileDir +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;
}
}
/**
* 将所有的分片联合成同一文件
*/
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(tempFileDir + segmentFile.getFileName(), i + 1);
fileInputStream = new FileInputStream(segmentFilePath);
while ((len = fileInputStream.read(byt)) != -1) {
outputStream.write(byt, 0, len);
}
}
} catch (IOException e) {
log.error("分片合并异常,异常原因:",e);
return false;
} finally {
try {
if (fileInputStream != null) {
fileInputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
log.info("IO流正常关闭");
} catch (Exception e) {
log.error("IO流关闭异常,异常原因:",e);
}
}
log.info("分片合并成功");
return true;
}
/**
* 完成合并,删除分片文件
*/
public void deleteSegments(String key) throws InterruptedException {
// 为了保证不被占用,先回收数据流对象
System.gc();
Thread.sleep(1000);
SegmentFile segmentFile = segmentFileMapper.getSegmentFileByKey(key).get(0);
int segmentCount = segmentFile.getSegmentTotal();
List<String> remain = new ArrayList<>();
int finished = 0;
int[] visited = new int[segmentCount];
for (int i = 0; i < segmentCount; i++) {
String segmentFilePath = FileUtil.getSegmentName(tempFileDir + segmentFile.getFileName(), i + 1);
remain.add(segmentFilePath);
File file = new File(segmentFilePath);
boolean result = file.delete();
if(result) {
finished++;
visited[i] = 1;
}
log.info("分片文件: {} 删除 {}" , 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++;
}
log.info("分片文件: {} 删除 {}" , segmentFilePath, result?"成功":"失败");
}
}
}
}
/**
* 存储方法
*/
private boolean upload(MultipartFile file, String path){
File dest = new File(path);
//判断文件父目录是否存在
if (!dest.getParentFile().exists()) {
boolean b = dest.getParentFile().mkdir();
if(!b){
return false;
}
}
//保存文件
try {
file.transferTo(dest);
return true;
} catch (IllegalStateException | IOException e) {
e.printStackTrace();
return false;
}
}
}
4.5 Controller类
主要是两个方法,一个是判断当前文件上传状况,主要就是想在前端选中文件后就调用一下,显示文件上传状态,这样就能实现秒传功能的效果了。第二个就是上传分片功能
/**
* 主要实现check文件存在与否
* 上传分片/整合分片
* 这里一定要@Controller,否则就不会跳转到static下了
*/
@Controller
@Slf4j
public class SegmentFileController {
@Autowired
SegmentFileService segmentFileService;
@Autowired
private ObjectMapper mapper;
@Value("${file.save-path}")
private String savePath;
@RequestMapping("/index")
public String index(){
return "/index.html";
}
@RequestMapping("/upload")
public String upload(){
return "/pages/upload.html";
}
@RequestMapping("/checkFile")
@ResponseBody
// 检查文件是否已经存在,且返回segment信息
public ReturnResult checkFileExist(String key) throws JsonProcessingException {
SegmentFile segmentFile = segmentFileService.checkSegmentFile(key);
if(segmentFile==null) {
log.warn("该文件未上传,md5:{}",key);
return new ReturnResult(false, "该文件未上传");
} else{
// 转成json回去用
String fileJson = mapper.writeValueAsString(segmentFile);
return new ReturnResult(true, fileJson);
}
}
/**
* 主要方法流程
* 上传文件需要从前端取分片序号和分片大小,因为切割是前端切滴,所以文件原始大小也要返回来
* 剩余信息在service中计算
* 首先确认是否存在该文件,不存在就放到数据库中新建
* 之后对segmentIndex分别处理,存储分片文件(文件分片前端完成)
* 简化情况,认为前端都是异步请求,并且分片是按顺序请求的,只有前面的index处理了才能处理后面的分片(在前端体现)
* 这样当segmentIndex和总count相同时,获取结果
* 最后如果失败了,需要删除数据库的记录,这样就可以让用户再次上传
*/
@RequestMapping("/uploadSegment")
@ResponseBody
public ReturnResult upLoadSegmentFile(MultipartFile file, String originFileName, long fileSize, Integer segmentIndex, Integer segmentSize, String key) throws JsonProcessingException{
log.info("分片文件 {} 上传开始",originFileName);
// 查找是否存在,不存在就写入
SegmentFile segmentFile = segmentFileService.checkSegmentFile(key);
if(segmentFile==null){
boolean writeSuccess = segmentFileService.createSegmentFile(originFileName, savePath, fileSize, segmentSize, key);
if(!writeSuccess){
// 写入失败,返回错误信息
log.warn("文件数据库记录创建失败");
return new ReturnResult(false, "文件数据库记录创建失败");
}
}
segmentFile = segmentFileService.checkSegmentFile(key);
// 将当前分片存入
boolean segmentWriteSuccess = segmentFileService.saveSegment(file, key, originFileName, segmentIndex);
if(!segmentWriteSuccess) {
log.warn("分片文件存储失败");
// 分片存储失败
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 {
log.warn("文件合并失败");
return new ReturnResult(false, "文件合并失败");
}
}
return new ReturnResult(true, mapper.writeValueAsString(segmentFile));
}
}
5、总结
因为是默认串行调用,文件已上传分片信息直接用当前上传的分片序号覆盖。如果要并行实现的话,数据库中可能需要存储一个总分片数量大小长度的字符串,用来记录上传进度(状态压缩),比如111011
,表示6个分片,分片4未上传,这样就能并行上传分片了
三、进阶方案
1、介绍
这篇文章写的也不错,使用的是Vue+SpringBoot大文件上传,可以参考:SpringBoot 实现大文件分片上传、断点续传及秒传
前端部分可以使用百度或者使用第三方的上传组件;后端用两种方式实现文件写入,一种是用RandomAccessFile
(参考:https:///dimudan2015/article/details/81910690);另一种是使用MappedByteBuffer
(参考:https:///p/f90866dcbffc)
使用该种方法要注意每一个分块的记录,因为通过偏移量存储文件的方式是直接操作源文件的,并不会生成一块块的分片文件,分片文件使用Mysql或者Redis进行记录,最后传输成功后记录完整的文件信息。
2、核心代码介绍
文件操作核心模板类代码
@Slf4j
public abstract class SliceUploadTemplate implements SliceUploadStrategy {
public abstract boolean upload(FileUploadRequestDTO param);
protected File createTmpFile(FileUploadRequestDTO param) {
FilePathUtil filePathUtil = SpringContextHolder.getBean(FilePathUtil.class);
param.setPath(FileUtil.withoutHeadAndTailDiagonal(param.getPath()));
String fileName = param.getFile().getOriginalFilename();
String uploadDirPath = filePathUtil.getPath(param);
String tempFileName = fileName + "_tmp";
File tmpDir = new File(uploadDirPath);
File tmpFile = new File(uploadDirPath, tempFileName);
if (!tmpDir.exists()) {
tmpDir.mkdirs();
}
return tmpFile;
}
@Override
public FileUploadDTO sliceUpload(FileUploadRequestDTO param) {
boolean isOk = this.upload(param);
if (isOk) {
File tmpFile = this.createTmpFile(param);
FileUploadDTO fileUploadDTO = this.saveAndFileUploadDTO(param.getFile().getOriginalFilename(), tmpFile);
return fileUploadDTO;
}
String md5 = FileMD5Util.getFileMD5(param.getFile());
Map<Integer, String> map = new HashMap<>();
map.put(param.getChunk(), md5);
return FileUploadDTO.builder().chunkMd5Info(map).build();
}
/**
* 检查并修改文件上传进度
*/
public boolean checkAndSetUploadProgress(FileUploadRequestDTO param, String uploadDirPath) {
String fileName = param.getFile().getOriginalFilename();
File confFile = new File(uploadDirPath, fileName + ".conf");
byte isComplete = 0;
RandomAccessFile accessConfFile = null;
try {
accessConfFile = new RandomAccessFile(confFile, "rw");
//把该分段标记为 true 表示完成
System.out.println("set part " + param.getChunk() + " complete");
//创建conf文件文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认0,已上传的就是Byte.MAX_VALUE 127
accessConfFile.setLength(param.getChunks());
accessConfFile.seek(param.getChunk());
accessConfFile.write(Byte.MAX_VALUE);
//completeList 检查是否全部完成,如果数组里是否全部都是127(全部分片都成功上传)
byte[] completeList = FileUtils.readFileToByteArray(confFile);
isComplete = Byte.MAX_VALUE;
for (int i = 0; i < completeList.length && isComplete == Byte.MAX_VALUE; i++) {
//与运算, 如果有部分没有完成则 isComplete 不是 Byte.MAX_VALUE
isComplete = (byte) (isComplete & completeList[i]);
System.out.println("check part " + i + " complete?:" + completeList[i]);
}
} catch (IOException e) {
log.error(e.getMessage(), e);
} finally {
FileUtil.close(accessConfFile);
}
boolean isOk = setUploadProgress2Redis(param, uploadDirPath, fileName, confFile, isComplete);
return isOk;
}
/**
* 把上传进度信息存进redis
*/
private boolean setUploadProgress2Redis(FileUploadRequestDTO param, String uploadDirPath,
String fileName, File confFile, byte isComplete) {
RedisUtil redisUtil = SpringContextHolder.getBean(RedisUtil.class);
if (isComplete == Byte.MAX_VALUE) {
redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "true");
redisUtil.del(FileConstant.FILE_MD5_KEY + param.getMd5());
confFile.delete();
return true;
} else {
if (!redisUtil.hHasKey(FileConstant.FILE_UPLOAD_STATUS, param.getMd5())) {
redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "false");
redisUtil.set(FileConstant.FILE_MD5_KEY + param.getMd5(),
uploadDirPath + FileConstant.FILE_SEPARATORCHAR + fileName + ".conf");
}
return false;
}
}
/**
* 保存文件操作
*/
public FileUploadDTO saveAndFileUploadDTO(String fileName, File tmpFile) {
FileUploadDTO fileUploadDTO = null;
try {
fileUploadDTO = renameFile(tmpFile, fileName);
if (fileUploadDTO.isUploadComplete()) {
System.out
.println("upload complete !!" + fileUploadDTO.isUploadComplete() + " name=" + fileName);
//TODO 保存文件信息到数据库
}
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
}
return fileUploadDTO;
}
/**
* 文件重命名
*
* @param toBeRenamed 将要修改名字的文件
* @param toFileNewName 新的名字
*/
private FileUploadDTO renameFile(File toBeRenamed, String toFileNewName) {
//检查要重命名的文件是否存在,是否是文件
FileUploadDTO fileUploadDTO = new FileUploadDTO();
if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {
log.info("File does not exist: {}", toBeRenamed.getName());
fileUploadDTO.setUploadComplete(false);
return fileUploadDTO;
}
String ext = FileUtil.getExtension(toFileNewName);
String p = toBeRenamed.getParent();
String filePath = p + FileConstant.FILE_SEPARATORCHAR + toFileNewName;
File newFile = new File(filePath);
//修改文件名
boolean uploadFlag = toBeRenamed.renameTo(newFile);
fileUploadDTO.setMtime(DateUtil.getCurrentTimeStamp());
fileUploadDTO.setUploadComplete(uploadFlag);
fileUploadDTO.setPath(filePath);
fileUploadDTO.setSize(newFile.length());
fileUploadDTO.setFileExt(ext);
fileUploadDTO.setFileId(toFileNewName);
return fileUploadDTO;
}
}
RandomAccessFile实现方式
@UploadMode(mode = UploadModeEnum.RANDOM_ACCESS)
@Slf4j
public class RandomAccessUploadStrategy extends SliceUploadTemplate {
@Autowired
private FilePathUtil filePathUtil;
@Value("${upload.chunkSize}")
private long defaultChunkSize;
@Override
public boolean upload(FileUploadRequestDTO param) {
RandomAccessFile accessTmpFile = null;
try {
String uploadDirPath = filePathUtil.getPath(param);
File tmpFile = super.createTmpFile(param);
accessTmpFile = new RandomAccessFile(tmpFile, "rw");
//这个必须与前端设定的值一致
long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024
: param.getChunkSize();
long offset = chunkSize * param.getChunk();
//定位到该分片的偏移量
accessTmpFile.seek(offset);
//写入该分片数据
accessTmpFile.write(param.getFile().getBytes());
boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath);
return isOk;
} catch (IOException e) {
log.error(e.getMessage(), e);
} finally {
FileUtil.close(accessTmpFile);
}
return false;
}
}
MappedByteBuffer实现方式
@UploadMode(mode = UploadModeEnum.MAPPED_BYTEBUFFER)
@Slf4j
public class MappedByteBufferUploadStrategy extends SliceUploadTemplate {
@Autowired
private FilePathUtil filePathUtil;
@Value("${upload.chunkSize}")
private long defaultChunkSize;
@Override
public boolean upload(FileUploadRequestDTO param) {
RandomAccessFile tempRaf = null;
FileChannel fileChannel = null;
MappedByteBuffer mappedByteBuffer = null;
try {
String uploadDirPath = filePathUtil.getPath(param);
File tmpFile = super.createTmpFile(param);
tempRaf = new RandomAccessFile(tmpFile, "rw");
fileChannel = tempRaf.getChannel();
long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024
: param.getChunkSize();
//写入该分片数据
long offset = chunkSize * param.getChunk();
byte[] fileData = param.getFile().getBytes();
mappedByteBuffer = fileChannel
.map(FileChannel.MapMode.READ_WRITE, offset, fileData.length);
mappedByteBuffer.put(fileData);
boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath);
return isOk;
} catch (IOException e) {
log.error(e.getMessage(), e);
} finally {
FileUtil.freedMappedByteBuffer(mappedByteBuffer);
FileUtil.close(fileChannel);
FileUtil.close(tempRaf);
}
return false;
}
}
四、Gzip 压缩超大 json 对象上传(加餐)
1、概述
1.1 业务背景
一个通过Json传值的接口需要传大量数据,例如一个广告接口,内部系统有一个广告保存接口,需要ADX那边将投放的广告数据进行保存供后续使用,其中一个字段存放了广告渲染的HTML代码,因此,对与请求数据那么大的接口我们肯定是需要作一个优化,否则太大的数据传输有以下几个弊端:
- 占用网络带宽,而有些云产品就是按照带宽来计费的,间接浪费了钱
- 传输数据大导致网络传输耗时
1.2 实现思路
请求广告保存接口时先将Json对象字符串进行GZIP压缩,那请求时传入的就是压缩后的数据,而GZIP的压缩效率是很高的,因此可以大大减小传输数据,而当数据到达广告保存接口前再将传来的数据进行解压缩,还原成JSON对象就完成了整个GZIP压缩数据的请求以及处理流程
- 对与需要占用而外的CPU计算资源来说,内部系统属于IO密集型应用,因此用一些CPU资源来换取更快的网络传输其实是很划算的
- 使用过滤器在请求数据到达Controller之前对数据进行解压缩处理后重新写回到Body中,避免影响Controller的逻辑,代码零侵入
- 而对于改造接口的同时是否会影响到原来的接口这一点可以通过 HttpHeader 的
Content-Encoding=gzip
属性来区分是否需要对请求数据进行解压缩
1.3 前置知识
- Http 请求结构以及Content-Encoding 属性
- gzip压缩方式
- Servlet Filter
- HttpServletRequestWrapper
- Spring Boot
- Java 输入输出流
1.4 实现流程
2、基础知识介绍
过滤器和拦截器的文章可以参考:
SpringBoot 过滤器、拦截器、监听器对比及使用场景!
SpringBoot的过滤器和拦截器和全局异常处理
Filter过滤器和Interceptor拦截器配置和生命周期
2.1 过滤器与拦截器介绍
-
Listener 监听
Listener 可以监听 web 服务器中某一个事件操作,并触发注册的回调函数。通俗的语言就是在 application,session,request 三个对象创建/消亡或者增删改属性时,自动执行代码的功能组件。
-
Servlet
Servlet 是一种运行服务器端的 java 应用程序,具有 独立于平台和协议的特性,并且可以动态的生成 web 页面,它工作在 客户端请求与服务器响应 的中间层。
-
过滤器 Filter
Filter对用户请求进行预处理,接着将请求交给 Servlet 进行处理并生成响应,最后 Filter 再对服务器响应进行后处理。Filter 是可以复用的代码片段,常用来转换 HTTP 请求、响应和头信息。Filter 不像 Servlet,它不能产生响应,而是只修改对某一资源的请求或者响应。
-
拦截器 Interceptor
类似面向切面编程(AOP)中的切面和通知,我们通过动态代理对一个 service() 方法添加通知进行功能增强。比如说在方法执行前进行初始化处理,在方法执行后进行后置处理。拦截器的思想和AOP 类似,区别就是拦截器只能对 Controller 的 HTTP 请求进行拦截
2.2 Filter 与 Interceptor 区别
- Filter 是基于函数回调的,而 Interceptor 则是基于 Java 反射 和 动态代理。
- Filter 依赖于 Servlet 容器,遵循Servlet规范,而 Interceptor 依赖于spring容器,遵循Spring规范。
- Filter 对几乎 所有的请求 起作用,但过滤器的控制比较粗,只能在请求进来时进行处理,对请求和响应进行包装,而 Interceptor 只对 Controller 对请求起作用,但更精细的控制,可以在controller对请求处理之前或之后被调用,也可以在渲染视图呈现给用户之后调用
2.3 执行顺序
- Filter 过滤请求处理
- Interceptor 拦截请求处理
- Aspect(切面) 拦截处理请求(这里不赘述)
- 对应的 HandlerAdapter 处理请求
- Aspect(切面) 拦截响应请求(这里不赘述)
- Interceptor 拦截响应处理
- Interceptor 的最终处理
- Filter 过滤响应处理
3、核心代码
3.1 配置controller
创建一个SpringBoot项目,先编写一个接口,功能很简单就是传入一个Json对象并返回,以模拟将广告数据保存到数据库
@Slf4j
@RestController
public class AdvertisingController {
@PostMapping("/save")
public Advertising saveProject(@RequestBody Advertising advertising) {
log.info("获取内容"+ advertising);
return advertising;
}
}
@Data
public class Advertising {
private String adName;
private String adTag;
}
3.2 压缩工具类
public class GZIPUtils {
// 这是我自己的测试,然后生成文件访问
public static void main(String[] args) throws Exception {
byte[] compress = compress("{\n" +
" \"adName\":\"1123\",\n" +
" \"adTag\":\"balabalbalalalallala\"\n" +
"}");
saveFile("test",compress);
}
public static final String GZIP_ENCODE_UTF_8 = "UTF-8";
/**
* 字符串压缩为GZIP字节数组
*
* @param str
* @return
*/
public static byte[] compress(String str) {
return compress(str, GZIP_ENCODE_UTF_8);
}
/**
* 字符串压缩为GZIP字节数组
*
* @param str
* @param encoding
* @return
*/
public static byte[] compress(String str, String encoding) {
if (str == null || str.length() == 0) {
return null;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
GZIPOutputStream gzip;
try {
gzip = new GZIPOutputStream(out);
gzip.write(str.getBytes(encoding));
gzip.close();
} catch (IOException e) {
e.printStackTrace();
}
return out.toByteArray();
}
/**
* GZIP解压缩
*
* @param bytes
* @return
*/
public static byte[] uncompress(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteArrayInputStream in = new ByteArrayInputStream(bytes);
try {
GZIPInputStream ungzip = new GZIPInputStream(in);
byte[] buffer = new byte[256];
int n;
while ((n = ungzip.read(buffer)) >= 0) {
out.write(buffer, 0, n);
}
} catch (IOException e) {
e.printStackTrace();
}
return out.toByteArray();
}
/**
* 解压并返回String
*
* @param bytes
* @return
*/
public static String uncompressToString(byte[] bytes) throws IOException {
return uncompressToString(bytes, GZIP_ENCODE_UTF_8);
}
/**
* @param bytes
* @return
*/
public static byte[] uncompressToByteArray(byte[] bytes) throws IOException {
return uncompressToByteArray(bytes, GZIP_ENCODE_UTF_8);
}
/**
* 解压成字符串
*
* @param bytes 压缩后的字节数组
* @param encoding 编码方式
* @return 解压后的字符串
*/
public static String uncompressToString(byte[] bytes, String encoding) throws IOException {
byte[] result = uncompressToByteArray(bytes, encoding);
return new String(result);
}
/**
* 解压成字节数组
*
* @param bytes
* @param encoding
* @return
*/
public static byte[] uncompressToByteArray(byte[] bytes, String encoding) throws IOException {
if (bytes == null || bytes.length == 0) {
return null;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteArrayInputStream in = new ByteArrayInputStream(bytes);
try {
GZIPInputStream ungzip = new GZIPInputStream(in);
byte[] buffer = new byte[256];
int n;
while ((n = ungzip.read(buffer)) >= 0) {
out.write(buffer, 0, n);
}
return out.toByteArray();
} catch (IOException e) {
e.printStackTrace();
throw new IOException("解压缩失败!");
}
}
/**
* 将字节流转换成文件
*
* @param filename
* @param data
* @throws Exception
*/
public static void saveFile(String filename, byte[] data) throws Exception {
if (data != null) {
String filepath = "/" + filename;
File file = new File(filepath);
if (file.exists()) {
file.delete();
}
FileOutputStream fos = new FileOutputStream(file);
fos.write(data, 0, data.length);
fos.flush();
fos.close();
System.out.println(file);
}
}
}
3.3 编写过滤器
首先编写一个自定义过滤器
@Slf4j
@Component
public class GZIPFilter implements Filter {
private static final String CONTENT_ENCODING = "Content-Encoding";
private static final String CONTENT_ENCODING_TYPE = "gzip";
@Override
public void init(FilterConfig filterConfig) {
log.info("init GZIPFilter");
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
long start = System.currentTimeMillis();
HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;
String encodeType = httpServletRequest.getHeader(CONTENT_ENCODING);
if (CONTENT_ENCODING_TYPE.equals(encodeType)) {
log.info("请求:{} 需要解压", httpServletRequest.getRequestURI());
UnZIPRequestWrapper unZIPRequestWrapper = new UnZIPRequestWrapper(httpServletRequest);
filterChain.doFilter(unZIPRequestWrapper,servletResponse);
}
else {
log.info("请求:{} 无需解压", httpServletRequest.getRequestURI());
filterChain.doFilter(servletRequest,servletResponse);
}
log.info("耗时:{}ms", System.currentTimeMillis() - start);
}
@Override
public void destroy() {
log.info("destroy GZIPFilter");
}
实现RequestWrapper实现解压和写回Body的逻辑
/**
* @Description: JsonString经过压缩后保存为二进制文件 -> 解压缩后还原成JsonString转换成byte[] 写回body中
*/
@Slf4j
public class UnZIPRequestWrapper extends HttpServletRequestWrapper {
private final byte[] bytes;
public UnZIPRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
try (BufferedInputStream bis = new BufferedInputStream(request.getInputStream());
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
final byte[] body;
byte[] buffer = new byte[1024];
int len;
while ((len = bis.read(buffer)) > 0) {
baos.write(buffer, 0, len);
}
body = baos.toByteArray();
if (body.length == 0) {
log.info("Body无内容,无需解压");
bytes = body;
return;
}
this.bytes = GZIPUtils.uncompressToByteArray(body);
} catch (IOException ex) {
log.info("解压缩步骤发生异常!");
ex.printStackTrace();
throw ex;
}
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
};
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
注册过滤器,也可以用注解@WebFilter(value = "/*",filterName ="AFilter" )
实现
@Configuration
public class FilterRegistration {
@Resource
private GZIPFilter gzipFilter;
@Bean
public FilterRegistrationBean<GZIPFilter> gzipFilterRegistrationBean() {
FilterRegistrationBean<GZIPFilter> registration = new FilterRegistrationBean<>();
//Filter可以new,也可以使用依赖注入Bean
registration.setFilter(gzipFilter);
//过滤器名称
registration.setName("gzipFilter");
//拦截路径
registration.addUrlPatterns("/*");
//设置顺序
registration.setOrder(1);
return registration;
}
}
4、测试
注意一个大坑:千万不要直接将压缩后的byte[]当作字符串进行传输,否则你会发现压缩后的请求数据竟然比没压缩后的要大得多🐶!一般有两种传输压缩后的byte[]的方式:
- 将压缩后的byet[]进行base64编码再传输字符串,这种方式会损失掉一部分GZIP的压缩效果,适用于压缩结果要存储在Redis中的情况
- 将压缩后的byte[]以二进制的形式写入到文件中,请求时直接在body中带上文件即可,用这种方式可以不损失压缩效果
测试的时候注意在请求头Headers里带上Content-Type=application/json
和Content-Encoding=gzip
,带上gzip就是就会进行解压,不带就正常