一、背景
平时在移动和客户端有普通的文件上传,但这种文件大多不大,从几k到几十兆,平时完全可以满足。但是对于有些终端系统(pc端、移动端),有时候存在文件过大,如拍摄的高清视频,导出上传不了(内存过小或响应时间过长)的问题,用户体验及不佳。这里上传不了的原因是前端也是需要将文件加载到内存后再上传。但文件过大,内存过小时问题就麻烦了。针对这种情景,特提供文件分片上传的功能。不仅可以提高上传速率,而且对普通和大文件都适用。并且对于文件实现断点续传、秒传的功能。
二、解决思路
首先,前端制定分片的规则。比如对于手机移动端,当上传文件大于10M时,采用分片的方式上传,并切割一片就上传,不用等待每片响应的结果。
对于前端,当前端上传文件时,前端边加载的时候边分割文件,每分割一片就上传。如前端加载完5M就直接上传,完成上传动作后释放上传的那块内存。防止占用内存的问题, 减少了内存占用。而分片可以采用线程、异步的方式,缩短了上传的时间,也解决了用户等待时间过长的问题。
对于后端,每次上传的文件,获取文件的md5值,保存md5值至数据库中。对于完整的文件md5值,作为文件信息存储;对于分片文件的md5值,保存在分片信息中。当上传一个文件时,首先是根据完整的md5值查找是否有上传的记录,有则说明上传的文件有上传的记录,若成功过直接返回url(文件秒传);没有成功过,但有上传记录,则有可能之前上传过部分,则需要继续上传未上传的文件(断电续传);没有则按照完整的流程上传。上传完成后,合并分片文件,更新并保存信息。
但是在开发的过长中,遇到几个问题:
①:对于文件md5值,前端如何获取到?因为文件md5值是通过文件中的内容确定的,每个不同的文件md5值是不一样的,而文件本身不可能加载全量文件再获取的。
②:如何判断文件是否全部上传完,并是否可以进行合并了?
③:上传的某片文件若出错了,怎么让该片文件重新上传?
④:合并文件时,如何保证合并的顺序?
针对上述问题,在开发的过程都一一解决了。对于
问题①:经过斟酌,做了一些取舍,舍弃了文件秒传的精确度。采用文件的属性(如文件名、类型、大小等) 加第一个分片的内容作为确定md5值;
问题②:在后端的表结构中,会记录这个文件以及这个分片文件的状态,前端也会告诉后端分了多少个文件。当上传一个分片时,会更新分片文件的状态,同时分片文件上传的数量会+1;当文件的状态已经成功并且上传成功的数量和需要上传的数量相同时就可以进行合并了。
问题③:在生成md5值后且在上传前,通过md5值去调用另外一个接口,获取上传信息,检测是否上传过。
问题④:每个上传的分片文件名和第几个分片都会记录下来,合并文件的时候按照这个顺序进行合并。
三、功能实现
①实现前端并发、异步调用后端接口上传;
②实现秒传、断点续传功能;
③支持失败分片重新上传;
④上传过程中,可以查询上传状态、进度
⑤上传权限校验,通过具有时效的token上传。
1.数据库的设计
主要创建三个表:文件分片上传信息表(t_file_fragment)、文件上传分片明细表(t_file_fragment_detail)、文件信息表(t_file_upload_info);各表直接的关联通过t_file_upload_info中的id进行关联
建表sql语句:
create table t_file_upload_info(
`id` int(11) NOT NULL AUTO_INCREMENT,
file_md5 varchar(100) not null comment '文件MD5',
`file_url` varchar(400) DEFAULT NULL COMMENT '文件存放url路径',
`file_name` varchar(100) NOT NULL COMMENT '文件名称',
file_type varchar(64) not null comment '文件类型',
`file_size` float DEFAULT NULL COMMENT '文件大小',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
create_id int(11) comment '创建人id',
PRIMARY KEY (`id`),
index idx_file_name(file_name)
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文件信息表';
create table t_file_fragment(
`id` int(11) NOT NULL AUTO_INCREMENT,
`file_info_id` int(11) DEFAULT NULL comment '外键:文件信息表主键',
`file_frag_name` varchar(100) NOT NULL COMMENT '分片文件名称',
`file_up_success_num` int(6) default 0 comment '上传成功个数',
`file_up_sum` int(6) DEFAULT NULL COMMENT '上传总的个数',
`up_status` int(2) DEFAULT 0 COMMENT '上传状态:0:未完成;1:已完成;2:已存在;3:上传出错;',
`last_operator_id` int(11) DEFAULT NULL COMMENT '上传者id',
`last_operator_name` varchar(24) DEFAULT NULL COMMENT '上传者名称',
`create_time` datetime DEFAULT NULL COMMENT '上传开始时间',
`end_time` datetime DEFAULT NULL COMMENT '上传结束时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
index idx_up_status(up_status)
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文件分片上传信息表';
create table t_file_fragment_detail(
`id` int(11) NOT NULL AUTO_INCREMENT,
`file_fragment_id` int(11) DEFAULT NULL comment '外键:文件分片上传信息id',
fragment_md5 varchar(100) not null comment '分片文件MD5',
`fragment_num` int(6) NOT NULL COMMENT '分片号',
`fragment_size` int(11) DEFAULT 0 COMMENT '分片大小',
`up_status` int(2) DEFAULT 0 COMMENT '上传状态:0:未完成;1:已完成;3:上传出错;',
`create_time` datetime DEFAULT NULL COMMENT '上传时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
index idx_up_status(up_status)
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文件上传分片明细表';
2.时序图
3.流程图
3.代码实现
总共分为三个接口:
①前端传入文件相关的参数 生成token;
②获取上传信息,检测是否上传过的接口;
③文件(碎片)上传接口
注:仅贴出部分demo代码
引入pom文件,主要为以下几个:
工具类,包括jwt加密工具、文件工具类
public class JwtUtils {
protected static final Logger logger = Logger.getLogger(JwtUtils.class.getName());
private static final String SECRET = "secLang";//你自己定的字符串 别让别人知道,加密时候用 是对称的秘钥 盐
public static final String FUNCTS = "FUNCTS";//获取用户的功能使用的key
public static final String USERINFO = "USER";//获取用户使用的key
private static final long EXPIRATION = 1800L;// token的生命周期30分
/**
* 创建token令牌 以下为参数都是自定义信息
* @param mark 一般放的唯一标识
* @param functs 当前用户的功能集合
* @param entity 实体类对象(如 当前用户 Users user)
* @return
*/
public static String createToken(String mark, List
Map
//当前用户拥有的功能
map.put(FUNCTS, JsonUtils.tojson(functs));
//当前用户信息
map.put(USERINFO, entity);
//
return Jwts.builder()
//主题 主角是谁? 赋值登录名
.setSubject(mark)
.setClaims(map)
//设置发布时间,也是生成时间
.setIssuedAt(new Date())
//设置过期时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
//设置HS256加密,并且把你的盐 放里,这里推荐使用SH256证书加密
.signWith(SignatureAlgorithm.HS256, SECRET)
//创建完成
.compact();
}
/**
* token是否过期
* @param token
* @return 过期返回true 否则为false
*/
public static boolean isExpiration(String token) {
try {
return getTokenBody(token).getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
// 获取主角,登录名
public static String getMark(String token) {
return getTokenBody(token).getSubject();
}
// 获取token中存储的功能
public static List
String str = getTokenBody(token).get(FUNCTS).toString();
List
return list;
}
// 获取token存储的用户
public static Object getEntity(String token) {
return getTokenBody(token).get(USERINFO);
}
// 公共获取自定义数据
public static Claims getTokenBody(String token) {
return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
}
// 刷新token
public static String refreshToken(String token) {
if (isExpiration(token)) {
logger.info("token刷新失败!! 过期了!!");
return null;
}
// 获取实体 权限信息
String functs = getTokenBody(token).get(FUNCTS).toString();
String entityStr = getTokenBody(token).get(USERINFO).toString();
String mark = getTokenBody(token).getSubject();
Map
map.put(FUNCTS, functs);
map.put(USERINFO, entityStr);
token = Jwts.builder().signWith(SignatureAlgorithm.HS256, SECRET).setClaims(map).setSubject(mark)
.setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
.compact();
return token;
}
}
(1)controller层
@Controller
@RequestMapping(value = "/bigfile")
public class BigFileController {
private final static Logger logger = LoggerFactory.getLogger(BigFileController.class);
@Autowired
private BigFileService bigFileSerivce;
/**
* 获取token,并将token保存在缓存中
* @param requestObj
* @return
*/
@GetMapping(value = "/gettoken")
public Object getUploadFileToken(FileUpInfoRequest requestObj) {
BaseResonse response = new BaseResonse();
try {
//保存文件信息 并获取token
String token = bigFileSerivce.getToken(requestObj);
JSONObject obj = new JSONObject();
obj.put("token", token);
response.setData(obj);
} catch (Exception e) {
logger.error("Exception: " + e.getMessage());
// response.setError(MainErrorType.BUSINESS_LOGIC_ERROR, Constants.EXCEPTION_DEFAULT);
}
return response;
}
/**
* 检验整个是否上传过以及上传分片列表 Authentication
* @param request
* @return
*/
@PostMapping(value = "/checkAndListFragmentDetail")
public Object checkAndListFragmentDetail(@RequestBody FileUpInfoRequest request) {
BaseResonse response = new BaseResonse();
Map
try {
resultMap = bigFileSerivce.checkAndListFragmentDetail(request);
response.setData(resultMap);
} catch (ServiceException e) {
e.printStackTrace();
response.setError(e.getCode(), e.getMessage());
}
return response;
}
/**
* 文件(碎片)上传
* @param request
* @return
*/
@PostMapping(value = "/uploadFile")
public Object uploadFile(@RequestBody FileUpInfoRequest request, @Param("file") MultipartFile file) {
BaseResonse response = new BaseResonse();
try {
//校验参数
checkValidateParams(request);
// byte[] content = file.getBytes();
UploadFileResponse uploadFileRes = bigFileSerivce.uploadFile(request, file);
response.setData(uploadFileRes);
} catch (ServiceException e) {
e.printStackTrace();
response.setError(e.getCode(), e.getMessage());
} catch (IOException e) {
e.printStackTrace();
}
return response;
}
/**
* 检查参数正确性
*
* @param request
* @throws ServiceException
*/
private void checkValidateParams(FileUpInfoRequest request) throws ServiceException {
if (StringUtils.isEmpty(request.getToken())) {
throw new ServiceException(FileConstant.PARAMETER_EMPTY_CODE, "token " + FileConstant.PARAMETER_EMPTY_MSG);
}
if (StringUtils.isEmpty(request.getFileName())) {
throw new ServiceException(FileConstant.PARAMETER_EMPTY_CODE, "文件名称 " + FileConstant.PARAMETER_EMPTY_MSG);
}
if (StringUtils.isEmpty(request.getLastOperatorName())) {
throw new ServiceException(FileConstant.PARAMETER_EMPTY_CODE, "操作人 " + FileConstant.PARAMETER_EMPTY_MSG);
}
if (request.getFileSize() == null || request.getFileSize() == 0) {
throw new ServiceException(FileConstant.PARAMETER_EMPTY_CODE, "文件大小 " + FileConstant.PARAMETER_EMPTY_MSG);
}
if (request.getFileUpSum() == null || request.getFileUpSum() == 0) {
throw new ServiceException(FileConstant.PARAMETER_EMPTY_CODE, "分片个数 " + FileConstant.PARAMETER_EMPTY_MSG);
}
String fileMD5 = request.getFileMD5();
if (StringUtils.isEmpty(fileMD5)) {
throw new ServiceException(FileConstant.PARAMETER_EMPTY_CODE, "文件md5值" + FileConstant.PARAMETER_EMPTY_MSG);
}
}
}
(2)Service层
@Service
public class BigFileService {
protected final Logger logger = Logger.getLogger(BigFileService.class.getName());
@Autowired
private FileUploadInfoMapper fileInfoMapper;
@Autowired
private FileFragmentMapper fragmentMapper;
@Autowired
private FileFragmentDetailMapper fragmentDetailMapper;
/**
* 采用jwt生成token,
* @param request
* @return
*/
public String getToken(FileUpInfoRequest request) {
return JwtUtils.createToken("admin", new ArrayList<>(), request);
}
public Map
String token = request.getToken();
//1.验证token是否过期
if (JwtUtils.isExpiration(token)) {
logger.info("token已经过期");
throw new ServiceException(FileConstant.CODE_CHECK_TOKEN_TIME, FileConstant.MSG_CHECK_TOKEN_TIME);
}
//2.判断是否已经上传过,若上传过,并且已经成功,则返回url;若部分成功,返回成功地详细信息
String fileMD5 = request.getFileMD5();
Map
FileUploadInfo fileInfo = fileInfoMapper.getFileUploadInfoByMD5(fileMD5);
if (fileInfo == null) {
obj.put("fileUploadStatus", FileUploadEnum.NOT_COMPLETED.getId());
obj.put("listFragmentDetail", new ArrayList<>());
obj.put("fileUrl", "");
return obj;
}
//已经上传成功过
if (!StringUtils.isNullOrEmpty(fileInfo.getFileUrl())) {
//说明上传过,可以实现秒传 则先保存上传记录作为日志,
saveFileFragment(fileInfo, request, FileUploadEnum.UPLOADING.getId());
obj.put("fileUploadStatus", FileUploadEnum.COMPLETED.getId());
obj.put("listFragmentDetail", new ArrayList<>());
obj.put("fileUrl", fileInfo.getFileUrl());
return obj;
}
//返回已上传过的分片 上传过的可以不用传
Integer fileInfoId = fileInfo.getId();
List
obj.put("fileUploadStatus", FileUploadEnum.COMPLETED_PART.getId());
obj.put("listFragmentDetail", fragmentDetail);
obj.put("fileUrl", "");
return obj;
}
/**
* 保存分片信息
* @param fileInfo
* @param request
*/
public FileFragment saveFileFragment(FileUploadInfo fileInfo, FileUpInfoRequest request, Integer upStatus) {
//处理文件名
String originFileName = request.getFileName();
String preFileName = FileUtils.getPreFileName(originFileName);
FileFragment fragment = new FileFragment();
fragment.setCreateTime(new Date());
fragment.setFileInfoId(fileInfo.getId());
fragment.setUpStatus(upStatus);
fragment.setEndTime(new Date());
fragment.setFileFragName(FileUtils.buildFileName(preFileName));
fragment.setLastOperatorId(request.getLastOperatorId());
fragment.setLastOperatorName(request.getLastOperatorName());
fragmentMapper.insert(fragment);
return fragment;
}
/**
* 文件上传
* @param request
* @return
*/
public UploadFileResponse uploadFile(FileUpInfoRequest request, MultipartFile file) throws ServiceException, IOException {
UploadFileResponse response = new UploadFileResponse();
//1.校验token是否已过期
checkTokenExpire(request.getToken());
String fileMD5 = request.getFileMD5();
FileUploadInfo fileInfo = fileInfoMapper.getFileUploadInfoByMD5(fileMD5);
FileFragment fragment = null;
//2.首次上传时生成文件记录,否则查询出记录
if (fileInfo == null) {
fileInfo = saveFileUploadInfo(request);
fragment = saveFileFragment(fileInfo, request, FileUploadEnum.NOT_COMPLETED.getId());
} else {
fragment = fragmentMapper.getFileFragmentByInfoId(fileInfo.getId());
}
//2.1 有其他相同md5的文件上传过 若存在地址 则直接返回
if (!StringUtils.isNullOrEmpty(fileInfo.getFileUrl())) {
response.setFileUrl(fileInfo.getFileUrl());
response.setUpdateStatus(FileUploadEnum.COMPLETED.getId());
return response;
}
Integer fileInfoId = fileInfo.getId();
String fileType = fileInfo.getFileType();
//3.检测文件是否上传过
boolean fragmentFlag = checkAlreadyOrSaveUpFileDetail(request, fileInfoId);
if (fragmentFlag ) {
//3.1 有上传成功过 进行合并检测
if (checkAllFramentSucc(fileInfoId)) {
return mergeFileFragment(fileInfoId, fileType);
}
response.setUpdateStatus(FileUploadEnum.COMPLETED.getId());
return response;
}
//3.2 未上传过或上传失败 则上传文件
String fileFragName = fragment.getFileFragName();
String fileName = fileFragName + "-" + request.getNumber() + "." + fileType;
String savePath = FileUtils.SAVE_ROOT_PATH;
FileFragmentDetail fragmentDetail = fragmentDetailMapper.getFragmentDetailByFileIdAndMd5(request.getFilefragmentMD5(), fileInfoId);
try {
FileUtils.uploadFile(file.getBytes(), savePath + FileUtils.FILE_SEPARATOR + "fragmentTemp" + FileUtils.FILE_SEPARATOR + fileName);
fragmentDetail.setUpStatus(FileUploadEnum.COMPLETED.getId());
fragmentDetail.setEndTime(new Date());
} catch (Exception e) {
e.printStackTrace();
fragmentDetail.setUpStatus(FileUploadEnum.UPLOAD_ERROR.getId());
}
fragmentDetail.setUpdateTime(new Date());
//4. 上传成功后,更新上传记录
fragmentDetailMapper.updateByPrimaryKeySelective(fragmentDetail);
if (fragmentDetail.getUpStatus() != null && FileUploadEnum.COMPLETED.getId().equals(fragmentDetail.getUpStatus())) {
updateFileFragment(fragment.getId());
}
//5. 上传成功后 检测并进行合并
if (checkAllFramentSucc(fileInfoId)) {
return mergeFileFragment(fileInfoId, fileType);
}
return null;
}
/**
* 合并文件,并生成url访问地址
*/
public UploadFileResponse mergeFileFragment(Integer fileInfoId, String fileType) {
FileFragment fragment = fragmentMapper.getFileFragmentByInfoId(fileInfoId);
String savePath = FileUtils.SAVE_ROOT_PATH;
String sourcePath = savePath + FileUtils.FILE_SEPARATOR + "fragmentTemp" + FileUtils.FILE_SEPARATOR;
FileServerData fileServerData = new FileServerData();
fileServerData.setFileName(fragment.getFileFragName());
fileServerData.setFileUpSum(fragment.getFileUpSum());
fileServerData.setFileType(fileType);
fileServerData.setFileSourcePath(sourcePath);
fileServerData.setSavePath(savePath);
FileUtils.mergeFileFragment(fileServerData);
if (fileServerData.getStatus() != 0) {
return null;
}
UploadFileResponse response = new UploadFileResponse();
response.setFileUrl(fileServerData.getUrl());
response.setUpdateStatus(FileUploadEnum.COMPLETED.getId());
return response;
}
/**
* 更新状态分片文件的状态
* @param id
*/
public synchronized void updateFileFragment(Integer id) {
FileFragment fragment = fragmentMapper.getFileFragmentById(id);
Integer fileUpSucc = fragment.getFileUpSuccessNum();
Integer fileUpSum = fragment.getFileUpSum();
if (fileUpSum == (fileUpSucc + 1)) {
fragment.setUpStatus(FileUploadEnum.COMPLETED.getId());
fragment.setEndTime(new Date());
}
fragment.setFileUpSuccessNum(fileUpSucc + 1);
fragment.setUpdateTime(new Date());
fragmentMapper.updateByPrimaryKeySelective(fragment);
}
/**
* 校验是否可以合并,true:可以合并 false:不能合并
* @param fileInfoId
*/
public boolean checkAllFramentSucc(Integer fileInfoId) {
FileFragment fragment = fragmentMapper.getFileFragmentByInfoId(fileInfoId);
Integer upStatus = fragment.getUpStatus();
if (!FileUploadEnum.COMPLETED.getId().equals(upStatus)) {
return false;
}
Integer fileUpSum = fragment.getFileUpSum();
List
if (fragmentDetails.size() != fileUpSum) {
return false;
}
//以下面这种方式 可能不太准确
// Integer fileUpSuccessNum = fragment.getFileUpSuccessNum();
// if (!fileUpSuccessNum.equals(fileUpSum)) {
// return false;
// }
return true;
}
/**
* 检测文件上传状态
* @param request
* @param fileInfoId
* @return
*/
public boolean checkAlreadyOrSaveUpFileDetail(FileUpInfoRequest request, Integer fileInfoId) {
String filefragmentMD5 = request.getFilefragmentMD5();
FileFragmentDetail fragmentDetail = fragmentDetailMapper.getFragmentDetailByFileIdAndMd5(filefragmentMD5, fileInfoId);
if (fragmentDetail == null ) {
fragmentDetail = new FileFragmentDetail();
fragmentDetail.setFragmentNum(request.getNumber());
fragmentDetail.setFragmentSize(request.getFileFragmentSize());
fragmentDetail.setCreateTime(new Date());
fragmentDetail.setFileInfoId(fileInfoId);
fragmentDetail.setUpStatus(FileUploadEnum.NOT_COMPLETED.getId());
fragmentDetailMapper.insert(fragmentDetail);
return false;
}
if (FileUploadEnum.COMPLETED.getId().equals(fragmentDetail.getUpStatus())) {
return false;
}
return true;
}
/**
* 检测token是否过期
* @param token
* @throws ServiceException
*/
public void checkTokenExpire(String token) throws ServiceException {
if (JwtUtils.isExpiration(token)) {
logger.info("token已经过期");
throw new ServiceException(FileConstant.CODE_CHECK_TOKEN_TIME, FileConstant.MSG_CHECK_TOKEN_TIME);
}
}
/**
* 保存文件信息
* @param request
* @return
*/
public FileUploadInfo saveFileUploadInfo(FileUpInfoRequest request) {
FileUploadInfo fileInfo = new FileUploadInfo();
fileInfo.setFileMd5(request.getFileMD5());
fileInfo.setFileName(request.getFileName());
fileInfo.setFileSize(request.getFileSize());
fileInfo.setFileType(request.getFileType());
fileInfo.setCreateId(request.getLastOperatorId());
fileInfo.setCreateTime(new Date());
fileInfoMapper.insert(fileInfo);
return fileInfo;
}
}
注:需要更完整的代码请留言
...