Springboot+vue3 MinIO文件前端直传例子
代码基于Springboot (maven) + vue3 (element-plus)
使用MinIO既可以提交给后端上传也可以前端直接上传,如果文件是从前端提交最好还是前端传比较好,通过MinIO生成签名URL能保证安全,前端直传省去后端中转也能提高传输效率。
以下例子后端部分仅支持了生成签名url提供前端直传的机制,无后端上传功能,若需要请参考其他文章。
支持单文件一次性上传、大文件分片上传。
后端提供了3个接口:
1、获取单文件上传签名URL
2、初始化分片上传并获取签名分片URLs
3、合并分片为整体文件


一、后端
maven pom.xml
<!-- MinIO SDK --> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.4.3</version> </dependency>
MinioConfig.java
package com.seentao.vet.cbec.common.minio.config; import lombok.Data; import org.springframework.context.annotation.Configuration; import org.springframework.beans.factory.annotation.Value; /** * @title: MinioConfig * @Author chenjye * @Date: 2025/8/9 上午9:27 * @Version 1.0 */ @Data @Configuration public class MinioConfig { @Value("${oss.type.default}") private String ossTypeDefault; @Value("${s3.oss.endpoint}") private String endpoint; @Value("${s3.oss.accessKeyId}") private String accessKeyId; @Value("${s3.oss.secretAccessKey}") private String secretAccessKey; @Value("${s3.oss.bucketName}") private String bucketName; }
MinioController.java
package com.seentao.vet.cbec.common.minio.controller; import com.chanjet.edu.course.dto.ResponseInfo; import com.seentao.vet.cbec.common.minio.util.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import javax.validation.Valid; import java.util.HashMap; import java.util.Map; /** * @title: MinioController * @Author chenjye * @Date: 2025/8/9 上午9:27 * @Version 1.0 */ @RestController public class MinioController { @Autowired private MinioUtil minioUtil; /** * 生成单文件上传签名url */ @RequestMapping (value = "/single/init", method = RequestMethod.POST) public ResponseInfo initSingleUpload(@RequestBody @Valid SingleUploadParams singleUploadParams) throws Exception { String object = singleUploadParams.getObject(); String contentType = singleUploadParams.getContentType(); UploadUrlsVO urlsVO; // 单文件上传,生成文件上传签名url urlsVO = minioUtil.getUploadObjectUrl(contentType, object); Map<String, Object> result = new HashMap<>(); result.put("urlsVO", urlsVO); return new ResponseInfo(result); } /** * 初始化文件分片地址及相关数据 */ @RequestMapping (value = "/multipart/init", method = RequestMethod.POST) public ResponseInfo initMultiPartUpload(@RequestBody @Valid MultipartUploadParams multipartUploadParams) throws Exception { String object = multipartUploadParams.getObject(); // String originFileName = fileUploadParams.getOriginFileName(); // String suffix = FileUtil.extName(originFileName); // String fileName = FileUtil.mainName(originFileName); // 对文件重命名,并以年月日文件夹格式存储 // String nestFile = DateUtil.format(LocalDateTime.now(), "yyyy/MM/dd"); // String object = nestFile + "/" + originFileName; UploadUrlsVO urlsVO; // 单文件上传,生成文件上传签名url if (multipartUploadParams.getChunkCount() == 1) { urlsVO = minioUtil.getUploadObjectUrl(multipartUploadParams.getContentType(), object); } // 分片上传,生成uploadId及文件上传签名url else { urlsVO = minioUtil.initMultiPartUpload(multipartUploadParams, object); } Map<String, Object> result = new HashMap<>(); result.put("urlsVO", urlsVO); return new ResponseInfo(result); } /** * 文件合并(单文件不会合并,仅信息入库) */ @RequestMapping (value = "/multipart/merge", method = RequestMethod.POST) public ResponseInfo mergeMultipartUpload(@RequestBody @Valid MultipartMergeParams multipartMergeParams) { if (multipartMergeParams.getChunkCount() == 1) { return new ResponseInfo(true, "单文件不进行合并", null); } else { minioUtil.mergeMultipartUpload(multipartMergeParams.getObject(), multipartMergeParams.getUploadId()); return new ResponseInfo(true, "合并完成", null); } } }
CustomMinioClient.java
package com.seentao.vet.cbec.common.minio.util; import com.google.common.collect.Multimap; import io.minio.CreateMultipartUploadResponse; import io.minio.ListPartsResponse; import io.minio.MinioAsyncClient; import io.minio.ObjectWriteResponse; import io.minio.messages.Part; /** * 由于MinioAsyncClient里某些方法是protected,不可直接调用,必须创建一个自定义类继承。 */ public class CustomMinioClient extends MinioAsyncClient { /** * 继承父类 * @param client */ public CustomMinioClient(MinioAsyncClient client) { super(client); } /** * 初始化分片上传、获取 uploadId * @param bucket String 存储桶名称 * @param region String * @param object String 文件名称 * @param headers Multimap<String, String> 请求头 * @param extraQueryParams Multimap<String, String> * @return String */ public String initMultiPartUpload(String bucket, String region, String object, Multimap<String, String> headers, Multimap<String, String> extraQueryParams) throws Exception { CreateMultipartUploadResponse response = super.createMultipartUploadAsync(bucket, region, object, headers, extraQueryParams).get(); return response.result().uploadId(); } /** * 合并分片 * @param bucketName String 桶名称 * @param region String * @param objectName String 文件名称 * @param uploadId String 上传的 uploadId * @param parts Part[] 分片集合 * @param extraHeaders Multimap<String, String> * @param extraQueryParams Multimap<String, String> * @return ObjectWriteResponse */ public ObjectWriteResponse mergeMultipartUpload(String bucketName, String region, String objectName, String uploadId, Part[] parts, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws Exception { return super.completeMultipartUploadAsync(bucketName, region, objectName, uploadId, parts, extraHeaders, extraQueryParams).get(); } /** * 查询当前上传后的分片信息 * @param bucketName String 桶名称 * @param region String * @param objectName String 文件名称 * @param maxParts Integer 分片数量 * @param partNumberMarker Integer 分片起始值 * @param uploadId String 上传的 uploadId * @param extraHeaders Multimap<String, String> * @param extraQueryParams Multimap<String, String> * @return ListPartsResponse */ public ListPartsResponse listMultipart(String bucketName, String region, String objectName, Integer maxParts, Integer partNumberMarker, String uploadId, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws Exception { return super.listPartsAsync(bucketName, region, objectName, maxParts, partNumberMarker, uploadId, extraHeaders, extraQueryParams).get(); } }
MinioUtil.java
package com.seentao.vet.cbec.common.minio.util; import cn.hutool.core.util.IdUtil; import com.google.common.collect.HashMultimap; import com.seentao.vet.cbec.common.minio.config.MinioConfig; import io.minio.GetPresignedObjectUrlArgs; import io.minio.ListPartsResponse; import io.minio.MinioAsyncClient; import io.minio.http.Method; import io.minio.messages.Part; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.validation.constraints.NotNull; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * MinIO接口工具类,后端与MinIO交流的桥梁。 */ @Slf4j @Component public class MinioUtil { private CustomMinioClient customMinioClient; private String bucket; @Autowired MinioConfig minioConfig; // spring自动注入会失败 @PostConstruct public void init() { if (!"MINIO".equals(minioConfig.getOssTypeDefault())) { throw new RuntimeException("You are about to upload files to the MinIO server, but the currently configured object storage server is not MinIO. Please check your configuration."); } MinioAsyncClient minioClient = MinioAsyncClient.builder() .endpoint(minioConfig.getEndpoint()) .credentials(minioConfig.getAccessKeyId(), minioConfig.getSecretAccessKey()) .build(); customMinioClient = new CustomMinioClient(minioClient); bucket = minioConfig.getBucketName(); } /** * 获取 Minio 中已经上传的分片文件 * @param object 文件名称 * @param uploadId 上传的文件id(由 minio 生成) * @return List<Integer> */ @SneakyThrows public List<Integer> getListParts(String object, String uploadId) { List<Part> parts = getParts(object, uploadId, bucket); return parts.stream() .map(Part::partNumber) .collect(Collectors.toList()); } /** * 单文件签名上传 * @param object 文件名称(uuid 格式) * @return UploadUrlsVO */ public UploadUrlsVO getUploadObjectUrl(String contentType, String object) throws Exception { try { UploadUrlsVO urlsVO = new UploadUrlsVO(); List<String> urlList = new ArrayList<>(); // 主要是针对图片,若需要通过浏览器直接查看,而不是下载,需要指定对应的 content-type HashMultimap<String, String> headers = HashMultimap.create(); if (contentType == null || contentType.equals("")) { contentType = "application/octet-stream"; } headers.put("Content-Type", contentType); String uploadId = IdUtil.simpleUUID(); // Map<String, String> reqParams = new HashMap<>(); // reqParams.put("uploadId", uploadId); String url = customMinioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket(bucket) .object(object) .extraHeaders(headers) // .extraQueryParams(reqParams) .expiry(30, TimeUnit.MINUTES) .build()); urlList.add(url); // urlsVO.setUploadId(uploadId); urlsVO.setUrls(urlList); return urlsVO; } catch (Exception e) { throw new Exception(e); } } /** * 初始化分片上传 * @param multipartUploadParams 前端传入的文件信息 * @param object object * @return UploadUrlsVO */ public UploadUrlsVO initMultiPartUpload(MultipartUploadParams multipartUploadParams, String object) throws Exception { Integer chunkCount = multipartUploadParams.getChunkCount(); String contentType = multipartUploadParams.getContentType(); String uploadId = multipartUploadParams.getUploadId(); UploadUrlsVO urlsVO = new UploadUrlsVO(); try { HashMultimap<String, String> headers = HashMultimap.create(); if (contentType == null || contentType.equals("")) { contentType = "application/octet-stream"; } headers.put("Content-Type", contentType); // 如果参数中有uploadId,说明是断点续传,不能重新生成 uploadId if (StringUtils.isBlank(uploadId)) { uploadId = customMinioClient.initMultiPartUpload(bucket, null, object, headers, null); } urlsVO.setUploadId(uploadId); List<String> partList = new ArrayList<>(); Map<String, String> reqParams = new HashMap<>(); reqParams.put("uploadId", uploadId); for (int i = 1; i <= chunkCount; i++) { reqParams.put("partNumber", String.valueOf(i)); String uploadUrl = customMinioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket(bucket) .object(object) .expiry(30, TimeUnit.MINUTES) .extraQueryParams(reqParams) .build()); partList.add(uploadUrl); } urlsVO.setUrls(partList); return urlsVO; } catch (Exception e) { throw new Exception(e); } } /** * 合并文件 * @param object object * @param uploadId uploadUd */ @SneakyThrows public boolean mergeMultipartUpload(String object, String uploadId) { // 获取所有分片 List<Part> partsList = getParts(object, uploadId, bucket); Part[] parts = new Part[partsList.size()]; int partNumber = 1; for (Part part : partsList) { parts[partNumber - 1] = new Part(partNumber, part.etag()); partNumber++; } // 合并分片 customMinioClient.mergeMultipartUpload(bucket, null, object, uploadId, parts, null, null); return true; } @NotNull private List<Part> getParts(String object, String uploadId, String bucket) throws Exception { int partNumberMarker = 0; boolean isTruncated = true; List<Part> parts = new ArrayList<>(); while(isTruncated){ ListPartsResponse partResult = customMinioClient.listMultipart(bucket, null, object, 1000, partNumberMarker, uploadId, null, null); parts.addAll(partResult.result().partList()); // 检查是否还有更多分片 isTruncated = partResult.result().isTruncated(); if (isTruncated) { // 更新partNumberMarker以获取下一页的分片数据 partNumberMarker = partResult.result().nextPartNumberMarker(); } } return parts; } }
MultipartMergeParams.java
package com.seentao.vet.cbec.common.minio.util; import lombok.Data; import lombok.experimental.Accessors; import javax.validation.constraints.NotNull; /** * 分片上传完成后请求合并,前端参数 */ @Data @Accessors(chain = true) public class MultipartMergeParams { // @NotNull(message = "存储对象不能为空") // private String bucket; @NotNull(message = "上传id不能为空") private String uploadId; @NotNull(message = "存储对象不能为空") private String object; @NotNull(message = "分片数量不能为空") private Integer chunkCount; }
MultipartUploadParams.java
package com.seentao.vet.cbec.common.minio.util; import lombok.Data; import lombok.experimental.Accessors; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.util.List; /** * 分片上传前端参数 */ @Data @Accessors(chain = true) public class MultipartUploadParams { // @NotBlank(message = "bucket 不可为空") // private String bucket; private String uploadId; // 一般不用传值,但也可以传值用于断点续传。 // @NotBlank(message = "文件名不能为空") // private String originFileName; // 仅秒传会有值 // private String url; // 文件名(含前缀路径) private String object; // private String type; // @NotNull(message = "文件大小不能为空") // private Long size; @NotNull(message = "分片数量不能为空") private Integer chunkCount; // @NotNull(message = "分片大小不能为空") // private Long chunkSize; private String contentType; // // listParts 从 1 开始,前端需要上传的分片索引+1 // private List<Integer> listParts; }
SingleUploadParams.java
package com.seentao.vet.cbec.common.minio.util; import lombok.Data; import lombok.experimental.Accessors; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.util.List; /** * 单文件上传前端参数 */ @Data @Accessors(chain = true) public class SingleUploadParams { // @NotBlank(message = "bucket 不可为空") // private String bucket; @NotBlank(message = "object 不可为空") private String object; @NotBlank(message = "contentType 不可为空") private String contentType; }
UploadUrlsVO.java
package com.seentao.vet.cbec.common.minio.util; import lombok.Data; import lombok.experimental.Accessors; import java.util.List; /** * 返回文件生成的分片上传地址 */ @Data @Accessors(chain = true) public class UploadUrlsVO { private String uploadId; private List<String> urls; }
二、前端
api接口定义 api/minio/index.js
import createAPI from '../config' const api = createAPI() export const minioAPI = { /* MinIO api */ initSingleUpload: (data) => api.post('/common/minio/single/init', data), initMultiPartUpload: (data) => api.post('/common/minio/multipart/init', data), mergeMultipartUpload: (data) => api.post('/common/minio/multipart/merge', data), }
MinioUpload.vue
<template> <div class="settings-view" v-loading="loading" element-loading-text="上传中..."> <h2>MinIO Upload example</h2> <hr> <div> <input type="file" ref="fileInput"/> <el-button @click="executeUpload" type="primary">Upload</el-button> </div> <div v-if="bigFile"> 大文件上传进度:<progress :value="progress" max="100" style="width:300px;"></progress> {{progress}}% </div> <div> {{status}} </div> <div v-if="resultUrl"> 上传结果URL: <el-link :href="resultUrl" target="_blank" type="primary">{{resultUrl}}</el-link> </div> </div> </template> <script setup> import { ref } from 'vue' import { ElMessage } from 'element-plus' import { minioAPI } from '@/api/minio' const fileInput = ref() const status = ref('') const progress = ref(0) const loading = ref(false) const bigFile = ref(false) // 是否大文件 const resultUrl = ref('') // 休眠函数 function sleep(time) { return new Promise((resolve, reject) => { setTimeout(()=>resolve(), time); }); } async function executeUpload() { bigFile.value = false status.value = '' progress.value = 0 console.log('fileInput.value', fileInput.value) const file = fileInput.value.files[0]; console.log('file.name', file.name) console.log('file.type', file.type) console.log('file.size', file.size) // 构建object名称,单文件上传、分片上传、分片合并都要用(虽然像路径字符串,实际上对于MinIO服务器它就是对象名称) const objectName = `CBEC/test/chenjye/${file.name}` // 小于5MB使用单文件上传 if (file.size / 1024 / 1024 < 5) { loading.value = true status.value = '请求单文件上传url' const urlResponse = await minioAPI.initSingleUpload({ object: objectName, contentType: file.type }) console.log('urlResponse', urlResponse) if (urlResponse.success && urlResponse.data) { const uploadUrl = urlResponse.data.urlsVO.urls[0] console.log('uploadUrl', uploadUrl) status.value = '上传url:' + uploadUrl await sleep(3000); status.value = '正在上传' const resultResponse = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file }) console.log('resultResponse', resultResponse) if (resultResponse.ok) { status.value = '上传完成' const fileUrl = uploadUrl.split('?')[0] console.log('结果url:', fileUrl) resultUrl.value = fileUrl } else { status.value = '上传失败' console.log('上传失败', resultResponse.status + " " + resultResponse.statusText + ' ' + resultResponse.type) } loading.value = false } } // 5MB及以上使用分片上传 else { bigFile.value = true progress.value = 0 status.value = '请求分片上传url' const chunkSize = 5 * 1024 * 1024 // 5MB const chunkCount = Math.ceil(file.size / chunkSize) // 初始化多片上传 const urlsResponse = await minioAPI.initMultiPartUpload({ object: objectName, chunkCount: chunkCount, // 生成指定数量个上传url contentType: file.type }) console.log('urlsResponse', urlsResponse) if (!urlsResponse.success) { ElMessage({ message: '上传失败', type: 'error' }) return } const uploadId = urlsResponse.data.urlsVO.uploadId const uploadUrls = urlsResponse.data.urlsVO.urls console.log('uploadId', uploadId) console.log('uploadUrls', uploadUrls) status.value = '上传url数量:' + uploadUrls.length // 并发上传分片 const uploadPromises = [] for (let i = 0; i < chunkCount; i++) { const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize) const uploadPromise = fetch(uploadUrls[i], { method: 'PUT', headers: { 'Content-Type': file.type, // 'Content-Length': chunk.size, }, body: chunk, }).then(response => { if (!response.ok) { ElMessage({ message: `分片 ${(i + 1)} 上传失败`, type: 'error' }) throw new Error(`分片 ${(i + 1)} 上传失败`); } // 更新进度 progress.value = Math.round(((i + 1) / chunkCount) * 100) }).catch(err => { ElMessage({ message: `分片 ${(i + 1)} 上传失败`, type: 'error' }) throw new Error(`分片 ${(i + 1)} 上传失败`); }) uploadPromises.push(uploadPromise) } await Promise.all(uploadPromises) const mergeResponse = await minioAPI.mergeMultipartUpload({ object: objectName, chunkCount, uploadId }) console.log('mergeResponse', mergeResponse) if (mergeResponse.success) { progress.value = 100 // 结果url取签名上传urls中第一个的前半部分即可 resultUrl.value = urlsResponse.data.urlsVO.urls[0].split('?')[0] status.value = '文件上传完成' console.log('文件上传完成') } else { status.value = '文件上传失败' console.log('文件上传失败') } } } </script> <style scoped> </style>
参考资料:
https://blog.csdn.net/weixin_46085718/article/details/147783679
https://blog.csdn.net/weixin_47233946/article/details/148239258
https://blog.csdn.net/qq_41323045/article/details/147202372

浙公网安备 33010602011771号