总览
浏览器情况下:
- 针对大文件上传,一次性传输文件会出现网络中断等情况。不会传输成功。
- 把大文件切分成多个二进制流格式切片进行多次传输。
- 如果上传过程中,遇到已经传输完成了的文件或者切片就不进行上传,按照传输成功处理。
- 多次切片进行合并成一个完整的文件。
理解的方案对比
异常问题抛出与处理
- q、如何比对是否是同一个文件。
a、一个文件就是一堆的字节数据,字节相同,文件就是一样的。eg:现在网上下载软件的时候,针对下载的文件下方都有一个md5数据。所以直接对文件字节进行md5加密,如果md5相同,文件或者切片就是一份文件,可以直接拿来用。PS:不知道网上下载软件旁边的md5是不是这样生成的,还有为什么都是采用md5进行加密文件字节,理论上任何加密或者编码都可以用来处理字节加密和确定文件唯一性? - 服务端生成文件md5 VS 前端生成文件md5?
q、服务端生成文件md5:
a、文件需要经过前端通过http传递过去,服务端接收文件,找一个逆向md5加密库加密上传的整个文件,然后查找已有的文件的md5,判断上传的文件md5是否已经存在。如果存在,就不上传到文件服务器上,直接返回文件上传成功,录入一个文件引用等业务操作。这样就可以实现秒传。
优势: 因为文件处理大部分集中在服务端,前端除了上传文件的时间,别的就只需要等待结果。客户体验较好。
劣势:服务端集中了文件上传的一大部分时间,服务器性能会疯狂暴增。遇到一个相同的文件,已经传到服务端了,才发现传输的文件已经存在了,白白浪费网络与机房电脑资源。
q、前端生成文件md5。
a、文件的基本信息都由前端生成,服务端只需要处理数据。前端找一个md5库,生成文件的md5。上传之前,前端先校验文件md5,然后直接调用服务端提供的方法校验md5是否已经存在,存在就返回已有的文件信息,没有文件就开始上传。
优势:服务端基本只用处理前端信息校验+文件不存在的时候的上传。不会有文件已经存在,实现秒传之前, 把整个文件都给丢过去。
劣势:前端计算md5比较浪费内存,用户体验不太好。
两种方式针对切片同样生效。
具体选中那种,就看自己了~~~
下面采用第二种方式,前端生成md5(采用服务端生成md5也是一样的)
一次性文件上传
前端:引入js md5加密插件:spark-md5,ajax插件:axios。axios工具包就不粘贴了,随便写一下就能出来。
yarn add spark-md5 axios
前端代码如下:
<template>
<el-dialog :model-value="modelValue" title="文件上传" width="30%" :close-on-press-escape="false" :close-on-click-modal="false"
:show-close="false"
:lock-scroll="false">
<ul>
<li v-for="(item,index) in queue" :key="index">
<p>{{item.name}}</p>
<el-progress :percentage="item.progress">
<span>{{ item.message }}</span>
</el-progress>
</li>
</ul>
</el-dialog>
</template>
<script>
import { onBeforeMount, ref, toRefs } from 'vue'
import { get, postUploadProgress } from '@/utils/httpRequest'
import { Md5ExistUrl, uploadUrl } from '@/utils/api'
import SparkMD5 from 'spark-md5'
export default {
name: 'commonUpload',
setup (props, { emit }) {
const { files } = toRefs(props)
// 最终返回给应用的文件列表
const finalFiles = ref([])
const upload = async (list, index) => {
if (index + 1 <= list.length) {
// 定义文件读取
const fileReader = new FileReader()
const sparkLocal = new SparkMD5.ArrayBuffer()
let md5
list[index].message = '正在计算文件md5,请稍候...'
fileReader.readAsArrayBuffer(list[index].file)
fileReader.onload = async (e) => {
sparkLocal.append(e.target.result)
md5 = sparkLocal.end()
console.log(md5)
const result0 = await get(Md5ExistUrl, { md5 })
if (result0.code === 200) {
// 当前文件已经存在
if (result0.data) {
finalFiles.value.push(result0.data)
list[index].progress = 100
list[index].message = '完成'
await upload(list, index + 1)
} else {
// 当前文件不存在,开始上传
const formData = new FormData()
formData.append('file', list[index].file)
formData.append('md5', md5)
formData.append('generateUrl', false)
const progress = (e) => {
const complete = parseInt((e.loaded / e.total) * 100)
list[index].progress = complete
list[index].message = '正在传输数据到服务端'
if (list[index].progress === 100) {
list[index].message = '正在等待服务端处理'
}
console.log('上传信息--', e)
}
const result1 = await postUploadProgress(uploadUrl, formData, progress)
if (result1.code === 200) {
finalFiles.value.push(result1.data)
list[index].message = '完成'
await upload(list, index + 1)
} else {
list[index].message = result1.message
}
}
}
}
} else {
console.error('finalFiles--', finalFiles.value)
// 没有可用的文件进行上传了,马上通知应用
emit('fileUploadFinished', finalFiles.value)
emit('update:modelValue', false)
}
}
// 上传队列
const queue = ref([])
onBeforeMount(() => {
// 构造参数
Array.from(files.value).forEach(item => {
const tempFileItem = {}
Object.assign(tempFileItem, item)
tempFileItem.name = item.name
tempFileItem.file = item
tempFileItem.progress = 0
tempFileItem.message = '等待上传'
queue.value.push(tempFileItem)
})
// 开始上传
upload(queue.value, 0)
})
return {
queue
}
},
props: {
modelValue: {
type: Boolean,
default: false
},
// 待上传的文件列表
files: {
type: Array,
required: true,
default () {
return []
}
}
}
}
</script>
<style scoped>
</style>
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getKey } from '@/utils/storage'
import { logout } from '@/utils/util'
const instance = axios.create(
{
baseURL: process.env.VUE_APP_BASE_URL
}
)
/**
* 请求拦截器
*/
instance.interceptors.request.use(config => {
if (getKey('tokenName') && getKey('tokenValue')) {
config.headers = {
[getKey('tokenName')]: getKey('tokenValue')
}
}
return config
}, error => {
return error
})
/**
* 响应拦截器
*/
instance.interceptors.response.use(res => {
// 下载文件,如果请求头没有【content-disposition】 arraybuffer转化为json进行异常抛出
if (!res.headers['content-disposition']) {
if (res.data.byteLength) {
res.data = JSON.parse(new TextDecoder('utf-8').decode(res.data))
}
if (res.data.code === 406) {
ElMessageBox.alert(res.data.message, '登录过期提醒', {
confirmButtonText: '重新登录',
callback: () => {
logout()
}
})
} else if (res.data.code !== 200 && res.data.code !== 406) {
ElMessage.warning(res.data.message)
}
}
return res
}, error => {
console.log('响应拦截器异常', error)
if (error.response && error.response.status === 404) {
ElMessage.error('未找到服务端地址。')
} else {
ElMessage.error('与服务端连接异常。')
}
return error
})
/**
* 公用post请求
* @param url
* @param data
* @returns {Promise<unknown>}
*/
const post = (url, data) => {
return new Promise((resolve, reject) => {
instance.request({
url,
data,
method: 'post'
}).then(res => {
resolve(res.data)
}).catch(error => {
reject(error)
})
})
}
/**
* 公用get请求
* @param url
* @param params
* @returns {Promise<unknown>}
*/
const get = (url, params) => {
return new Promise((resolve, reject) => {
instance.request({
url,
params,
method: 'get'
}).then(res => {
resolve(res.data)
}).catch(error => {
reject(error)
})
})
}
/**
* post文件上传获取进度条
* @param url
* @param data
* @param progress 获取进度信息回调函数
* @returns {Promise<unknown>}
*/
const postUploadProgress = (url, data, progress) => {
return new Promise((resolve, reject) => {
instance.request({
url,
data,
method: 'post',
onUploadProgress (e) {
progress(e)
}
}).then(res => {
resolve(res.data)
}).catch(error => {
reject(error)
})
})
}
/**
* post请求获取字节流信息
* @param url
* @param data
* @returns {Promise<unknown>}
*/
const postDownloadFile = (url, data) => {
return new Promise((resolve, reject) => {
instance.request({
url,
data,
method: 'post',
responseType: 'arraybuffer'
}).then(res => {
resolve(res)
}).catch(error => {
reject(error)
})
})
}
/**
* get请求获取字节流信息
* @param url
* @param params
* @returns {Promise<unknown>}
*/
const getDownloadFile = (url, params) => {
return new Promise((resolve, reject) => {
instance.request({
url,
params,
method: 'get',
responseType: 'arraybuffer'
}).then(res => {
resolve(res)
}).catch(error => {
reject(error)
})
})
}
/**
* 公用get请求下载文件
* @param url
* @param params
* @returns {Promise<void>}
*/
const commonGetDownloadFile = async (url, params) => {
const result = await getDownloadFile(url, params)
if (!result.data.code) {
hrefDownloadFile(result)
}
}
/**
* 公用post请求下载文件
* @param url
* @param data
* @returns {Promise<void>}
*/
const commonPostDownloadFile = async (url, data) => {
const result = await postDownloadFile(url, data)
// 判断返回的是否是字节流而不是json数据
if (!result.data.code) {
hrefDownloadFile(result)
}
}
/**
* 字节流进行下载
* @param result
*/
const hrefDownloadFile = (result) => {
const filename = result.headers['content-disposition']
const blob = new Blob([result.data])
const downloadElement = document.createElement('a')
const href = window.URL.createObjectURL(blob)
downloadElement.href = href
downloadElement.download = decodeURIComponent(filename.split('fileName=')[1])
document.body.appendChild(downloadElement)
downloadElement.click()
document.body.removeChild(downloadElement)
window.URL.revokeObjectURL(href)
}
export {
post,
get,
postDownloadFile,
getDownloadFile,
commonGetDownloadFile,
commonPostDownloadFile,
postUploadProgress
}
文件服务器采用minio8.x。
docker-compose.yml安装minio配置文件
version: '3'
services:
miniocheck:
image: minio/minio
volumes:
- ./data:/data # 持久化地址
ports:
- "9000:9000" # 绑定端口
- "9001:9001"
container_name: minio_check
restart: always
environment:
MINIO_ROOT_USER: admin # 账号
MINIO_ROOT_PASSWORD: admin123456 #密码
MINIO_PROMETHEUS_AUTH_TYPE: public
command: server /data --address '0.0.0.0:9000' --console-address '0.0.0.0:9001'
服务端SpringBoot代码
校验文件md5
/**
* 获取文件的md5是否已经存在,用于秒传(PS:返回文件信息,则已经存在。返回null,不存在)
*
* @param md5
* @return
*/
@GetMapping("Md5Exist")
public BaseResponse Md5Exist(@RequestParam("md5") String md5) {
return BaseResponse.info(ResponseCodeEnum.Success, adminFileService.Md5Exist(md5));
}
public AdminFileEntity Md5Exist(String md5) {
AdminFileEntity info = adminFileMapper.selectOne(new LambdaQueryWrapper<AdminFileEntity>().eq(AdminFileEntity::getMd5, md5));
return info;
}
上传文件
/**
* 单文件上传
*
* @param multipartFile 文件 必须参数
* @param md5 文件md5
* @param generateUrl 是否生成url 非必要参数 默认false
* @return
*/
@SneakyThrows
@PostMapping(value = "upload")
public BaseResponse upload(@RequestParam("file") MultipartFile multipartFile, @RequestParam(value = "md5") String md5, @RequestParam(value = "generateUrl", defaultValue = "false", required = false) Boolean generateUrl) {
AdminFileEntity adminFileEntity = adminFileService.uploadFile(multipartFile, md5, generateUrl);
return BaseResponse.info(ResponseCodeEnum.Success, adminFileEntity);
}
/**
* 一次性上传文件
*
* @param multipartFile 文件
* @param md5 文件md5
* @param isGenerateUrl 是否生成get链接
* @return
*/
@SneakyThrows
public AdminFileEntity uploadFile(MultipartFile multipartFile, String md5,Boolean isGenerateUrl) {
AdminFileEntity adminFileEntity = Utils.generateFileInfo(multipartFile, bucketName);
// 文件上传
minioUtil.uploadStream(multipartFile.getInputStream(), bucketName, adminFileEntity.getFileName());
// 生成可以访问的连接
if (isGenerateUrl) {
String url = minioUtil.generateGetUrl(bucketName, adminFileEntity.getFileName(), 7);
String s = url.split("\\?")[0];
adminFileEntity.setUrl(s);
}
adminFileEntity.setMd5(md5);
// 录入数据
adminFileMapper.insert(adminFileEntity);
return adminFileEntity;
}
Utils类
package top.lilele.adminSystem.utils;
import cn.hutool.core.lang.tree.TreeNodeConfig;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import top.lilele.adminSystem.entity.AdminFileEntity;
import java.beans.Introspector;
import java.lang.invoke.SerializedLambda;
import java.lang.reflect.Method;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author LI.Lele
*/
@Slf4j
public class Utils {
/**
* 白名单
*/
public static String[] authWhiteList = {"/user/login"};
/**
* stream切断进行分页(主要处理连表查询,多次查询问题)
*
* @param list
* @param pageNum
* @param pageSize
* @return
*/
public static Page generatePage(List<Object> list, Integer pageNum, Integer pageSize) {
Page<Object> page = new Page(pageNum, pageSize, list.size());
List<Object> collect = list.stream().skip((pageNum - 1) * pageSize).limit(pageSize).collect(Collectors.toList());
page.setRecords(collect);
return page;
}
/**
* 构建树形节点配置
*
* @param key
* @param parentKey
* @param name
* @return
*/
public static TreeNodeConfig generateTreeNode(Object key, Object parentKey, Object name) {
TreeNodeConfig treeNodeConfig = new TreeNodeConfig();
treeNodeConfig.setIdKey(key.toString());
treeNodeConfig.setParentIdKey(parentKey.toString());
treeNodeConfig.setChildrenKey("children");
treeNodeConfig.setNameKey(name.toString());
return treeNodeConfig;
}
/**
* 生成唯一id
*
* @return
*/
public static String generateId() {
String s = IdUtil.fastSimpleUUID();
return s;
}
/**
* 生成完整文件信息
*
* @param multipartFile 文件
* @return
*/
public static AdminFileEntity generateFileInfo(MultipartFile multipartFile, String bucketName) {
// 文件名称
String filename = IdUtil.fastSimpleUUID();
log.info("minio fileName {}", filename);
// 构造文件实体
AdminFileEntity adminFileEntity = new AdminFileEntity();
adminFileEntity.setFileName(filename);
adminFileEntity.setFileSize(multipartFile.getSize());
adminFileEntity.setFileOriginName(multipartFile.getOriginalFilename());
adminFileEntity.setBucketName(bucketName);
adminFileEntity.setContentType(multipartFile.getContentType());
// 获取文件后缀名(按照.来切割,除了第一个. 全部都是后缀名)
String[] split = multipartFile.getOriginalFilename().split("\\.");
StringBuilder stringBuilder = new StringBuilder();
for (int i = 1; i < split.length; i++) {
stringBuilder.append(".");
stringBuilder.append(split[i]);
}
adminFileEntity.setFileExtension(stringBuilder.toString());
return adminFileEntity;
}
@SneakyThrows
public static <T> String getName(SerializableFunction<T, ?> fn) {
// 获取序列化writeReplace方法
Method method = fn.getClass().getDeclaredMethod("writeReplace");
// 获取private字段
boolean isAccessible = method.isAccessible();
method.setAccessible(Boolean.TRUE);
// 转化为lambda序列化
SerializedLambda serializedLambda = (SerializedLambda) method.invoke(fn);
method.setAccessible(isAccessible);
// 从lambda信息取出method、field、class等
String getterMethod = serializedLambda.getImplMethodName();
String fieldName = Introspector.decapitalize(getterMethod.replace("get", ""));
return fieldName;
}
}
minio工具类
package top.lilele.adminSystem.utils;
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.Item;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
/**
* minio工具类
*
* @author lilele
*/
@Component
@Slf4j
public class MinioUtil {
@Resource
private MinioClient minioClient;
/**
* 创建桶
*
* @param bucketName 桶名
*/
@SneakyThrows
public void makeBucket(String bucketName) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
/**
* 文件上传
*
* @param multipartFile 文件
* @param bucketName 桶名
* @param fileName 文件名
*/
@SneakyThrows
public void upload(MultipartFile multipartFile, String bucketName, String fileName) {
InputStream inputStream = multipartFile.getInputStream();
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.stream(inputStream, inputStream.available(), -1)
.contentType(multipartFile.getContentType())
.build()
);
}
/**
* 生成一个get请求可以访问的url
*
* @param bucketName 桶名
* @param fileName 对象名
* @param expiry 到期时长
* @return
*/
@SneakyThrows
public String generateGetUrl(String bucketName, String fileName, Integer expiry) {
String url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().bucket(bucketName).object(fileName).expiry(expiry).method(Method.GET).build());
return url;
}
/**
* 上传文件流到minio
*
* @param inputStream
* @param bucketName
* @param fileName
*/
@SneakyThrows
public void uploadStream(InputStream inputStream, String bucketName, String fileName) {
minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).
object(fileName).stream(inputStream, inputStream.available(), -1).build());
}
/**
* 返回文件流
*
* @param bucketName
* @param fileName
* @return
*/
@SneakyThrows
public GetObjectResponse getObjects(String bucketName, String fileName) {
GetObjectResponse object = minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(fileName).build());
return object;
}
/**
* 删除桶
*
* @param bucketName 桶名
*/
@SneakyThrows
public void removeBucket(String bucketName) {
minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
}
/**
* 删除对象
*
* @param bucket 桶名
* @param fileName 文件名
*/
@SneakyThrows
public void deleteObj(String bucket, String fileName) {
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucket).object(fileName).build());
}
/**
* 下载文件
*
* @param bucketName 桶名
* @param fileName 文件名
*/
@SneakyThrows
public GetObjectResponse download(String bucketName, String fileName) {
GetObjectArgs objectArgs = GetObjectArgs.builder().bucket(bucketName)
.object(fileName).build();
GetObjectResponse getObjectResponse = minioClient.getObject(objectArgs);
return getObjectResponse;
}
/**
* 列出桶下的所有对象
*
* @param bucketName 桶名
* @return
*/
@SneakyThrows
public List<Item> listObjects(String bucketName) {
Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).build());
List<Item> items = new ArrayList<>();
for (Result<Item> result : results) {
items.add(result.get());
}
return items;
}
}
minioConfig配置类
package top.lilele.adminSystem.config;
import io.minio.MinioClient;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.annotation.Resource;
/**
* @author lilele
*/
@Configuration
@EnableConfigurationProperties(MinioPropertiesConfig.class)
public class MinioConfig {
@Resource
private MinioPropertiesConfig minioPropertiesConfig;
/**
* 初始化 MinIO 客户端
*/
@Bean
public MinioClient minioClient() {
MinioClient minioClient = MinioClient.builder()
.endpoint(minioPropertiesConfig.getEndpoint())
.credentials(minioPropertiesConfig.getAccessKey(), minioPropertiesConfig.getSecretKey())
.build();
return minioClient;
}
}
sql脚本
-- `lee-admin`.admin_file definition
CREATE TABLE `admin_file` (
`id` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '主键id',
`fileName` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '文件名',
`fileOriginName` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件原始名',
`bucketName` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '桶名',
`fileSize` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '文件大小',
`fileExtension` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '文件扩展名',
`fileUrl` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件路径',
`md5` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '前端传递的文件md5',
`fileContentType` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型',
`createdTime` datetime DEFAULT NULL COMMENT '创建时间',
`updatedTime` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `admin_file_UN` (`md5`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='文件信息';
切片上传
前端代码
const partUpload = async (uploadId, file, chunkTotal, currentChunk, chunkSize, Spark) => {
if (currentChunk < chunkTotal) {
const sliceFile = file.slice(currentChunk * chunkSize, (currentChunk + 1) * chunkSize)
const fileReader = new FileReader()
const sparkLocal = new SparkMD5.ArrayBuffer()
let md5
fileReader.readAsArrayBuffer(sliceFile)
fileReader.onload = async (e) => {
sparkLocal.append(e.target.result)
Spark.append(e.target.result)
md5 = sparkLocal.end()
console.log(md5)
debugger
const formData = new FormData()
formData.append('file', sliceFile)
formData.append('generateUploadId', uploadId)
formData.append('chunkTotal', chunkTotal)
formData.append('currentChunk', currentChunk)
formData.append('md5', md5)
// 最后一片的时候,构造最终的数据
if (currentChunk + 1 === chunkTotal) {
formData.append('finalMd5', Spark.end())
formData.append('contentType', file.type)
formData.append('originFileName', file.name)
}
const result = await post(partUploadUrl, formData)
if (result.code === 200) {
await partUpload(uploadId, file, chunkTotal, currentChunk + 1, chunkSize, Spark)
}
}
} else {
console.log(1)
// const md5 = spark.end()
// console.log('md5', md5)
}
}
const sliceUpload = async (file) => {
// 文件总的md5
const Spark = new SparkMD5.ArrayBuffer()
const result0 = await post(generateUploadIdUrl, {})
let uploadId = null
if (result0.code === 200) {
uploadId = result0.data
}
console.log(file)
// 文件大小
const fileSize = file.size
// 切片大小 1K
const chunkSize = 1024 * 1024
// 切片总数
const chunkTotal = Math.ceil(fileSize / chunkSize)
console.log('切片总数', chunkTotal)
await partUpload(uploadId, file, chunkTotal, 0, chunkSize, Spark)
}
onBeforeMount(() => {
// 上传队列
const queue = ref([])
// 监控文件上传
emitter.on(BEGIN_FILE_UPLOAD, (payload) => {
show.value = true
queue.value.push(payload)
// sliceUpload(queue.value[0].file[0])
})
})
服务端代码
@PostMapping(value = "upload")
public BaseResponse upload(@RequestParam("generateUploadId") String generateUploadId,
@RequestParam("chunkTotal") Integer chunkTotal,
@RequestParam("md5") String md5,
@RequestParam("currentChunk") Integer currentChunk,
@RequestParam("file") MultipartFile multipartFile,
@RequestParam(value = "finalMd5", required = false) String finalMd5,
@RequestParam(value = "contentType",required = false) String contentType,
@RequestParam(value = "originFileName",required = false) String originFileName) {
adminFilePartService.upload(generateUploadId, chunkTotal, currentChunk, md5, multipartFile, finalMd5, contentType, originFileName);
return BaseResponse.info(ResponseCodeEnum.Success, null);
}
/**
* 切片上传
*
* @param generateUploadId 批次id
* @param chunkTotal 总片数
* @param currentChunk 当前片数
* @param md5 文件md5
* @param multipartFile 文件
*/
@SneakyThrows
public void upload(String generateUploadId, Integer chunkTotal, Integer currentChunk, String md5, MultipartFile multipartFile, String finalMd5, String contentType, String originFileName) {
// 构造切片信息实体
FilePartEntity filePartEntity = new FilePartEntity();
filePartEntity.setUploadId(generateUploadId);
filePartEntity.setCurrentChunk(currentChunk);
filePartEntity.setChunkTotal(chunkTotal);
// 获取是否已经有当前的切片信息了
AdminFilePartEntity info = adminFilePartMapper.selectOne(new LambdaQueryWrapper<AdminFilePartEntity>().eq(AdminFilePartEntity::getMd5, md5));
if (info != null) {
log.info("md5为--{}--已经有切片信息,直接默认秒传", md5);
filePartEntity.setFileId(info.getId());
filePartMapper.insert(filePartEntity);
return;
}
// minio文件名
String fileName = IdUtil.fastSimpleUUID();
// 上传文件
minioUtil.uploadStream(multipartFile.getInputStream(), fileName, filePartBucketName);
// 构造文件实体
AdminFilePartEntity adminFilePartEntity = new AdminFilePartEntity();
adminFilePartEntity.setFileSize(multipartFile.getSize());
adminFilePartEntity.setBucketName(filePartBucketName);
adminFilePartEntity.setMd5(md5);
adminFilePartEntity.setFileName(fileName);
// 录入切片数据
adminFilePartMapper.insert(adminFilePartEntity);
// 录入批次上传数据
filePartEntity.setFileId(adminFilePartEntity.getId());
filePartMapper.insert(filePartEntity);
//当在文件最后一个元素的时候,合并文件
if (currentChunk + 1 == chunkTotal) {
// 最终文件名
Map map = mergePartAndUpload(generateUploadId);
String finalFileName = map.get("fileName").toString();
Integer finalFileSize = (Integer) map.get("fileSize");
// 构造最终文件数据
AdminFileEntity adminFileEntity = new AdminFileEntity();
// 获取文件后缀名(按照.来切割,除了第一个. 全部都是后缀名)
String[] split = originFileName.split("\\.");
StringBuilder stringBuilder = new StringBuilder();
for (int i = 1; i < split.length; i++) {
stringBuilder.append(".");
stringBuilder.append(split[i]);
}
adminFileEntity.setFileExtension(stringBuilder.toString());
adminFileEntity.setFileName(finalFileName);
adminFileEntity.setBucketName(bucketName);
adminFileEntity.setContentType(contentType);
adminFileEntity.setFileSize(Long.valueOf(finalFileSize));
adminFileEntity.setMd5(finalMd5);
adminFileEntity.setFileOriginName(originFileName);
adminFileMapper.insert(adminFileEntity);
}
}
@SneakyThrows
public Map mergePartAndUpload(String generateUploadId) {
// 获取当前批次上传的切片列表
List<FilePartDao> list = filePartMapper.selectUploadedFilePart(generateUploadId);
// 从minio下载的对象输入流集合
List<InputStream> listInput = new ArrayList<>();
list.stream().forEach(item -> {
GetObjectResponse response;
try {
response = minioUtil.download(item.getFileName(), item.getBucketName());
byte[] buf = new byte[1024];
int len;
try (FastByteArrayOutputStream os = new FastByteArrayOutputStream()) {
while ((len = response.read(buf)) != -1) {
os.write(buf, 0, len);
}
os.flush();
byte[] bytes = os.toByteArray();
ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
listInput.add(inputStream);
}
} catch (Exception e) {
e.printStackTrace();
}
});
// 定义内存输出流
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// 定义缓冲字节数组
byte[] array = new byte[1024];
// 读取长度
int len = 0;
for (InputStream ips : listInput) {
while ((len = ips.read(array)) > 0) {
byteArrayOutputStream.write(array, 0, len);
}
}
// 合并文件流
InputStream inputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
// 创建文件
String fileName = IdUtil.fastSimpleUUID();
log.info("最终生成的文件名 {},文件大小{}", fileName, inputStream.available());
Map map = new HashMap<String, Object>(Maps.newHashMapWithExpectedSize(7));
map.put("fileName", fileName);
map.put("fileSize", inputStream.available());
// 上传合并之后的字节流
minioUtil.uploadStream(inputStream, fileName, bucketName);
// 删除切片上传数据
filePartMapper.delete(new LambdaQueryWrapper<FilePartEntity>().eq(FilePartEntity::getUploadId, generateUploadId));
return map;
}
分片或者文件从minio拉回来的流,合并用的基础inputStream。可以使用springboot集成的工具包
import com.google.common.io.ByteStreams;
ByteStreams.copy(response, os);
额外话题
没事就周末抽时间写一个类似OA系统的桌面应用软件(外壳+网页形式),多多少少了写了一点东西。
规划内容:
第一个版本:单体应用分布式到多台机器。
技术选型:vue、springboot、sa-token、websocket、electron(javaFX)等。
nginx集群服务:lvs+keepalive。
mysql主从复制。
已实现内容:
0、角色管理。
1、基本的权限校验,动态菜单、动态按钮权限校验。
2、websocket连接与拦截器身份认证校验。
3、部门管理。
4、文件上传。
5、通知公告
6、角色、资源管理。
7、lvs+keepive搭建。
8、mysql主从复制。
…
前端地址:https://gitee.com/lilele01/lee-admin-desktop.git
服务端地址:https://gitee.com/lilele01/lee-admin-java.git