分片上傳本質(zhì)就是在前端把一個完整的文件拆分成若干份文件上傳,上傳完成后,服務(wù)器再把上傳的若干份文件合并成一個完整的文件,再刪除若干份分片文件。
首先導(dǎo)包
<dependencies>
<!-- 基本依賴 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- mapper -->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
<version>2.1.5</version>
</dependency>
<!-- fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.79</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
</dependency>
</dependencies>
application.yml
配置:
server:
port: 8080
servlet:
context-path: /test
tomcat:
max-http-form-post-size: -1
spring:
datasource:
name: DS #如果存在多個數(shù)據(jù)源萌京,監(jiān)控的時候可以通過名字來區(qū)分開來。如果沒有配置宏浩,將會生成一個名字知残,格式是:"DataSource-" + System.identityHashCode(this)
type: com.zaxxer.hikari.HikariDataSource
url: jdbc:mysql://127.0.0.1:3306/upload?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&useSSL=false&serverTimezone=GMT
#hikari相關(guān)配置
hikari:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123456
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
# mybatis配置
mybatis:
type-aliases-package: com.upload.pojo
# mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
cache-enabled: true #使全局的映射器啟用或禁用緩存。
lazy-loading-enabled: true #全局啟用或禁用延遲加載比庄。當(dāng)禁用時求妹,所有關(guān)聯(lián)對象都會即時加載。
aggressive-lazy-loading: true #當(dāng)啟用時佳窑,有延遲加載屬性的對象在被調(diào)用時將會完全加載任意屬性屹逛。否則腰涧,每種屬性將會按需要加載盼理。
jdbc-type-for-null: null #設(shè)置但JDBC類型為空時,某些驅(qū)動程序 要指定值,default:OTHER竿报,插入空值時不需要指定類型
logging:
level:
com.upload.dao: debug
再數(shù)據(jù)庫建一張表 file
CREATE TABLE `file` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`path` varchar(255) NOT NULL DEFAULT '' COMMENT '文件路徑',
`name` varchar(255) NOT NULL DEFAULT '' COMMENT '文件名稱',
`size` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '文件大小',
`suffix` varchar(10) NOT NULL DEFAULT '' COMMENT '后綴',
`type` varchar(10) NOT NULL DEFAULT '' COMMENT '文件類型',
`share_total` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '文件分片總數(shù)',
`share_index` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '已上傳分片索引,默認(rèn)0',
`key` varchar(32) NOT NULL DEFAULT '' COMMENT '文件唯一Key',
`create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `key` (`key`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='文件分片上傳表';
創(chuàng)建對應(yīng)實體類 FilePojo
:
package com.upload.pojo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
/**
* @author xiaochi
* @date 2022/3/14 22:52
* @desc FilePojo
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "file")
public class FilePojo implements Serializable {
private static final long serialVersionUID = -6334172193008858856L;
@Id
private Integer id;
private String path;
private String name;
private Long size;
private String suffix;
@Column(name = "`type`")
private String type;
private Integer shareTotal;
private Integer shareIndex;
@Column(name = "`key`")
private String key;
private Date createTime;
private Date updateTime;
}
接著再創(chuàng)建一個接收前端參數(shù)的Vo文件 FileVo
package com.upload.vo;
import com.upload.pojo.FilePojo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.web.multipart.MultipartFile;
/**
* @author xiaochi
* @date 2022/3/15 8:38
* @desc FileVo
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class FileVo extends FilePojo {
private static final long serialVersionUID = -4528742454491886780L;
private MultipartFile file;
}
FileVo
擁有 FilePojo
的所有屬性。接著創(chuàng)建一個 FileDao
文件
/**
* @author xiaochi
* @date 2022/3/14 22:56
* @desc FileDao
*/
public interface FileDao extends Mapper<FilePojo>, MySqlMapper<FilePojo> {
}
接著創(chuàng)建控制器 UploadController
package com.upload.controller;
import com.upload.common.R;
import com.upload.dao.FileDao;
import com.upload.pojo.FilePojo;
import com.upload.vo.FileVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import tk.mybatis.mapper.entity.Example;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @author xiaochi
* @date 2022/3/14 22:08
* @desc UploadController
*/
@Slf4j
@CrossOrigin("*")
@RestController
@RequestMapping("/file")
public class UploadController {
private static final String FILE_PATH = "d:/upload/";
@Autowired
private FileDao fileDao;
/**
* 根據(jù)文件唯一key判斷是否有上傳
* @param key
* @return
*/
@GetMapping("/check/{key}")
public R<FilePojo> check(@PathVariable String key){
return R.ok(findByKey(key));
}
/**
* 分片上傳(表單接收)
* @param fileVo
* @return
* @throws Exception
*/
@PostMapping(value = "/upload")
// public R<String> upload(@RequestBody @RequestParam("file") MultipartFile file) throws IOException {
public R<String> upload(FileVo fileVo) throws Exception {// 表單接收
MultipartFile file = fileVo.getFile();
String filename = file.getOriginalFilename();
String date = new SimpleDateFormat("yyyy/MM/dd").format(new Date());
String localPath = new StringBuilder()
.append(date)
.append(File.separator)
.append(fileVo.getName())
.append(".")
.append(fileVo.getShareIndex())
.toString();// 分片文件路徑與后綴處理 2022/03/15\13-提交Git倉庫.mp4.0 、2022/03/15\13-提交Git倉庫.mp4.1鹃唯、2022/03/15\13-提交Git倉庫.mp4.2 .....
fileVo.setPath(localPath);
File dest = new File(FILE_PATH + localPath);
if (!dest.getParentFile().exists()){
dest.getParentFile().setWritable(true);
dest.getParentFile().mkdirs();// 不加 getParentFile() 創(chuàng)建的是文件夾爱榕,不是文件
}
file.transferTo(dest);
FilePojo filePojo = new FilePojo();
BeanUtils.copyProperties(fileVo,filePojo);
String path = new StringBuilder()
.append(date)
.append(File.separator)
.append(fileVo.getName())
.toString();// 數(shù)據(jù)庫保存的最后完整文件的路徑與名稱, 2022/03/15\13-提交Git倉庫.mp4
filePojo.setPath(path);
// 查詢之前是否有過上傳
Example example = new Example(FilePojo.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("key",filePojo.getKey());
FilePojo filePojoDb = fileDao.selectOneByExample(example);
if (filePojoDb == null){
fileDao.insertSelective(filePojo);
}else {
fileDao.updateByExampleSelective(filePojo,example);
}
// 判斷是否上傳玩最后一個分片文件俯渤,然后進(jìn)行合并完整文件并刪除所有分片文件
if (fileVo.getShareIndex().equals(fileVo.getShareTotal()-1)){
this.merge(filePojo);
}
return R.ok(path);
}
/**
* 合并所有分片文件成功后并刪除所有分片文件
* @param filePojo
* @throws Exception
*/
private void merge(FilePojo filePojo) throws Exception {
String path = FILE_PATH + filePojo.getPath();
//FileOutputStream outputStream = new FileOutputStream(new File(path), true);// true表示可追加
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File(path), true));
//FileInputStream inputStream = null;
BufferedInputStream bis = null;
byte[] byt = new byte[10* 1024 * 1024];
int len;
try {
for (int i = 0,leng = filePojo.getShareTotal(); i < leng; i++) {
// 從第i個分片讀取
/*inputStream = new FileInputStream(new File(path + "." + i));//
while ((len = inputStream.read(byt)) != -1){
outputStream.write(byt,0,len);
}*/
bis = new BufferedInputStream(new FileInputStream(new File(path + "." + i)));
while ((len = bis.read(byt))!= -1){
bos.write(byt,0,len);
}
}
}catch (IOException e){
log.error("分片合并異常", e);
}finally {
/*try {
if (inputStream != null){
inputStream.close();
}
outputStream.close();
}catch (Exception e){
log.error("IO流關(guān)閉異常", e);
}*/
bos.flush();
bos.close();
if (bis != null){
bis.close();
}
}
// 刪除分片文件
System.gc();
Thread.sleep(100);
for (int i = 0,leng = filePojo.getShareTotal(); i < leng; i++) {
String localPath = path + "." + i;
File file = new File(localPath);
if (file.exists()){
boolean result = file.delete();
log.info("刪除分片文件{}呆细,{}", localPath, result ? "成功" : "失敗");
}
}
}
/**
* 根據(jù)文件唯一key查詢
* @param key
* @return
*/
private FilePojo findByKey(String key){
Example example = new Example(FilePojo.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("key",key);
return fileDao.selectOneByExample(example);
}
}
然后前端對應(yīng)的請求代碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div>
上傳:
<input type="file" id="file">
</div>
<button id="btn">點擊上傳</button>
<script src="https://cdn.bootcdn.net/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script>
$(function () {
$("#btn").click(function () {
var file = $("#file").prop("files")[0];
if (file){
var size = file.size;
var shareSize = 10 * 1024 * 1024;// 默認(rèn)分片大小 20M
var shareIndex = 0;// 默認(rèn)從第0片開始
var shareTotal = Math.ceil(size / shareSize);// 計算總分片
var key = md5(file.name+file.type+file.size);
var params = {
filename:file.name,
size:size,
suffix:file.name.substring(file.name.lastIndexOf(".")+1),
type:file.type,
shareTotal:shareTotal,
shareIndex:shareIndex,
key:key,
};
// 先查詢是否有過上傳,或 上傳到哪一個的索引
$.ajax({
type: "GET", // 數(shù)據(jù)提交類型
url: "http://localhost:8080/test/file/check/"+key, // 發(fā)送地址
dataType:"json",
success:function (res) {
if (res && res.data){
// 存在說明之前上傳過八匠,接著判斷是否之前上傳完成
if (res.data.shareIndex === (res.data.shareTotal - 1)){
// 相等說之前已經(jīng)上傳完整
alert("極速秒傳成功");
}else{
// 不相等說明之前上傳中斷了,接著再上傳
upload(params,file,res.data.shareIndex,shareSize);
}
}else{
// 沒有穿過就從第0個分片開始上傳
upload(params,file,shareIndex,shareSize);
}
}
});
}
})
});
/**
* 上傳
* @param params
* @param file
* @param shareIndex
* @param shareSize
*/
function upload(params,file,shareIndex,shareSize) {
var start = shareIndex * shareSize;// 分片起始位置
var end = Math.min(params.size,start+shareSize);
var fileShare = file.slice(start,end);// 截取file文件分片趴酣,進(jìn)行上傳
var formData = new FormData();
formData.append("file",fileShare);
formData.append("name",params.filename);
formData.append("size",params.size);
formData.append("suffix",params.suffix);
formData.append("type",params.type);
formData.append("shareTotal",params.shareTotal);
formData.append("shareIndex",shareIndex);
formData.append("key",params.key);
$.ajax({
type: "POST", // 數(shù)據(jù)提交類型
url: "http://localhost:8080/test/file/upload", // 發(fā)送地址
dataType:"json",
data: formData, //發(fā)送數(shù)據(jù)
// async: true, // 是否異步
processData: false,
contentType: false, // 注意:此處并不是json傳輸數(shù)據(jù)梨树,而是表單
success:function (res) {
if (shareIndex === (params.shareTotal - 1)){
// 分片文件已上傳完
return;
}else{
// 遞歸上傳分片文件
upload(params,file,shareIndex+1,shareSize);
}
}
})
}
</script>
</body>
</html>
到此完成,但是這里后臺接口沒有做文件完整性驗證岖寞,如要進(jìn)行完整性驗證抡四,請看下面
文件完整性驗證上傳
要進(jìn)行文件完整性驗證就不能直接 MultipartFile
接收了,要在前端吧文件轉(zhuǎn)成 Base64 進(jìn)行上傳了仗谆,然后后臺接收后解析成 MultipartFile
首先指巡,FileVo
文件要修改下
package com.upload.vo;
import com.upload.pojo.FilePojo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.web.multipart.MultipartFile;
/**
* @author xiaochi
* @date 2022/3/15 8:38
* @desc FileVo
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class FileVo extends FilePojo {
private static final long serialVersionUID = -4528742454491886780L;
private String file;// base64文件字符串
private String encryFile;// 前端進(jìn)行md5加密后的符
}
接著新建一個文件 Base64DecodeMultipartFile
用來將接收到的Base64字符串轉(zhuǎn)成 MultipartFile
package com.upload.util;
import org.springframework.web.multipart.MultipartFile;
import sun.misc.BASE64Decoder;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
/** base64轉(zhuǎn)為multipartFile
* @author xiaochi
* @date 2022/4/11 15:30
* @desc Base64DecodeMultipartFile
*/
public class Base64DecodeMultipartFile implements MultipartFile {
private final byte[] imgContent;
private final String header;
public Base64DecodeMultipartFile(byte[] imgContent, String header) {
this.imgContent = imgContent;
this.header = header.split(";")[0];
}
@Override
public String getName() {
return System.currentTimeMillis() + Math.random() + "." + header.split("/")[1];
}
@Override
public String getOriginalFilename() {
return System.currentTimeMillis() + (int) Math.random() * 10000 + "." + header.split("/")[1];
}
@Override
public String getContentType() {
return header.split(":")[1];
}
@Override
public boolean isEmpty() {
return imgContent == null || imgContent.length == 0;
}
@Override
public long getSize() {
return imgContent.length;
}
@Override
public byte[] getBytes() throws IOException {
return imgContent;
}
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(imgContent);
}
@Override
public void transferTo(File dest) throws IOException, IllegalStateException {
new FileOutputStream(dest).write(imgContent);
}
/**
* * base64轉(zhuǎn)multipartFile
* * @param base64
* * @return
*/
public static MultipartFile base64Convert(String base64) {
String[] baseStrs = base64.split(",");
BASE64Decoder decoder = new BASE64Decoder();
byte[] b = new byte[0];
try {
b = decoder.decodeBuffer(baseStrs[1]);
} catch (IOException e) {
e.printStackTrace();
}
for (int i = 0; i < b.length; ++i) {
if (b[i] < 0) {
b[i] += 256;
}
}
return new Base64DecodeMultipartFile(b, baseStrs[0]);
}
}
接著修改上傳文件的接口控制器方法 UploadController
的上傳方法 upload
/**
* 分片上傳(表單接收),且進(jìn)行文件完整性驗證
* @param fileVo
* @return
* @throws Exception
*/
@PostMapping("/upload")
public R<String> upload(FileVo fileVo) throws Exception {
MultipartFile file = Base64DecodeMultipartFile.base64Convert(fileVo.getFile());// 將 Base64 字符串解析成 MultipartFile
// 驗證文件完整性
if (!Objects.equals(fileVo.getEncryFile(),DigestUtils.md5DigestAsHex(fileVo.getFile().getBytes()))){
return R.error("上傳文件已被損壞");
}
String date = new SimpleDateFormat("yyyy/MM/dd").format(new Date());
String localPath = new StringBuilder()
.append(date)
.append(File.separator)
.append(fileVo.getName())
.append(".")
.append(fileVo.getShareIndex())
.toString();// 分片文件路徑與后綴處理 2022/03/15\13-提交Git倉庫.mp4.0 隶垮、2022/03/15\13-提交Git倉庫.mp4.1藻雪、2022/03/15\13-提交Git倉庫.mp4.2 .....
fileVo.setPath(localPath);
File dest = new File(FILE_PATH + localPath);
if (!dest.getParentFile().exists()){
dest.getParentFile().setWritable(true);
dest.getParentFile().mkdirs();// 不加 getParentFile() 創(chuàng)建的是文件夾,不是文件
}
file.transferTo(dest);
FilePojo filePojo = new FilePojo();
BeanUtils.copyProperties(fileVo,filePojo);
String path = new StringBuilder()
.append(date)
.append(File.separator)
.append(fileVo.getName())
.toString();// 數(shù)據(jù)庫保存的最后完整文件的路徑與名稱狸吞, 2022/03/15\13-提交Git倉庫.mp4
filePojo.setPath(path);
// 查詢之前是否有過上傳
Example example = new Example(FilePojo.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("key",filePojo.getKey());
FilePojo filePojoDb = fileDao.selectOneByExample(example);
if (filePojoDb == null){
fileDao.insertSelective(filePojo);
}else {
fileDao.updateByExampleSelective(filePojo,example);
}
// 判斷是否上傳玩最后一個分片文件勉耀,然后進(jìn)行合并完整文件并刪除所有分片文件
if (fileVo.getShareIndex().equals(fileVo.getShareTotal()-1)){
this.merge(filePojo);
}
return R.ok(path);
}
接著修改對應(yīng)的前端代碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div>
上傳:
<input type="file" id="file">
</div>
<button id="btn">點擊上傳</button>
<script src="https://cdn.bootcdn.net/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script>
$(function () {
$("#btn").click(function () {
var file = $("#file").prop("files")[0];
if (file){
var size = file.size;
var shareSize = 10 * 1024 * 1024;// 默認(rèn)分片大小 20M
var shareIndex = 0;// 默認(rèn)從第0片開始
var shareTotal = Math.ceil(size / shareSize);// 計算總分片
var key = md5(file.name+file.type+file.size);
var params = {
filename:file.name,
size:size,
suffix:file.name.substring(file.name.lastIndexOf(".")+1),
type:file.type,
shareTotal:shareTotal,
shareIndex:shareIndex,
key:key,
};
// 先查詢是否有過上傳,或 上傳到哪一個的索引
$.ajax({
type: "GET", // 數(shù)據(jù)提交類型
url: "http://localhost:8080/test/file/check/"+key, // 發(fā)送地址
dataType:"json",
success:function (res) {
if (res && res.data){
// 存在說明之前上傳過蹋偏,接著判斷是否之前上傳完成
if (res.data.shareIndex === (res.data.shareTotal - 1)){
// 相等說之前已經(jīng)上傳完整
alert("極速秒傳成功");
}else{
// 不相等說明之前上傳中斷了便斥,接著再上傳
upload(params,file,res.data.shareIndex,shareSize);
}
}else{
// 沒有穿過就從第0個分片開始上傳
upload(params,file,shareIndex,shareSize);
}
}
});
}
})
});
/**
* 上傳
* @param params
* @param file
* @param shareIndex
* @param shareSize
*/
function upload(params,file,shareIndex,shareSize) {
var start = shareIndex * shareSize;// 分片起始位置
var end = Math.min(params.size,start+shareSize);
var fileShare = file.slice(start,end);// 截取file文件分片,進(jìn)行上傳
// base64 上傳且進(jìn)行文件完整性驗證
fileToBase64(fileShare, function(base64){
var formData = new FormData();
formData.append("file",base64);
formData.append("encryFile",md5(base64));// 用于驗證文件完整性
formData.append("name",params.filename);
formData.append("size",params.size);
formData.append("suffix",params.suffix);
formData.append("type",params.type);
formData.append("shareTotal",params.shareTotal);
formData.append("shareIndex",shareIndex);
formData.append("key",params.key);
$.ajax({
type: "POST", // 數(shù)據(jù)提交類型
url: "http://localhost:8080/test/file/upload", // 發(fā)送地址
dataType:"json",
data: formData, //發(fā)送數(shù)據(jù)
// async: true, // 是否異步
processData: false,
contentType: false, // 注意:此處并不是json傳輸數(shù)據(jù)威始,而是表單
success:function (res) {
// res.code = 1枢纠,表示上傳失敗
if (res.code == 1 || shareIndex === (params.shareTotal - 1)){
// 分片文件已上傳完
return;
}else{
// 遞歸上傳分片文件
upload(params,file,shareIndex+1,shareSize);
}
}
})
})
}
/**
* File 轉(zhuǎn) Base64 圖片
*/
function fileToBase64(file, callback){
const reader = new FileReader()
reader.onload = function(evt){
if(typeof callback === 'function') {
callback(evt.target.result)
} else {
console.log("我是base64:", evt.target.result);
}
}
/* readAsDataURL 方法會讀取指定的 Blob 或 File 對象
** 讀取操作完成的時候,會觸發(fā) onload 事件
* result 屬性將包含一個data:URL格式的字符串(base64編碼)以表示所讀取文件的內(nèi)容黎棠。
*/
reader.readAsDataURL(file);
}
</script>
</body>
</html>
然后運行上傳完整晋渺,ok,到此結(jié)束葫掉。