minio开发参考
2023/4/18大约 7 分钟
准备
- pom.xml
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.17</version>
</dependency>
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
<!-- okhttp 由 minio 8.5.x 传递引入(4.12+),显式指定旧版本反而冲突,勿手动引入 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.32</version>
</dependency>- application.yml
# minio 参数配置
minio:
endpoint: http://localhost:20000
accessKey: abc
# 密码至少 8 位,MinIO 服务端强制要求,短了直接起不来
secretKey: minio123456
# 默认存储桶
bucketName: test
# 浏览器访问文件url前缀(引用桶名,避免两处维护)
urlPrefix: ${minio.endpoint}/${minio.bucketName}/
# 压缩时,源文件的临时存放路径(系统临时目录,Windows/Linux 通用)
temporaryFilePath: ${java.io.tmpdir}/minio/
spring:
servlet:
multipart:
# 上传大小限制,超出直接拒绝(防线在下层,Controller 里不用再判)
max-file-size: 20MB
max-request-size: 20MB- 配置类 MinIoConfig
@Data
@Component
@ConfigurationProperties(prefix = "minio")
public class MinIoConfig {
// minio Server地址
private String endpoint;
// minio accessKey
private String accessKey;
// minio secretKey
private String secretKey;
private String bucketName;
private String urlPrefix;
private String temporaryFilePath;
}服务端安装
docker-compose.yml 如下,9000 是 S3 API 端口(SDK 连它),9001 是控制台端口:
version: "3.8"
services:
minio:
image: minio/minio:latest
container_name: minio
restart: always
ports:
- 20000:9000
- 20001:9001
environment:
MINIO_ROOT_USER: abc
MINIO_ROOT_PASSWORD: minio123456
volumes:
- /usr/local/docker/minio/data:/data
- /usr/local/docker/minio/config:/root/.minio
command: server /data --console-address ":9001"提示
2025 年起 MinIO 社区版大幅缩减了网页控制台功能(管理界面逐步转向商业版),升级后控制台变 readonly/消失属正常现象;SDK 与 S3 API 不受影响。自建生产可考虑固定镜像版本号,避免跟着 latest 一起变。
minio 客户端
@Configuration
public class MinIoClientConfig {
@Resource
MinIoConfig config;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(config.getEndpoint())
.credentials(config.getAccessKey(), config.getSecretKey())
.build();
}
}minio 工具类
压缩、上传、指定存放位置;工具类自行注入 MinioClient 与配置,调用方无需层层传参。
@Slf4j
@Component
public class MinioUtil {
@Resource
MinioClient minioClient;
@Resource
MinIoConfig minIoConfig;
// 允许上传的文件后缀白名单,按业务调整;不设白名单,html/svg 等脚本载荷也能传上来
private static final List<String> ALLOW_SUFFIX =
Arrays.asList("jpg", "jpeg", "png", "gif", "webp", "bmp");
/**
* 创建存储桶,并设置为匿名只读(公开桶,浏览器可直接访问文件)
*/
public void createBucket(String bucketName) {
try {
// 创建存储桶
minioClient.makeBucket(MakeBucketArgs.builder()
.bucket(bucketName).build());
// 桶策略:允许匿名 GetObject;注意 JSON 数组元素间的逗号,少了直接设置失败
String policy = "{" +
"\"Statement\":[" +
"{\"Action\":[\"s3:GetBucketLocation\",\"s3:ListBucket\"],\"Effect\":\"Allow\",\"Principal\":\"*\",\"Resource\":\"arn:aws:s3:::%1$s\"}," +
"{\"Action\":\"s3:GetObject\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Resource\":\"arn:aws:s3:::%1$s/*\"}" +
"],\"Version\":\"2012-10-17\"}";
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucketName)
.config(String.format(policy, bucketName))
.build());
} catch (Exception e) {
log.error("创建存储桶出现异常:{}", e.getMessage(), e);
}
}
/**
* 上传文件;大于 2MB 的图片自动压缩
*
* @param file 文件
* @param prefix 存放文件夹,如 product
*/
public JSONObject uploadFile(MultipartFile file, String prefix) {
JSONObject object = new JSONObject();
try {
String original = StringUtils.defaultString(file.getOriginalFilename());
// 后缀白名单校验,不通过直接终止本次上传
String suffix = FileUtil.getSuffix(original);
if (!ALLOW_SUFFIX.contains(suffix)) {
throw new BusinessException("不支持的文件类型:" + suffix);
}
// 文件命名:短文件名补随机前缀防重,超长截断
String filename = original;
if (filename.length() < 16) {
filename = IdUtil.objectId() + filename;
}
if (filename.length() > 32) {
filename = filename.substring(filename.length() - 32);
}
// 无前缀时不要拼出 "/2024-xx" 的斜杠开头,对象名以 / 开头会多出一个空目录层级
String objectName = (StringUtils.isBlank(prefix) ? "" : prefix + "/")
+ DateUtil.format(new Date(), "yyyy-MM")
+ "/" + DateUtil.format(new Date(), "yyyy-MM-dd")
+ "/" + filename;
// ImageIO.read 判断是否是图片,非图片返回 null
BufferedImage image = ImageIO.read(file.getInputStream());
File tempFile = null;
try {
if (file.getSize() > 1024 * 1024 * 2 && image != null) {
// 大于 2MB 的图片:先压缩到本地临时文件再上传
File tempDir = new File(minIoConfig.getTemporaryFilePath());
if (!tempDir.exists() && !tempDir.mkdirs()) {
throw new BusinessException("临时目录创建失败:" + tempDir);
}
tempFile = new File(tempDir, UUID.fastUUID() + filename);
// scale 尺寸缩放比例、outputQuality 画质(0.1~1),按业务调整压缩力度
Thumbnails.of(file.getInputStream()).scale(0.8f).outputQuality(0.7f).toFile(tempFile);
try (InputStream in = Files.newInputStream(tempFile.toPath())) {
putObject(objectName, file.getContentType(), in, tempFile.length());
}
log.info("图片已压缩:{}B -> {}B", file.getSize(), tempFile.length());
image = ImageIO.read(tempFile);
} else {
try (InputStream in = file.getInputStream()) {
putObject(objectName, file.getContentType(), in, file.getSize());
}
}
} finally {
// 临时文件用完即删,防止磁盘堆积
if (tempFile != null) {
FileUtil.del(tempFile);
}
}
// urlPrefix 已含 endpoint 与桶名,直接拼对象名即可
String url = minIoConfig.getUrlPrefix() + objectName;
object.set("width", image == null ? 0 : image.getWidth());
object.set("height", image == null ? 0 : image.getHeight());
object.set("size", file.getSize());
object.set("filename", objectName);
object.set("url", url);
} catch (Exception e) {
log.error("文件上传发生异常:{}", e.getMessage(), e);
// 失败带上原因返回,不能吞掉异常装成功
object.set("error", e.getMessage());
}
return object;
}
/**
* 上传入口:桶不存在则先创建,随后上传
*/
public BaseResult<Object> uploadByPrefix(MultipartFile file, String prefix) {
try {
boolean isExist = minioClient.bucketExists(BucketExistsArgs.builder()
.bucket(minIoConfig.getBucketName()).build());
if (!isExist) {
// 建完桶继续上传,不要在这里 return(原写法首次建桶后直接掉到上传失败)
createBucket(minIoConfig.getBucketName());
log.info("存储桶不存在,已创建:{}", minIoConfig.getBucketName());
}
JSONObject object = uploadFile(file, prefix);
if (object.containsKey("error")) {
return BaseResult.error("文件上传失败:" + object.getStr("error"));
}
return BaseResult.ok("文件上传成功!", object);
} catch (Exception e) {
log.error("上传发生错误:{}", e.getMessage(), e);
}
return BaseResult.error("文件上传失败!");
}
/**
* 删除文件
*/
public void removeObject(String objectName) throws Exception {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(minIoConfig.getBucketName())
.object(objectName)
.build());
}
/**
* 上传的核心调用,统一收口
*/
private void putObject(String objectName, String contentType, InputStream stream, long size) throws Exception {
minioClient.putObject(PutObjectArgs.builder()
.bucket(minIoConfig.getBucketName())
.object(objectName)
.contentType(contentType)
.stream(stream, size, -1)
.build());
}
}预签名 URL
公开桶的文件链接永久有效、可被任意转发;私有桶 + 预签名 URL 才是受控访问的标准做法:链接携带签名与过期时间,到期即失效(最长 7 天)。合同、票据等敏感文件必用,在 MinioUtil 中追加:
/**
* 获取文件预签名下载 URL
*
* @param objectName 文件名
* @param expiry 有效期(秒),最大 7 天
*/
public String getPresignedUrl(String objectName, int expiry) {
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(minIoConfig.getBucketName())
.object(objectName)
.expiry(expiry, TimeUnit.SECONDS)
.build());
} catch (Exception e) {
throw new BusinessException("获取预签名 URL 失败:" + e.getMessage());
}
}前端直传也可用同思路:后端只签发 presignedPutObject 的 PUT 预签名 URL,文件流量不经过应用服务器。
其他工具
入库不应该存入域名、查询自动添加域名
此工具适用于处理出入数据库的数据
@Component
public class OssUtil {
@Resource
MinIoConfig ossConfig;
/**
* 给从数据库得到的到的文件列表添加前缀 【用以返回前台】
*/
public List<Oss> addOssDBListPrefix(List<Oss> ossList) {
List<Oss> newOssList = new ArrayList<>();
ossList.forEach(oss -> {
oss.setUrl(ossConfig.getUrlPrefix() + oss.getUrl());
newOssList.add(oss);
});
return newOssList;
}
/**
* 给url添加前缀【用以返回前台】
*/
public String addUrlPrefix(String ossDBUrl) {
ossDBUrl = ossConfig.getUrlPrefix() + ossDBUrl;
return ossDBUrl;
}
/**
* 指定字段名添加前缀
*
* @param data 需要处理的集合
* @param field 需要添加前缀的字段名
* @param <T> 类型
* @return 替换好的集合
*/
public <T> List<T> addAllUrlPrefix(List<T> data, String field) {
data.forEach(each -> {
Field urlField = Optional.ofNullable(ReflectionUtils.findField(each.getClass(), field)).orElseThrow(() -> {
throw new BusinessException("未查询到该字段");
});
ReflectionUtils.makeAccessible(urlField);
ReflectionUtils.setField(urlField, each, ossConfig.getUrlPrefix() + ReflectionUtils.getField(urlField, each));
});
return data;
}
/**
* 给从数据库得到的单个文件添加前缀 【用以返回前台】
*/
public Oss ossDBAddPrefix(Oss oss) {
oss.setUrl(ossConfig.getUrlPrefix() + oss.getUrl());
return oss;
}
/**
* 前台传的oss列表删除前缀,处理成入库数据
*
* @param associationId 关联id
* @param type 文件类型
*/
public List<Oss> delOssListPrefixForInsert(List<Oss> ossList, String associationId, Integer type) {
List<Oss> newOssList = new ArrayList<>();
ossList.forEach(oss -> {
oss.setOssId(IdUtil.getSnowflakeNextIdStr());
oss.setAssociationId(associationId);
if (StringUtils.isNotBlank(oss.getFilename())) {
// 若有文件名,直接保存
oss.setUrl(oss.getFilename());
} else {
// 根据url分割出文件名
oss.setUrl(CharSequenceUtil.removePrefix(oss.getUrl(), ossConfig.getUrlPrefix()));
}
oss.setType(type);
oss.setState(0);
oss.setVersion(1);
oss.setIsDelete(0);
oss.setCreateBy("");
oss.setUpdateBy("");
oss.setCreateTime(LocalDateTime.now());
oss.setUpdateTime(LocalDateTime.now());
newOssList.add(oss);
});
return newOssList;
}
/**
* 前台传的单个oss删除前缀,处理成入库数据
*
* @param associationId 关联id
* @param type 文件类型
*/
public Oss delOssPrefixForInsert(Oss oss, String associationId, Integer type) {
if (StringUtils.isNotBlank(oss.getFilename())) {
// 若有文件名,直接保存
oss.setUrl(oss.getFilename());
} else {
// 根据url分割出文件名
oss.setUrl(CharSequenceUtil.removePrefix(oss.getUrl(), ossConfig.getUrlPrefix()));
}
oss.setType(type);
oss.setAssociationId(associationId);
oss.setCreateBy("");
oss.setUpdateBy("");
return oss;
}
/**
* 给前台传的文件url删除前缀
*/
public String delUrlPrefix(String url) {
url = CharSequenceUtil.removePrefix(url, ossConfig.getUrlPrefix());
return url;
}
}通用文件传输控制器
@Slf4j
@RestController
@RequestMapping("/api/open/oss")
public class UploadController {
@Resource
MinioUtil minioUtil;
/**
* 上传商品图片
*/
@PostMapping("/upload/product")
public BaseResult<Object> uploadObjectProduct(MultipartFile file) {
return minioUtil.uploadByPrefix(file, "product");
}
/**
* 上传轮播图片
*/
@PostMapping("/upload/banner")
public BaseResult<Object> uploadObjectBanner(MultipartFile file) {
return minioUtil.uploadByPrefix(file, "banner");
}
/**
* 删除文件
*
* @param objectName 文件名
*/
@PostMapping("/delete")
public BaseResult<Object> delObject(@RequestParam("objectName") String objectName) {
// 删除操作务必挂到鉴权路径并校验 objectName 归属(如只允许删当前用户关联的文件),
// 否则任何人都能删任意文件;此处沿用 open 路径仅作演示
try {
minioUtil.removeObject(objectName);
return BaseResult.ok("文件删除成功!");
} catch (Exception e) {
log.error("文件删除发生错误:{}", e.getMessage(), e);
}
return BaseResult.error("文件删除失败!");
}
}生产化要点
- 桶策略取舍:头像、商品图等公开资源用匿名只读桶 + CDN 加速即可;合同、票据等敏感文件用私有桶 + 预签名 URL(见上文),链接到期自动失效;
- 类型白名单 + 大小限制:
ALLOW_SUFFIX与spring.servlet.multipart.max-file-size双保险,缺一个都能被塞进危险文件; - 删除接口:POST + 鉴权 + 归属校验,三样缺一不可;
- endpoint 生产化:走域名 + HTTPS(Nginx 反代到 9000),别把
http://内网IP:端口暴露给前端; - 超大文件:走前端直传(预签名 PUT)或 SDK 的分片上传,别让几十 MB 的文件流经应用服务器;
- 镜像版本固定:docker-compose 里 pin 具体版本号,别用 latest——MinIO 社区版近两年变动频繁。

