歡迎來到第十三天!昨天的內容相當輕鬆對吧! 我也覺得,但串接第三方就是很容易有些意外嘛,多留點空總不會錯!另一方面就是家中出了點事情,我需要一點時間去處理,這幾天的內容我會盡量放輕鬆一點,但不至於影響到我系列文的規劃,不用擔心!
昨天我們主要是建立了 Judge0的帳號並透過他提供的getLanguges API 測試了是否成功串接,從那一長串支援的語言清單來看是挺成功的!但我們要的可遠遠不止有這樣對吧!重點是要讓他執行程式碼嘛!今天,我們就要來做這件最關鍵的事:將使用者在前端編輯器中寫的 JavaScript 程式碼,安全地傳送給 Judge0,讓它在隔離的沙箱環境中執行,然後把詳細的「實驗報告」——也就是程式碼的客觀執行結果——拿回來。
這份報告包含了程式碼究竟是成功運行、還是噴出錯誤、輸出了什麼內容、花了多少時間等鐵證如山的客觀事實。這些事實,將是我們下一階段餵給 AI,讓它做出高品質 Code Review 的最重要依據。
/api/judge0/execute/route.ts
。stdout
, stderr
, time
, memory
等客觀數據。在開始寫程式碼之前,我們必須先理解 Judge0 處理程式碼執行的模式。它並不像一個簡單的函式呼叫,你傳入參數後就立刻得到回傳值。由於執行程式碼可能需要時間(從幾毫秒到幾秒鐘不等),Judge0 採用的是一個非同步 (Asynchronous) 的兩階段流程:
第一階段:提交任務,取得「取餐號碼牌」
POST /submissions
端點發出請求,請求中包含我們要執行的程式碼、語言 ID 等資訊。第二階段:憑號碼牌,輪詢取餐
token
後,需要對 GET /submissions/{token}
這個端點發出請求。token
查詢一次。這個重複查詢的過程,就叫做輪詢 (Polling)。stdout
, stderr
等資訊的完整報告。這個流程就像你去手搖飲店點餐,店員會給你一個號碼牌(token
),然後你會盯著叫號螢幕(輪詢),直到你的號碼出現(執行完成),你才能去取餐(取得結果)。
理解了流程後,我們來實作這個核心代理。根據昨天的經驗,我們知道 API 路由必須放在以它命名的資料夾底下。
請在 app/api/judge0/
資料夾下,建立一個新的資料夾 execute
,並在其中建立檔案 route.ts
。
檔案路徑: app/api/judge0/execute/route.ts
// app/api/judge0/execute/route.ts
import { NextResponse } from 'next/server';
const JUDGE0_API_HOST = process.env.JUDGE0_API_HOST;
const JUDGE0_API_KEY = process.env.JUDGE0_API_KEY;
// JavaScript 的語言 ID 在 Judge0 中是 93 (Node.js 18.15.0)
const JAVASCRIPT_LANGUAGE_ID = 93;
// 輔助函式:用於延遲
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function POST(request: Request) {
if (!JUDGE0_API_HOST || !JUDGE0_API_KEY) {
return NextResponse.json(
{ error: 'Judge0 API 環境變數未設定' },
{ status: 500 }
);
}
try {
const { source_code } = await request.json();
if (!source_code) {
return NextResponse.json({ error: '缺少 source_code' }, { status: 400 });
}
// --- Step 1: 提交程式碼 (Base64 編碼) ---
const encodedSourceCode = Buffer.from(source_code).toString('base64');
const submissionResponse = await fetch(
// 關鍵:base64_encoded=true
`https://${JUDGE0_API_HOST}/submissions?base64_encoded=true&wait=false`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-RapidAPI-Key': JUDGE0_API_KEY,
'X-RapidAPI-Host': JUDGE0_API_HOST,
},
body: JSON.stringify({
source_code: encodedSourceCode, // 送出編碼後的程式碼
language_id: JAVASCRIPT_LANGUAGE_ID,
}),
}
);
if (!submissionResponse.ok) {
const errorText = await submissionResponse.text();
console.error('Judge0 submission failed:', errorText);
return NextResponse.json(
{ error: '提交至 Judge0 失敗', details: errorText },
{ status: submissionResponse.status }
);
}
const submissionResult = await submissionResponse.json();
const { token } = submissionResult;
if (!token) {
return NextResponse.json(
{ error: '無法從 Judge0 取得 Token' },
{ status: 500 }
);
}
// --- Step 2: 輪詢 (Polling) 取得執行結果 ---
let resultData;
const maxRetries = 10;
const retryDelay = 500;
for (let i = 0; i < maxRetries; i++) {
await sleep(retryDelay);
const resultResponse = await fetch(
// 關鍵:base64_encoded=true
`https://${JUDGE0_API_HOST}/submissions/${token}?base64_encoded=true`,
{
method: 'GET',
headers: {
'X-RapidAPI-Key': JUDGE0_API_KEY,
'X-RapidAPI-Host': JUDGE0_API_HOST,
},
}
);
if (!resultResponse.ok) {
const errorText = await resultResponse.text();
return NextResponse.json(
{ error: '從 Judge0 獲取結果失敗', details: errorText },
{ status: resultResponse.status }
);
}
resultData = await resultResponse.json();
if (resultData.status_id > 2) {
// 1: In Queue, 2: Processing
break; // 執行完成 (成功、失敗、超時等)
}
}
if (!resultData || resultData.status_id <= 2) {
return NextResponse.json({ error: '程式碼執行超時' }, { status: 408 });
}
// --- Step 3: 解碼 Base64 結果 ---
const decodedResult = {
...resultData,
stdout: resultData.stdout
? Buffer.from(resultData.stdout, 'base64').toString('utf-8')
: null,
stderr: resultData.stderr
? Buffer.from(resultData.stderr, 'base64').toString('utf-8')
: null,
compile_output: resultData.compile_output
? Buffer.from(resultData.compile_output, 'base64').toString('utf-8')
: null,
};
return NextResponse.json(decodedResult);
} catch (error) {
console.error('代理 /api/judge0/execute 錯誤:', error);
return NextResponse.json({ error: '代理伺服器內部錯誤' }, { status: 500 });
}
}
整個流程可以拆解成三個主要階段:提交、輪詢和解碼。
在函式主體開始前,我們定義了幾個關鍵常數:
當 POST 請求進來後,在驗證過必要的參數後,我們立刻進入提交流程:
Buffer.from(...).toString('base64')
):這是整個流程中最關鍵的部分。我們將前端傳來的 source_code 字串轉換成 Base64 格式。這就像是把一份內容特殊的文件(可能包含中文、emoji 等)放進一個標準化的堅固信封裡,確保它在網路傳輸過程中不會因為編碼問題而出錯。
POST
請求至 /submissions
:拿到 token 後,我們不能傻等,而是要主動去「叫號」。這就是輪詢機制:
GET
請求至 /submissions/{token}
: 我們用 token 去查詢特定任務的進度。同樣地,我們也需要加上 base64_encoded=true 來告訴 Judge0 我們期望收到的回傳內容(如 stdout)也是 Base64 格式。resultData.status_id > 2
): 這是輪詢的核心邏輯。根據 Judge0 的文件,狀態 1 和 2 分別代表「排隊中」和「處理中」。任何大於 2 的狀態都意味著執行已結束(無論成功、失敗或超時)。一旦任務完成,我們就用 break 跳出迴圈。408 Request Timeout
錯誤給前端。一旦拿到執行完畢的結果,我們還需要做最後一步處理才能回傳給前端:
Buffer.from(..., 'base64').toString('utf-8')
):NextResponse
: 最後,我們將這個處理乾淨、完全解碼的 decodedResult 物件作為 JSON 回應,傳回給前端。為了在不修改前端的情況下,我們先快速測試這個新的代理 API,我們來建立一個獨立的 Node.js 腳本。
在專案根目錄的 scripts/ 資料夾下,建立一個新檔案 test-execution.js。
// scripts/test-execution.js
async function runTest(description, source_code) {
console.log(`\n--- 測試案例: ${description} ---`);
try {
const response = await fetch('http://localhost:3000/api/judge0/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_code }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(` ❌ 請求失敗 (${response.status}):`);
console.error('--- Server Response ---');
console.error(errorText);
console.error('--- End Server Response ---');
return;
}
const data = await response.json();
console.log(' ✅ 成功從代理 API 收到回應:');
const relevantData = {
stdout: data.stdout,
stderr: data.stderr,
status: data.status,
time: data.time,
memory: data.memory,
};
console.log(JSON.stringify(relevantData, null, 2));
} catch (error) {
console.error(' ❌ 執行測試時發生網路或其他錯誤:', error.message);
}
}
async function main() {
// 測試 1: 正常的 console.log
const codeSuccess = `console.log('Hello from the secure sandbox!');`;
await runTest('正常執行的程式碼', codeSuccess);
// 測試 2: 會產生執行階段錯誤 (Runtime Error) 的程式碼,並包含中文註解
const codeError = `const a = 1;\na.toUpperCase(); // 這會產生 TypeError`;
await runTest('會產生執行階段錯誤 (Runtime Error) 的程式碼', codeError);
// 測試 3: 語法錯誤的程式碼
const codeSyntaxError = `console.log('Missing quote);`;
await runTest('語法錯誤 (Syntax Error) 的程式碼', codeSyntaxError);
// 測試 4: 包含中文字串的正常程式碼
const codeUnicode = `console.log('你好,世界!');`;
await runTest('包含 Unicode (中文) 字串的程式碼', codeUnicode);
}
main();
現在,確保你的 Next.js 開發伺服器仍在運行,然後打開另一個終端機視窗,執行我們的測試腳本:
node scripts/test-execution.js
稍等幾秒鐘,你應該會看到類似以下的輸出:
--- 測試案例: 正常執行的程式碼 ---
✅ 成功從代理 API 收到回應:
{
"stdout": "Hello from the secure sandbox!\n",
"stderr": null,
"status": { "id": 3, "description": "Accepted" },
"time": "0.025",
"memory": 7964
}
--- 測試案例: 會產生執行階段錯誤 (Runtime Error) 的程式碼 ---
✅ 成功從代理 API 收到回應:
{
"stdout": null,
"stderr": "TypeError: a.toUpperCase is not a function\n at /box/script.js:2:3\n at ...",
"status": { "id": 11, "description": "Runtime Error (NZEC)" },
"time": "0.024",
"memory": 8048
}
--- 測試案例: 語法錯誤 (Syntax Error) 的程式碼 ---
✅ 成功從代理 API 收到回應:
{
"stdout": null,
"stderr": "/box/script.js:1\nconsole.log('Missing quote);\n ^^^^^^^^^^^^^^^\n\nSyntaxError: Invalid or unexpected token\n at ...",
"status": { "id": 11, "description": "Runtime Error (NZEC)" },
"time": "0.022",
"memory": 7764
}
--- 測試案例: 包含 Unicode (中文) 字串的程式碼 ---
✅ 成功從代理 API 收到回應:
{
"stdout": "你好,世界!\n",
"stderr": null,
"status": { "id": 3, "description": "Accepted" },
"time": "0.023",
"memory": 7872
}
太棒了!這次的輸出結果完美地驗證了我們代理 API 的健壯性。所有測試案例都成功回傳了預期的結果,這代表我們的程式碼執行引擎已經準備就緒。讓我們來逐一解析每個案例背後的意義:
總結來說,我們現在有了一個極其可靠的機制,無論使用者提交的 JavaScript 程式碼是正常、有語法錯誤、還是有執行階段錯誤,我們都能取得一份詳細、客觀的執行報告。這份報告,就是我們明天要餵給 AI 的高品質「事實材料」。
今天我們完成了「理科」能力的最後一塊拼圖,讓我們的 AI 面試官專案具備了判斷程式碼客觀對錯的能力。
✅ 我們理解了 Judge0 的非同步 API 運作模式(提交 -> 輪詢 -> 取回)。
✅ 我們成功建立了 /api/judge0/execute 代理,用於安全地提交程式碼。
✅ 我們在後端實作了輪詢機制,並加上了超時保護,讓流程更穩健。
✅ 我們透過測試腳本,驗證了可以精準地取得 stdout, stderr 等客觀執行數據。
客觀事實已經到手!明天(Day 14),我們將迎來第二週的最高潮:把 RAG 的「文科知識」和 Judge0 的「理科事實」結合起來,一起餵給 Gemini。我們將強制 AI 輸出結構化的 JSON,讓它的 Code Review 既有深度,又有根據!這是讓我們的 AI 面試官真正變得「智能」的關鍵一步。
我們明天見!