背景
最近在做一个小程序项目,需要支持用户上传头像、帖子图片、文档等文件。当前阶段文件存在服务器本地磁盘,但后期大概率要迁移到腾讯云 COS(对象存储)。
问题来了:怎么设计,才能让将来的迁移尽可能无痛?
最终落地的方案只有三个文件、四个方法,但背后涉及了几个值得聊的设计原则。
一、先看最终的代码结构
bash
services/storage/
├── types.ts # 接口定义
├── local.storage.ts # 本地磁盘实现
└── index.ts # 工厂 + 单例导出
接口长这样:
ts
interface StorageProvider {
put(key, buffer, mimeType): Promise<UploadResult>;
delete(key): Promise<void>;
get(key): Promise<{ buffer: Buffer; mimeType: string }>;
getPublicUrl(key): string;
}
业务代码只认这个接口:
ts
// upload.service.ts
import { storage } from "./storage";
const result = await storage.put(key, file.buffer, file.mimetype);
看起来平平无奇,但这个"平平无奇"本身就是设计的目标。
二、依赖倒置:让业务代码看不到"文件存在哪"
传统写法,业务层直接调 fs.writeFile:
ts
// ❌ 业务代码直接依赖具体实现
import fs from "fs";
const uploadAvatar = async (file) => {
await fs.writeFile(`uploads/${key}`, file.buffer);
};
问题很明显:业务逻辑和存储细节耦死了 。要换 COS,就得把所有 fs.writeFile 找出来改成 cos.putObject。项目小的时候改几处无所谓,项目大了就是灾难。
依赖倒置的做法是反过来 ------ 业务层依赖接口,具体实现反过来去适配接口:
markdown
upload.service → StorageProvider(接口) ← LocalStorageProvider
← CosStorageProvider(将来)
箭头方向是关键:高层模块(业务)定义它需要什么能力,低层模块(存储实现)去满足它。不是业务去迁就存储 API 的写法。
实际效果:upload.service.ts 和 user.service.ts 里没有一行 fs 或 cos 的 import。它们只知道 storage.put() / storage.getPublicUrl()。
三、工厂模式:启动时决定一次,运行时不再变
在最初思考这个设计时,我一度把它归类为"策略模式"。但仔细想想,策略模式的核心是运行时动态切换 ------ 比如支付时用户选微信还是支付宝,同一个上下文对象可以随时换算法。
而文件存储的场景不是这样。我们不会出现"这个请求存本地、下个请求存 COS"的情况。存储后端在应用启动时就确定了,之后不再变。
这其实是工厂模式 ------ 根据配置创建实例,创建完就定了:
ts
const createStorage = (): StorageProvider => {
switch (config.storageDriver) {
case "local":
return new LocalStorageProvider(config.uploadDir, config.publicBaseUrl);
case "cos":
throw new Error("COS 存储驱动尚未实现");
}
};
export const storage = createStorage(); // 单例,生命周期内不换
如果是策略模式,应该长这样:
ts
class StorageContext {
private provider: StorageProvider;
// 运行时可以随时切换
setProvider(p: StorageProvider) {
this.provider = p;
}
put(key, buffer, mimeType) {
return this.provider.put(key, buffer, mimeType);
}
}
区分"启动时配置"和"运行时切换"很重要。用错模式不会导致代码不能跑,但会引入不必要的复杂度 ------ 策略模式需要考虑线程安全、切换时机、状态一致性等问题,而工厂模式完全没有这些负担。
选最简单的、够用的方案。
四、数据与表现分离:DB 存 key,不存 URL
这个决策看似微小,实际上是整个迁移方案能否"无痛"的关键。
bash
DB: users.avatar = "users/123/avatar/abc.jpg" ← 这是 key
本地阶段: http://host/uploads/users/123/avatar/abc.jpg
COS 阶段: https://bucket.cos.xxx/users/123/avatar/abc.jpg
key 是不变的事实 (这个文件叫什么、放在哪个逻辑路径下),URL 是表现(用户通过什么地址能访问到它)。事实存进数据库,表现在运行时计算:
ts
const sanitizeUser = (user) => ({
...
avatar: user.avatar ? storage.getPublicUrl(user.avatar) : "",
});
这意味着:
- 迁移不动 DB ------ 文件搬到 COS 后,key 原样保留,
getPublicUrl自动拼出 COS 域名 - 回滚零成本 ------ 文件拷回来,环境变量改回
local,完事 - 多环境友好 ------ dev 用本地,staging 用 COS 测试桶,prod 用 COS 正式桶,同一份数据库
如果当初存的是完整 URL(http://localhost:3000/uploads/users/123/avatar/abc.jpg),迁移时就得跑一遍 UPDATE users SET avatar = REPLACE(avatar, 'http://localhost:3000/uploads/', 'https://bucket.cos.xxx/') ------ 丑陋、易错、不可逆。
五、最小接口原则:四个方法,不多不少
接口只暴露了 4 个方法:
| 方法 | 场景 |
|---|---|
put |
用户上传文件 |
delete |
删除文件、换头像时清理旧文件 |
get |
后端内部需要读文件(如图生图要读原图传给 AI API) |
getPublicUrl |
DB 中的 key → 前端可用的访问 URL |
没有 list(按目录列文件)、没有 copy(复制文件)、没有 move(移动文件)。不是这些操作不重要,而是当前业务不需要。
接口越小,实现新 provider 的成本越低。将来写 CosStorageProvider 时,只需要对着这 4 个方法各写几行 COS SDK 调用就完事了:
ts
class CosStorageProvider implements StorageProvider {
async put(key, buffer, mimeType) {
await this.cos.putObject({ Key: key, Body: buffer, ContentType: mimeType });
return { key, url: this.getPublicUrl(key), size: buffer.length, mimeType };
}
async delete(key) {
await this.cos.deleteObject({ Key: key });
}
async get(key) {
const res = await this.cos.getObject({ Key: key });
return { buffer: res.Body, mimeType: res.ContentType };
}
getPublicUrl(key) {
return `${this.cdnDomain}/${key}`;
}
}
如果接口里塞了 20 个方法(很多"说不定将来用得上"的),每加一个 provider 都要实现 20 个方法。大部分可能永远不会被调用,但你得写、得测、得维护。
需要时再加,永远比"万一用得上"便宜。
六、迁移那天到底要做什么
回到最初的问题:将来迁 COS 时,实际要做的事:
- 新写一个
cos.storage.ts(实现 4 个方法,约 40 行) index.ts的 switch 加一个case "cos".env改STORAGE_DRIVER=cos- 跑一个迁移脚本:遍历本地
uploads/→cos.putObject,key 保持原样 - 验证,切换,完成
业务代码零改动。数据库零改动。前端零改动。
这就是"提前做对一个小决策"的回报 ------ 不是提前写 COS 代码(那叫过度设计),而是提前把变化的边界划清楚。
总结
| 原则 | 在这个项目里的体现 |
|---|---|
| 依赖倒置 | 业务层只依赖 StorageProvider 接口,不依赖 fs 或 COS SDK |
| 工厂模式 | createStorage() 根据环境变量选实现,启动时决定,运行时不变 |
| 数据与表现分离 | DB 存 key 不存 URL,URL 在运行时由 provider 计算 |
| 最小接口 | 只有 4 个方法,够用就行,需要时再加 |
这些原则不是为了"架构好看"。它们解决的是一个很实际的问题:怎么在今天用最简单的方案(本地磁盘),同时不给明天的迁移埋坑。