MinIO实战——从环境搭建到生产级文件上传的完整链路
MinIO实战——从环境搭建到生产级文件上传的完整链路
从Windows上的MinIO服务部署,到Spring Boot集成,到文件上传的全链路实现——文件名自动生成、扩展名白名单、路径穿越防护、上传方式动态切换。这篇不是API翻译,是一个真实项目里跑了两年多的MinIO生产代码。
文章目录
- MinIO实战——从环境搭建到生产级文件上传的完整链路
- 一、MinIO是什么,为什么不用FastDFS
- 二、环境搭建——一行命令启动
- 三、Spring Boot集成
- 四、文件上传前的校验——扩展名白名单+路径穿越防护
- 五、上传实现——桶不存在自动创建
- 六、Controller层——完整的上传与下载接口
- 七、按类型分桶上传——不同的业务用不同的桶
- 八、上传方式动态切换——数据库配置驱动
- 九、预签名URL——临时访问,不暴露MinIO地址
- 十、完整链路总结
- 十一、结语
一、MinIO是什么,为什么不用FastDFS
文件存储是每个业务系统的标配需求。之前用FastDFS,后来切到MinIO,三个原因:
- 部署简单——MinIO一个exe文件,一行命令启动。FastDFS要装tracker+storage+nginx三个服务
- 自带Web管理台——
--console-address :9001打开浏览器就能管理Bucket、查看文件、生成分享链接 - S3兼容——调用方式和AWS S3一样,连阿里云OSS、华为云OBS的代码几乎不用改
二、环境搭建——一行命令启动
# 本地开发环境minio.exe server E:\minIO\data--address"127.0.0.1:9000"--console-address"127.0.0.1:9001"生产环境注册为Windows服务:
<!-- 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集成
@Data@Configuration@ConfigurationProperties(prefix="minio")publicclassMinioConfig{publicStringurl;publicStringaccessKey;publicStringsecretKey;publicStringbucketName;publicstaticBooleansecure=false;@BeanpublicMinioClientgetMinioClient(){returnMinioClient.builder().endpoint(url).credentials(accessKey,secretKey).build();}}# application-dev.ymlminio:url:http://192.168.70.77:9000accessKey:minioadminsecretKey:minioadminbucketName:video@ConfigurationProperties(prefix = "minio")把YAML配置自动注入到Bean。全局只有一个MinioClient实例,线程安全,不用每次都new。
四、文件上传前的校验——扩展名白名单+路径穿越防护
publicclassMinioFileUtil{privatestaticMap<String,String>extMap=newHashMap<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");}publicStringminioFileName(MultipartFilemFile)throwsException{StringoriginalFilename=mFile.getOriginalFilename();originalFileName=URLDecoder.decode(originalFilename,"UTF-8");// 路径穿越检测if(originalFilename.indexOf("%00")>-1||originalFilename.indexOf("./")>-1||originalFilename.indexOf(".\\")>-1){thrownewServiceException("上传文件名称非法!");}// 去除Windows路径前缀intlastSlashPos=originalFileName.lastIndexOf("\\");if(lastSlashPos>-1){originalFileName=originalFileName.substring(lastSlashPos+1);}// 提取扩展名并校验StringfileExt=originalFileName.substring(originalFileName.lastIndexOf(".")+1).toLowerCase();if(!Arrays.asList(extMap.get(dirName).split(",")).contains(fileExt)){thrownewServiceException("上传文件扩展名是不允许的扩展名!");}// 自动生成存储文件名:yyyyMMddHHmmssSSS_随机数.扩展名SimpleDateFormatdf=newSimpleDateFormat("yyyyMMddHHmmssSSS");fileName=df.format(newDate())+"_"+newRandom().nextInt(1000)+"."+fileExt;returnfileName;}}三个安全措施:
- 扩展名白名单——不在
extMap里的类型一律拦截。不是黑名单"禁止.exe/.sh",是白名单"只允许这些" - 路径穿越防护——
%00(空字节截断)、./、.\三种经典攻击手段全部拦截。攻击者试图把文件名伪造成../../etc/passwd上传覆盖其他文件——过不了 - 文件名自动生成——不保存用户的原始文件名,用时间戳+随机数生成唯一文件名。避免同名覆盖、避免双写乱码
扩展名按dirName分组管理——images只允许图片格式,files允许文档格式,all允许全部。同一个上传方法,传不同的dirName就切换不同的白名单。
五、上传实现——桶不存在自动创建
@ComponentpublicclassMinioUtil{@AutowiredprivateMinioClientminioClient;@AutowiredprivateMinioConfigminIOConfig;/** 判断桶是否存在 */publicBooleanbucketExists(StringbucketName){try{returnminioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());}catch(Exceptione){returnfalse;}}/** 创建桶 */publicBooleanmakeBucket(StringbucketName){try{minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());returntrue;}catch(Exceptione){returnfalse;}}/** 上传文件——桶不存在自动创建 */publicBooleanupload(MultipartFilefile,StringfileName,StringbucketName){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());returntrue;}catch(Exceptione){returnfalse;}}}上传前先检查桶是否存在,不存在就自动创建——不必让运维手动建Bucket,第一人上传就自动搞定。
六、Controller层——完整的上传与下载接口
@RestController@RequestMapping("/expertFile")publicclassMinoFileController{@ResourceprivateMinioConfigminioConfig;@ResourceprivateMinioUtilminioUtil;/** 通用文件上传 */@PostMapping(value="/uploadFile",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)publicAjaxResultuploadFile(HttpServletRequestrequest,MultipartFilefile){Assert.notNull(file,"文件不能为空!");try{MinioFileUtilminioFileUtil=newMinioFileUtil();StringfileName=minioFileUtil.minioFileName(file);Booleansuccess=minioUtil.upload(file,fileName,minioConfig.getBucketName());if(!success){thrownewServiceException("上传失败请联系管理员!");}StringfilePath=minioConfig.getUrl()+"/"+minioConfig.getBucketName()+"/"+fileName;returnAjaxResult.success("上传成功",JSONUtil.createObj().set("url",filePath).set("originalFileName",minioFileUtil.getOriginalFileName()).set("name",fileName).set("size",file.getSize()+""));}catch(Exceptione){thrownewServiceException("上传文件失败!");}}/** 文件下载 */@GetMapping("/downloadFile/{fileName}/{orginalFileName}")publicvoiddownloadFile(HttpServletResponseresponse,@PathVariable("fileName")StringfileName,@PathVariable("orginalFileName")StringorginalFileName){minioUtil.download(minioConfig.getBucketName(),fileName,response,orginalFileName);}}下载时注意URL中的中文文件名处理:
// MinioUtil.download()if(StrUtil.isNotBlank(originName)){originName=URLEncoder.encode(originName,"utf-8");res.addHeader("Content-Disposition","attachment;fileName="+originName);}浏览器下载文件时,Content-Disposition里的中文文件名必须URL编码,否则文件名乱码或直接丢失。
七、按类型分桶上传——不同的业务用不同的桶
/** 指定桶上传 */@PostMapping("/uploadFileByBucketName")publicAjaxResultuploadFileByBucketName(@RequestParam("file")MultipartFilefile,@RequestParam("bucketName")StringbucketName){MinioFileUtilminioFileUtil=newMinioFileUtil();StringfileName=minioFileUtil.minioFileName(file);minioUtil.upload(file,fileName,bucketName);StringfilePath=minioConfig.getUrl()+"/"+bucketName+"/"+fileName;returnAjaxResult.success("上传成功",JSONUtil.createObj().set("url",filePath).set("name",fileName));}同一个方法,上传不同的bucketName就把文件放到不同的桶。专家申报用的附件放在expert桶,系统附件放在system桶。桶之间的文件物理隔离,权限策略可以独立配置。
八、上传方式动态切换——数据库配置驱动
/** 从系统配置表读取当前使用的上传方式 */publicstaticStringuploadType(){SysConfigconfigByType=configFeignService.getSysConfigByCode("ATTA_UPLOAD_TYPE").getData();if(null!=configByType){returnconfigByType.getValue();}return"4";// 默认统一文件服务}// 上传方式枚举publicinterfaceUPLOAD_TYPE{StringIN_PROJECT="1";// 存项目目录StringIN_DISK="2";// 存磁盘StringFTP="3";// FTP文件服务StringUNIFIED_FILES="4";// 统一文件服务StringMINIO_FILES="5";// MinIO文件服务}上传方式不是硬编码的——去系统配置表查ATTA_UPLOAD_TYPE的值。值是3就走FTP,值是5就走MinIO。切换存储方式不需要重启服务,不需要改代码,改配置表一行记录就生效。
九、预签名URL——临时访问,不暴露MinIO地址
/** 生成文件预览URL */publicStringgetPreviewUrl(StringfileName,StringbucketName){if(StringUtils.isNotBlank(fileName)){bucketName=StringUtils.isNotBlank(bucketName)?bucketName:minIOConfig.getBucketName();try{// 先确认文件存在minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(fileName).build());// 生成预签名URLreturnminioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().method(Method.GET).bucket(bucketName).object(fileName).build());}catch(Exceptione){}}returnnull;}不是把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%的日常操作。复杂的是文件上传场景里的安全和规范——文件名生成、扩展名校验、路径穿越防护、桶管理、上传方式切换。
