做C语言上机考试系统这件事,听起来像是个课程设计,但真上手后你会发现,它其实是一个典型的“小而全”的全栈项目:既要处理题库、组卷、评分这些业务逻辑,又要照顾到考试场景下学生、老师、管理员三种角色的差异,还得保证考试过程稳定不翻车。我选的组合是Python Flask做后端,Vue做前端,数据库用的MySQL,整个系统做下来最大的感受是:Flask足够轻,Vue足够灵活,两者配合非常适合这种需要快速迭代、逻辑又不太复杂的教学类系统。
这篇文章把我的完整实现思路、关键代码、踩坑记录都整理出来,从题库导入到在线考试再到自动评分,每一步都尽量讲清楚“为什么这么做”,希望对正在做类似系统或者准备做课程设计的同学有实际帮助。
1. 整体设计与技术选型:为什么是Flask+Vue而不是其他组合
1.1 技术选型背后的考量
先聊技术选型。C语言上机考试系统和普通的理论考试系统最大的区别在于:它不仅要管“题”,还要管“代码的运行环境”。也就是说,学生写完代码之后,系统得能够在服务器端把代码编译运行,并且用预设的测试用例来判定对错。这决定了后端不能太重,也不能太“死板”,必须能方便地调用系统命令、处理进程、拿到编译输出。
当时我面前有几个选择。Java Spring Boot确实强大,自带的东西很多,但对于这种规模的系统来说,Spring Boot的项目结构、依赖管理、部署成本都偏重,课程设计或者实验室内部系统根本不需要那么“工业级”的复杂度。Node.js的Express也很轻,但Node调用外部编译进程、处理多进程并发的时候,写起来没有Python顺手。Python的Flask框架足够轻量,路由和请求处理的抽象层次刚好合适,配合subprocess模块调用gcc编译器,天然契合“编译并运行学生代码”这个核心需求。选Vue做前端则是看中了它的组件化开发方式加上生态成熟,管理后台和考试页面可以完全拆成两套视觉体系来做,代码组织起来不混乱。
一个有意思的细节是,Flask的Jinja2模板其实也能直接渲染页面,但那样会把前后端耦合在一起,代码写得越长越难维护。用Vue做前后端分离之后,前端通过AJAX请求后端接口,后端只负责输出JSON数据。这样做的好处是:题库管理界面、在线考试界面、成绩统计界面三套页面可以并行开发,互不干扰,而且部署时Vue打包出来的静态文件可以直接丢给Nginx托管,Flask只负责API,职责清晰。
1.2 功能模块与角色权限拆解
系统面向三类用户,每类用户的“主战场”不同。管理员负责系统的基础配置、学生账号管理、题库的导入和整理;老师负责组卷、发布考试、查看成绩和分析答题情况;学生则是考试的主角,登录后参加考试、查看自己的成绩和答题详情。
这三类角色的权限边界必须清晰。我在设计数据库时,用户表里直接加了一个role字段,取值是admin、teacher、student三种,接口层面通过装饰器统一做角色校验,避免每个视图函数里都写一遍判断逻辑。考试表、试卷表、答题记录表也都带上了关联字段,从“谁出的卷”到“谁答的题”到“得了多少分”,整条链路都能追溯。
功能上拆成几个大块:题库管理(支持单题录入和批量导入)、试卷管理(手动选题和自动组卷)、在线考试(倒计时、代码编辑、自动保存、禁切屏)、自动评分(编译运行测试用例)、成绩统计(班级维度、知识点维度)。每个大块展开后涉及的细节都不少,后面逐个讲。
1.3 题库设计:不只是存文本那么简单
题库是整个系统的心脏,设计得好不好直接决定评分算法的复杂度和出卷的灵活性。我建的question表包含以下字段:
id:主键title:题目名称,比如“计算5*5矩阵的鞍点”content:题目描述正文,支持多行文本,包含输入输出格式说明difficulty:难度,1到5整数knowledge_point:知识点标签,比如“循环”“数组”“指针”“结构体”sample_input、sample_output:示例输入输出,用于前端展示和基础验证test_cases:JSON格式的测试用例数组,每个用例包含输入和期望输出time_limit:时间限制,默认2000毫秒created_at:创建时间
为什么测试用例要单独用JSON存而不是建一张独立的表?从数据库范式角度讲,确实应该建test_case表做一对多关联,但实际开发中我发现,题目和测试用例的关联基本只会“整体读取”,很少单独修改某一个用例。用JSON字段存储可以简化查询逻辑,一次查出题目所有信息,减少一次关联查询。代价是不能在数据库层面按用例做统计,但对于这个系统规模来说完全不值得引入额外的表。
知识点的标签化很重要。C语言的知识点相对固定:数据类型、运算符、分支、循环、数组、函数、指针、结构体、文件操作。这些标签不仅用于组卷时按比例抽题,还用于考试后的成绩分析——统计班级在“指针”这个知识点上的得分率,老师就能知道哪些内容需要重点讲。成绩分析的前提是组卷时有按知识点配比的能力,后面讲组卷算法时会细说。
2. 核心细节解析与实操要点:从题库导入到自动评分
2.1 题库批量导入:用Python脚本解析C语言练习题
手工逐题录入效率太低,尤其是题库初始化和扩充阶段。我写了一个基于Python的导入脚本,支持从两种格式的文件批量导题。第一种是JSON格式,字段一一对应数据库列,最简单直接;第二种是自定义文本格式,兼容从现有文档或题库网站复制的题目。
具体自定义格式是这样的:以#题目分隔,标题在下一行,然后是描述、输入说明、输出说明、示例输入、示例输出、测试用例、知识点、难度。脚本逐行解析,遇到#题目标记就说明前一道题解析完毕,把数据插入数据库。整个脚本跑一遍能导入数百道题,比手工录入快几个量级。
解析脚本的要点在于容错。题库来源不一,空行、多余空格、注释符号都可能出现。针对这些情况,我做了这几件事:
- 去掉每行首尾空白字符,忽略空行
- 题目描述、示例输入输出等长文本字段统一用三引号包裹,中间允许换行
- 测试用例要求是
input=...和output=...配对格式,缺失任何一个就跳过该题并打印警告
导入脚本运行一次后输出统计信息:成功导入N道,跳过M道,每题跳过原因。这样即使试卷来源格式混乱,也能快速定位问题。
2.2 自动组卷算法:按知识点、难度、题量配比抽题
自动组卷不是简单地随机选N道题,而是要满足老师设定的“知识点配比”和“难度分布”。举个例子,老师可能希望一份试卷里“指针”知识点占30%、“数组”占30%、“循环”占20%、“结构体”占20%,同时简单题占40%、中等题占40%、难题占20%。
我是用“分层随机抽样”的思路来解决的。先把所有题按知识点分组,然后对每个知识点内部按难度再次分组,最后从每个“知识点-难度”格子中随机抽取指定数量的题。比如“指针-简单”这个格子需要2道题,就从题库中把所有“指针且简单”的题目收集起来,随机打乱后取前2道。
这样做的好处是分布可控,而且避免了“全凭运气”的纯随机出卷导致难易不均的问题。关键代码是三层嵌套循环,最外层遍历知识点配比,中层遍历难度档位,内层执行随机抽取,代码非常精简。有一个边界情况要处理:某个格子里的题量不够,比如“结构体-困难”只有1道题但要求抽2道,这时给出友好提示,并且整体跳过该知识点配比,不让老师拿到一份缺题的试卷。
2.3 在线考试分数判定的核心:调用gcc编译并运行为可执行文件
自动评分是上机考试系统的灵魂。学生提交的是C语言源代码,系统要做的是把这份源代码编译成可执行文件,然后用多个测试用例去对比运行输出和期望输出。具体流程是这样的:
前端把学生代码作为字符串POST到后端接口,接口收到代码后先写入一个带时间戳和用户ID的文件,文件名类似submit_20250321_153001_user001.c,然后用subprocess调用系统gcc命令编译:
import subprocess, os, uuid def compile_code(code, work_dir): c_file = os.path.join(work_dir, f"main_{uuid.uuid4().hex}.c") exe_file = os.path.join(work_dir, f"main_{uuid.uuid4().hex}.exe") if os.name == 'nt' else os.path.join(work_dir, f"main_{uuid.uuid4().hex}") with open(c_file, 'w', encoding='utf-8') as f: f.write(code) compile_result = subprocess.run( ['gcc', c_file, '-o', exe_file, '-std=c99', '-Wall'], capture_output=True, text=True, timeout=10 ) if compile_result.returncode != 0: return {'status': 'compile_error', 'output': compile_result.stderr} return exe_file编译成功后,每个测试用例的测试方式是把输入数据写入文件,然后用管道方式重定向给可执行程序运行,捕获程序的输出和退出码:
def run_test(exe_file, test_input, time_limit=2000): try: result = subprocess.run( exe_file, input=test_input, capture_output=True, text=True, timeout=time_limit / 1000 ) return {'returncode': result.returncode, 'stdout': result.stdout} except subprocess.TimeoutExpired: return {'timeout': True}评分标准是三档:完全没有通过编译则记0分并展示编译报错信息;通过编译但测试用例有部分失败则按“通过的测试用例数/总测试用例数”折算比例得分;全部用例通过得满分。如果运行超时则视为该用例未通过。
这里有个我吃过亏的细节:Windows系统和Linux系统下编译产物的命名规则不同,Windows下gcc默认生成a.exe,Linux下生成a.out,虽然可以在命令行加参数指定名称,但跨平台时路径分隔符和扩展名处理稍不注意就是坑。我的做法是写了一个跨平台辅助函数统一拼接可执行文件路径,按os.name做分支处理。
另一个教训是代码中如果包含中文注释,Windows控制台默认编码是GBK,学生代码整体按UTF-8保存,编译时不会有问题,但运行时如果学生printf输出的中文会乱码。这个属于环境问题,我做法是在考试须知里要求学生编码统一使用UTF-8,同时题目中尽量不用中文输出,只判断标准英文输出的测试用例。
2.4 考试防作弊与稳定性:禁切屏、自动保存、断线续考
在线考试比离线考试多了一个“实时性”的要求,所以必须考虑几个场景:切屏被检测到、浏览器意外关闭、网络断断续续。我的方案是:
禁切屏检测通过监听浏览器的visibilitychange事件实现,页面一旦切换到后台就记录一次切屏日志,超过设定次数直接标记为作弊嫌疑。这个检测由后端接口配合记录,前端只是上报,最终判定权交给老师。这道逻辑做起来不难但很有用,C语言上机考试中最常见的作弊方式就是切出去查资料。
自动保存针对的是代码编辑器。我用的编辑器是CodeMirror,设置一个定时器每30秒自动把编辑器内容提交到后端接口保存草稿。就算学生中途浏览器崩溃,重新登录后还能把草稿拉回来继续写。
断线续考和自动保存配合使用:后端记录“最近心跳时间”和“最近保存内容”,学生重新登录后拉取最新草稿继续作答。实际考试中遇到过几次机房网络抖动的情况,这个机制确实救了不少学生,试卷进度没丢。
3. 实操过程与核心环节实现:前端Vue与后端Flask的完整交互
3.1 Vue项目搭建与环境配置
前端我用了Vue 3加Vite构建,相比Vue 2的Webpack版本,Vite的开发服务器冷启动快得多,修改代码热更新也顺畅。安装Vue环境这件事其实很简单,按顺序执行:
npm create vite@latest exam-frontend -- --template vue cd exam-frontend npm install npm install vue-router@4 axios element-plus npm run devElement Plus是Vue 3配套的UI组件库,表格、表单、弹窗、消息提示这些做后台管理界面时直接拿来用,比自己写CSS快非常多。但我必须承认,Element Plus的样式风格偏“后台感”,用来做学生考试界面不是特别合适。我的做法是:管理后台全部用Element Plus,学生考试页单独写了一套样式,白底黑字居中布局,尽可能接近真实考试系统那种安静专注的氛围。
路由结构方面,我按角色划分了路由层级。登录页是公共路由,登录成功后根据角色动态添加路由:
/admin:题库管理、用户管理、知识点统计/teacher:试卷管理、考试管理、成绩分析/student:考试列表、参加考试、成绩详情
Vue Router的addRoute方法可以在运行时动态挂载路由,配合beforeEach钩子做登录状态和角色校验。这里要特别注意刷新页面后路由会重置,所以需要在全局状态中保存当前用户角色信息,刷新后再按角色重新挂载路由。
3.2 Flask后端结构与接口设计
Flask后端我采用了蓝图的组织方式,按功能模块拆分了几个文件,而不是把所有路由堆在app.py里。目录结构如下:
exam_api/ ├── app.py # 应用入口,注册蓝图,初始化扩展 ├── config.py # 配置项,数据库连接、密钥、上传目录 ├── models.py # SQLAlchemy 数据模型 ├── auth.py # 登录注册、JWT鉴权 ├── admin_bp.py # 管理员相关接口 ├── teacher_bp.py # 教师相关接口 ├── student_bp.py # 学生考试相关接口 ├── judge.py # 编译运行核心模块 └── utils.py # 通用工具函数接口设计遵循RESTful风格,核心接口大致如下:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login | 登录,返回JWT令牌和角色信息 |
| GET | /api/questions | 分页获取题库,支持关键词和知识点筛选 |
| POST | /api/questions/batch | 批量导入题目 |
| POST | /api/exams/generate | 自动组卷 |
| POST | /api/exams/<id>/publish | 发布考试 |
| GET | /api/exams/<id>/paper | 获取当前学生考试的题目列表 |
| POST | /api/exams/<id>/submit | 提交某道题的代码并评分 |
| POST | /api/exams/<id>/finalize | 交卷,结束考试 |
| GET | /api/exams/<id>/scores | 获取考试成绩列表 |
这里有个值得说一说的设计决策:考试过程中前端调的是“提交单题”的接口,而不是整卷一次性提交。这样每道题都能立刻获得评分结果反馈到前端,学生可以看到自己哪道题通过了哪些用例。考试结束时再调一次“交卷确认”接口,把答题记录状态从“作答中”改为“已交卷”。
JWT鉴权用flask-jwt-extended扩展实现,登录成功后生成访问令牌,前端把令牌存到localStorage,每次请求时通过axios拦截器自动在请求头加上Authorization: Bearer <token>。这比传统Session方案更适合前后端分离架构,因为Flask后端可以被同一个前端多台服务器轮询调用,不需要考虑Session同步的问题。
3.3 前后端联调与跨域处理
开发阶段前后端分别在两个端口运行,Vite默认端口5173,Flask默认端口5000,浏览器直接跨域请求会被拦。解决方式有两种:一是Flask端用flask-cors扩展开启CORS,二是Vite配置server.proxy代理,把/api前缀的请求转发到Flask服务。我两种都试过,实际开发时用Vite代理更方便,因为看起来请求完全同源,不需要修改Flask的CORS规则。
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } } } })部署时反过来,需要在前端打包后的静态目录上配置反向代理,让/api前缀的请求同样转发到Flask服务。Nginx配置示例如下:
location /api { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这么做的最大好处是浏览器只需要访问一个地址,不暴露Flask的内部端口,同时避免了很多因为跨域导致的前端字体、图片资源加载失败的问题。
3.4 考试流程中的关键页面与交互细节
学生考试页面是系统中交互最复杂的部分。页面分为三栏:左侧是题目列表和知识点标签,中间是CodeMirror代码编辑器,右侧是题目描述、示例输入输出和“运行/提交”按钮。
CodeMirror集成时要注意Vue生命周期问题。编辑器实例要在组件mounted时创建,beforeUnmount时销毁,否则会出现编辑器重复初始化、内容无法更新的问题。我的代码大致是这样:
import CodeMirror from 'codemirror' import 'codemirror/mode/clike/clike' import 'codemirror/addon/edit/matchbrackets' onMounted(() => { editor = CodeMirror.fromTextArea(textareaRef.value, { mode: 'text/x-csrc', lineNumbers: true, matchBrackets: true, indentUnit: 4, tabSize: 4 }) editor.setValue(currentCode.value || defaultTemplate) }) onBeforeUnmount(() => { if (editor) { editor.toTextArea() editor = null } })考试倒计时是另一个需要小心处理的点。倒计时不能只在前端做本地计时,因为学生刷新页面计时器就会重置。我的做法是后端记录考试开始时间和考试总时长,前端每次进入页面时向后端同步剩余时间,然后前端用每秒递减的方式显示,同时每60秒再向后端校准一次,避免因网络延迟产生时间偏差。
自动保存的接口在保存成功后不返回复杂数据,只告诉前端“已保存”,前端配合一个提示动画,让学生有“系统在帮我存着”的安全感。这个体验细节看起来不起眼,但实际考试时学生非常在意自己的代码有没有被保存上。
4. 常见问题与排查技巧实录
4.1 编译环境搭建:Windows、Linux与虚拟机的坑
编译环境是这块最容易出问题的地方。学生反馈“我代码本地跑得好好的,为什么系统里编译不过”,大多数时候是环境差异导致的。最常见的情况有以下几种:
第一,gcc版本和默认C标准不同。有些题目用了for (int i = 0; i < n; i++)这种C99语法,而老的gcc版本默认可能是C90标准,会报“for loop initial declaration”错误。我在编译命令里显式加了-std=c99参数,统一标准,避免版本差异导致的误判。
第二,缺少limits.h等标准头文件时,某些极端测试数据会出问题。比如计算5*5矩阵鞍点时,常规做法是先把每行的最大值和每列的最小值求出来,再逐个位置判断。如果不初始化用于比较的变量,默认初值不确定,可能首行首列的值恰好就是最大值导致漏判。这类问题靠编译参数解决不了,只能靠题目设计时给出明确的边界说明,同时在测试用例中包含边界数据,比如全零矩阵、单元素矩阵、负数矩阵等。
第三,虚拟机里装Ubuntu配置C语言环境时,容易漏装build-essential这个元包,导致系统里只有编辑器没有gcc编译器。我在部署文档里专门强调了这个安装命令:
sudo apt update sudo apt install build-essential gcc --version如果线上一次性有几十个人同时交卷,每个交卷都触发一次gcc编译,服务器的CPU占用会瞬间飙升。实测下来,4核8G的云服务器能承受约30个并发编译任务,再多就会出现编译超时。为了解决这个问题,一是把编译超时时间设置得保守一点,二是给编译进程加了并发信号量限制,超过并发数时让请求排队等待。
4.2 判题逻辑细节:标准化输出、超时控制与资源隔离
判题逻辑中最容易误判的是“输出比对”的精度问题。学生代码输出的结果如果在行尾多了一个空格、结尾多了一个换行,严格按字符串对比就会判为不通过。我在比对时做了规范化:先把期望输出和实际输出的字符串都按行拆分,去掉每行的首尾空白字符,再忽略末尾的空白行,最后逐行比对。
超时控制同样重要。如果学生代码里有无限循环,while(1)是合理的题目要求,但如果是逻辑错误导致的死循环,进程会一直占用CPU。subprocess.run里设置timeout参数可以强制杀掉超时进程,但要注意程序运行中产生的子进程不会被自动杀掉。我见过一次学生代码偷偷fork了子进程,subprocess.run超时后主进程被杀但子进程还在跑,一直占着CPU。解决方法是使用start_new_session=True创建新的进程组,超时后通过os.killpg杀掉整个进程组:
import subprocess, os, signal result = subprocess.run( exe_file, input=test_input, capture_output=True, text=True, timeout=2, start_new_session=True ) # 超时处理 try: result = subprocess.run(..., timeout=2, start_new_session=True) except subprocess.TimeoutExpired as e: os.killpg(os.getpgid(e.pid), signal.SIGKILL)这种写法能确保即使程序自身有子进程,也能一并清理干净,不会在服务器上留僵尸进程。
4.3 Vue前端调试:路由拦截、动态路由刷新失效、编辑器取值
前端遇到的问题也不少,挑几个典型的说一说。
Vue Router的beforeEach守卫里做登录判断时,最忌讳的是只用“本地是否有token”来判断登录状态。token存在但已过期,或者后端重启导致token失效,都会出现前端显示已登录、后端接口全报401的情况。我的做法是:axios拦截器收到401响应时,清空本地token并强制跳转到登录页,同时在登录页面保留一条提示信息告诉用户“登录已过期,请重新登录”。
动态路由在刷新后失效是一个经典问题。学生登录后本来有考试入口,F5刷新一下就变成空白页或404,因为动态路由是运行时挂到路由表里的,刷新后路由表恢复初始状态,动态路由丢失。解决的办法是把当前用户的角色信息持久化到localStorage,在应用初始化时读取角色并重新挂载对应路由,等路由ready后再渲染页面。代码逻辑不复杂,但顺序很重要,必须先挂路由再创建Vue实例。
CodeMirror取值的坑在于:如果你用普通的textarea绑定v-model,编辑器并不会自动同步内容到textarea,直接提交时拿到的是空字符串或初始值。需要显式调用editor.getValue()来取得最新内容。自动保存时也调用这个方法,不能依赖v-model。
4.4 考试过程中的异常处理:断电、断网、误操作
线上考试最怕的不是题目难,而是环境出问题。我实际遇到过机房集体断电的情况,学生写了一半的代码全部消失。虽然我做了自动保存,但自动保存是每30秒一次,断电前最后30秒的内容确实可能丢。后来又补了一个机制:监听浏览器offline事件,从正常上网状态切换到离线状态时立刻触发一次保存请求,这样网络断开前往往还能救回最近的内容。
误操作方面,学生可能不小心点了交卷按钮。我的处理是:交卷按钮点击后弹出二次确认框,内容包含“确认交卷?交卷后无法继续作答”的提示,同时必须在后端记录“已交卷”状态后才能结束考试。万一学生误触交卷,老师端有一个“重置考试状态”的按钮,可以把考试重置为“作答中”,学生重新登录就能继续考试。这个功能老师在考试结束后再决定是否开放,考试中重置需要老师确认操作,防止学生自己乱来。
4.5 部署过程中的数据库与文件权限问题
Flask后端部署时常用Gunicorn作为生产服务器,官方推荐的运行方式里有一个容易被忽视的点:Gunicorn默认使用多进程模型,每个worker进程都是独立的,如果代码里用了进程内缓存或简单的内存变量存状态,多个worker之间是不共享的,导致的结果就是A用户请求落在worker1上保存了状态,下一次请求落在worker2上发现状态丢失。我的系统因为鉴权用了JWT,不依赖内存Session,所以这个问题没遇到,但如果是传统Session方案的Flask应用,部署时就要改成共享存储或者用flask-session的Redis后端。
文件权限的问题主要集中在“上传目录可写”这一点上。编译过程中生成的临时.c文件和可执行文件都在工作目录里,如果是root用户启动的Flask服务,写入没问题;但如果用普通用户启动Gunicorn,工作目录的写权限没开好,编译就会失败。而且工人进程之间互相删除临时文件的情况也需要通过带唯一后缀的文件名来避免,我用uuid保证文件名的全局唯一性,不会出现两个用户同时交卷时互相覆盖的情形。
5. 题目设计经验与题库内容建设
5.1 从C语言基础题到经典题的收集整理
题库的质量决定了系统好不好用。收集题目时我的原则是“经典优先、由易到难、覆盖面广”。从基础的数据类型定义与变量分类、printf与scanf格式控制,到分支结构(比如九九乘法表的多种输出格式)、循环结构(字符串逆序、数值统计)、数组与矩阵处理(最典型的就是5*5矩阵鞍点问题),再到函数、指针、结构体、文件操作,每个知识点都保留了有代表性的经典题。
浙江大学翁恺老师的C语言练习题给我提供了很好的选题思路,题目的叙述风格简洁、边界条件明确,非常适合作为上机考试题。比如字符串逆序那类题,考察的是数组下标操作和循环边界条件,学生容易在边界处出错,正好作为中等难度题。
5.2 测试用例设计:边界、特殊值、性能
测试用例是自动评分的核心依据。每个题目我至少设计5组测试用例,按照“常规输入、边界输入、特殊输入、大输入、错误输入”的思路来覆盖。以55鞍点问题为例,常规用例是一般矩阵;边界用例是全零矩阵,此时每个位置都是行最大值、列最小值,需要输出特殊的“无鞍点”提示而不是错误结果;特殊用例是负数矩阵;大输入是10001000矩阵,目的是检验时间复杂度和内存使用。
这里要特别提醒的是,题目的输入格式要与测试用例严格一致,否则学生程序按常规写法读取不到数据会直接判错。我遇到过一道题描述里写着“输入两个整数”,但测试用例的输入文件中包含多余的空行尾缀,学生程序用scanf读完后无法退出循环,导致超时判错。后来我在测试用例设计时新增了一条规则:必须先跑一遍标准答案代码,确认它能通过全部测试用例,才能把测试用例录入系统。没有这个环节,“判分逻辑本身出错”的问题几乎无法察觉。
5.3 知识点标签与难度校准
题目入库时要打上知识点标签,但每个标签的覆盖范围需要统一口径。比如“指针”这个标签,既包含指针基础、指针与数组、指针与函数,又包含指针与结构体。如果标签口径混乱,组卷时按“指针”抽题就会抽出一堆不相关的题。我参考了C语言教材的章节划分,把知识点定为两级:一级标签是“基础语法、分支、循环、数组、函数、指针、结构体、文件操作、综合应用”,二级标签更细,但题库量大时维护成本高,实际只做到一级标签加一个“扩展标签”字段,用于存放更细粒度的分类。
难度校准是我觉得最有必要但最难以自动化的事情。一道题是否属于“简单”“中等”“困难”,仅靠出题人主观判断常常不准。我的做法是:先在内部小范围测试中让学生做一遍,记录每个学生的答题正确率,正确率高于80%的记为简单,50%到80%的记为中等,低于50%的记为困难。系统上线后,随着考试数据积累,还可以根据实际得分率动态修正难度标签,让组卷的难度分布更符合实际班级水平。
6. 性能优化与并发处理实录
6.1 Flask接口层面的性能瓶颈与优化
Flask本身性能中规中矩,真正的瓶颈集中在“编译运行学生代码”这个环节。一般请求几十毫秒就返回了,编译一个C程序至少几百毫秒,加上运行测试用例可能超过一秒。应对并发交卷压力,我做了两件事。
第一,把“编译运行”的耗时操作放到线程池中执行。Flask的接口处理是同步阻塞的,如果并发请求都来编译,进程会被占满。用ThreadPoolExecutor把编译任务提交给线程池,接口本身可以快速返回一个任务ID,前端再轮询查询结果。这样接口的并发能力大幅提升,不会因为某个代码编译太慢而拖垮所有请求。
第二,限制同时编译的数量。信号量threading.Semaphore(4)控制最多同时4个编译任务在执行,其余的排队等待。这个“排队”不是接口层排队,而是编译任务在信号量处等待,接口返回后前端拿到的是“判题中”状态,等编译任务完成后由异步回调更新结果。实际效果是:即使有30个学生同时交卷,接口也不会超时,只是每个学生的等待时间变长了一点。
6.2 数据库查询优化与索引设计
题库表、考试表、答题记录表、用户表的核心查询都加了索引。最常用的是:
question.knowledge_point和question.difficulty的联合索引,因为自动组卷时大量按这两个字段筛选exam_record.student_id加索引,因为学生查自己成绩记录是高频操作exam.student_id加索引,用于查某学生所有考试
一个容易被忽略的点是:考试开始时需要批量创建答题记录,如果循环一条条INSERT,几百道题几百个学生就是上万条INSERT,数据库性能根本扛不住。我用bulk_insert_mappings一次性插入所有记录,几秒钟搞定。
6.3 前端性能:大题库的懒加载与虚拟滚动
题库列表如果几百道题一次返回,前端渲染也会卡顿。我用分页接口配合Element Plus的el-table自带分页功能,每页20条。题目内容包含长文本,展开详情时才从后端请求题目完整描述,列表只显示标题和标签,避免一次传输大量MB级JSON数据。
考试时题目列表加载也做了优化:学生进入考试页面,后端只返回每道题的题号和标题,页面先渲染出列表。点击具体某道题时,再请求该题的完整描述和当前已保存代码。这样即使是500人的大考场,数据库和网络的负载都被控制在合理范围内。
个人心得体会是,只要是做考试系统,一定要把“异常处理”当成一等公民来对待。功能开发可能只占一半时间,另一半时间都在处理“学生网络断了怎么办”“代码编译超时怎么办”“考试中误点了交卷怎么办”这类问题。把这些异常场景提前想清楚,考试当天才不至于手忙脚乱。代码规范方面,编译参数、超时时间、比对规则都在配置文件中统一维护,遇到新问题只需要改配置不碰代码,维护成本低很多。
后续如果要扩展这个系统,我觉得有两个方向很有价值。一是把判题能力推广到其他语言,比如Python判题只需要调整编译运行命令,核心判题模块完全可以复用。二是增加考后试卷分析与反馈,比如把学生的代码保存下来,老师可以回看某道题哪些学生卡在了哪个测试用例上,相当于自动定位教学重难点。这套系统目前已经稳定运行了一段时间,后续计划加入Python题库的支持和新版界面的适配,到时候再单独写一篇分享。