SpringBoot+Vue实现大文件分块上传

发布时间 2023-12-11 18:49:55作者: Xproer-松鼠

1. 项目背景
由于用户需求,需要上传大量图片,只能通过上传压缩包的形式上传,可是压缩包过大时,又会出现上传超时的情况,故需要将压缩包分块上传,然后解压缩图片、若图片过大则再对图片进行压缩。

2. 分块上传
分块上传我在用的时候发现有两种:第一种:分块合并接口全由后端接口生成;第二种:前端分块,后端上传
开始用的第一种,结果发现生成的文件没有内容,应该是向文件内写的方法有问题;所以又用的第二种;

2.1. 前端分页
js文件:

import http from './index'

export const white = (data) => http({
url: '/api/file/upload',
method: 'post',
data
})

页面:

<template>
<div>
<el-form ref="editForm" :model="formFileds">
<el-row :gutter="24">

<el-col :span="12">
<el-form-item>
<el-input></el-input>
</el-form-item>
</el-col>

<el-col :span="12">
<el-form-item>
<el-button type="primary">
查询
</el-button>

<el-button type="primary" @click="add()">
增加
</el-button>

</el-form-item>
</el-col>

</el-row>


</el-form>
<div>
<!-- <button type="button" v-on:click="selectFile()" class="btn btn-white btn-default btn-round">
<i class="ace-icon fa fa-upload"></i>{{ text }}
</button> -->
<input class="hidden" type="file" ref="file" v-on:change="uploadFile()" v-bind:id="inputId + '-input'">

</div>
<!--表格内容-->
<el-table ref="list" :data="tableData" style="width: 100%;margin-bottom: 5=1px;" border stripe highlight-current-row
:default-sort="{ prop: 'sx', order: 'sx' }">
<!-- v-for="item in tableData" v-bind:key="item.index" -->
<el-table-column fixed type="selection" width="45">
</el-table-column>
<el-table-column property="name" label="名称" align="center">
</el-table-column>
<!-- <el-form-item label="发布状态" prop="fbzt">
<el-switch ></el-switch>
</el-form-item> -->
<el-table-column property="fbzt" label="发布状态" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.fbzt" disabled></el-switch>
</template>
</el-table-column>
<el-table-column property="sx" label="顺序" align="center">
</el-table-column>
<el-table-column fixed="right" label="操作" align="center">
<template slot-scope="scope">
<!-- <el-button circle icon="el-icon-refresh" title="重置密码" type="success" size="small"></el-button>
<el-button circle icon="el-icon-phone" title="变更手机号" type="success" size="small"></el-button> -->
<el-button circle icon="el-icon-edit-outline" type="primary" @click="Compile()" title="编辑" size="small">
</el-button>
<el-button circle icon="el-icon-delete" type="danger" title="删除" size="small"
@click="rowDel(scope.row.id)"></el-button>
</template>
</el-table-column>
</el-table>
<!--分页-->
<el-pagination :page-sizes="[10, 20, 30, 40, 100]" :page-size="10" :total="100"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>

<Add v-if="isShowAddDialog" :isShowAddDialog="isShowAddDialog" @dialogClose="dialogClose"></Add>
<Compile v-if="isShowComDialog" :isShowComDialog="isShowComDialog" @dialogClose="dialogClose"></Compile>
</div>
</template>

<script>
import Add from "./auctionProcessAdd.vue";
import Compile from "./auctionProcesscomple.vue";
import { getAllJmxz,deleteJmxz } from '@/api/systemAdministrator/auctionProcess';
import {white} from '@/api/white'
import axios from "axios";

export default {
name: "auctionProcess",
components: {
Add,
Compile,
},
data() {
return {
editProps: '',
text: '',
inputId: '',
hex_md5: '',
formFileds: {
},
tableData: [
{
id: 0,
name: '',
fbzt: '',
sx: 0,
nr: "",
bz: '',
},
],
pageSize: 10,
total: 0,
currentPage: 1,
editProps: '',
isShowAddDialog:false,
isShowComDialog:false,

}
},
methods: {
/**
* 点击【上传】
*/
// selectFile () {
// let _this = this;
// $("#" + _this.inputId + "-input").trigger("click");
// },

/**
* 上传文件
*/
uploadFile () {
let _this = this;
// 1. 获取 input 中被选中的文件
let file = _this.$refs.file.files[0];


// 2. 生成文件标识,标识多次上传的是不是同一个文件
// let key = md5(file.name + file.size + file.type);
// let key10 = parseInt(key, 16);
// let key62 = Tool._10to62(key10);
let time = Date.now();
// console.log(time);
let fileName = file.name;
let temp = fileName.split('.');
let key62 = time + '`' + temp[0];
// console.log(key62);
// 判断文件格式 (非必选,根据实际情况选择是否需要限制文件上传类型)
// let suffixs = _this.suffixs;s

let suffix = fileName.substring(fileName.lastIndexOf(".") + 1, fileName.length).toLowerCase();
// if (!(!suffixs || JSON.stringify(suffixs) === "{}" || suffixs.length === 0)) {
// let validateSuffix = false;
// for (let s of suffixs) {
// if (s.toLocaleLowerCase() === suffix) {
// validateSuffix = true;
// break;
// }
// }
// if (!validateSuffix) {
// Toast.warning("文件格式不正确!只支持上传:" + suffixs.join(","));
// $("#" + _this0.inputId + "-input").val("");
// return;
// }
// }

// 3. 文件分片开始
// 3.1 设置与计算分片必选参数
let shardSize = 20 * 1024 * 1024; // 20M为一个分片
let shardIndex = 1; // 分片索引,1表示第1个分片
let size = file.size; // 文件的总大小
let shardTotal = Math.ceil(size / shardSize); // 总分片数

// 3.2 拼接将要传递到参数, use 非必选,这里用来标识文件用途。
let param = {
'shardIndex': shardIndex,
'shardSize': shardSize,
'shardTotal': shardTotal,
'use': _this.use,
'name': file.name,
'suffix': suffix,
'size': file.size,
'key': key62
};

// 3.3 传递分片参数,通过递归完成分片上传。
_this.upload(param);

},

/**
* 递归上传分片
*/
upload (param) {
let _this = this;
let shardIndex = param.shardIndex;
let shardTotal = param.shardTotal;
let shardSize = param.shardSize;
// 3.3.1 根据参数,获取文件分片
let fileShard = _this.getFileShard(shardIndex, shardSize);


// 3.3.2 将文件分片转为base64进行传输
let fileReader = new FileReader();
// 读取并转化 fileShard 为 base64
fileReader.readAsDataURL(fileShard);
// readAsDataURL 读取后的回调,
// 将 经过 base64 编码的 分片 整合到 param ,发送给后端,从而上传分片。
fileReader.onload = function (e) {
let base64 = e.target.result;
param.shard = base64;
console.log('shard',param.shard)
// Loading.show();
let params = new FormData();
params = param;
// params.append=(param);
// const params2 = JSON.stringify(params);
// console.log('params2',params2);
white(params).then((res) => {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!');
// console.log(params);
// axios.post('/api/file/upload', params).then((res)=> {
// Loading.hide();
let resp = res.data;
// 上传结果
// 当前分片索引小于 分片总数,继续执行分派,反之 则表示全部上传成功。
if (shardIndex < shardTotal) {
// 上传下一个分片
param.shardIndex = param.shardIndex + 1;
// this.$message.warning('正在上传');
_this.upload(param);
} else {
// 文件上传成功后的回调
// _this.afterUpload(resp);
return ;
// this.$message.success('上传成功');
}
// $('#' + _this.inputId + '-input').val('');
});
};
},
// axios.post('/api/file/upload',params).then((res)=>{
// // white(param).then((res) => {
// // _this.$ajax.post( "localhost:8080/admin/upload", param).then((res)=> {
// // Loading.hide();s
// let resp = res.data;
// // 上传结果
// // 当前分片索引小于 分片总数,继续执行分派,反之 则表示全部上传成功。
// if(shardIndex < shardTotal) {
// // 上传下一个分片
// param.shardIndex = param.shardIndex + 1;
// _this.upload(param);
// } else {
// // 文件上传成功后的回调
// _this.afterUpload(resp);
// }
// $("#" + _this.inputId + "-input").val("");
// });


/**
* 文件分片函数
*/
getFileShard (shardIndex, shardSize) {
let _this = this;
let file = _this.$refs.file.files[0];
let start = (shardIndex - 1) * shardSize; // 当前分片起始位置
let end = Math.min(file.size, start + shardSize); // 当前分片结束位置
let fileShard = file.slice(start, end); // 从文件中截取当前的分片数据
return fileShard;
},
rowEdit(row) {
if(row.fbzt == '已提交'){
row.fbzt = 'true';
}
else{
row.fbzt = 'false';
}

// //当前选中行
this.rowDataS = row;
console.log(1)
},
dialogClose() {
this.isShowEditDialog = false;
this.isShowAddDialog = false;
this.isShowComDialog = false;
},
handleEdit(id) {
this.$refs.editForm.validate(isValid => {

if (!isValid) return;

// 保存编辑后的数据
Object.assign(this.tableData[id], this.formFileds);
this.isShowEditDialog = false;

// 考虑到可能编辑了日期-需要重新排序
// ***注意:手动排序传参和表格的default-sort属性格式不太一样
this.$refs.list.sort('date', 'descending');

this.$message.success('编辑成功');
});
},
getList(){
this.param = 'pageno=' + this.currentPage + '&countonepage=' + this.pageSize;
getAllJmxz(this.param).then((res) => {
if(res){
console.log(res.data);
console.log(res.msg);
this.tableData = res.data.list;
this.total = res.data.total;
for(let item of this.tableData){
if(item.fbzt == '已提交'){
item.fbzt = true;
}
else{
item.fbzt = false;
}
}
}
})
},
add() {
console.log("add");
this.isShowAddDialog = true;
},
Compile() {
console.log("Compile");
this.isShowComDialog = true;
},

rowDel(id) {
// this.formFileds = row;
console.log(id);
this.$confirm('确定要删除当前行吗?', '删除', {
comfirmButtonText: '确定',
cancelButtonText: '取消'
}).then(() => {
deleteJmxz(id).then((res) => {
if(res){
console.log(res.data);
console.log(res.msg);
this.tableData.splice(id, 1);
this.$message.success('删除成功');
this.getList();
}
})
});
},
// 选中当前行-当前行的复选框被勾选
setCurRowChecked(row) {

this.$refs.list.clearSelection();
this.$refs.list.toggleRowSelection(row);
},
},
mounted(){
this.getList();
// console.log(this.tableData);
// console.log(new Date().toLocaleString());
}
}
</script>

<style scoped lang="less">
.el-form {
padding: 0 10px;
}

.el-date-editor {
width: 100% !important;
}
</style>


2.2. 后端合并
接收类:

package com.example.springboot.domain;

import lombok.Data;

import com.fasterxml.jackson.annotation.JsonFormat;
import java.util.Date;
@Data
public class FileDto {

/**
* id
*/
private String id;

/**
* 相对路径
*/
private String path;

/**
* 文件名
*/
private String name;

/**
* 后缀
*/
private String suffix;

/**
* 大小|字节B
*/
private Integer size;

/**
* 用途
*/
private String use;

/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;

/**
* 修改时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;

private Integer shardIndex;

private Integer shardSize;

private Integer shardTotal;

private String key;

/**
* base64
*/
private String shard;
}


Controller:

package com.example.springboot.controller;

 

import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.ZipUtil;
import com.example.springboot.common.Base64ToMultipartFile;
import com.example.springboot.common.FileViewer;
import com.example.springboot.common.ReturnResult;
import com.example.springboot.domain.FileDto;
import net.coobird.thumbnailator.Thumbnails;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;


import java.io.*;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@RequestMapping("/file")
@RestController
public class UploadController {

private static final Logger LOG = LoggerFactory.getLogger(UploadController.class);

private static final String storePath = "E:/usr/local/apps/pmxt-download/merge/";


@PostMapping("/upload")
public ReturnResult upload(@RequestBody FileDto fileDto) throws Exception {
Date date = new Date();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy/MM/dd/");
String format = simpleDateFormat.format(date);
LOG.info("日期信息:"+format);
String tem = storePath + format + fileDto.getName();
File f1 = new File(tem);
if (f1.exists()){
f1.delete();
}
LOG.info("上传文件开始");
String use = fileDto.getUse();
String key = fileDto.getKey();
String suffix = fileDto.getSuffix();
String shardBase64 = fileDto.getShard();
MultipartFile shard = Base64ToMultipartFile.base64ToMultipart(shardBase64);

// 保存文件到本地
String fullPath = storePath + format;
if (!new File(fullPath).exists()){
new File(fullPath).mkdirs();
}
String path = new StringBuffer(fullPath)
.append(key.split("`")[1])
.append(".")
.append(suffix)
.toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4
System.out.println(path);
String localPath = new StringBuffer(path)
.append(".")
.append(fileDto.getShardIndex())
.toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
System.out.println(localPath);
File dest = new File(localPath);
shard.transferTo(dest);
System.out.println(dest.getAbsolutePath());
LOG.info("保存文件记录开始");
fileDto.setPath(path);
// 合并分片
if (fileDto.getShardIndex().equals(fileDto.getShardTotal())) {
this.merge(fileDto);
}
// ResponseDto<Object> responseDto = new ResponseDto<>();
// responseDto.setContent("http://127.0.0.1:8080/f/"+ format + key + "-" + fileName);
// return responseDto;
return ReturnResult.buildSuccessResult("true");
}


public void merge(FileDto fileDto) throws Exception {
LOG.info("合并分片开始");
String path = fileDto.getPath(); //http://127.0.0.1:9000/file/f/course\6sfSqfOwzmik4A4icMYuUe.mp4
Integer shardTotal = fileDto.getShardTotal();
File newFile = new File(path);
FileOutputStream outputStream = new FileOutputStream(newFile, true);//文件追加写入
FileInputStream fileInputStream = null;//分片文件
byte[] byt = new byte[10 * 1024 * 1024];
int len;
try {
for (int i = 0; i < shardTotal; i++) {
// 读取第i个分片
fileInputStream = new FileInputStream(new File( path + "." + (i + 1))); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
while ((len = fileInputStream.read(byt)) != -1) {
outputStream.write(byt, 0, len);
}
}
} catch (IOException e) {
LOG.error("分片合并异常", e);
} finally {
try {
if (fileInputStream != null) {
fileInputStream.close();
}
outputStream.close();
LOG.info("IO流关闭");
} catch (Exception e) {
LOG.error("IO流关闭", e);
}
}
LOG.info("合并分片结束");

// 删除分片
LOG.info("删除分片开始");
File file = null;
String filePath = "";
for (int i = 0; i < shardTotal; i++) {
filePath = path + "." + (i + 1);
file = new File(filePath);
System.gc();
boolean result = file.delete();
LOG.info("删除{},{}", filePath, result ? "成功" : "失败");
}
LOG.info("删除分片结束");
}

@PostMapping("/unzip")
public ReturnResult unzip(String path){
return ReturnResult.buildSuccessResult(ZipUtil.unzip(path, "E:/usr/local/apps/pmxt-download/merge"));
}

@PostMapping("/selectBySize")
public ReturnResult selectBySize(String path) {
List<String> flist = FileViewer.getListFiles(path,true);
for (String s : flist) {
File file = new File(s);
if (file.isDirectory()) {
return ReturnResult.buildFailureResult("文件不存在");
} else {
if(file.length()>1048576*3){
ImgUtil.scale(
FileUtil.file(s),
FileUtil.file(s),
0.5f//缩放比例
);
// try {
// Thumbnails.of(s)
// .scale(0.5f)
// .outputQuality(0.5f)
// .toFile(s);
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
}
}
}
return ReturnResult.buildSuccessResult(true);

}


}


工具类:64位编码转文件

package com.example.springboot.common;

import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.Base64;

public class Base64ToMultipartFile implements MultipartFile {

private final byte[] imgContent;
private final String header;

public Base64ToMultipartFile(byte[] imgContent, String header) {
this.imgContent = imgContent;
this.header = header.split(";")[0];
}

@Override
public String getName() {
// TODO - implementation depends on your requirements
return System.currentTimeMillis() + Math.random() + "." + header.split("/")[1];
}

@Override
public String getOriginalFilename() {
// TODO - implementation depends on your requirements
return System.currentTimeMillis() + (int) Math.random() * 10000 + "." + header.split("/")[1];
}

@Override
public String getContentType() {
// TODO - implementation depends on your requirements
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);
}

public static MultipartFile base64ToMultipart(String base64) {
System.out.println(base64);
String[] baseStrs = base64.split(",");
// Encoder decoder = Base64.getEncoder();
Base64.Decoder decoder = Base64.getDecoder();
byte[] b;
b = decoder.decode(baseStrs[1]);

for(int i = 0; i < b.length; ++i) {
if (b[i] < 0) {
b[i] += 256;
}
}

return new Base64ToMultipartFile(b, baseStrs[0]);
}
}


返回类(若返回为字符串或者已有返回类,则这个类没用):

package com.example.springboot.common;

import lombok.Data;

@Data
public class ReturnResult {
// 状态码
private int code;
// 消息
private String msg;
// 数据
private Object data;

private ReturnResult(){

}

private static ReturnResult buildResult(int code, String msg, Object data) {
ReturnResult result = new ReturnResult();
result.code = code;
result.msg = msg;
result.data = data;
return result;
}

public static ReturnResult buildSuccessResult(String msg, Object data) {
return buildResult(200, msg, data);
}

public static ReturnResult buildSuccessResult(Object data) {
return buildSuccessResult("success", data);
}

public static ReturnResult buildFailureResult(int code, String msg, Object data) {
return buildResult(code, msg, data);
}

public static ReturnResult buildFailureResult(String msg, Object data) {
return buildFailureResult(500, msg, data);
}

public static ReturnResult buildFailureResult(String msg) {
return buildFailureResult(500, msg, null);
}
}



参考:https://windcoder.com/dawenjianfenpianshangchuanjavabanjiandanshixian

3. 压缩与解压缩
@PostMapping("/unzip")
public ReturnResult unzip(String path){
return ReturnResult.buildSuccessResult(ZipUtil.unzip(path, "E:/usr/local/apps/pmxt-download/merge"));
}

参考:http://hutool.cn/docs/index.html#/core/%E5%B7%A5%E5%85%B7%E7%B1%BB/%E5%8E%8B%E7%BC%A9%E5%B7%A5%E5%85%B7-ZipUtil

4. 图片压缩
这里我的实现主要有两种:Google图片处理工具与hutool工具,我都用了一下,发现前者将3700kb质量比例双压缩后为1295kb,后者仅是比例压缩就将3700kb压缩为了1100kb,但是前者对图片的处理方式更多,所以用那种,根据自身需求选择

文件夹处理工具类:

package com.example.springboot.common;

import java.io.File;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;

public class FileViewer {

public static List<String> getListFiles(String path, boolean isdepth) {
List<String> lstFileNames = new ArrayList<String>();
File file = new File(path);
return FileViewer.listFile(lstFileNames, file, "jpg", isdepth);
}

private static List<String> listFile(List<String> lstFileNames, File f,
String suffix, boolean isdepth) {
// 若是目录, 采用递归的方法遍历子目录
if (f.isDirectory()) {
File[] t = f.listFiles();
for (int i = 0; i < t.length; i++) {
if (isdepth || t[i].isFile()) {
listFile(lstFileNames, t[i], suffix, isdepth);
}
}
} else {
String filePath = f.getAbsolutePath();
if (!suffix.equals("")) {
int begIndex = filePath.lastIndexOf("."); // 最后一个.(即后缀名前面的.)的索引
String tempsuffix = "";
if (begIndex != -1) {
tempsuffix = filePath.substring(begIndex + 1,
filePath.length());
if (tempsuffix.equals(suffix) || tempsuffix.equals("bmp") || tempsuffix.equals("jpeg") || tempsuffix.equals("png") || tempsuffix.equals("gif")) {
lstFileNames.add(filePath);
}
}
} else {
lstFileNames.add(filePath);
}

}
return lstFileNames;
}

// 递归取得文件夹(包括子目录)中所有文件的大小
public static long getFileSize(File f) throws Exception// 取得文件夹大小
{
long size = 0;
File flist[] = f.listFiles();
for (int i = 0; i < flist.length; i++) {
if (flist[i].isDirectory()) {
size = size + getFileSize(flist[i]);
} else {
size = size + flist[i].length();
}
}
return size;
}

public static String FormetFileSize(long fileS) {// 转换文件大小
DecimalFormat df = new DecimalFormat("#.00");
String fileSizeString = "";
if (fileS < 1024) {
fileSizeString = df.format((double) fileS) + "B";
} else if (fileS < 1048576) {
fileSizeString = df.format((double) fileS / 1024) + "K";
} else if (fileS < 1073741824) {
fileSizeString = df.format((double) fileS / 1048576) + "M";
} else {
fileSizeString = df.format((double) fileS / 1073741824) + "G";
}
return fileSizeString;
}

}



遍历文件夹下所有图片,过大则进行压缩:

@PostMapping("/selectBySize")
public ReturnResult selectBySize(String path) {
List<String> flist = FileViewer.getListFiles(path,true);
for (String s : flist) {
File file = new File(s);
if (file.isDirectory()) {
return ReturnResult.buildFailureResult("文件不存在");
} else {
if(file.length()>1048576*3){
ImgUtil.scale(
FileUtil.file(s),
FileUtil.file(s),
0.5f//缩放比例
);
// try {
// Thumbnails.of(s)
// .scale(0.5f)
// .outputQuality(0.5f)
// .toFile(s);
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
}
}
}
return ReturnResult.buildSuccessResult(true);

}

 

参考文章:http://blog.ncmem.com/wordpress/2023/12/11/springbootvue%e5%ae%9e%e7%8e%b0%e5%a4%a7%e6%96%87%e4%bb%b6%e5%88%86%e5%9d%97%e4%b8%8a%e4%bc%a0/

欢迎入群一起讨论