日志详情

使用Minio作为文件存储服务

发布时间:2025-03-10

image

一、Minio

MinIO 是一个高性能的分布式对象存储系统。 它是软件定义的,在行业标准硬件上运行,并且 100% 开源,主要许可证是 GNU AGPL v3


MinIO 的不同之处在于它从一开始就被设计为私有/混合云对象存储的标准。 因为 MinIO 是专门为对象而构建的,所以单层架构可以毫不妥协地实现所有必要的功能。 结果是一个同时具有高性能、可扩展性和轻量级的云原生对象服务器


二、思路设计

Minio已经是一个包含后台的应用,几乎是即开即用的为念服务应用,使用起来也是相当简单的。

接下来我们将要实现一个支持验证etag的图片获取流程:

1. 前端发起请求先检查etag是否存在(etag会对应一个图片信息用于利用浏览器缓存,案例中使用的是预先加载生成blob的方式)

2. 后端接收请求,先通过minio获取该同名图片的etag。如果获取的而etag和前端传入的相同,则表示图片无修改,无需通过minio再获取图片资源,直接使用浏览器缓存即可。如果不相同,则表示图片不匹配,可能已经进行过更新,则获取新的图片,传递给前端。

3. 前端拿到图片信息,从响应中获取etag,并使用Image预加载图片并生成blob存入storage中,便于刷新后使用浏览器缓存。


三、功能实现

1、 docker部署minio

docker run -p 9000:9000 -e "MINIO_ACCESS_KEY=minio-access-key" -e "MINIO_SECRET_KEY=minio-secret-key" -v /data:/data minio/minio server /data


2、 spring实现单文件获取和上传功能

2.1 在pom.xml中引入minio api

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.10</version>
</dependency>


2.2 配置文件构建minioClient

MinioConfig.java



@Configuration
public class MinioConfig {
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.accessKey}")
    private String accessKey;
    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

MinioService.java

@Autowired
private MinioClient minioClient;

2.3 getFile方法的实现

MinioService.java

@Async
public CompletableFuture<ResponseEntity<InputStreamResource>> getFile(
    String fileName,
    String bucketName,
    HttpServletRequest request
) {
    try {
        // etag 支持
        // 1. 获取文件元数据(包含 ETag)
        StatObjectResponse stat = minioClient.statObject(
            StatObjectArgs.builder()
                .bucket(bucketName)
                .object(fileName)
                .build()
        );
        String etag = stat.etag(); // 从 MinIO 元数据获取 ETag
        
        // 2. 检查客户端 ETag(If-None-Match)
        String clientEtag = request.getHeader("If-None-Match");
        if (clientEtag != null && clientEtag.equals(etag)) {
            // ETag 匹配,返回 304 未修改
            return CompletableFuture.completedFuture(ResponseEntity.status(HttpStatus.NOT_MODIFIED)
            .eTag(etag)
            .build());
        }
        // 3. 从 MinIO 获取文件流
        InputStream inputStream = minioClient.getObject(
            GetObjectArgs.builder()
                    .bucket(bucketName)
                    .object(fileName)
                    .build()
        );
        // 4. 设置 HTTP 响应头,支持浏览器访问
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()));
        headers.add(HttpHeaders.CACHE_CONTROL, "public, max-age=3600");

        ResponseEntity<InputStreamResource> response = ResponseEntity.ok()
                .headers(headers)
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .eTag(etag)
                .body(new InputStreamResource(inputStream));

        return CompletableFuture.completedFuture(response);
    } catch (ErrorResponseException | InsufficientDataException | InternalException | InvalidResponseException | ServerException | XmlParserException | IOException | IllegalArgumentException | InvalidKeyException | NoSuchAlgorithmException e) {
        return CompletableFuture.completedFuture(ResponseEntity.notFound().build());
    }
}


2.4 uploadFile的实现(单文件)

@Async
public CompletableFuture<ResponseEntity<ApiResponse<Object>>> uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("bucketName") String bucketName) {
    try {
        // 1. 确保存储桶存在
        boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        if (!found) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
        }

        // 2. 生成唯一文件名
        String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename();

        // 3. 上传到 MinIO
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(fileName)
                        .stream(file.getInputStream(), file.getSize(), -1)
                        .contentType(file.getContentType())
                        .build()
        );
            
        // 4. 这里上传完成后需要生成api访问
        HashMap<String, Object> responseMap = new HashMap<>();
        responseMap.put("fileName", fileName);
        responseMap.put("bucketName", bucketName);
        String url = ApiPrefix.files + "/get/" + bucketName + "/" + fileName;
        responseMap.put("url", url);
        return CompletableFuture.completedFuture(ApiResponseUtil.success(responseMap));
    } catch (ErrorResponseException | InsufficientDataException | InternalException | InvalidResponseException | ServerException | XmlParserException | IOException | IllegalArgumentException | InvalidKeyException | NoSuchAlgorithmException e) {
        return CompletableFuture.completedFuture(ApiResponseUtil.error("上传失败: " + e.getMessage()));
    }
}


3、前端实现单文件获取和请求

3.1 这里以单图片上传和加载为例

3.2 一般情况下直接使用返回的url调用后端接口即可

<img src="api/get/{bucketName}/{fileName}" />


3.3 预加载并利用http缓存避免重复连接minio获取资源(这里是背景大图的获取和缓存)

// 检查 blob URL 是否有效
const checkUrlValidity = (url: string): Promise<boolean> => {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => resolve(true);
    img.onerror = () => resolve(false);
    img.src = url;
    setTimeout(() => resolve(false), 2000); // 超时判定为失效
  });
};

const getBackground = async (fileUrl: string, storageKey: string) => {
  const headers: HttpRequestHeader = {}
  const { etag, url } = getLocalStorage<BgInStorage>(storageKey) || {};
    
  // 1. 验证缓存是否有效
  let isValid = false;
  if (url) {
    isValid = await checkUrlValidity(url);
  }
  
  // 2. header设置etag
  
  if (etag && isValid) {
    headers['If-None-Match'] = etag;
  }

  const response = await fetch(fileUrl, { headers });
  if (response.status === HttpStatusCode.NotModified) {
    return url;
  }

  if (response.ok) {
    // 3. 保存新的 ETag
    const etag = response.headers.get('ETag') || '';
    // 4. 处理文件流
    const blob = await response.blob();
    const url = URL.createObjectURL(blob);

    setLocalStorage<BgInStorage>(storageKey, {
      etag,
      url,
    });
    
    // 5. 返回处理好的blob,便于显示

    return url;
  }
}