iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0

前言

歡迎來到第十三天!昨天的內容相當輕鬆對吧! 我也覺得,但串接第三方就是很容易有些意外嘛,多留點空總不會錯!另一方面就是家中出了點事情,我需要一點時間去處理,這幾天的內容我會盡量放輕鬆一點,但不至於影響到我系列文的規劃,不用擔心!

昨天我們主要是建立了 Judge0的帳號並透過他提供的getLanguges API 測試了是否成功串接,從那一長串支援的語言清單來看是挺成功的!但我們要的可遠遠不止有這樣對吧!重點是要讓他執行程式碼嘛!今天,我們就要來做這件最關鍵的事:將使用者在前端編輯器中寫的 JavaScript 程式碼,安全地傳送給 Judge0,讓它在隔離的沙箱環境中執行,然後把詳細的「實驗報告」——也就是程式碼的客觀執行結果——拿回來。

這份報告包含了程式碼究竟是成功運行、還是噴出錯誤、輸出了什麼內容、花了多少時間等鐵證如山的客觀事實。這些事實,將是我們下一階段餵給 AI,讓它做出高品質 Code Review 的最重要依據。

今日目標

  • 理解 Judge0 的核心 API 流程:非同步的「提交 (Submission)」與「取回 (Retrieval)」。
  • 建立一個處理程式碼執行的核心後端代理 API:/api/judge0/execute/route.ts
  • 在後端實作輪詢 (Polling) 機制,以非同步方式等待遠端程式碼執行完畢。
  • 撰寫測試腳本,驗證我們能成功執行程式碼並取得 stdout, stderr, time, memory 等客觀數據。

Step 1: 理解 Judge0 的非同步執行流程

在開始寫程式碼之前,我們必須先理解 Judge0 處理程式碼執行的模式。它並不像一個簡單的函式呼叫,你傳入參數後就立刻得到回傳值。由於執行程式碼可能需要時間(從幾毫秒到幾秒鐘不等),Judge0 採用的是一個非同步 (Asynchronous) 的兩階段流程:

  1. 第一階段:提交任務,取得「取餐號碼牌」

    • 我們對 Judge0 的 POST /submissions 端點發出請求,請求中包含我們要執行的程式碼、語言 ID 等資訊。
    • Judge0 收到後,不會立刻執行並等待結果。相反地,它會馬上回覆:「好的,我收到你的任務了,這是你的任務編號 (Token),你待會再來用這個編號查詢進度。」
  2. 第二階段:憑號碼牌,輪詢取餐

    • 我們拿到 token 後,需要對 GET /submissions/{token} 這個端點發出請求。
    • 第一次查詢時,Judge0 可能會回覆:「處理中 (Processing)」。
    • 我們需要等待一小段時間(例如 200 毫秒),然後再用同一個 token 查詢一次。這個重複查詢的過程,就叫做輪詢 (Polling)
    • 直到某一次查詢,Judge0 回覆:「完成了!這是你的執行結果。」我們才能拿到包含 stdout, stderr 等資訊的完整報告。

這個流程就像你去手搖飲店點餐,店員會給你一個號碼牌(token),然後你會盯著叫號螢幕(輪詢),直到你的號碼出現(執行完成),你才能去取餐(取得結果)。

Step 2: 建立核心代理 API

理解了流程後,我們來實作這個核心代理。根據昨天的經驗,我們知道 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 });
  }
}

整個流程可以拆解成三個主要階段:提交、輪詢和解碼。

環境設定與前置作業

在函式主體開始前,我們定義了幾個關鍵常數:

  • JUDGE0_API_HOST 和 JUDGE0_API_KEY: 從環境變數讀取我們的 API 憑證,確保金鑰不會洩漏在程式碼中。
  • JAVASCRIPT_LANGUAGE_ID: 我們明確指定 93,這代表 Judge0 中較新的 Node.js v18.15.0 環境。這確保了程式碼執行環境貼近現代前端開發標準。
  • sleep(): 一個簡單的輔助函式,讓我們可以在兩次請求之間建立延遲,是實現「輪詢」機制的關鍵。

第一步:安全地提交任務 (Base64 編碼)

當 POST 請求進來後,在驗證過必要的參數後,我們立刻進入提交流程:

  1. Base64 編碼 (Buffer.from(...).toString('base64')):

這是整個流程中最關鍵的部分。我們將前端傳來的 source_code 字串轉換成 Base64 格式。這就像是把一份內容特殊的文件(可能包含中文、emoji 等)放進一個標準化的堅固信封裡,確保它在網路傳輸過程中不會因為編碼問題而出錯。

  1. 發送 POST 請求至 /submissions:
  • 我們在 API 的 URL 中明確地加上 base64_encoded=true,告訴 Judge0 我們信封裡裝的是 Base64 格式的內容。
  • 另一個重要參數是 wait=false,這是在告訴 Judge0:「請不要等程式碼跑完,收到任務後立刻給我一個任務編號(token),我待會自己會來查詢進度。」這啟動了整個非同步流程。
  1. 取得 token:
    如果提交成功,Judge0 會回傳一個獨一無二的 token。這個 token 就像是手搖飲店的取餐號碼牌,是我們後續查詢結果的唯一憑證。

第二步:耐心輪詢,等待結果 (Polling)

拿到 token 後,我們不能傻等,而是要主動去「叫號」。這就是輪詢機制:

  1. for 迴圈: 我們設定了一個最多重試 10 次、每次間隔 500 毫秒的迴圈,總共最多等待約 5 秒。
  2. sleep(retryDelay): 在每次查詢前,我們先暫停 500 毫秒,避免過於頻繁地請求轟炸 Judge0 伺服器,這是一種禮貌且穩定的作法。
  3. 發送 GET 請求至 /submissions/{token}: 我們用 token 去查詢特定任務的進度。同樣地,我們也需要加上 base64_encoded=true 來告訴 Judge0 我們期望收到的回傳內容(如 stdout)也是 Base64 格式。
  4. 檢查狀態 (resultData.status_id > 2): 這是輪詢的核心邏輯。根據 Judge0 的文件,狀態 1 和 2 分別代表「排隊中」和「處理中」。任何大於 2 的狀態都意味著執行已結束(無論成功、失敗或超時)。一旦任務完成,我們就用 break 跳出迴圈。
  5. 超時處理: 如果 for 迴圈跑完後,狀態依然是 1 或 2,代表程式碼在我們設定的 5 秒內沒有執行完畢,此時我們會回傳一個 408 Request Timeout 錯誤給前端。

第三步:解碼並回傳乾淨的結果

一旦拿到執行完畢的結果,我們還需要做最後一步處理才能回傳給前端:

  1. Base64 解碼 (Buffer.from(..., 'base64').toString('utf-8')):
    我們將 Judge0 回傳的 Base64 格式的 stdout(標準輸出)和 stderr(標準錯誤)解碼,還原成人類可讀的正常 UTF-8 字串。
  2. 安全處理 null 值: 我們用三元運算子 (? ... : null) 確保,如果 stdout 或 stderr 本身就是 null,我們不會對它進行解碼而導致程式出錯。
  3. 回傳 NextResponse: 最後,我們將這個處理乾淨、完全解碼的 decodedResult 物件作為 JSON 回應,傳回給前端。

Step 3: 撰寫測試腳本驗證 API

為了在不修改前端的情況下,我們先快速測試這個新的代理 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();

Step 4: 執行並解讀結果

現在,確保你的 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 的健壯性。所有測試案例都成功回傳了預期的結果,這代表我們的程式碼執行引擎已經準備就緒。讓我們來逐一解析每個案例背後的意義:

  1. 正常執行的程式碼
    觀察: stdout 欄位成功捕獲了 console.log 的內容 "Hello from the secure sandbox!\n",而 stderr 為 null,狀態是 "Accepted"。
    意義: 這證明了我們的「快樂路徑」(Happy Path) 運作正常。從接收程式碼、Base64 編碼、提交、輪詢、解碼到回傳結果的整個流程是通暢無誤的。這是我們判斷程式碼是否「至少能跑」的基準線。
  2. 會產生執行階段錯誤 (Runtime Error) 的程式碼
    觀察: stdout 是 null,而 stderr 精準地捕獲了 TypeError: a.toUpperCase is not a function 的錯誤訊息與其詳細的堆疊追蹤 (stack trace)。
    意義: 這是最重要的測試之一!它證明了我們的代理不僅能執行成功的程式碼,更能捕獲執行期間發生的錯誤。這份 stderr 報告就是一份客觀的「程式碼錯誤診斷書」,將成為 AI 進行 Code Review 時最有力的證據。同時,這也驗證了我們的 Base64 策略成功解決了常見因中文註解導致的 400 錯誤。
  3. 語法錯誤 (Syntax Error) 的程式碼
    觀察: 同樣地,stdout 是 null,stderr 則清楚地指出了 SyntaxError: Invalid or unexpected token,甚至標示出了出錯的位置 ('Missing quote);)。
    意義: 這證明了我們的系統能夠處理另一種常見的錯誤類型:語法錯誤。這類錯誤在程式碼執行之前就會被 Node.js 引擎發現。能夠區分「語法錯誤」和「執行階段錯誤」對於提供精準的回饋至關重要。
  4. 包含 Unicode (中文) 字串的程式碼
    觀察: stdout 正確地顯示了解碼後的中文內容 "你好,世界!\n",整個過程沒有產生任何錯誤。
    意義: 這是對我們 Base64 策略的最終考驗。它完美證明了我們的系統不僅能處理包含中文的註解,也能正確執行並回傳包含中文字串的輸出。這確保了我們的應用程式在處理各種語言和字元時都具有高穩定性。

總結來說,我們現在有了一個極其可靠的機制,無論使用者提交的 JavaScript 程式碼是正常、有語法錯誤、還是有執行階段錯誤,我們都能取得一份詳細、客觀的執行報告。這份報告,就是我們明天要餵給 AI 的高品質「事實材料」。

今日回顧

今天我們完成了「理科」能力的最後一塊拼圖,讓我們的 AI 面試官專案具備了判斷程式碼客觀對錯的能力。
✅ 我們理解了 Judge0 的非同步 API 運作模式(提交 -> 輪詢 -> 取回)。
✅ 我們成功建立了 /api/judge0/execute 代理,用於安全地提交程式碼。
✅ 我們在後端實作了輪詢機制,並加上了超時保護,讓流程更穩健。
✅ 我們透過測試腳本,驗證了可以精準地取得 stdout, stderr 等客觀執行數據。

明日預告

客觀事實已經到手!明天(Day 14),我們將迎來第二週的最高潮:把 RAG 的「文科知識」和 Judge0 的「理科事實」結合起來,一起餵給 Gemini。我們將強制 AI 輸出結構化的 JSON,讓它的 Code Review 既有深度,又有根據!這是讓我們的 AI 面試官真正變得「智能」的關鍵一步。

我們明天見!


上一篇
為 AI 裝上裁判之眼:初探 Judge0 與安全後端代理
下一篇
AI 的文理雙全:整合 RAG 與 Judge0,強制輸出結構化 JSON
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言