iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 8

Day 8 - 儲存架構設計:分層儲存策略與安全邊界

  • 分享至 

  • xImage
  •  

在設計 Crew Up 的儲存層時,我們面臨的核心挑戰不僅是「如何儲存」,更重要的是「如何建立清晰的安全邊界」。作為一個涉及使用者認證、社交互動和敏感資料的應用,我們需要在效能、安全性和可維護性之間找到最佳平衡點。

本文將深入探討我們的儲存架構設計決策,包括:

  • 分層儲存策略:如何根據資料敏感度劃分儲存邊界
  • 介面抽象設計:降耦合、提高可維護性的架構實作
  • 安全性考量:敏感資料的完整生命週期管理
  • 與 Repository 層的整合:Clean Architecture 中的實際應用

儲存架構的核心決策

安全邊界劃分

在評估專案需求後,我們採用基於資料敏感度的分層儲存策略:

┌─────────────────────────────────────┐
│           Application Layer         │
├─────────────────────────────────────┤
│  Repository Layer (Business Logic)  │
├─────────────────────────────────────┤
│        Storage Abstraction Layer    │
├──────────────────┬──────────────────┤
│   LocalStorage   │  SecureStorage   │
│  (Non-sensitive) │   (Sensitive)    │
├──────────────────┼──────────────────┤
│ SharedPreferences│flutter_secure_   │
│                  │    storage       │
└──────────────────┴──────────────────┘

設計原則

  1. 明確的安全邊界:敏感與非敏感資料絕不混用
  2. 介面隔離:不同儲存需求使用獨立抽象
  3. 注入依賴:支援不同實作的靈活切換
  4. 錯誤邊界:完整的異常處理和降級策略
  5. 效能權衡:根據資料特性選擇最適合的儲存方案

儲存分工策略

LocalStorage(一般儲存)

使用場景:快取資料、UI 狀態、非敏感業務資料
底層實作:SharedPreferences
資料範圍:使用者去識別化資料、活動列表、計數器
特性:讀寫效能優先、支援多種資料型別

SecureStorage(安全儲存)

使用場景:認證憑證、私密金鑰、使用者隱私資料
底層實作:flutter_secure_storage
資料範圍:Firebase ID Token(Firebase Auth 自動管理 token 生命週期)
特性:加密儲存、系統級保護、跨平台安全一致性

效能權衡的深度分析

在儲存架構設計中,我們面臨的核心權衡是安全性 vs 效能

SharedPreferences (LocalStorage) 的效能特性

  • 讀寫速度:直接檔案 I/O
  • 記憶體占用:將常用資料載入記憶體快取
  • 適用場景:頻繁讀寫的快取資料、UI 狀態、計數器

flutter_secure_storage (SecureStorage) 的效能成本

  • 加密開銷:每次讀寫都需要進行加密/解密操作
  • 系統層互動:需要與 iOS Keychain / Android Keystore 通訊
  • 效能影響:每次操作都必須解密,因此 I/O 延遲明顯高於 SharedPreferences
┌─────────────────┬─────────────────┬─────────────────┐
│     操作類型     │  LocalStorage   │  SecureStorage  │
├─────────────────┼─────────────────┼─────────────────┤
│ 讀取速度         │   ⚡ 快速         │ 🐢 較慢(需加解密)│
│ 寫入速度         │   ⚡ 快速         │ 🐢 較慢(需加解密)│
│ 批次存取效率      │   👍 適合        │ 👎 不建議(開銷大)│
└─────────────────┴─────────────────┴─────────────────┘

為什麼這個權衡至關重要?

正因為 SecureStorage 的效能成本較高,我們才更需要將其使用範圍限制在絕對必要的敏感資料上(如 Firebase ID Token)。如果將 SecureStorage 濫用於頻繁讀寫的業務快取,會對 App 流暢度造成可觀測的影響,特別是在:

  • 應用啟動時的資料載入
  • 列表滑動時的快取讀取
  • 背景同步時的批次操作

安全威脅分析:為什麼需要分層儲存?

在決定儲存架構時,我們必須理解不同儲存方式面臨的安全威脅。

SharedPreferences 的安全限制

SharedPreferences 和 iOS 的 UserDefaults 本質上都是明文儲存,存在多種安全風險:

Android 平台風險:
/data/data/com.example.crew_up/shared_prefs/
├── FlutterSharedPreferences.xml  ← 明文可讀
├── [其他設定檔案].xml

iOS 平台風險:
~/Library/Preferences/com.example.crew-up.plist  ← 明文 plist 檔案

具體威脅場景

  1. Root/Jailbreak 設備風險

    • Android:在已 Root 的設備上,任何應用都可以讀取其他應用的 SharedPreferences
    • iOS:在已 Jailbreak 的設備上,可以直接存取應用的 UserDefaults plist 檔案
    • 攻擊者可以直接存取檔案系統中的明文資料
    • 所有儲存的資料都是明文,包括可能的敏感資訊
  2. 除錯工具風險

    • Android:透過 adb shell 可以直接讀取應用資料
    • iOS:透過 Xcode 或其他除錯工具可以存取應用沙盒
    • 開發階段的除錯資訊可能洩露到生產環境
    • 測試設備上的敏感資料可能被意外暴露
  3. 設備備份洩露

    • Android:應用備份機制可能包含 SharedPreferences
    • iOS:iTunes/iCloud 備份包含 UserDefaults 資料
    • 雲端備份服務可能不加密儲存這些資料
    • 使用者換機時備份資料可能被第三方取得

實際的安全邊界劃分

在 Crew Up 專案中,我們的劃分策略基於資料的敏感度:

// 一般儲存(LocalStorage)- 可接受的風險
final chatCache = await localStorage.getStringList('recent_chats');
final activityCount = await localStorage.getInt('activities_count');
// 即使被讀取,也不會造成嚴重的安全問題

// 安全儲存(SecureStorage)- 必須保護的資料
final firebaseIdToken = await secureStorage.getString(SecureStorageKeys.firebaseIdToken);
// Firebase ID Token 如果被竊取,攻擊者可以冒充使用者身份

風險評估的實務考量

  1. 影響範圍:Firebase ID Token 被竊取可能導致帳戶被接管
  2. 資料敏感度:活動快取洩露相對影響較小
  3. 效能平衡:頻繁存取的資料不適合放在 SecureStorage
  4. 合規要求:個資法規對敏感資料有特殊保護要求
  5. Firebase 優勢:自動處理 token 過期和刷新,簡化安全管理

這就是為什麼我們採用分層儲存策略的核心原因:不同敏感度的資料需要不同層級的保護

LocalStorage 抽象層設計

介面定義:平衡完整性與簡潔性

// lib/app/core/storage/local_storage.dart

// (imports omitted)

/// 本地儲存抽象介面
/// 
/// 提供類型安全的儲存操作,支援 SharedPreferences 的所有基本資料型別
/// 設計原則:
/// - 介面隔離:只暴露必要的操作
/// - 異常透明:讓呼叫方決定錯誤處理策略
/// - 類型安全:避免運行時類型錯誤
abstract class LocalStorage {
  // 基本儲存操作
  Future<void> saveString(String key, String value);
  Future<String?> getString(String key);
  
  Future<void> saveBool(String key, bool value);
  Future<bool?> getBool(String key);
  
  Future<void> saveInt(String key, int value);
  Future<int?> getInt(String key);
  
  Future<void> saveDouble(String key, double value);
  Future<double?> getDouble(String key);
  
  // 複合資料型別
  Future<void> saveStringList(String key, List<String> value);
  Future<List<String>?> getStringList(String key);
  
  // 元資料操作
  Future<bool> containsKey(String key);
  Future<void> remove(String key);
  Future<void> clear();
  Future<Set<String>> getKeys();
}

🎯 設計考量

  • 完整性:覆蓋 SharedPreferences 的所有資料型別,避免直接依賴
  • 一致性:統一的異常處理模式,所有操作都是 async
  • 可擴展性:純介面設計,輕鬆切換不同的底層實作

實作:關注可觀測性與錯誤處理

// lib/app/core/storage/local_storage.dart

// (imports omitted)

/// LocalStorage 的生產環境實作
/// 
/// 基於 SharedPreferences,增強了錯誤處理和可觀測性
class LocalStorageImpl implements LocalStorage {
  SharedPreferences? _preferences;

  /// 延遲初始化的 SharedPreferences 實例
  /// 使用單例模式避免重複初始化開銷
  Future<SharedPreferences> get _prefs async {
    _preferences ??= await SharedPreferences.getInstance();
    return _preferences!;
  }

  @override
  Future<void> saveString(String key, String value) async {
    try {
      final prefs = await _prefs;
      await prefs.setString(key, value);
      developer.log('✅ 字串儲存成功: $key', name: 'LocalStorageImpl');
    } on Exception catch (e) {
      developer.log('❌ 字串儲存失敗: $key - $e', name: 'LocalStorageImpl');
      rethrow; // 讓呼叫方決定降級策略
    }
  }

  @override
  Future<String?> getString(String key) async {
    try {
      final prefs = await _prefs;
      final value = prefs.getString(key);
      developer.log('📖 字串讀取: $key = $value', name: 'LocalStorageImpl');
      return value;
    } on Exception catch (e) {
      developer.log('❌ 字串讀取失敗: $key - $e', name: 'LocalStorageImpl');
      throw LocalStorageReadException(
        'Failed to read string value',
        originalException: e,
        key: key,
      );
    }
  }

  // 其他方法遵循相同模式...
}

💡 實作亮點

  • 結構化日誌:使用 developer.log 提供豐富的除錯資訊
  • 一致的錯誤處理:所有操作失敗都拋出具體的異常型別,讓呼叫方決定處理策略

SecureStorage 安全儲存設計

多平台安全配置

// lib/app/core/storage/secure_storage.dart

// (imports omitted)

/// 安全儲存抽象介面
/// 
/// 專門處理敏感資料,提供系統級加密保護
/// 設計目標:最小化攻擊面、跨平台一致性
abstract class SecureStorage {
  Future<void> saveString(String key, String value);
  Future<String?> getString(String key);
  Future<bool> containsKey(String key);
  Future<void> remove(String key);
  Future<void> clear();
}

/// 基於 flutter_secure_storage 的實作
/// 
/// 針對不同平台優化安全參數,提供最佳的安全性與可用性平衡
class SecureStorageImpl implements SecureStorage {
  final FlutterSecureStorage _storage;

  SecureStorageImpl({FlutterSecureStorage? storage})
      : _storage = storage ?? const FlutterSecureStorage();

  // 平台特定的安全配置
  IOSOptions get _defaultIOSOptions =>
      const IOSOptions(accessibility: KeychainAccessibility.unlocked);

  AndroidOptions get _defaultAndroidOptions =>
      const AndroidOptions(encryptedSharedPreferences: true);

  // 其他平台配置...

  @override
  Future<void> saveString(String key, String value) async {
    try {
      await _storage.write(
        key: key,
        value: value,
        iOptions: _defaultIOSOptions,
        aOptions: _defaultAndroidOptions,
        // 其他平台參數...
      );
      developer.log('🔐 Saved secure key: $key', name: 'SecureStorageImpl');
    } on Exception catch (e) {
      developer.log(
        '❌ Failed to save secure key: $key - $e',
        name: 'SecureStorageImpl',
      );
      rethrow;
    }
  }
  
  // 其他方法實作...
}

/// 安全金鑰常數定義
/// 
/// 集中管理避免魔法字串,降低金鑰衝突風險
class SecureStorageKeys {
  static const String firebaseIdToken = 'auth:firebase_id_token';
  // 注意:使用 Firebase Auth,只需儲存 Firebase ID Token,
  // 不需要傳統的 access token / refresh token 機制
  
  // 私有建構函數防止實例化
  SecureStorageKeys._();
}

🔒 安全性考量

  • 平台最佳化:針對 iOS Keychain 和 Android EncryptedSharedPreferences 的專門配置
  • 金鑰命名規範:使用前綴避免衝突,集中管理避免重複
  • 最小權限原則:只提供必要的操作,降低攻擊面

平台安全配置的深入解析

iOS Keychain 配置選擇

IOSOptions get _defaultIOSOptions =>
    const IOSOptions(accessibility: KeychainAccessibility.unlocked);

為什麼選擇 KeychainAccessibility.unlocked?這是基於專案實際需求的選擇:

  • unlocked:設備解鎖後即可存取,支援背景刷新等場景
  • 實際需求:Firebase ID Token 需要在背景同步時被讀取
  • 權衡考量:在使用者體驗和安全性之間找到平衡點

Android EncryptedSharedPreferences 配置

AndroidOptions get _defaultAndroidOptions =>
    const AndroidOptions(encryptedSharedPreferences: true);

encryptedSharedPreferences: true 在專案中的實際效果:

  • 自動金鑰管理:Android 系統自動管理加密和解密金鑰
  • 應用隔離:每個應用都有獨立的加密金鑰空間
  • 專案好處:Firebase ID Token 以加密形式儲存在設備上

跨平台一致性設計

// 統一的安全配置介面
await _storage.write(
  key: key,
  value: value,
  iOptions: _defaultIOSOptions,      // iOS 專屬配置
  aOptions: _defaultAndroidOptions,  // Android 專屬配置
  // ... 其他平台
);

這種設計確保了:

  1. 一致的 API:跨平台統一的呼叫方式
  2. 平台最佳化:每個平台使用最適合的安全機制
  3. 可維護性:集中的配置管理,易於調整安全策略

與認證流程的整合:完整的生命週期管理

登入流程:安全儲存最佳實務

// lib/features/auth/data/datasources/auth_remote_datasource.dart

// (imports omitted)

// Token 儲存:優先級和降級策略
final String? idToken = await firebaseUser.getIdToken();
if (idToken != null && idToken.isNotEmpty) {
  developer.log(
    '🔐 保存 Firebase ID Token 至 SecureStorage',
    name: 'AuthRemoteDataSourceFirebaseOnly',
  );
  
  // 安全儲存 Token
  await _secureStorage.saveString(
    SecureStorageKeys.firebaseIdToken,
    idToken,
  );
  
  developer.log(
    '✅ Firebase ID Token 已安全儲存',
    name: 'AuthRemoteDataSourceFirebaseOnly',
  );
}

登出流程:安全清理策略

// lib/features/auth/data/datasources/auth_remote_datasource.dart

// (imports omitted)

// 清除敏感資料:確保無殘留
developer.log(
  '🧽 準備清除 SecureStorage Token',
  name: 'AuthRemoteDataSourceFirebaseOnly',
);

// 清除 Firebase ID Token
await _secureStorage.remove(SecureStorageKeys.firebaseIdToken);

🎯 生命週期管理重點

  • 簡潔高效:相信型別安全的 API,避免不必要的驗證
  • 完整清除:清除 Firebase ID Token,確保登出安全
  • 錯誤處理:依賴 API 自身的錯誤處理機制

架構整合:與 Repository 層的協作

在 Crew Up 的 Clean Architecture 實作中,儲存層作為基礎設施層,透過 Repository 模式為業務邏輯提供服務:

// Repository 中的儲存層使用範例

// (imports omitted)

class ActivityRepositoryImpl extends BaseRepository 
    with RepositoryErrorHandling 
    implements ActivityRepository {
  
  // 注入儲存抽象,不直接依賴具體實作
  final ActivityLocalDataSource _localDataSource;  // 內部使用 LocalStorage
  final ActivityDataSource _remoteDataSource;
  
  @override
  Future<Result<List<Activity>>> getActivities() async => executeRemoteFirst(
    '獲取活動列表',
    RepositoryOperation.read,
    () async {
      // 遠端優先策略
      final remoteActivities = await _remoteDataSource.getAllActivities();
      
      // 同步到本地儲存,使用 safeSyncOperation 確保錯誤隔離
      await safeSyncOperation(() async {
        for (final activity in remoteActivities) {
          await _localDataSource.saveActivity(activity);  // 內部使用 LocalStorage
        }
      }, '同步活動列表到本地');
      
      return remoteActivities;
    },
    () async => await _localDataSource.getAllActivities(),  // 本地降級策略
  );
}

🏗️ 架構整合優勢

  • 依賴反轉:Repository 依賴儲存抽象,不依賴具體實作
  • 錯誤隔離:儲存層異常不影響業務邏輯
  • 策略模式:支援不同的快取和同步策略

下一步

明天,我們將深入儲存系統的進階議題:資料快取、TTL 過期控制與版本管理。我們會學習如何設計可過期的快取策略、在本地與遠端之間做出正確取捨,並在資料格式演進時安全完成遷移與回滾。

期待與您在 Day 9 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 8 - 儲存架構設計:分層儲存策略與安全邊界
  • 文章日期: 2025-09-22
  • 技術棧: Flutter, Clean Architecture, SharedPreferences, flutter_secure_storage, Repository Pattern

上一篇
Day 7 - Repository 模式:軟體架構的自由基石
下一篇
Day 9 - Cache、TTL 與版本管理:實現高效能的資料快取策略
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言