生產環境的程式碼必須面對現實,而現實幾乎從來不是一路順遂的「快樂路徑(happy path)」。
Effect 作者:Michael Arnaldi
前一章節我們有提到 Product-grade 軟體實作的考量重點,但這不是一定要怎麼做的硬性規定。很多東西可能一開始開發的時候,你根本不需要,或甚至你還無法預料到你會面對到其中的哪些議題(不確定產品是否能發展下去,發展方向又是什麼)。所以通常我們是隨著產品成長的過程中發現這些議題。
接下來我想要透過程式碼的展示,來一步一步讓你理解如何將一個 POC 等級的程式碼轉化為 Product-grade 的軟體。順帶一提我會用Next.js 的開發框架去實作,但這不是重點,你可以用你熟悉的框架去實作。完整的程式碼在這兒。😀
"use client";
import { useEffect, useState } from "react";
type Todo = {
userId: number;
id: number;
title: string;
completed: boolean;
};
const getTodo = async (id: number): Promise<unknown> => {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
return await res.json();
};
const getTodos = async (ids: number[]) => {
const todos: unknown[] = [];
for (const id of ids) {
todos.push(await getTodo(id));
}
return todos;
};
export default function Home() {
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
async function main() {
const list = (await getTodos([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])) as Todo[];
setTodos(list);
for (const todo of list) {
console.log(`Got a todo: ${JSON.stringify(todo)}`);
}
}
main();
}, []);
return (
<main className="flex flex-col items-center justify-center h-screen">
<h1>Todos</h1>
<ul>
{todos.map((t) => (
<li key={t.id}>
<span>{t.completed ? "[x]" : "[ ]"}</span>
<span className="ml-3">{t.title}</span>
</li>
))}
</ul>
</main>
);
}
從上面程式碼可以看到,我們有一個 getTodos
函式,它會去抓取一個 Todo 的列表。但我們可以發現這個 function 沒有並行化 (Concurrency),導致有 fetch waterfall 的問題。解決方法是透過 chunkSize 限制每次抓取的數量,並使用 Promise.all
來並行化 fetch 資料。
const getTodos = async (ids: number[]) => {
const chunkSize = 5;
const todos: unknown[] = [];
for (let i = 0; i < ids.length; i += chunkSize) {
const chunk = ids.slice(i, i + chunkSize);
const chunkTodos = await Promise.all(chunk.map(getTodo));
todos.push(...chunkTodos);
}
return todos;
};
但這樣做就夠了嗎?現在的程式碼每5個資料抓取完畢後,才會再抓取下一個5個資料。一但有一個階段的其中一個 fetch 任務變慢,影響的會是整個資料 fetching 流程。但如果不用 chunk 一但資料很大,也會導致 fetch 資料的時間過長等問題。所以我們需要再更進一步的優化。
const getTodos = (ids: number[], limit = 5) => {
const remaining = ids
.slice(0, ids.length) // ids 的淺拷貝
.map((id, index) => [id, index] as const) // 例如輸入 [101, 102, 103] → [[101,0],[102,1],[103,2]]
.reverse(); // 為了方便後續用 pop() 拿出剩下的任務
const results: unknown[] = []; // 用來存放 fetch 回來的資料,index 會對應到原來的 ids index
return new Promise<unknown[]>((resolve, reject) => {
// 起始並行請求
let pending = 0; // 記錄目前正在執行的請求數量
for (let i = 0; i < limit; i++) {
fetchRemaining(); // 一開始會同時啟動 limit 個請求
}
function fetchRemaining() {
if (remaining.length > 0) {
const [remainingToFetchId, remainingToFetchIdx] = remaining.pop()!; // 拿出第一個任務,並從 remaining 中移除
pending++; // 正在執行的請求數量 + 1
getTodo(remainingToFetchId)
.then((res) => {
results[remainingToFetchIdx] = res; // 確保結果按照 ids 原始順序存放在 results。JavaScript 陣列允許「先放後面,再補前面」,即便 results[0] 還是 undefined,也不會影響後續再補上。
pending--;
fetchRemaining();
})
.catch((err) => reject(err)); // 如果有一個請求失敗,就 reject 整個 Promise
} else if (pending === 0) {
resolve(results); // 全部完成後 resolve(results)
}
}
});
};
這個程式碼就是在實作一個「併發請求池」,用白話文簡單解釋一下運作原理
for (let i = 0; i < limit; i++) {
fetchRemaining();
}
假設 limit=5,就會啟動 5 個「工人」(worker),每個 worker 負責持續從 remaining pop 一個任務、執行、完成後再去拿下一個。
getTodo(remainingToFetchId)
.then((res) => {
results[remainingToFetchIdx] = res;
pending--;
fetchRemaining(); // 這個才是接力
})
limit
個。fetchRemaining()
在 then
裡呼叫了「自己」,它只會取代「剛完成的那個空檔 Worker」繼續幹活,不會再額外生成新的 Worker。但這樣就夠了嗎?現在的程式碼在有請求失敗的時候,error 會讓我們 reject 整個 Promise。當然這沒問題,我們需要有一個完整的正確資料。但中斷之後,我們希望後續如果還有其他請求,應該被中斷以節省資源。以現在的程式碼來說,假設 limit=3,一開始派出三個 Worker:
Worker A 在執行的時候出錯,觸發了 reject(err)。這時 Promise 會進入失敗狀態,但 Worker B 和 Worker C 已經開始執行各自的請求流程了,所以還是會繼續跑完。只是結果不會被 resolve。為了不造成資源的浪費,我們應該在程式碼中加入中斷的機制。