iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Modern Web

用 Effect 實現產品級軟體系列 第 16

[學習 Effect Day16] Effect 進階錯誤管理 (二)

  • 分享至 

  • xImage
  •  

在前一篇文章中,我們學習了 Effect 重試機制的概念與實作。然而,重試機制並非萬能,當所有重試都失敗時,我們需要一個降級策略來確保系統的可用性。這篇文章將深入探討 Effect 的降級策略,讓您的應用在面對持續性錯誤時仍能優雅地提供基本功能。

什麼是降級策略?

降級策略(Fallback Strategy)是系統設計中的最後防線,當主要功能完全失敗時,提供一個可接受的替代方案。這不是失敗,而是一種優雅的降級

為什麼需要降級策略?

在真實的生產環境中,我們會遇到各種無法預期的情況:

  • 網路不穩定:用戶在移動環境中使用應用
  • 服務器過載:高流量時期的系統壓力
  • 第三方服務故障:依賴的外部 API 暫時不可用
  • 資料庫連接問題:暫時性的資料庫維護

沒有降級策略的系統在遇到這些問題時會:

  • ❌ 顯示錯誤頁面或白屏
  • ❌ 用戶體驗完全中斷
  • ❌ 業務功能完全停擺

有了降級策略的系統則能:

  • ✅ 提供基本功能,保持系統可用性
  • ✅ 優雅地處理錯誤,提升用戶體驗
  • ✅ 確保核心業務流程不中斷

Effect 的降級策略:retryOrElse

Effect 提供了 retryOrElse 方法來實現降級策略,它結合了重試機制和降級策略:

基本語法

const retryWithFallback = Effect.retryOrElse(
  originalEffect,    // 要重試的 Effect
  retrySchedule,     // 重試策略
  fallbackEffect     // 降級策略
)

降級策略的三大類型

降級類型 核心概念 實際應用 用戶體驗
功能降級 提供基本功能替代完整功能 用戶資料 → 預設頭像 避免功能完全失效
資料降級 使用快取或預設資料 即時資料 → 快取資料 保持內容可見性
服務降級 切換到備用服務 支付服務 → 備用支付 確保業務流程不中斷

降級策略的設計原則

  1. 功能完整性:降級後仍能提供核心功能
  2. 用戶體驗:避免完全失敗,保持基本可用性
  3. 資源效率:使用本地資源或輕量級替代方案
  4. 可觀測性:記錄降級事件,便於監控和除錯

實戰範例:用戶資料獲取的降級策略

讓我們通過一個完整的實戰範例來學習降級策略的實作。這個範例模擬了一個真實的用戶資料獲取場景,展示如何在 API 失敗時優雅地降級到基本功能。

場景

假設我們正在開發一個電商網站的用戶個人頁面,需要獲取用戶的詳細資料來顯示畫面。在正常情況下,可以從 API 獲取完整的用戶資料,但當 API 回應失敗時,我們需要提供一個降級方案,以維持用戶基本體驗。目標是無論如何都要讓用戶看到個人頁面,不能出現白屏或資料空白的情況。

降級策略設計

降級策略設計如下:

降級策略

Mock API 模擬器

這部分沒興趣可以直接跳過沒關係,我們的重點是理解 Effect 中的降級策略如何實踐

為了測試降級策略,我們需要創建可以精準控制第幾次才會得到成功的 API 模擬函數,分別是 getFromAPIgetFromRedisgetFromLocalStorage,程式碼如下:

import { Effect, Schedule } from "effect"

// 通用模擬函數配置類型
type MockConfig = {
  name: string
  icon: string
  failureThreshold: number
  delay: number
  successMessage: string
  failureMessage: string
  dataGenerator: (userId: string) => any
}

// 通用模擬函數工廠
const createMockFunction = (config: MockConfig) => {
  let attemptCount = 0

  return (userId: string) => {
    return Effect.tryPromise({
      try: () => {
        attemptCount++
        console.log(`${config.icon} 嘗試從 ${config.name} 獲取用戶 ${userId} 的資料 (嘗試 ${attemptCount})`)

        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (attemptCount <= config.failureThreshold) {
              console.log(`❌ ${config.name} 第 ${attemptCount} 次嘗試失敗`)
              reject(new Error(`${config.failureMessage} - 嘗試 ${attemptCount}`))
            } else {
              console.log(`✅ ${config.successMessage}`)
              resolve(config.dataGenerator(userId))
            }
          }, config.delay)
        })
      },
      catch: (error) => new Error(`${config.name} 錯誤: ${error}`)
    })
  }
}

// 模擬函數配置
const mockConfigs = {
  api: {
    name: "API",
    icon: "🔄",
    failureThreshold: 2,
    delay: 1000,
    successMessage: "API 調用成功",
    failureMessage: "API 調用失敗",
    dataGenerator: (userId: string) => ({
      id: userId,
      name: `User ${userId}`,
      email: `user${userId}@example.com`,
      avatar: `https://i.pravatar.cc/150?img=${userId}`,
      phone: `+1-555-${userId.padStart(4, "0")}`,
      website: `https://user${userId}.example.com`,
      company: `Company ${userId}`,
      source: "API"
    })
  },
  redis: {
    name: "Redis",
    icon: "🔍",
    failureThreshold: 2,
    delay: 500,
    successMessage: "Redis 連接成功,返回快取資料",
    failureMessage: "Redis 連接失敗",
    dataGenerator: (userId: string) => ({
      id: userId,
      name: `Cached User ${userId}`,
      email: `cached${userId}@example.com`,
      avatar: `https://i.pravatar.cc/150?img=${userId}`,
      phone: `+1-555-${userId.padStart(4, "0")}`,
      website: `https://cached${userId}.example.com`,
      company: `Cached Company ${userId}`,
      source: "Redis Cache"
    })
  },
  localStorage: {
    name: "LocalStorage",
    icon: "💾",
    failureThreshold: 1,
    delay: 300,
    successMessage: "LocalStorage 讀取成功,返回本地資料",
    failureMessage: "LocalStorage 讀取失敗",
    dataGenerator: (userId: string) => ({
      id: userId,
      name: `Local User ${userId}`,
      email: `local${userId}@example.com`,
      avatar: `https://i.pravatar.cc/150?img=${userId}`,
      phone: `+1-555-${userId.padStart(4, "0")}`,
      website: `https://local${userId}.example.com`,
      company: `Local Company ${userId}`,
      source: "LocalStorage"
    })
  }
}

// 創建模擬函數實例
const getFromAPI = createMockFunction(mockConfigs.api)
const getFromRedis = createMockFunction(mockConfigs.redis)
const getFromLocalStorage = createMockFunction(mockConfigs.localStorage)

這個模擬器核心功能如下:

  • 通用模擬函數工廠:透過 createMockFunction 創建可配置的模擬函數
  • 可控的失敗模式:每個模擬器都有獨立的 failureThreshold 設定失敗次數
  • 模擬真實延遲:透過 delay 參數模擬網路延遲情境
  • 詳細的日誌記錄:記錄每次嘗試的狀態和結果,方便觀察降級流程
  • 多層級模擬:支援 API、Redis、LocalStorage 三種不同層級的資料來源

用我們前面教過的 Effect Schedule 建置重試策略配置

// 重試策略配置
// 最多重試 3 次,每次間隔 1 秒 => 也就是總共最多會嘗試 1(初次)+ 3(重試)= 4 次。
const retryPolicy = Schedule.compose(
  Schedule.recurs(3),
  Schedule.fixed("1 seconds")
)

第一層:主要 API 調用 + 重試機制 (核心業務邏輯)

export const fetchUserFromAPI = (userId: string) =>
  Effect.retryOrElse(
    getFromAPI(userId),
    retryPolicy,
    () => getCachedUserData(userId) // 降級到快取
  )

第二層:Redis 快取降級

const getCachedUserData = (userId: string) =>
  Effect.retryOrElse(
    getFromRedis(userId),
    retryPolicy,
    () => getLocalStorageData(userId) // 降級到本地儲存
  )

第三層:本地儲存降級

const getLocalStorageData = (userId: string) =>
  Effect.retryOrElse(
    getFromLocalStorage(userId),
    retryPolicy,
    () => getDefaultUserData(userId) // 降級到預設資料
  )

第四層:預設資料降級

const getDefaultUserData = (userId: string) =>
  Effect.succeed({
    id: userId,
    name: "訪客用戶",
    avatar: "/assets/default-avatar.png"
    // ... 其他預設資料
  })

從上面程式碼可以發現,我們透過 Effect.retryOrElse 將各層的 API 調用組合起來,讓每層資料獲取失敗後,自動切換到下一層的降級策略。

完整資料流向圖

https://ithelp.ithome.com.tw/upload/images/20250930/20175990PUNcWJUNRd.png

多層降級策略測試

大家可以跑跑看,是真的可以跑起來的,我覺得滿有成就感的。雖然大部分程式都是 AI 寫的🤣。

// 創建測試場景的降級函數
const createTestScenario = (apiConfig: MockConfig, redisConfig: MockConfig, localStorageConfig: MockConfig) => {
  const testApiCall = createMockFunction(apiConfig)
  const testGetFromRedis = createMockFunction(redisConfig)
  const testGetFromLocalStorage = createMockFunction(localStorageConfig)

  const testFetchUserFromAPI = (userId: string) =>
    Effect.retryOrElse(
      Effect.tryPromise({
        try: () => testApiCall(userId).pipe(Effect.runPromise),
        catch: (error) => new Error(`Failed to fetch user profile: ${error}`)
      }),
      retryPolicy,
      () => testGetCachedUserData(userId)
    )

  const testGetCachedUserData = (userId: string) =>
    Effect.retryOrElse(
      testGetFromRedis(userId),
      retryPolicy,
      () => testGetLocalStorageData(userId)
    )

  const testGetLocalStorageData = (userId: string) =>
    Effect.retryOrElse(
      testGetFromLocalStorage(userId),
      retryPolicy,
      () => getDefaultUserData(userId)
    )

  return testFetchUserFromAPI
}

// 測試程序
const program = Effect.gen(function*() {
  console.log("🚀 開始測試多層降級策略...")
  console.log("📝 降級策略:API → Redis → LocalStorage → 預設資料")
  console.log("⏱️  每層都有重試機制,最多重試 3 次")
  console.log("🛡️  完整降級流程測試")
  console.log("=".repeat(60))

  // 測試場景 1:API 成功(第 3 次重試成功)
  console.log("📋 測試場景 1:API 重試成功")
  const scenario1 = createTestScenario(
    mockConfigs.api, // API 正常
    { ...mockConfigs.redis, failureThreshold: 10 }, // Redis 永遠失敗
    { ...mockConfigs.localStorage, failureThreshold: 10 } // LocalStorage 永遠失敗
  )
  const userProfile1 = yield* scenario1("1")
  console.log("✅ 場景 1 結果:", userProfile1)
  console.log("=".repeat(40))

  // 測試場景 2:API 失敗,Redis 成功
  console.log("📋 測試場景 2:API 失敗,Redis 重試成功")
  const scenario2 = createTestScenario(
    { ...mockConfigs.api, failureThreshold: 10 }, // API 永遠失敗
    mockConfigs.redis, // Redis 正常
    { ...mockConfigs.localStorage, failureThreshold: 10 } // LocalStorage 永遠失敗
  )
  const userProfile2 = yield* scenario2("2")
  console.log("✅ 場景 2 結果:", userProfile2)
  console.log("=".repeat(40))

  // 測試場景 3:API 和 Redis 都失敗,LocalStorage 成功
  console.log("📋 測試場景 3:API 和 Redis 都失敗,LocalStorage 重試成功")
  const scenario3 = createTestScenario(
    { ...mockConfigs.api, failureThreshold: 10 }, // API 永遠失敗
    { ...mockConfigs.redis, failureThreshold: 10 }, // Redis 永遠失敗
    mockConfigs.localStorage // LocalStorage 正常
  )
  const userProfile3 = yield* scenario3("3")
  console.log("✅ 場景 3 結果:", userProfile3)
  console.log("=".repeat(40))

  // 測試場景 4:所有層級都失敗,降級到預設資料
  console.log("📋 測試場景 4:所有層級都失敗,降級到預設資料")
  const scenario4 = createTestScenario(
    { ...mockConfigs.api, failureThreshold: 10 }, // API 永遠失敗
    { ...mockConfigs.redis, failureThreshold: 10 }, // Redis 永遠失敗
    { ...mockConfigs.localStorage, failureThreshold: 10 } // LocalStorage 永遠失敗
  )
  const userProfile4 = yield* scenario4("4")
  console.log("✅ 場景 4 結果:", userProfile4)
  console.log("=".repeat(40))

  return {
    scenario1: userProfile1,
    scenario2: userProfile2,
    scenario3: userProfile3,
    scenario4: userProfile4
  }
})

// 執行測試
Effect.runPromise(program).then((res) => {
  console.log("🔍 最終結果:", res)
})

總結

降級策略是 Effect 錯誤管理中的重要組成部分,它確保系統在面對持續性錯誤時仍能提供基本功能。通過 retryOrElse 方法,我們可以:

  1. 結合重試和降級:先嘗試重試,失敗時自動降級
  2. 保證系統可用性:即使主要功能失敗,仍能提供基本功能
  3. 提升用戶體驗:避免完全失敗,保持系統可用性
  4. 實現優雅降級:提供有意義的替代方案

降級策略讓我們的應用在面對各種不可預期的情況時,仍保持服務的穩定性和可用性。

參考資料


上一篇
[學習 Effect Day15] Effect 進階錯誤管理 (一)
下一篇
[學習 Effect Day17] Effect 進階錯誤管理 (三)
系列文
用 Effect 實現產品級軟體22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言