一佳技术

easy-oss 对象存储客户端组件设计

本文档描述 easy-oss 组件的设计与规划。该组件是后端中立的对象存储客户端抽象层,通过 SPI 接口(ObjectStorage)抽象「上传 / 下载 / 删除 / 元数据 / 预签名 URL」等能力,MinIO(io.minio:minio)作为首个且默认的后端实现。应用按需引入,minio 依赖为 optional scope,后续可扩展阿里云 OSS / 腾讯云 COS / 华为 OBS 等 S3 兼容后端。设计采用「门面接口 → 抽象代理 → 后端 SPI → Connector 实现」的分层模式,与 easy-messaging / easy-store 一致。


一、背景与问题

1.1 为什么要独立对象存储抽象层

Matrix Framework 的多个业务模块(网关固件包、平台运行镜像、报表导出、用户头像 / 富文本附件等)都需要文件存取能力。不同部署场景对对象存储后端的选择不同:

后端适用场景选择者S3 兼容
MinIO私有化部署、开源、轻量生产 / 内网✅
阿里云 OSS公有云、国内生态公网用户✅
腾讯云 COS / 华为 OBS公有云、跨国公网用户✅
本地磁盘测试 / POC、零外部依赖开发期❌

如果业务层硬编码 MinioClient,后续切换 OSS / COS 需改动所有调用方;且一个服务里混用多个后端时维护成本高。easy-oss 通过 SPI 把后端差异隔离在 Connector 层,业务层只依赖 ObjectStorage 门面。

1.2 为什么「客户端」先行

对象存储的接入有两种形态:

  • 客户端(客户端组件):在业务 JVM 内直接持有 SDK,调用方把文件流 / 字节传给组件,组件负责与对象存储后端交互。本期实现。
  • 服务端(中间件):部署一个独立的 OSS 网关服务,向前端浏览器 / 外部系统暴露 HTTP / GraphQL 接口,统一做鉴权、预签名令牌签发、文件 URL 代理等。本期只做规划,不实现(见第八章)。

客户端先行是因为它解决的是「业务代码里怎么写文件存取」这一最频繁、最基础的问题,边界清晰、见效快。


二、组件定位与边界

定位:对象存储客户端门面。业务层注入 ObjectStorage,调用 putString / putStream / getString / statObject / presignedGetUrl 等即可完成文件存取,底层由配置驱动可用的后端 Connector。

边界:

做不做
ObjectStorage 门面接口 + 抽象代理不做对象存储服务端(MinIO 服务端 / 中间件网关)
BackendStorage SPI + MinIO Connector 实现不做文件内容解析 / 压缩 / 加解密(由使用方处理)
统一对象路径规范(bucket + 对象 key 构造)不做文件分库 / 快照管理(可在 Application 层编排)
预签名 URL(上传 / 下载、有限时效)不做 CDN / 回源代理
元数据查询(stat / 列表 / 数 bucket 是否存在)不做文件类型识别 / 病毒扫描
大文件分片上传封装(putLarge)不做自定义 storage 前端页面(归 MinIO Console)

约束:

  • MinIO 为 optional scope:核心 ObjectStorage / SPI 不依赖第三方 SDK,只有引用 minio 依赖并配置 easy.oss.* 时才装配 MinIO Connector
  • 同一应用只启用一个对象存储后端:所有 Connector AutoConfiguration 使用 @ConditionalOnMissingBean(ObjectStorage.class) 互斥,避免多后端并存
  • 对象 key 由调用方或命名工厂生成:组件提供默认 key 工厂,但业务方可通过 ObjectKeyFactory SPI 覆盖(多租户隔离见第五章)
  • 预签名 URL 依赖后端实现:OSS / COS 的预设 URL 签名方案可能与 MinIO 不同,presignedGetUrl 是 SPI 方法而非门面层的通用逻辑

三、总体架构

flowchart TD
    subgraph "用户层"
        A1["ObjectStorage (门面接口)<br/>putString / putStream / getString /<br/>statObject / deleteObject / presignedGetUrl"]
        A2["AbstractObjectStorage (抽象代理)<br/>持有 BackendObjectStorage<br/>对象 key 工厂 + bucket 校验"]
        A3["FileObject (值对象)<br/>objectName / contentType / size / etag / metadata"]
        A4["ObjectKeyFactory (SPI)<br/>生成对象 key(默认按租户+日期排列)"]
    end

    subgraph "SPI 层"
        B1["BackendObjectStorage (SPI)<br/>putObject / getObject / deleteObject<br/>statObject / presignedUrl"]
    end

    subgraph "Connector 层(首个实现,互斥)"
        C1["MinioObjectStorage<br/>io.minio.MinioClient<br/>支持 S3 兼容 / PathStyle"]
    end

    subgraph "自动配置层"
        D1["OssAutoConfiguration<br/>@ConditionalOnProperty(easy.oss.*)<br/>+ @ConditionalOnMissingBean 互斥"]
        D2["ObjectStorageProperties<br/>endpoint / accessKey / secretKey<br/>bucket / region / secure"]
    end

    A1 --> A2
    A2 --> B1
    A2 --> A4
    B1 --> C1
    C1 --> D2
    D1 -->|"注入 BackendObjectStorage"| B1
    D1 --> A2

一次上传的协作流:

flowchart TD
    U1["ObjectStorage.putString(endpoint, content)"]
    U2["ObjectKeyFactory.newKey(endpoint, fileName)"]
    U3["bucket 校验(存在 → 建)"]
    U4["BackendObjectStorage.putObject(bucket, key, bytes)"]
    U5["FileObject{objectName, contentType, size, etag}"]
    U1 --> U2 --> U3 --> U4 --> U5

四、核心 API 草图

ObjectStorage(门面接口)

public interface ObjectStorage {
    // 上传:字符串 / 字节 / 流 / 文件
    FileObject putString(String endpoint, String content, String contentType);
    FileObject putString(String endpoint, String fileName, String content, String contentType);
    FileObject putBytes(String endpoint, byte[] bytes, String contentType);
    FileObject putBytes(String endpoint, String fileName, byte[] bytes, String contentType);
    FileObject putStream(String endpoint, String fileName, InputStream in, String contentType, long size);
    FileObject putFile(String endpoint, String fileName, File file, String contentType);

    // 下载:按端点取回字节 / 流
    byte[] getBytes(String endpoint);
    InputStream getStream(String endpoint);

    // 元数据 / 列表
    FileObject statObject(String endpoint);
    boolean bucketExists(String bucket);
    void makeBucket(String bucket, String region);
    List<String> listObjects(String bucket, String prefix);

    // 删除
    boolean deleteObject(String endpoint);

    // 预签名 URL(下载)
    String presignedGetUrl(String endpoint, Duration expire);
    String presignedPutUrl(String bucket, String objectName, Duration expire);
}

BackendObjectStorage(SPI)

// 后端中立的能力契约,只管与具体对象存储后端交互,不管 key 构造 / 业务语义
public interface BackendObjectStorage {

    void putObject(String bucket, String objectName,
                   InputStream in, String contentType,
                   long size, Map<String, String> headers);

    InputStream getObject(String bucket, String objectName);

    StatObjectResult statObject(String bucket, String objectName);

    boolean deleteObject(String bucket, String objectName);

    boolean bucketExists(String bucket);

    void makeBucket(String bucket, String region);

    Iterable<ListObjectsResult> listObjects(String bucket, String prefix);

    String presignedGetUrl(String bucket, String objectName, Duration expire);

    String presignedPutUrl(String bucket, String objectName, Duration expire);
}

FileObject(值对象)

@Data
public class FileObject {
    String objectName;    // 对象 key(含路径)
    String bucket;        // 所在桶
    String contentType;   // MIME 类型
    long size;            // 字节数
    String etag;          // 服务端 ETag
    Map<String, String> metadata;  // 自定义元数据
    String endpoint;      // 业务侧约定的端点标识
}

endpoint 是「业务语义端点」而非 MinIO 物理地址:它由调用方定义并作为对象 key 的一部分参与生成(如 avatar / report / firmware),与 easy.oss.* 的物理后端解耦。


五、对象路径与命名规范

对象 key 采用 {tenant}/{endpoint}/{yyyy/MM/dd}/{uuid}-{filename} 三段式,兼顾多租户隔离、可读性与前缀检索:

flowchart LR
    A["对象 key"] --> B["{tenant}<br/>租户隔离层"]
    B --> C["{endpoint}<br/>业务语义(avatar/report/firmware)"]
    C --> D["{yyyy/MM/dd}<br/>按天分区,支持前缀检索"]
    D --> E["{uuid}-{filename}<br/>防重名 + 可读"]

规则要点:

规则说明
tenant 前缀首个实现由 Spring Security 上下文注入;无上下文时可用 default
endpoint 约定调用方声明,常量集中管理,避免散落字符串
按天分区yyyy/MM/dd 本地时区,方便按天归档 / 清理
文件名处理保留原 filename 作后缀增强可读性,云厂商文件名分号 : / 反斜杠等会做 URI 安全转义
大文件不限制单对象 size,MinIO 原生支持 5GiB+ 分片

可扩展点:默认实现由 ObjectKeyFactory 提供;业务方可自实现 ObjectKeyFactory 覆盖(如去掉 uuid、改用业务单号)。


六、后端 SPI 与 MinIO Connector

6.1 SPI 分层

easy-oss/
└── io/matrix/oss/
    ├── ObjectStorage.java            # 门面接口(业务语义 + 对象 key 组装)
    ├── AbstractObjectStorage.java    # 抽象代理:持有 BackendObjectStorage + ObjectKeyFactory
    ├── FileObject.java               # 对象值对象
    ├── key/
    │   ├── ObjectKeyFactory.java     # 对象 key 生成 SPI
    │   └── DefaultObjectKeyFactory.java  # 默认实现(tenant/endpoint/date/uuid)
    └── storage/
        ├── BackendObjectStorage.java # 后端中立 SPI
        ├── minio/
        │   ├── MinioObjectStorage.java            # MinIO Connector 实现
        │   └── MinioObjectStorageAutoConfig.java  # 自动配置
    └── autoconfigure/
        ├── OssProperties.java        # easy.oss.* 配置属性
        └── OssAutoConfiguration.java # 门面 + SPI 装配
类职责
ObjectStorage顶层门面契约;组合 BackendObjectStorage + ObjectKeyFactory
AbstractObjectStorage数据持有者:BackendObjectStorage + ObjectKeyFactory;负责 bucket 校验、默认 endpoint 复用
ObjectKeyFactory对象 key 生成 SPI;newKey(endpoint, originalName, context)
BackendObjectStorage后端中立能力契约;put / get / delete / stat / list / presigned
MinioObjectStorageMinIO Connector;封装 io.minio.MinioClient
MinioObjectStorageAutoConfig@ConditionalOnClass(MinioClient) + @ConditionalOnProperty(easy.oss.minio.*) 装配 MinIO
OssProperties配置属性;见第七章

6.2 MinIO Connector 实现要点

// 构造 MinioClient:端点 / 凭据 / 区域 / secure
MinioClient.Builder builder = MinioClient.builder()
        .endpoint(endpoint)     // endpoint 未带协议时按 secure 自动补全 http(s)
        .credentials(accessKey, secretKey);
if (region 非空) builder.region(region);
// 注:minio-java 8.5.x 无 forcePathStyle API;对 IP / localhost 自动走 path-style 寻址
  • 上传:client.putObject(PutObjectArgs),支持 ByteArrayInputStream / ObjectWriteResponse 返回 etag
  • 下载:client.getObject(GetObjectArgs) 返回 InputStream
  • 元数据:client.statObject(StatObjectArgs) → size / etag / contentType / userMetadata
  • 删除:client.removeObject(RemoveObjectArgs)
  • 预签名:client.getPresignedObjectUrl(GetPresignedObjectUrlArgs),Method.GET / Method.PUT + expiry
  • bucket:client.bucketExists / client.makeBucket 兜底自动建桶

依赖范围:io.minio:minio 为 optional。minio 自身依赖 okhttp / bouncycastle,版本对齐见第九章决策。


七、Spring Boot 自动装配

配置属性(easy.oss.*)

easy:
  oss:
    enabled: false                     # 总开关,默认关闭
    default-bucket: matrix             # 默认桶(给小写,3 位以上,自动创建)
    key-factory: default               # 对象 key 工厂名称
    minio:                             # 仅当引入 minio 依赖时生效
      endpoint: http://127.0.0.1:9000
      access-key: admin
      secret-key: password
      region: us-east-1
      secure: false                    # https 与否
属性默认值说明
easy.oss.enabledfalse总开关;false 时不创建任何 Bean
easy.oss.default-bucketmatrix默认桶,未指定 bucket 的调用走它
easy.oss.minio.endpoint—MinIO 服务地址
easy.oss.minio.access-key / secret-key—访问凭据
easy.oss.minio.regionus-east-1区域
easy.oss.minio.securefalse是否 HTTPS(endpoint 未带协议时补全)
easy.oss.tenantdefault对象 key 的租户前缀

装配顺序

flowchart TD
    S1["easy.oss.enabled=true"]
    S2{"class 含 MinioClient?"}
    S3["MinioObjectStorageAutoConfig<br/>@ConditionalOnClass + @ConditionalOnProperty(easy.oss.minio.*)"]
    S4["MinioObjectStorage Bean (BackendObjectStorage)"]
    S5["OssAutoConfiguration<br/>@ConditionalOnMissingBean(ObjectStorage)<br/>注册 AbstractObjectStorage + DefaultObjectKeyFactory"]
    S6["注入 ObjectStorage ✔"]
    S1 --> S2
    S2 -- "是" --> S3 --> S4 --> S5 --> S6
    S2 -- "否" --> S7["提示缺少后端依赖<br/>不装配"]
  • 互斥:OssAutoConfiguration 使用 @ConditionalOnMissingBean,允许使用方自定义 ObjectStorage Bean 覆盖
  • 懒初始化:MinioClient 使用 Spring 管理,避免 @PostConstruct;putObject 时按需建桶
  • 异常包装:遵循框架约定,将下层 MinioException / 网络异常包装为统一 OssException

八、与中间件服务端的衔接(规划)

本期不实现中间件服务端,以下仅为架构预留与演进方向,边界上保证客户端组件可被服务端复用。

为什么需要服务端:

  • 浏览器直传:前端无法持有业务 access-key,需要服务端签发预签名 PUT URL(safe 直传)
  • 统一鉴权:服务端做租户 / 权限校验,客户端组件只管存取
  • URL 代理 / 外网映射:统一内网 MinIO 与公网访问的 URL 归一化
flowchart TD
    subgraph "浏览器 / 外部系统"
        FE["前端页面"]
    end
    subgraph "中间件 easy-oss-server(规划)"
        GW["OSS 网关服务<br/>REST / GraphQL"]
        AUTH["鉴权 + 租户解析"]
        SIGN["预签名 URL 签发"]
        PROXY["下载 / 上传代理"]
    end
    subgraph "复用客户端能力"
        OS["ObjectStorage 门面<br/>(本组件)"]
    end
    subgraph "对象存储后端"
        MINIO["MinIO"]
    end

    FE -->|"PUT 直传(预签名 URL)"| MINIO
    FE -->|"请求上传凭证"| GW
    GW --> AUTH --> SIGN
    SIGN --> OS
    OS --> MINIO
    PROXY --> OS

演进路径:

  1. 阶段一(本期):只做客户端组件,业务 JVM 内置 SDK 直接存取
  2. 阶段二(规划):新增 middlewares/easy-oss-server,复用 ObjectStorage 门面,提供预签名 URL 签发接口,支持浏览器直传大文件
  3. 阶段三(规划):URL 代理、图片裁剪 / 缩略图、生命周期(自动分层 / 过期清理)策略

九、关键设计决策

决策内容理由
门面 + 抽象代理 + SPI 三层ObjectStorage 定契约、AbstractObjectStorage 持有依赖、BackendObjectStorage 隔离后端与 easy-messaging / easy-store 架构一致,后端可插拔
MinIO 为 optional核心不依赖第三方 SDK,引用 minio + 配置才装配避免强制引入 okhttp / bouncycastle 传递依赖
对象 key 三段式(tenant/endpoint/date/uuid)由 ObjectKeyFactory 生成,可 SPI 覆盖多租户隔离 + 按天分区检索 + 防重名
bucket 自动创建putObject 前 bucketExists 兜底 makeBucket降低接入成本,避免手动建桶报错
endpoint 与物理后端解耦业务传「端点标识」,key 工厂拼装;物理地址走配置业务侧不感知后端细节,便于切换
预签名 URL 放 SPIpresignedGetUrl 由后端实现不同存储的签名算法不同,不上升为门面通用逻辑
多后端互斥@ConditionalOnMissingBean(ObjectStorage)同一应用只启用一个后端,避免歧义

依赖管理(待补充至 kernel-dependencies BOM):

依赖scope说明
io.minio:minio:8.5.xoptionalMinIO SDK;建议在 BOM <dependencyManagement> 中统一定版本
spring-boot-startercompile自动配置支持
lombokoptional@Data

十、测试覆盖规划

测试项方式断言
ObjectKeyFactory 默认三段式单测(Mock 租户上下文)key 符合 tenant/endpoint/date/uuid-filename
AbstractObjectStorage 门面组装单测 + Mockito Mock BackendObjectStorageputStream 委托正确、返回 FileObject 字段完整
默认 bucket 自动创建单测 Mock bucketExists=false → 调 makeBucket建桶被调用
MinioObjectStorage 上传单测 Mock MinioClient(或 minio-mock/testcontainers)etag / size 透传
presigned URL单测 Mock 签名返回 URL 含过期参数
自动装配条件@SpringBootTest 条件开关enabled=false 无 Bean;配 minio 后 Bean 存在

前端 / 中间件相关不在本期测试范围;MinIO 真实连通性测试可选用 Testcontainers MinIO 容器在 CI 中执行。