时间: 2025-12-22 20:57
标签: 架构优化 代码简化 重构
背景
在之前的实现中,前后端都存储和传递 MIME 类型信息,但分析发现:
- MIME 完全由扩展名决定 - 前后端的 MIME 判断逻辑都是基于文件扩展名
- 存在冗余存储 - 数据库中
files表有mime_type列 - API 传递冗余 -
FileMetadata结构体包含MimeType字段 - 调用繁琐 - 类型判断函数需要同时传递
mimeType和fileName两个参数
由于项目处于开发阶段,无历史数据负担,可以进行彻底的架构优化。
问题分析
原有设计的问题
// ❌ 旧设计:需要传递 mimeType 和 fileName
bool isVideoFile(String mimeType, String fileName) {
if (mimeType.contains('video/')) return true;
final ext = getExtension(fileName);
return videoExtensions.contains(ext);
}
// ❌ 调用繁琐
if (isVideoFile(file.mimeType, file.name)) { ... }// ❌ Go 后端也有同样的问题
type FileMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
MimeType string `json:"mimeType"` // 冗余字段
// ...
}核心矛盾
- MIME 信息 100% 由扩展名决定
- 却在多处存储和传递
- 增加了代码复杂度,没有实际价值
解决方案
设计思路
彻底移除 MIME 类型存储,改用扩展名驱动的类型系统
- 引入
AppFileType枚举替代字符串 MIME - 所有类型判断统一基于文件名(扩展名)
- 删除数据库中的冗余列
- 简化函数签名,提升代码可读性
架构变化
Before(冗余设计)
┌─────────────┐│ 数据库 ││ mime_type │──┐└─────────────┘ │ ├──> 类型判断需要 mimeType + fileName┌─────────────┐ ││ API 传输 │──┘│ MimeType │└─────────────┘After(简洁设计)
┌─────────────┐│ 文件名 │──> 扩展名 ──> AppFileType 枚举│ name │ ↓└─────────────┘ 类型判断/图标/颜色实现细节
1. 前端新增 AppFileType 枚举
文件: client/lib/core/utils/file_utils.dart
/// 文件类型枚举(避免与 file_picker 的 FileType 冲突)
enum AppFileType {
folder,
video,
image,
audio,
document,
text,
code,
archive,
apk,
other,
}
/// 根据文件名获取文件类型
AppFileType getFileType(String fileName, {bool isDir = false}) {
if (isDir) return AppFileType.folder;
final ext = _getExtension(fileName);
if (ext == 'apk') return AppFileType.apk;
if (_videoExtensions.contains(ext)) return AppFileType.video;
if (_imageExtensions.contains(ext)) return AppFileType.image;
if (_audioExtensions.contains(ext)) return AppFileType.audio;
if (_codeExtensions.contains(ext)) return AppFileType.code;
if (_archiveExtensions.contains(ext)) return AppFileType.archive;
if (_documentExtensions.contains(ext)) return AppFileType.document;
if (_textExtensions.contains(ext)) return AppFileType.text;
return AppFileType.other;
}
/// 根据文件类型获取图标
IconData getFileIcon(AppFileType type) {
return switch (type) {
AppFileType.folder => MdiIcons.folder,
AppFileType.video => MdiIcons.fileVideo,
AppFileType.image => MdiIcons.fileImage,
// ...
};
}
/// 根据文件类型获取图标颜色
Color getFileIconColor(AppFileType type) {
return switch (type) {
AppFileType.folder => Colors.amber,
AppFileType.video => Colors.blue,
AppFileType.image => Colors.green,
// ...
};
}
/// 根据文件类型获取中文标签(用于详情对话框)
String getFileTypeLabel(AppFileType type) {
return switch (type) {
AppFileType.folder => '文件夹',
AppFileType.video => '视频',
AppFileType.image => '图片',
// ...
};
}关键改进:
- 使用
AppFileType命名避免与file_picker包的FileType冲突 - 利用 Dart 3 的 switch expression 简化代码
- 统一的类型系统,易于扩展和维护
2. 简化类型判断函数
// ✅ 新设计:只需要文件名
bool isVideoFile(String fileName) {
final ext = _getExtension(fileName);
return _videoExtensions.contains(ext);
}
bool isImageFile(String fileName) {
final ext = _getExtension(fileName);
return _imageExtensions.contains(ext);
}
bool isTextFile(String fileName) {
final ext = _getExtension(fileName);
return _textExtensions.contains(ext) || _codeExtensions.contains(ext);
}
// ✅ 调用简洁
if (isVideoFile(file.name)) { ... }3. Go 后端删除 MimeType
文件: core/internal/api/server.go
// ✅ 简化后的结构体
type FileMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
EncryptedSize int64 `json:"encryptedSize"`
IsDir bool `json:"isDir"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
IsSystemFolder bool `json:"isSystemFolder"`
// ❌ 删除了 MimeType 字段
}
// ✅ 简化后的判断函数
func isVideoFile(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
return videoExtensions[ext]
}
func isImageFile(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
return imageExtensions[ext]
}4. 数据库 Schema 升级
文件: client/lib/core/database/database.dart
class Files extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get path => text()();
IntColumn get size => integer()();
IntColumn get encryptedSize => integer()();
BoolColumn get isDir => boolean()();
// ❌ 删除: TextColumn get mimeType => text()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
BoolColumn get isSystemFolder => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
@override
int get schemaVersion => 6; // 5 -> 6
@override
MigrationStrategy get migration {
return MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from < 6) {
// 重建 files 表(删除 mimeType 列)
await m.deleteTable('files');
await m.createTable(files);
}
},
);
}5. UI 层全面改用枚举
文件: client/lib/ui/files_page.dart
// ✅ 文件打开逻辑
void _openFile(FileMetadata file) {
final type = getFileType(file.name);
switch (type) {
case AppFileType.video:
Navigator.push(context,
MaterialPageRoute(builder: (_) => VideoPlayerPage(file: file)));
case AppFileType.image:
Navigator.push(context,
MaterialPageRoute(builder: (_) => ImagePreviewPage(file: file)));
case AppFileType.document when file.name.toLowerCase().endsWith('.pdf'):
Navigator.push(context,
MaterialPageRoute(builder: (_) => PdfPreviewPage(file: file)));
case AppFileType.text || AppFileType.code:
Navigator.push(context,
MaterialPageRoute(builder: (_) => TextEditorPage(file: file)));
default:
Navigator.push(context,
MaterialPageRoute(builder: (_) => UnsupportedPreviewPage(file: file)));
}
}
// ✅ 图标获取
IconData _getIcon() {
if (widget.file.isSystemFolder) {
return MdiIcons.folderCog;
}
return getFileIcon(getFileType(widget.file.name, isDir: widget.file.isDir));
}
// ✅ 缩略图加载判断
void _loadThumbnailIfNeeded() async {
final type = getFileType(widget.file.name);
final needThumbnail = !widget.file.isDir &&
(type == AppFileType.image || type == AppFileType.video);
// ...
}
// ✅ 文件详情展示
_DetailRow(
label: '类型',
value: file.isThumbnail
? '缩略图'
: file.isSystemFolder
? '系统文件夹'
: file.isDir
? '文件夹'
: getFileTypeLabel(getFileType(file.name)),
)6. 顺带修复的 Bug
在修改 deleteFile 时发现并修复了删除文件夹时缩略图不会被删除的 bug:
// ✅ 修复后
if file.IsDir {
// 删除文件夹:删除所有子文件的 S3 数据和缩略图
for id, f := range meta.Files {
if strings.HasPrefix(f.Path, dirPrefix) {
if !f.IsDir {
filePrefix := fmt.Sprintf("files/%s/", id)
s.s3.DeletePrefix(ctx, filePrefix)
// ✅ 新增:删除缩略图
thumbKey := fmt.Sprintf("thumbs/%s.enc", id)
_ = s.s3.Delete(ctx, thumbKey)
}
}
}
} else {
// 删除单个文件
filePrefix := fmt.Sprintf("files/%s/", fileID)
s.s3.DeletePrefix(ctx, filePrefix)
// ✅ 新增:删除缩略图
thumbKey := fmt.Sprintf("thumbs/%s.enc", fileID)
_ = s.s3.Delete(ctx, thumbKey)
}改动统计
修改的文件
| 文件 | 变更类型 | 说明 |
|---|---|---|
core/internal/api/server.go | 删除/重构 | 删除 MimeType 字段,简化类型判断函数,修复缩略图删除 bug |
client/lib/core/models/file_metadata.dart | 删除 | 删除 mimeType 字段及相关代码 |
client/lib/core/database/database.dart | 删除/升级 | 删除 mimeType 列,schema 5→6,添加迁移逻辑 |
client/lib/core/utils/file_utils.dart | 新增/重构 | 新增 AppFileType 枚举,重构所有类型判断函数 |
client/lib/core/state/app_state.dart | 删除 | 移除缩略图和游离文件构造中的 mimeType 参数 |
client/lib/ui/files_page.dart | 重构 | 全面改用 AppFileType 枚举,简化类型判断逻辑 |
代码对比
函数调用简化:
// Before: 需要传递两个参数
if (isVideoFile(file.mimeType, file.name)) { ... }
final icon = getFileIcon(file.mimeType, isDir: file.isDir, fileName: file.name);
// After: 只需一个参数
if (isVideoFile(file.name)) { ... }
final icon = getFileIcon(getFileType(file.name, isDir: file.isDir));类型表达更清晰:
// Before: 字符串比较,容易出错
if (file.mimeType.contains('video/')) { ... }
// After: 枚举比较,类型安全
if (getFileType(file.name) == AppFileType.video) { ... }技术债务清理
命名冲突处理
初次实现时使用了 FileType 作为枚举名,但与 file_picker 包冲突:
// ❌ 会导致 ambiguous_import 错误
import 'package:file_picker/file_picker.dart';
import 'package:e2eepan_client/core/utils/file_utils.dart';
enum FileType { ... } // 与 file_picker 的 FileType 冲突解决方案:重命名为 AppFileType,避免冲突。
数据库迁移策略
由于项目处于开发阶段,采用最简单的迁移方式:
if (from < 6) {
// 直接重建表,无需保留数据
await m.deleteTable('files');
await m.createTable(files);
}生产环境需要更谨慎的迁移策略(如数据迁移、备份等)。
验证结果
Go 后端验证
$ cd core
$ go build ./... # ✅ 编译通过
$ go vet ./... # ✅ 静态分析通过Flutter 前端验证
$ cd client
$ dart run build_runner build --delete-conflicting-outputs # ✅ 重新生成数据库代码
$ flutter analyze # ✅ 分析通过(仅 1 个代码风格 info)最终结果: 所有编译和静态分析均通过,无错误。
性能影响
正面影响
- 减少数据传输 - API 响应体积减小(删除了 mimeType 字段)
- 减少存储开销 - 数据库删除了冗余列
- 减少内存占用 - FileMetadata 对象更小
- 提升代码可读性 - 枚举比字符串更清晰
性能测试
由于改动只涉及类型判断逻辑(从字符串比较改为扩展名查表),性能影响可忽略不计。
扩展名提取和查表操作都是 O(1) 复杂度,不会成为性能瓶颈。
后续优化建议
1. 考虑添加 MIME 检测工具函数
虽然删除了 MIME 存储,但某些场景(如 HTTP 响应 Content-Type)仍需要:
/// 根据文件名检测 MIME 类型(用于 HTTP Content-Type)
String detectMimeType(String fileName) {
final ext = _getExtension(fileName);
const mimeMap = {
'jpg': 'image/jpeg',
'png': 'image/png',
'mp4': 'video/mp4',
// ...
};
return mimeMap[ext] ?? 'application/octet-stream';
}已实现 - 该函数已在 file_utils.dart 中保留,供 HTTP 下载时使用。
2. 扩展文件类型支持
如需支持新文件类型,只需:
- 在对应的
_xxxExtensionsSet 中添加扩展名 - 在
AppFileType枚举中添加新类型(如有必要) - 在
getFileIcon等函数中添加对应的图标和颜色
3. 考虑动态文件类型识别
对于无扩展名或扩展名不可靠的文件,可以考虑:
- 使用 magic number 检测(读取文件头部字节)
- 但会增加复杂度,目前基于扩展名的方案已满足需求
经验总结
架构设计原则
-
YAGNI 原则 - 不需要的功能就不要实现
- MIME 存储看似完整,实则冗余
-
DRY 原则 - 不要重复自己
- MIME 完全可由扩展名推导,无需存储
-
单一数据源 - 避免数据不一致
- 扩展名是唯一的文件类型来源
重构时机
- ✅ 开发阶段重构成本低 - 无历史数据负担
- ✅ 及时发现问题 - 避免技术债务积累
- ✅ 保持代码简洁 - 降低长期维护成本
测试策略
- 静态分析优先(
go vet,flutter analyze) - 编译验证必不可少
- 关键路径手动测试(文件上传、预览、删除等)
总结
这次 MIME 简化重构是一次成功的架构优化案例,充分体现了项目早期重构的价值:
- 删除冗余设计 - 前后端共删除 100+ 行冗余代码
- 提升代码质量 - 类型安全、可读性增强
- 简化 API 设计 - 函数签名更简洁
- 修复潜在 bug - 顺带修复了缩略图删除问题
最重要的是,这次重构不影响任何功能,纯粹的内部优化。这正是早期重构的最佳时机。
相关笔记:
- 20251214-181724-mime-extension-only-and-icons.md - 最初的 MIME 简化设计
- 20251219-221638-system-folders-and-thumbnails.md - 缩略图系统实现