MinIO实战------从环境搭建到生产级文件上传的完整链路
从Windows上的MinIO服务部署,到Spring Boot集成,到文件上传的全链路实现------文件名自动生成、扩展名白名单、路径穿越防护、上传方式动态切换。这篇不是API翻译,是一个真实项目里跑了两年多的MinIO生产代码。
文章目录
一、MinIO是什么,为什么不用FastDFS
文件存储是每个业务系统的标配需求。之前用FastDFS,后来切到MinIO,三个原因:
- 部署简单------MinIO一个exe文件,一行命令启动。FastDFS要装tracker+storage+nginx三个服务
- 自带Web管理台 ------
--console-address :9001打开浏览器就能管理Bucket、查看文件、生成分享链接 - S3兼容------调用方式和AWS S3一样,连阿里云OSS、华为云OBS的代码几乎不用改
二、环境搭建------一行命令启动
bash
# 本地开发环境
minio.exe server E:\minIO\data --address "127.0.0.1:9000" --console-address "127.0.0.1:9001"
生产环境注册为Windows服务:
xml
<!-- minio-service.xml -->
<service>
<id>minio</id>
<name>minio</name>
<description>minio service</description>
<executable>E:\minIO\minio.exe</executable>
<arguments>server "E:\minIO\data" --address "192.168.70.77:9000" --console-address "192.168.70.77:9001"</arguments>
<logpath>E:\minIO\log</logpath>
</service>
一个真实踩坑:--address 和 --console-address 之间必须有一个空格。少了一个空格,服务启动日志就是:
FATAL Unable to split host port 192.168.70.77:9000--console-address: invalid port number
查半天不知道是不是IP配错了、端口被占用了------最后发现是少了一个空格。加了空格,服务正常启动。
三、Spring Boot集成
java
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
public String url;
public String accessKey;
public String secretKey;
public String bucketName;
public static Boolean secure = false;
@Bean
public MinioClient getMinioClient() {
return MinioClient.builder()
.endpoint(url)
.credentials(accessKey, secretKey)
.build();
}
}
yaml
# application-dev.yml
minio:
url: http://192.168.70.77:9000
accessKey: minioadmin
secretKey: minioadmin
bucketName: video
@ConfigurationProperties(prefix = "minio") 把YAML配置自动注入到Bean。全局只有一个 MinioClient 实例,线程安全,不用每次都new。
四、文件上传前的校验------扩展名白名单+路径穿越防护
java
public class MinioFileUtil {
private static Map<String, String> extMap = new HashMap<String, String>();
static {
extMap.put("images", "gif,jpg,jpeg,png,bmp");
extMap.put("flashs", "swf,flv");
extMap.put("medias", "swf,flv,mp3,wav,wma,wmv,mid,avi,mpg,asf,rm,rmvb,mp4,3gp,mov");
extMap.put("files", "doc,docx,xls,xlsx,ppt,txt,zip,rar,gz,bz2,pdf,ktr,kjb,apk");
extMap.put("all", imagesExt + "," + flashsExt + "," + mediasExt + "," + filesExt + ",data");
}
public String minioFileName(MultipartFile mFile) throws Exception {
String originalFilename = mFile.getOriginalFilename();
originalFileName = URLDecoder.decode(originalFilename, "UTF-8");
// 路径穿越检测
if (originalFilename.indexOf("%00") > -1
|| originalFilename.indexOf("./") > -1
|| originalFilename.indexOf(".\\") > -1) {
throw new ServiceException("上传文件名称非法!");
}
// 去除Windows路径前缀
int lastSlashPos = originalFileName.lastIndexOf("\\");
if (lastSlashPos > -1) {
originalFileName = originalFileName.substring(lastSlashPos + 1);
}
// 提取扩展名并校验
String fileExt = originalFileName
.substring(originalFileName.lastIndexOf(".") + 1).toLowerCase();
if (!Arrays.asList(extMap.get(dirName).split(",")).contains(fileExt)) {
throw new ServiceException("上传文件扩展名是不允许的扩展名!");
}
// 自动生成存储文件名:yyyyMMddHHmmssSSS_随机数.扩展名
SimpleDateFormat df = new SimpleDateFormat("yyyyMMddHHmmssSSS");
fileName = df.format(new Date()) + "_"
+ new Random().nextInt(1000) + "." + fileExt;
return fileName;
}
}
三个安全措施:
- 扩展名白名单 ------不在
extMap里的类型一律拦截。不是黑名单"禁止.exe/.sh",是白名单"只允许这些" - 路径穿越防护 ------
%00(空字节截断)、./、.\三种经典攻击手段全部拦截。攻击者试图把文件名伪造成../../etc/passwd上传覆盖其他文件------过不了 - 文件名自动生成------不保存用户的原始文件名,用时间戳+随机数生成唯一文件名。避免同名覆盖、避免双写乱码
扩展名按 dirName 分组管理------images 只允许图片格式,files 允许文档格式,all 允许全部。同一个上传方法,传不同的 dirName 就切换不同的白名单。
五、上传实现------桶不存在自动创建
java
@Component
public class MinioUtil {
@Autowired
private MinioClient minioClient;
@Autowired
private MinioConfig minIOConfig;
/** 判断桶是否存在 */
public Boolean bucketExists(String bucketName) {
try {
return minioClient.bucketExists(
BucketExistsArgs.builder().bucket(bucketName).build());
} catch (Exception e) {
return false;
}
}
/** 创建桶 */
public Boolean makeBucket(String bucketName) {
try {
minioClient.makeBucket(
MakeBucketArgs.builder().bucket(bucketName).build());
return true;
} catch (Exception e) {
return false;
}
}
/** 上传文件------桶不存在自动创建 */
public Boolean upload(MultipartFile file, String fileName, String bucketName) {
try {
// 桶不存在则自动创建
if (!this.bucketExists(bucketName)) {
this.makeBucket(bucketName);
}
// 上传
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
return true;
} catch (Exception e) {
return false;
}
}
}
上传前先检查桶是否存在,不存在就自动创建------不必让运维手动建Bucket,第一人上传就自动搞定。
六、Controller层------完整的上传与下载接口
java
@RestController
@RequestMapping("/expertFile")
public class MinoFileController {
@Resource
private MinioConfig minioConfig;
@Resource
private MinioUtil minioUtil;
/** 通用文件上传 */
@PostMapping(value = "/uploadFile",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult uploadFile(HttpServletRequest request,
MultipartFile file) {
Assert.notNull(file, "文件不能为空!");
try {
MinioFileUtil minioFileUtil = new MinioFileUtil();
String fileName = minioFileUtil.minioFileName(file);
Boolean success = minioUtil.upload(
file, fileName, minioConfig.getBucketName());
if (!success) {
throw new ServiceException("上传失败请联系管理员!");
}
String filePath = minioConfig.getUrl() + "/"
+ minioConfig.getBucketName() + "/" + fileName;
return AjaxResult.success("上传成功", JSONUtil.createObj()
.set("url", filePath)
.set("originalFileName", minioFileUtil.getOriginalFileName())
.set("name", fileName)
.set("size", file.getSize() + ""));
} catch (Exception e) {
throw new ServiceException("上传文件失败!");
}
}
/** 文件下载 */
@GetMapping("/downloadFile/{fileName}/{orginalFileName}")
public void downloadFile(HttpServletResponse response,
@PathVariable("fileName") String fileName,
@PathVariable("orginalFileName") String orginalFileName) {
minioUtil.download(minioConfig.getBucketName(), fileName,
response, orginalFileName);
}
}
下载时注意URL中的中文文件名处理:
java
// MinioUtil.download()
if (StrUtil.isNotBlank(originName)) {
originName = URLEncoder.encode(originName, "utf-8");
res.addHeader("Content-Disposition",
"attachment;fileName=" + originName);
}
浏览器下载文件时,Content-Disposition 里的中文文件名必须URL编码,否则文件名乱码或直接丢失。
七、按类型分桶上传------不同的业务用不同的桶
java
/** 指定桶上传 */
@PostMapping("/uploadFileByBucketName")
public AjaxResult uploadFileByBucketName(
@RequestParam("file") MultipartFile file,
@RequestParam("bucketName") String bucketName) {
MinioFileUtil minioFileUtil = new MinioFileUtil();
String fileName = minioFileUtil.minioFileName(file);
minioUtil.upload(file, fileName, bucketName);
String filePath = minioConfig.getUrl() + "/"
+ bucketName + "/" + fileName;
return AjaxResult.success("上传成功", JSONUtil.createObj()
.set("url", filePath)
.set("name", fileName));
}
同一个方法,上传不同的 bucketName 就把文件放到不同的桶。专家申报用的附件放在 expert 桶,系统附件放在 system 桶。桶之间的文件物理隔离,权限策略可以独立配置。
八、上传方式动态切换------数据库配置驱动
java
/** 从系统配置表读取当前使用的上传方式 */
public static String uploadType() {
SysConfig configByType = configFeignService
.getSysConfigByCode("ATTA_UPLOAD_TYPE").getData();
if (null != configByType) {
return configByType.getValue();
}
return "4"; // 默认统一文件服务
}
// 上传方式枚举
public interface UPLOAD_TYPE {
String IN_PROJECT = "1"; // 存项目目录
String IN_DISK = "2"; // 存磁盘
String FTP = "3"; // FTP文件服务
String UNIFIED_FILES = "4"; // 统一文件服务
String MINIO_FILES = "5"; // MinIO文件服务
}
上传方式不是硬编码的------去系统配置表查 ATTA_UPLOAD_TYPE 的值。值是3就走FTP,值是5就走MinIO。切换存储方式不需要重启服务,不需要改代码,改配置表一行记录就生效。
九、预签名URL------临时访问,不暴露MinIO地址
java
/** 生成文件预览URL */
public String getPreviewUrl(String fileName, String bucketName) {
if (StringUtils.isNotBlank(fileName)) {
bucketName = StringUtils.isNotBlank(bucketName)
? bucketName : minIOConfig.getBucketName();
try {
// 先确认文件存在
minioClient.statObject(StatObjectArgs.builder()
.bucket(bucketName).object(fileName).build());
// 生成预签名URL
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(fileName)
.build());
} catch (Exception e) { }
}
return null;
}
不是把MinIO的 192.168.70.77:9000 直接暴露给前端------用预签名URL,前端看到的是一个有时效性的临时地址。即使URL被截获,过期后就无法访问。内部网络结构不暴露。
十、完整链路总结
前端上传
├── Controller 接收 MultipartFile
├── MinioFileUtil.minioFileName()
│ ├── 路径穿越检测 (%00, ./, .\\)
│ ├── 扩展名白名单校验
│ ├── 自动生成唯一文件名 (时间戳+随机数)
│ └── 返回安全的文件名
├── MinioUtil.upload()
│ ├── 检查桶是否存在 → 不存在则创建
│ └── putObject() 流式上传到MinIO
├── 返回结果
│ └── {url, originalFileName, name, size}
└── SysAttaManager 写入数据库
└── sys_atta.minioUploadUrl = "http://.../bucket/fileName"
从接收文件到入库------五层,每层只做一件事。换存储方式时改配置表,不改代码。加新的文件类型时改 extMap,不改业务逻辑。
十一、结语
MinIO的Java SDK本身很简单------putObject、getObject、removeObject,三个方法覆盖90%的日常操作。复杂的是文件上传这个场景的安全和规范------文件名怎么生成、扩展名怎么校验、路径穿越怎么防、桶怎么管理、上传方式怎么切换。
MinIO的Java SDK本身很简单------putObject、getObject、removeObject,三个方法覆盖90%的日常操作。复杂的是文件上传场景里的安全和规范------文件名生成、扩展名校验、路径穿越防护、桶管理、上传方式切换。