1. 项目概述:基于.NET Core MVC的在线考试系统
这个在线考试系统是我为一家工业自动化企业开发的内部培训考核平台。作为企业数字化转型的一部分,它彻底改变了传统纸质考试的繁琐流程,实现了从题库管理、组卷、考试到成绩分析的全流程数字化。系统采用.NET Core 3.1作为开发框架,结合SQL Server数据库和Vue.js前端技术,构建了一个高性能、易扩展的B/S架构应用。
在实际开发过程中,我发现这类系统有几个关键痛点需要解决:首先是考试并发时的稳定性,其次是复杂题型的自动批改逻辑,最后是多维度成绩分析的可视化呈现。这个系统通过合理的架构设计和优化,较好地解决了这些问题。现在平均可以支持500人同时在线考试,客观题批改准确率达到100%,成绩分析报表生成时间控制在3秒以内。
提示:选择.NET Core而不是传统的.NET Framework,主要是看中它的跨平台特性和更高的性能表现。实测在相同硬件条件下,.NET Core的请求处理速度比.NET Framework快约30%。
2. 系统架构与技术选型解析
2.1 整体架构设计
系统采用经典的四层架构,但在实现上做了一些创新:
-
表现层(Web):没有采用传统的Razor Pages,而是使用ASP.NET Core MVC + Web API + Vue.js的组合。这样前后端分离更彻底,API还可以供未来的移动端复用。
-
业务逻辑层(BLL):这里我引入了领域驱动设计(DDD)的部分思想,将核心业务如组卷策略、自动批改算法等封装为领域服务。例如自动组卷就不是简单的随机选题,而是基于难度系数、知识点分布等维度进行智能筛选。
-
数据访问层(DAL):采用EF Core Code First模式,配合仓储模式抽象数据访问。一个特别的优化是对于高频访问的试题数据实现了二级缓存(内存缓存+Redis)。
-
工具层(Utility):除了常规的辅助类,这里还封装了几个特色组件:
- Excel导入导出服务(基于EPPlus)
- 考试防作弊监测服务
- 多语言文本资源管理器
2.2 关键技术选型考量
技术栈的选择经过了多轮对比测试:
| 技术选项 | 对比方案 | 选择理由 |
|---|---|---|
| .NET Core 3.1 | .NET Framework 4.8 | 更好的性能、跨平台支持、长期支持(LTS)版本 |
| SQL Server | MySQL | 企业已有SQL Server授权,且其JSON支持和内存优化表更适合考试场景 |
| EF Core Code First | Dapper | 开发效率高,迁移方便,配合LINQ能简化复杂查询 |
| Vue.js | React/Angular | 学习曲线平缓,与ASP.NET Core集成简单,适合企业内部开发团队技术栈 |
| JWT认证 | Cookie认证 | 更适合前后端分离架构,无状态特性有利于水平扩展 |
特别说明EF Core的性能优化:通过批量操作(BulkInsert)、异步编程、查询优化(避免N+1问题)等措施,在万级题库数据量下,组卷响应时间仍能控制在1秒内。
3. 核心功能模块实现细节
3.1 多角色权限系统
系统设计了RBAC(基于角色的访问控制)模型,包含三类角色:
- 管理员:拥有完整权限,特别要注意的是用户-科目分配功能。因为企业要求不同部门的员工只能参加指定科目的考试,这通过UserSubject关联表实现。
csharp复制// 用户分配科目的核心逻辑
public async Task AssignSubjects(int userId, List<int> subjectIds)
{
// 先删除现有关联
var existing = await _dbContext.UserSubjects.Where(x => x.UserId == userId).ToListAsync();
_dbContext.UserSubjects.RemoveRange(existing);
// 添加新关联
var newRelations = subjectIds.Select(id => new UserSubject {
UserId = userId,
SubjectId = id
});
await _dbContext.UserSubjects.AddRangeAsync(newRelations);
return await _dbContext.SaveChangesAsync();
}
- 教师:限制只能管理自己被分配的科目。这里通过在每个查询中添加SubjectId过滤条件实现:
csharp复制var questions = await _dbContext.Questions
.Where(x => x.SubjectId == currentUser.SubjectId)
.ToListAsync();
- 员工:只能查看和参加自己有权限的考试。这个控制主要在ClientController中实现,查询考试列表时会关联UserSubject表进行过滤。
3.2 智能组卷引擎
组卷功能是系统的核心难点之一,我们实现了两种模式:
自动组卷算法流程:
- 根据试卷规则确定各题型数量
- 按难度系数分层随机选题(保证简单:中等:难题≈3:5:2)
- 检查知识点覆盖度(避免同一知识点题目过于集中)
- 排除近期使用过的题目(基于历史记录)
- 计算总分并生成试卷
csharp复制public async Task<Examination> GenerateExamAuto(int ruleId)
{
var rule = await _ruleRepo.GetByIdAsync(ruleId);
var questions = new List<Question>();
// 按题型循环选题
foreach(var type in rule.QuestionTypes)
{
var pool = await _questionRepo.GetQuestionPool(
subjectId: rule.SubjectId,
type: type.Type,
difficulty: type.DifficultyLevel,
excludeRecent: true);
// 随机选择指定数量的题目
var selected = Shuffle(pool).Take(type.Count).ToList();
questions.AddRange(selected);
}
// 创建试卷实体
var exam = new Examination {
Title = $"{rule.Subject.Name} - 自动组卷 {DateTime.Now:yyyyMMdd}",
Questions = questions,
// 其他属性设置...
};
return await _examRepo.CreateAsync(exam);
}
手动组卷则提供了可视化的选题界面,教师可以按知识点、难度等条件筛选题目,然后手动添加到试卷中。这里一个关键技术点是实现了试题的实时预览和去重检查。
3.3 考试过程管理
考试模块有几个关键设计点:
-
限时控制:使用JavaScript倒计时+服务端双重校验。即使考生修改本地时间,提交时服务端会重新计算实际考试时长。
-
自动保存:每30秒自动保存答题进度到localStorage,意外退出后可以恢复。
-
防作弊措施:
- 禁止页面复制粘贴
- 窗口失去焦点超过3次警告
- 定时抓取考生摄像头快照(需授权)
-
提交处理:
csharp复制public async Task SubmitExam(int examId, List<Answer> answers)
{
// 验证考试状态
var exam = await _dbContext.Examinations.FindAsync(examId);
if(exam == null || exam.Status != ExamStatus.Published)
throw new Exception("考试不可用");
// 验证考生权限
var hasAccess = await _dbContext.UserExams
.AnyAsync(x => x.UserId == currentUserId && x.ExamId == examId);
if(!hasAccess) throw new UnauthorizedAccessException();
// 保存答案
foreach(var answer in answers)
{
var entity = new UserAnswer {
UserId = currentUserId,
QuestionId = answer.QuestionId,
Content = answer.Content,
SubmitTime = DateTime.Now
};
await _dbContext.UserAnswers.AddAsync(entity);
}
// 自动批改客观题
await AutoGradeObjectiveQuestions(examId, currentUserId);
// 更新考试状态
var userExam = await _dbContext.UserExams
.FirstAsync(x => x.UserId == currentUserId && x.ExamId == examId);
userExam.Status = ExamStatus.Submitted;
await _dbContext.SaveChangesAsync();
}
3.4 多语言实现方案
系统使用ASP.NET Core的本地化中间件实现中英文切换:
-
资源文件按控制器拆分:
- Controllers/HomeController.zh.resx
- Controllers/HomeController.en.resx
-
前端Vue组件中也封装了多语言支持:
javascript复制// 语言切换组件
<template>
<select v-model="currentLang" @change="changeLanguage">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</template>
<script>
export default {
methods: {
changeLanguage() {
this.$i18n.locale = this.currentLang;
axios.post('/api/language', { lang: this.currentLang });
}
}
}
</script>
- 服务端通过Cookie保存语言偏好:
csharp复制public IActionResult SetLanguage(string lang)
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(lang)),
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
);
return LocalRedirect("~/");
}
4. 性能优化实践
4.1 数据库优化
-
索引策略:
- 在UserExam表的(UserId, ExamId)上创建复合索引
- 为Question表的SubjectId和TypeId字段添加索引
- 对经常用于查询条件的字段如CreateTime建立索引
-
查询优化:
- 使用AsNoTracking()减少EF Core的变更跟踪开销
- 通过Include()预先加载关联实体避免N+1查询
- 复杂统计查询使用存储过程
csharp复制// 优化的成绩统计查询
var stats = await _dbContext.UserExams
.Where(x => x.ExamId == examId && x.Status == ExamStatus.Graded)
.GroupBy(x => 1) // 仅用于聚合
.Select(g => new {
AvgScore = g.Average(x => x.Score),
MaxScore = g.Max(x => x.Score),
MinScore = g.Min(x => x.Score),
Count = g.Count()
})
.AsNoTracking()
.FirstOrDefaultAsync();
4.2 缓存策略
-
题库缓存:使用MemoryCache缓存高频访问的试题数据,设置5分钟绝对过期时间。
-
成绩排行缓存:使用Redis缓存考试Top10成绩,通过滑动过期保持数据新鲜度。
-
缓存失效策略:当题库或考试成绩变更时,通过领域事件通知缓存失效。
csharp复制// 缓存实现示例
public async Task<List<Question>> GetQuestionsBySubject(int subjectId)
{
var cacheKey = $"questions_subject_{subjectId}";
if(_cache.TryGetValue(cacheKey, out List<Question> questions))
return questions;
questions = await _dbContext.Questions
.Where(x => x.SubjectId == subjectId)
.AsNoTracking()
.ToListAsync();
_cache.Set(cacheKey, questions, TimeSpan.FromMinutes(5));
return questions;
}
4.3 并发处理
考试提交高峰期可能产生并发问题,我们采用以下策略:
- 使用乐观并发控制处理成绩更新:
csharp复制try {
var exam = await _dbContext.Exams.FindAsync(examId);
exam.Status = ExamStatus.Graded;
await _dbContext.SaveChangesAsync(); // 自动检查ConcurrencyToken
} catch(DbUpdateConcurrencyException) {
// 重试逻辑
}
- 对于自动批改这类CPU密集型操作,使用后台服务队列处理:
csharp复制_backgroundQueue.QueueBackgroundWorkItem(async token => {
await _gradingService.GradeExamAsync(examId);
});
5. 部署与运维实践
5.1 系统部署方案
我们采用Docker容器化部署,主要优势在于:
- 环境一致性:开发、测试、生产环境完全一致
- 快速扩展:考试高峰期可以快速增加容器实例
- 资源隔离:数据库和应用服务独立部署
典型的docker-compose.yml配置:
yaml复制version: '3.4'
services:
web:
image: ${DOCKER_REGISTRY-}examweb
build:
context: .
dockerfile: Exam.Web/Dockerfile
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Server=db;Database=ExamSystem;User=sa;Password=your_password;
ports:
- "5000:80"
depends_on:
- db
db:
image: mcr.microsoft.com/mssql/server:2019-latest
environment:
SA_PASSWORD: "your_password"
ACCEPT_EULA: "Y"
ports:
- "1433:1433"
volumes:
- sqlvolume:/var/opt/mssql
5.2 监控与日志
-
应用监控:使用Application Insights收集性能数据
- 配置方法:在Program.cs中添加
csharp复制
builder.Services.AddApplicationInsightsTelemetry(); -
日志策略:
- 使用Serilog替代默认日志系统
- 重要操作记录审计日志(如试卷修改、成绩变更)
- 错误日志发送到Elasticsearch便于分析
csharp复制// Serilog配置示例
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File("logs/exam-system-.log", rollingInterval: RollingInterval.Day)
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri("http://elastic:9200"))
{
AutoRegisterTemplate = true,
IndexFormat = "exam-system-logs-{0:yyyy.MM}"
})
.CreateLogger();
5.3 备份策略
-
数据库备份:
- 每日完整备份 + 每小时差异备份
- 备份文件自动上传到云存储
-
代码备份:
- 使用Git仓库管理
- 自动同步到远程私有Git服务器
-
灾难恢复:
- 准备备用数据库服务器
- 定期演练恢复流程
6. 开发经验与避坑指南
6.1 值得分享的开发技巧
-
EF Core迁移管理:
- 每个功能分支创建独立的迁移
- 合并前检查迁移脚本冲突
- 生产环境使用脚本审核工具检查潜在问题
-
高效的前端开发:
- 使用Vue的单文件组件(SFC)模式
- 封装通用的考试UI组件(如计时器、答题卡)
- 利用Axios拦截器统一处理API错误
-
自动化测试策略:
- 单元测试覆盖核心业务逻辑
- 集成测试验证API端点
- UI测试覆盖关键用户旅程
csharp复制// 典型的单元测试示例
[Fact]
public async Task AutoGradeExam_Should_CorrectlyGradeMultipleChoice()
{
// 准备测试数据
var question = new Question {
Type = QuestionType.MultipleChoice,
CorrectAnswer = "A,B",
Score = 2
};
var answer = new UserAnswer {
Content = "A,B",
Question = question
};
// 执行测试
var result = await _gradingService.GradeAnswerAsync(answer);
// 验证结果
Assert.True(result.IsCorrect);
Assert.Equal(2, result.Score);
}
6.2 遇到的典型问题及解决方案
-
考试提交超时问题:
- 现象:高峰期大量提交导致请求超时
- 解决方案:
- 前端实现排队机制
- 服务端增加异步处理
- 使用Redis缓存临时存储提交数据
-
自动批改准确性问题:
- 现象:填空题大小写和空格导致误判
- 解决方案:
- 实现智能匹配算法(去除空格、忽略大小写、允许同义词)
- 对模糊答案给出部分分数
-
多语言资源同步问题:
- 现象:新增功能后遗漏翻译
- 解决方案:
- 建立资源文件变更检查机制
- 开发自定义Roslyn分析器检测未翻译文本
6.3 如果重来我会改进的地方
经过实际运行和用户反馈,我认为系统还有以下优化空间:
- 考试防作弊:增加人脸识别验证和活体检测
- 移动端适配:开发专门的移动应用或PWA版本
- 智能组卷:引入机器学习算法优化题目选择
- 语音支持:增加试题语音朗读功能
- 监控告警:完善异常监控和自动告警机制
这个项目让我深刻体会到,一个好的在线考试系统不仅需要扎实的技术实现,更需要深入理解教育测量学的专业知识。比如试题难度系数的设定、试卷信效度的保证等,这些都需要与专业的教育工作者密切合作。