一、问题背景
实现 E2E 便携式加密文件的导入导出功能时,我们经历了一个”越做越复杂”的过程,最终意识到走入了过度设计的误区。
二、第一版实现(走入误区)
2.1 最初的 API 设计
// 导出:返回加密后的字节流
POST /api/v1/e2e/export/:id
Body: { "password": "xxx" }
Response: 二进制流
// 导入:multipart 上传
POST /api/v1/e2e/import
Form: file, password, path
Response: FileMetadata问题:
- 导出返回整个文件到内存,大文件 OOM
- 导入使用 multipart,虽然能工作但没有复用上传逻辑
2.2 优化后的”路径模式”(过度设计)
为了解决大文件问题,我们参考 upload-by-path 模式,设计了独立的路径模式 API:
// 导出到本地路径(核心直接写文件)
POST /api/v1/e2e/export-by-path
Body: { "fileId", "password", "savePath" }
Response: { "taskId" }
// 从本地路径导入(核心直接读文件)
POST /api/v1/e2e/import-by-path
Body: { "filePath", "password", "targetPath" }
Response: { "taskId" }
// 进度查询
GET /api/v1/e2e/progress/:taskId
Response: E2EProgress2.3 实现细节
后端 (server.go):
type E2EProgress struct {
TaskID string `json:"taskId"`
Phase string `json:"phase"`
Progress int `json:"progress"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
Error string `json:"error,omitempty"`
Result *FileMetadata `json:"result,omitempty"`
SavedPath string `json:"savedPath,omitempty"`
}
// doExportByPath 实际执行导出(后台 goroutine)
func (s *Server) doExportByPath(taskID, fileID, password, savePath string, chunkMeta *crypto.ChunkMeta) {
// 阶段1:下载并解密所有分块 (0-60%)
progress.Phase = "downloading"
for i := 0; i < chunkMeta.TotalChunks; i++ {
chunkData := s.s3.DownloadBytes(...)
decrypted := s.encryptor.DecryptChunkSimple(chunkData, fileKey)
fileData = append(fileData, decrypted...)
progress.Progress = (i + 1) * 60 / chunkMeta.TotalChunks
}
// 阶段2:用新密码加密为 E2E 格式 (60-90%)
progress.Phase = "encrypting"
e2eData := crypto.ExportToE2E(fileData, e2eMeta, password)
// 阶段3:写入本地文件 (90-100%)
progress.Phase = "writing"
os.WriteFile(savePath, e2eData, 0644)
progress.Phase = "done"
}
// doImportByPath 实际执行导入(后台 goroutine)
func (s *Server) doImportByPath(taskID, filePath, password, targetPath string, fileSize int64) {
// 阶段1:读取本地文件 (0-20%)
e2eData := os.ReadFile(filePath)
// 阶段2:解密 E2E 文件 (20-40%)
fileData, e2eMeta := crypto.ImportFromE2E(e2eData, password)
// 阶段3:分块加密并上传到 S3 (40-90%)
for i := 0; i < totalChunks; i++ {
encrypted := s.encryptor.EncryptChunkSimple(chunk, fileKey)
s.s3.UploadBytes(ctx, chunkKey, encrypted, ...)
}
// 阶段4:保存元数据 (90-100%)
s.updateMetadataIndex(ctx, &metadata)
progress.Phase = "done"
progress.Result = &metadata
}客户端 (app_state.dart):
/// 导出路径模式:核心直接写文件到本地路径
Future<void> _doExportByPath(_DownloadJob job) async {
// 计算保存路径
final savePath = '${downloadDir.path}/$fileName';
// 发起导出请求,获取 taskId
final startResult = await api.exportToE2EByPath(
fileId: job.file.id,
password: job.exportPassword!,
savePath: savePath,
);
final taskId = startResult.data!;
// 轮询进度
while (true) {
await Future.delayed(const Duration(milliseconds: 500));
final progressResult = await api.getE2EProgress(taskId);
final progress = progressResult.data!;
// 更新 UI 进度
_transfers[idx] = _transfers[idx].copyWith(
progress: progress.progress / 100.0,
);
if (progress.isDone) break;
}
}
/// 导入任务(使用路径模式 API)
void _startImportJob(_ImportJob job) {
// 类似的轮询逻辑...
}三、问题分析:为什么这是过度设计?
3.1 代码重复
| 功能 | upload-by-path | import-by-path |
|---|---|---|
| 分块加密 | ✅ 已实现 | ❌ 重复实现 |
| 上传到 S3 | ✅ 已实现 | ❌ 重复实现 |
| 元数据更新 | ✅ 已实现 | ❌ 重复实现 |
| 进度追踪 | ✅ 已实现 | ❌ 新建独立系统 |
| 取消机制 | ✅ 已实现 | ❌ 需要额外实现 |
3.2 违反设计原则
根据项目规范:
“导入导出功能应复用上传下载的接口与UI逻辑,实现统一的传输体验” “接口设计应注重高复用率,避免重复实现,统一逻辑入口”
我们的实现:
- ❌ 没有复用上传逻辑,而是重新实现了一遍
- ❌ 独立的进度系统,没有复用传输队列
- ❌ 代码膨胀:后端 +430 行,客户端 +200 行
3.3 本质问题
误区:把”导入导出”当作独立功能来实现
正确理解:
- 导入 = E2E 解密 + 上传
- 导出 = 下载 + E2E 加密
E2E 加解密只是一个”转换层”,不应该重新实现上传下载的核心逻辑。
四、正确方案:最小化 E2E 模块
4.1 后端只提供纯粹的加解密接口
// 解密 E2E 文件 → 保存到临时目录,返回路径和元数据
POST /api/v1/e2e/decrypt
{
"filePath": "/path/to/file.e2e",
"password": "xxx"
}
→ { "tempPath": "/tmp/xxx", "fileName": "原始文件名", "mimeType": "xxx", "size": 123 }
// 下载文件并 E2E 加密 → 保存到指定路径
POST /api/v1/e2e/encrypt
{
"fileId": "xxx",
"password": "xxx",
"savePath": "/path/to/output.e2e"
}
→ { "savedPath": "...", "size": 123 }4.2 客户端流程
导入:
void enqueueImport(String e2eFilePath, String password) {
// 1. 调用 e2e/decrypt 解密到临时文件
final decryptResult = await api.decryptE2E(e2eFilePath, password);
// decryptResult = { tempPath, fileName, mimeType, size }
// 2. 加入上传队列(复用现有上传逻辑)
enqueueUploadToPath(
filePath: decryptResult.tempPath,
fileName: decryptResult.fileName,
remotePath: targetPath,
displayName: '导入: ${decryptResult.fileName}', // UI 显示为"导入"
);
}导出:
void enqueueExport(FileMetadata file, String password) {
// 1. 加入下载队列
// 2. 下载完成回调中:
// - 调用 e2e/encrypt 加密
// - 或者复用 _DownloadJob 的 isExport 标记,下载完成后自动加密
}4.3 优势对比
| 维度 | 过度设计方案 | 简化方案 |
|---|---|---|
| 后端新增代码 | ~430 行 | ~50 行 |
| 客户端新增代码 | ~200 行 | ~30 行 |
| 进度条 | 独立轮询 | 复用上传下载 |
| 取消机制 | 需要额外实现 | 自动继承 |
| 缩略图生成 | 需要额外处理 | 上传流程自动处理 |
| 临时文件清理 | 需要额外处理 | 上传流程自动清理 |
五、经验教训
5.1 设计原则
- 先问”能否复用”,再考虑”如何新建”
- 功能分解:把复杂功能拆成小模块,看哪些已经存在
- 最小化新代码:新功能应该尽量利用现有基础设施
5.2 导入导出的本质
导入 ≠ 一个独立的复杂操作导入 = 解密(E2E文件) → 得到原始数据 → 上传(复用现有逻辑)
导出 ≠ 一个独立的复杂操作导出 = 下载(复用现有逻辑) → 得到原始数据 → 加密(E2E格式)5.3 反思
直觉是对的:
“我本来以为,我们只是做一个加解密 E2E 文件的模块,然后和上传下载接口顺序调用一下就行了”
这才是正确的思路。我们不应该被”性能优化”带偏,而忽视了架构的简洁性。
六、实施完成 ✅
6.1 后端清理
删除了过度设计的代码(~430 行):
E2EProgress结构体e2eProgressmap 和 mutexexportToE2EByPath()/doExportByPath()importFromE2EByPath()/doImportByPath()getE2EProgress()
添加了简化的 API(~170 行):
// POST /api/v1/e2e/decrypt
// 解密 E2E 文件到临时目录,返回路径和元数据
func (s *Server) decryptE2E(c *gin.Context)
// POST /api/v1/e2e/encrypt
// 下载文件并加密为 E2E 格式,保存到指定路径
func (s *Server) encryptToE2E(c *gin.Context)6.2 客户端重构
导入流程(_startImportJob → _doImport):
// 1. 解密 E2E 文件到临时目录
final decryptResult = await api.decryptE2E(
filePath: job.filePath,
password: job.password,
);
// 2. 复用 uploadByPath 上传(完全复用现有上传逻辑)
final taskResult = await api.uploadByPath(
filePath: decrypted.tempPath,
fileName: decrypted.fileName,
remotePath: job.targetPath,
);
// 3. 轮询上传进度(复用 getUploadProgress)
while (true) {
final progress = await api.getUploadProgress(taskId);
// ...
}导出流程(_doExportByPath):
// 直接调用 encryptToE2E,后端一步完成下载+加密+写文件
final result = await api.encryptToE2E(
fileId: job.file.id,
password: job.exportPassword!,
savePath: savePath,
);6.3 代码统计
| 项目 | 过度设计方案 | 简化方案 | 减少 |
|---|---|---|---|
| 后端新增代码 | ~430 行 | ~170 行 | -60% |
| 客户端导入逻辑 | ~105 行(独立实现) | ~220 行(但复用 uploadByPath) | 复用率↑ |
| 独立进度系统 | 有 | 无(复用上传进度) | 统一 |
| 取消机制 | 需额外实现 | 复用上传取消 | 自动 |
| 临时文件清理 | 需额外处理 | 复用上传清理 | 自动 |
七、代码清理清单
7.1 后端待删除
server.go:E2EProgress结构体e2eProgressmap 和 mutexExportByPathRequest/ImportByPathRequestexportToE2EByPath()/doExportByPath()importFromE2EByPath()/doImportByPath()getE2EProgress()- 相关路由注册
7.2 客户端待删除
-
api_client.dart:exportToE2EByPath()importFromE2EByPath()getE2EProgress()
-
models/e2e_progress.dart: 整个文件 -
app_state.dart:_doExportByPath()_startImportJob()中的轮询逻辑_finishImportJob()
八、架构图对比
8.1 过度设计(当前)
┌─────────────────────────────────────────────────────────────┐│ 客户端 │├─────────────────────────────────────────────────────────────┤│ enqueueExport() ──→ _doExportByPath() ──→ 轮询进度 ││ enqueueImport() ──→ _startImportJob() ──→ 轮询进度 │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ 后端 API │├─────────────────────────────────────────────────────────────┤│ export-by-path ──→ doExportByPath() ──→ 下载+解密+加密+写 ││ import-by-path ──→ doImportByPath() ──→ 读+解密+加密+上传 ││ e2e/progress ──→ 返回进度 ││ ││ (重复了 uploadByPath 和 downloadFile 的逻辑) │└─────────────────────────────────────────────────────────────┘8.2 简化方案(目标)
┌─────────────────────────────────────────────────────────────┐│ 客户端 │├─────────────────────────────────────────────────────────────┤│ 导入: e2e/decrypt ──→ enqueueUpload() ──→ 复用上传逻辑 ││ 导出: enqueueDownload() ──→ e2e/encrypt ──→ 保存文件 │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ 后端 API │├─────────────────────────────────────────────────────────────┤│ e2e/decrypt ──→ 解密到临时文件,返回路径 ││ e2e/encrypt ──→ 下载+解密+加密+写文件 ││ ││ upload-by-path ──→ (复用) 分块加密+上传+元数据 ││ downloadFile ──→ (复用) 分块下载+解密 │└─────────────────────────────────────────────────────────────┘结论:功能设计时,先考虑“组合现有能力”,而不是“重新实现一切”。
九、代码清理实施过程
重构完成后,进行了彻底的代码清理,删除所有废弃的 API 和函数。
9.1 后端清理明细
删除的路由注册:
// 删除前
api.POST("/e2e/export/:id", s.exportToE2E)
api.POST("/e2e/import", s.importFromE2E)
// 保留
// E2E 简化 API(纯加解密,复用上传下载逻辑)
api.POST("/e2e/decrypt", s.decryptE2E)
api.POST("/e2e/encrypt", s.encryptToE2E)删除的函数:
exportToE2E()- ~80 行,返回整个文件字节流(大文件 OOM)importFromE2E()- ~126 行,multipart 上传方式- 总计删除 ~206 行
9.2 客户端清理明细
api_client.dart 删除:
// 删除旧版 API(~74 行)
Future<ApiResult<Uint8List>> exportToE2E(...) // 返回字节数据
Future<ApiResult<FileMetadata>> importFromE2E(...) // multipart 上传最终保留的简化 API:
/// 解密 E2E 文件到临时目录
Future<ApiResult<E2EDecryptResult>> decryptE2E({
required String filePath,
required String password,
})
/// 下载文件并加密为 E2E 格式
Future<ApiResult<E2EEncryptResult>> encryptToE2E({
required String fileId,
required String password,
required String savePath,
})