December 22, 2025
7 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

时间: 2025-12-22 20:57
标签: 架构优化 代码简化 重构

背景

在之前的实现中,前后端都存储和传递 MIME 类型信息,但分析发现:

  1. MIME 完全由扩展名决定 - 前后端的 MIME 判断逻辑都是基于文件扩展名
  2. 存在冗余存储 - 数据库中 files 表有 mime_type
  3. API 传递冗余 - FileMetadata 结构体包含 MimeType 字段
  4. 调用繁琐 - 类型判断函数需要同时传递 mimeTypefileName 两个参数

由于项目处于开发阶段,无历史数据负担,可以进行彻底的架构优化。

问题分析

原有设计的问题

// ❌ 旧设计:需要传递 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 类型存储,改用扩展名驱动的类型系统

  1. 引入 AppFileType 枚举替代字符串 MIME
  2. 所有类型判断统一基于文件名(扩展名)
  3. 删除数据库中的冗余列
  4. 简化函数签名,提升代码可读性

架构变化

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)

最终结果: 所有编译和静态分析均通过,无错误。

性能影响

正面影响

  1. 减少数据传输 - API 响应体积减小(删除了 mimeType 字段)
  2. 减少存储开销 - 数据库删除了冗余列
  3. 减少内存占用 - FileMetadata 对象更小
  4. 提升代码可读性 - 枚举比字符串更清晰

性能测试

由于改动只涉及类型判断逻辑(从字符串比较改为扩展名查表),性能影响可忽略不计。

扩展名提取和查表操作都是 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. 扩展文件类型支持

如需支持新文件类型,只需:

  1. 在对应的 _xxxExtensions Set 中添加扩展名
  2. AppFileType 枚举中添加新类型(如有必要)
  3. getFileIcon 等函数中添加对应的图标和颜色

3. 考虑动态文件类型识别

对于无扩展名或扩展名不可靠的文件,可以考虑:

  • 使用 magic number 检测(读取文件头部字节)
  • 但会增加复杂度,目前基于扩展名的方案已满足需求

经验总结

架构设计原则

  1. YAGNI 原则 - 不需要的功能就不要实现

    • MIME 存储看似完整,实则冗余
  2. DRY 原则 - 不要重复自己

    • MIME 完全可由扩展名推导,无需存储
  3. 单一数据源 - 避免数据不一致

    • 扩展名是唯一的文件类型来源

重构时机

  • 开发阶段重构成本低 - 无历史数据负担
  • 及时发现问题 - 避免技术债务积累
  • 保持代码简洁 - 降低长期维护成本

测试策略

  1. 静态分析优先(go vet, flutter analyze
  2. 编译验证必不可少
  3. 关键路径手动测试(文件上传、预览、删除等)

总结

这次 MIME 简化重构是一次成功的架构优化案例,充分体现了项目早期重构的价值

  • 删除冗余设计 - 前后端共删除 100+ 行冗余代码
  • 提升代码质量 - 类型安全、可读性增强
  • 简化 API 设计 - 函数签名更简洁
  • 修复潜在 bug - 顺带修复了缩略图删除问题

最重要的是,这次重构不影响任何功能,纯粹的内部优化。这正是早期重构的最佳时机。


相关笔记: