easy-oss 对象存储客户端组件设计
本文档描述 easy-oss 组件的设计与规划。该组件是后端中立的对象存储客户端抽象层,通过 SPI 接口(
ObjectStorage)抽象「上传 / 下载 / 删除 / 元数据 / 预签名 URL」等能力,MinIO(io.minio:minio)作为首个且默认的后端实现。应用按需引入,minio依赖为 optional scope,后续可扩展阿里云 OSS / 腾讯云 COS / 华为 OBS 等 S3 兼容后端。设计采用「门面接口 → 抽象代理 → 后端 SPI → Connector 实现」的分层模式,与 easy-messaging / easy-store 一致。
- 一、背景与问题
- 二、组件定位与边界
- 三、总体架构
- 四、核心 API 草图
- 五、对象路径与命名规范
- 六、后端 SPI 与 MinIO Connector
- 七、Spring Boot 自动装配
- 八、与中间件服务端的衔接(规划)
- 九、关键设计决策
- 十、测试覆盖规划
一、背景与问题
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 工厂,但业务方可通过
ObjectKeyFactorySPI 覆盖(多租户隔离见第五章) - 预签名 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 |
| MinioObjectStorage | MinIO 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.enabled | false | 总开关;false 时不创建任何 Bean |
easy.oss.default-bucket | matrix | 默认桶,未指定 bucket 的调用走它 |
easy.oss.minio.endpoint | — | MinIO 服务地址 |
easy.oss.minio.access-key / secret-key | — | 访问凭据 |
easy.oss.minio.region | us-east-1 | 区域 |
easy.oss.minio.secure | false | 是否 HTTPS(endpoint 未带协议时补全) |
easy.oss.tenant | default | 对象 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,允许使用方自定义ObjectStorageBean 覆盖 - 懒初始化:
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
演进路径:
- 阶段一(本期):只做客户端组件,业务 JVM 内置 SDK 直接存取
- 阶段二(规划):新增
middlewares/easy-oss-server,复用ObjectStorage门面,提供预签名 URL 签发接口,支持浏览器直传大文件 - 阶段三(规划):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 放 SPI | presignedGetUrl 由后端实现 | 不同存储的签名算法不同,不上升为门面通用逻辑 |
| 多后端互斥 | @ConditionalOnMissingBean(ObjectStorage) | 同一应用只启用一个后端,避免歧义 |
依赖管理(待补充至 kernel-dependencies BOM):
| 依赖 | scope | 说明 |
|---|---|---|
io.minio:minio:8.5.x | optional | MinIO SDK;建议在 BOM <dependencyManagement> 中统一定版本 |
spring-boot-starter | compile | 自动配置支持 |
lombok | optional | @Data |
十、测试覆盖规划
| 测试项 | 方式 | 断言 |
|---|---|---|
| ObjectKeyFactory 默认三段式 | 单测(Mock 租户上下文) | key 符合 tenant/endpoint/date/uuid-filename |
| AbstractObjectStorage 门面组装 | 单测 + Mockito Mock BackendObjectStorage | putStream 委托正确、返回 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 中执行。