核心模块逐行解析:执行器与判题引擎
就拆解项目最核心的两个模块:Pyodide 执行器和自动判题引擎
- 理解 Web Worker 通信机制
- 掌握 Pyodide 执行器的完整流程
- 理解判题引擎的三种判题模式
- 学会超时保护和错误处理
Pyodide 执行器:lib/pyodide/executor.ts
这是项目里最核心的文件。它在 Web Worker 里加载 Pyodide、执行 Python 代码、收集输出。就按段落拆着说。
单例模式与请求管理
// ===== 单例 Worker 管理 =====
let worker: Worker | null = null;
let pyodideReady = false;
let requestId = 0;
// 待处理请求的 Map:请求 ID -> { resolve, reject, timer }
const pendingRequests = new Map<
number,
{
resolve: (result: ExecutionResult) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
>();
/*
* 设计要点:
* 1. worker 是单例:整个应用共享一个 Worker,避免重复加载
* 2. requestId 自增:每次执行分配唯一 ID,用于匹配返回结果
* 3. pendingRequests Map:同时发起多个请求时,通过 ID 区分回调
* 4. 每个请求有 timer:超时保护,防止死循环永久挂起
*/Map 的 key 可以是数字,增删性能更好。频繁增删的场景,用 Map 比普通对象合适。
创建 Worker 与消息处理
// ===== 创建 Worker =====
function createWorker(): Worker {
// type: "module" 是因为 Pyodide v314 使用 ES 模块
const w = new Worker("/pyodide/worker.js", { type: "module" });
w.onmessage = (e: MessageEvent) => {
const msg = e.data;
if (msg.type === "result" && msg.id !== undefined) {
// 找到对应的请求回调
const pending = pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timer);
pendingRequests.delete(msg.id);
pending.resolve({
output: msg.output || "",
error: msg.error || null,
executionTime: msg.executionTime || 0,
});
}
}
};
w.onerror = () => {
// Worker 崩溃时,拒绝所有待处理请求
pendingRequests.forEach((pending) => {
clearTimeout(pending.timer);
pending.reject(new Error("Worker 加载失败,请刷新重试。"));
});
pendingRequests.clear();
worker = null;
};
return w;
}
/*
* 通信流程:
* 主线程 Worker
* │ postMessage({id, code}) ───→│ 接收代码
* │ │ Pyodide 执行
* │←── postMessage({id, result}) │ 返回结果
* │ 通过 id 找到 resolve 回调 │
*/执行 Python 代码
// ===== 执行入口 =====
export async function executePython(
code: string,
options: ExecuteOptions = {}
): Promise<ExecutionResult> {
const { timeout = 5000, stdin = [] } = options;
// 懒初始化:第一次执行时才创建 Worker
if (!worker) worker = createWorker();
return new Promise((resolve, reject) => {
const id = requestId++;
// 超时保护:5 秒后自动终止
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error("执行超时(超过 5 秒)。检查是否有死循环。"));
}, timeout);
pendingRequests.set(id, { resolve, reject, timer });
worker!.postMessage({ type: "execute", id, code, stdin });
});
}
/*
* 关键设计:
* 1. Promise 封装:回调风格变成 async/await
* 2. 超时保护:setTimeout 在指定时间后 reject
* 3. 懒加载:第一次运行代码时才加载 Pyodide
*/Web Worker 内部:worker.js
// worker.js 核心逻辑
let pyodide = null;
async function loadPyodideRuntime() {
if (pyodide) return pyodide;
const mod = await import("/pyodide/pyodide.js");
const py = await mod.loadPyodide({ indexURL: "/pyodide/" });
// 重定向 stdout:Python 的 print() 调用这个回调
py.setStdout({ batched: (text) => { currentOutput += text + "\n"; } });
py.setStderr({ batched: (text) => { currentError += text + "\n"; } });
pyodide = py;
return py;
}
self.onmessage = async (e) => {
const { type, id, code } = e.data;
if (type !== "execute") return;
const py = await loadPyodideRuntime();
currentOutput = ""; currentError = "";
const startTime = performance.now();
try {
await py.runPythonAsync(code);
} catch (err) {
currentError = err.message;
}
self.postMessage({
type: "result", id,
output: currentOutput,
error: currentError || null,
executionTime: performance.now() - startTime,
});
};判题引擎:lib/judge/engine.ts
# 三种判题模式
print("""
模式 1:标准输出比对
用户 print() 的输出 == 期望输出
模式 2:函数返回值断言
执行用户定义的函数,断言返回值
模式 3:异常捕获测试
验证代码是否正确抛出/处理异常
""")输出比对的实现
// ===== 输出比对 =====
function compareOutput(actual: string, expected: string): boolean {
// 逐行比对,忽略首尾空白和行尾空格
const a = actual.trim().split("\n").map(l => l.trimEnd());
const e = expected.trim().split("\n").map(l => l.trimEnd());
if (a.length !== e.length) return false;
return a.every((line, i) => line === e[i]);
}
/*
* 为什么要 trim?
* 1. 用户可能多打一个换行
* 2. Windows 和 Unix 换行符不同
* 3. 行尾空格不可见但会导致比较失败
* 但不能过度宽松:只 trim 行尾,不 trim 行首(缩进可能是答案的一部分)
*/判题代码和用户代码在同一个 Pyodide 环境里跑。用户要是改了内置函数,比如写 print = lambda x: None,可能会影响判题。生产环境得在独立命名空间里执行。
executePython 中 requestId 的作用是什么?
print("hello" == "hello\n".strip())为什么执行 Python 要放进 Web Worker,而不是主线程?
资深工程师加餐
底层原理 · 大厂视角 · 工程经验,点卡片展开
客户端把进度存到本地(localStorage / SQLite)后,下一版字段结构一变,老用户存档就可能解析失败、进度全丢。专业做法是给存档加 version 字段,读取时按版本依次执行迁移函数(补默认值、改名、重组结构),并对解析全程 try/catch、坏档能回退到安全默认。写入时还要防御 NaN/undefined(如 JSON.stringify(NaN) 会变成 null),这正是「数据比代码活得久」的含义。
挑战任务
实现请求 ID 配对器
多个请求并发发出、响应可能乱序回来。请实现 pair(pending_ids, incoming):incoming 是乱序的 [id, payload],返回按 pending_ids 顺序排列的 payload 列表。