功能介紹
文件上傳
- 小文件(圖片、文檔、視頻)上傳可以直接使用很多ui框架封裝的上傳組件,或者自己寫一個input 上傳,利用FormData 對象提交文件數(shù)據(jù),后端使用spring提供的MultipartFile進行文件的接收,然后寫入即可。
- 但是對于比較大的文件,比如上傳2G左右的文件(http上傳),就需要將文件分片上傳(file.slice()),否則中間http長時間連接可能會斷掉
分片上傳
分片上傳,就是將所要上傳的文件,按照一定的大小,將整個文件分隔成多個數(shù)據(jù)塊(我們稱之為Part)來進行分別上傳,上傳完之后再由服務端對所有上傳的文件進行匯總整合成原始的文件
秒傳
- 通俗的說,你把要上傳的東西上傳,服務器會先做MD5校驗,如果服務器上有一樣的東西,它就直接給你個新地址,其實你下載的都是服務器上的同一個文件
- 想要不秒傳,其實只要讓MD5改變,就是對文件本身做一下修改(改名字不行),例如一個文本文件,你多加幾個字,MD5就變了,就不會秒傳了
斷點續(xù)傳
- 斷點續(xù)傳是在下載或上傳時,將下載或上傳任務(一個文件或一個壓縮包)人為的劃分為幾個部分,每一個部分采用一個線程進行上傳或下載
- 如果碰到網(wǎng)絡故障,可以從已經(jīng)上傳或下載的部分開始繼續(xù)上傳或者下載未完成的部分,而沒有必要從頭開始上傳或者下載。本文的斷點續(xù)傳主要是針對斷點上傳場景。
相關概念
- chunkNumber: 當前塊的次序,第一個塊是 1,注意不是從 0 開始的。
- totalChunks: 文件被分成塊的總數(shù)。
- chunkSize: 分塊大小,根據(jù) totalSize 和這個值你就可以計算出總共的塊數(shù)。注意最后一塊的大小可能會比這個要大。
- currentChunkSize: 當前塊的大小,實際大小。
- totalSize: 文件總大小。
- identifier: 這個就是MD5值,每個文件的唯一標示。
- filename: 文件名
相關方法
- .upload() 開始或者繼續(xù)上傳。
- .pause() 暫停上傳。
- .resume() 繼續(xù)上傳。
- .cancel() 取消所有上傳文件,文件會被移除掉。
- .progress() 返回一個0-1的浮點數(shù),當前上傳進度。
- .isUploading() 返回一個布爾值標示是否還有文件正在上傳中。
- .addFile(file) 添加一個原生的文件對象到上傳列表中。
- .removeFile(file) 從上傳列表中移除一個指定的 Uploader.File 實例對象。
大文件上傳流程
- 前端對文件進行MD5加密,并且將文件按一定的規(guī)則分片
- vue-simple-uploader先會發(fā)送get請求校驗分片數(shù)據(jù)在服務端是否完整,如果完整則進行秒傳,如果不完整或者無數(shù)據(jù),則進行分片上傳。
- 后臺校驗MD5值,根據(jù)上傳的序號和分片大小計算相應的開始位置并寫入該分片數(shù)據(jù)到文件中。
前端切片處理邏輯
后端處理切片的邏輯
流程解析
- 在created時,初始化uploader組件,指定分片大小、上傳方式等配置。
- 在onFileAdded方法中,當選擇文件計算MD5后,調(diào)用file.resume()開始上傳。
- file.resume()內(nèi)部首先發(fā)送一個GET請求,詢問服務端該文件已上傳的分片。
- 服務端返回一個JSON,里面包含已上傳分片的列表。
- uploader組件調(diào)用checkChunkUploadedByResponse,校驗當前分片是否在已上傳的列表中。
- 對未上傳的分片,file.resume()會繼續(xù)觸發(fā)上傳該分片的POST請求。
- POST請求會包含一個分片的數(shù)據(jù)和偏移量等信息。
- 服務端接收分片數(shù)據(jù),寫入文件的指定位置并返回成功響應。
- uploader組件會記錄該分片已上傳完成。
- 依次上傳完所有分片后,服務器端合并所有分片成一個完整的文件。
- onFileSuccess被調(diào)用,通知上傳成功。
- 這樣通過GET請求詢問已上傳分片+POST上傳未完成分片+校驗的方式,實現(xiàn)了斷點續(xù)傳/分片上傳。
后端代碼實現(xiàn)
SpringBoot2.7.16+MySQL+JPA+hutool文章來源:http://www.zghlxwxcb.cn/news/detail-853571.html
功能目標
- get請求接口校驗上傳文件MD5值和文件是否完整
- post請求接收上傳文件,并且計算分片,寫入合成文件
- 文件完整上傳完成時,往文件存儲表tool_local_storage中加一條該文件的信息
- get請求接口實現(xiàn)簡單的文件下載
1.建表SQL
DROP TABLE IF EXISTS `file_chunk`;
CREATE TABLE `file_chunk` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
`file_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件名',
`chunk_number` int(11) NULL DEFAULT NULL COMMENT '當前分片,從1開始',
`chunk_size` float NULL DEFAULT NULL COMMENT '分片大小',
`current_chunk_size` float NULL DEFAULT NULL COMMENT '當前分片大小',
`total_size` double(20, 0) NULL DEFAULT NULL COMMENT '文件總大小',
`total_chunk` int(11) NULL DEFAULT NULL COMMENT '總分片數(shù)',
`identifier` varchar(45) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件標識',
`relative_path` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'md5校驗碼',
`createtime` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatetime` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1529 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS `tool_local_storage`;
CREATE TABLE `tool_local_storage` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`real_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件真實的名稱',
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '文件名',
`suffix` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '后綴',
`path` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '路徑',
`type` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '類型',
`size` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '大小',
`identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT 'md5校驗碼\r\n',
`create_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '創(chuàng)建者',
`update_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '更新者',
`createtime` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatetime` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3360 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '文件存儲' ROW_FORMAT = Compact;
2.引入依賴
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.4</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.70</version>
</dependency>
3.實體類
package com.zjl.domin;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Data
@Entity
@Table(name = "file_chunk")
public class FileChunkParam implements Serializable {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "chunk_number")
private Integer chunkNumber;
@Column(name = "chunk_size")
private Float chunkSize;
@Column(name = "current_chunk_size")
private Float currentChunkSize;
@Column(name = "total_chunk")
private Integer totalChunks;
@Column(name = "total_size")
private Double totalSize;
@Column(name = "identifier")
private String identifier;
@Column(name = "file_name")
private String filename;
@Column(name = "relative_path")
private String relativePath;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Column(name = "createtime")
private Date createtime;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Column(name = "updatetime")
private Date updatetime;
@Transient
private MultipartFile file;
}
package com.zjl.domin;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Data
@Entity
@Table(name = "tool_local_storage")
public class LocalStorage implements Serializable {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "real_name")
private String realName;
@Column(name = "name")
private String name;
@Column(name = "suffix")
private String suffix;
@Column(name = "path")
private String path;
@Column(name = "type")
private String type;
@Column(name = "size")
private String size;
@Column(name = "identifier")
private String identifier;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Column(name = "createtime")
private Date createtime;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Column(name = "updatetime")
private Date updatetime;
public LocalStorage() {
}
public LocalStorage(String realName, String name, String suffix, String path, String type, String size, String identifier) {
this.realName = realName;
this.name = name;
this.suffix = suffix;
this.path = path;
this.type = type;
this.size = size;
this.identifier = identifier;
}
public LocalStorage(Long id, String realName, String name, String suffix, String path, String type, String size, String identifier) {
this.id = id;
this.realName = realName;
this.name = name;
this.suffix = suffix;
this.path = path;
this.type = type;
this.size = size;
this.identifier = identifier;
}
public void copy(LocalStorage source) {
BeanUtil.copyProperties(source, this, CopyOptions.create().setIgnoreNullValue(true));
}
}
4.響應模板
package com.zjl.domin;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Data
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class ResultVO<T> {
/**
* 錯誤碼.
*/
private Integer code;
/**
* 提示信息.
*/
private String msg;
/**
* 具體內(nèi)容.
*/
private T data;
public ResultVO(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public ResultVO(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public ResultVO() {
}
}
5.枚舉類
package com.zjl.enums;
import lombok.Getter;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
public enum MessageEnum {
/**
* 消息枚舉
*/
FAIL(-1, "操作失敗"),
SUCCESS(200, "操作成功"),
RECORD_NOT_EXISTED(1001, "記錄不存在"),
PARAM_NOT_NULL(1002, "參數(shù)不能為空"),
PARAM_INVALID(1003, "參數(shù)錯誤"),
UPLOAD_FILE_NOT_NULL(1004, "上傳文件不能為空"),
OVER_FILE_MAX_SIZE(1005, "超出文件大小");
MessageEnum(int value, String text) {
this.code = value;
this.message = text;
}
@Getter
private final int code;
@Getter
private final String message;
public static MessageEnum valueOf(int value) {
MessageEnum[] enums = values();
for (MessageEnum enumItem : enums) {
if (value == enumItem.getCode()) {
return enumItem;
}
}
return null;
}
}
6.自定義異常
package com.zjl.exception;
import com.zjl.enums.MessageEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public abstract class BaseErrorException extends RuntimeException {
private static final long serialVersionUID = 6386720492655133851L;
private int code;
private String error;
public BaseErrorException(MessageEnum messageEnum) {
this.code = messageEnum.getCode();
this.error = messageEnum.getMessage();
}
}
package com.zjl.exception;
import com.zjl.enums.MessageEnum;
import lombok.Data;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Data
public class BusinessException extends BaseErrorException {
private static final long serialVersionUID = 2369773524406947262L;
public BusinessException(MessageEnum messageEnum) {
super(messageEnum);
}
public BusinessException(String error) {
super.setCode(-1);
super.setError(error);
}
}
7.工具類
package com.zjl.utils;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.poi.excel.BigExcelWriter;
import cn.hutool.poi.excel.ExcelUtil;
import com.zjl.enums.MessageEnum;
import com.zjl.exception.BusinessException;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import sun.misc.BASE64Encoder;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:File工具類,擴展 hutool 工具包
*/
public class FileUtil extends cn.hutool.core.io.FileUtil {
private static final Logger log = LoggerFactory.getLogger(FileUtil.class);
/**
* 系統(tǒng)臨時目錄
* <br>
* windows 包含路徑分割符,但Linux 不包含,
* 在windows \\==\ 前提下,
* 為安全起見 同意拼裝 路徑分割符,
* <pre>
* java.io.tmpdir
* windows : C:\Users/xxx\AppData\Local\Temp\
* linux: /temp
* </pre>
*/
public static final String SYS_TEM_DIR = System.getProperty("java.io.tmpdir") + File.separator;
/**
* 定義GB的計算常量
*/
private static final int GB = 1024 * 1024 * 1024;
/**
* 定義MB的計算常量
*/
private static final int MB = 1024 * 1024;
/**
* 定義KB的計算常量
*/
private static final int KB = 1024;
/**
* 格式化小數(shù)
*/
private static final DecimalFormat DF = new DecimalFormat("0.00");
/**
* MultipartFile轉File
*/
public static File toFile(MultipartFile multipartFile) {
// 獲取文件名
String fileName = multipartFile.getOriginalFilename();
// 獲取文件后綴
String prefix = "." + getExtensionName(fileName);
File file = null;
try {
// 用uuid作為文件名,防止生成的臨時文件重復
file = File.createTempFile(IdUtil.simpleUUID(), prefix);
// MultipartFile to File
multipartFile.transferTo(file);
} catch (IOException e) {
log.error(e.getMessage(), e);
}
return file;
}
/**
* 獲取文件擴展名,不帶 .
*/
public static String getExtensionName(String filename) {
if ((filename != null) && (filename.length() > 0)) {
int dot = filename.lastIndexOf('.');
if ((dot > -1) && (dot < (filename.length() - 1))) {
return filename.substring(dot + 1);
}
}
return filename;
}
/**
* Java文件操作 獲取不帶擴展名的文件名
*/
public static String getFileNameNoEx(String filename) {
if ((filename != null) && (filename.length() > 0)) {
int dot = filename.lastIndexOf('.');
if ((dot > -1) && (dot < (filename.length()))) {
return filename.substring(0, dot);
}
}
return filename;
}
/**
* 文件大小轉換
*/
public static String getSize(long size) {
String resultSize;
if (size / GB >= 1) {
//如果當前Byte的值大于等于1GB
resultSize = DF.format(size / (float) GB) + "GB ";
} else if (size / MB >= 1) {
//如果當前Byte的值大于等于1MB
resultSize = DF.format(size / (float) MB) + "MB ";
} else if (size / KB >= 1) {
//如果當前Byte的值大于等于1KB
resultSize = DF.format(size / (float) KB) + "KB ";
} else {
resultSize = size + "B ";
}
return resultSize;
}
/**
* inputStream 轉 File
*/
static File inputStreamToFile(InputStream ins, String name) throws Exception {
File file = new File(SYS_TEM_DIR + name);
if (file.exists()) {
return file;
}
OutputStream os = new FileOutputStream(file);
int bytesRead;
int len = 8192;
byte[] buffer = new byte[len];
while ((bytesRead = ins.read(buffer, 0, len)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.close();
ins.close();
return file;
}
/**
* 將文件名解析成文件的上傳路徑
*/
public static File upload(MultipartFile file, String filePath) {
Date date = new Date();
SimpleDateFormat format = new SimpleDateFormat("yyyyMMddhhmmssS");
String name = getFileNameNoEx(file.getOriginalFilename());
String suffix = getExtensionName(file.getOriginalFilename());
String nowStr = "-" + format.format(date);
try {
String fileName = name + nowStr + "." + suffix;
String path = filePath + fileName;
// getCanonicalFile 可解析正確各種路徑
File dest = new File(path).getCanonicalFile();
// 檢測是否存在目錄
if (!dest.getParentFile().exists()) {
if (!dest.getParentFile().mkdirs()) {
System.out.println("was not successful.");
}
}
// 文件寫入
file.transferTo(dest);
return dest;
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return null;
}
/**
* 導出excel
*/
public static void downloadExcel(List<Map<String, Object>> list, HttpServletResponse response) throws IOException {
String tempPath = SYS_TEM_DIR + IdUtil.fastSimpleUUID() + ".xlsx";
File file = new File(tempPath);
BigExcelWriter writer = ExcelUtil.getBigWriter(file);
// 一次性寫出內(nèi)容,使用默認樣式,強制輸出標題
writer.write(list, true);
//response為HttpServletResponse對象
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
//test.xls是彈出下載對話框的文件名,不能為中文,中文請自行編碼
response.setHeader("Content-Disposition", "attachment;filename=file.xlsx");
ServletOutputStream out = response.getOutputStream();
// 終止后刪除臨時文件
file.deleteOnExit();
writer.flush(out, true);
//此處記得關閉輸出Servlet流
IoUtil.close(out);
}
public static String getFileType(String type) {
String documents = "txt pdf pps wps doc docx ppt pptx xls xlsx";
String music = "mp3 wav wma mpa ram ra aac aif m4a";
String video = "avi mpg mpe mpeg asf wmv mov qt rm mp4 flv m4v webm ogv ogg";
String image = "bmp dib pcp dif wmf gif jpg tif eps psd cdr iff tga pcd mpt png jpeg";
if (image.contains(type)) {
return "圖片";
} else if (documents.contains(type)) {
return "文檔";
} else if (music.contains(type)) {
return "音樂";
} else if (video.contains(type)) {
return "視頻";
} else {
return "其他";
}
}
public static String getTransferFileType(String type) {
String documents = "txt pdf pps wps doc docx ppt pptx xls xlsx";
String music = "mp3 wav wma mpa ram ra aac aif m4a";
String video = "avi mpg mpe mpeg asf wmv mov qt rm mp4 flv m4v webm ogv ogg";
String image = "bmp dib pcp dif wmf gif jpg tif eps psd cdr iff tga pcd mpt png jpeg";
if (image.contains(type)) {
return "image";
} else if (documents.contains(type)) {
return "documents";
} else if (music.contains(type)) {
return "music";
} else if (video.contains(type)) {
return "video";
} else {
return "other";
}
}
public static void checkSize(long maxSize, long size) {
// 1M
int len = 1024 * 1024;
if (size > (maxSize * len)) {
throw new BusinessException(MessageEnum.OVER_FILE_MAX_SIZE);
}
}
/**
* 判斷兩個文件是否相同
*/
public static boolean check(File file1, File file2) {
String img1Md5 = getMd5(file1);
String img2Md5 = getMd5(file2);
return img1Md5.equals(img2Md5);
}
/**
* 判斷兩個文件是否相同
*/
public static boolean check(String file1Md5, String file2Md5) {
return file1Md5.equals(file2Md5);
}
private static byte[] getByte(File file) {
// 得到文件長度
byte[] b = new byte[(int) file.length()];
try {
InputStream in = new FileInputStream(file);
try {
System.out.println(in.read(b));
} catch (IOException e) {
log.error(e.getMessage(), e);
}
} catch (FileNotFoundException e) {
log.error(e.getMessage(), e);
return null;
}
return b;
}
private static String getMd5(byte[] bytes) {
// 16進制字符
char[] hexDigits = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
try {
MessageDigest mdTemp = MessageDigest.getInstance("MD5");
mdTemp.update(bytes);
byte[] md = mdTemp.digest();
int j = md.length;
char[] str = new char[j * 2];
int k = 0;
// 移位 輸出字符串
for (byte byte0 : md) {
str[k++] = hexDigits[byte0 >>> 4 & 0xf];
str[k++] = hexDigits[byte0 & 0xf];
}
return new String(str);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return null;
}
/**
* 下載文件
*
* @param request /
* @param response /
* @param file /
*/
public static void downloadFile(HttpServletRequest request, HttpServletResponse response, File file, boolean deleteOnExit) throws UnsupportedEncodingException {
response.setCharacterEncoding(request.getCharacterEncoding());
response.setContentType("application/octet-stream");
FileInputStream fis = null;
String filename = filenameEncoding(file.getName(), request);
try {
fis = new FileInputStream(file);
response.setHeader("Content-Disposition", String.format("attachment;filename=%s", filename));
IOUtils.copy(fis, response.getOutputStream());
response.flushBuffer();
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
if (fis != null) {
try {
fis.close();
if (deleteOnExit) {
file.deleteOnExit();
}
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}
}
public static String getMd5(File file) {
return getMd5(getByte(file));
}
public static String filenameEncoding(String filename, HttpServletRequest request) throws UnsupportedEncodingException {
// 獲得請求頭中的User-Agent
String agent = request.getHeader("User-Agent");
// 根據(jù)不同的客戶端進行不同的編碼
if (agent.contains("MSIE")) {
// IE瀏覽器
filename = URLEncoder.encode(filename, "utf-8");
} else if (agent.contains("Firefox")) {
// 火狐瀏覽器
BASE64Encoder base64Encoder = new BASE64Encoder();
filename = "=?utf-8?B?" + base64Encoder.encode(filename.getBytes("utf-8")) + "?=";
} else {
// 其它瀏覽器
filename = URLEncoder.encode(filename, "utf-8");
}
return filename;
}
}
8.Controller層
package com.zjl.controller;
import com.zjl.domin.FileChunkParam;
import com.zjl.domin.ResultVO;
import com.zjl.service.FileChunkService;
import com.zjl.service.FileService;
import com.zjl.service.LocalStorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@RestController
@Slf4j
@RequestMapping("/api")
public class FileUploadController {
@Resource
private FileService fileService;
@Resource
private FileChunkService fileChunkService;
@Resource
private LocalStorageService localStorageService;
@GetMapping("/upload")
public ResultVO<Map<String, Object>> checkUpload(FileChunkParam param) {
log.info("文件MD5:" + param.getIdentifier());
List<FileChunkParam> list = fileChunkService.findByMd5(param.getIdentifier());
Map<String, Object> data = new HashMap<>(1);
// 判斷文件存不存在
if (list.size() == 0) {
data.put("uploaded", false);
return new ResultVO<>(200, "上傳成功", data);
}
// 處理單文件
if (list.get(0).getTotalChunks() == 1) {
data.put("uploaded", true);
data.put("url", "");
return new ResultVO<Map<String, Object>>(200, "上傳成功", data);
}
// 處理分片
int[] uploadedFiles = new int[list.size()];
int index = 0;
for (FileChunkParam fileChunkItem : list) {
uploadedFiles[index] = fileChunkItem.getChunkNumber();
index++;
}
data.put("uploadedChunks", uploadedFiles);
return new ResultVO<Map<String, Object>>(200, "上傳成功", data);
}
@PostMapping("/upload")
public ResultVO chunkUpload(FileChunkParam param) {
log.info("上傳文件:{}", param);
boolean flag = fileService.uploadFile(param);
if (!flag) {
return new ResultVO(211, "上傳失敗");
}
return new ResultVO(200, "上傳成功");
}
@GetMapping(value = "/download/{md5}/{name}")
public void downloadbyname(HttpServletRequest request, HttpServletResponse response, @PathVariable String name, @PathVariable String md5) throws IOException {
localStorageService.downloadByName(name, md5, request, response);
}
}
9.FileService
package com.zjl.service;
import com.zjl.domin.FileChunkParam;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
public interface FileService {
/**
* 上傳文件
* @param param 參數(shù)
* @return
*/
boolean uploadFile(FileChunkParam param);
}
package com.zjl.service.impl;
import com.zjl.domin.FileChunkParam;
import com.zjl.enums.MessageEnum;
import com.zjl.exception.BusinessException;
import com.zjl.service.FileChunkService;
import com.zjl.service.FileService;
import com.zjl.service.LocalStorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import sun.misc.Cleaner;
import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.lang.reflect.Method;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.security.AccessController;
import java.security.PrivilegedAction;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Service("fileService")
@Slf4j
public class FileServiceImpl implements FileService {
/**
* 默認的分片大?。?0MB
*/
public static final long DEFAULT_CHUNK_SIZE = 20 * 1024 * 1024;
@Value("${file.BASE_FILE_SAVE_PATH}")
private String BASE_FILE_SAVE_PATH;
@Resource
private FileChunkService fileChunkService;
@Resource
private LocalStorageService localStorageService;
@Override
public boolean uploadFile(FileChunkParam param) {
if (null == param.getFile()) {
throw new BusinessException(MessageEnum.UPLOAD_FILE_NOT_NULL);
}
// 判斷目錄是否存在,不存在則創(chuàng)建目錄
File savePath = new File(BASE_FILE_SAVE_PATH);
if (!savePath.exists()) {
boolean flag = savePath.mkdirs();
if (!flag) {
log.error("保存目錄創(chuàng)建失敗");
return false;
}
}
// todo 處理文件夾上傳(上傳目錄下新建上傳的文件夾)
/*String relativePath = param.getRelativePath();
if (relativePath.contains("/") || relativePath.contains(File.separator)) {
String div = relativePath.contains(File.separator) ? File.separator : "/";
String tempPath = relativePath.substring(0, relativePath.lastIndexOf(div));
savePath = new File(BASE_FILE_SAVE_PATH + File.separator + tempPath);
if (!savePath.exists()) {
boolean flag = savePath.mkdirs();
if (!flag) {
log.error("保存目錄創(chuàng)建失敗");
return false;
}
}
}*/
// 這里可以使用 uuid 來指定文件名,上傳完成后再重命名,F(xiàn)ile.separator指文件目錄分割符,win上的"\",Linux上的"/"。
String fullFileName = savePath + File.separator + param.getFilename();
// 單文件上傳
if (param.getTotalChunks() == 1) {
return uploadSingleFile(fullFileName, param);
}
// 分片上傳,這里使用 uploadFileByRandomAccessFile 方法,也可以使用 uploadFileByMappedByteBuffer 方法上傳
boolean flag = uploadFileByRandomAccessFile(fullFileName, param);
if (!flag) {
return false;
}
// 保存分片上傳信息
fileChunkService.saveFileChunk(param);
return true;
}
private boolean uploadFileByRandomAccessFile(String resultFileName, FileChunkParam param) {
try (RandomAccessFile randomAccessFile = new RandomAccessFile(resultFileName, "rw")) {
// 分片大小必須和前端匹配,否則上傳會導致文件損壞
long chunkSize = param.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : param.getChunkSize().longValue();
// 偏移量
long offset = chunkSize * (param.getChunkNumber() - 1);
// 定位到該分片的偏移量
randomAccessFile.seek(offset);
// 寫入
randomAccessFile.write(param.getFile().getBytes());
} catch (IOException e) {
log.error("文件上傳失?。? + e);
return false;
}
return true;
}
private boolean uploadFileByMappedByteBuffer(String resultFileName, FileChunkParam param) {
// 分片上傳
try (RandomAccessFile randomAccessFile = new RandomAccessFile(resultFileName, "rw");
FileChannel fileChannel = randomAccessFile.getChannel()) {
// 分片大小必須和前端匹配,否則上傳會導致文件損壞
long chunkSize = param.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : param.getChunkSize().longValue();
// 寫入文件
long offset = chunkSize * (param.getChunkNumber() - 1);
byte[] fileBytes = param.getFile().getBytes();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, offset, fileBytes.length);
mappedByteBuffer.put(fileBytes);
// 釋放
unmap(mappedByteBuffer);
} catch (IOException e) {
log.error("文件上傳失?。? + e);
return false;
}
return true;
}
private boolean uploadSingleFile(String resultFileName, FileChunkParam param) {
File saveFile = new File(resultFileName);
try {
// 寫入
param.getFile().transferTo(saveFile);
localStorageService.saveLocalStorage(param);
} catch (IOException e) {
log.error("文件上傳失?。? + e);
return false;
}
return true;
}
/**
* 釋放 MappedByteBuffer
* 在 MappedByteBuffer 釋放后再對它進行讀操作的話就會引發(fā) jvm crash,在并發(fā)情況下很容易發(fā)生
* 正在釋放時另一個線程正開始讀取,于是 crash 就發(fā)生了。所以為了系統(tǒng)穩(wěn)定性釋放前一般需要檢
* 查是否還有線程在讀或寫
* 來源:https://my.oschina.net/feichexia/blog/212318
*
* @param mappedByteBuffer mappedByteBuffer
*/
public static void unmap(final MappedByteBuffer mappedByteBuffer) {
try {
if (mappedByteBuffer == null) {
return;
}
mappedByteBuffer.force();
AccessController.doPrivileged((PrivilegedAction<Object>) () -> {
try {
Method getCleanerMethod = mappedByteBuffer.getClass()
.getMethod("cleaner");
getCleanerMethod.setAccessible(true);
Cleaner cleaner =
(Cleaner) getCleanerMethod
.invoke(mappedByteBuffer, new Object[0]);
cleaner.clean();
} catch (Exception e) {
log.error("MappedByteBuffer 釋放失?。? + e);
}
System.out.println("clean MappedByteBuffer completed");
return null;
});
} catch (Exception e) {
log.error("unmap error:" + e);
}
}
}
10.LocalStorageService
package com.zjl.service;
import com.zjl.domin.FileChunkParam;
import com.zjl.domin.LocalStorage;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
public interface LocalStorageService {
/**
* 根據(jù)文件 md5 查詢
*
* @param md5 md5
* @return
*/
LocalStorage findByMd5(String md5);
/**
* 保存記錄
*
* @param localStorage 記錄參數(shù)
*/
void saveLocalStorage(LocalStorage localStorage);
/**
* 保存記錄
*
* @param param 記錄參數(shù)
*/
void saveLocalStorage(FileChunkParam param);
/**
* 刪除記錄
*
* @param localStorage localStorage
* @return
*/
void delete(LocalStorage localStorage);
/**
* 根據(jù) id 刪除
*
* @param id id
* @return
*/
void deleteById(Long id);
void downloadByName(String name, String md5, HttpServletRequest request, HttpServletResponse response);
}
package com.zjl.service.impl;
import com.zjl.domin.FileChunkParam;
import com.zjl.domin.LocalStorage;
import com.zjl.repository.LocalStorageRepository;
import com.zjl.service.LocalStorageService;
import com.zjl.utils.FileUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.UnsupportedEncodingException;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Service
@Slf4j
public class LocalStorageServiceImpl implements LocalStorageService {
@Resource
private LocalStorageRepository localStorageRepository;
@Value("${file.BASE_FILE_SAVE_PATH}")
private String BASE_FILE_SAVE_PATH;
@Override
public LocalStorage findByMd5(String md5) {
return localStorageRepository.findByIdentifier(md5);
}
@Override
public void saveLocalStorage(LocalStorage localStorage) {
localStorageRepository.save(localStorage);
}
@Override
public void saveLocalStorage(FileChunkParam param) {
Long id = null;
LocalStorage byIdentifier = localStorageRepository.findByIdentifier(param.getIdentifier());
if (!ObjectUtils.isEmpty(byIdentifier)) {
id = byIdentifier.getId();
}
String name = param.getFilename();
String suffix = FileUtil.getExtensionName(name);
String type = FileUtil.getFileType(suffix);
LocalStorage localStorage = new LocalStorage(
id,
name,
FileUtil.getFileNameNoEx(name),
suffix,
param.getRelativePath(),
type,
FileUtil.getSize(param.getTotalSize().longValue()),
param.getIdentifier()
);
localStorageRepository.save(localStorage);
}
@Override
public void delete(LocalStorage localStorage) {
localStorageRepository.delete(localStorage);
}
@Override
public void deleteById(Long id) {
localStorageRepository.deleteById(id);
}
@Override
public void downloadByName(String name, String md5, HttpServletRequest request, HttpServletResponse response) {
LocalStorage storage = localStorageRepository.findByRealNameAndIdentifier(name, md5);
if (ObjectUtils.isEmpty(storage)) {
return;
}
File tofile = new File(BASE_FILE_SAVE_PATH + File.separator + storage.getPath());
try {
FileUtil.downloadFile(request, response, tofile, false);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
}
11.FileChunkService
package com.zjl.service;
import com.zjl.domin.FileChunkParam;
import java.util.List;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
public interface FileChunkService {
/**
* 根據(jù)文件 md5 查詢
*
* @param md5 md5
* @return
*/
List<FileChunkParam> findByMd5(String md5);
/**
* 保存記錄
*
* @param param 記錄參數(shù)
*/
void saveFileChunk(FileChunkParam param);
/**
* 刪除記錄
*
* @param fileChunk fileChunk
* @return
*/
void delete(FileChunkParam fileChunk);
/**
* 根據(jù) id 刪除
*
* @param id id
* @return
*/
void deleteById(Long id);
}
package com.zjl.service.impl;
import com.zjl.domin.FileChunkParam;
import com.zjl.repository.FileChunkRepository;
import com.zjl.service.FileChunkService;
import com.zjl.service.LocalStorageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Service
public class FileChunkServiceImpl implements FileChunkService {
@Resource
private FileChunkRepository fileChunkRepository;
@Resource
private LocalStorageService localStorageService;
@Override
public List<FileChunkParam> findByMd5(String md5) {
return fileChunkRepository.findByIdentifier(md5);
}
@Override
public void saveFileChunk(FileChunkParam param) {
fileChunkRepository.save(param);
// 當文件分片完整上傳完成,存一份在LocalStorage表中
if (param.getChunkNumber().equals(param.getTotalChunks())) {
localStorageService.saveLocalStorage(param);
}
}
@Override
public void delete(FileChunkParam fileChunk) {
fileChunkRepository.delete(fileChunk);
}
@Override
public void deleteById(Long id) {
fileChunkRepository.deleteById(id);
}
}
12. Repository
package com.zjl.repository;
import com.zjl.domin.FileChunkParam;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
public interface FileChunkRepository extends JpaRepository<FileChunkParam, Long>, JpaSpecificationExecutor<FileChunkParam> {
List<FileChunkParam> findByIdentifier(String identifier);
}
package com.zjl.repository;
import com.zjl.domin.LocalStorage;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
public interface LocalStorageRepository extends JpaRepository<LocalStorage, Long>, JpaSpecificationExecutor<LocalStorage> {
LocalStorage findByIdentifier(String identifier);
LocalStorage findByRealNameAndIdentifier(String name, String md5);
}
13.跨域配置
package com.zjl.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author: zjl
* @datetime: 2024/4/9
* @desc:
*/
@Configuration
public class GlobalCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowCredentials(true)
.allowedHeaders("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("Authorization", "Cache-Control", "Content-Type")
.maxAge(3600);
}
}
前端Vue
源碼下載地址:
鏈接:https://pan.baidu.com/s/1KFzWdq-kfOAxMKDaCPCDPQ?pwd=6666 提取碼:6666 文章來源地址http://www.zghlxwxcb.cn/news/detail-853571.html
關鍵代碼
安裝插件、指定分片大小
import SparkMD5 from "spark-md5";
const FILE_UPLOAD_ID_KEY = "file_upload_id";
// 分片大小,20MB
const CHUNK_SIZE = 20 * 1024 * 1024;
定義后端接口地址、判斷分片是否上傳
// 上傳地址
target: "http://127.0.0.1:9999/api/upload",
// 是否開啟服務器分片校驗。默認為 true
testChunks: true,
// 真正上傳的時候使用的 HTTP 方法,默認 POST
uploadMethod: "post",
// 分片大小
chunkSize: CHUNK_SIZE,
// 并發(fā)上傳數(shù),默認為 3
simultaneousUploads: 3,
/**
* 判斷分片是否上傳,秒傳和斷點續(xù)傳基于此方法
* 這里根據(jù)實際業(yè)務來 用來判斷哪些片已經(jīng)上傳過了 不用再重復上傳了 [這里可以用來寫斷點續(xù)傳!?。
*/
checkChunkUploadedByResponse: (chunk, message) => {
// message是后臺返回
let messageObj = JSON.parse(message);
let dataObj = messageObj.data;
if (dataObj.uploaded !== undefined) {
return dataObj.uploaded;
}
// 判斷文件或分片是否已上傳,已上傳返回 true
// 這里的 uploadedChunks 是后臺返回]
return (dataObj.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;
},
計算MD5,并校驗是否已上傳
onFileAdded(file, event) {
this.uploadFileList.push(file);
console.log("file :>> ", file);
// 有時 fileType為空,需截取字符
console.log("文件類型:" + file.fileType);
// 文件大小
console.log("文件大?。? + file.size + "B");
// 1. todo 判斷文件類型是否允許上傳
// 2. 計算文件 MD5 并請求后臺判斷是否已上傳,是則取消上傳
console.log("校驗MD5");
this.getFileMD5(file, (md5) => {
if (md5 != "") {
// 修改文件唯一標識
file.uniqueIdentifier = md5;
// 請求后臺判斷是否上傳
// 恢復上傳
file.resume();
}
});
},
// 計算文件的MD5值
getFileMD5(file, callback) {
let spark = new SparkMD5.ArrayBuffer();
let fileReader = new FileReader();
//獲取文件分片對象(注意它的兼容性,在不同瀏覽器的寫法不同)
let blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice;
// 當前分片下標
let currentChunk = 0;
// 分片總數(shù)(向下取整)
let chunks = Math.ceil(file.size / CHUNK_SIZE);
// MD5加密開始時間
let startTime = new Date().getTime();
// 暫停上傳
file.pause();
loadNext();
// fileReader.readAsArrayBuffer操作會觸發(fā)onload事件
fileReader.onload = function (e) {
// console.log("currentChunk :>> ", currentChunk);
spark.append(e.target.result);
if (currentChunk < chunks) {
currentChunk++;
loadNext();
} else {
// 該文件的md5值
let md5 = spark.end();
console.log(
`MD5計算完畢:${md5},耗時:${new Date().getTime() - startTime} ms.`
);
// 回調(diào)傳值md5
callback(md5);
}
};
fileReader.onerror = function () {
this.$message.error("文件讀取錯誤");
file.cancel();
};
// 加載下一個分片
function loadNext() {
const start = currentChunk * CHUNK_SIZE;
const end =
start + CHUNK_SIZE >= file.size ? file.size : start + CHUNK_SIZE;
// 文件分片操作,讀取下一分片(fileReader.readAsArrayBuffer操作會觸發(fā)onload事件)
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
}
},
fileStatusText(status, response) {
if (status === "md5") {
return "校驗MD5";
} else {
return this.fileStatusTextObj[status];
}
},
計算上傳進度
onFileProgress(rootFile, file, chunk) {
console.log(`當前進度:${Math.ceil(file._prevProgress * 100)}%`);
},
到了這里,關于【SpringBoot整合系列】SpringBoot 實現(xiàn)大文件分片上傳、斷點續(xù)傳及秒傳的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網(wǎng)!