负载均衡式在线评测系统(Load-Balanced Online OJ)技术全景指南
前言
欢迎来到 Load-Balanced Online OJ 的技术解析世界!本技术全景指南旨在从架构设计、核心技术选型到底层代码实现,全方位地拆解一个分布式在线代码评测系统是如何从 0 到 1 搭建起来的。无论你是对 C++ 后端架构感兴趣的开发者,还是希望了解系统设计与部署运维的全栈工程师,甚至是零基础的编程爱好者,都能在这里找到详尽的图解与代码级剖析。
本文将从多个维度展开:项目概述与架构设计、技术实现详解、环境搭建与部署指南、性能与运维、总结与展望。通过系统性的技术梳理,帮助你深入理解在线评测系统的核心原理与最佳实践,并且能够跟随本文一步一步搭建属于自己的在线 OJ 系统。
第一部分:项目概述与架构设计
第1章 项目背景与技术选型
1.1 项目要解决什么问题
作为一个在线代码评测平台(Online Judge,简称 OJ),系统的最核心业务链路如下:
用户通过浏览器提交代码 → 主服务接收并下发任务 → 编译服务器编译并运行代码 → 采集运行数据并与测试用例比对 → 最终返回判题结果(如 AC, WA, TLE 等)。
术语解释
- OJ:Online Judge 的缩写,是一种在线代码评测系统,用于自动评测用户提交的代码是否正确。
- AC:Accepted 的缩写,表示代码正确通过所有测试用例。
- WA:Wrong Answer 的缩写,表示代码运行结果与预期不符。
- TLE:Time Limit Exceeded 的缩写,表示代码运行时间超过了题目限制。
面临的关键挑战
- 隔离与限额 :判题任务属于典型的"CPU与内存密集型"操作,不仅需要限制资源消耗(如最大运行时间、最大内存使用),还需要在安全的沙箱环境中运行,防止恶意代码破坏宿主机。 术语解释:沙箱(Sandbox)是一种安全机制,用于隔离运行环境,防止不可信的程序对系统造成损害。
- 横向扩展与并发处理 :在用户提交高峰期(如比赛或作业提交截止前),单台编译服务器的性能极易达到瓶颈,系统必须支持多节点的横向扩展和智能任务分发。 术语解释:
- 横向扩展(Horizontal Scaling):通过增加服务器数量来提高系统处理能力。
- 并发处理(Concurrent Processing):同时处理多个任务的能力。
- 工程可维护性 :系统需要具备清晰的模块边界,前后端分离,API设计稳定,且方便在 Docker 环境下快速部署与运维。 术语解释:
- 前后端分离(Frontend-Backend Separation):将用户界面(前端)与业务逻辑(后端)分离开发的架构模式。
- API:Application Programming Interface,应用程序编程接口,是不同软件组件之间的通信规范。
- Docker:一种容器化技术,用于打包应用及其依赖,实现跨平台一致部署。
1.2 技术选型原则
为了解决上述挑战,我们在进行技术选型时遵循以下核心原则:
- 轻量与可控:核心评测链路尽量减少对外部重型框架的依赖,采用 C++ 搭配极简的 Web 库实现,使得服务不仅部署简单,而且我们能够精确掌控系统的每一个底层细节。
- 性能与安全优先 :在编译与运行环节,贴近操作系统的 API(如
fork,exec,setrlimit)能够实现极其精细的资源控制与权限隔离。在前端环节,对用户输入的 Markdown 内容进行严格的 XSS 过滤,确保输出安全。 - 高演进空间:系统架构设计预留了平滑升级的接口,未来可以无缝接入 Redis 缓存、Nginx 反向代理集群,并支持扩展更多编程语言的评测环境。
1.3 技术栈总览
为了直观呈现本项目的技术分布,以下是系统各个分层的技术选型及说明。
| 分层 | 选用技术 | 核心作用 | 选择原因 | 可替代演进方案 |
|---|---|---|---|---|
| Web 主服务 | C++11 + httplib | 处理业务 API、提供页面路由、分发判题任务 | 极致轻量、部署便捷、性能稳定 | Nginx + Lua、Go (Gin)、Node.js |
| 判题沙箱服务 | g++/javac + fork/exec | 执行用户代码的编译与运行 | 与 OS 系统能力贴近,资源控制极度精细 | Docker 容器化沙箱、Seccomp |
| 持久化数据层 | MySQL 8.0 | 存储用户、题目、提交记录、讨论及题单数据 | 关系模型清晰,SQL 便于复杂查询 | PostgreSQL |
| 缓存与队列(可选) | Redis | 存储热点数据缓存、队列限流及会话管理 | 内存级低延迟,生态极为成熟 | 本地内存缓存、Kafka |
| 基础设施运维 | Docker Compose | 负责全栈环境的一键启动与容器编排 | 环境一致性极佳,屏蔽了 OS 差异 | Kubernetes (K8s)、Systemd |
技术栈术语解释
- C++11:一种高性能编程语言,适合系统级编程和资源密集型应用。
- httplib:一个轻量级的 C++ HTTP 库,用于构建 Web 服务器和客户端。
- g++/javac:分别是 C++ 和 Java 的编译器,用于将源代码编译成可执行文件。
- fork/exec:Unix/Linux 系统调用,用于创建新进程并执行程序。
- MySQL:一种关系型数据库管理系统,用于存储结构化数据。
- Redis:一种内存数据库,用于缓存和消息队列。
- Docker Compose:用于定义和运行多容器 Docker 应用的工具。
系统分层架构简图
数据与扩展层
评测执行层
接入与业务层
前端展示层
HTTP/HTTPS
反向代理
读写会话/热点
负载均衡分发
负载均衡分发
负载均衡分发
读写业务数据
定期入库题目
用户浏览器
Nginx 反向代理
OJ 主服务 / 8096端口
缓存与队列 / Redis
编译服务 Node 1 / 8081端口
编译服务 Node 2 / 8082端口
编译服务 Node 3 / 8083端口
MySQL 数据库
后台爬虫服务 Crawler
💡 阅读建议
如果您希望快速了解系统全貌,建议优先阅读系统架构图与核心业务流程图;如果您对 C++ 底层的进程控制与容错机制感兴趣,请直接关注关键技术实现细节。
第2章 系统架构全景
在深入技术实现细节之前,让我们先通过架构图来建立对整个系统的宏观认知。本章将通过两张层次分明的架构图(简版与细化版),为您揭示整个在线评测系统的组件拓扑与数据流向。
2.1 简版整体架构图(核心链路)
简版架构图展示了系统最基础、最核心的业务闭环:用户提交代码、主服务接收并调度、编译集群处理以及数据库持久化。
编译集群
HTTP GET/POST
HTTP POST: /compile_and_run
HTTP POST: /compile_and_run
SQL (读写)
SQL (定时写入题目)
用户浏览器
OJ 主服务器
编译服务器 #1
编译服务器 #2
MySQL 数据库
后台爬虫 Crawler
阅读指引 :在这条极简链路中,OJ 主服务器 是唯一暴露给外网的节点。它不仅负责提供页面和 API,还承担着"负载均衡器"的角色,将极其消耗系统资源的编译与运行任务通过内网分发给隔离的编译服务器集群。
2.2 细化架构图(完整拓扑与演进)
细化架构图引入了更多在生产环境或未来演进中必不可少的组件,包括反向代理、静态资源服务器、分布式缓存以及各组件之间的心跳检测机制。
数据持久与缓存层 (Data Layer)
评测执行层 (Sandbox Cluster)
业务核心层 (Business Logic)
接入层 (Gateway)
HTTPS
HTTP
直接访问
心跳检测 /ping
任务下发 /compile_and_run
心跳检测 /ping
任务下发 /compile_and_run
自动发现与负载均衡
读写业务数据
读取热点数据/Session
清洗并写入爬取数据
用户浏览器/移动端
Nginx (反向代理 / HTTPS 卸载)
静态资源服务 (CSS/JS/Images)
OJ 主服务器 (端口 8096)
Compile Server #1 (端口 8081)
Compile Server #2 (端口 8082)
Compile Server #N (自动扩容)
MySQL 8.0 (主库)
Redis (缓存/会话 - 演进方案)
后台定时爬虫 (C++)
2.3 组件职责卡片与网络边界
为了更好地理解上述架构图中每个节点的角色,我们梳理了核心组件的职责与网络暴露情况:
OJ 主服务器 (OJ Server)
- 核心职责 :系统的"大脑"。负责处理用户的注册/登录、题目列表分页、帖子发布等 Web 请求;并在用户提交代码时,通过
SmartChoice算法将判题任务负载均衡地派发给后端的编译服务器。 - 网络边界 :绑定
8096端口。在生产环境中,通常不对外网直接暴露,而是被 Nginx 反向代理包裹。
编译服务器集群 (Compile Server Cluster)
- 核心职责 :系统的"肌肉"。纯粹的无状态计算节点,仅提供
/ping和/compile_and_run接口。它们负责将用户的源码落盘,调用g++/javac编译,并在严格的资源限制(CPU、内存)沙箱下运行代码,最终将执行结果、日志或错误信号回传给主服务。 - 网络边界 :绑定
8081~808x端口。严格禁止外网访问,仅允许 OJ 主服务器所在内网进行调用,防止恶意攻击者绕过鉴权直接提交恶意代码。
数据库 (MySQL) & 爬虫 (Crawler)
- 核心职责:MySQL 提供强一致性的事务与数据存储;C++ 爬虫作为一个独立的定时任务进程,定期从第三方平台(如洛谷、VJudge)抓取公开题目,清洗后写入 MySQL 题库。
- 网络边界 :数据库绑定
3306端口,同样仅限内网微服务访问。
通过这种物理层面的拆分,我们将"高并发 I/O 请求"(主服务)与"高消耗 CPU/内存计算"(编译服务)完全隔离开来,使得系统在面对突发的高峰期时,可以通过随时增加 Compile Server 节点来实现平滑的横向扩容。
第3章 核心业务流程
在建立了系统架构的整体认知后,让我们深入了解系统最核心的生命周期:代码判题流程。本章将通过 UML 时序图展示一条代码提交从用户浏览器到最终返回判题结果的完整流转,随后通过流程图剖析在"沙箱环境"内部,代码是如何经历编译、降权运行及资源监控的。
3.1 判题提交流程 (Sequence Diagram)
当用户在浏览器中点击"提交"按钮时,系统会触发一系列同步与异步操作。主服务需要从数据库拉取测试用例,通过负载均衡选择最优编译节点,并汇总比对结果。
编译服务器 (Sandbox) 负载均衡模块 MySQL 数据库 OJ 主服务器 编译服务器 (Sandbox) 负载均衡模块 MySQL 数据库 OJ 主服务器 alt [结果不匹配或发生异常 (WA/TLE/MLE)] loop [遍历每一个测试用例] 用户浏览器 (Browser) POST /api/judge (包含代码、语言、题目ID) 1 根据题目ID查询 测试用例、时间与内存限制 2 返回测试用例集合 3 请求 SmartChoice() 选择节点 4 返回压力最小的 Compile Server 5 POST /compile_and_run (源码, 输入用例, 资源限制) 6 返回判题执行结果 JSON (包含 stdout, stderr, 状态码) 7 将 stdout 与预期 output 比对 8 中断测试,标记该用例失败原因 9 将最终结果、耗时、内存消耗写入 submissions 表 10 写入成功 11 返回最终判题结果 (如 AC, WA 等) 12 用户浏览器 (Browser)
3.2 编译与运行流程 (Flowchart)
当 Compile Server 接收到主服务的 /compile_and_run 请求后,它必须在极度安全且资源受限的环境下执行这段可能充满恶意的用户代码。这部分是我们系统安全性的核心屏障。
不合法或缺少代码
校验通过
否
是
正常退出
收到超时信号
收到内存错误信号
收到浮点异常
收到 /compile_and_run 请求
参数校验
返回系统错误
在 ./temp 目录落盘源文件与输入文件
调用 g++ / javac 子进程进行编译
编译是否成功?
读取 compiler_error 文件构建编译错误响应
准备运行代码: fork 子进程
调用 setrlimit 限制 CPU、内存、最大进程数
切换用户身份实现降权执行
通过 execl 执行编译出的二进制文件重定向 stdin/stdout/stderr
父进程使用 waitpid/wait4 监控子进程
子进程如何退出?
获取真实内存/时间消耗标记为运行成功
标记为 TLE
标记为 MLE 或 RE
标记为 RE
清理临时文件
构造并返回 JSON 结果
3.3 判题结果码对照表
在上述沙箱运行流程中,子进程的不同退出状态会被映射为标准的判题结果。以下是系统核心结果码的映射对照表:
| 内部状态码 | OJ 最终结果 (Result) | 触发场景与原因分析 | 用户提示 |
|---|---|---|---|
| 0 | Accepted (AC) |
代码编译通过,运行正常结束,且输出与测试用例完全一致。 | 答案正确 |
| -1 | Compile Error (CE) |
g++/javac 编译阶段返回非 0 状态码,通常是语法错误。 | 编译错误(附带 stderr 输出) |
| -2 | Runtime Error (RE) |
运行时发生段错误、除以零 (SIGFPE) 或异常 abort (SIGABRT)。 | 运行错误 |
| -3 | Time Limit Exceeded (TLE) |
触发了 setrlimit 的 RLIMIT_CPU 限制,进程被系统发送 SIGXCPU 或 SIGKILL 强杀。 |
运行超时 |
| -4 | Memory Limit Exceeded (MLE) |
程序尝试申请的内存超过了沙箱限制,被 OOM Killer 杀掉,或由于虚拟内存限制导致分配失败抛出异常。 | 内存超限 |
| -5 | Wrong Answer (WA) |
程序正常运行完毕,但标准输出 (stdout) 经过裁剪后,与数据库中的标准输出不匹配。 | 答案错误 |
🔧 安全演练提示 (Failure Injection)
在本地开发时,可以通过提交
while(true){}来模拟触发 TLE;通过不断申请动态内存while(true){ int* p = new int[1024*1024]; }来模拟触发 MLE,以此验证沙箱信号捕获机制的可靠性。
第二部分:技术实现详解
章节引言
在了解了系统的整体架构和核心业务逻辑之后,让我们深入到具体的技术实现层面。本部分将详细剖析前端和后端的技术选型依据、核心组件的工作原理,以及数据持久化层的设计思路。通过对技术栈的深入解析,你将理解每个技术决策背后的工程考量。
第4章 前端技术栈详解(页面与交互层)
本章将深入解析在线评测系统的前端实现细节。由于系统强调极简与快速落地,我们目前采用"HTML/CSS/JS + CTemplate 模板引擎"的形态,结合纯原生 JavaScript 和现代浏览器的能力,实现了一个包含代码高亮、Markdown 渲染与讨论区交互的响应式页面。本章不仅会探讨现有方案的状态管理与安全策略,还会给出未来向 React 架构演进的思考。
4.1 前端形态与框架选择
当前技术选型
目前项目前端并未引入 React 或 Vue 等重型框架,而是采用了基于后端模板渲染的方案:
- 核心结构:原生 HTML/CSS/JavaScript 构建页面骨架与交互逻辑。
- 模板引擎:后端使用 Google CTemplate 引擎注入初始化数据(如当前登录用户信息、题目基本信息等),以加快首屏加载速度并简化鉴权流程。
- 关键增强:在需要复杂交互的模块(代码编辑器、Markdown 渲染、拖拽排序等)引入了轻量级的第三方库。
框架形态对比分析
下面是当前"模板渲染"方案与未来可能演进的"单页应用 (SPA)"方案的对比:
演进方案:React SPA架构
浏览器获取静态HTML/JS
React挂载并渲染组件
全局状态管理Zustand/Redux
通过REST API异步加载所有数据
现有方案:CTemplate模板渲染
浏览器发起请求
后端拼接HTML+数据
浏览器直接渲染完整首屏
部分交互依赖原生JS fetch API
| 维度 | 模板渲染(当前) | SPA 架构(演进方向) |
|---|---|---|
| 首屏加载 (FCP) | 极快,HTML 直接包含数据 | 稍慢,依赖 JS 包下载与执行 |
| 交互复杂度 | 中等,依靠原生 DOM 操作 | 极高,支持复杂的局部刷新与状态共享 |
| 工程化成本 | 较低,无需 Node.js 构建工具链 | 较高,需要 Webpack/Vite 等打包工具 |
| 组件复用率 | 依赖后端模板 include | 极高,前端组件化彻底 |
4.2 关键 UI 能力组件
为了在不使用庞大框架的前提下提供极佳的用户体验,我们集成了一系列功能强大的原生 JS 插件。
代码编辑器:ACE Editor
ACE Editor 提供了类似 VS Code 的代码编写体验。我们深度定制了代码高亮主题(如 Monokai)、快捷键绑定,并封装了底部拖拽改变编辑器高度的面板交互。
富文本编辑:EasyMDE
在讨论区和题解模块,我们集成了 EasyMDE。它提供了所见即所得的 Markdown 编写体验,并被我们扩展支持了图片的拖拽/粘贴上传以及本地 PDF 文件的文本导入解析功能。
列表排序:SortableJS
在"题单"管理页面,为了让管理员能够灵活地调整题目顺序,我们引入了 SortableJS,实现了原生丝滑的拖拽排序,并将排序结果(order_index)同步至后端持久化。
4.3 状态管理与数据获取策略
在没有全局状态库(如 Redux)的情况下,我们采用了页面级状态闭环 与异步 fetch 获取 相结合的策略。页面的初始核心状态由 CTemplate 注入,而后续的分页、评论加载、状态刷新等则通过 async/await 结合 Fetch API 实现。
示例代码:讨论区详情的数据拉取
以下代码展示了如何在原生 JS 中封装数据的异步获取与 DOM 更新。这段代码负责在页面加载时,根据帖子 ID 获取详细的讨论数据,并动态渲染到页面上。
javascript
/**
* 异步获取讨论帖子详情,并将其渲染到页面指定的容器中
* 此函数演示了如何在原生 JS 环境下处理异步请求、错误捕获以及动态 DOM 更新
*/
async function loadDiscussionDetail(id) {
try {
// 使用原生的 fetch API 发起 GET 请求
const response = await fetch('/api/discussion/' + id);
const res = await response.json();
// 状态码 0 代表成功,且必须确保有 data 载荷
if (res.status === 0 && res.data) {
const post = res.data;
// 构建包含帖子元数据(头像、作者、日期)的 HTML
document.getElementById('post-detail-content').innerHTML = `
<h1>${escapeHtml(post.title)}</h1>
<div class="post-meta">
<img src="${post.avatar}" alt="${escapeHtml(post.author)}">
<span>${escapeHtml(post.author)} · ${post.date}</span>
</div>
<!-- 预留用于渲染 Markdown 内容的容器 -->
<div class="markdown-body" id="post-markdown-content"></div>
`;
// 后续逻辑会调用 Markdown 渲染器将 post.content 转换为安全 HTML 并填入
renderMarkdownContent(post.content);
} else {
// 友好的错误提示反馈给用户
document.getElementById('post-detail-content').innerHTML =
'<div class="error">加载失败: ' + (res.reason || '数据为空') + '</div>';
}
} catch (e) {
// 捕获网络异常或 JSON 解析异常
console.error('Fetch error:', e);
document.getElementById('post-detail-content').innerHTML =
'<div class="error">加载异常,请检查网络连接。</div>';
}
}
4.4 富文本渲染安全(核心防 XSS 策略)
在线评测系统的讨论区允许用户提交包含代码、甚至内联 HTML 标签的 Markdown 内容。为了防止跨站脚本攻击(XSS),我们构建了一套严格的安全渲染链路:Markdown 原文 -> marked.js 转换 -> DOMPurify 过滤 -> 注入 DOM。
示例代码:Markdown 的安全渲染过滤
下面的代码段展示了如何在客户端确保富文本渲染的绝对安全,即使输入包含了恶意的 <script> 标签也会被清洗掉。
javascript
/**
* 将用户输入的 Markdown 文本安全地转换为 HTML 并注入页面
* 结合了 marked.js 用于解析,以及 DOMPurify 用于 XSS 过滤
*/
function renderMarkdownContent(rawMarkdown) {
// 1. 预处理 Markdown,例如规范化换行符
const markdown = normalizeMarkdown(rawMarkdown);
// 2. 将 Markdown 解析为原始(但不安全)的 HTML 字符串
const unsafeHTML = marked.parse(markdown);
// 3. 配置 DOMPurify 的安全白名单策略
// 允许 data- 属性,因为代码高亮插件可能需要依赖这些属性
const purifyConfig = {
ALLOW_DATA_ATTR: true,
// 这里可以配置更多白名单标签,如允许 img, a 等,但严格禁止 script
};
// 4. 执行深度清洗,剥离所有不安全的事件处理器和标签
const renderedHTML = DOMPurify.sanitize(unsafeHTML, purifyConfig);
// 5. 将绝对安全的 HTML 字符串注入到页面中
const contentDiv = document.getElementById('post-markdown-content');
contentDiv.innerHTML = renderedHTML;
// 6. (可选)触发 Highlight.js 对代码块进行语法高亮
contentDiv.querySelectorAll('pre code').forEach((el) => {
hljs.highlightElement(el);
});
}
渲染链路流程图
发现恶意 script / on事件
正常标签 (h1, p, pre)
用户提交 Markdown 文本
marked.js 解析为 HTML 字符串
DOMPurify 过滤拦截
移除危险节点与属性
保留节点
生成安全的 Safe HTML
注入浏览器 DOM 树渲染
4.5 UI 样式体系总结
我们采用原生 CSS 变量 结合原子化类名的设计,摒弃了传统的全局重置,使得样式更加模块化。
- 深色模式优先 :整个平台采用类似于 VS Code 的暗黑主题配色,通过在
:root定义--bg-main,--text-color,--border-color等变量实现。 - 演进方向:在未来的 React 重构计划中,我们将全面引入 TailwindCSS 替代手写 CSS,以获得更高的一致性与开发效率。
第5章 后端技术栈详解(服务、数据与 API)
本章节详细介绍在线评测系统后端的核心技术架构。从编程语言、Web 框架的选择,到持久化存储与缓存的设计,再到前后端交互的 API 协议约束,我们将深入剖析如何使用轻量级工具链构建高并发、高可用的 C++ 判题后端系统。
5.1 服务端语言与 Web 框架
核心语言:C++11
作为判题系统的后端,我们坚决选择了 C++11。
- 优势 :
- 性能极致,能够高效处理密集的并发连接。
- 能够直接调用 Linux/macOS 底层 POSIX 接口(如
fork,exec,setrlimit),这对于精细化控制判题沙箱的资源、内存以及系统调用至关重要。
- 挑战:工程复杂度较高,需自行管理内存和异常;这要求我们建立严格的代码边界并做好自动化测试。
Web 框架:httplib
为了坚持"轻量化"原则,我们并没有引入复杂的 RPC 或 Web 框架(如 gRPC, Boost.Beast 等),而是选用了单头文件跨平台网络库 cpp-httplib。
- 主服务 (OJ Server) :使用
httplib::Server对外提供 REST API 和页面路由。 - 内部通信 :主服务使用
httplib::Client封装 HTTP POST 请求,将判题任务分发给后端的编译服务集群。 - 编译服务 (Compile Server) :使用
httplib::Server暴露/ping和/compile_and_run接口。
示例代码:主服务 API 路由配置
下面是主服务利用 httplib 进行 API 路由挂载的关键代码片段,展示了闭包和请求参数的解析方式。
cpp
/**
* 配置主服务的路由信息
* 使用 httplib::Server 挂载 GET 请求,并通过 Control 层处理业务逻辑
*/
// 创建 httplib 服务器实例
Server svr;
// 配置线程池,适应高并发的 I/O 密集型场景
svr.new_task_queue = [] { return new httplib::ThreadPool(500); };
// 配置首页路由
svr.Get("/", [&ctrl](const Request &req, Response &resp){
// 禁用缓存,保证动态数据的实时性
SetNoCache(resp);
std::string html;
// 调用 Control 层方法生成首页 HTML
ctrl.Home(req, &html);
resp.set_content(html, "text/html; charset=utf-8");
});
// 配置题目列表 API(需鉴权)
svr.Get("/all_questions", [&ctrl](const Request &req, Response &resp){
SetNoCache(resp);
User user;
// 检查用户 Session,若未登录则重定向
if (!ctrl.AuthCheck(req, &user)) {
resp.set_redirect("/login");
return;
}
std::string html;
ctrl.AllQuestions(req, &html);
resp.set_content(html, "text/html; charset=utf-8");
});
示例代码:主服务调用编译服务(带超时与重试)
在微服务调用中,必须处理网络延迟或节点挂掉的情况。以下是主服务调用编译服务时的核心容错代码。
cpp
/**
* 核心判题链路:向选定的 Compile Server 发起代码编译与运行请求
* 包含了负载均衡机器选择、超时控制和容错重试机制
*/
// 1. 通过负载均衡算法选择一台压力最小的编译服务器
Machine* m = nullptr;
if(!load_blance_.SmartChoice(&id, &m)) {
// 若无可用节点,快速失败
*out_json = SerializeJson(err_res);
return;
}
// 2. 初始化 HTTP 客户端并设置超时(防止主服务线程被长期阻塞)
httplib::Client cli(m->ip, m->port);
cli.set_connection_timeout(1); // 1秒连接超时
cli.set_read_timeout(5); // 5秒读取超时
cli.set_write_timeout(2); // 2秒写入超时
bool request_success = false;
int retry_count = 0;
// 3. 简单的失败重试机制(最多重试 3 次)
while (retry_count < 3) {
m->IncLoad(); // 请求前增加该机器的负载计数
// 发起 POST 请求调用编译与运行接口
auto res = cli.Post("/compile_and_run", compile_string, "application/json;charset=utf-8");
m->DecLoad(); // 请求结束后释放负载计数
if (res && res->status == 200) {
request_success = true;
// 请求成功,跳出重试循环,解析返回的 JSON 数据
break;
}
// 请求失败或超时,增加重试次数
retry_count++;
}
5.2 数据库与缓存技术
MySQL 核心存储
MySQL 8.0 作为系统的持久化底座,遵循第三范式(3NF)设计。
- 存储内容:用户信息(密码 SHA256 哈希)、题目题干与测试用例限制、用户的提交记录、讨论区帖子、以及训练题单等。
- 访问方式:通过 C++ 原生的 MySQL C API 进行连接与查询,每次 HTTP 请求到来时获取连接,请求结束释放。
Redis 缓存与队列(可选扩展)
为了进一步提升系统的吞吐量与可用性,架构中预留了 Redis 组件:
- 热点缓存:缓存首页的排行榜、高频访问的题目详情。
- 会话状态:实现分布式的 Session 存储,替代目前的内存 Session,使主服务变为真正的无状态(Stateless)服务。
数据库选型对比
| 业务场景 | MySQL (当前实现) | Redis (演进补充) |
|---|---|---|
| AC 记录/题目修改 (强一致事务) | 强,支持完整的 ACID 事务 | 弱,需通过 Lua 脚本或队列辅助 |
| 多表关联查询 (如分页查提交记录) | 强,索引优化后性能稳定 | 弱,不擅长复杂关系过滤 |
| 热点题目高频读 | 容易遇到 I/O 瓶颈,需加缓存 | 强,纯内存读写,极低延迟 |
5.3 API 设计与协议约束
前后端及服务间通信均遵循了标准化的约束,确保解耦和可维护性。
- RESTful 风格 :资源导向的 API 设计(例如
GET /api/questions,POST /api/discussion)。 - 统一 JSON 结构 :所有 API 请求和响应的主体必须是 JSON。标准的响应体包含:
status: 错误码(0成功,非0为业务/系统异常)。reason: 错误描述(前端可直接向用户展示的友好提示)。data: 实际业务数据载荷。
- 认证方式 :基于 Cookie 的 Session 会话机制(
HttpOnly属性防御 XSS 获取 Cookie)。
内部微服务调用时序图
主服务向编译服务分发任务的过程如下所示:
编译服务 (Compile Server) 负载均衡模块 (LoadBalance) 主服务 (OJ Server) 编译服务 (Compile Server) 负载均衡模块 (LoadBalance) 主服务 (OJ Server) alt [请求超时或失败] 请求 SmartChoice() 获取最优节点 返回节点 IP:Port (如 127.0.0.1:8081) POST /compile_and_run (带超时控制) 编译代码 沙箱运行代码并采集资源消耗 返回判题执行结果 (JSON) 再次重试 POST (最多3次) 返回成功
通过这一套经过深思熟虑的技术栈组合,我们不仅保证了系统的极致性能,还使其拥有了出色的水平扩展能力。
第6章 数据库设计与优化
本章节将详细拆解 Load-Balanced Online OJ 系统的底层数据模型设计。为了保证系统的可扩展性与查询效率,我们的 MySQL 数据库设计严格遵循关系型数据库的范式。通过以下两张实体关系(ER)图,我们将数据域划分为"核心评测域"与"社区与题单域",并在此基础上分析高频查询场景的索引设计。
6.1 核心评测域 ER 图
核心评测域是判题系统的心脏,承载了用户管理、题目元数据以及所有的代码提交记录。这部分的表结构设计直接决定了判题流程的 I/O 效率。
发起提交 (submits)
被评测 (judged_on)
users
INT
id
PK
自增主键
VARCHAR
username
唯一用户名
VARCHAR
password
SHA256哈希密码
INT
role
角色 (0普通, 1管理)
TIMESTAMP
created_at
注册时间
submissions
INT
id
PK
自增主键
INT
user_id
FK
关联 users.id
INT
question_id
FK
关联 oj_questions.number
VARCHAR
result
最终结果(如 AC, WA, TLE)
INT
cpu_time
实际消耗时间(ms)
INT
mem_usage
实际消耗内存(KB)
TEXT
content
用户提交的源码
VARCHAR
language
编程语言(cpp/java)
TIMESTAMP
created_at
提交时间
oj_questions
INT
number
PK
题目编号 (如 1, 2, 3)
VARCHAR
title
题目标题
VARCHAR
star
难度 (简单/中等/困难)
INT
cpu_limit
CPU时间限制(秒)
INT
mem_limit
内存限制(KB)
TEXT
description
Markdown 题干
TEXT
tail
隐式后置代码(可选)
6.2 社区与题单域 ER 图
除了基础的做题功能,为了增强平台的用户黏性与学习体验,我们设计了讨论区(支持行内评论与文章评论)以及训练题单模块。
发布 (writes)
包含文章评论
包含行内评论
发表 (writes)
发表 (writes)
创建题单
包含条目
被收录
users
discussions
INT
id
PK
VARCHAR
title
帖子标题
TEXT
content
Markdown 内容
INT
author_id
FK
作者
INT
question_id
关联题目ID(0为全局帖子)
INT
views
浏览量
article_comments
INT
id
PK
INT
post_id
FK
所属帖子
INT
user_id
FK
评论者
TEXT
content
评论内容
TIMESTAMP
created_at
inline_comments
INT
id
PK
INT
post_id
FK
所属帖子
INT
user_id
FK
评论者
INT
parent_id
父评论ID(支持盖楼)
TEXT
selected_text
被选中的Markdown文本
TEXT
content
评论内容
training_lists
INT
id
PK
VARCHAR
title
题单标题
INT
author_id
FK
创建者
TEXT
tags
分类标签
TIMESTAMP
created_at
training_list_items
INT
id
PK
INT
training_list_id
FK
所属题单
INT
question_id
FK
关联题目
INT
order_index
拖拽排序序号
oj_questions
6.3 表字段设计要点对照表
为了保证系统在大数据量下的稳定性,我们在建表时对特定字段施加了严格的约束与索引策略:
| 表名 | 关键字段 | 设计要点与约束 | 优化目的 |
|---|---|---|---|
users |
username |
设置为 UNIQUE INDEX |
保证注册时不出现重名,加速登录时的全表检索。 |
users |
password |
定长 64 字符(存储 SHA256) | 不存储明文,即使数据库脱库也无法被轻易撞库破解。 |
submissions |
user_id + question_id |
建立联合索引 idx_user_question |
高频查询场景:"查询某用户在某题的提交记录"。 |
discussions |
question_id |
建立普通索引 | 当用户在题目详情页点击"题解"时,需快速过滤该题关联的帖子。 |
training_list_items |
order_index |
整数类型,由前端拖拽插件生成 | 保证题单中的题目按照用户自定义的逻辑顺序(而非主键)稳定展示。 |
6.4 典型高频查询 SQL 示例
以下是系统中几个最核心的业务查询场景及其原生 SQL 实现逻辑。
场景一:获取用户的提交记录分页列表
在用户个人主页,我们需要按时间倒序展示该用户的提交历史,并支持分页。
sql
-- 假设每页 20 条,获取第 2 页 (OFFSET 20)
-- 依赖 idx_user_id 索引,同时由于主键 id 是自增的,通常与时间强相关,
-- 在某些优化下可以利用主键进行分页(即游标分页)以避免深度分页导致的性能抖动。
SELECT id, question_id, result, cpu_time, mem_usage, language, created_at
FROM submissions
WHERE user_id = ?
ORDER BY id DESC
LIMIT 20 OFFSET 20;
场景二:查询某题目下的所有题解(帖子)
当用户进入某道题目的"题解区"时,我们需要拉取关联该题的所有讨论。
sql
-- 这里通过 INNER JOIN 获取帖子的详细信息以及作者的用户名
SELECT d.id, d.title, u.username as author, d.views, d.created_at
FROM discussions d
INNER JOIN users u ON d.author_id = u.id
WHERE d.question_id = ?
ORDER BY d.created_at DESC;
场景三:获取题单中的题目列表(按拖拽顺序)
题单详情页需要展示被收录的题目,并且必须严格按照用户拖拽设定的 order_index 进行排序。
sql
SELECT q.number, q.title, q.star, tli.order_index
FROM training_list_items tli
INNER JOIN oj_questions q ON tli.question_id = q.number
WHERE tli.training_list_id = ?
ORDER BY tli.order_index ASC, tli.id ASC;
良好的底层数据模型是整个后端系统的基石。通过合理切分业务域并提前规划索引,我们确保了系统在并发提交与海量日志查询时的稳定性。
第三部分:环境搭建与部署指南
第8章 系统环境要求
在开始搭建在线评测系统之前,我们需要确保系统环境满足以下要求:
| 环境 | 版本要求 | 说明 |
|---|---|---|
| 操作系统 | Ubuntu 20.04 LTS 或 macOS 10.15+ | 推荐使用 Linux 系统,因为沙箱机制在 Linux 下更稳定 |
| C++ 编译器 | g++ 9.0+ | 用于编译 C++ 代码 |
| Java 编译器 | javac 8.0+ | 用于编译 Java 代码 |
| MySQL | 8.0+ | 用于存储数据 |
| Docker | 20.10+ | 用于容器化部署 |
| Docker Compose | 1.29+ | 用于管理多容器应用 |
| Git | 2.0+ | 用于代码版本控制 |
第9章 依赖安装步骤
9.1 Ubuntu 系统依赖安装
bash
# 更新系统包
sudo apt update && sudo apt upgrade -y
# 安装 C++ 编译器和构建工具
sudo apt install build-essential -y
# 安装 Java 开发工具包
sudo apt install openjdk-11-jdk -y
# 安装 MySQL
sudo apt install mysql-server -y
# 安装 Docker
sudo apt install docker.io -y
# 安装 Docker Compose
sudo apt install docker-compose -y
# 安装 Git
sudo apt install git -y
# 安装其他依赖
sudo apt install libmysqlclient-dev libjsoncpp-dev -y
9.2 macOS 系统依赖安装
bash
# 安装 Homebrew(如果尚未安装)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 安装 C++ 编译器和构建工具
brew install gcc
# 安装 Java 开发工具包
brew install openjdk@11
# 安装 MySQL
brew install mysql
# 安装 Docker
brew install --cask docker
# 安装 Git
brew install git
# 安装其他依赖
brew install jsoncpp
第10章 服务启动与配置
10.1 系统架构流程图
为了帮助读者更直观地理解整个系统的组件和流程,以下是系统架构的流程图:
数据层
服务层
用户层
HTTP请求
负载均衡
负载均衡
负载均衡
读写数据
缓存数据
返回结果
返回结果
返回结果
返回响应
用户浏览器
OJ主服务器
端口8096
编译服务器1
端口8081
编译服务器2
端口8082
编译服务器3
端口8083
MySQL数据库
Redis缓存
10.2 克隆项目代码
bash
# 克隆项目仓库
git clone https://github.com/yourusername/load-balanced-online-oj.git
# 进入项目目录
cd load-balanced-online-oj
10.2 配置 MySQL 数据库
bash
# 启动 MySQL 服务
sudo systemctl start mysql # Ubuntu
brew services start mysql # macOS
# 登录 MySQL 并创建数据库
mysql -u root -p
# 在 MySQL 命令行中执行以下命令
CREATE DATABASE online_oj;
CREATE USER 'oj_user'@'localhost' IDENTIFIED BY 'oj_password';
GRANT ALL PRIVILEGES ON online_oj.* TO 'oj_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
10.3 配置环境变量
创建 .env 文件并添加以下内容:
env
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_NAME=online_oj
DB_USER=oj_user
DB_PASSWORD=oj_password
# 服务配置
OJ_SERVER_PORT=8096
COMPILE_SERVER_PORT_1=8081
COMPILE_SERVER_PORT_2=8082
COMPILE_SERVER_PORT_3=8083
# 安全配置
SECRET_KEY=your_secret_key_here
10.4 构建和启动服务
以下是部署流程的可视化图示:
克隆项目代码
安装系统依赖
配置 MySQL 数据库
创建 .env 配置文件
构建项目
启动 Docker 容器
初始化数据库
验证服务状态
访问系统
执行以下命令构建和启动服务:
bash
# 构建项目
make
# 使用 Docker Compose 启动服务
docker-compose up -d
# 查看服务状态
docker-compose ps
10.5 初始化数据库
bash
# 运行数据库初始化脚本
mysql -u oj_user -p online_oj < sql/init.sql
第11章 常见问题与故障排除
11.1 服务启动失败
-
问题 :编译服务器启动失败
解决方案 :检查端口是否被占用,确保 8081-8083 端口未被其他服务使用。可以使用lsof -i :8081命令查看端口占用情况。 -
问题 :MySQL 连接失败
解决方案 :检查数据库配置是否正确,确保 MySQL 服务正在运行。可以使用systemctl status mysql(Ubuntu)或brew services list(macOS)查看 MySQL 服务状态。 -
问题 :Docker 容器启动失败
解决方案 :查看 Docker 日志,使用docker logs <容器名称>命令查看具体错误信息。确保 Docker 服务正在运行,并且系统资源充足。 -
问题 :Make 构建失败
解决方案:检查依赖是否安装完整,确保所有必要的开发库都已安装。查看构建日志中的错误信息,针对性地解决依赖问题。
11.2 判题功能异常
-
问题 :提交代码后无响应
解决方案 :检查编译服务器是否正常运行,查看服务日志。可以使用docker-compose logs oj-server查看主服务日志。 -
问题 :判题结果不正确
解决方案:检查测试用例配置是否正确,确保沙箱环境配置合理。验证测试用例的输入和预期输出是否匹配。 -
问题 :编译错误但代码在本地可以正常编译
解决方案:检查编译服务器的编译器版本是否与本地一致,确保编译环境配置正确。
11.3 性能问题
-
问题 :系统响应缓慢
解决方案 :增加编译服务器数量,优化数据库查询,考虑使用 Redis 缓存。可以通过修改docker-compose.yml文件来增加编译服务器的数量。 -
问题 :判题速度慢
解决方案:检查编译服务器的资源使用情况,确保 CPU 和内存资源充足。可以考虑在更高配置的机器上部署编译服务器。
11.4 安全问题
-
问题 :沙箱隔离失败
解决方案 :确保编译服务器以非 root 用户运行,检查setrlimit配置是否正确。在生产环境中,建议使用 Docker 容器或更高级的沙箱技术。 -
问题 :SQL 注入风险
解决方案:确保所有数据库查询使用参数化查询,避免直接拼接 SQL 语句。
11.5 其他常见问题
-
问题 :无法访问系统
解决方案:检查 OJ 主服务器是否在 8096 端口正常运行,确保防火墙没有阻止该端口的访问。 -
问题 :用户注册失败
解决方案:检查数据库连接是否正常,确保用户表结构正确,并且用户名没有重复。 -
问题 :题目上传失败
解决方案:检查文件权限是否正确,确保上传目录存在并且有写入权限。
第12章 关键技术实现细节(核心代码拆解)
本章节深入到系统的核心代码层,通过抽取真实项目中的关键代码片段,为您展示负载均衡调度、沙箱资源控制、多语言编译等机制的具体实现。每一段代码我们都会提供详细的背景说明与逐行注释,帮助您理解代码背后的设计哲学。
12.1 负载均衡:最小负载 + 同负载随机
当用户提交代码时,主服务需要选择一台压力最小的编译服务器。如果只选择"当前负载最小的机器",在极高并发下可能会导致这台机器瞬间被"打爆"。因此,我们设计了"同负载随机"的退避策略,保证了请求的均匀散列。
cpp
/**
* SmartChoice:智能负载均衡选择算法
* 该函数位于主服务 LoadBalance 模块,负责从所有在线的编译服务器中,
* 挑选出一台当前处理任务数(Load)最少的机器。如果有多台机器的负载
* 都是最小的,则在这些机器中随机挑选一台,防止"羊群效应"。
*/
bool SmartChoice(int *id, Machine **m)
{
// 1. 加锁保护,防止在遍历过程中有机器突然上线或下线
// 锁是一种同步机制,确保同一时间只有一个线程能访问共享资源
mtx.lock();
// 获取在线机器数量
int online_num = online.size();
// 检查是否有在线机器
if (online_num == 0)
{
mtx.unlock(); // 解锁
LOG(FATAL) << " 所有的后端编译主机已经离线, 请运维的同事尽快查看\n";
return false;
}
// 2. 找到所有负载最小的机器,使用数组记录这些主机的下标
// 初始化时假设第一台在线机器负载最小
uint64_t min_load = machines[online[0]].Load();
std::vector<int> min_load_machines; // 存储负载最小的机器列表
min_load_machines.push_back(online[0]);
// 3. 遍历其余机器进行对比
for (int i = 1; i < online_num; i++)
{
// 获取当前机器的负载
uint64_t curr_load = machines[online[i]].Load();
// 如果当前机器负载更小
if (curr_load < min_load)
{
// 更新最小负载值
min_load = curr_load;
// 清空之前的记录,因为找到了更小的负载
min_load_machines.clear();
// 将当前机器添加到候选列表
min_load_machines.push_back(online[i]);
}
// 如果当前机器负载与最小负载相同
else if (curr_load == min_load)
{
// 将当前机器追加到候选数组中
min_load_machines.push_back(online[i]);
}
}
// 4. 如果有多个最小负载相同的机器,随机选一个以避免并发请求聚集在同一台机器
// 生成一个随机索引
int random_idx = rand() % min_load_machines.size();
// 获取选中的机器ID
*id = min_load_machines[random_idx];
// 获取选中的机器指针
*m = &machines[*id];
// 5. 解锁并返回成功
mtx.unlock();
return true;
}
12.2 主服务调用编译服务:超时与重试降级
在分布式系统中,网络抖动或节点假死是常态。为了防止主服务的线程池被缓慢的编译节点耗尽,我们在 HTTP 调用时设置了严格的超时控制,并增加了请求级的重试逻辑。
cpp
/**
* 带有超时控制和重试机制的 HTTP 任务分发
* 位于主服务的 oj_control.hpp 中,一旦通过 SmartChoice 选定机器后,
* 主服务通过 httplib 发送 JSON 数据给该编译节点,并妥善处理超时失败。
*/
// 初始化针对该选定机器的 HTTP 客户端
// m 是通过 SmartChoice 算法选择的编译服务器
httplib::Client cli(m->ip, m->port);
// 1. 强制设置连接、读、写超时(单位:秒)
// 这是防止主服务因为目标节点卡死而发生雪崩的关键防御手段
// 连接超时:1秒 - 连接到服务器的最大时间
cli.set_connection_timeout(1);
// 读取超时:5秒 - 从服务器读取响应的最大时间
cli.set_read_timeout(5);
// 写入超时:2秒 - 向服务器发送数据的最大时间
cli.set_write_timeout(2);
// 标记请求是否成功
bool request_success = false;
// 重试计数器
int retry_count = 0;
// 2. 引入简单的重试机制,最多允许重试 3 次
while (retry_count < 3) {
// 请求前,手动将该机器的负载计数 +1
// 这样可以确保负载均衡算法能够准确反映各机器的当前负载
m->IncLoad();
// 发起阻塞的 POST 请求,将代码和测试用例发给编译节点
// compile_string 是包含用户代码、测试用例等信息的 JSON 字符串
auto res = cli.Post("/compile_and_run", compile_string, "application/json;charset=utf-8");
// 请求结束(无论成功或超时失败),将负载计数 -1
m->DecLoad();
// 3. 判断是否成功收到 200 响应
// res 不为空且状态码为 200 表示请求成功
if (res && res->status == 200) {
request_success = true;
break; // 成功则跳出重试循环
}
// 失败则增加重试次数
retry_count++;
// 可选:添加重试间隔,避免立即重试造成的网络拥塞
// std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
12.3 判题沙箱:资源限制 (setrlimit)
在编译服务器中,用户的代码是通过 fork 出的子进程运行的。为了防止恶意死循环(消耗 CPU)、恶意申请巨大数组(消耗内存)或 Fork 炸弹(耗尽进程池),我们利用 POSIX 的 setrlimit 接口对子进程进行了极其严格的物理约束。
cpp
/**
* SetProcLimit:为判题子进程设置严苛的资源上限
* 该函数在 fork 之后、exec 之前调用。通过修改系统内核级别的
* 进程资源限制 (Resource Limits),确保恶意代码无法拖垮宿主机。
*/
static void SetProcLimit(int _cpu_limit, int _mem_limit)
{
// 1. 限制 CPU 运行时间(单位:秒)
// 一旦程序运行时间超过该值,内核会向其发送 SIGXCPU 或 SIGKILL 信号
struct rlimit cpu_rlimit;
cpu_rlimit.rlim_max = RLIM_INFINITY; // 硬限制保持无穷大(由于我们没有 root 权限去修改硬限制)
cpu_rlimit.rlim_cur = _cpu_limit; // 软限制设置为题目要求的最大秒数
setrlimit(RLIMIT_CPU, &cpu_rlimit);
// 2. 限制最大虚拟内存地址空间(单位:字节)
// 题目配置的 _mem_limit 单位通常为 KB,因此需要乘以 1024
struct rlimit mem_rlimit;
mem_rlimit.rlim_max = RLIM_INFINITY;
mem_rlimit.rlim_cur = _mem_limit * 1024;
setrlimit(RLIMIT_AS, &mem_rlimit);
// 3. 安全防御:限制最大进程数,防止 Fork 炸弹
// 将进程数限制在合理范围,既允许必要的线程(如 Java 的 GC 线程),又防止恶意 fork
struct rlimit nproc_rlimit;
nproc_rlimit.rlim_max = 200;
nproc_rlimit.rlim_cur = 200;
setrlimit(RLIMIT_NPROC, &nproc_rlimit);
}
12.4 子进程状态监控与结果提取
沙箱执行完毕后,父进程(编译服务本身)需要知道子进程是怎么死的:是正常退出?还是被系统强杀了?我们通过 waitpid (或 wait4) 获取进程的终止信号,从而判断是 TLE、MLE 还是 RE。
cpp
/**
* 父进程监控子进程执行状态的核心逻辑
* 通过分析 status 变量的二进制位,我们可以精准地知道用户的代码发生了什么异常,
* 从而反馈出精准的判题结果(如 TLE, MLE, RE)。
*/
int status = 0;
struct rusage ru;
// 1. 阻塞等待子进程退出,并收集子进程的资源使用统计信息(存入 ru 中)
wait4(pid, &status, 0, &ru);
// 2. 分析子进程退出原因
if (WIFEXITED(status)) {
// WIFEXITED 为真,表示程序是正常调用 exit() 退出的
// 但并不意味着答案正确,只是没有发生崩溃
int exit_code = WEXITSTATUS(status);
if (exit_code == 0) {
// 运行成功,记录实际消耗的内存 (ru_maxrss)
// 注意:macOS 和 Linux 上 ru_maxrss 的单位存在差异(macOS 为 byte,Linux 为 KB)
result_json["mem_usage"] = ru.ru_maxrss;
}
}
else if (WIFSIGNALED(status)) {
// WIFSIGNALED 为真,表示程序是因为收到了操作系统的信号而被强杀的
int sig = WTERMSIG(status);
if (sig == SIGXCPU || sig == SIGKILL) {
// 因 CPU 超时被杀 -> Time Limit Exceeded (TLE)
result_json["status"] = -3;
}
else if (sig == SIGABRT || sig == SIGSEGV) {
// 因段错误或内存分配失败 abort -> Runtime Error 或 Memory Limit Exceeded
result_json["status"] = -2;
}
else if (sig == SIGFPE) {
// 浮点异常(如除以 0)
result_json["status"] = -2;
}
}
12.5 爬虫模块:第三方题库数据同步
为了丰富本平台的题库,我们编写了独立的 C++ 爬虫程序,定期从公开题库抓取数据并同步至 MySQL 中。爬虫模块使用了 curl 命令行调用作为 fallback 以解决复杂的 SSL 证书校验问题。
cpp
/**
* 爬虫数据抓取辅助函数
* 当 cpp-httplib 处理某些网站的 HTTPS/SSL 握手遇到兼容性问题时,
* 我们选择通过 popen 借用操作系统的 curl 命令进行可靠的数据抓取。
*/
std::string fetch_url_via_curl(const std::string& url) {
std::string result;
char buffer[128];
// 1. 拼接 curl 命令,-s 参数表示静默模式,防止输出进度条污染数据
// 使用双引号包裹 URL 防止 shell 注入风险
std::string cmd = "curl -s \"" + url + "\"";
// 2. 使用 popen 打开一个进程来执行 curl 命令,并读取其标准输出
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) {
std::cerr << "popen() failed!" << std::endl;
return "";
}
// 3. 循环读取管道中的数据到 result 字符串中
while (fgets(buffer, 128, pipe) != NULL) {
result += buffer;
}
// 4. 关闭管道并回收子进程
pclose(pipe);
return result;
}
12.6 段内评论系统:精确的文本选择评论机制
段内评论系统是本平台的创新功能之一,它允许用户选中任意文本段落进行精准评论,实现了类似现代文档协作工具的交互体验。与传统的整页评论不同,段内评论能够精确定位到具体的文本内容,为用户提供更加细致的讨论空间。
核心设计思路:
- 基于文本选择的精确评论定位
- 支持选中任意长度的文本段落
- 评论内容与原文本段落关联存储
- 实时高亮显示评论区域
- 支持评论回复与盖楼机制
后端实现架构:
cpp
/**
* 段内评论数据模型设计
* 位于 oj_model.hpp 中,定义了段内评论的核心数据结构
*/
struct InlineComment {
std::string id; // 评论唯一标识
std::string user_id; // 评论者ID
std::string username; // 评论者用户名
std::string user_avatar; // 评论者头像
std::string post_id; // 关联的文章/题目ID
std::string content; // 评论内容
std::string selected_text; // 选中的文本内容
std::string parent_id; // 父评论ID(支持回复)
std::string created_at; // 创建时间
};
/**
* 段内评论API实现
* 位于 oj_control.hpp 中,提供评论的增删改查功能
*/
bool AddInlineComment(const std::string &user_id, const std::string &post_id,
const std::string &content, const std::string &selected_text,
const std::string &parent_id, std::string *json_out)
{
InlineComment c;
c.user_id = user_id;
c.post_id = post_id;
c.content = content;
c.selected_text = selected_text;
c.parent_id = parent_id;
if (model_.AddInlineComment(c)) {
Json::Value res;
res["status"] = 0;
res["reason"] = "Success";
*json_out = SerializeJson(res);
return true;
} else {
Json::Value res;
res["status"] = 1;
res["reason"] = "Database Error";
*json_out = SerializeJson(res);
return false;
}
}
前端交互实现:
javascript
/**
* 段内评论前端核心逻辑
* 集成在 discussion.html 中,提供文本选择、评论弹窗、高亮显示等功能
*/
// 文本选择事件监听
document.addEventListener('selectionchange', function() {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
// 显示评论工具提示
showInlineCommentTooltip(selection);
currentSelectionText = selectedText;
} else {
// 隐藏工具提示
hideInlineCommentTooltip();
currentSelectionText = "";
}
});
// 显示评论弹窗
function showInlineCommentModal() {
if (!currentSelectionText) return;
document.getElementById('inline-comment-selected-text').innerText = currentSelectionText;
document.getElementById('inline-comment-modal').style.display = 'flex';
document.getElementById('inline-comment-tooltip').style.display = 'none';
// 重置输入框
document.getElementById('inline-comment-input').value = "";
}
// 提交段内评论
async function submitInlineComment() {
const content = document.getElementById('inline-comment-input').value;
if (!content) return;
try {
const response = await fetch('/api/inline_comment/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
post_id: currentPostId,
content: content,
selected_text: currentSelectionText,
parent_id: ""
})
});
const result = await response.json();
if (result.status === 0) {
// 成功提交后刷新评论列表
loadInlineComments();
closeInlineCommentModal();
showNotification('评论发布成功');
} else {
showNotification('评论发布失败: ' + result.reason, 'error');
}
} catch (error) {
console.error('提交评论失败:', error);
showNotification('网络错误,请重试', 'error');
}
}
技术亮点:
- 精确文本定位:通过记录用户选中的文本内容,实现段落级别的精确评论
- 实时高亮显示:使用CSS样式动态高亮显示包含评论的文本段落
- 抽屉式交互:采用模态框设计,提供沉浸式的评论体验
- 多级回复支持:通过parent_id字段支持评论的嵌套回复
- 性能优化:评论数据按需加载,避免一次性加载大量评论数据
7.7 AI智能提示系统:基于DeepSeek的智能代码分析
AI智能提示系统是本平台的技术亮点之一,它集成了DeepSeek大语言模型API,能够分析用户的代码、题目描述和错误信息,提供有针对性的编程指导。系统特别注重教育性,避免直接给出完整答案,而是专注于思路引导和错误分析。
核心功能特性:
- 基于DeepSeek API的智能分析
- 代码错误分析与修复建议
- 算法思路指导
- 多语言代码优化建议
- 中文智能回复支持
后端AI服务实现:
cpp
/**
* DeepSeek API集成模块
* 位于 deepseek_api.hpp 中,负责与DeepSeek大语言模型API的通信
*/
class DeepSeekAPI {
private:
std::string api_key_; // API密钥
std::string api_url_; // API接口地址
int max_tokens_; // 最大token数量
float temperature_; // 温度参数
public:
/**
* 生成智能提示的核心方法
* 分析题目描述、用户代码和错误信息,提供针对性的修复建议
*/
bool GenerateHint(const std::string& problem_desc,
const std::string& user_code,
const std::string& error_msg,
const std::string& test_cases,
std::string* out_hint) {
// 构建系统提示词
Json::Value messages(Json::arrayValue);
Json::Value system_msg;
system_msg["role"] = "system";
system_msg["content"] = "你是一个编程在线评测系统(Online Judge)的智能助手。"
"你的任务是根据用户的代码、题目描述和报错信息,提供有帮助的提示。"
"请不要直接给出完整的正确代码,而是解释错误原因并建议如何修复。"
"请使用Markdown格式回答。"
"如果是编译错误,请解释编译器输出的含义。"
"如果是逻辑错误(Wrong Answer),请尝试找出逻辑漏洞。"
"请务必使用中文回答。";
messages.append(system_msg);
// 构建用户输入内容
std::string user_content = "Problem Description:\n" + problem_desc + "\n\n";
user_content += "User Code:\n```\n" + user_code + "\n```\n\n";
user_content += "Error Message/Test Result:\n" + error_msg + "\n\n";
if (!test_cases.empty()) {
user_content += "Test Cases:\n" + test_cases + "\n\n";
}
user_content += "请分析上述代码的问题,并给出修复建议。";
Json::Value user_msg;
user_msg["role"] = "user";
user_msg["content"] = user_content;
messages.append(user_msg);
// 调用DeepSeek API
return CallAPI(messages, out_hint);
}
private:
/**
* 调用DeepSeek API的具体实现
* 使用httplib进行HTTP请求,支持流式响应
*/
bool CallAPI(const Json::Value& messages, std::string* out_hint) {
httplib::Client cli(api_url_.c_str());
cli.set_connection_timeout(10);
cli.set_read_timeout(30); // AI响应可能需要较长时间
// 构建请求体
Json::Value request_body;
request_body["model"] = "deepseek-chat";
request_body["messages"] = messages;
request_body["max_tokens"] = max_tokens_;
request_body["temperature"] = temperature_;
request_body["stream"] = false; // 使用非流式响应
std::string request_str = SerializeJson(request_body);
// 设置请求头
httplib::Headers headers = {
{"Authorization", "Bearer " + api_key_},
{"Content-Type", "application/json"}
};
// 发送请求
auto res = cli.Post("/v1/chat/completions", headers, request_str, "application/json");
if (res && res->status == 200) {
// 解析响应
Json::Value response_json;
Json::Reader reader;
if (reader.parse(res->body, response_json)) {
if (response_json.isMember("choices") &&
response_json["choices"].isArray() &&
response_json["choices"].size() > 0) {
*out_hint = response_json["choices"][0]["message"]["content"].asString();
return true;
}
}
}
return false;
}
};
前端AI提示界面:
javascript
/**
* AI智能提示前端实现
* 集成在 one_question.html 中,提供智能提示的交互界面
*/
// 获取AI智能提示
function getAiHint() {
// 切换到AI提示标签页
switchResultTab('ai');
var container = $("#ai-hint-container");
var btn = $("#btn-ai-hint");
// 显示加载状态
btn.prop("disabled", true).html('<svg class="animate-spin" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"></path></svg> 思考中...');
container.html('<div style="padding:20px; text-align:center; color:var(--text-secondary);"><div style="margin-bottom:10px;">DeepSeek AI 正在分析您的代码...</div><div style="font-size:12px;">可能需要几秒钟时间</div></div>');
// 获取当前代码和题目信息
var editor = ace.edit("code");
var code = editor.getSession().getValue();
var problemDesc = document.getElementById('problem-desc-raw').value;
var errorMsg = getLatestErrorMessage(); // 获取最新的错误信息
var testCases = getTestCasesInfo(); // 获取测试用例信息
// 发送AI提示请求
$.ajax({
url: "/api/ai_hint",
type: "POST",
dataType: "json",
contentType: "application/json",
data: JSON.stringify({
'problem_desc': problemDesc,
'code': code,
'error_msg': errorMsg,
'test_cases': testCases
}),
success: function(data) {
// 恢复按钮状态
btn.prop("disabled", false).html('<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg> AI智能提示');
if (data.status === 0) {
// 解析Markdown格式的AI回复
var html = marked.parse(data.hint);
container.html('<div style="color:#2cbb5d; font-weight:bold; margin-bottom:10px; border-bottom:1px solid #444; padding-bottom:5px;">DeepSeek AI 分析结果:</div>' + html);
// 对代码块进行语法高亮
container.find("pre code").each(function(i, block) {
hljs.highlightBlock(block);
});
} else {
container.html('<div style="color:#ff4d4f;">AI提示生成失败: ' + (data.reason || "未知错误") + '</div>');
}
},
error: function() {
btn.prop("disabled", false).html('<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg> AI智能提示');
container.html('<div style="color:#ff4d4f;">网络请求失败</div>');
}
});
}
// 智能显示AI提示按钮
function shouldShowAiHint(status, stdout) {
// 如果代码运行失败,或者测试用例未全部通过,显示AI提示按钮
if (status !== 0) return true;
try {
var parsed = JSON.parse(stdout);
if (parsed && parsed.summary && parsed.summary.passed !== parsed.summary.total) return true;
if (parsed && parsed.cases) {
var allPassed = parsed.cases.every(function(c) { return c.pass; });
if (!allPassed) return true;
}
} catch(e) {}
return false;
}
智能提示触发机制:
- 编译错误:分析编译器输出,解释错误含义
- 运行时错误:识别段错误、浮点异常等运行时问题
- 逻辑错误:分析测试用例失败原因,指出逻辑漏洞
- 性能问题:识别潜在的性能瓶颈,提供优化建议
教育性设计原则:
- 避免直接给出完整答案
- 注重编程思路的引导
- 提供相关知识点链接
- 鼓励用户独立思考和解决问题
7.8 代码高亮与语法检测:多语言编辑器支持
代码高亮系统是提升用户编程体验的重要组成部分。我们集成了业界成熟的代码高亮库,为C++、Java、Python等多种编程语言提供专业的语法高亮支持,同时支持行号显示、主题适配等高级功能。
技术选型与集成:
- Highlight.js:轻量级、高性能的语法高亮库
- 多语言支持:自动识别代码语言类型
- 主题适配:支持深色主题下的高亮优化
- 行号显示:增强代码可读性
前端集成实现:
javascript
/**
* 代码高亮系统集成
* 在 discussion.html 和 one_question.html 中广泛使用
*/
// 初始化代码高亮
function initializeCodeHighlighting() {
// 配置Highlight.js
hljs.configure({
languages: ['cpp', 'java', 'python', 'javascript', 'sql'],
tabReplace: ' ', // 将tab替换为4个空格
useBR: false // 不使用<br>标签
});
// 高亮所有代码块
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightBlock(block);
// 添加行号支持
addLineNumbers(block);
});
}
// 添加行号功能
function addLineNumbers(block) {
// 获取代码行数
const lines = block.textContent.split('\n').length;
// 创建行号容器
const lineNumbers = document.createElement('div');
lineNumbers.className = 'line-numbers';
lineNumbers.setAttribute('aria-hidden', 'true');
// 生成行号
for (let i = 1; i <= lines; i++) {
const lineNumber = document.createElement('span');
lineNumber.textContent = i;
lineNumbers.appendChild(lineNumber);
}
// 添加行号样式
const pre = block.parentNode;
pre.classList.add('line-numbers-wrapper');
pre.insertBefore(lineNumbers, block);
}
// 在代码编辑器中使用高亮
function setupCodeEditor() {
// 初始化ACE编辑器
const editor = ace.edit("code");
// 设置主题
editor.setTheme("ace/theme/monokai");
// 设置编程语言模式
const language = document.getElementById('language-select').value;
switch (language) {
case 'C++':
editor.session.setMode("ace/mode/c_cpp");
break;
case 'Java':
editor.session.setMode("ace/mode/java");
break;
case 'Python':
editor.session.setMode("ace/mode/python");
break;
}
// 配置编辑器选项
editor.setOptions({
fontSize: "14px",
showLineNumbers: true,
showPrintMargin: false,
highlightActiveLine: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true
});
}
CSS样式优化:
css
/**
* 代码高亮样式定义
* 位于 one_question.css 和 discussion.css 中
*/
/* 代码块基础样式 */
.markdown-body pre {
position: relative;
background: var(--code-bg);
border-radius: 6px;
padding: 16px;
overflow-x: auto;
margin: 16px 0;
border: 1px solid var(--border-color);
}
/* 深色主题下的高亮优化 */
[data-theme="dark"] .markdown-body pre {
background: #1e1e1e;
border-color: #3a3a3a;
}
/* 行号样式 */
.line-numbers {
position: absolute;
left: 0;
top: 16px;
width: 40px;
text-align: right;
color: #6a737d;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.5;
user-select: none;
pointer-events: none;
}
.line-numbers span {
display: block;
padding-right: 12px;
}
/* 代码内容样式 */
.markdown-body pre code {
display: block;
padding-left: 50px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.5;
color: #e6edf3;
background: none;
border: none;
}
/* Highlight.js 语法高亮颜色定制 */
.hljs-keyword { color: #ff7b72; }
.hljs-string { color: #a5d6ff; }
.hljs-number { color: #79c0ff; }
.hljs-comment { color: #8b949e; font-style: italic; }
.hljs-function { color: #d2a8ff; }
.hljs-class { color: #ffa657; }
.hljs-variable { color: #ffa657; }
性能优化策略:
- 按需加载:只在需要时加载高亮库,减少初始加载时间
- 缓存机制:避免重复高亮相同的代码块
- 异步处理:大量代码块时使用异步方式处理,避免阻塞UI
- 虚拟滚动:在长列表中只高亮可见区域的代码
7.9 实时通知系统:友好的用户反馈机制
实时通知系统是提升用户体验的重要组成部分,它为用户的各种操作提供即时反馈,包括代码评测结果、评论回复、系统消息等。我们设计了多层次的通知机制,确保用户能够及时获得重要的系统信息。
通知类型分类:
- 成功通知:操作成功的确认信息
- 错误通知:操作失败或系统错误的提示
- 警告通知:需要注意但不影响使用的信息
- 信息通知:一般性的系统信息更新
前端通知实现:
javascript
/**
* 通知系统核心实现
* 提供多种通知方式和样式
*/
// 通知配置
const NotificationConfig = {
duration: 3000, // 默认显示时长(毫秒)
maxCount: 5, // 最大同时显示数量
position: 'top-right', // 显示位置
animation: 'slide' // 动画效果
};
// 通知管理器
class NotificationManager {
constructor() {
this.notifications = [];
this.container = this.createContainer();
}
// 创建通知容器
createContainer() {
const container = document.createElement('div');
container.className = 'notification-container';
container.setAttribute('data-position', NotificationConfig.position);
document.body.appendChild(container);
return container;
}
// 显示通知
show(message, type = 'info', duration = NotificationConfig.duration) {
// 检查通知数量限制
if (this.notifications.length >= NotificationConfig.maxCount) {
this.remove(this.notifications[0]);
}
const notification = this.createNotification(message, type);
this.container.appendChild(notification);
this.notifications.push(notification);
// 触发动画
setTimeout(() => {
notification.classList.add('show');
}, 10);
// 自动移除
if (duration > 0) {
setTimeout(() => {
this.remove(notification);
}, duration);
}
return notification;
}
// 创建通知元素
createNotification(message, type) {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
// 根据类型设置图标
const icon = this.getIcon(type);
notification.innerHTML = `
<div class="notification-content">
<div class="notification-icon">${icon}</div>
<div class="notification-message">${message}</div>
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
return notification;
}
// 获取图标
getIcon(type) {
const icons = {
success: '✓',
error: '✗',
warning: '⚠',
info: 'ℹ'
};
return icons[type] || icons.info;
}
// 移除通知
remove(notification) {
notification.classList.add('hide');
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
const index = this.notifications.indexOf(notification);
if (index > -1) {
this.notifications.splice(index, 1);
}
}, 300);
}
}
// 创建全局通知管理器实例
const notificationManager = new NotificationManager();
// 便捷函数
function showNotification(message, type, duration) {
return notificationManager.show(message, type, duration);
}
function showSuccess(message, duration) {
return showNotification(message, 'success', duration);
}
function showError(message, duration) {
return showNotification(message, 'error', duration);
}
function showWarning(message, duration) {
return showNotification(message, 'warning', duration);
}
function showInfo(message, duration) {
return showNotification(message, 'info', duration);
}
CSS样式设计:
css
/**
* 通知系统样式
* 现代化、优雅的通知外观
*/
.notification-container {
position: fixed;
z-index: 1000;
pointer-events: none;
}
.notification-container[data-position="top-right"] {
top: 20px;
right: 20px;
}
.notification {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-bottom: 10px;
overflow: hidden;
transform: translateX(400px);
transition: transform 0.3s ease, opacity 0.3s ease;
pointer-events: all;
min-width: 300px;
max-width: 400px;
}
.notification.show {
transform: translateX(0);
}
.notification.hide {
transform: translateX(400px);
opacity: 0;
}
.notification-content {
display: flex;
align-items: center;
padding: 16px;
position: relative;
}
.notification-icon {
font-size: 20px;
margin-right: 12px;
flex-shrink: 0;
}
.notification-message {
flex: 1;
font-size: 14px;
line-height: 1.5;
color: #333;
}
.notification-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 0;
margin-left: 12px;
line-height: 1;
}
.notification-close:hover {
color: #666;
}
/* 不同类型通知的颜色主题 */
.notification-success {
border-left: 4px solid #52c41a;
}
.notification-success .notification-icon {
color: #52c41a;
}
.notification-error {
border-left: 4px solid #ff4d4f;
}
.notification-error .notification-icon {
color: #ff4d4f;
}
.notification-warning {
border-left: 4px solid #faad14;
}
.notification-warning .notification-icon {
color: #faad14;
}
.notification-info {
border-left: 4px solid #1890ff;
}
.notification-info .notification-icon {
color: #1890ff;
}
/* 深色主题适配 */
[data-theme="dark"] .notification {
background: #2a2a2a;
color: #e6edf3;
}
[data-theme="dark"] .notification-message {
color: #e6edf3;
}
[data-theme="dark"] .notification-close {
color: #8b949e;
}
[data-theme="dark"] .notification-close:hover {
color: #c9d1d9;
}
使用场景示例:
javascript
// 代码提交成功
showSuccess('代码提交成功,正在评测中...', 3000);
// 评测结果返回
if (result.status === 0) {
showSuccess('恭喜!代码通过所有测试用例', 5000);
} else if (result.status === -1) {
showError('编译失败,请检查代码语法', 5000);
} else if (result.status === -2) {
showWarning('运行时错误,请检查代码逻辑', 5000);
} else if (result.status === -3) {
showWarning('时间超限,请优化算法效率', 5000);
}
// 评论操作
showInfo('评论发布成功', 2000);
showError('评论发布失败,请重试', 3000);
// 系统消息
showInfo('系统将在5分钟后进行维护', 0); // 0表示不自动消失
7.10 题单训练系统:个性化学习路径管理
题单训练系统是本平台的特色功能之一,它允许用户创建和管理个性化的题目集合,支持拖拽排序、进度跟踪、标签分类等高级功能。该系统为用户提供了结构化的学习方式,帮助用户系统地提升编程能力。
核心功能特性:
- 题单创建与管理
- 拖拽排序功能
- 实时进度计算
- 题目标签与难度分类
- 题单可见性控制(公开/私有)
- 题单分享与收藏
数据模型设计:
cpp
/**
* 题单数据模型
* 位于 oj_model.hpp 中,定义题单的核心数据结构
*/
struct TrainingList {
std::string id; // 题单ID
std::string title; // 题单标题
std::string description; // 题单描述
std::string user_id; // 创建者ID
std::string username; // 创建者用户名
std::string visibility; // 可见性(public/private)
std::string created_at; // 创建时间
std::string updated_at; // 更新时间
int question_count; // 题目数量
int completed_count; // 已完成数量
float progress; // 完成进度(百分比)
};
struct TrainingListItem {
std::string id; // 条目ID
std::string training_list_id; // 所属题单ID
std::string question_id; // 题目ID
std::string question_title; // 题目标题
std::string question_difficulty; // 题目难度
int order_index; // 排序索引
bool is_completed; // 是否已完成
std::string completed_at; // 完成时间
};
后端API实现:
cpp
/**
* 题单管理API实现
* 位于 oj_control.hpp 中,提供题单的CRUD操作
*/
// 创建题单
bool CreateTrainingList(const std::string &user_id, const std::string &title,
const std::string &description, const std::string &visibility,
std::string *json_out) {
TrainingList list;
list.user_id = user_id;
list.title = title;
list.description = description;
list.visibility = visibility;
if (model_.CreateTrainingList(list)) {
Json::Value res;
res["status"] = 0;
res["reason"] = "Success";
res["data"]["id"] = list.id;
*json_out = SerializeJson(res);
return true;
}
Json::Value res;
res["status"] = 1;
res["reason"] = "Failed to create training list";
*json_out = SerializeJson(res);
return false;
}
// 更新题单题目顺序(支持拖拽排序)
bool UpdateTrainingListOrder(const std::string &training_list_id,
const std::vector<std::string> &question_order,
std::string *json_out) {
// 开启事务
if (!model_.BeginTransaction()) {
return ErrorResponse("Database transaction failed", json_out);
}
try {
// 删除现有顺序
if (!model_.DeleteTrainingListItems(training_list_id)) {
model_.RollbackTransaction();
return ErrorResponse("Failed to delete existing items", json_out);
}
// 按照新顺序重新插入
for (int i = 0; i < question_order.size(); i++) {
TrainingListItem item;
item.training_list_id = training_list_id;
item.question_id = question_order[i];
item.order_index = i;
if (!model_.AddTrainingListItem(item)) {
model_.RollbackTransaction();
return ErrorResponse("Failed to add training list item", json_out);
}
}
// 提交事务
if (!model_.CommitTransaction()) {
model_.RollbackTransaction();
return ErrorResponse("Failed to commit transaction", json_out);
}
return SuccessResponse("Training list order updated successfully", json_out);
} catch (const std::exception &e) {
model_.RollbackTransaction();
return ErrorResponse(std::string("Exception: ") + e.what(), json_out);
}
}
// 获取题单详情(包含进度信息)
bool GetTrainingListDetail(const std::string &training_list_id, const std::string &user_id,
std::string *json_out) {
TrainingList list;
if (!model_.GetTrainingListById(training_list_id, &list)) {
return ErrorResponse("Training list not found", json_out);
}
// 检查访问权限
if (list.visibility == "private" && list.user_id != user_id) {
return ErrorResponse("Access denied", json_out);
}
// 获取题单中的所有题目
std::vector<TrainingListItem> items;
if (!model_.GetTrainingListItems(training_list_id, &items)) {
return ErrorResponse("Failed to get training list items", json_out);
}
// 计算用户完成进度
int completed_count = 0;
for (auto &item : items) {
if (model_.IsQuestionCompletedByUser(user_id, item.question_id)) {
item.is_completed = true;
completed_count++;
}
}
// 更新进度信息
list.completed_count = completed_count;
list.progress = items.empty() ? 0.0 : (float)completed_count / items.size() * 100;
// 构建响应
Json::Value res;
res["status"] = 0;
res["reason"] = "Success";
Json::Value list_json;
list_json["id"] = list.id;
list_json["title"] = list.title;
list_json["description"] = list.description;
list_json["user_id"] = list.user_id;
list_json["username"] = list.username;
list_json["visibility"] = list.visibility;
list_json["question_count"] = list.question_count;
list_json["completed_count"] = list.completed_count;
list_json["progress"] = list.progress;
list_json["created_at"] = list.created_at;
Json::Value items_json(Json::arrayValue);
for (const auto &item : items) {
Json::Value item_json;
item_json["id"] = item.id;
item_json["question_id"] = item.question_id;
item_json["question_title"] = item.question_title;
item_json["question_difficulty"] = item.question_difficulty;
item_json["order_index"] = item.order_index;
item_json["is_completed"] = item.is_completed;
items_json.append(item_json);
}
list_json["items"] = items_json;
res["data"] = list_json;
*json_out = SerializeJson(res);
return true;
}
前端拖拽排序实现:
javascript
/**
* 题单拖拽排序功能
* 使用现代化的拖拽API实现流畅的交互体验
*/
class TrainingListDragManager {
constructor() {
this.draggedElement = null;
this.draggedIndex = null;
this.dropTarget = null;
this.init();
}
init() {
this.setupDragAndDrop();
this.setupTouchSupport();
}
// 设置拖拽事件
setupDragAndDrop() {
const items = document.querySelectorAll('.training-list-item');
items.forEach((item, index) => {
// 设置拖拽属性
item.draggable = true;
item.setAttribute('data-index', index);
// 拖拽开始
item.addEventListener('dragstart', (e) => {
this.draggedElement = item;
this.draggedIndex = index;
// 设置拖拽效果
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', item.outerHTML);
// 添加拖拽样式
item.classList.add('dragging');
// 延迟添加透明效果,避免拖拽图像显示异常
setTimeout(() => {
item.style.opacity = '0.5';
}, 0);
});
// 拖拽结束
item.addEventListener('dragend', (e) => {
item.classList.remove('dragging');
item.style.opacity = '';
// 清除所有放置目标样式
document.querySelectorAll('.drop-target').forEach(el => {
el.classList.remove('drop-target');
});
this.draggedElement = null;
this.draggedIndex = null;
});
// 拖拽经过
item.addEventListener('dragover', (e) => {
e.preventDefault(); // 允许放置
e.dataTransfer.dropEffect = 'move';
// 计算放置位置
const rect = item.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
if (e.clientY < midpoint) {
// 放置在当前项目上方
item.classList.add('drop-target-above');
item.classList.remove('drop-target-below');
} else {
// 放置在当前项目下方
item.classList.add('drop-target-below');
item.classList.remove('drop-target-above');
}
});
// 拖拽离开
item.addEventListener('dragleave', (e) => {
item.classList.remove('drop-target-above', 'drop-target-below');
});
// 放置
item.addEventListener('drop', (e) => {
e.preventDefault();
if (item === this.draggedElement) return; // 不能放置到自己身上
// 确定放置位置
const rect = item.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const insertBefore = e.clientY < midpoint;
// 执行重新排序
this.reorderItems(this.draggedIndex, index, insertBefore);
// 清除样式
item.classList.remove('drop-target-above', 'drop-target-below');
});
});
}
// 重新排序项目
reorderItems(fromIndex, toIndex, insertBefore) {
const items = Array.from(document.querySelectorAll('.training-list-item'));
const draggedItem = items[fromIndex];
// 计算目标位置
let targetIndex = toIndex;
if (fromIndex < toIndex) {
// 向下拖拽
targetIndex = insertBefore ? toIndex : toIndex + 1;
} else {
// 向上拖拽
targetIndex = insertBefore ? toIndex : toIndex + 1;
}
// 调整目标索引
if (fromIndex < targetIndex) {
targetIndex--;
}
// 重新排列DOM元素
const parent = draggedItem.parentNode;
const referenceNode = items[targetIndex];
if (targetIndex < items.length) {
parent.insertBefore(draggedItem, referenceNode);
} else {
parent.appendChild(draggedItem);
}
// 更新索引属性
this.updateItemIndices();
// 发送到后端保存
this.saveOrderToBackend();
// 显示成功通知
showSuccess('题单顺序已更新');
}
// 更新项目索引
updateItemIndices() {
const items = document.querySelectorAll('.training-list-item');
items.forEach((item, index) => {
item.setAttribute('data-index', index);
});
}
// 保存到后端
async saveOrderToBackend() {
const items = document.querySelectorAll('.training-list-item');
const questionOrder = Array.from(items).map(item => item.getAttribute('data-question-id'));
try {
const response = await fetch('/api/training_list/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
training_list_id: currentTrainingListId,
question_order: questionOrder
})
});
const result = await response.json();
if (result.status !== 0) {
showError('保存顺序失败: ' + result.reason);
}
} catch (error) {
console.error('保存顺序失败:', error);
showError('网络错误,顺序保存失败');
}
}
// 设置触摸支持(移动端)
setupTouchSupport() {
if (!('ontouchstart' in window)) return;
const items = document.querySelectorAll('.training-list-item');
let touchItem = null;
let touchOffset = { x: 0, y: 0 };
items.forEach(item => {
item.addEventListener('touchstart', (e) => {
touchItem = item;
const touch = e.touches[0];
const rect = item.getBoundingClientRect();
touchOffset.x = touch.clientX - rect.left;
touchOffset.y = touch.clientY - rect.top;
// 延迟触发拖拽,避免与滚动冲突
setTimeout(() => {
if (touchItem === item) {
item.classList.add('touch-dragging');
}
}, 300);
});
item.addEventListener('touchmove', (e) => {
if (!touchItem || touchItem !== item) return;
e.preventDefault();
const touch = e.touches[0];
// 更新项目位置
item.style.position = 'fixed';
item.style.zIndex = '1000';
item.style.left = (touch.clientX - touchOffset.x) + 'px';
item.style.top = (touch.clientY - touchOffset.y) + 'px';
item.style.opacity = '0.8';
item.style.transform = 'scale(1.05)';
});
item.addEventListener('touchend', (e) => {
if (!touchItem || touchItem !== item) return;
// 重置样式
item.classList.remove('touch-dragging');
item.style.position = '';
item.style.zIndex = '';
item.style.left = '';
item.style.top = '';
item.style.opacity = '';
item.style.transform = '';
touchItem = null;
});
});
}
}
// 初始化拖拽管理器
document.addEventListener('DOMContentLoaded', function() {
if (document.querySelector('.training-list-container')) {
new TrainingListDragManager();
}
});
进度可视化实现:
javascript
/**
* 题单进度可视化
* 使用SVG和CSS动画展示学习进度
*/
class TrainingProgressVisualizer {
constructor(containerId, progress) {
this.container = document.getElementById(containerId);
this.progress = progress;
this.init();
}
init() {
this.createProgressCircle();
this.createProgressBars();
this.animateProgress();
}
// 创建圆形进度条
createProgressCircle() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '120');
svg.setAttribute('height', '120');
svg.setAttribute('viewBox', '0 0 120 120');
// 背景圆环
const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bgCircle.setAttribute('cx', '60');
bgCircle.setAttribute('cy', '60');
bgCircle.setAttribute('r', '50');
bgCircle.setAttribute('fill', 'none');
bgCircle.setAttribute('stroke', '#e6e6e6');
bgCircle.setAttribute('stroke-width', '8');
// 进度圆环
const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
progressCircle.setAttribute('cx', '60');
progressCircle.setAttribute('cy', '60');
progressCircle.setAttribute('r', '50');
progressCircle.setAttribute('fill', 'none');
progressCircle.setAttribute('stroke', '#2cbb5d');
progressCircle.setAttribute('stroke-width', '8');
progressCircle.setAttribute('stroke-linecap', 'round');
progressCircle.setAttribute('transform', 'rotate(-90 60 60)');
// 计算圆周长
const circumference = 2 * Math.PI * 50;
const strokeDasharray = circumference;
const strokeDashoffset = circumference - (this.progress / 100) * circumference;
progressCircle.setAttribute('stroke-dasharray', strokeDasharray);
progressCircle.setAttribute('stroke-dashoffset', strokeDashoffset);
// 百分比文字
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', '60');
text.setAttribute('y', '65');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('font-size', '24');
text.setAttribute('font-weight', 'bold');
text.setAttribute('fill', '#333');
text.textContent = Math.round(this.progress) + '%';
svg.appendChild(bgCircle);
svg.appendChild(progressCircle);
svg.appendChild(text);
this.container.appendChild(svg);
// 保存引用用于动画
this.progressCircle = progressCircle;
this.textElement = text;
this.circumference = circumference;
}
// 创建详细进度条
createProgressBars() {
const details = document.createElement('div');
details.className = 'progress-details';
// 总体进度
const overallProgress = document.createElement('div');
overallProgress.className = 'progress-item';
overallProgress.innerHTML = `
<div class="progress-label">总体进度</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${this.progress}%"></div>
</div>
<div class="progress-text">${Math.round(this.progress)}%</div>
`;
// 难度分布
const difficultyProgress = document.createElement('div');
difficultyProgress.className = 'progress-difficulty';
difficultyProgress.innerHTML = `
<div class="progress-label">难度分布</div>
<div class="difficulty-bars">
<div class="difficulty-item">
<span class="difficulty-label">简单</span>
<div class="difficulty-bar">
<div class="difficulty-fill easy" style="width: ${this.progressData.easy}%"></div>
</div>
<span class="difficulty-count">${this.progressData.easyCount}/${this.progressData.easyTotal}</span>
</div>
<div class="difficulty-item">
<span class="difficulty-label">中等</span>
<div class="difficulty-bar">
<div class="difficulty-fill medium" style="width: ${this.progressData.medium}%"></div>
</div>
<span class="difficulty-count">${this.progressData.mediumCount}/${this.progressData.mediumTotal}</span>
</div>
<div class="difficulty-item">
<span class="difficulty-label">困难</span>
<div class="difficulty-bar">
<div class="difficulty-fill hard" style="width: ${this.progressData.hard}%"></div>
</div>
<span class="difficulty-count">${this.progressData.hardCount}/${this.progressData.hardTotal}</span>
</div>
</div>
`;
details.appendChild(overallProgress);
details.appendChild(difficultyProgress);
this.container.appendChild(details);
}
// 动画效果
animateProgress() {
// 圆形进度条动画
let currentProgress = 0;
const targetProgress = this.progress;
const increment = targetProgress / 100; // 分成100步
const animate = () => {
if (currentProgress < targetProgress) {
currentProgress += increment;
if (currentProgress > targetProgress) {
currentProgress = targetProgress;
}
// 更新圆形进度条
const offset = this.circumference - (currentProgress / 100) * this.circumference;
this.progressCircle.setAttribute('stroke-dashoffset', offset);
// 更新文字
this.textElement.textContent = Math.round(currentProgress) + '%';
requestAnimationFrame(animate);
}
};
// 延迟开始动画,确保DOM已完全加载
setTimeout(animate, 500);
}
}
// 使用示例
document.addEventListener('DOMContentLoaded', function() {
const progressData = {
easy: 80,
easyCount: 8,
easyTotal: 10,
medium: 60,
mediumCount: 6,
mediumTotal: 10,
hard: 30,
hardCount: 3,
hardTotal: 10
};
const visualizer = new TrainingProgressVisualizer('progress-container', 56.7);
visualizer.progressData = progressData;
});
技术亮点:
- 拖拽排序:使用现代拖拽API,支持桌面端和移动端
- 事务处理:后端使用数据库事务确保数据一致性
- 进度可视化:使用SVG和CSS动画展示学习进度
- 权限控制:支持题单的公开/私有访问控制
- 性能优化:按需加载题目数据,避免一次性加载大量数据
7.11 娱乐中心系统:游戏化学习体验
娱乐中心系统是本平台的创新功能,它将经典游戏(如超级玛丽、恐龙游戏)集成到在线评测平台中,为用户提供轻松愉快的学习间隙。这种游戏化设计不仅缓解了编程学习的紧张感,还通过娱乐元素增强了用户粘性。
核心游戏功能:
- 超级玛丽经典复刻
- HTML5 Canvas游戏渲染
- 游戏状态管理
- 分数统计与排行榜
- 响应式键盘控制
超级玛丽游戏实现:
javascript
/**
* 超级玛丽游戏核心引擎
* 基于HTML5 Canvas实现,提供流畅的游戏体验
*/
class MarioGame {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.gameState = 'menu'; // menu, playing, paused, gameover
this.score = 0;
this.lives = 3;
this.level = 1;
// 游戏对象
this.mario = null;
this.enemies = [];
this.platforms = [];
this.coins = [];
this.powerups = [];
// 游戏物理参数
this.gravity = 0.5;
this.friction = 0.8;
this.scrollSpeed = 2;
this.camera = { x: 0, y: 0 };
this.init();
}
init() {
this.setupCanvas();
this.loadAssets();
this.setupControls();
this.generateLevel();
this.gameLoop();
}
// 设置画布
setupCanvas() {
// 响应式画布大小
const resizeCanvas = () => {
const container = this.canvas.parentElement;
this.canvas.width = container.clientWidth;
this.canvas.height = Math.min(600, window.innerHeight - 200);
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
// 加载游戏资源
loadAssets() {
// 图片资源
this.assets = {
mario: new Image(),
enemy: new Image(),
platform: new Image(),
coin: new Image(),
background: new Image()
};
// 设置图片源(使用base64编码的小图片或外部URL)
this.assets.mario.src = '/assets/mario-sprite.png';
this.assets.enemy.src = '/assets/enemy-sprite.png';
this.assets.platform.src = '/assets/platform-tile.png';
this.assets.coin.src = '/assets/coin-sprite.png';
this.assets.background.src = '/assets/background.png';
// 音频资源
this.sounds = {
jump: new Audio('/assets/sounds/jump.mp3'),
coin: new Audio('/assets/sounds/coin.mp3'),
powerup: new Audio('/assets/sounds/powerup.mp3'),
gameover: new Audio('/assets/sounds/gameover.mp3'),
background: new Audio('/assets/sounds/background.mp3')
};
// 设置音频循环和音量
this.sounds.background.loop = true;
this.sounds.background.volume = 0.3;
}
// 设置控制
setupControls() {
this.keys = {};
// 键盘事件
document.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
// 特殊按键处理
switch(e.code) {
case 'Space':
e.preventDefault();
if (this.gameState === 'menu') {
this.startGame();
} else if (this.gameState === 'playing') {
this.pauseGame();
} else if (this.gameState === 'paused') {
this.resumeGame();
}
break;
case 'Escape':
if (this.gameState === 'playing') {
this.pauseGame();
}
break;
}
});
document.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
});
// 触摸控制(移动端)
this.setupTouchControls();
}
// 设置触摸控制
setupTouchControls() {
if (!('ontouchstart' in window)) return;
const leftBtn = document.getElementById('touch-left');
const rightBtn = document.getElementById('touch-right');
const jumpBtn = document.getElementById('touch-jump');
// 左移按钮
leftBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
this.keys['ArrowLeft'] = true;
});
leftBtn.addEventListener('touchend', (e) => {
e.preventDefault();
this.keys['ArrowLeft'] = false;
});
// 右移按钮
rightBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
this.keys['ArrowRight'] = true;
});
rightBtn.addEventListener('touchend', (e) => {
e.preventDefault();
this.keys['ArrowRight'] = false;
});
// 跳跃按钮
jumpBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
this.keys['ArrowUp'] = true;
});
jumpBtn.addEventListener('touchend', (e) => {
e.preventDefault();
this.keys['ArrowUp'] = false;
});
}
// 生成关卡
generateLevel() {
// 创建马里奥对象
this.mario = new Mario(100, 300, this.assets.mario);
// 生成平台
this.platforms = [
new Platform(0, 400, 200, 20, this.assets.platform),
new Platform(300, 350, 150, 20, this.assets.platform),
new Platform(500, 300, 100, 20, this.assets.platform),
new Platform(700, 250, 150, 20, this.assets.platform),
new Platform(950, 200, 100, 20, this.assets.platform)
];
// 生成敌人
this.enemies = [
new Enemy(350, 330, this.assets.enemy),
new Enemy(550, 280, this.assets.enemy),
new Enemy(750, 230, this.assets.enemy)
];
// 生成金币
this.coins = [
new Coin(320, 320, this.assets.coin),
new Coin(520, 270, this.assets.coin),
new Coin(720, 220, this.assets.coin),
new Coin(970, 170, this.assets.coin)
];
// 生成道具
this.powerups = [
new PowerUp(150, 350, 'mushroom')
];
}
// 游戏主循环
gameLoop() {
// 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 更新游戏状态
this.update();
// 渲染游戏画面
this.render();
// 继续循环
requestAnimationFrame(() => this.gameLoop());
}
// 更新游戏逻辑
update() {
if (this.gameState !== 'playing') return;
// 更新马里奥
this.mario.update(this.keys, this.platforms, this.gravity, this.friction);
// 更新相机位置(跟随马里奥)
this.camera.x = this.mario.x - this.canvas.width / 3;
if (this.camera.x < 0) this.camera.x = 0;
// 更新敌人
this.enemies.forEach(enemy => {
enemy.update(this.platforms);
// 检测与马里奥的碰撞
if (this.mario.collidesWith(enemy)) {
if (this.mario.isFalling() && this.mario.y < enemy.y) {
// 马里奥踩到敌人
this.enemies = this.enemies.filter(e => e !== enemy);
this.score += 100;
this.mario.bounce();
this.playSound('enemy');
} else {
// 马里奥被敌人碰到
this.mario.takeDamage();
if (this.mario.lives <= 0) {
this.gameOver();
}
}
}
});
// 更新金币
this.coins = this.coins.filter(coin => {
if (this.mario.collidesWith(coin)) {
this.score += 50;
this.playSound('coin');
return false; // 移除金币
}
return true;
});
// 更新道具
this.powerups = this.powerups.filter(powerup => {
if (this.mario.collidesWith(powerup)) {
this.mario.powerUp(powerup.type);
this.score += 200;
this.playSound('powerup');
return false; // 移除道具
}
return true;
});
// 检查游戏胜利条件
if (this.mario.x > 1100) {
this.levelComplete();
}
}
// 渲染游戏画面
render() {
// 保存当前变换矩阵
this.ctx.save();
// 应用相机变换
this.ctx.translate(-this.camera.x, 0);
// 绘制背景
this.renderBackground();
// 绘制平台
this.platforms.forEach(platform => platform.render(this.ctx));
// 绘制金币
this.coins.forEach(coin => coin.render(this.ctx));
// 绘制道具
this.powerups.forEach(powerup => powerup.render(this.ctx));
// 绘制敌人
this.enemies.forEach(enemy => enemy.render(this.ctx));
// 绘制马里奥
this.mario.render(this.ctx);
// 恢复变换矩阵
this.ctx.restore();
// 绘制UI(不受相机影响)
this.renderUI();
}
// 渲染背景
renderBackground() {
// 绘制天空渐变
const gradient = this.ctx.createLinearGradient(0, 0, 0, this.canvas.height);
gradient.addColorStop(0, '#87CEEB'); // 天蓝色
gradient.addColorStop(1, '#E0F6FF'); // 浅蓝色
this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, 0, this.canvas.width + this.camera.x * 2, this.canvas.height);
// 绘制云朵
this.drawCloud(200 - this.camera.x * 0.3, 100);
this.drawCloud(500 - this.camera.x * 0.5, 150);
this.drawCloud(800 - this.camera.x * 0.4, 120);
}
// 绘制云朵
drawCloud(x, y) {
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
this.ctx.beginPath();
this.ctx.arc(x, y, 30, 0, Math.PI * 2);
this.ctx.arc(x + 25, y, 35, 0, Math.PI * 2);
this.ctx.arc(x + 50, y, 30, 0, Math.PI * 2);
this.ctx.arc(x + 15, y - 20, 25, 0, Math.PI * 2);
this.ctx.arc(x + 35, y - 20, 25, 0, Math.PI * 2);
this.ctx.fill();
}
// 渲染UI
renderUI() {
// 半透明背景
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
this.ctx.fillRect(10, 10, 200, 80);
// 分数
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = 'bold 20px Arial';
this.ctx.fillText(`Score: ${this.score}`, 20, 35);
// 生命数
this.ctx.fillText(`Lives: ${this.mario.lives}`, 20, 60);
// 关卡
this.ctx.fillText(`Level: ${this.level}`, 20, 85);
// 游戏状态提示
if (this.gameState === 'menu') {
this.renderMenu();
} else if (this.gameState === 'paused') {
this.renderPauseMenu();
} else if (this.gameState === 'gameover') {
this.renderGameOver();
} else if (this.gameState === 'levelcomplete') {
this.renderLevelComplete();
}
}
// 渲染主菜单
renderMenu() {
// 半透明遮罩
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// 标题
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = 'bold 48px Arial';
this.ctx.textAlign = 'center';
this.ctx.fillText('Super Mario', this.canvas.width / 2, 200);
// 开始提示
this.ctx.font = '24px Arial';
this.ctx.fillText('Press SPACE to Start', this.canvas.width / 2, 300);
// 操作说明
this.ctx.font = '16px Arial';
this.ctx.fillText('Use Arrow Keys to Move, Up to Jump', this.canvas.width / 2, 350);
this.ctx.fillText('Touch controls available on mobile', this.canvas.width / 2, 380);
this.ctx.textAlign = 'left';
}
// 游戏控制方法
startGame() {
this.gameState = 'playing';
this.score = 0;
this.mario.reset();
this.playSound('background');
}
pauseGame() {
this.gameState = 'paused';
this.sounds.background.pause();
}
resumeGame() {
this.gameState = 'playing';
this.sounds.background.play();
}
gameOver() {
this.gameState = 'gameover';
this.sounds.background.pause();
this.sounds.background.currentTime = 0;
this.playSound('gameover');
}
levelComplete() {
this.gameState = 'levelcomplete';
this.level++;
this.score += 1000;
}
// 播放音效
playSound(soundName) {
if (this.sounds[soundName]) {
this.sounds[soundName].currentTime = 0;
this.sounds[soundName].play().catch(e => {
console.log('Sound play failed:', e);
});
}
}
}
// 马里奥角色类
class Mario {
constructor(x, y, sprite) {
this.x = x;
this.y = y;
this.width = 32;
this.height = 32;
this.sprite = sprite;
this.velocityX = 0;
this.velocityY = 0;
this.speed = 5;
this.jumpPower = 12;
this.isGrounded = false;
this.lives = 3;
this.powered = false;
this.invulnerable = false;
}
update(keys, platforms, gravity, friction) {
// 水平移动
if (keys['ArrowLeft']) {
this.velocityX = -this.speed;
} else if (keys['ArrowRight']) {
this.velocityX = this.speed;
} else {
this.velocityX *= friction;
}
// 跳跃
if (keys['ArrowUp'] && this.isGrounded) {
this.velocityY = -this.jumpPower;
this.isGrounded = false;
}
// 应用重力
this.velocityY += gravity;
// 更新位置
this.x += this.velocityX;
this.y += this.velocityY;
// 碰撞检测
this.checkCollisions(platforms);
// 边界检查
if (this.y > 600) {
this.takeDamage();
}
}
checkCollisions(platforms) {
this.isGrounded = false;
platforms.forEach(platform => {
if (this.x < platform.x + platform.width &&
this.x + this.width > platform.x &&
this.y < platform.y + platform.height &&
this.y + this.height > platform.y) {
// 从上方碰撞
if (this.velocityY > 0 && this.y < platform.y) {
this.y = platform.y - this.height;
this.velocityY = 0;
this.isGrounded = true;
}
// 从下方碰撞
else if (this.velocityY < 0 && this.y > platform.y) {
this.y = platform.y + platform.height;
this.velocityY = 0;
}
// 从左侧碰撞
else if (this.velocityX > 0 && this.x < platform.x) {
this.x = platform.x - this.width;
this.velocityX = 0;
}
// 从右侧碰撞
else if (this.velocityX < 0 && this.x > platform.x) {
this.x = platform.x + platform.width;
this.velocityX = 0;
}
}
});
}
collidesWith(obj) {
return this.x < obj.x + obj.width &&
this.x + this.width > obj.x &&
this.y < obj.y + obj.height &&
this.y + this.height > obj.y;
}
isFalling() {
return this.velocityY > 0;
}
bounce() {
this.velocityY = -8;
}
takeDamage() {
if (this.invulnerable) return;
this.lives--;
this.invulnerable = true;
// 闪烁效果
let flashCount = 0;
const flash = () => {
if (flashCount < 10) {
this.visible = !this.visible;
flashCount++;
setTimeout(flash, 100);
} else {
this.visible = true;
this.invulnerable = false;
}
};
flash();
// 重置位置
this.x = 100;
this.y = 300;
this.velocityX = 0;
this.velocityY = 0;
}
powerUp(type) {
this.powered = true;
this.height = 48; // 变大
// 临时增加能力
this.jumpPower = 15;
this.speed = 7;
// 能力持续时间
setTimeout(() => {
this.powered = false;
this.height = 32;
this.jumpPower = 12;
this.speed = 5;
}, 10000);
}
reset() {
this.x = 100;
this.y = 300;
this.velocityX = 0;
this.velocityY = 0;
this.lives = 3;
this.powered = false;
this.invulnerable = false;
}
render(ctx) {
if (!this.visible) return;
// 绘制马里奥精灵
if (this.sprite.complete) {
ctx.drawImage(this.sprite, this.x, this.y, this.width, this.height);
} else {
// 精灵未加载完成时的备用绘制
ctx.fillStyle = this.powered ? '#FF6B6B' : '#FF0000';
ctx.fillRect(this.x, this.y, this.width, this.height);
// 绘制简单的面部特征
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(this.x + 8, this.y + 8, 4, 4); // 眼睛
ctx.fillRect(this.x + 20, this.y + 8, 4, 4);
ctx.fillStyle = '#000000';
ctx.fillRect(this.x + 10, this.y + 20, 12, 2); // 嘴巴
}
}
}
// 平台类
class Platform {
constructor(x, y, width, height, sprite) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.sprite = sprite;
}
render(ctx) {
if (this.sprite.complete) {
// 平铺绘制平台
const tileSize = 20;
for (let i = 0; i < this.width; i += tileSize) {
ctx.drawImage(this.sprite, this.x + i, this.y, tileSize, this.height);
}
} else {
// 备用绘制
ctx.fillStyle = '#8B4513';
ctx.fillRect(this.x, this.y, this.width, this.height);
// 添加纹理
ctx.fillStyle = '#A0522D';
for (let i = 0; i < this.width; i += 10) {
ctx.fillRect(this.x + i, this.y, 2, this.height);
}
}
}
}
// 其他游戏对象类(敌人、金币、道具等)类似实现...
// 初始化游戏
document.addEventListener('DOMContentLoaded', function() {
const gameCanvas = document.getElementById('game-canvas');
if (gameCanvas) {
const marioGame = new MarioGame('game-canvas');
// 暴露游戏实例供调试使用
window.marioGame = marioGame;
// 添加游戏状态显示
console.log('Super Mario Game initialized successfully!');
}
});
游戏特色功能:
- 经典还原:忠实还原超级玛丽的核心玩法和物理特性
- 响应式设计:支持键盘和触摸双重控制方式
- 关卡生成:程序化生成游戏关卡,支持无限扩展
- 状态管理:完整的游戏状态机,支持暂停、恢复、重玩
- 音效系统:集成音效播放,增强游戏沉浸感
- 分数系统:实时计分和生命系统
- 移动端适配:专门为移动设备优化的触摸控制
技术实现亮点:
- Canvas渲染:使用HTML5 Canvas实现流畅的2D图形渲染
- 物理引擎:自定义物理系统,实现重力、摩擦、碰撞检测
- 资源管理:统一的图片和音频资源加载管理
- 性能优化:使用requestAnimationFrame确保流畅的动画效果
- 模块化设计:游戏对象采用面向对象设计,便于扩展和维护
第三部分:性能与运维
章节引言
技术架构的合理性直接决定了系统的性能上限。在掌握了系统的具体实现细节后,我们需要关注如何进一步提升系统性能,应对高并发场景下的各种挑战。本部分将从性能瓶颈分析、数据库优化、部署运维等多个维度,为你展现一个完整的企业级系统优化方案。
第8章 性能优化策略(从瓶颈出发)
作为一个支撑代码在线评测的系统,性能瓶颈通常不是出在单纯的 Web 请求并发上,而是出在沙箱环境的文件 I/O 、子进程的启动开销 以及数据库在海量提交记录下的慢查询。本章将遵循"发现瓶颈 → 提出方案 → 验证优化"的思路,分层次解析系统的性能调优策略。
8.1 主链路优化:打破单点瓶颈
在最核心的代码提交链路中,任何一个慢操作都会阻塞后续请求。我们将链路时延进行了分解,针对最耗时的编译与运行阶段进行了架构级优化。
8.1.1 判题时延分解图
40% 30% 15% 10% 5% 单次代码提交耗时分解 (示意比例) 主服务任务分发与网络 IO 数据库拉取测试用例 磁盘读写 (生成源文件/写入临时目录) 沙箱编译阶段 (g++ 启动开销) 代码实际运行阶段
8.1.2 针对性优化:无状态扩展与心跳自动剔除
既然最大的瓶颈在编译节点(单机通常只能支撑极少数目并发的 g++ 进程),主服务绝不能被慢节点卡死。
我们在 LoadBalance 模块中引入了后台独立的心跳检测线程,每 3 秒检测一次编译节点。一旦某节点卡死或断网,立刻将其移出在线队列,确保前端的后续请求秒级切流。
8.2 数据库性能优化:对抗慢查询
随着时间推移,submissions (提交记录) 表的数据量会呈指数级增长,最初"跑得很快"的普通 SQL 语句将成为拖垮整个主服务的元凶。
8.2.1 组合索引的威力
用户在个人主页查看提交记录时,常常带有特定条件(如:查询用户 A 在题目 B 的提交记录)。如果没有正确的索引,这会导致全表扫描。
未优化的原始查询瓶颈:
sql
-- 若只有单列索引,过滤效率极低
SELECT * FROM submissions WHERE user_id = 123 AND question_id = 1001 ORDER BY created_at DESC LIMIT 20;
优化方案:创建组合索引
我们针对高频联合查询的字段建立了组合索引。组合索引遵循最左前缀匹配原则,使得数据库在进行联合条件筛选时能够一步到位定位数据行。
sql
/**
* 优化手段:创建针对 (user_id, question_id) 的组合索引。
* 在极端情况下,还可以将 created_at 加入组合索引以避免文件排序 (FileSort)。
*/
ALTER TABLE submissions ADD INDEX idx_user_question_time (user_id, question_id, created_at);
8.2.2 深分页 (Deep Pagination) 的抖动治理
在展示所有题目的列表或全局提交日志时,如果用户翻到了第 1000 页,传统 LIMIT offset, size 会导致 MySQL 扫描并抛弃前几万条数据,耗时急剧上升。
优化方案:延迟关联与游标分页
对于深分页,我们建议演进为基于主键的游标查询:
sql
/**
* 优化前:
* SELECT * FROM submissions ORDER BY id DESC LIMIT 20000, 20;
* 优化后:利用覆盖索引先查出 ID,再回表查具体数据,极大减少了回表带来的随机 I/O
*/
SELECT s.* FROM submissions s
INNER JOIN (
SELECT id FROM submissions ORDER BY id DESC LIMIT 20000, 20
) AS tmp ON s.id = tmp.id;
| 优化维度 | 未优化时的潜在风险 | 优化后采取的应对方案 |
|---|---|---|
| 组合查询 | 发生全表扫描,CPU 100% | 针对高频条件建立组合索引 |
| 深度分页 | 大量回表导致 I/O 抖动 | 延迟关联查询或使用游标分页 |
| 单表过大 | B+树层级加深,写入锁冲突 | 按时间月度进行分表归档(演进方向) |
8.3 判题沙箱的 IO 与复用优化 (演进方向)
在当前的编译服务器中,每一次 /compile_and_run 请求都会在磁盘 ./temp 目录下创建随机命名的 .cpp 和 .exe 文件,执行完毕后再执行文件系统的 remove。
文件 I/O 瓶颈 :
高并发下,频繁的磁盘小文件创建与删除会引发操作系统底层 inode 锁竞争,并消耗大量的磁盘 I/O。
未来优化策略:
- 内存文件系统 :将
./temp目录挂载为 Linux 的tmpfs(基于内存的虚拟文件系统),让所有的编译临时文件都在内存中读写,彻底消除磁盘 I/O 瓶颈。 - 编译产物哈希复用:计算用户提交代码的 MD5/SHA256 哈希值。如果发现哈希值相同的代码此前已经成功编译过(且未过期),则直接复用旧的二进制执行文件,从而将耗时最高的"编译阶段开销"直接降为 0。
8.4 前端体验层的性能提升
在不使用 SPA 架构的现状下,为了保证页面加载的顺畅感:
- 无闪烁分页 :通过在前端 JavaScript 拦截按钮点击事件,使用
fetch局部替换列表 HTML 的方式,避免了传统表单提交带来的整页白屏刷新。 - 静态资源缓存 :通过配置 Nginx,针对
common.css、marked.min.js等依赖库开启长期的Cache-Control: max-age缓存,大幅降低用户二次访问时的网络开销。
第9章 部署与运维方案(Docker Compose 为主线)
本章节着眼于系统的工程化交付。在生产环境或开发环境中,如何保证多个微服务(OJ 主服务、多个编译节点、数据库、爬虫)的环境一致性并实现一键启动,是本章的核心议题。我们将通过 Docker Compose 梳理服务的网络拓扑、配置挂载以及可观测性方案。
9.1 容器化部署拓扑与端口规划
为了避免在宿主机上配置复杂的 C++ 编译环境、MySQL 以及系统依赖,我们将所有服务都进行了容器化。每个服务跑在独立的 Docker 容器中,并通过 Docker 内部网桥(Bridge Network)进行通信。
Host
oj-net
HTTPS :443
HTTP 反代映射
内网访问 :3306
内网访问 :3306
心跳与下发 :8081
心跳与下发 :8082
mysql_db (IP: 172.x.x.2)
oj_server (IP: 172.x.x.3)
compile_server_1 (IP: 172.x.x.4)
compile_server_2 (IP: 172.x.x.5)
contest_crawler
Nginx (反向代理)
User
端口分配约定表:
| 服务名称 | 容器内端口 | 宿主机映射暴露端口 | 访问权限 | 说明 |
|---|---|---|---|---|
oj_server |
8096 |
8096 |
仅 Nginx / 运维可达 | 承接 HTTP 请求的核心服务 |
mysql_db |
3306 |
(不暴露) |
仅网桥内微服务可达 | 防止数据库暴露在外网被爆破 |
compile_server_* |
808x |
(不暴露) |
仅 OJ Server 可达 | 保证只有认证的主服务能提交代码 |
9.2 Docker Compose 配置编排示例
以下是提取自项目实际部署脚本的 docker-compose.yml 片段。通过这份编排文件,我们定义了服务之间的启动依赖(depends_on),并将敏感信息通过环境变量(.env)进行隔离注入。
yaml
# docker-compose.yml 核心片段
version: '3.8'
services:
db:
image: mysql:8.0
restart: always
# 从同目录下的 .env 文件中读取密码,拒绝硬编码
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: oj_db
# 挂载宿主机目录实现数据持久化,防止容器销毁导致数据丢失
volumes:
- ./mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- oj-net
oj_server:
build:
context: .
dockerfile: Dockerfile.oj
restart: always
ports:
- "8096:8096"
# 等待数据库初始化完成后再启动主服务
depends_on:
- db
volumes:
- ./conf:/app/conf # 挂载配置文件,修改无需重新构建镜像
networks:
- oj-net
compile_server_1:
build:
context: .
dockerfile: Dockerfile.compile
restart: always
# 挂载宿主机的 /tmp 目录供沙箱使用,或使用 tmpfs 提升 IO
tmpfs:
- /app/temp
networks:
- oj-net
networks:
oj-net:
driver: bridge
9.3 可观测性:日志、指标与监控
在"黑盒"的容器内部,如果没有良好的可观测性体系,排查问题将成为灾难。
9.3.1 日志管理
我们通过 C++ 后端接入了轻量级日志宏(如 LOG(INFO), LOG(FATAL)),日志被格式化输出并挂载到宿主机。
- 主服务日志:记录所有的 HTTP 访问状态、异常栈、以及数据库连接失败等。
- 编译服务日志 :除了记录常规启停,重点记录了每次代码运行抛出的底层信号(如
[WARNING] Process killed by SIGKILL (OOM)),这对排查沙箱边界极为关键。
9.3.2 性能监控指标 (Prometheus 风格展望)
为了应对未来的大流量场景,可以在 C++ 中暴露 /metrics 接口供 Prometheus 抓取。推荐的关键监控指标包括:
oj_http_requests_total(Counter): 按响应状态码分类的总请求数。oj_judge_latency_seconds(Histogram): 包含编译耗时、运行耗时的分位图(P95/P99),用于精准发现卡顿。oj_active_compile_nodes(Gauge): 当前负载均衡池中在线的编译服务器数量。
9.4 扩容与故障演练方案
横向一键扩容
由于编译服务器是完全无状态的,如果遇到线上比赛期间算力不足,可以通过以下两步实现平滑扩容,且无需重启主服务:
- 启动一个新的
compile_server容器(指定端口 8083)。 - 在主服务的配置文件
service_machine.conf中追加一行172.x.x.x:8083。主服务的定时心跳线程会自动发现该节点并将其纳入负载均衡池。
故障容灾演练 (Chaos Testing)
为验证系统高可用,我们制定了标准演练流程:
- 沙箱故障模拟 :通过
docker kill强制杀掉某一台编译服务器容器。 - 预期表现:主服务的下一次请求可能会超时或失败(计入 Retry),随后心跳线程(每 3 秒执行一次)会检测到该节点失联,自动将其从在线队列中摘除。用户在前端最多只会感受到一次持续几秒的判题延迟,但最终依然能拿到其他节点正确判题的结果。
第四部分:总结与展望
章节引言
技术永无止境,每一个系统的建设都是一次工程思维的锤炼。作为整个技术全景指南的收尾,本部分将回归到项目的原点,总结我们在从 0 到 1 搭建这个负载均衡式在线评测系统时所积累的核心工程经验。同时,我们也将罗列出系统在未来架构演进、安全加固以及前端重构方面的明确路线图,为系统的持续发展指明方向。
第10章 项目总结与技术演进
10.1 本项目的关键工程收获
通过对本项目整个技术栈的打磨,我们提炼出以下几点对于开发分布式评测系统至关重要的认知:
1. 资源隔离是沙箱的灵魂
在线判题系统不同于普通的 Web 增删改查。它必须直面用户"充满恶意"或"极度低效"的输入。通过深入操作系统底层调用 fork, setrlimit 和 wait4,我们认识到仅靠语言层面的 try-catch 是无法防御 OOM 或死循环的,必须依赖内核级的资源配额与降权(root → nobody)来兜底。
2. 状态剥离与任务分发机制
系统能承载高并发的关键在于将"轻量但高频的 HTTP 路由"与"沉重且阻塞的编译运行"物理剥离。主服务只做调度器(LoadBalance),不参与任何实质计算;编译节点纯粹无状态,可以随时上线下线。这种架构设计极大降低了由于单点故障导致系统雪崩的概率。
3. 轻量级技术栈的魅力
选用 C++11 与 httplib,放弃了庞杂的 Spring/Nest 体系,让整个系统的打包镜像极小、启动耗时在毫秒级别。没有了层层封装的反射与依赖注入,我们可以精准定位到每一行代码引起的性能抖动,这使得"可控性"与"可解释性"达到了极致。
10.2 下一步演进方向(Roadmap)
为了将本系统从"可用"推向"企业级可用",我们规划了以下四个维度的技术演进方向(按优先级排序):
10.2.1 架构与并发演进 (Architecture)
- 全面异步化 :目前的
/compile_and_run虽然有重试机制,但仍然是同步阻塞调用。未来将引入 Redis / RabbitMQ 消息队列 。主服务接收代码后直接返回一个Task_ID,编译服务作为 Worker 消费队列异步执行,前端通过 WebSocket 或轮询查询结果,彻底解放主服务线程池。 - 缓存与限流 (Rate Limiting):在 Redis 中实现对热门题目详情的缓存,同时基于 IP 或 User_ID 增加令牌桶限流算法,防止比赛期间的"提交风暴 (Submission Storm)"。
10.2.2 安全隔离演进 (Security)
- 更强的沙箱防御 :当前的
setrlimit能防住大部分资源消耗攻击,但无法完全防御恶意系统调用(如尝试读取/etc/passwd)。下一步将引入 Seccomp (Secure Computing Mode) 或 AppArmor,基于白名单机制彻底锁死子进程可以执行的 System Call。 - 容器级隔离:探索直接使用轻量级容器(如 Docker API / runc)来拉起评测环境,实现更彻底的文件系统隔离与网络隔离。
10.2.3 前端工程化演进 (Frontend)
- React + TypeScript 重构:告别 CTemplate 的服务端渲染,全面转向 React SPA 架构。引入 Zustand 进行全局状态管理,使用 TailwindCSS 统一视觉 Token,从而大幅提升组件的复用率与代码的类型安全。
- 端到端测试覆盖:在现有 Playwright 基础之上,进一步完善针对代码提交全链路的 E2E 自动化测试用例,确保在重构过程中业务逻辑的绝对稳定。
10.2.4 数据与运维演进 (Data & Ops)
- 冷热数据分离与归档 :随着时间推移,提交记录 (
submissions) 表将变得无比巨大。未来将实现定时任务,将三个月前非 AC 的日志归档到冷库或文件中,保持主表轻量。 - 慢查询治理:搭建完整的 Prometheus + Grafana 监控大盘,接入 MySQL 慢查询日志分析告警,为系统的持续健康运行提供数据支撑。
10.3 附录规划参考
在整理成完整文档集时,建议保留以下速查手册作为开发者参考:
- 附录 A:结果码与系统信号对照表 (包含
SIGXCPU,SIGKILL,SIGSEGV在 OJ 系统中的具体含义与调试建议)。 - 附录 B:核心 REST API 契约(包括认证头、请求 JSON Schema 及标准错误码)。
- 附录 C:Docker Compose 一键部署手册与常见网络排错清单。
结语
搭建一个 Online OJ 是一次穿透全栈的奇妙旅程,它不仅考验我们对前端交互与数据库的理解,更倒逼我们深入操作系统的内存与进程管理。希望本技术全景指南能为你带来启发,在分布式系统设计与实现的路上提供一些有价值的参考。技术之路永无止境,让我们继续保持好奇,不断探索!
附录
A. 技术栈快速参考
| 层级 | 技术选型 | 版本 | 主要用途 |
|---|---|---|---|
| 前端 | HTML/CSS/JS | ES6+ | 页面结构与交互 |
| 前端 | CTemplate | 最新 | 服务端模板渲染 |
| 前端 | ACE Editor | 1.4+ | 代码编辑器 |
| 前端 | EasyMDE | 2.x | Markdown 编辑器 |
| 前端 | DOMPurify | 2.x | XSS 防护 |
| 后端 | C++11 | 11标准 | 核心业务逻辑 |
| 后端 | httplib | 0.10+ | Web 服务框架 |
| 后端 | MySQL | 8.0+ | 数据持久化 |
| 后端 | Redis | 6.x+ | 缓存与会话 |
| 运维 | Docker | 20+ | 容器化部署 |
| 运维 | Docker Compose | 1.29+ | 服务编排 |
B. 核心配置文件结构
project/
├── conf/
│ ├── server.conf # 主服务配置
│ ├── database.conf # 数据库连接配置
│ └── machines.conf # 编译节点列表
├── docker-compose.yml # 服务编排
├── Dockerfile.oj # 主服务镜像
├── Dockerfile.compile # 编译服务镜像
└── init.sql # 数据库初始化脚本
C. 常用命令速查
bash
# 启动服务
docker-compose up -d
# 查看日志
docker-compose logs -f oj_server
# 重启服务
docker-compose restart compile_server_1
# 扩容编译节点
docker-compose up -d --scale compile_server_1=3
# 数据库备份
docker exec oj_db mysqldump -uroot -p oj_db > backup.sql