iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0
Modern Web

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

[學習 Effect Day3] 從 POC 到 Production(二)

  • 分享至 

  • xImage
  •  

這篇與上一篇是連貫的,如果你還沒看過,請先看過再來看這篇。

加入中斷機制

const getTodo = async (
  id: number,
  opt?: { signal?: AbortSignal },
): Promise<unknown> => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
    signal: opt?.signal,
  });
  return await res.json();
};

const getTodos = (
  ids: number[],
  opt?: { limit?: number; signal?: AbortSignal },
) => {
  const limit = opt?.limit ?? 5;
  const controller = new AbortController();
  const remaining = ids
    .slice(0, ids.length)
    .map((id, index) => [id, index] as const)
    .reverse();
  const results: unknown[] = [];
  if (opt?.signal) {
    opt.signal.addEventListener("abort", () => {
      controller.abort();
    });
  }
  return new Promise<unknown[]>((resolve, reject) => {
    let pending = 0;
    for (let i = 0; i < limit; i++) {
      fetchRemaining();
    }
    function fetchRemaining() {
      if (remaining.length > 0) {
        const [remainingToFetchId, remainingToFetchIdx] = remaining.pop()!;
        pending++;
        getTodo(remainingToFetchId, { signal: controller.signal })
          .then((res) => {
            results[remainingToFetchIdx] = res;
            pending--;
            fetchRemaining();
          })
          .catch((err) => {
            controller.abort();
            return reject(err);
          });
      } else if (pending === 0) {
        resolve(results);
      }
    }
  });
};

程式碼說明:中斷機制重點

  • getTodos 可取消:呼叫 getTodos(ids, { signal });當你對該 signal 呼叫 abort(),我們會把中止訊號轉給所有進行中的 fetch,它們會立刻中斷後續行為。
  • 單一錯誤即全體中止:任一請求 .catch 到錯誤後,會立刻 controller.abort()reject(error)。這樣可以避免資源浪費(網路、CPU)並加速失敗回報(fail fast)。
  • 順序不亂:雖然請求是併發的,但用 results[originalIndex] = res 保證結果順序與輸入 ids 相同。
  • 資源釋放fetch 在收到中止訊號會拋出 AbortError,最先抵達的錯誤會讓最外層 Promise 失敗。其餘同時被中止的請求也會各自結束,不會留下懸掛任務。
  • 與併發池協作pendingremaining 構成簡單的「併發請求池」。一旦中止,池中 worker 不再遞補新任務,既有中的任務也會被中止。

呼叫端使用範例

const controller = new AbortController();

const p = getTodos([1,2,3,4,5,6,7,8,9,10], {
  limit: 5,
  signal: controller.signal,
});

// 例如超過 300ms 還沒完成就中止
setTimeout(() => controller.abort(), 300);

p.catch((err) => {
  if ((err as any)?.name === "AbortError") {
    // 被中止
    return;
  }
  // 其他錯誤處理
});

注意事項與最佳實踐

  • 避免事件監聽洩漏:若擔心 opt.signal.addEventListener 綁定後未移除,可在監聽時加上 { once: true },或在 Promise settle 後手動移除。
    • 範例:opt.signal.addEventListener("abort", () => controller.abort(), { once: true })
  • 中止屬於「預期事件」:在錯誤處理時可特別辨識 AbortError,避免把中止誤當成系統故障。
  • 是否要「最佳努力(best-effort)」:本文選擇「任一失敗即全部失敗」。若需求是「回傳已成功的部分並附上錯誤清單」,則可調整回傳結構為 { successes: A[], errors: Error[] },而非立即 reject
  • 與重試/logging 的整合:中止訊號應優先於重試(收到中止就不要重試);並在 logging 中標示 aborted: true 以利追蹤。

加入重試機制

透過網路發出的請求,隨時可能因為連線問題而失敗;我們需要一套錯誤處理機制來避免因為暫時性錯誤而讓整個流程中斷。這時就會用到 retry(重試)機制——簡單說,就是在失敗時再嘗試幾次。底下是實作程式碼:

const wait = (ms: number) => new Promise((res) => setTimeout(res, ms));

const callWithRetry = async (
  fn: () => Promise<unknown>,
  opt?: { limit?: number; cap?: number; base?: number; unitMs?: number },
  depth = 0,
): Promise<unknown> => {
  try {
    return await fn();
  } catch (error) {
    if (depth >= (opt?.limit ?? 10)) {
      throw error;
    }
    await wait(
      Math.min((opt?.base ?? 2) ** depth * (opt?.unitMs ?? 10), opt?.cap ?? 2000),
    );
    return callWithRetry(fn, opt, depth + 1);
  }
};

const getTodo = async (
  id: number,
  opt?: { signal?: AbortSignal },
): Promise<unknown> => {
  return callWithRetry(
    async () => {
      const res = await fetch(
        `https://jsonplaceholder.typicode.com/todos/${id}`,
        { signal: opt?.signal },
      );
      return await res.json();
    },
    { limit: 10, cap: 2000, base: 2, unitMs: 10 },
  );
};

上面實現了簡單的指數退避(Exponential Backoff),簡單來說,就是 retry 的時候,每次等待的時間會隨指數型增長。這樣可以避免因為錯誤導致 server 受到持續性的大量請求。

程式碼說明:指數退避(Exponential Backoff)重點

  • wait(ms):將 setTimeout 包成 Promise,用來在重試前延遲一段時間。
  • callWithRetry(fn, opt, depth)
    • depth 代表第幾次重試遞迴層級;第一次執行 fn() 成功就直接回傳,失敗才進入 catch 並等待後重試。
    • 終止條件depth >= limit(預設 10)時丟出原始錯誤,避免無限重試。
    • 延遲公式delayMs = min(base^depth * unitMs, cap)
      • base:指數底數(預設 2),每次重試倍增等待。
      • unitMs:基礎時間單位(預設 10ms)。
      • cap:上限(預設 2000ms),避免等待時間無限制成長。
      • 例如 base=2, unitMs=10, cap=2000 時,依序為 10, 20, 40, 80, 160, 320, 640, 1280, 2000, 2000...(達上限後維持)。
  • 為什麼用 Math.min:控制最大等待時間,避免對下游服務造成雪崩式壓力或拖累整體延遲。
  • 錯誤類型判斷(可擴充):本範例對所有錯誤都重試;實務上建議僅針對暫時性錯誤(如網路閃斷、5xx)重試,對於 4xx(如 400/401/404)通常不應重試。

小提醒:指數退避通常還會搭配「抖動(Jitter)」降低同時重試的同步化風險,例如將延遲乘上一個 0.5~1.5 的隨機係數,以減少瞬間尖峰。雖然我們這裡沒有實作,但這也是產品級的服務需要考慮的。

加入 logging 機制

在產品級應用中,logging 提供可觀測性(發生了什麼、何時、在哪裡、錯誤細節)。

  • 等級門檻(最小輸出層級):debug / info / warn / error
  • 敏感資訊遮罩:指定 key 以避免外洩(如 token、password)
  • 簡易計時(timer):量測操作耗時

先實作一個建立 logger 的方法:

type LogLevel = "debug" | "info" | "warn" | "error";

export type Logger = {
  debug: (message: string, context?: Record<string, unknown>) => void;
  info: (message: string, context?: Record<string, unknown>) => void;
  warn: (message: string, context?: Record<string, unknown>) => void;
  error: (
    message: string,
    error?: unknown,
    context?: Record<string, unknown>,
  ) => void;
  timer: (
    name: string,
    context?: Record<string, unknown>,
  ) => (
    success?: boolean,
    error?: unknown,
    extraContext?: Record<string, unknown>,
  ) => void;
};

const levelPriority: Record<LogLevel, number> = {
  debug: 10,
  info: 20,
  warn: 30,
  error: 40,
};

const mask = (
  obj: Record<string, unknown> | undefined,
  keys: string[],
): Record<string, unknown> | undefined => {
  if (!obj) return obj;
  const out: Record<string, unknown> = {};
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      out[key] = keys.includes(key) ? "[REDACTED]" : (obj as any)[key];
    }
  }
  return out;
};

export const createLogger = (options?: {
  level?: LogLevel;
  redactKeys?: string[];
}): Logger => {
  const minPriority = levelPriority[options?.level ?? "info"];
  const redactKeys = options?.redactKeys ?? [];
  const log = (
    level: LogLevel,
    message: string,
    context?: Record<string, unknown>,
    error?: unknown,
  ) => {
    if (levelPriority[level] < minPriority) return;
    const payload = mask(context, redactKeys);
    const method =
      level === "error"
        ? console.error
        : level === "warn"
        ? console.warn
        : level === "info"
        ? console.info
        : console.debug;
    error
      ? method(message, { ...(payload ?? {}), error })
      : method(message, payload);
  };
  return {
    debug: (message, context) => log("debug", message, context),
    info: (message, context) => log("info", message, context),
    warn: (message, context) => log("warn", message, context),
    error: (message, error, context) => log("error", message, context, error),
    timer: (name, context) => {
      const start = Date.now();
      return (success = true, error, extraContext) => {
        const durationMs = Date.now() - start;
        const contextToLog = {
          durationMs,
          ...(context ?? {}),
          ...(extraContext ?? {}),
        };
        success
          ? log("info", `${name} completed`, contextToLog)
          : log("error", `${name} failed`, contextToLog, error);
      };
    },
  };
};

程式碼說明:logger 實作重點

  • 層級門檻:以 levelPriority 搭配 minPriority 控制輸出層級。
  • 敏感遮罩maskredactKeys 將敏感欄位改為 "[REDACTED]"
  • 統一輸出log 依層級選擇 console 方法,並統一結構化輸出。
  • 計時工具timer(name, context) 回傳結束函式,結束時自動計時並輸出成功/失敗與額外上下文。

在程式碼中使用 logger

先建立一個 logger 實例,並在 getTodos 中使用。

const logger = createLogger({
  level: "info",
  redactKeys: ["password", "token", "authorization"],
});

const getTodos = (
  ids: number[],
  opt?: { limit?: number; signal?: AbortSignal; logger?: Logger },
) => {
  const log = opt?.logger ?? createLogger({ level: "info" });
  const limit = opt?.limit ?? 5;
  const controller = new AbortController();
  const remaining = ids
    .slice(0, ids.length)
    .map((id, index) => [id, index] as const)
    .reverse();
  const results: unknown[] = [];

  if (opt?.signal) {
    opt.signal.addEventListener("abort", () => {
      log.warn("getTodos aborted by caller");
      controller.abort();
    });
  }

  return new Promise<unknown[]>((resolve, reject) => {
    const stopTimer = log.timer("getTodos", { total: ids.length, limit });
    let pending = 0;
    log.info("getTodos start", { remaining: remaining.length });

    for (let i = 0; i < limit; i++) {
      fetchRemaining();
    }

    function fetchRemaining() {
      if (remaining.length > 0) {
        const [idToFetch, originalIndex] = remaining.pop()!;
        pending++;
        log.info("fetch todo", { id: idToFetch, pending });

        getTodo(idToFetch, { signal: controller.signal })
          .then((res) => {
            results[originalIndex] = res;
            pending--;
            fetchRemaining();
          })
          .catch((error) => {
            log.error("fetch todo failed", error, { id: idToFetch });
            controller.abort();
            stopTimer(false, error, {
              fetched: results.filter((x) => x != null).length,
            });
            return reject(error);
          });
      } else if (pending === 0) {
        log.info("getTodos completed", { count: results.length });
        stopTimer(true, undefined, { count: results.length });
        resolve(results);
      }
    }
  });
};

加入 logger 後的 log 時機

  • start:呼叫 getTodos 時記錄起始(info),包含 totallimitremaining
  • per-fetch:每次要對某個 id 發出請求前記錄(info),包含該 id 與當下 pending
  • caller-abort:呼叫端觸發中斷時記錄(warn),並中止所有進行中的請求。
  • error:只要任一請求失敗,會輸出兩筆錯誤並收斂流程:
    • 單一請求錯誤:訊息為 "fetch todo failed",內容含失敗的 iderror
    • 流程失敗(timer):訊息為 "getTodos failed",內容含 durationMs 與目前已完成數 fetched
      接著會中止所有進行中的請求並結束計時。
  • complete:全部完成、pending === 0 時記錄(info),包含最終 count,並結束計時。

這裡的 logging 機制只是一個簡單範例。產品級的 logger 還需要更多功能,例如除了 console.log,也應提供其他輸出管道,例如輸出到第三方 logging 服務,或具備 child logger 機制。

完整程式碼在這裡,歡迎拉下來跑跑看。

總結

看似只是簡單地取得 Todo 資料,卻變得這麼複雜。但我們的程式碼離產品級軟體的實作標準還有非常大的距離。無論是在剛剛我們實作的面相上(中斷機制、重試機制、logging),還是其他面向,例如 timeout 機制、切換瀏覽器頁籤返回後的自動 refetch、metrics(度量指標)、tracing、telemetry 等等。若你也想從一般工程師(Average Engineer)成長為產品級工程師,我認為在產品成長過程中持續學習,掌握各個節點的最佳實踐,是非常有效的路徑。別讓自己一直停留在 POC 階段,否則很難做出真正好的產品。順帶一提,網路上充斥著許多非產品級的範例,AI 預設產生的內容也未必夠穩健(robust);唯有具備相應知識,才能引導 AI 產出符合產品級要求的結果。這也是希望避免被 AI 取代的一般工程師可以努力的方向。

參考資料:


上一篇
[學習 Effect Day2] 從 POC 到 Production(一)
下一篇
[學習 Effect Day4] 為什麼需要 Effect
系列文
用 Effect 實現產品級軟體10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言