从一个文件上传功能聊聊后端架构中的设计原则

背景

最近在做一个小程序项目,需要支持用户上传头像、帖子图片、文档等文件。当前阶段文件存在服务器本地磁盘,但后期大概率要迁移到腾讯云 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.tsuser.service.ts 里没有一行 fscos 的 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 时,实际要做的事:

  1. 新写一个 cos.storage.ts(实现 4 个方法,约 40 行)
  2. index.ts 的 switch 加一个 case "cos"
  3. .envSTORAGE_DRIVER=cos
  4. 跑一个迁移脚本:遍历本地 uploads/cos.putObject,key 保持原样
  5. 验证,切换,完成

业务代码零改动。数据库零改动。前端零改动。

这就是"提前做对一个小决策"的回报 ------ 不是提前写 COS 代码(那叫过度设计),而是提前把变化的边界划清楚。

总结

原则 在这个项目里的体现
依赖倒置 业务层只依赖 StorageProvider 接口,不依赖 fs 或 COS SDK
工厂模式 createStorage() 根据环境变量选实现,启动时决定,运行时不变
数据与表现分离 DB 存 key 不存 URL,URL 在运行时由 provider 计算
最小接口 只有 4 个方法,够用就行,需要时再加

这些原则不是为了"架构好看"。它们解决的是一个很实际的问题:怎么在今天用最简单的方案(本地磁盘),同时不给明天的迁移埋坑。

相关推荐
二月龙1 小时前
小程序与H5的核心区别:沙箱环境、双线程架构
后端
鱼人2 小时前
突破 2MB 瓶颈:小程序分包加载与性能优化实战
后端
码界奇点2 小时前
基于Spring Boot的插件化微服务热更新系统设计与实现
spring boot·后端·微服务·架构·毕业设计·源代码管理
Predestination王瀞潞2 小时前
Java EE3-我独自整合(第五章:Spring AOP 介绍与入门案例)
java·后端·spring·java-ee
小江的记录本2 小时前
【 AI工程化】AI工程化:MLOps、大模型全生命周期管理、大模型安全(幻觉、Prompt注入、数据泄露、合规)
java·人工智能·后端·python·机器学习·ai·架构
码界奇点2 小时前
基于Spring Boot与Vue的教务管理系统设计与实现
vue.js·spring boot·后端·java-ee·毕业设计·源代码管理
Ares-Wang2 小时前
flask》》Blueprint 蓝图
后端·python·flask
饺子大魔王的男人2 小时前
不想再给云存储交月费?Go2RTC + EasyNVR 让摄像头录像留在本地不花钱
后端·数据分析
Rust研习社2 小时前
Rust 并发同步:Mutex 与 RwLock 智能指针
开发语言·后端·rust