浏览器里跑学生代码:互动式编程平台的沙箱、判题与成本

发布时间:2026/11/5 3:55:11
浏览器里跑学生代码:互动式编程平台的沙箱、判题与成本

互动式编程学习平台的产品形态几乎是一个模子:左边一段讲解,中间一个编辑器,右边一块输出区,跑通了点亮一个进度。用户感知到的是「边学边练」,工程上要回答的却是三个硬问题——这段陌生代码在哪里跑、跑完怎么判对错、跑一次要花多少钱。

Codédex 是这类产品里做得比较完整的一个样本(互动式课程、即时运行、勋章与连续进度)。它没有公开技术实现,本文不猜它的内部架构,只把这几个决策点摊开讲——它们是同类系统绕不过去的通用问题。

一、代码在哪跑:三种落点的取舍

「让学生代码跑起来」有四个可选落点,安全性和成本差一个量级:

落点隔离强度冷启动能跑的语言单次成本
页面内直接 eval无0JS0
iframe / Web Worker中(同源策略兜底)毫秒JS0
WASM 运行时(如 Pyodide)中(进程内,无系统调用)首屏几 MB 下载能编译到 WASM 的语言吃客户端 CPU
服务端容器高(内核级)几百毫秒起任意随并发线性增长

前三种的共同问题是判题不可信:代码在学生的机器上跑,学生就能改运行时、改断言、改返回值。所以只要涉及「这道题到底算不算过」,最终结论就必须在服务端复核一遍,前端跑的那一次只负责体验——立刻给反馈,而不是立刻给成绩。

iframe 的最小安全写法只有一个要点:sandbox里不要加allow-same-origin。

<!-- 有 allow-scripts 但没有 allow-same-origin:脚本能跑,但读不到父页面 --> <iframe sandbox="allow-scripts" srcdoc="<script>parent.postMessage('ok','*')</script>"></iframe>

两个属性同时给,等于把沙箱拆了——脚本能拿到同源上下文,直接摸parent.document。这是这类平台最常见的配置错误,而且自己测试时很难发现,因为功能「能跑」。

判断规则:课程要覆盖 Python、C、Java 这类必须真解释器的语言,且题目涉及文件读写、多文件工程,只能走服务端容器;只教前端三门语言的,iframe 加 Worker 就够,别为了「统一」上一个容器集群。

二、判题:输出比对只是最差的一档

判题方式从弱到强有三级,难度与准确度同向:

  1. 输出比对:跑一遍,把标准输出和标准答案逐字符比。实现最简单,误判最多。
  2. 断言测试:往学生代码里注入一组断言,全过才算对。前端测试框架就是这套思路。
  3. 结构检查:解析成语法树,检查是否用到了要求的语法结构(比如「必须用列表推导式」)。

第二级最实用。但即使是断言,也有四个坑必须显式处理,否则学生会看到「我明明对了却判错」:

  • 随机性:题目里出现随机数、当前时间、字典遍历顺序,输出天然不稳定。要么禁用,要么把种子固定下来。
  • 浮点:0.1 + 0.2永远不等于0.3。断言必须带容差,不能直接用等号。
  • 时间与内存:死循环是学生最常见的错误,没有超时保护会把整个判题队列拖死。
  • 空白与格式:末尾换行、行尾空格,统一用strip()抹平,不要让学生为了一个换行符反复试。

一个够用的最小判题器长这样——重点不在逻辑,在于每个资源都给上限:

import subprocess, tempfile, os, resource def judge(code: str, case: dict, cpu_sec=2, mem_mb=128): with tempfile.TemporaryDirectory() as d: src = os.path.join(d, "main.py") open(src, "w", encoding="utf-8").write(code) def limit(): # 子进程里生效,父进程不受影响 resource.setrlimit(resource.RLIMIT_CPU, (cpu_sec, cpu_sec)) resource.setrlimit(resource.RLIMIT_AS, (mem_mb << 20, mem_mb << 20)) try: p = subprocess.run( ["python", "-I", src], # -I:隔离模式,不读环境变量与用户 site input=case["stdin"], capture_output=True, text=True, timeout=cpu_sec + 1, preexec_fn=limit, ) except subprocess.TimeoutExpired: return {"ok": False, "reason": "timeout"} if p.returncode != 0: return {"ok": False, "reason": "runtime_error", "stderr": p.stderr[-400:]} got = p.stdout.strip() return {"ok": got == case["expect"].strip(), "reason": "" if got == case["expect"].strip() else "wrong_answer"}

python -I这一行容易被忽略:不加它,学生写一句import os再读环境变量,就能摸到判题机的配置。同理,内存上限要设,否则一个超大列表就能把机器吃干净。

三、课程即数据:别把题目写进模板

这类平台最容易被低估的工程量,是题目本身的可维护性。

课程内容一开始往往写成独立页面,改一道题要动模板、动样式、动判题逻辑三处。做到几十节课以后,加一门新语言就基本推不动了。可行的结构是「课程是数据,渲染与判题是代码」,一节课是一个结构化条目:

{ "id": "py-03-02", "title": "列表推导式", "lang": "python", "starter": "nums = [1, 2, 3]\n# 用一行代码生成平方列表\nsquares = ?\n", "tests": [ {"stdin": "", "expect": "[1, 4, 9]"} ], "hints": ["想想 [x*x for x in ...] 的结构", "先写出循环版,再改写成推导式"], "check_ast": {"node": "ListComp", "min": 1} }

这么拆的收益在三处:

  • 加一门语言等于加一个运行时适配器,不动课程内容;
  • 题目可以批量回归——课程包一改,跑一遍全量断言就知道有没有把老题改坏;
  • 结构检查能表达「必须用推导式」这种语法要求,光靠输出比对做不到。

代价是课程作者得学这套结构。所以配套通常要有一个本地校验命令:写完一节课,先本地跑通断言再提交,别等线上判错。

四、进度与打卡:存事件,不存布尔值

「连续学习 N 天」看起来是个小功能,实际是最容易埋 bug 的地方,坑集中在三点:

时区。用户在东八区、服务器在 UTC,跨零点时「今天」到底算哪天?正确做法是按用户所在时区算自然日,把结果规范化后存下来,而不是拿服务器时间戳直接切。

幂等。同一节课重复提交、页面刷新重发、网络重试,都会让「完成数」多加一次。写入要带幂等键(用户 + 课程 + 提交序号),重复请求直接返回上次结果。

补签与断签。如果只存一个「连续天数」整数,断签那天没人访问就永远补不上。存事件才可回溯:

create table learning_event ( user_id bigint not null, lesson_id text not null, local_date date not null, -- 按用户时区算好的自然日 idem_key text not null, primary key (user_id, idem_key) );

连续天数由查询算出来(按日期去重后求最长连续段),而不是维护一个计数器。计数器一旦算错,就再也对不回来了;事件表算错,改一条查询就能修。

五、权限边界:前端只负责好看

免费层和会员层的分界,常见错误是只在前端判:按钮置灰、内容折叠、接口照样返回。

这类限制在浏览器里没有任何强度——打开开发者工具看一眼网络面板,接口原样能调。正确做法是把「这门课是否在开放范围内」做成服务端的策略判断,前端拿到的数据就是过滤后的。

判断点应该收敛到一个函数,而不是散落在各个接口里:

def can_read(user, lesson) -> bool: if lesson.tier == "free": return True return user.has_active_membership()

散落在各处的问题不只是「不安全」,而是改规则时漏改一处,于是某天出现「某几节课意外开放了但没人发现」。这类洞通常是在做数据报表时才被翻出来。

六、成本:一次运行到底花多少

如果走服务端容器,成本结构大致是:冷启动 + 执行时长 + 常驻内存。教育场景的特点是请求极度不均衡——晚上和周末是白天的好几倍,白天又有大量「打开又关掉」的无效会话。

三个能立刻见效的手段:

  • 分级执行:把确定性题目放前端跑(只做体验反馈),只在「提交答案」时才上服务端跑一次。请求量能掉一个数量级。
  • 预热池:容器冷启动几百毫秒,对学生来说就是「点了没反应」。维护一个小规模预热池,代价是常驻内存,收益是体感。
  • 超时与配额前置:单次执行的 CPU 与内存上限必须在调度层拦住,不能指望容器自己退出。一个死循环如果跑满三十秒,乘上并发就是灾难。

收尾

这类平台的技术难点,很少在「沙箱做得有多隔离」这种单点上,而在三个判断能不能同时成立:前端敢不敢立刻给反馈、服务端能不能复核结论、单次运行的边际成本能不能压到可接受。

真要说护城河,也不在沙箱——沙箱是现成的。难的是内容生产那一段:题目能被结构化、能被批量回归、能被非工程师写出来。课程从几十节长到几百节时,决定产品还能不能继续迭代的,是这套数据格式,而不是跑代码的那台机器。