背景

今年各地青少年编程竞赛火热,花都区的编程竞赛有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)花的时间最多,但也最重要。

希望对正在做类似项目的同学有帮助。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐