從 POC 到 Production(二)
這篇與上一篇是連貫的,如果你還沒看過,請先看過再來看這篇。
加入中斷機制
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 失敗。其餘同時被中止的請求也會各自結束,不會留下懸掛任務。 - 與併發池協作:
pending與remaining構成簡單的「併發請求池」。一旦中止,池中 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 受到持續性的大量請求。
程式碼說明
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);
};
},
};
};程式碼說明
- 層級門檻:以
levelPriority搭配minPriority控制輸出層級。 - 敏感遮罩:
mask依redactKeys將敏感欄位改為"[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),包含total、limit、remaining。 - per-fetch:每次要對某個
id發出請求前記錄(info),包含該id與當下pending。 - caller-abort:呼叫端觸發中斷時記錄(
warn),並中止所有進行中的請求。 - error:只要任一請求失敗,會輸出兩筆錯誤並收斂流程:
- 單一請求錯誤:訊息為
"fetch todo failed",內容含失敗的id與error。 - 流程失敗(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 取代的一般工程師可以努力的方向。
參考資料:
Last updated on