Quiet 项目简介:/post/717122…

上一篇:

作为一个后端 Java 开发,为何、如何自己完成一个 Markdown 编辑器

前言

在上一篇文章中,Markdown 编辑器还没有完成图片上传的功用,要完成图片上传,那么后端服务就需求支撑文件上传,文件上传是许多后端服务需求的功用,能够完成一个 Spring Boot Starter 来支撑文件的相关功用,比方文件上传、预览、下载、删去等。

完成原理:Creating Your Own Auto-configuration

开源组件版别

Spring Boot:2.6.3

Minio:8.2.1

装置 Minio

Minio 简介:min.io/

# 拉取 minio 镜像
docker pull minio/minio
# 运转 minio 服务
docker run -n minio-dev -p 7000:9000 -p 7001:9001 minio/minio server /data --console-address ":9001"

启动完成后,本地拜访:http://localhost:7001/

项目依靠

因为是嵌入一个文件服务,在平常的 Spring Boot 项目中能够查看项目的健康状况,那么 Minio 服务的状况也增加进项目健康状况中,这样就能监控 Minio 的服务状况了,所以需求增加依靠 org.springframework.boot:spring-boot-starter-actuator,还需求支撑文件相关接口,需求增加org.springframework.boot:spring-boot-starter-webio.minio:minio:8.2.1 依靠。

Starter 完成

自定义 Spring Boot Starter 是 Spring Boot 一个比较常用的扩展点,能够运用这个扩展点为应用供给默认装备或许自定义功用等。

准备

新建一个 quiet-minio-spring-boot-starter 项目,同时新建一个 spring.factories 文件。

spring.factories 文件在 Spring Boot 2.7.0 版别已过期,该版别以上的请自行适配。

如何结合 Minio 实现一个简单的可嵌入的 Spring Boot Starter 文件服务

Config Properties

classifications:图片分类,一个项目中大部分不止一个地方用到文件上传,为了更好办理文件资源,后端能够限制能够上传的文件分类,以 / 切割能够在 Minio 中创立文件夹,完成分文件夹办理文件。

objectPrefix:拜访文件时的前缀,不同的服务,它有不同的 URL,不同的端口号,这个不是有必要的,但是在后端一致装备更方便一致办理,这个能够根据团队的标准自行决定是否运用。

/**
 * @author <a href="mailto:lin-mt@outlook.com">lin-mt</a>
 */
@Slf4j
@Getter
@Setter
@ConfigurationProperties(prefix = "quiet.minio")
public class MinioConfigurationProperties implements InitializingBean {
  private String url = "http://localhost:7000";
  private String accessKey;
  private String secretKey;
  private String bucketName;
  private Set<String> classifications;
  private String objectPrefix;
  private Duration connectTimeout = Duration.ofSeconds(10);
  private Duration writeTimeout = Duration.ofSeconds(60);
  private Duration readTimeout = Duration.ofSeconds(10);
  private boolean checkBucket = true;
  private boolean createBucketIfNotExist = true;
  @Override
  public void afterPropertiesSet() {
    Assert.hasText(accessKey, "accessKey must not be empty.");
    Assert.hasText(secretKey, "secretKey must not be empty.");
    Assert.hasText(bucketName, "bucketName must not be empty.");
    Assert.hasText(objectPrefix, "objectPrefix must not be empty.");
  }
}

Configuration

  1. 创立一个装备类,在这个类中,运用咱们上一步供给的装备信息,注入一个 Bean 实例 MinioClient,所有文件的相关操作都能够经过这个 Bean 完成。
  2. spring.factory 文件中需求增加:org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.gitee.quiet.minio.config.QuietMinioConfiguration

该过程是完成项目嵌入 Minio 服务的关键,详细的原理能够看源码 org.springframework.boot.autoconfigure.AutoConfigurationImportSelectororg.springframework.core.io.support.SpringFactoriesLoader

/**
 * @author <a href="mailto:lin-mt@outlook.com">lin-mt</a>
 */
@Slf4j
@Configuration
@AllArgsConstructor
@ComponentScan("com.gitee.quiet.minio")
@EnableConfigurationProperties(MinioConfigurationProperties.class)
public class QuietMinioConfiguration {
  private final MinioConfigurationProperties properties;
  @Bean
  public MinioClient minioClient()
      throws ServerException, InsufficientDataException, ErrorResponseException, IOException,
          NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException,
          XmlParserException, InternalException {
    MinioClient minioClient =
        MinioClient.builder()
            .endpoint(properties.getUrl())
            .credentials(properties.getAccessKey(), properties.getSecretKey())
            .build();
    minioClient.setTimeout(
        properties.getConnectTimeout().toMillis(),
        properties.getWriteTimeout().toMillis(),
        properties.getReadTimeout().toMillis());
    if (properties.isCheckBucket()) {
      String bucketName = properties.getBucketName();
      BucketExistsArgs existsArgs = BucketExistsArgs.builder().bucket(bucketName).build();
      boolean bucketExists = minioClient.bucketExists(existsArgs);
      if (!bucketExists) {
        if (properties.isCreateBucketIfNotExist()) {
          MakeBucketArgs makeBucketArgs = MakeBucketArgs.builder().bucket(bucketName).build();
          minioClient.makeBucket(makeBucketArgs);
        } else {
          throw new IllegalStateException("Bucket does not exist: " + bucketName);
        }
      }
    }
    return minioClient;
  }
}

Controller

供给文件相关操作的接口,比方文件上传、下载、删去、预览等。

/**
 * @author <a href="mailto:lin-mt@outlook.com">lin-mt</a>
 */
@Slf4j
@RestController
@AllArgsConstructor
@RequestMapping("/minio")
public class MinioController {
  private final MinioService minioService;
  private final MinioConfigurationProperties properties;
  private final Optional<MinioHandler> minioHandler;
  private String getFileName(String object) {
    if (StringUtils.isBlank(object)) {
      return UUID.randomUUID().toString().replace("-", "");
    }
    if (!object.contains("/") || object.endsWith("/")) {
      return object;
    }
    return object.substring(object.lastIndexOf("/") + 1);
  }
  private FileResponse buildFileResponse(StatObjectResponse metadata, Tags tags) {
    FileResponse.FileResponseBuilder builder = FileResponse.builder();
    String object = metadata.object();
    String objectPrefix = properties.getObjectPrefix();
    if (!objectPrefix.endsWith("/")) {
      objectPrefix = objectPrefix + "/";
    }
    objectPrefix = objectPrefix + "minio/";
    builder
        .object(object)
        .detailPath(objectPrefix + "detail/" + object)
        .viewPath(objectPrefix + "view/" + object)
        .downloadPath(objectPrefix + "download/" + object)
        .deletePath(objectPrefix + "delete/" + object)
        .lastModified(metadata.lastModified().toLocalDateTime())
        .fileSize(metadata.size())
        .filename(getFileName(metadata.object()))
        .contentType(metadata.contentType())
        .userMetadata(metadata.userMetadata())
        .headers(metadata.headers());
    if (tags != null) {
      builder.tags(tags.get());
    }
    return builder.build();
  }
  @SneakyThrows
  @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
  @ResponseStatus(HttpStatus.CREATED)
  public ResponseEntity<List<FileResponse>> fileUpload(
      @RequestParam("classification") String classification,
      @RequestPart("files") List<MultipartFile> files) {
    minioHandler.ifPresent(handler -> handler.beforeUpload(classification, files));
    if (CollectionUtils.isEmpty(files)) {
      return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }
    if (!properties.getClassifications().contains(classification)) {
      throw new IllegalArgumentException("classification is not config.");
    }
    List<FileResponse> responses = new ArrayList<>(files.size());
    for (MultipartFile file : files) {
      String fileId = UUID.randomUUID().toString().replace("-", "");
      String originalFilename = file.getOriginalFilename();
      if (originalFilename == null) {
        originalFilename = fileId;
      }
      StringBuilder fileName = new StringBuilder(fileId);
      if (originalFilename.contains(".")) {
        fileName.append(originalFilename.substring(originalFilename.lastIndexOf(".")));
      }
      Path source = Path.of(classification, fileName.toString());
      Multimap<String, String> userMetadata = ArrayListMultimap.create(1, 1);
      userMetadata.put("original_file_name", originalFilename);
      minioService.upload(source, file.getInputStream(), null, userMetadata);
      responses.add(
          buildFileResponse(minioService.getMetadata(source), minioService.getTags(source)));
    }
    AtomicReference<List<FileResponse>> reference = new AtomicReference<>(responses);
    minioHandler.ifPresent(handler -> reference.set(handler.afterUpload(responses)));
    return ResponseEntity.status(HttpStatus.CREATED)
        .contentType(MediaType.APPLICATION_JSON)
        .body(reference.get());
  }
  @GetMapping("/view/**")
  @ResponseStatus(HttpStatus.OK)
  public ResponseEntity<InputStreamResource> viewFile(HttpServletRequest request) {
    String object = request.getRequestURL().toString().split("/view/")[1];
    minioHandler.ifPresent(handler -> handler.beforeView(object));
    Path objectPath = Path.of(object);
    InputStream inputStream = minioService.get(objectPath);
    StatObjectResponse metadata = minioService.getMetadata(objectPath);
    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(metadata.contentType()))
        .contentLength(metadata.size())
        .header("Content-disposition", "attachment; filename=" + getFileName(metadata.object()))
        .body(new InputStreamResource(inputStream));
  }
  @GetMapping("/download/**")
  @ResponseStatus(HttpStatus.OK)
  public ResponseEntity<InputStreamResource> downloadFile(HttpServletRequest request) {
    String object = request.getRequestURL().toString().split("/download/")[1];
    minioHandler.ifPresent(handler -> handler.beforeDownloadGetObject(object));
    Path objectPath = Path.of(object);
    InputStream inputStream = minioService.get(objectPath);
    StatObjectResponse metadata = minioService.getMetadata(objectPath);
    AtomicReference<StatObjectResponse> ref = new AtomicReference<>(metadata);
    minioHandler.ifPresent(
        handler -> {
          StatObjectResponse response = handler.beforeDownload(metadata);
          if (response == null) {
            log.warn("response can not be null.");
          } else {
            ref.set(response);
          }
        });
    return ResponseEntity.ok()
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .contentLength(ref.get().size())
        .header("Content-disposition", "attachment; filename=" + getFileName(ref.get().object()))
        .body(new InputStreamResource(inputStream));
  }
  @DeleteMapping("/delete/**")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  public ResponseEntity<Object> removeFile(HttpServletRequest request) {
    String object = request.getRequestURL().toString().split("/delete/")[1];
    minioHandler.ifPresent(handler -> handler.beforeDelete(object));
    Path objectPath = Path.of(object);
    minioService.remove(objectPath);
    minioHandler.ifPresent(handler -> handler.afterDelete(object));
    return ResponseEntity.noContent().build();
  }
  @GetMapping("/detail/**")
  @ResponseStatus(HttpStatus.OK)
  public ResponseEntity<FileResponse> getFileDetail(HttpServletRequest request) {
    String object = request.getRequestURL().toString().split("/detail/")[1];
    minioHandler.ifPresent(handler -> handler.beforeGetDetail(object));
    Path objectPath = Path.of(object);
    StatObjectResponse metadata = minioService.getMetadata(objectPath);
    FileResponse response = buildFileResponse(metadata, minioService.getTags(objectPath));
    AtomicReference<FileResponse> reference = new AtomicReference<>(response);
    minioHandler.ifPresent(handler -> reference.set(handler.afterGetDetail(response)));
    return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(reference.get());
  }
}

MinioHandler

因为这是一个嵌入式的文件服务,在进行文件操作的时候,不同的项目或许需求做一些自定义操作,那么咱们需求供给一些扩展点,这也是软件规划的原则之一:对扩展开放,对修改关闭。当然,这个扩展点可供给也可不供给,详细完成能够根据自己的团队标准进行规划。

/**
 * @author <a href="mailto:lin-mt@outlook.com">lin-mt</a>
 */
public interface MinioHandler {
  default void beforeUpload(String classification, List<MultipartFile> files) {}
  default List<FileResponse> afterUpload(List<FileResponse> responses) {
    return responses;
  }
  default void beforeView(String object) {}
  default void beforeDownloadGetObject(String object) {}
  default StatObjectResponse beforeDownload(StatObjectResponse response) {
    return response;
  }
  default void beforeDelete(String object) {}
  default void afterDelete(String object) {}
  default void beforeGetDetail(String object) {}
  default FileResponse afterGetDetail(FileResponse response) {
    return response;
  }
}

MinioService

MinioService 主要是对 MinioClient 的常用 API 的简略封装。

/**
 * @author <a href="mailto:lin-mt@outlook.com">lin-mt</a>
 */
@Service
@AllArgsConstructor
@ConditionalOnBean({MinioClient.class, MinioProperties.class})
public class MinioService {
  private final MinioClient minioClient;
  private final MinioProperties properties;
  @SneakyThrows
  public Tags getTags(Path path) {
    GetObjectTagsArgs args =
        GetObjectTagsArgs.builder()
            .bucket(properties.getBucketName())
            .object(path.toString())
            .build();
    return minioClient.getObjectTags(args);
  }
  @SneakyThrows
  public InputStream get(Path path) {
    GetObjectArgs args =
        GetObjectArgs.builder().bucket(properties.getBucketName()).object(path.toString()).build();
    return minioClient.getObject(args);
  }
  @SneakyThrows
  public StatObjectResponse getMetadata(Path path) {
    StatObjectArgs args =
        StatObjectArgs.builder().bucket(properties.getBucketName()).object(path.toString()).build();
    return minioClient.statObject(args);
  }
  @SneakyThrows
  public void upload(
      Path source,
      InputStream file,
      String contentType,
      Multimap<String, String> userMetadata,
      Multimap<String, String> headers,
      Map<String, String> tags) {
    PutObjectArgs.Builder builder =
        PutObjectArgs.builder().bucket(properties.getBucketName()).object(source.toString()).stream(
            file, file.available(), -1);
    if (userMetadata != null) {
      builder.userMetadata(userMetadata);
    }
    if (headers != null) {
      builder.headers(headers);
    }
    if (tags != null) {
      builder.tags(tags);
    }
    if (StringUtils.isNotBlank(contentType)) {
      builder.contentType(contentType);
    }
    minioClient.putObject(builder.build());
  }
  public void upload(
      Path source,
      InputStream file,
      String contentType,
      Multimap<String, String> userMetadata,
      Multimap<String, String> headers) {
    upload(source, file, contentType, userMetadata, headers, null);
  }
  public void upload(
      Path source, InputStream file, String contentType, Multimap<String, String> userMetadata) {
    upload(source, file, contentType, userMetadata, null);
  }
  public void upload(Path source, InputStream file, String contentType) {
    upload(source, file, contentType, null);
  }
  public void upload(Path source, InputStream file) {
    upload(source, file, null);
  }
  @SneakyThrows
  public void remove(Path source) {
    RemoveObjectArgs args =
        RemoveObjectArgs.builder()
            .bucket(properties.getBucketName())
            .object(source.toString())
            .build();
    minioClient.removeObject(args);
  }
}

健康状况查看

这个 Starter 供给了一个文件上传的服务,咱们需求供给监控该服务的健康状况的信息,这部分能够自己增加健康状况的详细信息。

/**
 * @author <a href="mailto:lin-mt@outlook.com">lin-mt</a>
 */
@Component
@AllArgsConstructor
@ConditionalOnClass(ManagementContextAutoConfiguration.class)
public class MinioHealthIndicator implements HealthIndicator {
  private final MinioClient minioClient;
  private final MinioConfigurationProperties properties;
  @Override
  public Health health() {
    if (minioClient == null) {
      return Health.down().build();
    }
    String bucketName = properties.getBucketName();
    try {
      BucketExistsArgs args = BucketExistsArgs.builder().bucket(properties.getBucketName()).build();
      if (minioClient.bucketExists(args)) {
        return Health.up().withDetail("bucketName", bucketName).build();
      } else {
        return Health.down().withDetail("bucketName", bucketName).build();
      }
    } catch (Exception e) {
      return Health.down(e).withDetail("bucketName", bucketName).build();
    }
  }
}

至此,一个简易的开箱即用的文件服务插件就完成了。

示例

创立 accessKey

如何结合 Minio 实现一个简单的可嵌入的 Spring Boot Starter 文件服务

项目中引进 Starter

api project(path: ":quiet-spring-boot-starters:quiet-minio-spring-boot-starter", configuration: "default")

application.yml 装备 Minio

quiet:
  minio:
    bucket-name: ${spring.application.name}
    access-key: 65mtumFyO3xMpUyP
    secret-key: sXBTjKmCtWf8iwOiy8Uw3fCOhe8ibuGV
    object-prefix: http://localhost:8080/doc
    classifications:
      - api/remark

效果图

文件上传

如何结合 Minio 实现一个简单的可嵌入的 Spring Boot Starter 文件服务

服务状况

如何结合 Minio 实现一个简单的可嵌入的 Spring Boot Starter 文件服务

github.com/lin-mt/quie…

源码:quiet-spring-boot-starters/quiet-minio-spring-boot-starter

示例项目:quiet-doc

下一篇:

完成在 Markdown 编辑器中图片的上传和缩放