SpringBoot 实现大文件断点续传
最近在工作中有涉及到文件上传功能,需求方要求文件最大上限为1G,如果直接将文件在前端做上传,会出现超长时间等待,如果服务端内存不够,会直接内存溢出,此时我们可以通过断点续传方式解决,前端我们通过WebUploader实现文件分割和上传,语言是React,后端我们通过SpringBoot实现文件接收和组装功能,下面我列出前后端主要功能代码。
一、前端代码
由于WebUploader依赖Jquery,所以在最开始我们应该引入Jquery,我们使用的前端脚手架是Ant Design,所以我在 src/pages/document.ejs 文件中引入,代码如下:
<script type="text/javascript" src="<%= context.config.manifest.basePath + 'jquery-1.11.1.min.js'%>"></script>
然后我们在前端代码中注册3个事件,分别是 before-send-file、before-send、after-send-file,这三个钩子分别是在发送文件前(上传文件之前执行,触发一次)、发送请求前(上传文件分块之前执行,触发多次)、文件上传后(分块全部上传完成之后执行,触发一次)
在 @/pages/device/index.jsx 文件中断点续传核心代码如下:
<Form.Item
label="选择版本包"
name="file"
>
<div name="file">
<label
id="uploadWrapper"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
textAlign: 'center',
border: '1px dashed #E5E5E5',
cursor: inputDisabled ? 'default' : 'pointer',
}}
htmlFor={'softUpload'}
onDrop={(e) => {
document.getElementById('uploadWrapper').style.border = '1px dashed #E5E5E5';
document.getElementById('uploadOption').innerHTML = '点击或将文件拖拽到这里上传';
if (!inputDisabled) {
inputOnChange(e);
}
}}
onDragOver={(e) => {
e.preventDefault();
document.getElementById('uploadWrapper').style.border = '1px dashed #1890FF';
document.getElementById('uploadOption').innerHTML = '释放鼠标';
}}
>
<input
disabled={inputDisabled}
type="file"
title=""
id={'softUpload'}
multiple={false}
name="file"
style={{ opacity: 0 }}
onChange={(e) => inputOnChange(e)}
/>
<label
htmlFor={'softUpload'}
style={{ cursor: inputDisabled ? 'default' : 'pointer' }}
>
<p style={{ marginBottom: '10px' }}>
<span
style={{
display: 'block',
width: '102px',
height: '30px',
lineHeight: '30px',
margin: '0 auto',
color: '#1890FF',
backgroundColor: '#E7F3FF',
border: '1px solid #E7F3FF',
}}
>
<UploadOutlined /> 上传
</span>
</p>
<div>
<p id="uploadOption" className="ant-upload-text">
点击或将文件拖拽到这里上传
</p>
<p className="ant-upload-hint">
支持扩展名:
<span style={{ color: 'red' }}>
{'版本包大小不超过1GB'}
</span>
</p>
</div>
</label>
</label>
<div
style={{
maxWidth: '100%',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
lineHeight: 1,
padding: '3px 0',
marginTop: '13px',
}}
>
<span>{task && task.file && task.file.name}</span>
</div>
<div style={{ padding: '0 24px 8px 0', width: '100%' }}>
<Progress
showInfo
style={{ display: `${inputDisabled ? 'block' : 'none'}` }}
strokeColor={{
from: '#108ee9',
to: '#108ee9',
}}
percent={task.progress}
size="small"
/>
</div>
</div>
</Form.Item>
在 @/pages/device/index.jsx 文件中 file input 点击事件核心代码如下:
const inputOnChange = async (e) => {
e.preventDefault();
e.stopPropagation();
const files = e.target.files || e.dataTransfer.files;
if (files && files[0]) {
const isLt1G = files[0].size / 1024 / 1024 < 1024;
if (!isLt1G) {
message.error('版本包大小不能超过1GB!');
return;
}
addToTaskList(files[0]);
}
};
// 更新单个任务
const updateTask = (task, newProps) => {
const newTask = Object.assign({}, task, newProps);
setTask(newTask, function (data) {
uploadNext(data);
});
};
// 调用上传接口
const startUpload = (task) => {
//初始化状态
const uploader = new Uploader({
file: task.file,
onSuccess: (props) => {
updateTask(task, { progress: 100, status: 'success' });
setCode(props.code);
setMd5(props.md5);
setInputDisabled(false);
message.success(`${props.fileName} 文件上传成功`);
},
onError: ({ msg }) => {
setInputDisabled(false);
updateTask(task, { progress: 0, status: 'error' });
message.error(msg);
},
onProgress: ({ file, percentage }) => {
const progress = (parseInt(percentage.toFixed(4) * 10000, 10) - 1) / 100;
updateTask(task, { progress, status: 'uploading' });
},
});
updateTask(task, { progress: 0, status: 'uploading', uploader });
setInputDisabled(true);
uploader.start();
};
// 开始下一个上传任务
const uploadNext = (task) => {
// 单次仅允许一个上传任务
if (task.status === 'uploading') return;
if (task.status === 'init') {
startUpload(task);
}
};
// 添加任务
const addToTaskList = (file) => {
setTask({ id: new Date().getTime(), file, progress: 0, status: 'init' }, function (data) {
uploadNext(data);
});
};
在 @/utils/upload.js 文件中封装了断点续传核心代码,代码如下:
import request from '@/utils/request';
import WebUploader from '../../public/webuploader.min';
import { TP_TOKE, BPR_BASE_URL } from '@/utils/constant';
/**
*
* 断点续传纯逻辑组件
*
* 用法:
* ```
* uploader = new Uploader({
* file: targetFile,
* onSuccess: ({ fileName, resourceId, filePath }) => {
* },
* onError: ({ msg }) => {
* },
* onProgress: ({ data, percentage }) => {
* },
* });
*
* uploader.start();
* ```
* @class Uploader
*/
class Uploader {
constructor({ file, onSuccess, onError, onProgress }) {
// const files = e.target.files || e.dataTransfer.files;
// 转化为WebUploader的内部file对象
this.file = new WebUploader.File(new WebUploader.Lib.File(WebUploader.guid('rt_'), file));
this.onSuccess = props => {
this.clean();
if (onSuccess) onSuccess(props);
};
this.onError = props => {
this.clean();
if (onError) onError(props);
};
this.onProgress = onProgress;
this.uploader = null;
}
init = () => {
WebUploader.Uploader.register({
name: 'webUploaderHookCommand',
'before-send-file': 'beforeSendFile',
'before-send': 'beforeSend',
'after-send-file': 'afterSendFile',
}, {
beforeSendFile: file => {
const task = new WebUploader.Deferred();
this.fileName = file.name;
this.fileSize = file.size;
this.mimetype = file.type;
this.fileExt = file.ext;
(new WebUploader.Uploader())
.md5File(file, 0, 10 * 1024 * 1024 * 1024 * 1024).progress(percentage => { })
.then(val => {
this.fileMd5 = val;
const url = `${BPR_BASE_URL}/register`;
const data = {
fileMd5: this.fileMd5,
fileName: file.name,
fileSize: file.size,
mimetype: file.type,
fileExt: file.ext,
};
request(url, {
method: 'post',
data,
}).then(res => {
console.log('register', res);
if (res && res.status === 1) {
task.resolve();
} else if (res && res.data && res.code === 103404) {
// 文件已上传
this.onSuccess({
fileName: this.fileName,
resourceId: res.data.resId,
filePath: res.data.filePath,
});
task.reject();
} else {
file.statusText = res && res.message;
task.reject();
}
});
});
return task.promise();
},
beforeSend: block => {
console.log('beforeSend');
const task = new WebUploader.Deferred();
const url = `${BPR_BASE_URL}/checkChunk`;
const data = {
fileMd5: this.fileMd5,
chunk: block.chunk,
chunkSize: block.end - block.start,
};
request(url, {
method: 'post',
data,
}).then(res => {
console.log('checkChunk', res);
if (res && res.data === true) {
task.reject(); // 分片存在,则跳过上传
} else {
task.resolve();
}
});
this.uploader.options.formData.fileMd5 = this.fileMd5;
this.uploader.options.formData.chunk = block.chunk;
return task.promise();
},
afterSendFile: () => {
console.log('start afterSendFile');
const task = new WebUploader.Deferred();
const url = `${BPR_BASE_URL}/mergeChunks`;
const data = {
fileMd5: this.fileMd5,
fileName: this.fileName,
fileSize: this.fileSize,
mimetype: this.mimetype,
fileExt: this.fileExt,
};
request(url, {
method: 'post',
data,
}).then(res => {
console.log('mergeChunks', res);
if (res && res.status === 1 && res.data && res.data.resId) {
task.resolve();
this.onSuccess({
fileName: this.fileName,
resourceId: res.data.resId,
filePath: res.data.filePath,
});
} else {
task.reject();
this.onError({ msg: '合并文件失败' });
}
});
},
});
}
clean = () => {
if (this.uploader) {
WebUploader.Uploader.unRegister('webUploaderHookCommand');
}
}
start = () => {
if (!this.uploader) {
this.init();
}
// 实例化
this.uploader = WebUploader.create({
server: BPR_BASE_URL,
chunked: true,
chunkSize: 1024 * 1024 * 5,
chunkRetry: 1,
threads: 3,
duplicate: true,
formData: { // 上传分片的http请求中一同携带的数据
appid: '1',
token: localStorage.getItem(TP_TOKE),
methodname: 'breakpointRenewal',
},
});
// 一个分片上传成功后,调用该方法
this.uploader.on('uploadProgress', (data, percentage) => {
console.log('uploadProgress');
this.onProgress({ data, percentage });
});
this.uploader.on('error', err => {
this.onError({ msg: '上传出错,请重试' });
});
this.uploader.addFiles(this.file);
this.uploader.upload();
}
cancel = () => {
console.log('call cancel');
this.uploader.stop(true);
this.uploader.destroy();
console.log('getStats', this.uploader.getStats());
}
}
export default Uploader;
在 @/utils/constant 文件中定义了上述代码中所使用的常量,代码如下:
const constants = {
BPR_BASE_URL: '/v1.0/sys/admin/files/breakpointRenewal',
};
module.exports = constants;
到这里前端代码就写完了。
二、后端代码
1、maven依赖
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.11.0</version>
</dependency>
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
<scope>compile</scope>
</dependency>
2、ResponseEnum代码
/**
* @Description: 返回码常量类
* @Author: henry
* @Date: 2019/6/21
* code范围:
* 成功:200
* 公共:0001-0999
* PaaS
* FM 103400-103599
*/
public enum ResponseEnum {
/**
* 公共模块码
*/
RESPONSE_CODE_FAIL(100, "请求失败"),
RESPONSE_CODE_SUCCESS(200, "请求成功"),
RESPONSE_CODE_PARAM_ERR(400, "请求参数错误"),
RESPONSE_CODE_PARAM_VALUE_ERR(401, "参数值错误"),
RESPONSE_CODE_PARAM_EMPTY(402, "缺少参数"),
RESPONSE_CODE_NOT_FOUND(404, "找不到指定的资源"),
RESPONSE_CODE_METHOD_NOT_SUPPORT(405, "请求方法不支持"),
RESPONSE_CODE_TYPE_NOT_ACCEPTABLE(406, "请求类型不接受"),
RESPONSE_CODE_METHOD_NOT_EXIST(407, "请求方法不存在"),
RESPONSE_CODE_PARAM_NOT_NULL(430, "参数为空"),
RESPONSE_CODE_RECORD_ALREADY_EXISTS(431, "数据已存在"),
RESPONSE_CODE_RECORD_NOT_EXISTS(432, "数据不存在"),
RESPONSE_CODE_JSON_ERROR(433, "JSON格式不正确"),
RESPONSE_CODE_PARAM_LENGTH_TOO_MIN(434, "参数长度过短"),
RESPONSE_CODE_PARAM_LENGTH_TOO_MAX(435, "参数长度过长"),
RESPONSE_CODE_NOTLANK_PARAM_NOT_EXISTS(436, "必填参数不存在"),
RESPONSE_CODE_VERIFICATION_CODE_ERROR(442, "验证码无效"),
RESPONSE_CODE_SYSTEM_BUSY(459, "系统忙或访问超时,请稍候重试"),
RESPONSE_CODE_SYSTEM_ERROR(500, "系统错误,请稍后再试"),
RESPONSE_CODE_PERMISSION_DENIED(502, "没有该操作的权限"),
/**
* FM(103400-103599)
*/
RESPONSE_CODE_FILE_UPLOAD_ERROR(103400, "文件上传失败"),
RESPONSE_CODE_FILE_DOWNLOAD_ERROR(103401, "文件下载失败"),
RESPONSE_CODE_FILE_DELETE_ERROR(103402, "文件删除失败"),
RESPONSE_CODE_FILE_LIST_QUERY_ERROR(103403, "文件列表查询失败"),
RESPONSE_CODE_BREAKPOINT_RENEVAL_REGISTRATION_ERROR(103404, "断点叙传注册:注册文件已存在"),
RESPONSE_CODE_MERGE_FILE_ERROR(103405, "断点叙传合并:文件合并失败"),
RESPONSE_CODE_FILE_BLOCK_DOES_NOT_EXIST_ERROR(103406, "断点叙传合并:文件分块不存在"),
RESPONSE_CODE_VERIFY_FILE_ERROR(103407, "断点叙传校验:文件校验失败"),
RESPONSE_CODE_PICTURE_SUFFIX_ERROR(103408, "图片格式不正确");
@Getter
private int code;
@Getter
private String msg;
ResponseEnum(int code, String msg) {
this.code = code;
this.msg = msg;
}
}
3、ResponseResult代码
/**
* @Classname: com.openailab.oascloud.common.model.ResponseResult
* @Description: 统一返回结果
* @Author: zxzhang
* @Date: 2019/6/26
*/
@Component
public class ResponseResult implements Serializable {
private static final long serialVersionUID = 5836869421731990598L;
/**
* 状态描述
*/
@Getter
private String message;
/**
* 返回数据
*/
@Getter
private Object data;
/**
* 响应码
*/
@Getter
private int code;
/**
* 状态(0:失败、1:成功)
*/
@Getter
private int status;
/**
* 总条数
*/
@Getter
private Integer total;
public ResponseResult() {
}
public ResponseResult(int status, Object data) {
this.status = status;
this.data = data;
}
public ResponseResult(int status, String message, Object data) {
this.status = status;
this.message = message;
this.data = data;
}
public ResponseResult(String message, Object data, int code, int status) {
this.message = message;
this.data = data;
this.code = code;
this.status = status;
}
public ResponseResult(String message, Object data, int code, int status, Integer total) {
this.message = message;
this.data = data;
this.code = code;
this.status = status;
this.total = total;
}
public static ResponseResult fail(String msg) {
if (StringUtils.isEmpty(msg)) {
return new ResponseResult(ResponseEnum.RESPONSE_CODE_FAIL.getMsg(), null, ResponseEnum.RESPONSE_CODE_FAIL.getCode(), CommonConst.RESPONSE_FAIL);
} else {
return new ResponseResult(msg, null, ResponseEnum.RESPONSE_CODE_FAIL.getCode(), CommonConst.RESPONSE_FAIL);
}
}
public static ResponseResult fail(int code, String msg) {
return new ResponseResult(msg, null, code, CommonConst.RESPONSE_FAIL);
}
public static ResponseResult fail(ResponseEnum responseEnum, Object obj) {
return new ResponseResult(responseEnum.getMsg(), obj, responseEnum.getCode(), 0);
}
public static ResponseResult success(Object data) {
return new ResponseResult(ResponseEnum.RESPONSE_CODE_SUCCESS.getMsg(), data, ResponseEnum.RESPONSE_CODE_SUCCESS.getCode(), CommonConst.RESPONSE_SUCCESS);
}
public static ResponseResult success(Object data, int code, String message) {
return new ResponseResult(message, data, code, CommonConst.RESPONSE_SUCCESS);
}
public ResponseResult setMessage(String message) {
this.message = message;
return this;
}
public ResponseResult setData(Object data) {
this.data = data;
return this;
}
public ResponseResult setStatus(int status) {
this.status = status;
return this;
}
public ResponseResult setCode(int code) {
this.code = code;
return this;
}
public ResponseResult setTotal(Integer total) {
this.total = total;
return this;
}
}
4、FileController代码
/**
* @description: 文件管理-Controller
* @author: zhangzhixiang
* @createDate: 2019/12/9
* @version: 1.0
*/
@RestController
@RequestMapping("/v1.0/sys/admin/files")
public class FileController {
private static Logger LOG = LoggerFactory.getLogger(FileController.class);
@Autowired
private IFileService fileService;
@Autowired
private IUserService userService;
/**
* 断点叙传
*
* @param file
* @param fileMd5
* @param chunk
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2020/1/13
*/
@PostMapping(value = "/breakpointRenewal",
produces = {MediaType.APPLICATION_JSON_UTF8_VALUE},
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseResult breakpointRenewal(@RequestPart("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") Integer chunk) {
try {
return fileService.breakpointRenewal(file, fileMd5, chunk);
} catch (Exception e) {
LOG.error("********FileController->breakpointRenewal throw Exception.fileMd5:{},chunk:{}********", fileMd5, chunk, e);
}
return ResponseResult.fail(null);
}
/**
* 断点叙传注册
*
* @param fileMd5
* @param fileName
* @param fileSize
* @param mimetype
* @param fileExt
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2020/1/13
*/
@PostMapping(value = "/breakpointRenewal/register")
public ResponseResult breakpointRegister(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("fileSize") Long fileSize,
@RequestParam("mimetype") String mimetype,
@RequestParam("fileExt") String fileExt) {
try {
return fileService.breakpointRegister(fileMd5, fileName, fileSize, mimetype, fileExt);
} catch (Exception e) {
LOG.error("********FileController->breakpointRegister throw Exception.fileMd5:{},fileName:{}********", fileMd5, fileName, e);
}
return ResponseResult.fail(null);
}
/**
* 检查分块是否存在
*
* @param fileMd5
* @param chunk
* @param chunkSize
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2020/1/10
*/
@PostMapping(value = "/breakpointRenewal/checkChunk")
public ResponseResult checkChunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") Integer chunk,
@RequestParam("chunkSize") Integer chunkSize) {
try {
return fileService.checkChunk(fileMd5, chunk, chunkSize);
} catch (Exception e) {
LOG.error("********FileController->breakpointRenewal throw Exception.fileMd5:{},chunk:{}********", fileMd5, chunk, e);
}
return ResponseResult.fail(null);
}
/**
* 合并文件块
*
* @param fileMd5
* @param fileName
* @param fileSize
* @param mimetype
* @param fileExt
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2020/1/11
*/
@PostMapping(value = "/breakpointRenewal/mergeChunks")
public ResponseResult mergeChunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("fileSize") Long fileSize,
@RequestParam("mimetype") String mimetype,
@RequestParam("fileExt") String fileExt,
@RequestParam("token") String token) {
try {
LoginUserInfo user = userService.getLoginUser(token);
return fileService.mergeChunks(fileMd5, fileName, fileSize, mimetype, fileExt, user);
} catch (Exception e) {
LOG.error("********FileController->breakpointRenewal throw Exception.fileMd5:{},fileName:{}********", fileMd5, fileName, e);
}
return ResponseResult.fail(null);
}
}
5、IFileService代码
/**
* @description: 文件管理-Interface
* @author: zhangzhixiang
* @createDate: 2019/12/9
* @version: 1.0
*/
public interface IFileService {
/**
* 断点叙传注册
*
* @param fileMd5
* @param fileName
* @param fileSize
* @param mimetype
* @param fileExt
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2020/1/10
*/
ResponseResult breakpointRegister(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt);
/**
* 断点叙传
*
* @param file
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2019/12/9
*/
ResponseResult breakpointRenewal(MultipartFile file, String fileMd5, Integer chunk);
/**
* 检查分块是否存在
*
* @param fileMd5
* @param chunk
* @param chunkSize
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2020/1/10
*/
ResponseResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize);
/**
* 合并文件块
*
* @param fileMd5
* @param fileName
* @param fileSize
* @param mimetype
* @param fileExt
* @return com.openailab.oascloud.common.model.ResponseResult
* @author zxzhang
* @date 2020/1/11
*/
ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt, LoginUserInfo user);
}
6、FileServiceImpl代码
/**
* @description: 文件管理-service
* @author: zhangzhixiang
* @createDate: 2019/12/9
* @version: 1.0
*/
@Service
public class FileServiceImpl implements IFileService {
private final static Logger LOG = LoggerFactory.getLogger(FileServiceImpl.class);
private static final SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd");
@Autowired
private FileDao fileDao;
@Autowired
private BootstrapConfig bootstrapConfig;
@Autowired
private FileManagementHelper fileManagementHelper;
@Autowired
private PageObjUtils pageObjUtils;
@Autowired
private RedisDao redisDao;
private String getUploadPath() {
return bootstrapConfig.getFileRoot() + bootstrapConfig.getUploadDir() + "/";
}
private String getFileFolderPath(String fileMd5) {
return getUploadPath() + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/";
}
private String getFilePath(String fileMd5, String fileExt) {
return getFileFolderPath(fileMd5) + fileMd5 + "." + fileExt;
}
private String getFileRelativePath(String fileMd5, String fileExt) {
return bootstrapConfig.getUploadDir() + "/" + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "." + fileExt;
}
private String getChunkFileFolderPath(String fileMd5) {
return bootstrapConfig.getFileRoot() + bootstrapConfig.getBreakpointDir() + "/" + fileMd5 + "/";
}
@Override
public ResponseResult breakpointRegister(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
Map<String, String> ret = Maps.newHashMap();
// 检查文件是否存在于磁盘
String fileFolderPath = this.getFileFolderPath(fileMd5);
String filePath = this.getFilePath(fileMd5, fileExt);
File file = new File(filePath);
boolean exists = file.exists();
// 检查文件是否存在于PostgreSQL中 (文件唯一标识为 fileMd5)
ResourceBO resourceBO = new ResourceBO();
resourceBO.setFileMd5(fileMd5);
resourceBO.setIsDelete(0);
List<ResourceBO> resourceBOList = fileDao.selectResourceByCondition(resourceBO);
if (exists && resourceBOList.size() > 0) {
// 既存在于磁盘又存在于数据库说明该文件存在,直接返回resId、filePath
resourceBO = resourceBOList.get(0);
ret.put("filePath", resourceBO.getFilePath());
ret.put("resId", String.valueOf(resourceBO.getResourceId()));
return ResponseResult.fail(ResponseEnum.RESPONSE_CODE_BREAKPOINT_RENEVAL_REGISTRATION_ERROR, ret);
}
//若磁盘中存在,但数据库中不存在,则生成resource记录并存入redis中
if (resourceBOList.size() == 0) {
// 首次断点叙传的文件需要创建resource新记录并返回redId,并存入redis中
resourceBO.setType(fileManagementHelper.judgeDocumentType(fileExt));
resourceBO.setStatus(TranscodingStateEnum.UPLOAD_NOT_COMPLETED.getCode());
resourceBO.setFileSize(fileSize);
resourceBO.setFileMd5(fileMd5);
resourceBO.setFileName(fileName);
resourceBO.setCreateDate(new Date());
resourceBO.setIsDelete(0);
final Integer resourceId = fileDao.addResource(resourceBO);
resourceBO.setResourceId(resourceId);
redisDao.set(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5, JSONObject.toJSONString(resourceBO), RedisPrefixConst.EXPIRE);
}
//如果redis中不存在,但数据库中存在,则存入redis中
String breakpoint = redisDao.get(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5);
if (StringUtils.isEmpty(breakpoint) && resourceBOList.size() > 0) {
resourceBO = resourceBOList.get(0);
redisDao.set(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5, JSONObject.toJSONString(resourceBO), RedisPrefixConst.EXPIRE);
}
// 若文件不存在则检查文件所在目录是否存在
File fileFolder = new File(fileFolderPath);
if (!fileFolder.exists()) {
// 不存在创建该目录 (目录就是根据前端传来的MD5值创建的)
fileFolder.mkdirs();
}
return ResponseResult.success(null);
}
@Override
public ResponseResult breakpointRenewal(MultipartFile file, String fileMd5, Integer chunk) {
Map<String, String> ret = Maps.newHashMap();
// 检查分块目录是否存在
String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
File chunkFileFolder = new File(chunkFileFolderPath);
if (!chunkFileFolder.exists()) {
chunkFileFolder.mkdirs();
}
// 上传文件输入流
File chunkFile = new File(chunkFileFolderPath + chunk);
try (InputStream inputStream = file.getInputStream(); FileOutputStream outputStream = new FileOutputStream(chunkFile)) {
IOUtils.copy(inputStream, outputStream);
// redis中查找是否有fileMd5的分块记录(resId)
String breakpoint = redisDao.get(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5);
ResourceBO resourceBO = new ResourceBO();
if (!StringUtils.isEmpty(breakpoint)) {
// 存在分块记录说明资源正在上传中,直接返回fileMd5对应的resId,且不再重复创建resource记录
resourceBO = JSONObject.parseObject(breakpoint, ResourceBO.class);
ret.put("resId", String.valueOf(resourceBO.getResourceId()));
}
} catch (IOException e) {
e.printStackTrace();
}
return ResponseResult.success(ret);
}
@Override
public ResponseResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize) {
// 检查分块文件是否存在
String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
// 分块所在路径+分块的索引可定位具体分块
File chunkFile = new File(chunkFileFolderPath + chunk);
if (chunkFile.exists() && chunkFile.length() == chunkSize) {
return ResponseResult.success(true);
}
return ResponseResult.success(false);
}
@Override
public ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt, LoginUserInfo user) {
FileClient fileClient = ClientFactory.createClientByType(bootstrapConfig.getFileClientType());
String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
File chunkFileFolder = new File(chunkFileFolderPath);
File[] files = chunkFileFolder.listFiles();
final String filePath = this.getFilePath(fileMd5, fileExt);
File mergeFile = new File(filePath);
List<File> fileList = Arrays.asList(files);
// 1. 合并分块
mergeFile = this.mergeFile(fileList, mergeFile);
if (mergeFile == null) {
return ResponseResult.fail(ResponseEnum.RESPONSE_CODE_MERGE_FILE_ERROR, null);
}
// 2、校验文件MD5是否与前端传入一致
boolean checkResult = this.checkFileMd5(mergeFile, fileMd5);
if (!checkResult) {
return ResponseResult.fail(ResponseEnum.RESPONSE_CODE_VERIFY_FILE_ERROR, null);
}
// 3、删除该文件所有分块
FileUtil.deleteDir(chunkFileFolderPath);
// 4、在redis中获取文件分块记录
String breakpoint = redisDao.get(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5);
if (StringUtils.isEmpty(breakpoint)) {
return ResponseResult.fail("文件分块不存在");
}
ResourceBO resourceBO = JSONObject.parseObject(breakpoint, ResourceBO.class);
// 5、删除redis分块记录
redisDao.del(RedisPrefixConst.BREAKPOINT_PREFIX + fileMd5);
// 6、组装返回结果
ret.put("filePath", getFileRelativePath(fileMd5, fileExt));
ret.put("resId", String.valueOf(resourceBO.getResourceId()));
return ResponseResult.success(ret);
}
/**
* 合并文件
*
* @param chunkFileList
* @param mergeFile
* @return java.io.File
* @author zxzhang
* @date 2020/1/11
*/
private File mergeFile(List<File> chunkFileList, File mergeFile) {
try {
// 有删 无创建
if (mergeFile.exists()) {
mergeFile.delete();
} else {
mergeFile.createNewFile();
}
// 排序
Collections.sort(chunkFileList, (o1, o2) -> {
if (Integer.parseInt(o1.getName()) > Integer.parseInt(o2.getName())) {
return 1;
}
return -1;
});
byte[] b = new byte[1024];
RandomAccessFile writeFile = new RandomAccessFile(mergeFile, "rw");
for (File chunkFile : chunkFileList) {
RandomAccessFile readFile = new RandomAccessFile(chunkFile, "r");
int len = -1;
while ((len = readFile.read(b)) != -1) {
writeFile.write(b, 0, len);
}
readFile.close();
}
writeFile.close();
return mergeFile;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
/**
* 校验文件MD5
*
* @param mergeFile
* @param md5
* @return boolean
* @author zxzhang
* @date 2020/1/11
*/
private boolean checkFileMd5(File mergeFile, String md5) {
try {
// 得到文件MD5
FileInputStream inputStream = new FileInputStream(mergeFile);
String md5Hex = DigestUtils.md5Hex(inputStream);
if (StringUtils.equalsIgnoreCase(md5, md5Hex)) {
return true;
}
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
/**
* 获取文件后缀
*
* @param fileName
* @return java.lang.String
* @author zxzhang
* @date 2019/12/10
*/
public String getExt(String fileName) {
return fileName.substring(fileName.lastIndexOf(".") + 1);
}
/**
* 获取文件所在目录
*
* @param filePath
* @return java.lang.String
* @author zxzhang
* @date 2019/12/10
*/
public String getFileDir(String filePath) {
return filePath.substring(0, filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR));
}
/**
* 获取文件名
*
* @param filePath
* @return java.lang.String
* @author zxzhang
* @date 2019/12/10
*/
public String getFileName(String filePath) {
return filePath.substring(filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR) + 1, filePath.lastIndexOf("."));
}
}
7、FileUtil代码
/**
* @description:
* @author: zhangzhixiang
* @createDate: 2020/1/7
* @version: 1.0
*/
public class FileUtil {
private static final Logger LOG = LoggerFactory.getLogger(FileUtil.class);
/**
* 清空文件夹下所有文件
*
* @param path
* @return boolean
* @author zxzhang
* @date 2020/1/7
*/
public static boolean deleteDir(String path) {
File file = new File(path);
if (!file.exists()) {//判断是否待删除目录是否存在
return false;
}
String[] content = file.list();//取得当前目录下所有文件和文件夹
for (String name : content) {
File temp = new File(path, name);
if (temp.isDirectory()) {//判断是否是目录
deleteDir(temp.getAbsolutePath());//递归调用,删除目录里的内容
temp.delete();//删除空目录
} else {
if (!temp.delete()) {//直接删除文件
LOG.error("********文件删除失败,file:{}********" + name);
}
}
}
return true;
}
/**
* 复制单个文件
*
* @param oldPath String 原文件路径 如:c:/fqf
* @param newPath String 复制后路径 如:f:/fqf/ff
* @return boolean
*/
public static void copyFile(String oldPath, String newPath) {
try {
int bytesum = 0;
int byteread = 0;
File oldfile = new File(oldPath);
if (oldfile.exists()) { //文件存在时
InputStream inStream = new FileInputStream(oldPath); //读入原文件
//newFilePath文件夹不存在则创建
File newParentFile = new File(newPath).getParentFile();
if (!newParentFile.exists()) {
newParentFile.mkdirs();
}
FileOutputStream fs = new FileOutputStream(newPath);
byte[] buffer = new byte[1444];
int length;
while ((byteread = inStream.read(buffer)) != -1) {
bytesum += byteread; //字节数 文件大小
System.out.println(bytesum);
fs.write(buffer, 0, byteread);
}
inStream.close();
}
} catch (Exception e) {
LOG.error("********复制单个文件操作出错********");
e.printStackTrace();
}
}
/**
* 复制整个文件夹内容
*
* @param oldPath String 原文件路径 如:c:/fqf
* @param newPath String 复制后路径 如:f:/fqf/ff
* @return boolean
*/
public static void copyFolder(String oldPath, String newPath) {
try {
//newFilePath文件夹不存在则创建
File newParentFile = new File(newPath).getParentFile();
if (!newParentFile.exists()) {
newParentFile.mkdirs();
}
File a = new File(oldPath);
String[] file = a.list();
File temp = null;
for (int i = 0; i < file.length; i++) {
if (oldPath.endsWith(File.separator)) {
temp = new File(oldPath + file[i]);
} else {
temp = new File(oldPath + File.separator + file[i]);
}
if (temp.isFile()) {
FileInputStream input = new FileInputStream(temp);
FileOutputStream output = new FileOutputStream(newPath + "/" +
(temp.getName()).toString());
byte[] b = new byte[1024 * 5];
int len;
while ((len = input.read(b)) != -1) {
output.write(b, 0, len);
}
output.flush();
output.close();
input.close();
}
if (temp.isDirectory()) {//如果是子文件夹
copyFolder(oldPath + "/" + file[i], newPath + "/" + file[i]);
}
}
} catch (Exception e) {
LOG.error("********复制整个文件夹内容操作出错********");
e.printStackTrace();
}
}
/**
* 获取一个文件的md5值(可处理大文件)
*
* @param file
* @return java.lang.String
* @author zxzhang
* @date 2020/3/23
*/
public static String getMD5(MultipartFile file) {
InputStream fileInputStream = null;
try {
MessageDigest MD5 = MessageDigest.getInstance("MD5");
fileInputStream = file.getInputStream();
byte[] buffer = new byte[8192];
int length;
while ((length = fileInputStream.read(buffer)) != -1) {
MD5.update(buffer, 0, length);
}
return new String(Hex.encodeHex(MD5.digest()));
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
try {
if (fileInputStream != null) {
fileInputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 获取一个文件的md5值(可处理大文件)
*
* @param file
* @return java.lang.String
* @author zxzhang
* @date 2020/3/23
*/
public static String getMD5(File file) {
try(FileInputStream fileInputStream = new FileInputStream(file)) {
MessageDigest MD5 = MessageDigest.getInstance("MD5");
byte[] buffer = new byte[8192];
int length;
while ((length = fileInputStream.read(buffer)) != -1) {
MD5.update(buffer, 0, length);
}
return new String(Hex.encodeHex(MD5.digest()));
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 求一个字符串的md5值
*
* @param target
* @return java.lang.String
* @author zxzhang
* @date 2020/3/23
*/
public static String MD5(String target) {
return DigestUtils.md5Hex(target);
}
}
8、FileManagementHelper代码
/**
* @description:
* @author: zhangzhixiang
* @createDate: 2019/12/11
* @version: 1.0
*/
@Component
public class FileManagementHelper {
private static final Logger LOG = LoggerFactory.getLogger(FileManagementHelper.class);
@Autowired
private BootstrapConfig bootstrapConfig;
/**
* 根据文件后缀判断类型
*
* @param ext
* @return java.lang.Integer
* @author zxzhang
* @date 2019/12/10
*/
public Integer judgeDocumentType(String ext) {
//视频类
if (VedioEnum.containKey(ext) != null) {
return ResourceTypeEnum.VIDEO.getCode();
}
//图片类
if (ImageEnum.containKey(ext) != null) {
return ResourceTypeEnum.IMAGE.getCode();
}
//文档类
if (DocumentEnum.containKey(ext) != null) {
return ResourceTypeEnum.FILE.getCode();
}
//未知
return ResourceTypeEnum.OTHER.getCode();
}
/**
* 生成随机文件名称
*
* @param ext
* @return java.lang.String
* @author zxzhang
* @date 2019/12/10
*/
public static String createFileName(String ext) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddHHmmss");
return simpleDateFormat.format(new Date()) + (int) (Math.random() * 900 + 100) + ext;
}
/**
* 获取文件后缀
*
* @param fileName
* @return java.lang.String
* @author zxzhang
* @date 2019/12/10
*/
public String getExt(String fileName) {
return fileName.substring(fileName.lastIndexOf(".") + 1);
}
/**
* 获取文件所在目录
*
* @param filePath
* @return java.lang.String
* @author zxzhang
* @date 2019/12/10
*/
public String getFileDir(String filePath) {
return filePath.substring(0, filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR));
}
/**
* 获取文件名
*
* @param filePath
* @return java.lang.String
* @author zxzhang
* @date 2019/12/10
*/
public String getFileName(String filePath) {
return filePath.substring(filePath.lastIndexOf(BootstrapConst.PATH_SEPARATOR) + 1, filePath.lastIndexOf("."));
}
}
9、PageObjUtils代码
/**
* @Classname: com.openailab.oascloud.um.util.PageObjUtils
* @Description: 分页对象工具类
* @Author: ChenLiang
* @Date: 2019/7/17
*/
@Component
public class PageObjUtils<T> {
public PageVO getPageList(PageInfo<T> personPageInfo) {
PageVO result = new PageVO();
if (personPageInfo != null) {
if (!personPageInfo.getList().isEmpty()) {
result.setPageNo(personPageInfo.getPageNum());
result.setPageSize(personPageInfo.getPageSize());
result.setTotal(Integer.valueOf(String.valueOf(personPageInfo.getTotal())));
result.setItems(personPageInfo.getList());
}
}
return result;
}
}
10、RedisDao代码
/**
* @Classname: com.openailab.oascloud.datacenter.api.IRedisApi
* @Description: Redis API
* @Author: zxzhang
* @Date: 2019/7/1
*/
@FeignClient(ServiceNameConst.OPENAILAB_DATA_CENTER_SERVICE)
public interface RedisDao {
/**
* @api {POST} /redis/set 普通缓存放入并设置过期时间
* @apiGroup Redis
* @apiVersion 0.1.0
* @apiParam {String} key 键
* @apiParam {String} value 值
* @apiParam {long} expire 过期时间
*/
@PostMapping("/redis/set")
ResponseResult set(@RequestParam("key") String key, @RequestParam("value") String value, @RequestParam("expire") long expire);
/**
* @api {POST} /redis/get 普通缓存获取
* @apiGroup Redis
* @apiVersion 0.1.0
* @apiParam {String} key 键
* @apiSuccess {String} value 值
*/
@PostMapping("/redis/get")
String get(@RequestParam("key") String key);
/**
* @api {POST} /redis/del 普通缓存删除
* @apiGroup Redis
* @apiVersion 0.1.0
* @apiParam {String} key 键
*/
@PostMapping("/redis/del")
ResponseResult del(@RequestParam("key") String key);
/**
* @api {POST} /redis/hset 存入Hash值并设置过期时间
* @apiGroup Redis
* @apiVersion 0.1.0
* @apiParam {String} key 键
* @apiParam {String} item 项
* @apiParam {String} value 值
* @apiParam {long} expire 过期时间
*/
@PostMapping("/redis/hset")
ResponseResult hset(@RequestParam("key") String key, @RequestParam("item") String item, @RequestParam("value") String value, @RequestParam("expire") long expire);
/**
* @api {POST} /redis/hget 获取Hash值
* @apiGroup Redis
* @apiVersion 0.1.0
* @apiParam {String} key 键
* @apiParam {String} item 项
* @apiSuccess {String} value 值
* @apiSuccessExample {json} 成功示例
* {"name":"张三","age":30}
*/
@PostMapping("/redis/hget")
Object hget(@RequestParam("key") String key, @RequestParam("item") String item);
/**
* @api {POST} /redis/hdel 删除Hash值SaasAppKeyDao
* @apiGroup Redis
* @apiVersion 0.1.0
* @apiParam {String} key 键
* @apiParam {String} item 项
*/
@PostMapping("/redis/hdel")
ResponseResult hdel(@RequestParam("key") String key, @RequestParam("item") String item);
}
11、BootstrapConfig代码
/**
* @Classname: com.openailab.oascloud.security.common.config.BootstrapConsts
* @Description: 引导类
* @Author: zxzhang
* @Date: 2019/10/8
*/
@Data
@Configuration
public class BootstrapConfig {
@Value("${file.client.type}")
private String fileClientType;
@Value("${file.root}")
private String fileRoot;
@Value("${file.biz.file.upload}")
private String uploadDir;
@Value("${file.biz.file.download}")
private String downloadDir;
@Value("${file.biz.file.backup}")
private String backupDir;
@Value("${file.biz.file.tmp}")
private String tmpDir;
@Value("${file.biz.file.breakpoint}")
private String breakpointDir;
}
12、application.properties
eureka.instance.instance-id=${spring.application.name}:${server.port}
eureka.instance.prefer-ip-address=true
eureka.client.serviceUrl.defaultZone=http://127.0.0.1:32001/eureka/
server.port=32018
spring.application.name=openailab-file-management
#file
file.client.type = ceph
file.root = /usr/local/oas/file
file.biz.file.upload = /upload
file.biz.file.download = /download
file.biz.file.backup = /backup
file.biz.file.tmp = /tmp
file.biz.file.breakpoint = /breakpoint
#ribbon
ribbon.ReadTimeout=600000
ribbon.ConnectTimeout=600000
#base
info.description=文件管理服务
info.developer=andywebjava@163.com
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=5120MB
spring.servlet.multipart.max-request-size=5120MB
13、表结构
字段名 注释 类型 长度 是否必填 是否主键
id 主键ID,sequence(course_resource_id_seq) int 32 是 是
type 资源类型,1:视频;2:文档;3:图片 int 2 是 否
fileName 文件名称 varchar 100 是 否
fileSize 文件大小 int 64 是 否
filePath 文件路径 varchar 200 否 否
status 0:无需转码 1:转码中 2:已转码 3:未上传完成 4:已上传完成 -1:转码失败 int 2 是 否
createDate 创建时间 timestamp 0 是 否
createUser 创建用户 varchar 50 是 否
isDelete 是否删除:0未删除,1已删除 int 2 是 否
userId 用户ID int 32 是 否
fileMd5 文件唯一标识(webupload文件md5唯一标识) varchar 100 是 否
文件断点续传到此就介绍完了
参考文章:http://blog.ncmem.com/wordpress/2023/10/25/springboot-%e5%ae%9e%e7%8e%b0%e5%a4%a7%e6%96%87%e4%bb%b6%e6%96%ad%e7%82%b9%e7%bb%ad%e4%bc%a0/
欢迎入群一起讨论