一、背景
平時在移動和客戶端有普通的文件上傳,但這種文件大多不大,從幾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文件,主要為以下幾個:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.1</version> </dependency> <dependency> <groupId>tk.mybatis</groupId> <artifactId>mapper</artifactId> <version>3.4.1</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.41</version> </dependency>
工具類,包括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<Object> functs, Object entity) { Map<String, Object> map = new HashMap<>(); //當前用戶擁有的功能 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<Object> getFuncts(String token) { String str = getTokenBody(token).get(FUNCTS).toString(); List<Object> list = JSON.parseArray(str); 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<String, Object> map = new HashMap<>(); 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<String, Object> resultMap = null; 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, Object> checkAndListFragmentDetail(FileUpInfoRequest request) throws ServiceException { 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<String, Object> obj = new HashMap<>(); 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<FileFragmentDetail> fragmentDetail = fragmentDetailMapper.listFileFragmentDetailByFileInfoId(fileInfoId); 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<FileFragmentDetail> fragmentDetails = fragmentDetailMapper.listFileFragmentDetailByFileInfoId(fileInfoId); 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; } }
注:需要更完整的代碼請留言
...