从0到1搭建在线编程评测系统,这7个技术坑我替你踩了
背景
今年各地青少年编程竞赛火热,花都区的编程竞赛有400多名少年参加,鹤岗市的AI科创营收到224件学生作品。我们服务的几家少儿编程培训机构也提出了一个需求:能不能做一个在线编程评测系统,让学生在浏览器里写代码、提交、自动判分?
听起来不复杂——不就是个在线IDE+自动评测嘛。但真做起来,坑比你想象的多。
这篇文章记录了从0到1搭建这套系统的过程中踩过的7个技术坑,每个坑都附带了最终的解决方案。
技术栈
- 前端:Vue3 + Monaco Editor(VS Code同款编辑器)
- 后端:Node.js + Express
- 代码执行:Docker容器隔离
- 数据库:PostgreSQL + Redis(缓存)
- 消息队列:RabbitMQ(处理评测任务)
坑1:Monaco Editor在Vue3中的性能问题
问题描述
Monaco Editor功能强大,但直接在Vue3中使用会有性能问题。当编辑器内容超过500行时,输入会出现明显卡顿;切换页面再回来时,编辑器偶尔会白屏。
原因分析
Monaco Editor内部使用了大量DOM操作和Web Worker,与Vue3的响应式系统冲突。如果用`ref`或`reactive`包裹编辑器实例,Vue的Proxy会拦截Monaco内部的对象访问,导致性能下降。
### 解决方案
```javascript
// 错误做法:用ref包裹editor实例
const editor = ref(null)
editor.value = monaco.editor.create(...)
// 正确做法:用shallowRef或普通变量
import { shallowRef } from 'vue'
const editorRef = shallowRef(null)
onMounted(() => {
editorRef.value = monaco.editor.create(container.value, {
language: 'python',
theme: 'vs-dark',
automaticLayout: true, // 自动调整布局,解决白屏问题
})
})
onBeforeUnmount(() => {
// 必须手动销毁,否则会内存泄漏
editorRef.value?.dispose()
})
```
关键点:
- 用`shallowRef`而不是`ref`,避免Vue的深度响应式代理
- `automaticLayout: true`解决容器尺寸变化时的白屏问题
- 组件卸载时必须`dispose()`,否则Web Worker不会释放
坑2:代码沙箱的安全隔离
问题描述
学生提交的代码需要在服务器上执行。如果直接在宿主机上跑,一段`os.system('rm -rf /')`就能把服务器搞崩。
解决方案
用Docker容器做代码隔离,每个评测任务启动一个独立容器,执行完毕后销毁。
```javascript
// 评测执行器
const Docker = require('dockerode')
const docker = new Docker()
async function runCodeInSandbox(code, language, testCases) {
// 创建容器
const container = await docker.createContainer({
Image: `eval-${language}:latest`, // 预构建的评测镜像
Cmd: ['python3', '/tmp/solution.py'],
HostConfig: {
Memory: 256 * 1024 * 1024, // 内存限制256MB
NanoCpus: 1000000000, // CPU限制1核
NetworkMode: 'none', // 禁用网络
AutoRemove: true, // 执行完自动删除
ReadonlyRootfs: false, // 需要写临时文件
Tmpfs: { '/tmp': 'size=64m' } // 临时目录限制64MB
},
timeout: 10000 // 10秒超时
})
// 写入代码
const exec = await container.exec({
Cmd: ['sh', '-c', `echo '${base64Encode(code)}' | base64 -d > /tmp/solution.py`],
AttachStdout: true,
AttachStderr: true
})
await container.start()
// ... 执行并收集输出
}
```
关键安全措施:
- 网络隔离:`NetworkMode: 'none'`,容器内无法访问外网
- 资源限制:CPU 1核、内存256MB、磁盘64MB
- 超时机制:10秒强制杀进程,防止死循环
- 自动销毁:`AutoRemove: true`,用完即删
坑3:并发评测时的资源耗尽
问题描述
高峰期同时有50+学生提交代码,每个评测任务启动一个Docker容器。服务器的CPU和内存很快就扛不住了,响应时间从2秒飙升到30秒。
解决方案
引入RabbitMQ消息队列 + 评测Worker池:
```javascript
// 评测任务生产者
async function submitEvaluation(submission) {
const task = {
id: uuid.v4(),
code: submission.code,
language: submission.language,
testCases: submission.testCases,
callbackUrl: `/api/eval/result/${submission.id}`
}
await rabbitMQChannel.sendToQueue('eval_tasks', Buffer.from(JSON.stringify(task)))
return { taskId: task.id, status: 'queued' }
}
// 评测Worker(限制并发数)
const MAX_CONCURRENT = 5 // 最多同时跑5个容器
let runningCount = 0
rabbitMQChannel.consume('eval_tasks', async (msg) => {
if (runningCount >= MAX_CONCURRENT) {
// 超过并发限制,重新入队
rabbitMQChannel.nack(msg)
return
}
runningCount++
try {
const task = JSON.parse(msg.content.toString())
const result = await runCodeInSandbox(task.code, task.language, task.testCases)
await reportResult(task.callbackUrl, result)
rabbitMQChannel.ack(msg)
} catch (err) {
console.error('Evaluation failed:', err)
rabbitMQChannel.ack(msg) // 失败也ack,避免无限重试
} finally {
runningCount--
}
})
```
效果:并发从50降到5,响应时间稳定在10秒以内。多出来的任务在队列里排队,用户端显示"评测中..."的状态。
## 坑4:Python代码的输出比对问题
问题描述
Python的`print()`函数默认会在末尾加换行符,但不同操作系统、不同编码方式下,换行符可能是`\n`也可能是`\r\n`。学生代码的输出和标准答案比对时,因为不可见字符的差异导致判分错误。
解决方案
```python
def normalize_output(output):
"""标准化输出,消除不可见字符差异"""
if output is None:
return ''
# 统一换行符
output = output.replace('\r\n', '\n').replace('\r', '\n')
# 去除末尾多余空白
lines = output.strip().split('\n')
# 每行去除尾部空格
lines = [line.rstrip() for line in lines]
return '\n'.join(lines)
def compare_output(actual, expected):
return normalize_output(actual) == normalize_output(expected)
```
另外还有一个坑:Python的浮点数精度问题。`0.1 + 0.2`在Python里输出的是`0.30000000000000004`而不是`0.3`。对于涉及浮点数运算的题目,需要做容差比对:
```python
def compare_float_output(actual, expected, tolerance=1e-6):
try:
actual_lines = actual.strip().split('\n')
expected_lines = expected.strip().split('\n')
if len(actual_lines) != len(expected_lines):
return False
for a, e in zip(actual_lines, expected_lines):
try:
if abs(float(a) - float(e)) > tolerance:
return False
except ValueError:
if a.strip() != e.strip():
return False
return True
except:
return False
```
坑5:Scratch图形化编程的评测
问题描述
文字编程语言的评测相对简单——比对输出就行。但Scratch是图形化编程,学生提交的是`.sb3`文件(本质是ZIP包),无法用传统的输入输出方式评测。
解决方案
解析`.sb3`文件中的`project.json`,检查学生的程序结构是否满足题目要求:
```javascript
const AdmZip = require('adm-zip')
function evaluateScratch(sb3Buffer, requirements) {
const zip = new AdmZip(sb3Buffer)
const projectJson = JSON.parse(zip.getEntry('project.json').getData().toString('utf8'))
const results = []
for (const req of requirements) {
switch (req.type) {
case 'has_event_block':
// 检查是否包含特定事件积木(如"当绿旗被点击")
const hasEvent = projectJson.targets.some(target =>
target.blocks && Object.values(target.blocks).some(
block => block.opcode === req.opcode
)
)
results.push({ requirement: req.name, passed: hasEvent })
break
case 'has_loop':
// 检查是否包含循环结构
const hasLoop = projectJson.targets.some(target =>
target.blocks && Object.values(target.blocks).some(
block => ['control_forever', 'control_repeat', 'control_repeat_until']
.includes(block.opcode)
)
)
results.push({ requirement: req.name, passed: hasLoop })
break
case 'variable_count':
// 检查变量数量是否满足要求
const varCount = projectJson.targets.reduce((sum, target) =>
sum + (target.variables ? Object.keys(target.variables).length : 0), 0)
results.push({
requirement: req.name,
passed: varCount >= req.minCount
})
break
}
}
return {
total: results.length,
passed: results.filter(r => r.passed).length,
details: results
}
}
```
这种方式不能评测运行结果,但能评测程序结构——对于初学者的Scratch课程来说够用了。
坑6:评测结果的实时推送
问题描述
学生提交代码后,需要实时显示评测结果。一开始用HTTP轮询,每2秒请求一次接口,高峰期服务器扛不住。
解决方案
改用WebSocket + Redis Pub/Sub:
```javascript
// 服务端:评测完成后发布结果
async function reportResult(callbackUrl, result) {
const submissionId = callbackUrl.split('/').pop()
await redis.publish(`eval:result:${submissionId}`, JSON.stringify(result))
}
// WebSocket服务:订阅结果并推送给前端
wss.on('connection', (ws, req) => {
const submissionId = req.url.split('submissionId=')[1]
const subscriber = redis.duplicate()
subscriber.subscribe(`eval:result:${submissionId}`)
subscriber.on('message', (channel, message) => {
ws.send(message)
ws.close()
subscriber.unsubscribe()
})
// 30秒超时
setTimeout(() => {
ws.send(JSON.stringify({ status: 'timeout' }))
ws.close()
}, 30000)
})
```
坑7:防作弊机制
问题描述
有学生发现可以在代码里读取测试用例文件,直接输出答案。还有学生在代码里嵌入`while True: pass`来搞崩服务器。
解决方案
```dockerfile
# 评测镜像Dockerfile
FROM python:3.11-slim
# 创建非root用户
RUN useradd -m -s /bin/bash evaluser
USER evaluser
# 只允许执行solution.py,无法访问其他文件
COPY --chown=evaluser:evaluser solution.py /tmp/solution.py
WORKDIR /tmp
# 禁用危险模块
RUN echo 'import os; os.system = lambda *a: None' >> /etc/python3.11/sitecustomize.py
```
防死循环:在容器层面设置10秒超时,超时自动杀进程并返回"Time Limit Exceeded"。
总结
| 坑 | 核心原因 | 解决方案 |
|---|---|---|
| Monaco卡顿 | Vue响应式与编辑器冲突 | shallowRef + 手动dispose |
| 代码安全 | 学生代码可能恶意执行 | Docker隔离 + 资源限制 |
| 并发耗尽 | 每个任务一个容器 | 消息队列 + Worker池 |
| 输出比对 | 不可见字符和浮点精度 | 标准化处理 + 容差比对 |
| Scratch评测 | 无法用输入输出评测 | 解析项目结构 |
| 实时推送 | HTTP轮询效率低 | WebSocket + Redis Pub/Sub |
| 防作弊 | 学生可能读取测试数据 | 非root用户 + 模块禁用 |
在线编程评测系统的核心不是"能跑代码",而是"安全、稳定、公平地跑代码"。这7个坑里,安全相关的坑(2、5、7)花的时间最多,但也最重要。
希望对正在做类似项目的同学有帮助。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)