本文记录 Tokimo(Web Desktop OS)blob 存储系统的完整重构过程——从各 app 独立管理存储到主进程统一管控、app 通过 RPC 读写的架构演进。涵盖重构动因、两阶段实施路径及最终架构设计。
背景与问题
Tokimo 采用 pnpm monorepo + Rust sidecar 架构,系统中包含多个独立 app(音乐、视频、照片等),每个 app 均需持久化 blob 数据(专辑封面、艺术家头像、视频海报、缩略图等)。
重构前的存储架构
┌─────────────────────────────────────────────────────────────────┐
│ Tokimo 主进程 │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Music App │ │ Video App │ │ Photo App │ ← 各 sidecar │
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Storage │ │ Storage │ │ Storage │ ← 各自持有 │
│ │ Provider │ │ Provider │ │ Provider │ StorageProvider │
│ │ (OpenDAL) │ │ (OpenDAL) │ │ (OpenDAL) │ + 独立实例 │
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ .data/storage/ │ ← 共享物理目录 │
│ │ music/ │ │
│ │ video/ │ │
│ │ tmdb-images/ │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
每个 app 持有独立的 StorageProvider 实例(基于 OpenDAL),均指向同一物理目录 {DATA_LOCAL_PATH}/storage。从功能层面看,该设计能够正常工作——音乐 app 将封面写入 music/ 前缀,视频 app 将海报写入 video/ 前缀,各自维护独立的文件命名空间。
然而,该架构在 sidecar 拆分后暴露了三个结构性缺陷:
问题一:Sidecar 进程间的存储路径不一致
当音乐 app 从主进程内嵌模块拆分为独立 sidecar 进程后,DATA_LOCAL_PATH 环境变量在进程间传递出现偏差。Sidecar 通过 tokimo-app.toml 声明并由主进程 spawn,其工作目录与主进程不同,导致 DATA_LOCAL_PATH 解析结果不一致:
主进程: DATA_LOCAL_PATH = /home/lab/.data/tokimo
Sidecar: DATA_LOCAL_PATH = /home/lab/.data/tokimo/apps/music ← 解析错误
结果是专辑封面被写入 sidecar 的 cwd 而非主进程的存储目录,前端请求 GET /storage/music/ab/cd/uuid/cover.jpg 时无法定位文件,所有图片资源返回 404。
问题二:Key 格式泄露内部 app 结构
原有的 key 格式为 {app_id}/{path}/{filename}:
music/library-images/music/abc123/cover.jpg
video/tmdb-images/poster/550.jpg
该 key 直接暴露在 URL 中(GET /storage/music/library-images/...)。若后续接入 CDN 或启用公开分享,URL 将泄露内部 app 的组织结构和文件命名规则。
问题三:缺乏写隔离,存在跨 app 数据篡改风险
所有 app 共享同一存储目录,且 key 格式未做强制校验。恶意或有缺陷的 app 可以构造其他 app 命名空间下的 key 来删除或覆盖文件,系统缺乏基于 app 维度的访问控制。
Phase 1:Storage Bus Service
识别出上述问题后,核心重构目标明确:将存储访问从 app 自治模式转为主进程集中管控模式。
设计目标
Before: App → 自有 StorageProvider → 本地文件系统
After: App → bus client → bus → 主进程 StorageProvider → 本地文件系统
选择 bus RPC 而非共享文件系统的原因:sidecar 作为独立进程,其 DATA_LOCAL_PATH 可能与主进程不同,且未来可能运行在独立容器中。基于 Unix Domain Socket 的 bus RPC 是进程间唯一可靠的通信通道。
Service 接口定义
主进程通过 bus local service 将存储能力暴露给各 sidecar app,底层依赖开源 crate tokimo-package-storage(后文详述):
┌──────────────────────────────────────────────────────────────┐
│ storage bus service │
├──────────────┬──────────┬────────────────────────────────────┤
│ method │ HTTP │ 说明 │
├──────────────┼──────────┼────────────────────────────────────┤
│ upload │ POST │ 上传文件,返回生成的 key │
│ download │ POST │ 下载文件,返回 base64 编码的 bytes │
│ delete │ POST │ 删除文件 │
│ exists │ POST │ 检查文件是否存在 │
│ head │ POST │ 获取文件元数据(key + size) │
│ list │ POST │ 列出 app 下的文件 │
│ local_path │ POST │ 获取本地绝对路径(仅本地后端) │
└──────────────┴──────────┴────────────────────────────────────┘
传输协议选择: Bus 协议 payload 为 Vec<u8>,文件数据以 base64 编码传输。该方案适用于 artwork/thumbnail 等小文件(通常 < 5MB)。视频等大文件通过已有的 VFS 流式传输机制处理,不经过 storage service。
认证模型: Bus 基于 Unix Domain Socket,调用方在建立连接时已完成身份验证。CallerCtx 中携带 caller_app_id,用于后续的写隔离鉴权。
Phase 2:Opaque Key 与 App 写隔离
Bus service 上线后,key 格式仍为 {app_id}/{path}/{filename},前文识别的 URL 泄露和写隔离问题尚未解决。
Opaque Key 生成策略
新 key 格式: {hex[0:2]}/{hex[2:4]}/{uuid}.{ext}
fn generate_key(_app_id: &str, filename: &str) -> String {
let uuid = Uuid::new_v4();
let hex = uuid.as_simple().to_string();
let ext = filename.rsplit('.').next().unwrap_or("bin");
format!("{}/{}/{uuid}.{ext}", &hex[0..2], &hex[2..4])
}
Before: music/library-images/music/abc123/cover.jpg
After: a3/b8/550e8400-e29b-41d4-a716-446655440000.jpg
设计决策:
| 决策点 | 方案 | 理由 |
|---|---|---|
| key 是否包含 app_id | 不包含 | URL 不泄露任何 app 信息 |
| 是否保留原始文件名 | 不保留 | key 完全 opaque,无语义信息 |
| 前两级目录划分 | UUID hex 前 4 位分两级 | 避免单目录文件数过多(ext4 目录项超 10K 后性能下降) |
| 文件扩展名 | 保留原始扩展名 | 便于 Content-Type 推断 |
storage_objects 元数据表
Opaque key 使 URL 不再包含业务语义,但 app 仍需追踪自己上传的文件。为此引入 storage_objects 表,作为 key 与 app 归属关系的映射层:
CREATE TABLE storage_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE, -- opaque storage key
app_id TEXT NOT NULL, -- 上传方 app 标识
filename TEXT NOT NULL, -- 原始文件名(调试/管理用途)
content_type TEXT, -- MIME type
size BIGINT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_storage_objects_app_id ON storage_objects(app_id);
上传流程:
App 调用 storage.upload({ appId: "music", filename: "cover.jpg", dataBase64: "..." })
│
▼
主进程 storage service:
│
├─ 1. generate_key("music", "cover.jpg")
│ → "a3/b8/550e8400-...-0000.jpg"
│
├─ 2. StorageProvider::upload("a3/b8/...jpg", data)
│ → 写入物理存储
│
├─ 3. INSERT INTO storage_objects (key, app_id, filename, ...)
│ → 记录归属元数据
│
└─ 4. 返回 { key: "a3/b8/550e8400-...-0000.jpg" }
→ App 将 key 存入业务表
删除流程(含写隔离鉴权):
App 调用 storage.delete({ key: "a3/b8/..." })
│
▼
主进程 storage service:
│
├─ 1. SELECT app_id FROM storage_objects WHERE key = ?
│ → owner = "music"
│
├─ 2. caller.caller_app_id == owner ?
│ ├─ YES → 继续执行删除
│ └─ NO → 返回 Unauthorized
│
├─ 3. StorageProvider::delete(key)
│ → 删除物理文件
│
└─ 4. DELETE FROM storage_objects WHERE key = ?
→ 清理元数据记录
访问控制模型:
| 操作 | 鉴权策略 | 设计理由 |
|---|---|---|
| upload | bus 连接级认证 | 仅已注册的 app 可调用 bus service |
| download | 公开读 | 图片/媒体场景下任意 app 均需读取资源 |
| delete | 写隔离(app_id 校验) | 防止 app 间数据误删或恶意篡改 |
| list | 按 app_id 过滤 | 各 app 仅可见自身文件列表 |
最终架构
┌─────────────────────────────────────────────────────────────────────────┐
│ tokimo-package-storage (开源 crate) │
│ ┌───────────────────────────────────────────┐ │
│ │ StorageProvider trait │ │
│ │ OpendalStorageProvider (local filesystem) │ │
│ └───────────────────────────────────────────┘ │
│ ▲ ▲ ▲ │
│ │ dep │ dep │ dep │
├─────────┼────────────────────┼────────────────────┼──────────────────────┤
│ │ Tokimo 主进程 │ │
│ │ │ │
│ ┌──────┴──────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ Bus Broker │ │ storage │ │ storage │ │ │
│ │ (tokimo-bus)│ │ local svc │ │ objects │ │ │
│ │ │ │ │ │ (DB 表) │ │ │
│ └──────┬──────┘ └─────┬─────┘ └───────────┘ │ │
│ │ │ │ │
│ ┌──────┴───────────────┴──────────────────────┐ │ │
│ │ bus RPC (Unix Domain Socket) │ │ │
│ └──┬──────────────┬──────────────┬────────────┘ │ │
│ │ │ │ │ │
│ ┌──┴──────┐ ┌────┴─────┐ ┌────┴─────┐ │ │
│ │ Music │ │ Video │ │ Photo │ │ │
│ │ App │ │ App │ │ App │ │ │
│ │(sidecar)│ │(sidecar) │ │(sidecar) │ │ │
│ └─────────┘ └──────────┘ └──────────┘ │ │
│ │ │
│ ┌───────────────────────────────────────────┐ │ │
│ │ StorageProvider (主进程持有的唯一实例) │◄───┘ │
│ └─────────────────┬─────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ .data/storage/ │ │
│ │ │ │
│ │ a3/b8/uuid1.jpg │ ← opaque key │
│ │ f1/2c/uuid2.png │ │
│ │ 7d/e9/uuid3.jpg │ │
│ └─────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ PostgreSQL │ │
│ │ │ │
│ │ storage_objects: │ │
│ │ ┌──────┬───────────┬─────────┬────────────┐ │ │
│ │ │ key │ app_id │ filename│ size │ │ │
│ │ ├──────┼───────────┼─────────┼────────────┤ │ │
│ │ │ a3/..│ music │ cover │ 45KB │ │ │
│ │ │ f1/..│ video │ poster │ 120KB │ │ │
│ │ │ 7d/..│ music │ avatar │ 22KB │ │ │
│ │ └──────┴───────────┴─────────┴────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
前端通过 GET /storage/{key} 访问文件,该接口为公开读(无需鉴权)。Key 本身为 opaque 格式,不包含任何可推断的业务信息。
设计权衡
集中管控 vs App 自治
Sidecar 为独立进程,拥有各自的 DATA_LOCAL_PATH 和工作目录。若由各 app 自行管理存储路径,每个 app 均需感知"存储根目录"这一全局配置,sidecar 拆分后该配置的传递和一致性维护成本过高。集中管控模式下,app 仅需知道 bus service 的地址,无需关心底层存储细节。
Bus Payload vs S3 预签名 URL
当前阶段文件体积较小(artwork/thumbnail 通常 < 5MB),bus payload 的 Vec<u8> 完全适用。视频等大文件通过已有的 VFS 流式传输机制处理。若后续需支持大文件上传,可扩展 storage.request_upload_url 方法返回预签名 URL,app 直接 PUT 至 S3,主进程仅负责签名生成。
公开读 + 写隔离
这是图片/媒体场景的典型访问模式。封面、海报、头像等资源需要被任意 app 读取(如全局搜索结果页展示跨类型的媒体封面),但写入(尤其是删除)操作必须严格隔离,避免单一 app 的缺陷影响其他 app 的数据完整性。
存储抽象层:tokimo-package-storage
整个重构的基础设施层是开源 crate tokimo-package-storage。它定义了存储系统的统一抽象,使主进程和各 sidecar app 基于同一 trait 编程,后端实现可独立演进。
StorageProvider Trait
#[async_trait]
pub trait StorageProvider: Send + Sync {
async fn upload(&self, key: &str, body: Bytes, options: Option<UploadOptions>) -> Result<(), String>;
async fn upload_opaque(&self, key: &str, body: Bytes, options: Option<UploadOptions>) -> Result<String, String>;
async fn download(&self, key: &str) -> Result<Bytes, String>;
async fn delete(&self, key: &str) -> Result<(), String>;
async fn exists(&self, key: &str) -> Result<bool, String>;
async fn head(&self, key: &str) -> Result<Option<StorageObject>, String>;
async fn list(&self, prefix: Option<&str>) -> Result<Vec<StorageObject>, String>;
fn local_absolute_path(&self, key: &str) -> Option<PathBuf> { None }
}
关键设计点:
| 方法 | 设计意图 |
|---|---|
upload |
传统上传,caller 提供 key |
upload_opaque |
服务端生成 key 并返回,bus storage 的核心接口 |
head |
元数据查询(不下载内容),返回 None 表示不存在 |
local_absolute_path |
仅本地后端实现,将 virtual key 映射为物理路径 |
upload_opaque 的默认实现直接委托给 upload 并返回传入的 key。但在 bus storage 场景下,主进程 override 该方法:忽略 caller 提供的 key,由服务端生成 opaque UUID 路径后返回。这一设计使 app 端代码无需区分"本地直写"和"bus RPC 代理"两种模式。
基于 OpenDAL 的可插拔后端
OpendalStorageProvider 是当前的默认实现,基于 Apache OpenDAL(Open Data Access Layer):
StorageProvider trait
│
▼
OpendalStorageProvider
│
▼
OpenDAL Operator
│
┌────┴────┬────────┬────────┬────────┐
▼ ▼ ▼ ▼ ▼
Local S3/MinIO FTP/SFTP WebDAV ...
(Fs)
OpenDAL 屏蔽了底层存储后端的差异(API 签名、错误码、路径规则),OpendalStorageProvider 只需处理业务逻辑(Content-Type 推断、目录递归过滤、NotFound 错误归一化)。切换存储后端只需修改 Operator::new() 的 service 配置,上层代码零改动。
为何在 OpenDAL 之上再封装一层?
OpenDAL 面向"通用数据访问"场景,Tokimo 的存储系统有三个 OpenDAL 不覆盖的业务需求:
- Opaque key 生成 —
upload_opaque需要在上传时由服务端决定最终 key,OpenDAL 的write_with只接受 caller 提供的 key - App 归属追踪 —
storage_objects表的写入逻辑与存储操作耦合,OpenDAL 不感知"谁上传了什么" - 写隔离鉴权 —
delete操作需要先查storage_objects验证app_id,这是业务层逻辑
tokimo-package-storage 在 OpenDAL 之上封装了这些业务语义,同时保持 trait 的通用性——未来替换为 S3 直连或自定义分布式存储时,只需实现 StorageProvider trait。
总结
本次重构将 Tokimo 的存储系统从"各 app 自治"演进为"主进程集中管控",核心变化:
┌──────────────────────────────────────────────────────────────┐
│ 重构前 → 重构后 │
├──────────────────────┬───────────────────────────────────────┤
│ 存储实例 │ N 个独立实例 → 1 个主进程实例 │
│ Key 生成 │ App 自行构造 → 服务端 opaque UUID │
│ 访问控制 │ 无 → 基于 storage_objects 的写隔离 │
│ 存储抽象 │ 各 app 重复实现 → tokimo-package-storage│
│ │ (基于 OpenDAL 的统一封装) │
│ 进程模型 │ 内嵌模块 → sidecar 通过 bus RPC 调用 │
└──────────────────────┴───────────────────────────────────────┘
关键架构决策:
- 集中管控:解决 sidecar 拆分后的路径一致性问题,消除 app 对存储路径的感知
- Opaque key:URL 不泄露 app 结构和文件语义,适配 CDN 和公开分享场景
- 公开读 + 写隔离:匹配媒体资源的典型访问模式,兼顾可用性和安全性
- Trait 抽象:
tokimo-package-storage基于 OpenDAL 封装业务语义,将存储接口与实现解耦,后端可独立演进
版权属于:一名宅。
本文链接:https://zhaiyiming.com/archives/84.html
转载时须注明出处及本声明