本文记录 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 不覆盖的业务需求:

  1. Opaque key 生成upload_opaque 需要在上传时由服务端决定最终 key,OpenDAL 的 write_with 只接受 caller 提供的 key
  2. App 归属追踪storage_objects 表的写入逻辑与存储操作耦合,OpenDAL 不感知"谁上传了什么"
  3. 写隔离鉴权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 封装业务语义,将存储接口与实现解耦,后端可独立演进