跳到主要内容 C++ 在线判题系统(OJ)设计与实现 | 极客日志
C++ 算法
C++ 在线判题系统(OJ)设计与实现 基于 C++ 开发的轻量级在线判题系统(OJ),采用分层架构设计。系统包含 compile_server 编译运行服务、oj_server 业务服务及 comm 公共模块。oj_server 基于 MVC 结构,Model 层管理题库数据,View 层负责网页渲染,Control 层整合业务逻辑与负载均衡。支持代码提交、编译、运行、资源限制(CPU/内存)及故障主机自动下线恢复。实现了从题目管理到判题反馈的完整链路,适用于编程教学或小型竞赛场景。
1. 项目说明与整体框架
1.1 项目说明
本项目是模仿牛客平台的 OJ 判题系统,主要实现三个模块:
comm :公共模块
compile_server :编译运行模块
oj_server :获取题目列表、查看题目、编写题目界面、负载均衡及其他功能
1.2 项目宏观结构图
(图片已移除)
1.3 实现思路
先实现 compile_server
实现 oj_server
基于文件版本的在线 OJ
前端页面的设计
2. compile_server 服务设计
提供的服务 :编译并运行代码,得到格式化的相关结果。
在正式编写编译功能之前,我们先来编写一下 comm 公共模块,因为后面其他模块可能都需要用到其中的方法,所以我们先来实现公共模块中的一些需求。
公共模块包含两个部分:简化版日志 、实用工具部分 。
我们先来看简化版日志的设计:
简化版日志代码(log.hpp)
#pragma once
#include <iostream>
#include <string>
#include "util.hpp"
namespace ns_log {
using namespace ns_util;
enum { INFO, DEBUG, WARNING, ERROR, FATAL };
inline std::ostream &Log (const std::string &level, const std::string &file_name, int line) {
std::string message = "[" ;
message += level;
message += "]" ;
message += "[" ;
message += file_name;
message += "]" ;
message += ;
message += std:: (line);
message += ;
message += ;
message += TimeUtil:: ();
message += ;
std::cout << message;
std::cout;
}
}
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
"["
to_string
"]"
"["
GetTimeStamp
"]"
return
#define LOG(level) Log(#level, __FILE__, __LINE__)
这里我们定义了一个命名空间 ns_log,这样做可以防止与其他代码发生冲突;
在设计 Log 函数的时候,将其设计为内联函数,因为这个函数在后面会被我们频繁调用,且它整体代码长度也不长,这样可以减少函数调用的栈帧开销,提高效率。
在函数体中,我们直接构造 message,实现格式化日志输出,返回 std::ostream&支持链式调用,这里注意我们在代码中没有主动刷新缓冲区。
在最后用到了宏定义,使用了 __FILE__和__LINE__这两个 C++ 预定义宏,分别自动获取当前源文件名和行号,用于定位日志产生的位置(调试关键信息),宏中的#level是预处理器的字符串化操作,将宏参数level直接转换为字符串字面量,这样用户就不需要手动传入字符串了,直接使用宏,传入日志等级即可输出日志,开放式日志支持用户输入多条日志消息(通过<<操作符)。
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <atomic>
#include <fstream>
#include <boost/algorithm/string.hpp>
namespace ns_util {
class TimeUtil {
public :
static std::string GetTimeStamp () {
struct timeval _time;
gettimeofday (&_time, nullptr );
return std::to_string (_time.tv_sec);
}
static std::string GetTimeMS () {
struct timeval _time;
gettimeofday (&_time, nullptr );
return std::to_string (_time.tv_sec * 1000 + _time.tv_usec / 1000 );
}
};
const std::string temp_path = "./temp/" ;
class PathUtil {
public :
static std::string Addsuffix (const std::string &filename, const std::string &suffix) {
std::string pathname = temp_path;
pathname += filename;
pathname += suffix;
return pathname;
}
static std::string Src (const std::string &filename) { return Addsuffix (filename, ".cpp" ); }
static std::string Exe (const std::string &filename) { return Addsuffix (filename, ".exe" ); }
static std::string CompilerError (const std::string &filename) { return Addsuffix (filename, ".compile_error" ); }
static std::string Stdin (const std::string &filename) { return Addsuffix (filename, ".stdin" ); }
static std::string Stdout (const std::string &filename) { return Addsuffix (filename, ".stdout" ); }
static std::string Stderr (const std::string &filename) { return Addsuffix (filename, ".stderr" ); }
};
class Fileutil {
public :
static bool IsFileExists (const std::string &path_name) {
struct stat st;
if (stat (path_name.c_str (), &st) == 0 ) return true ;
return false ;
}
static std::string UniqFileName () {
static std::atomic_uint id (0 ) ;
id++;
std::string ms = TimeUtil::GetTimeMS ();
std::string uniq_id = std::to_string (id);
return ms + "_" + uniq_id;
}
static bool WriteFile (const std::string &target, const std::string &content) {
std::ofstream out (target) ;
if (!out.is_open ()) { return false ; }
out.write (content.c_str (), content.size ());
out.close ();
return true ;
}
static bool ReadFile (const std::string &target, std::string *content, bool keep = false ) {
(*content).clear ();
std::ifstream in (target) ;
if (!in.is_open ()) { return false ; }
std::string line;
while (std::getline (in, line)) {
(*content) += line;
(*content) += (keep ? "\n" : "" );
}
in.close ();
return true ;
}
};
class StringUtil {
public :
static void SplitString (const std::string &str, std::vector<std::string> *target, const std::string sep) {
boost::split ((*target), str, boost::is_any_of (sep), boost::algorithm::token_compress_on);
}
};
}
本段代码涉及到四个功能类,分别为 TimeUtil、PathUtil、FileUtil、StringUtil;
一、TimeUtil 类:时间戳生成工具,聚焦多精度时间获取
提供了两个接口,分别是获得秒级时间戳和获得毫秒级时间戳,使用 gettimeofday(来自<sys/time.h>);
两个函数均为 static 函数,包括后面其他工具类中的函数也都是静态的,无需实例化对象就可以直接调用,简化了使用方式。
二、PathUtil 类:路径生成工具,聚焦临时文件路径标准化
1. 基于'基础路径 + 文件名 + 后缀'的统一规则
设置了全局常量 temp_path 作为所有临时文件的根目录,再通过核心函数 Addsuffix 来完成路径拼接。
在 Addsuffix 的基础上,封装出了各种文件的专用函数,避免用户手动拼接后缀。
编译相关:Src(.cpp 源文件)、Exe(.exe 可执行文件)、CompilerError(.compiler_error 编译错误文件);
运行相关:Stdin(.stdin 标准输入文件)、Stdout(.stdout 文件)、Stderr(.stderr 标准错误文件)
三、FileUtil 类:文件操作工具,聚焦文件读写与唯一性保证
通过系统调用 stat 来检查文件元数据,若调用成功则返回 0,说明文件存在,否则不存在,stat 更轻量适合快速检查。
毫秒级时间戳:通过调用上面时间类中的 GetTimeMS 方法获得。
静态原子计数器:通过 id++(原子操作),来获得唯一序号。
最后将时间戳和序号进行拼接,就可以得到一个唯一的文件名。
WriteFile:使用 std::ofstream 打开目标文件,写入完成后关闭,返回值为 bool 类型表示是否写入成功。
ReadFile:使用 std::ifstream 读取文件,这里注意将 content 内容进行清空操作,保证每次读取的都是一个文件的内容,不会存在几个文件内容追加的现象,使用 getline 函数从打开的文件流中进行读取,同时支持支持通过 keep 参数控制是否保留原文件的换行符,返回类型同样为 bool 类型表示是否读取成功。
四、StringFile:字符串处理工具,负责字符串切分工作
直接复用 boost 库中的 split 函数,快速可靠地完成字符串切分任务。
对外提供 SplitString 函数接口,这里的参数需要注意一下,str 的输入型参数,即要切分的原字符串,target 为输出型参数,它的类型是 vector,即函数最后会将切分好的结果存入到 target 中,sep 为指定分隔符。
2.1 compiler(编译功能) #pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "../comm/util.hpp"
#include "../comm/log.hpp"
namespace ns_complier {
using namespace ns_util;
using namespace ns_log;
class Complier {
public :
Complier () { }
~Complier () { }
static bool Compile (const std::string &filename) {
pid_t res = fork();
if (res < 0 ) {
LOG (ERROR) << "创建子进程失败" << "\n" ;
return false ;
} else if (res == 0 ) {
umask (0 );
int _stderr = open (PathUtil::CompilerError (filename).c_str (), O_CREAT | O_WRONLY, 0644 );
if (_stderr < 0 ) {
LOG (WARNING) << "没有成功形成 stderr 文件" << "\n" ;
exit (1 );
}
dup2 (_stderr, 2 );
execlp ("g++" ,"g++" , "-o" , PathUtil::Exe (filename).c_str (), PathUtil::Src (filename).c_str (), "-std=c++11" , "-D" ,"COMPILER_ONLINE" ,nullptr );
LOG (ERROR) << "启动编译器 g++失败,可能是参数错误" << "\n" ;
exit (2 );
} else {
waitpid (res, nullptr , 0 );
if (Fileutil::IsFileExists (PathUtil::Exe (filename))) {
LOG (INFO) << PathUtil::Exe (filename) << "编译成功!" << "\n" ;
return true ;
}
}
LOG (ERROR) << "编译失败,没有形成可执行程序" << "\n" ;
return false ;
}
};
}
核心功能 :通过调用 g++ 将源文件编译成为可执行程序,并对编译过程中的错误和结果进行管理。
通过 fork() 创建子进程:父进程负责等待和判断结果,子进程负责执行编译,如果创建子进程失败了,程序回直接返回,并且打印错误日志。
调整文件权限掩码,确保后续创建的错误文件权限正确。
以读写的方式打开编译错误文件,用于存储 g++ 的错误输出。
重定向标准错误到上面打开的错误文件中,使 g++ 的错误信息存入到错误文件中。
利用进程替换,调用 execlp 执行 g++ 命令,参数为'g++ -o 可执行程序 源文件 -std=c++11',这里需要指定 c++11 标准,避免语法兼容问题。
如果 execlp 执行失败,那么输出一条日志信息,然后退出子进程。
通过 waitpid 等待子进程结束(确保编译过程完成)。
利用之前文件类中功能函数检查是否存在可执行文件,存在则说明编译成功,输出日志;否则说明编译失败,同样输出日志,并返回 false。
2.2 run(运行功能) 这里我们首先要有一个概念,就是运行模块只考虑代码有没有运行结束,不考虑最终结果的正确与否,结果是否正确是由测试用例来决定的,本模块只负责将程序运行完成。
#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include "../comm/util.hpp"
#include "../comm/log.hpp"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>
namespace ns_runner {
using namespace ns_util;
using namespace ns_log;
class Runner {
public :
Runner () {}
~Runner () {}
public :
static void SetProcLimit (int _cpu_limit, int _mem_limit) {
struct rlimit cpu_rlimit;
cpu_rlimit.rlim_max = RLIM_INFINITY;
cpu_rlimit.rlim_cur = _cpu_limit;
setrlimit (RLIMIT_CPU, &cpu_rlimit);
struct rlimit mem_rlimit;
mem_rlimit.rlim_max = RLIM_INFINITY;
mem_rlimit.rlim_cur = _mem_limit * 1024 ;
setrlimit (RLIMIT_AS, &mem_rlimit);
}
static int Run (const std::string &file_name, int cpu_limit, int mem_limit) {
std::string _execute = PathUtil::Exe (file_name);
std::string _stdin = PathUtil::Stdin (file_name);
std::string _stdout = PathUtil::Stdout (file_name);
std::string _stderr = PathUtil::Stderr (file_name);
umask (0 );
int _stdin_fd = open (_stdin.c_str (), O_CREAT | O_RDONLY, 0644 );
int _stdout_fd = open (_stdout.c_str (), O_CREAT | O_WRONLY, 0644 );
int _stderr_fd = open (_stderr.c_str (), O_CREAT | O_WRONLY, 0644 );
if (_stdin_fd < 0 || _stdout_fd < 0 || _stderr_fd < 0 ) {
LOG (ERROR) << "运行时打开标准文件失败" << "\n" ;
return -1 ;
}
pid_t pid = fork();
if (pid < 0 ) {
LOG (ERROR) << "运行时创建子进程失败" << "\n" ;
close (_stdin_fd);
close (_stdout_fd);
close (_stderr_fd);
return -2 ;
} else if (pid == 0 ) {
dup2 (_stdin_fd, 0 );
dup2 (_stdout_fd, 1 );
dup2 (_stderr_fd, 2 );
SetProcLimit (cpu_limit, mem_limit);
execl (_execute.c_str (), _execute.c_str (), nullptr );
exit (1 );
} else {
close (_stdin_fd);
close (_stdout_fd);
close (_stderr_fd);
int status = 0 ;
waitpid (pid, &status, 0 );
LOG (INFO) << "运行完毕" << (status & 0x7f ) << "\n" ;
return status & 0x7f ;
}
}
};
}
目标:限制进程的 CPU 时间和内存空间,类似于 OJ 中空间、时间限制
使用 struct rlimit 结构体配置 CPU 限制和内存限制,硬限制设为 RLIM_INFINITY(无限),我们传入的参数是用来设置 rlim_cur(软限制)的,最后通过 setrlimit 将配置应用到进程。
目标:启动可执行程序,管理其输入输出,限制资源,并返回运行状态。
通过工具类中的路径类来生成运行时需要的标准文件,然后用 open 来打开并创建对应文件,获取对应的文件描述符,如果有任意一个文件打开失败,那么输出日志消息,退出程序。
IO 重定向:通过调用 dup2() 将标准输入、标准输出、标准错误全部重定向到对应的标准文件中,实现程序 IO 与文件的联系。
资源限制:调用上面实现好的接口,直接将传入的时间、空间限制参数传给实现好的功能函数,完成资源限制操作。
执行程序:通过 execl() 执行可执行文件,如果 execl 返回则说明执行失败,此时就会立即退出子进程。
关闭不再需要的文件描述符,调用 waitpid 阻塞等待子进程运行结束,获取其退出状态 status,并且通过位运算来判断程序是'正常退出'还是'因异常信号终止'(如段错误、超时等)。如果为 0 说明程序正常运行完毕了,如果大于 0,说明子进程接收到了系统的信号,那么这个值就是信号的编号。
2.3 compile_run(编译运行功能) #include "compiler.hpp"
#include "run.hpp"
#include "../comm/util.hpp"
#include "../comm/log.hpp"
#include <jsoncpp/json/json.h>
#include <signal.h>
#include <unistd.h>
namespace ns_compile_and_run {
using namespace ns_util;
using namespace ns_log;
using namespace ns_complier;
using namespace ns_runner;
class CompileAndRun {
public :
static void RemoveTempFile (const std::string &file_name) {
std::string _src = PathUtil::Src (file_name);
if (Fileutil::IsFileExists (_src)) unlink (_src.c_str ());
std::string _compile_error = PathUtil::CompilerError (file_name);
if (Fileutil::IsFileExists (_compile_error)) unlink (_compile_error.c_str ());
std::string _execute = PathUtil::Exe (file_name);
if (Fileutil::IsFileExists (_execute)) unlink (_execute.c_str ());
std::string _stdin = PathUtil::Stdin (file_name);
if (Fileutil::IsFileExists (_stdin)) unlink (_stdin.c_str ());
std::string _stdout = PathUtil::Stdout (file_name);
if (Fileutil::IsFileExists (_stdout)) unlink (_stdout.c_str ());
std::string _stderr = PathUtil::Stderr (file_name);
if (Fileutil::IsFileExists (_stderr)) unlink (_stderr.c_str ());
}
static std::string CodeToDesc (int code, const std::string &file_name) {
std::string desc;
switch (code) {
case 0 : desc = "编译运行成功" ; break ;
case -1 : desc = "用户提交的代码为空" ; break ;
case -2 : desc = "未知错误" ; break ;
case -3 :
Fileutil::ReadFile (PathUtil::CompilerError (file_name), &desc, true ); break ;
case SIGABRT:
desc = "内存超出范围" ; break ;
case SIGXCPU:
desc = "CPU 运行超时" ; break ;
case SIGFPE: desc = "浮点数溢出" ; break ;
default : desc = "未知" + std::to_string (code); break ;
}
return desc;
}
static void Start (const std::string in_json, std::string *out_json) {
Json::Value in_value;
Json::Reader reader;
reader.parse (in_json, in_value);
std::string code = in_value["code" ].asString ();
std::string input = in_value["input" ].asString ();
int cpu_limit = in_value["cpu_limit" ].asInt ();
int mem_limit = in_value["mem_limit" ].asInt ();
int status_code = 0 ;
Json::Value out_value;
int run_result = 0 ;
std::string file_name;
if (code.size () == 0 ) {
status_code = -1 ;
goto END;
}
file_name = Fileutil::UniqFileName ();
if (!Fileutil::WriteFile (PathUtil::Src (file_name), code)) {
status_code = -2 ;
goto END;
}
if (!Complier::Compile (file_name)) {
status_code = -3 ;
goto END;
}
run_result = Runner::Run (file_name, cpu_limit, mem_limit);
if (run_result < 0 ) {
status_code = -2 ;
goto END;
} else if (run_result > 0 ) {
status_code = run_result;
} else {
status_code = 0 ;
}
END:
out_value["status" ] = status_code;
out_value["reason" ] = CodeToDesc (status_code, file_name);
if (status_code == 0 ) {
std::string _stdout;
Fileutil::ReadFile (PathUtil::Stdout (file_name), &_stdout, true );
out_value["stdout" ] = _stdout;
std::string _stderr;
Fileutil::ReadFile (PathUtil::Stderr (file_name), &_stderr, true );
out_value["stderr" ] = _stderr;
}
Json::StreamWriterBuilder writer_builder;
writer_builder["emitUTF8" ] = true ;
writer_builder["indentation" ] = " " ;
std::unique_ptr<Json::StreamWriter> writer (writer_builder.newStreamWriter()) ;
std::ostringstream oss;
writer->write (out_value, &oss);
*out_json = oss.str ();
RemoveTempFile (file_name);
}
};
}
一、RemoveTempFile:临时文件的全生命周期回收
**1. 定位所有临时文件路径:**通过 PathUtil 分别获取源文件、编译错误文件、标准输入文件、标准输出文件、标准错误文件的路径。
**2. 存在性检查与删除:**对于每个路径,利用 FileUtil 中的方法检查其是否存在,如果存在,就调用 unlink 系统调用接口删除文件。
功能:将抽象的'状态码'转化为可读性较高的'错误/成功描述',以便问题排查和结果展示。
分类处理状态码:通过 switch-case 语句覆盖各种可能的状态类型;
流程性错误:如状态码为 -1,表示用户代码为空;状态码为 -2,表示未知错误。
编译错误:状态码为 -3 的时候,读取编译错误的文件作为描述。
运行时信号异常:如果代码运行过程中接收到了系统信号,那么我们转化为对应的描述即可。
默认分支:对未定义的状态码,返回'未知 + 编号'的兜底描述。
三、Start:编译 - 运行全流程串联与结果封装
功能:整合'编译、运行、JSON 处理、资源清理'全流程,完成'用户代码→编译→运行→结果返回'的端到端逻辑。
**1. 输入 JSON 解析:**调用第三方库,用 Jsoncpp 的 Json::Reader 解析输入字符串 in_json,提取 code(用户代码)、input(程序输入)、cpu_limit(CPU 时间限制)、mem_limit(内存限制)等核心参数。
**2. 生成唯一临时文件名:**调用 FileUtil 中的 UniFileName 方法生成全局唯一的文件名。
**3. 将用户代码写入临时源文件:**调用 FileUtil 中的 WriteFile 方法将用户代码写入对应生成的源文件中,如果写入失败,设置状态码为 -2,然后跳转到结果构造阶段。
**4. 调用编译模块:**调用 Compiler 类中的 Compile 方法对代码进行编译处理,如果编译失败,设置状态码为 -3,然后跳转到结果构造阶段。
**5. 调用运行模块:**编译成功后,调用运行功能,根据运行结果分三种情况;
run_result<0:运行过程中内部出现错误,设置状态码为 -2。
run_result>0:程序运行收到了系统的信号,设置状态码为 run_result(信号编号)。
run_result=0:程序正常运行完毕,设置状态码为 0。
**6. 输出 JSON 构造:**在 END 标签处,用 out_value 构造输出结构:
必选字段:'status'状态码、'reason'状态描述。
可选字段:如果状态码为 0,那么说明整个编译运行的过程都是没有问题的。这时候我们可以再添加两个字段,分别读取程序标准输出和标准错误的内容,设置到'stdout'和'stderr'字段。
**7. JSON 序列化和资源清理:**将 out_value 序列化为格式化字符串,这里的 out_value 是一个输出型参数,我们最终将 out_value 返回即可,最后再清理编译运行过程中产生的所有临时文件,保证磁盘资源回收。
下面我们来进行一下简单的测试,测试一下编译运行模块有没有什么问题;
#include "compile_run.hpp"
using namespace ns_compile_and_run;
int main () {
std::string in_json;
Json::Value in_value;
in_value["code" ] = R"(
#include <iostream>
int main() {
std::cout << "测试成功哈哈哈" << std::endl;
return 0;
}
)" ;
in_value["input" ] = "" ;
in_value["cpu_limit" ] = 1 ;
in_value["mem_limit" ] = 10240 * 3 ;
Json::FastWriter writer;
in_json = writer.write (in_value);
std::cout << in_json << std::endl;
std::string out_json;
CompileAndRun::Start (in_json, &out_json);
std::cout << out_json << std::endl;
return 0 ;
}
这里大家可以看到,我们的代码成功完成了编译并运行的功能,证明我们前面的代码是 OK 的。
2.4 将编译运行功能打包成一个网络服务 #include "compile_run.hpp"
#include "../comm/httplib.h"
using namespace ns_compile_and_run;
using namespace httplib;
void Usage (std::string proc) {
std::cerr << "Usage:" << "\n\t" << proc << "port" << std::endl;
}
int main (int argc, char *argv[]) {
if (argc != 2 ) {
Usage (argv[0 ]);
return 1 ;
}
Server svr;
svr.Post ("/compile_and_run" , [](const Request &req, Response &resp) {
std::string in_json=req.body;
std::string out_json;
std::cout << "compile run request " << std::endl;
if (!in_json.empty ()) {
std::cout << "!in_json.empty() " << std::endl;
CompileAndRun::Start (in_json,&out_json);
resp.set_content (out_json,"application/json;charset=utf-8" );
}
});
svr.listen ("0.0.0.0" , atoi (argv[1 ]));
return 0 ;
}
这里我们需要引入 cpp-httplib 第三方库,使用其中的方法就可以将我们的编译运行功能打包成一个网络服务,下面我们用 postman 来测试一下我们的网络服务:
这里我们可以看到,应答字符串按照我们要求的格式进行了输出,这就说明我们成功地将我们的编译运行功能打包成了一个网络服务。
3. 基于 MVC 结构的 oj_server 服务设计 在设计 oj_server 之前,先来介绍一下本模块需要完成的功能以及什么是 MVC;
获取首页,用题目列表充当。
编辑区域页面。
提交判题功能 (编译并运行)。
M: Model,通常是和数据交互的模块,比如,对题库进行增删改查(文件版,MySQL)。
V: view,通常是拿到数据之后,要进行构建网页,渲染网页内容,展示给用户的 (浏览器)。
C: control,控制器,就是我们的核心业务逻辑。
3.1 model 模块(提供对数据的操作) #pragma once
#include <iostream>
#include <string>
#include <unordered_map>
#include <cassert>
#include <cstdlib>
#include <vector>
#include <fstream>
#include "../comm/log.hpp"
#include "../comm/util.hpp"
namespace ns_model {
using namespace std;
using namespace ns_log;
using namespace ns_util;
struct Question {
string number;
string title;
string star;
int cpu_limit;
int mem_limit;
string desc;
string header;
string tail;
};
const string questions_list = "./questions/questions.list" ;
const string questions_path = "./questions/" ;
class Model {
private :
unordered_map<string, Question> questions;
public :
Model () {
assert (LoadQuestionList (questions_list));
}
bool LoadQuestionList (const string &question_list) {
ifstream in (question_list) ;
if (!in.is_open ()) {
LOG (FATAL)<<"加载题库失败,请检查是否存在配置文件" <<"\n" ;
return false ;
}
string line;
while (getline (in, line)) {
vector<string> tokens;
StringUtil::SplitString (line, &tokens, " " );
if (tokens.size () != 5 ) {
LOG (WARNING)<<"加载部分题目失败,请检查文件格式" <<"\n" ;
continue ;
}
Question q;
q.number = tokens[0 ];
q.title = tokens[1 ];
q.star = tokens[2 ];
q.cpu_limit = atoi (tokens[3 ].c_str ());
q.mem_limit = atoi (tokens[4 ].c_str ());
string path = questions_path;
path += q.number;
path += "/" ;
Fileutil::ReadFile (path + "/desc.txt" , &(q.desc), true );
Fileutil::ReadFile (path + "/header.cpp" , &(q.header), true );
Fileutil::ReadFile (path + "/tail.cpp" , &(q.tail), true );
questions.insert ({q.number,q});
}
LOG (INFO)<<"加载题目成功" <<"\n" ;
in.close ();
return true ;
}
bool GetAllQuestions (vector<Question> *out) {
if (questions.size () == 0 ) {
LOG (ERROR)<<"用户获取题目失败" <<"\n" ;
return false ;
}
for (const auto &q : questions) {
out->push_back (q.second);
}
return true ;
}
bool GetOneQuestion (const string &number, Question *q) {
const auto &iter = questions.find (number);
if (iter == questions.end ()) {
LOG (ERROR)<<"用户获取题目失败,题目编号:" <<number<<"\n" ;
return false ;
}
(*q) = iter->second;
return true ;
}
~Model () { }
};
}
核心功能:封装单道题目的所有属性,作为数据传输和存储的载体。
聚合数据所有信息:包含题目的各种信息,包括'标识类'(编号、标题、难度)、'资源限制类'(CPU 限制、内存限制)、'代码/描述类'(题目描述、预设代码、测试用例)。
所有属性均为内置类型或者 string 类型,结构清晰,便于管理。
核心功能:初始化题库,确保程序启动前题库加载完毕。
利用 assert 直接对加载结果进行断言,如果加载失败程序将直接终止。确保后续对题目进行操作的接口不会对空数据进行操作。
2. 核心加载逻辑:LoadQuestionList
核心功能:从文件系统读取并解析题库数据,填充到内存存储中。
打开配置文件:读取 question.list 文件,如果读取失败,那么输出 FATAL 级别日志,然后直接返回 false,表示加载失败了。
逐行解析配置
每行对应一道题,此时就需要用到我们前面在工具类中封装好的 StringUtil::SplitString 方法来对字符串进行切分,提取'编号 标题 难度 CPU 限制 内存限制'5 个核心字段。
如果切分后的格式错误(不是 5 部分),那么输出日志,跳过当前行,继续切分下一行,这样就不会影响其他题目加载。
调用 Fileutil::ReadFile 分别读取题目描述、预设代码、测试用例,填充到 Question 对象中。
内存存储:将 Question 对象加载到哈希表中,完成一道题目的加载。
收尾:加载成功,输出日志,关闭文件。
核心功能:获取全部题目列表,适用于前端展示题库清单。
遍历哈希表,将数据逐一存储到 out 数组中,这里的 out 是输出型参数,最后所有题目的信息将存放在这个数组中并返回给调用层。
如果题库为空,那就返回 false,否则返回 true,明确告诉调用获取数据是否成功。
核心功能:获取某一道题目,适用于用户选择某题后加载详情。
利用 find 函数直接按照题号查找,时间复杂度为 O(1),效率较高。
存储的 unordered_map 和 Question 对象均为栈上或标准库容器管理的资源,无需手动释放资源。
3.2 view 模块(网页渲染) #pragma once
#include <iostream>
#include <string>
#include <vector>
#include <ctemplate/template.h>
#include "oj_model.hpp"
namespace ns_view {
using namespace ns_model;
const std::string template_path = "./template_html/" ;
class View {
public :
View () { }
void AllExpandHtml (const vector<struct Question> &questions, std::string *html) {
std::string src_html = template_path + "all_questions.html" ;
ctemplate::TemplateDictionary root ("all_questions" ) ;
for (const auto &q : questions) {
ctemplate::TemplateDictionary *sub = root.AddSectionDictionary ("question_list" );
sub->SetValue ("number" , q.number);
sub->SetValue ("title" , q.title);
sub->SetValue ("star" , q.star);
}
ctemplate::Template *tpl = ctemplate::Template::GetTemplate (src_html, ctemplate::DO_NOT_STRIP);
tpl->Expand (html, &root);
}
void OneExpandHtml (const struct Question &q, std::string *html) {
std::string src_html = template_path + "one_question.html" ;
ctemplate::TemplateDictionary root ("one_question" ) ;
root.SetValue ("number" , q.number);
root.SetValue ("title" , q.title);
root.SetValue ("star" , q.star);
root.SetValue ("desc" , q.desc);
root.SetValue ("pre_code" , q.header);
ctemplate::Template *tpl = ctemplate::Template::GetTemplate (src_html, ctemplate::DO_NOT_STRIP);
tpl->Expand (html, &root);
}
};
}
本模块核心功能:依赖 ctemplate 模板库,完成'数据→HTML 页面'的渲染工作,具体功能分为两类:
将所有题目的核心信息(编号、标题、难度)填充到列表模板,生成题库首页 HTML(供用户浏览所有题目);
拼接模板路径->构建列表型数据字典->加载 HTML 模板->数据填充+HTML 生成。
将单道题的完整信息(编号、标题、难度、描述、预设代码)填充到详情模板,生成做题页面 HTML(供用户查看题目要求并编写代码)。
3.3 control 模块(核心逻辑控制) 本模块包含三个核心类,职责层层递进:Machine(主机数据载体)->LoadBalance(负载均衡管理)->Control(业务逻辑整合),形成'资源管理->调度->业务落地'的完整链路。
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <mutex>
#include <cassert>
#include <fstream>
#include <algorithm>
#include <jsoncpp/json/json.h>
#include "oj_model.hpp"
#include "../comm/log.hpp"
#include "../comm/util.hpp"
#include "../comm/httplib.h"
#include "oj_view.hpp"
namespace ns_control {
using namespace std;
using namespace ns_log;
using namespace ns_util;
using namespace ns_model;
using namespace ns_view;
using namespace httplib;
class Machine {
public :
std::string ip;
int port;
uint64_t load;
std::mutex *mtx;
public :
Machine () : ip ("" ), port (0 ), load (0 ), mtx (nullptr ) { }
~Machine () { }
public :
void IncLoad () {
if (mtx) mtx->lock ();
++load;
if (mtx) mtx->unlock ();
}
void DecLoad () {
if (mtx) mtx->lock ();
--load;
if (mtx) mtx->unlock ();
}
void ResetLoad () {
if (mtx) mtx->lock ();
load = 0 ;
if (mtx) mtx->unlock ();
}
uint64_t Load () {
uint64_t _load = load;
if (mtx) mtx->lock ();
_load = load;
if (mtx) mtx->unlock ();
return _load;
}
};
const std::string service_machine = "./conf/service_machine.conf" ;
class LoadBalance {
private :
std::vector<Machine> machines;
std::vector<int > online;
std::vector<int > offline;
std::mutex mtx;
public :
LoadBalance () {
assert (LoadConf (service_machine));
LOG (INFO) << "加载" << service_machine << "成功" << "\n" ;
}
~LoadBalance () { }
public :
bool LoadConf (const std::string &machine_conf) {
std::ifstream in (machine_conf) ;
if (!in.is_open ()) {
LOG (FATAL) << "加载" << machine_conf << "失败" << "\n" ;
return false ;
}
std::string line;
while (getline (in, line)) {
std::vector<std::string> tokens;
StringUtil::SplitString (line, &tokens, ":" );
if (tokens.size () != 2 ) {
LOG (WARNING) << "切分" << line << "失败" << "\n" ;
continue ;
}
Machine m;
m.ip = tokens[0 ];
m.port = atoi (tokens[1 ].c_str ());
m.load = 0 ;
m.mtx = new std::mutex ();
online.push_back (machines.size ());
machines.push_back (m);
}
in.close ();
return true ;
}
bool SmartChoice (int *id, Machine **m) {
mtx.lock ();
int online_num = online.size ();
if (online_num == 0 ) {
mtx.unlock ();
LOG (FATAL) << "所有后端编译主机全部离线,请尽快检查" << "\n" ;
return false ;
}
*id = online[0 ];
*m = &machines[online[0 ]];
uint64_t min_load = machines[online[0 ]].Load ();
for (int i = 1 ; i < online_num; i++) {
uint64_t cur_load = machines[online[i]].Load ();
if (cur_load < min_load) {
min_load = cur_load;
*id = online[i];
*m = &machines[online[i]];
}
}
mtx.unlock ();
return true ;
}
void OfflineMachine (int which) {
mtx.lock ();
for (auto iter = online.begin (); iter != online.end (); iter++) {
if (*iter == which) {
machines[which].ResetLoad ();
online.erase (iter);
offline.push_back (which);
break ;
}
}
mtx.unlock ();
}
void OnlineMachine () {
mtx.lock ();
online.insert (online.end (), offline.begin (), offline.end ());
offline.erase (offline.begin (), offline.end ());
mtx.unlock ();
LOG (INFO) << "所有的主机有上线啦!" << "\n" ;
}
};
class Control {
private :
Model _model;
View _view;
LoadBalance _load_balance;
public :
Control () { }
~Control () { }
public :
void RecoveryMachine () {
_load_balance.OnlineMachine ();
}
bool AllQuestions (string *html) {
vector<struct Question> all;
if (_model.GetAllQuestions (&all)) {
sort (all.begin (), all.end (), [](const struct Question &q1, const struct Question &q2) {
return atoi (q1. number.c_str ()) < atoi (q2. number.c_str ());
});
_view.AllExpandHtml (all, html);
} else {
*html = "获取题目失败,形成题目列表失败" ;
return false ;
}
return true ;
}
bool Question (const string &number, string *html) {
struct Question q;
if (_model.GetOneQuestion (number, &q)) {
_view.OneExpandHtml (q, html);
} else {
*html = "指定题目" + number + "不存在!" ;
return false ;
}
return true ;
}
void Judge (const std::string &number, const std::string &in_json, std::string *out_json) {
struct Question q;
_model.GetOneQuestion (number, &q);
Json::Value in_value;
Json::Reader reader;
reader.parse (in_json, in_value);
std::string code = in_value["code" ].asString ();
Json::Value compile_value;
compile_value["input" ] = in_value["input" ].asString ();
compile_value["code" ] = code + q.tail;
compile_value["cpu_limit" ] = q.cpu_limit;
compile_value["mem_limit" ] = q.mem_limit;
Json::FastWriter writer;
std::string compile_string = writer.write (compile_value);
while (true ) {
int id = 0 ;
Machine *m = nullptr ;
if (!_load_balance.SmartChoice (&id, &m)) break ;
LOG (INFO) << "选择主机成功,主机 id:" << id << "详情:" << m->ip << ":" << m->port << "\n" ;
Client cli (m->ip, m->port) ;
m->IncLoad ();
LOG (INFO) << "选择主机成功,主机 id: " << id << " 详情:" << m->ip << ":" << m->port << " 当前主机的负载是:" << m->Load () << "\n" ;
if (auto res = cli.Post ("/compile_and_run" , compile_string, "application/json;charset=utf-8" )) {
if (res->status == 200 ) {
*out_json = res->body;
m->DecLoad ();
LOG (INFO) << "请求成功" << "\n" ;
break ;
}
m->DecLoad ();
} else {
LOG (ERROR) << "当前请求主机的 id:" << id << "详情:" << m->ip << ":" << m->port << "可能已经离线" << "\n" ;
_load_balance.OfflineMachine (id);
}
}
}
};
}
核心功能:封装单台编译服务主机的核心信息(IP、端口号)和运行状态(负载),提供线程安全的负载操作接口,为负载均衡提供基础数据支持。
**设计初衷:**编译服务是多进程并发调用的,load 作为共享资源,必须通过锁机制保证操作安全。
线程安全:mtx,互斥锁,保护 load 变量的并发修改。
InLoad()/DeLoad():负载递增/递减,加锁保证多线程下负载操作的原子性。
ResetLoad():负载重置,主机离线时调用,避免故障后负载残留。
Load():获取当前负载,加锁保证读取到的是最新值(避免内存可见性问题)。
核心功能:管理所有编译主机的生命周期(在线 / 离线),实现'最小负载选择'的负载均衡算法,确保编译任务高效、稳定分配。
负载均衡:避免单台主机过载,提升系统整体并发能力;
容错机制:故障主机自动下线,保证业务不中断;批量上线机制简化故障恢复流程。
读取 service_machine.conf 配置文件,格式:IP:端口,逐行解析主机信息。
每台主机对应一个 Machine 对象,初始化负载为 0,分配互斥锁,这里有一个细节,我们将 m 对象填充好后,先进行操作 online 数组,我们把每台主机的下标当作主机 id,所以在第一次调用时,此时 machines 数组中还没有元素,所以这里插入的就是 0,表示 0 号主机已经在线了。
如果解析失败(字段数!=2),那么就跳过当前行,这样做不影响其他主机加载,提升鲁棒性。
2. 核心调度:最小负载选择(SmartChoice)
加锁保护主机状态,避免选择过程中主机状态发生变化。
遍历 online 数组,筛选出负载最小的主机,返回其 ID 和指针,这里 idh 和 m 都是输出型参数,并且这里 m 是一个二级指针,这样做是为了在我们找到负载最小的主机后,我们返回其地址,这样就可以直接访问这台主机,就不需要在去遍历寻找了。
如果没有在线主机,输出 FATAL 日志并返回 false,提示运维检查。
3. 主机状态管理(OfflineMachine/OnlineMachine)
OfflineMachine:将故障主机(请求失败)从 online 数组移至 offline 数组,重置其负载,避免后续任务分配到故障主机。
OnlineMachine:故障恢复后,将 offline 数组中的所有主机批量地移回 online,快速恢复服务。
核心功能:作为这个系统是'中枢神经',整合 Model(数据)、View(视图)、LoadBalance(负载均衡),对外提供三大核心业务接口。
业务整合:将分散的模块(数据、视图、负载均衡)串联成完整业务流程,对外提供统一接口;
容错重试:判题时自动重试离线主机,提升服务可用性;
解耦设计:Control 不关心模块内部实现(如 Model 如何加载题库、LoadBalance 如何选主机),仅调用接口,便于后续替换模块。
1. AllQuestions:渲染所有题目列表(题库首页)
数据获取:调用 GetAllQuestions,从题库中获取所有题目数据;
用户体验优化:按题号进行升序排序,方便用户按序号浏览;
视图渲染:调用 AllExpandHtml,将排序后的题目数据填充到 HTML 模板中,生成题库首页;
错误处理:获取题目失败,返回明确的错误提示 HTML。
精准查询:按用户传入的题号,调用 GetOneQuestion 获取单题完整数据(描述、预设代码、测试用例);
视图渲染:调用 OneExpandHtml,生成包含题目要求、代码编辑区的做题页面;
错误处理:当题号不存在时,返回错误提示 HTML;
解析输入 JSON:通过 jsoncpp 解析前端传入的 in_json,提取用户提交的代码(code)和输入(input);
拼接完整代码:将用户代码与题目自带的测试用例进行拼接,形成'用户代码 + 测试逻辑'的完整可编译代码;
构造编译请求参数:封装拼接后的代码、用户输入、题目要求的资源限制(CPU、内存),序列化为 JSON 字符串;
选择可用编译主机:循环调用 SmartChoice,直到选中可用主机;
跨服务通信:通过 httplib 发起 POST 请求,调用后台的编译运行服务;
响应成功(状态码 200):将编译运行后的结果赋值给 out_json,返回给前端;
响应失败:将该主机标记为离线,继续选择下一台主机。
返回结果:将最终判题结果(成功 / 失败、输出 / 错误信息)序列化后返回。
4. 顶层代码设计 #include <iostream>
#include <signal.h>
#include "../comm/httplib.h"
#include "oj_control.hpp"
using namespace httplib;
using namespace ns_control;
static Control *ctrl_ptr = nullptr ;
void Recovery (int signo) {
ctrl_ptr->RecoveryMachine ();
}
int main () {
signal (SIGQUIT, Recovery);
Server svr;
Control ctrl;
ctrl_ptr = &ctrl;
svr.Get ("/all_questions" , [&ctrl](const Request &req, Response &resp){
std::string html;
ctrl.AllQuestions (&html);
resp.set_content (html, "text/html; charset=utf-8" );
});
svr.Get (R"(/question/(\d+))" , [&ctrl](const Request &req, Response &resp){
std::string number = req.matches[1 ];
std::string html;
ctrl.Question (number, &html);
resp.set_content (html, "text/html; charset=utf-8" );
});
svr.Post (R"(/judge/(\d+))" , [&ctrl](const Request &req, Response &resp){
std::string number = req.matches[1 ];
std::string result_json;
ctrl.Judge (number, req.body, &result_json);
resp.set_content (result_json, "application/json;charset=utf-8" );
});
svr.set_base_dir ("./wwwroot" );
svr.listen ("0.0.0.0" , 8080 );
return 0 ;
}
本模块将前后端全部整合在一起,提供标准化 HTTP 接口,对接前端三大核心场景(浏览题库、查看单题、提交代码判题),同时支持静态资源托管和故障恢复。
注册 SIGNQUIT 信号(Ctrl+\),绑定 Recovery 函数;
定义全局指针 ctrl_ptr 指向核心业务对象 Control(信号处理函数无法直接访问局部变量,需通过全局指针间接调用)。
功能:当所有编译主机离线后,我们可以通过 Ctrl+\让所有主机重新上线,这样就可以快速恢复服务。
创建 Control 类实例 ctrl,该对象融合了'题库管理、页面渲染、负载均衡、判题逻辑'所有核心业务功能。
路由回调函数通过引用捕获 ctrl,避免重复创建对象。
调用 svr.listen 监听所有网卡的 8080 接口,允许外部通过 IP+Port 进行访问。
服务启动后,持续接收前端 HTTP 请求,分发到对应路由进行处理。
路由是'前端请求'与'后端业务'之间的桥梁,这里注册了三个核心路由;
1. 路由/all_questions(GET 请求):浏览题库列表
功能:返回所有题目信息的 HTML 页面(前端题库首页)。
业务调用:通过调用 AllQuestions 函数获取渲染后的题目列表 HTML(包含题号、标题、难度);
响应封装:设置响应的 Content-Type 为 text/html,charset 为 utf-8,告诉浏览器这是 html 页面,并且
支持中文编码,避免乱码问题,将 HTML 返回给前端。
2. 路由/question/(\d+)(GET 请求,正则匹配):查看单题详情
功能:根据题号返回单道题的详情 HTML 页面(前端做题页面)。
路由匹配:使用正则表达式精准匹配题号,例如/question/2 会匹配成功,反之如果是/question/abc 则会匹配失败;
参数提取:通过 req.matchs[1] 获取正则匹配到的题号;
业务调用:通过调用 Question 函数,传入题号,获取某一题详情 HTML(包含题目描述、代码编辑区、预设代码);
响应封装:同样设置响应的 Content-Type 为 text/html,charset 为 utf-8 编码,返回个性化的做题页面。
3. 路由/judge/(\d+)(POST 请求,正则匹配):提交代码判题
功能:接收用户提交的代码,返回编译运行结果(前端判题交互)。
路由匹配:正则 R"(/judge/(\d+))"匹配题号,确保判题请求与具体题目绑定;
参数提取:通过 req.matchs[1] 获取正则匹配到的题号,req.body 获取前端 POST 提交的 JSON 数据(包含用户代码、输入);
业务调用:调用 Judge 函数,传入题号和请求体,触发判题流程。
响应封装:设置 Content-Type 为 application/json;charset=utf-8(告知前端返回的是 JSON 数据),将**判题结果(状态码、描述、输出 / 错误信息)**返回给前端,供前端解析展示。
功能:提供前端页面所需的静态资源(CSS、JS、图片、静态 HTML 等)。
通过 svr.set_base_dir("./wwwroot") 设置静态资源根目录,httplib 会自动托管该目录下的所有文件;
优势:简化部署流程,无需单独搭建 Nginx 等静态资源服务器,降低运维成本。
.PHONY: all
all:
@cd compile_server;
make;
cd -;
cd oj_server;
make;
cd -;
.PHONY:output
output:
@mkdir -p output/compile_server;
mkdir -p output/oj_server;
cp -rf compile_server/compile_server output/compile_server;
cp -rf compile_server/temp output/compile_server;
cp -rf oj_server/conf output/oj_server/;
cp -rf oj_server/questions output/oj_server/;
cp -rf oj_server/template_html output/oj_server/;
cp -rf oj_server/wwwroot output/oj_server/;
cp -rf oj_server/oj_server output/oj_server/;
.PHONY:clean
clean:
@cd compile_server;
make clean;
cd -;
cd oj_server;
make clean;
cd -;
rm -rf output;
5. 总结 本项目是一款轻量级、模块化、高可用的在线判题系统(Online Judge, OJ),基于 C++ 开发,依托 Linux 系统调用和开源库,实现了'题目管理、代码提交、编译运行、结果反馈'的端到端完整链路。系统采用分层设计思想,模块职责清晰、耦合度低,支持多编译节点负载均衡和故障快速恢复,适用于编程教学、小型竞赛或团队代码评测场景。