MinIO实战——从环境搭建到生产级文件上传的完整链路

MinIO实战------从环境搭建到生产级文件上传的完整链路

从Windows上的MinIO服务部署,到Spring Boot集成,到文件上传的全链路实现------文件名自动生成、扩展名白名单、路径穿越防护、上传方式动态切换。这篇不是API翻译,是一个真实项目里跑了两年多的MinIO生产代码。

文章目录


一、MinIO是什么,为什么不用FastDFS

文件存储是每个业务系统的标配需求。之前用FastDFS,后来切到MinIO,三个原因:

  1. 部署简单------MinIO一个exe文件,一行命令启动。FastDFS要装tracker+storage+nginx三个服务
  2. 自带Web管理台 --------console-address :9001 打开浏览器就能管理Bucket、查看文件、生成分享链接
  3. 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本身很简单------putObjectgetObjectremoveObject,三个方法覆盖90%的日常操作。复杂的是文件上传这个场景的安全和规范------文件名怎么生成、扩展名怎么校验、路径穿越怎么防、桶怎么管理、上传方式怎么切换。

MinIO的Java SDK本身很简单------putObjectgetObjectremoveObject,三个方法覆盖90%的日常操作。复杂的是文件上传场景里的安全和规范------文件名生成、扩展名校验、路径穿越防护、桶管理、上传方式切换。

相关推荐
卷无止境2 小时前
C++ 存储类说明符(Storage Class Specifier)大横评
c++·后端
用户019027581612 小时前
量化数据的 batch 接口有多好用?从 1 只到 500 只,批量拉数据的正确姿势
后端
rruining2 小时前
Java设计模式——结构型
后端
卷无止境2 小时前
C++ 编程的一大坑:非常量全局变量是"万恶之源"
c++·后端
Sinclair3 小时前
认识安企CMS-系统和模板文件结构
后端
柒和远方5 小时前
Phase 7.4 学习博客:为什么多 API 项目需要 Swagger / OpenAPI
前端·后端·架构
柒和远方5 小时前
Phase 7.3 复盘:后台任务不只是“扔进队列”,还要能被看见
前端·后端·架构
易协同低代码5 小时前
通达OA模块开发实战
后端
聂二AI落地内参5 小时前
LLM 数据增强任务卡 4 天:upsert 少传 id 后发生了什么
后端