从零打造一个支持小众语言的在线评测系统
前言
刷题平台大家见得多了,LeetCode、洛谷、Codeforces……但这些平台几乎全是围绕 C++/Java/Python 这类主流编程语言设计的。如果你是想练习 Shell 脚本、SQL 查询,甚至是 Bash 命令行操作,基本找不到一个像样的在线评测环境。
这个项目就是从这个缺口开始的。三个月业余时间,我用 PHP + MySQL 搭了一个支持Bash、SQL等小众语言的在线评测系统(OJ),并在过程中顺手实现了一套适合学校教学场景的班级管理模式。这篇文章把设计和实现过程做一个复盘。
曜渊OJ
项目定位:为什么做它
市面上 OJ 系统的主要痛点:
- 语言支持单一:99% 的平台只支持编程语言,不支持 Shell/SQL 这类"脚本型"语言
- 学校场景薄弱:现有平台以个人刷题为主,缺少班级、作业、教师管理等学校刚需功能
- 判题耦合严重:很多开源 OJ 的判题逻辑和前端耦合在一起,水平扩展困难
基于这三个痛点,我给自己定了三个核心目标:
- 支持 Bash、SQL 等小众语言的精确评测
- 内置学校模式——班级、教师、作业、排名一站式解决
- 异步判题队列——解耦提交和判题,支持高并发
技术架构
整体采用经典的 LAMP 栈,但在几个关键点上做了针对性设计:
1. 数据库层
核心表结构设计:
| 表 | 职责 |
|---|---|
problems | 题目基础信息(支持类型、难度、标签) |
submissions | 提交记录(含代码、状态、分数) |
judge_queue | 异步判题队列(待评测任务缓冲) |
judge_details | 逐测试点评测详情 |
organizations | 学校/组织信息 |
org_classes | 班级表(学校模式的扩展) |
org_teachers | 教师表 |
org_assignments | 作业表(支持按班级发布) |
2. 判题引擎
判题是整个系统的核心。针对 Bash 和 SQL 两种语言,设计了完全不同的评测策略:
Bash 评测:
- 使用轻量级沙箱执行用户脚本
- 模拟文件系统(VFS)提供测试环境
- 比对标准输出(逐字符 diff)
SQL 评测:
- 在隔离的 MySQL 测试库中执行用户 SQL
- 对比查询结果的行数、列名、数据内容
- 支持 UPDATE/DELETE 等 DML 语句的副作用验证
判题结果统一抽象为:Pending → Judging → Accepted / Wrong Answer / TLE / MLE / RE / CE
3. 异步队列
最早期版本是同步判题——用户提交后页面卡死等待。后来改成了异步队列:
用户提交 → 写入 judge_queue(pending) → 返回 submission_id ↓ judge_worker 消费队列 ← 异步判题 ↓ 更新 submissions 状态 ← 用户轮询或 SSE 推送judge_worker.php支持两种启动方式:
- CLI 模式:
php judge_worker.php --run持续消费 - HTTP 触发模式:提交后自动触发,无需常驻进程
核心功能亮点
1. Monaco Editor 代码编辑器
放弃了简单的 textarea,集成了 VS Code 同源的 Monaco Editor:
- 语法高亮(Bash、SQL、Python 等)
- 代码自动补全
- 主题切换(亮色/暗色)
- 快捷键支持(Ctrl+Enter 提交)
一个小细节:为了适配 CSP 策略,CDN 资源需要加到白名单中,否则会被浏览器拦截加载。
2. 学校模式——班级系统
这是为教学场景专门设计的模块,层级结构是:
学校(organization) └── 班级(org_classes) ├── 教师(org_teachers) ├── 学生(org_class_members) └── 作业(org_assignments,绑定 class_id)教师可以:
- 创建班级并指定班主任
- 按班级发布作业(而非全校统一)
- 查看班级内学生的作业完成率和排名
- 一键导出作业统计报表
学生视角:
- 查看自己的作业列表和截止时间
- 提交后自动判题,实时查看排名
- 班级内解题数排行榜
3. 题解系统
每道题目支持用户发布题解,支持 Markdown 格式。题解有独立评分和排序算法,优质题解会优先展示。这个设计参考了 LeetCode 的题解区,但更轻量。
4. 题目导入导出
支持批量导入题目(JSON 格式),包含:
- 题目描述、输入输出格式
- 测试用例(标准输入/输出)
- 标签、难度、类型
也支持导出为 JSON 备份,方便题库迁移。
踩过的坑
1. Bash 脚本的换行符陷阱
Bash 对换行符极其敏感。用户写echo "hello"和echo -n "hello"的输出完全不同。早期测试用例里因为没有处理末尾换行,导致大量 WA(答案错误)。后来强制规定:除非题目特殊说明,所有输出必须严格匹配末尾换行。
2. SQL 评测的状态污染
如果多个评测任务共享同一个测试库,前面的 UPDATE/DELETE 会影响后面的测试。解决方案是每个测试用例执行前重置数据库状态——用事务回滚或重建表。
3. 前端 CSP 策略与 Monaco 的冲突
Monaco Editor 需要从 CDN 加载大量 JS 资源,但网站的 CSP(Content Security Policy)默认只允许同域资源。报错信息很隐晦:loader.min.js: Loading ... violates Content Security Policy。最后解法是把cdn.jsdelivr.net加到script-src白名单里。
4. 移动端菜单的 className 不一致
这是个小 bug 但很有意思。CSS 里菜单展开用的是.nav-menu.open,但 JS 里 toggle 的是.nav-menu.active。小屏幕下点击汉堡菜单没有任何反应,排查了半天才发现是类名没对上。这种前后端不一致的问题在快速迭代时很容易出现。
实现细节:异步判题的两种模式
判题队列的实现其实经历了两次迭代。
第一版:同步阻塞
用户点击提交 → 后端直接调用判题引擎 → 等待结果 → 返回页面。简单直接,但用户体验极差——提交后页面卡死,而且并发量一上来直接崩。
第二版:异步队列 + HTTP 触发
- 用户提交后,代码写入
submissions表,同时在judge_queue插入一条待处理记录 submit.php通过curl发起一个 100ms 超时的异步 HTTP 请求到judge_worker.phpjudge_worker.php从队列中取出任务执行判题,更新数据库状态- 前端通过轮询获取最新状态
关键是那个 100ms 超时的设计——submit.php 不需要等待判题完成,触发就走。如果 worker 正在运行,新请求会被忽略(通过文件锁控制),避免重复启动。
functiontriggerJudgeWorker(){$ch=curl_init();curl_setopt_array($ch,[CURLOPT_URL=>url('api/judge_worker.php'),CURLOPT_POST=>true,CURLOPT_TIMEOUT_MS=>100,// 100ms 后超时,不等待响应CURLOPT_RETURNTRANSFER=>true,]);@curl_exec($ch);curl_close($ch);}学校模式:从组织到班级的扩展
最初的organizations表只是一个简单的成员集合。为了支持教学场景,我新增了三个表:
org_classes:班级信息(名称、班主任、状态)org_class_members:班级成员(学生/班长角色)org_teachers:学校教师(带科目信息)
作业表org_assignments增加了一个class_id字段——如果为空就是全校作业,如果有值就是班级作业。这样兼容了原有数据,不需要迁移。
权限模型设计为三层:
- 学校 owner/admin:拥有所有班级管理权限
- 班级教师:管理自己班级(添加学生、发布作业、查看统计)
- 学生:提交作业、查看排名、退出学校
退出学校的逻辑也做了边界处理:owner 不能直接退出,必须先转让所有权或解散学校,避免学校变成"孤儿"。
写在最后
这个项目没有用什么新技术栈,PHP + MySQL + 原生 JS,但解决了很多实际痛点。最大的收获不是技术深度,而是把多个零散功能整合成一个完整产品的能力——从判题引擎到班级管理,从 Monaco 编辑器到题解社区,每个模块单独看都不复杂,但拼在一起要能流畅运转,需要对边界条件和异常流程有充分的预判。
如果你也在做类似的教育/评测类系统,欢迎交流。项目代码会逐步整理开源,对 Bash/SQL 评测引擎感兴趣的可以重点关注JudgeEngine.php和BashSimulator.php这两个核心文件。
技术栈:PHP 8.x / MySQL 8.0 / Monaco Editor / Vanilla JS
部署环境:Linux + Nginx + PHP-FPM
代码行数:约 15k(PHP)+ 5k(JS/CSS)