项目之在线OJ
目录
在线 OJ 项目背景
在线 OJ 宏观原理
项目技术栈和项目环境
compile_server 模块实现
编写编译模块
编写日志模块
编写运行模块
编写编译和运行整合模块
对 compile 模块进行测试
形成 compile_server 网络服务
测试 cpp-httplib 库
测试 compile_server 网络服务
oj_server 模块实现
设计用户请求的服务路由功能
设计文件版本题库
设计 Model 模块
编写加载题库代码
安装 boost 库
编写获取所有题目详细信息代码
编写获取单个题目详细信息代码
设计 View 模块
安装 ctemplate 模板库
测试使用 ctemplate 第三方库
编写渲染所有题目列表界面的代码
编写渲染单个题目编辑界面的代码
设计 Control 模块
编写获取所有题目详细信息并渲染所有题目的列表页面代码
编写获取单个题目详细信息并渲染单个题目的编辑页面代码
编写负载均衡代码
进行判题
一键上线所有服务
oj_server 模块最终源码
测试 oj_server 网络服务
测试主界面
测试获取所有题目列表界面
测试获取单个题目编辑页面
测试判题服务
构建前端页面
oj_server 服务主页面
oj_server 服务所有题目列表页面
oj_server 服务单个题目页面
前后端交互代码
MySQL 版题库构建
使用 MySQL Workbench 创建数据库表
更改 Model 模块代码
获取所有题目信息
获取单个题目信息
C 语言链接数据库通过 sql 查询题目信息
项目综合调试
项目地址
项目总结
在线 OJ 项目背景
市面上在线 OJ 功能的应用有很多,最热门的比如 newcoder,leetcode。我们以 leecode 为例,leetcode 的在线 OJ 界面如下图所示。
题目显示界面:
在题目显示界面,用户可以点击要做的题目,然后跳转到做题界面。
做题界面:
在做题界面,用户可以根据题目进行代码的编写,提交代码,最终得知编码正确与否。
基于此背景,本项目旨在设计一款类似于 leetcode 的题目显示功能和线上做题功能的一个在线 OJ 项目。
在线 OJ 宏观原理
项目主要分为三个模块。
- comm 模块:实现多个模块公共功能的代码编写。
- compile_server 模块:实现编译与运行功能。
- oj_server 模块:实现获取题目列标,编写题目,负载均衡和其它功能。
项目宏观原理图示如下,
client 端的请求不同时,在后端请求的服务就不同。如果是获取题目列表和编写题目等请求,那么就获取后端的 oj_server 服务,在文件或者数据库中加载对应的题目列表和题目信息等;如果是代码提交的请求,那么获取的就是后端的 compile_server 请求,因为代码提交之后,要进行编译和运行。
项目技术栈和项目环境
- 技术栈:C/C++,STL,BOOST 准标准库(用于字符串切割),cpp-httplib (第三方网络库,用于提供网络服务), jsoncpp (第三方序列化和反序列化库),负载均衡设计,多线程,多进程设计,MySQL C语言连接,Ace 前端在线编辑器,html/css/js/jQuery第三方库(获取前端数据,动态构建前端页面)/ajax(前后端数据交互)
- 开发环境:centos 7,vscode,MySQL workbench
compile_server 模块实现
compile_server 模块主要分为两个大步骤。
- 对源代码进行编译。
- 运行可执行程序。
- 将编译和运行服务进行整合。
编译模块的实现逻辑如下图所示。
编写编译模块
编译模块主要分为四步。
- 创建子进程。
- 子进程对要编译的源文件创建对应的错误文件,并将进程替换之后的错误信息重定向输出到错误文件中,而不是输出到标准错误中。
- 子进程进行进程替换,去编译对应的源文件。
- 父进程等待子进程退出,等到子进程退出之后,父进程开始进行检测,是否在对应的路径下生成了可执行程序,如果生成了可执行程序,则证明子进程编译成功,否则子进程编译失败。
编译模块编码如下。
#pragma once#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>#include "../comm/util.hpp"
#include "../comm/log.hpp"namespace ns_compiler
{using namespace ns_util;using namespace ns_log;class Compiler{public:Compiler() {}~Compiler() {}static bool Compile(const std::string &filename) // 这里的file_name就是单纯的文件名,不带有路径和后缀{int pid = fork();if (pid < 0){LOG(ERROR)<<"创建子进程失败!"<<std::endl;return false;}else if (pid == 0){// 打开stderr文件,要将子进程编译之后,运行错误的文件重定向输出到stderr文件中,int _stderr = open(PathUtil::StdErr(filename).c_str(), O_CREAT | O_WRONLY, 0644);if (_stderr < 0){LOG(ERROR)<<"创建stderr文件失败!"<<std::endl;exit(1);}// 重定向,将输出到标准错误的内容,全部重定向输出到stderr文件中dup2(_stderr, 2);// 这是子进程,子进程进行编译// 子进程进行进程替换函数,执行其它代码// 子进程要对源文件进行编译,就必须得有源文件,所以得先通过文件名获取带文件路径和后缀的源文件execlp("g++", "g++", "-o", PathUtil::Exe(filename).c_str(), PathUtil::Src(filename).c_str(), "-std=c++11", nullptr);LOG(ERROR)<<"编译失败,请检查g++命令行参数传递是否符合规则!"<<std::endl;exit(2);}else{// 这是父进程,父进程要等待子进程waitpid(pid, nullptr, 0);// 判断子进程是否编译成功,即判断对应的路径下是否生成可执行程序if (FileUtil::IsFileExists(PathUtil::Exe(filename))){LOG(INFO)<< PathUtil::Src(filename)<<"编译成功!"<<std::endl;return true;}}LOG(ERROR)<<"没有形成可执行程序,编译失败!"<<std::endl;return false;}};}
需要注意的是,子进程在进行错误文件的创建时,以及编译对应的文件时,是需要知道对应的文件的目录+文件名+文件后缀的,但是编译函数中我们只能知道对应的文件名,所以我们要在工具类中创建对应的类,去获取带文件路径+文件名+后缀的文件名称。工具类中的相关接口是所有的其它头文件和源文件共享的,属于 comm 模块的文件。
// 添加文件路径与文件后缀static std::string AddPathSuffix(const std::string &filename, const std::string &suffix){// 添加文件路径std::string path_name = temp_path;// 添加文件名path_name += filename;// 添加文件后缀path_name += suffix;// 返回 文件路径+文件名称+文件后缀 的完整文件return path_name;}// 根据文件名获取一个带文件路径和后缀的源文件名static std::string Src(const std::string &filename){return AddPathSuffix(filename, ".cc");}// 根据文件名获取一个带文件路径和后缀的可执行程序名static std::string Exe(const std::string &filename){return AddPathSuffix(filename, ".exe");}// 根据文件名和获取一个带文件路径和后缀的标准错误文件名static std::string StdErr(const std::string &filename){return AddPathSuffix(filename, ".stderr");}
编写日志模块
当代码中出现了错误时,为了方便后续能快速的获取程序运行结果和定位错误,我们引入了日志进制,日志信息可以直观的向我们展示哪个文件的哪一行出现了异常。
日志模块编码如下。
#pragma once#include <iostream>
#include <string>
#include "util.hpp"namespace ns_log
{// 表示日志的类型enum{INFO, // 普通类型DEBUG, // 需要调试WARNING, // 警告,但是不影响程序运行ERROR, // 错误,只影响当前用户FATAL // 崩溃,影响所有用户};// 获取日志信息static std::ostream &Log(const std::string &level, const std::string &filename, int line){// 1.填充日志等级std::string message = "[";message += level;message += "]";// 2.填充文件名称message += "[";message += filename;message += "]";// 3.填充行号message += "[";message += std::to_string(line);message += "]";// 4.填充时间戳// 获取时间戳message += "[";message += ns_util::TimeUtil::GetTimeStamp();message += "]";// 5.将日志信息写进cout标准输出中,只要不加换行符,就不会刷新std::cout << message;return std::cout;}
#define LOG(level) Log(#level, __FILE__, __LINE__)}
日志模块也可以被其它头文件和源文件访问,所以也属于公共模块,在 comm 目录下。
编写运行模块
运行模块即就是运行编译模块形成的可执行程序。
运行模块编码如下。
#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() {}static void SetProcLimit(int cpu_limit,int mem_limit){//设置cpu限制struct rlimit _cpu_limit;_cpu_limit.rlim_max=RLIM_INFINITY; //硬条件无限制_cpu_limit.rlim_cur=cpu_limit; //软条件为我们自己设置的cpu运行时间setrlimit(RLIMIT_CPU,&_cpu_limit);//设置内存限制struct rlimit _mem_limit;_mem_limit.rlim_max=RLIM_INFINITY;_mem_limit.rlim_cur=mem_limit *1024; //基本单位是byte,所以我们给的mem_limit是多少,就是多少kbsetrlimit(RLIMIT_AS,&_mem_limit);}static int Run(const std::string &filename,int cpu_limit,int mem_limit){// 获取当前文件名对应的标准输入文件名std::string _stdin = PathUtil::StdIn(filename);// 获取当前文件名对应的标准输出文件名std::string _stdout = PathUtil::StdOut(filename);// 获取当前文件名对应的标准错误文件名std::string _stderr = PathUtil::StdErr(filename);// 创建当前可执行程序的标准输入文件umask(0);int infd = open(_stdin.c_str(), O_CREAT | O_WRONLY, 0644);int outfd = open(_stdout.c_str(), O_CREAT | O_WRONLY, 0644);int errfd = open(_stderr.c_str(), O_CREAT | O_WRONLY, 0644);if (infd < 0 || outfd < 0 || errfd < 0){LOG(ERROR)<<"运行时打开文件错误!"<<std::endl;return -1; // 创建可执行程序对应的文件错误,返回-1}int pid = fork();if (pid < 0){close(infd);close(outfd);close(errfd);LOG(ERROR)<<"运行时创建子进程出错!"<<std::endl;return -2; // 创建子进程失败,返回-2}else if (pid == 0){// 子进程//子进程进行进程替换,运行对应的可执行程序//因为此时进程替换运行的使我们自己创建的可执行程序,所以必须使用l系列的exec函数,l系列包含可执行程序文件路径//如果如果不包含路径,操作系统无法找到对应的可执行程序,这个路径一般为相对路径//同时,虽然子进程进行了程序替换,但是本身的进程内核结构是不变的,继承的文件描述符依然是存在的//对子进程的标准输入/标准输出/标准错误三个文件描述符进行重定向,只影响子进程dup2(infd,0);dup2(outfd,1);dup2(errfd,2);//对子进程在运行可执行程序时消耗的cpu时长和占用的内存进行限制SetProcLimit(cpu_limit,mem_limit);execl(PathUtil::Exe(filename).c_str(),PathUtil::Exe(filename).c_str(),nullptr);LOG(ERROR)<<"子进程运行出错!"<<std::endl;exit(1);//如果替换失败,让子进程直接终止即可}else{// 父进程// 父进程不关心打开的文件,所以创建并打开的文件描述符关闭close(infd);close(outfd);close(errfd);int status = 0; // 输出型参数,用于获取子进程的退出信息,如子进程退出码和信号,我们重点关心信号waitpid(pid, &status, 0); // 阻塞式等待LOG(INFO)<<"运行完毕,info: "<<(status & 0x7f)<<std::endl;return status & 0x7f; // 获取信号,如果为0证明,子进程运行完毕;如果>0证明子进程接收到了信号,没有运行完毕}}};}
同样的,我们没有让父进程去运行可执行程序,因为父进程一旦被进程替换之后,去运行可执行程序,就会导致进程替换之后的代码不会被运行,从而导致运行模块之后的其它模块不会再被运行,导致代码出现错误,所以我们要创建子进程,然子进程进行进程替换去运行编译模块生成的可执行程序。
同时,我们还要对子进程运行可执行程序时,所消耗的资源进行限制,防止恶意的请求来消耗大量的服务器资源,所以我们要用到 linux 中对应的资源限制接口。
注意:我们只限制了cpu资源(通过cpu运行时长进行限制),内存资源(比如限制子进程只能消耗我们设置的对应大小的内存资源)。
对 Linux 中的资源限制接口进行封装,对应的代码如下。
static void SetProcLimit(int cpu_limit,int mem_limit){//设置cpu限制struct rlimit _cpu_limit;_cpu_limit.rlim_max=RLIM_INFINITY; //硬条件无限制_cpu_limit.rlim_cur=cpu_limit; //软条件为我们自己设置的cpu运行时间setrlimit(RLIMIT_CPU,&_cpu_limit);//设置内存限制struct rlimit _mem_limit;_mem_limit.rlim_max=RLIM_INFINITY;_mem_limit.rlim_cur=mem_limit *1024; //基本单位是byte,所以我们给的mem_limit是多少,就是多少kbsetrlimit(RLIMIT_AS,&_mem_limit);}
编写编译和运行整合模块
编译模块整体的代码框架如下。
编译和运行模块就是将编译和运行这两个模块进行整合,然后提供一个外部接口Start接口供compile_server 服务进行调用。
但是在整合两个模块时,关键的操作如下。
- 接收前端用户传来的序列化之后的 json 串,然后对该 json 串进行处理,最终向用户返回一个 json 串,用户可以根据这个返回的 json 串得知自己提交的代码的运行结果如何。
- 用户前端传来的代码,后端要先通过毫秒时间戳+原子性递增唯一值先创建一个文件名,然后获取该文件名对应的一个带文件路径和文件后缀的源文件名。然后将前端传来的代码写入该文件中。
- 根据生成的源文件进行编译。
- 运行编译之后形成的可执行程序。
- 对上述的每一个操作失败或者成功与否,我们都要生成一个状态码,同时给这个状态码进行描述,最终用户可以通过状态码和描述得知自己的代码是否运行成功。
编译和运行模块整合代码如下。
#pragma once#include "compiler.hpp"
#include "runner.hpp"
#include "jsoncpp/json/json.h"
#include "../comm/log.hpp"
#include "../comm/util.hpp"
#include "signal.h"namespace ns_compile_and_run
{using namespace ns_util;using namespace ns_log;using namespace ns_compiler;using namespace ns_runner;class CompileAndRun{// 编译和运行模块的启动函数// 前端传来一个序列化之后的json序列,我们要对其进行反序列化之后,提取出我们需要的字段/*用户输入in_json:code:用户提交的代码input:用户给自己提交的代码对应的输入,我们不做处理cpu_limit:执行用户代码的时间要求mem_limit:执行用户代码的空间要求*//*程序输出out_json:status:状态码,表示子进程是正常退出还是异常退出reason:执行的结果,表示子进程运行可执行程序之后返回的结果stdout:子进程运行可执行程序之后的输出结过保存在了可执行程序的stdout文件中stderr:子进程运行可执行程序之后的错误结果保存在了可执行程序的stderr文件中*/public:static std::string CodeTodesc(int code, 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:break;}return desc;}static void Start(const std::string &in_json, std::string *out_json){// 1.通过前端传递的序列化的json串,提取出有效的字段,code和input// Value创建的对象,用户存储对序列化之后的字符串数据进行反序列化之后的数据Json::Value in_value;// Reader类创建的对象用于对传来的序列化字符串进行反序列化Json::Reader reader;// 将in_json序列化的数据,通过reader对象进行反序列化到in_value中// 依次获取in_value中的各个字段,作为后面其它代码的参数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();std::string file_name;int run_result = 0; // 表示run函数的返回结果// 设置一个向用户返回的 out_value JSON对象// 用户可以根据这个对象,得知状态码和状态码对应的信息和stdout与stderr文件中的相关数据Json::Value out_value;int status_code = 0; // 创建一个状态码if (code.size() == 0){status_code = -1; // 代码为空goto END;}// 2.获取一个唯一的文件名,前端传来的代码不同,我们创建的源文件名称也应该不同,这个文件名不包含路径和后缀file_name = FileUtil::UniqueFileName();// 3.将解析出来的code写入到file_name对应的源文件中,所以我们还要通过file_name获取一个带路径和后缀的源文件名称if (!FileUtil::WriteFile(PathUtil::Src(file_name), code)){// 向源文件中写入code失败,向用户反映为未知错误status_code = -2;goto END;}// 4.进行文件的编译,特定路径下生成可执行程序if (!Compiler::Compile(file_name)){// 编译失败status_code = -3;goto END;}// 5.运行可执行程序run_result = Runner::Run(file_name, cpu_limit, mem_limit);if (run_result < 0){// 运行时的内部错误,向用户反映为未知错误status_code = -2;}else if (run_result > 0){// 子进程运行可执行程序时异常退出,因为收到了信号status_code = run_result;}else{// run_result==0,子进程正常退出,运行成功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::StyledWriter writer;*out_json = writer.write(out_value);}};}
当子进程在运行对应的可执行程序成功时,会将对应的运行结果重定向输出到可执行程序对应的 stdout 和 stderr 中,最终将 stdout 和 stderr 中的内容也会作为返回用户的 json 串中的数据。
对 compile 模块进行测试
测试代码如下,模拟前端用户,先创建一个结构化的json对象,然后对 json 对象的每个成员进行赋值,赋值之后对结构化 json 对象进行序列化成为 in_json 串,in_json 作为用户传入的序列化字符串作为输入参数传入给 Start 函数,同时设置一个 out_json 作为输出型参数,最终通过输出型参数的值,去判断 compile 模块的整体运行逻辑是否有误。
1.正常运行,返回用户的状态码为0,同时会返回可执行程序文件对应的 stdout 和 stderr 文件中的内容。
测试代码如下:
#include"compiler.hpp"
#include"runner.hpp"
#include"compile_run.hpp"
using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_compile_and_run;int main()
{Json::Value in_value;in_value["code"]=R"(#include<iostream>int main(){std::cout<<"made in china!"<<std::endl;return 0;})";in_value["input"]="";in_value["cpu_limit"]=1;in_value["mem_limit"]=1024*10*3;Json::FastWriter writer;std::string in_json=writer.write(in_value);std::string out_json;CompileAndRun::Start(in_json,&out_json);std::cout<<out_json<<std::endl;return 0;
}
运行结果如下。
2.cpu运行时长超时,发送24号信号,状态码为24,运行异常。
测试代码如下。
#include"compiler.hpp"
#include"runner.hpp"
#include"compile_run.hpp"
using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_compile_and_run;int main()
{Json::Value in_value;in_value["code"]=R"(#include<iostream>int main(){while(true);std::cout<<"made in china!"<<std::endl;return 0;})";in_value["input"]="";in_value["cpu_limit"]=1;in_value["mem_limit"]=1024*10*3;Json::FastWriter writer;std::string in_json=writer.write(in_value);std::string out_json;CompileAndRun::Start(in_json,&out_json);std::cout<<out_json<<std::endl;return 0;
}
运行结果如下。
运行结果符合预期。
3.使用内存超过限制,发送6号信号,状态码为6,运行异常。
测试代码如下。
#include"compiler.hpp"
#include"runner.hpp"
#include"compile_run.hpp"
using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_compile_and_run;int main()
{Json::Value in_value;in_value["code"]=R"(#include<iostream>int main(){char* p1=new char[1024*1024*50];std::cout<<"made in china!"<<std::endl;return 0;})";in_value["input"]="";in_value["cpu_limit"]=1;in_value["mem_limit"]=1024*10*3; //30MBJson::FastWriter writer;std::string in_json=writer.write(in_value);std::string out_json;CompileAndRun::Start(in_json,&out_json);std::cout<<out_json<<std::endl;return 0;
}
运行结果如下。
运行结果符合预期。
4.发生了除零错误,进程收到了8号新号,状态码为8,运行异常。
测试代码如下。
#include"compiler.hpp"
#include"runner.hpp"
#include"compile_run.hpp"
using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_compile_and_run;int main()
{Json::Value in_value;in_value["code"]=R"(#include<iostream>int main(){int a=10/0;std::cout<<"made in china!"<<std::endl;return 0;})";in_value["input"]="";in_value["cpu_limit"]=1;in_value["mem_limit"]=1024*10*3; //30MBJson::FastWriter writer;std::string in_json=writer.write(in_value);std::string out_json;CompileAndRun::Start(in_json,&out_json);std::cout<<out_json<<std::endl;return 0;
}
运行结果如下。
运行结果符合预期。
基于此,所有测试用例的运行结果都符合预期,compile 模块测试完成。
形成 compile_server 网络服务
编译模块的服务最终一定是以网络服务的形式呈现给用户的,所以我们就要使用第三方库 cpp-httplib 库。我们选择使用现成的第三方库 cpp-httplib 库(推荐下载v.0.7.15),下载压缩包,然后使用 rz 指令上传至项目目录下,使用 unzip 指令压缩即可获得 cpp-httplib 目录,我们主要使用 cpp-httplib 目录下的 httplib.h 头文件,将该头文件直接引入我们项目中的 comm 模块即可。
同时,在下载好 cpp-httplib 库之后,应该使用较新的 gcc 编译器,centos7 下默认为 gcc 4.8.5 版本,gcc编译器升级在此。
测试 cpp-httplib 库
测试代码如下。
#include"compiler.hpp"
#include"runner.hpp"
#include"compile_run.hpp"
#include"../comm/httplib.h"
using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_compile_and_run;
using namespace httplib;
int main()
{Server svr;svr.Get("/hello",[](const Request& req,Response& rep){rep.set_content("你好","text/plain;charset=utf-8");});svr.listen("0.0.0.0",8080);return 0;
}
在浏览器端访问服务。
表明 cpp-httplib 库引入成功且能成功运行。
打包形成 compile_server 网络服务,代码如下。
#include"compiler.hpp"
#include"runner.hpp"
#include"compile_run.hpp"
#include"../comm/httplib.h"
using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_compile_and_run;
using namespace httplib;
int main()
{Server svr;svr.Post("/compile_server",[](const Request& req,Response& res){//从request的body中获取前端传来的序列化之后的json字符串std::string in_json=req.body;std::string out_json;if(!in_json.empty()){CompileAndRun::Start(in_json,&out_json);res.set_content(out_json,"application/json;charset=utf-8");}});svr.listen("0.0.0.0",8080);//启动http服务return 0;
}
因为将来的云服务器上的 compile_server 服务有多个,所以我们得对上述代码进行改进,改造成动态端口的形式,改进之后的代码如下。
#include"compiler.hpp"
#include"runner.hpp"
#include"compile_run.hpp"
#include"../comm/httplib.h"
using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_compile_and_run;
using namespace httplib;void Usage(const std::string&proc )
{std::cout<<proc<<" port"<<std::endl;
}
int main(int argc,char* argv[])
{//我们用 ./compile_server port 的方式启动 compile_server 服务,所以有两个命令行参数if(argc!=2){Usage(argv[0]);}Server svr;svr.Post("/compile_server",[](const Request& req,Response& res){//从request的body中获取前端传来的序列化之后的json字符串std::string in_json=req.body;std::string out_json;if(!in_json.empty()){CompileAndRun::Start(in_json,&out_json);res.set_content(out_json,"application/json;charset=utf-8");}});svr.listen("0.0.0.0",atoi(argv[1]));//启动http服务return 0;
}
测试 compile_server 网络服务
因为客户端发送的请求是一个序列化 json 请求,所以我们需要使用第三方工具,模拟客户端发送 json 请求。postman工具下载,Apifox下载
推荐使用 Apifox ,因为它免费,也可以不下 Apifox 的客户端程序而直接使用其 Apifox 在线程序。
1.代码正常运行。
运行结果符合预期。
2.cpu运行时长超时。
运行结果符合预期。
3.申请内存超过限制。
运行结果符合预期。
4.浮点数溢出。
运行结果符合预期。
5.编译错误。
运行结果符合预期。
所有测试用例均符合预期,compile_server 测试完成。
基于此,compile_server 模块编写全部完成。
oj_server 模块实现
oj_server 模块简单来说就是实现一个小型的网站,该网站具备以下三个功能。
- 获取首页,题目列表填充首页。
- 代码编辑区域页面。
- 提交代码并进行判题功能。
我们将 oj_server 模块设计成了 mvc 模式。
- Model(m):通常指和数据交互的模块,比如对题库的增删查改。
- View(v):获取导到数据之后构建并渲染网页,最终展示给用户。
- Control(c):控制器,程序的核心业务逻辑。比如怎么样取数据,取多少数据等等核心逻辑。
oj_server 模块的实现分为四步:
- 设计用户请求的服务路由功能,根据用户不同的请求,调用不同的函数,做出不同的响应。
- 设计文件版本题库。(后续还可以设计 mysql 数据库版本题库)
- 设计 Model 数据交互模块。
- 设计 View 渲染网页模块。
- 设计 Control 处理业务核心逻辑模块。
设计用户请求的服务路由功能
用户的请求有三种请求。
- 请求获取所有的题目列表。
- 请求获取单个题目的内容。
- 提交代码,请求判题。
编码如下。
#include<iostream>
#include"../comm/httplib.h"
using namespace httplib;int main()
{Server svr;//用户的三种请求//1.获取题目列表svr.Get("/all_questions",[](const Request& req,Response& res){res.set_content("获取题目列表","text/plain;charset=utf8");});//2.根据题目编号,获取题目内容svr.Get(R"(/question/(\d+))",[](const Request& req,Response& res){res.set_content("获取题目内容","text/plain;charset=utf8");});//3.用户提交代码,使用判题功能(1.每个题的测试永和 2.compile和run即compile_server服务)svr.Get(R"(/judge/(\d+))",[](const Request& req,Response& res){res.set_content("提交代码","text/plain;charset=utf8");});svr.listen("0.0.0.0",8080);}
设计文件版本题库
对于一个题目而言,这个题目需要包含以下6个部分。
- 题目的编号。(暴露给用户)
- 题目的标题。(暴露给用户)
- 题目的难度。(暴露给用户)
- 题目的描述。(暴露给用户)
- 题目的时间要求。(CPU运行时长,内部处理,不暴露给用户)
- 题目的空间要求。(消耗的内存大小,内部处理,不暴露给用户)
文件的相关信息我们是通过两批文件实现的。
- questions.list:用于存放题目的列表,包含题目的编号,题目的标题,题目的难度,题目的cpu运行时长要求,题目的空间要求。
- desc.txt:用于存放题目的描述。
questions.list 文件内容如下。
1 判断回文数 简单 1 30000
2 求最大值 简单 1 30000
desc.txt 文件内容如下。
判断一个整数是否是回文数。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。示例 1:输入: 121
输出: true
示例 2:输入: -121
输出: false
解释: 从左向右读, 为 -121 。 从右向左读, 为 121- 。因此它不是一个回文数。
示例 3:输入: 10
输出: false
解释: 从右向左读, 为 01 。因此它不是一个回文数。
进阶:你能不将整数转为字符串来解决这个问题吗?
同时每个题目还应包含两个cpp文件。
- header.cpp:这个文件中的内容将来要传给用户,为用户的代码编辑模块。
- tail.cpp:这个文件中存放的是每个题目的测试用例,用于测试用于提交的代码是否符合预期。最终将用户从前端提交的题目源文件和 tail.cpp 源文件进行拼接形成一个最终的源文件,交给 compile_server 服务进行编译运行,通过读取编译运行之后的 stdout 文件,将测试用例的结果返回给用户,从而用户得知自己的代码编写是否正确。
header.cpp 文件内容如下。
//用户看到的代码编辑模块
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>using namespace std;class Solution{public:bool isPalindrome(int x){//将你的代码写在下面return true;}
};
tail.cpp 文件内容如下。
#ifndef COMPILER_ONLINE
#include "header.cpp"
#endifvoid Test1()
{// 通过定义临时对象,来完成方法的调用bool ret = Solution().isPalindrome(121);if(ret){std::cout << "通过用例1, 测试121通过 ... OK!" << std::endl;}else{std::cout << "没有通过用例1, 测试的值是: 121" << std::endl;}
}void Test2()
{// 通过定义临时对象,来完成方法的调用bool ret = Solution().isPalindrome(-10);if(!ret){std::cout << "通过用例2, 测试-10通过 ... OK!" << std::endl;}else{std::cout << "没有通过用例2, 测试的值是: -10" << std::endl;}
}int main()
{Test1();Test2();return 0;
}
设计 Model 模块
Model模块主要分为三步。
- 加载题库。
- 获取题库中所有题目的信息。
- 获取单个题目的信息。
题库的设计使用 unordered_map 结构,该结构的每个元素的 key 为题目的编号,value 为题目编号对应的题目的详细信息。
unordered_map<string,Question> questions; //题目编号:题目细节
我们使用 Question 这个结构体表示题目的详细信息。
//题目详细信息对应的结构体struct Question{string number; //题目编号,唯一string title; // 题目标题string star; //题目难度int cpu_limit; //题目的时间限制(s)int mem_limit; //题目的空间限制(kb)string desc; //题目的描述string header; //向用户显示的编辑区域的代码string tail; //题目的测试用例,将来和header拼接,形成最终传给 compile_server 的代码};
编写加载题库代码
//根据路径找到对应的questions.list文件,通过该文件获取所有的题目的 题目编号和题目细节(加载题库)bool LoadQuestionList(const string& questions_list){//1.根据路径打开questions.list的配置文件,读取每一行的内容,一行内容就对应了一个题目的详细信息//1 判断回文数 简单 1 30000ifstream in(questions_list_path);if(!in.is_open()){//如果配置文件没有打开LOG(FATAL)<<"打开配置文件失败,请检查配置文件是否存在!"<<endl;return false;}//配置文件打开,开始按行进行读取string line;while(!getline(in,line)) //这里每一次的读取都会覆盖上一次读取的结果,也就是每次的line值都不会受上次line中的值的影响{//对line中的数据进行切分,获取到切分之后的字符串,切分之后的每一个字符串都是Questions结构体中的对应的成员变量vector<string> target;StringUtil::SplitString(line,&target,"");if(target.size()!=5){LOG(WARNING)<<"读取配置文件失败,请检查配置文件格式是否正确!"<<endl;return false;}Question q;q.number=target[0];q.title=target[1];q.star=target[2];q.cpu_limit=atoi(target[3].c_str());q.mem_limit=atoi(target[4].c_str());const string _questions_path=questions_path+target[0];FileUtil::ReadFile(_questions_path+"/"+"desc.txt",&(q.desc),true);FileUtil::ReadFile(_questions_path+"/"+"header.cc",&(q.header),true);FileUtil::ReadFile(_questions_path+"/"+"tail.cc",&(q.tail),true);questions.insert(make_pair(q.number,q));}in.close();LOG(INFO)<<"加载题库......成功!"<<endl;return true;};
在加载题库时,我们要先打开对应的配置文件,然后读取配置文件的每一行的数据,一行的数据就对应了一个题目的详细信息。但是读取出来的是一个长字符串,所以我们要通过分割符对字符串进行切分,这里我们使用 boost 库中的 split 函数进行字符串分割,所以就要先安装 boost 准标准库,boost 准标准库就是基于 C++ 官方库的一个准标准库,boost 库中的一些标准最终会被C++ 官方库所引入。
安装 boost 库
使用以下指令进行 boost 库的安装。
sudo yum install -y boost-devel
安装完成之后如下图。
因为作者已经安装了,所以 Nothing to do 。
使用 boost 中的 split 函数对读取的配置文件中的一行字符串进行切割,代码如下。
class StringUtil{public:static void SplitString(const std::string src,std::vector<std::string>*target,const std::string &sep){boost::split((*target),src,boost::is_any_of(sep),boost::algorithm::token_compress_on);}};
token_compress_on 表示要压缩重叠的多个分隔符,不然将来会有多个空字符串。 如果以 ":" 作为一个字符串切割时的分割符,那么如果设置了 token_compress_on 选项之后,"h:::::e::ll::o" 就会被压缩为 "h:e:ll:o" ,这样在进行切割时,就不会出现多个 " " 字符串啦。
编写获取所有题目详细信息代码
//获取所有的题目细节(获取题库)bool GetAllQuestions(vector<Question>*out){if(questions.size()==0){LOG(WARNING)<<"用户获取题库失败"<<endl;return false;}for(const auto &e:questions){out->push_back(e.second);}return true;}
编写获取单个题目详细信息代码
bool GetOneQuestion(const string& number,Question* q){const auto & iter=questions.find(number);if(iter==questions.end()){LOG(WARNING)<<"获取"<<number <<"题目信息失败!"<<endl;return false;}else{*q=iter->second;}return true;}
设计 View 模块
view模块主要有两个功能。
- 根据获取的所有题目的详细信息,渲染一个 html 页面返回给前端用户,作为所有题目的列表页面,包含所有题目的编号,标题,难度。
- 根绝获取的一个题目的详细信息,渲染一个 html 页面返回给前端用户,作为一个题目的编辑页面,包含题目的编号,标题,难度,描述和代码编写模块。
渲染页面我们使用了 Google 公司的 ctemplate 模板库。
安装 ctemplate 模板库
ctemplate 模板库是 Google 公司开源的一个模板库,我们主要使用该模板库进行网页的构建。 ctemplate 下载
1. 进行git clone。
git clone https://gitcode.com/gh_mirrors/ct/ctemplate.git
2. 进入 ctemplate 目录,运行 autogen.sh 可执行程序,生成 configure 可执行程序。
./autogen.sh
注意:如果运行失败,需要使用 yum 工具安装以下三个文件。
sudo yum install -y autoconf automake libtool
3.在 ctemplate 目录下,运行 configure 可执行程序。
./configure
4. 在 ctemplate 目录下,使用 make 指令进行编译。
make
5.在 ctemplate 目录下,使用 sudo make install 指令将对应的头文件安装到系统头文件中。
sudo make install
6.在运行程序时可能会出现以下报错。
在命令行中运行以下指令即可。
export LD\_LIBRARY\_PATH=$LD\_LIBRARY\_PATH:/usr/local/lib
但是运行上述指令只在当前的会话有效,要想永久生效,可以将上述指令导入 ~/.bashrc 文件的末尾即可。
测试使用 ctemplate 第三方库
test.cc
#include<iostream>
#include<ctemplate/template.h>int main()
{std::string in_html="./test.html";std::string value="made in china!";//形成数据字典ctemplate::TemplateDictionary root("test");root.SetValue("key",value);//获取被渲染网页对象ctemplate::Template *tp=ctemplate::Template::GetTemplate(in_html,ctemplate::DO_NOT_STRIP);//添加数据字典到网页中,形成一个新的网页std::string out_html;tp->Expand(&out_html,&root);//完成渲染std::cout<< out_html <<std::endl;return 0;
}
test.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>测试ctemplate</title>
</head>
<body><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p>
</body>
</html>
编译形成可执行程序,运行结果如下。
如上图,我们动态渲染了一个新的页面。注意:原有的 test.html 内容是不发生变化的。
编写渲染所有题目列表界面的代码
被渲染的 html 页面代码如下。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>在线oj-题目列表</title>
</head>
<body><table><tr><th>编号</th><th>标题</th><th>难度</th></tr>{{#questions_list}}<tr><td>{{number}}</td><td><a href="/question/{{number}}">{{title}}</a></td><td>{{star}}</td></tr>{{/questions_list}}</table>
</body>
</html>
渲染所有题目列表的代码如下。
//构建所有题目的网页信息void AllExpandHtml(const vector<struct Question>&questions,string * html ){//1.获取要被渲染的 html 文件路径const string src_html=question_path+"all_questions.html";//2.形成数据字典ssssctemplate::TemplateDictionary root("all_questions");for(const auto& q:questions){//形成数据字典的子字典ctemplate::TemplateDictionary* sub=root.AddSectionDictionary("questions_list");sub->SetValue("number",q.number);sub->SetValue("title",q.title);sub->SetValue("star",q.star);}//3.获取要被渲染的htmlctemplate::Template* tp=ctemplate::Template::GetTemplate(src_html,ctemplate::DO_NOT_STRIP);//4.开始完成渲染tp->Expand(html,&root);}
编写渲染单个题目编辑界面的代码
被渲染的 html 页面如下。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{{number}}.{{title}}</title>
</head>
<body><h4>{{number}}.{{title}}.{{star}}</h4><p>{{desc}}</p><textarea name="code" cols="30"rows="10"> {{pre_code}}</textarea>
</body>
</html>
渲染单个题目编辑界面的代码如下。
//构建单个题目的网页信息void OneExpandHtml(const struct Question& question,string* html){//1.获取要被渲染的 html 文件路径const string src_html=question_path+"one_question.html";//2.形成数据字典ctemplate::TemplateDictionary root("one_quest");root.SetValue("number",question.number);root.SetValue("title",question.title);root.SetValue("star",question.star);root.SetValue("desc",question.desc);root.SetValue("pre_code",question.header);//3.获取要被渲染的htmlctemplate::Template* tp=ctemplate::Template::GetTemplate(src_html,ctemplate::DO_NOT_STRIP);//4.开始完成渲染tp->Expand(html,&root);}
设计 Control 模块
Control 模块作为 Model 模块和 View 模块的管理者,主要将 Model 模块和 View 模块的功能进行整合,并进行负载均衡选择合适的主机进行编译服务。 Control 模块主要有四个功能。
- 获取所有题目的详细信息,渲染所有题目的列表页面。
- 获取单个题目的详细信息,渲染单个题目的编辑页面。
- 进行负载均衡,选择合适的编译服务。
- 进行判题功能。
- 当所有编译服务离线之后,就要重新上线所有的编译服务。
Control 有三个成员变量。
class Control{private:Model _model; // 获取后端数据View _view; // 动态渲染前端页面LoadBalance _loadbalance; // 核心负载均衡器,用于选择对应编译服务和管理对应的编译服务};
Model 类对象用于对后端数据的管理,比如加载题目,获取所有题目信息,获取单个题目信息。View 类对象用于前端页面的动态渲染,比如动态渲染题目列表页面,动态渲染单个题目编辑页面,LoadBalance 类对象用于负载均衡,如加载编译服务,选取负载较低的编译服务,上线编译服务,下线编译服务等。
编写获取所有题目详细信息并渲染所有题目的列表页面代码
代码如下。
// 获取所有的题目的详细信息,构建一个所有题目网页bool AllQuestions(string *html){vector<struct Question> target;if (_model.GetAllQuestions(&target)){// 获取所有的题目详细信息成功,开始构建网页_view.AllExpandHtml(target, html);}else{*html = "获取所有题目信息失败,获取题目列表失败!";return false;}return true;}
编写获取单个题目详细信息并渲染单个题目的编辑页面代码
代码如下。
// 通过题目编号,获取单个题目的详细信息,构建一个单个题目的网页bool Question(string number, string *html){struct Question q;if (_model.GetOneQuestion(number, &q)){// 获取单个题目的详细信息成功,开始构建网页_view.OneExpandHtml(q, html);}else{*html = "获取单个题目信息失败!";return false;}return true;}
编写负载均衡代码
我们的 oj 系统是一个负载均衡式的在线系统,所以我们必须得有一个负责均衡抽象类去实现对应的负载均衡功能。
class LoadBalance{private:vector<Machine> machines; // 存储所有的编译服务,vector的下标表示了是第几个服务vector<int> online; // 表示在线的服务vector<int> offline; // 表示离线的服务mutex mtx; // 我们的后端服务是一个多线程服务,所以得有互斥锁保证每个多线程的原子性访问
};
这个类中有四个成员变量。
- machines 是一个存储编译服务的vector对象。
- online 是一个存储在线服务编号的vector对象。
- offline 是一个存储离线服务编号的vector对象。
- mtx 是一个互斥锁。
在 LoadBalance 中为什么要有互斥锁这个成员变量呢?
这是因为,我们引入的 cpp-httplib 库是一个多线程网络服务库,多个线程回去会去使用同一个 Control 对象,所以 Control 对象中的 LoadBalance 对象中的 online 成员和 offline 成员也是多个线程共享的,所以就有可能导致读写冲突和写写重读线程安全问题,所以就要使用互斥锁保证多个线程访问这两个成员时的线程安全问题。
一个个的编译服务我们用 Machine 这个类抽象的进行描述。
class Machine{public:string ip; // 编译服务的ipint port; // 编译服务的端口号uint64_t load; // 编译服务的负载,就是一个计数器mutex *mtx; // C++中的互斥锁,这个锁禁止拷贝,所以设置为指针类型,因为我们在LoadBalance类中有一个存放Machine对象的vector,push_back时会发生拷贝
};
- ip 为对应编译服务的 ip。
- port 为对应编译服务的端口号。
- load 为对应编译服务的负载。
- mtx 为一个互斥锁指针。
为什么要有互斥锁指针呢?
这是因为,我们引入的 cpp-httplib 库是一个多线程网络服务库,多个线程回去会去使用同一个 Control 对象,所以 Control 对象中的 LoadBalance 对象中的 online 成员和 offline 成员也是多个线程共享的,也就意味着这两个成员中的编号对应的 Machine 服务也是多个线程共享的,Machine 中的 load 成员就有可能同时被多个线程访问,为了避免多线程访问 load 的读写冲突和写写冲突带来的线程安全问题,所以我们就要使用互斥锁保证线程安全问题。用互斥锁指针是因为互斥锁对象是不支持拷贝的,但是我们要将多个 Machine 对象存入 LoadBalance 中的 machines 成员中,而 machines 是一个 vector 对象,所以在进行 Machine 对象 push_back 的时候就会发生拷贝,但是 mutex 是不支持拷贝的,所以如果仍然要强请拷贝,就会编译报错,所以我们要使用互斥锁指针。
负载均衡选择合适的服务代码如下。
bool SmartChoice(int *id, Machine **m){mtx.lock();int online_num = online.size();if (online_num == 0){mtx.unlock();LOG(FATAL) << "所有的后端编译服务都已经离线,请尽快维护编译服务!" << endl;return false;}// 通过遍历的方式找到在线的负载最小的服务*id = online[0];*m = &machines[online[0]];uint64_t min_load = machines[online[0]].GetLoad();for (int i = 1; i < online.size(); i++){int cur_load = machines[online[i]].GetLoad();if (min_load > cur_load){min_load = cur_load;*id = online[i];*m = &machines[online[i]];}}mtx.unlock();return true;}
进行判题
在进行判题时,我们要注意,我们并不是直接把从前端获取的题目的编辑代码传递给编译服务,让编译服务进行编译运行,而是要将前端传来的题目编辑代码和题目测试代码进行整合,最总传递给编译服务,再让编译服务进行编译和运行。
简单来说判题服务分为两步。
- 前端用户访问 oj_server 的判题服务。
- oj_sever 将前端传来的题目编辑代码和题目测试代码进行整合,创建一个 Json 对象,根据题目编号获取题目的详细信息,然后根据题目的详细信息,填充 Json 对象的各个字段,最终将 Json 对象序列化成一个 json 串,作为请求参数,请求编译服务,编译服务根据请求参数,对请求参数中的代码进行编译和运行,最终将运行的结果作为响应以 json 串的形式返回给 oj_server,oj_sever将返回的 json 串作为判题服务的响应返回给前端用户。
判题服务代码如下。
// 根据题目编号,对题目编号对应的题目进行判题void Judge(const string &number, const string &in_json, std::string *out_json){// in_json的格式为 code input// 1.根据题目编号获取题目的详细信息struct Question q;_model.GetOneQuestion(number, &q);// 2.反序列化用户传来的json串Json::Value in_value;Json::Reader reader;reader.parse(in_json, in_value);string code = in_value["code"].asString();// 3.拼接用户代码和测试用例代码,形成最终的传给编译服务的代码Json::Value compile_value;compile_value["code"] = code + "\n" + q.tail;compile_value["input"] = in_value["input"];compile_value["cpu_limit"] = q.cpu_limit;compile_value["mem_limit"] = q.mem_limit;Json::FastWriter writer;string compile_string = writer.write(compile_value);// 4.智能选择编译服务,循环选择while (true){int id = 0;Machine *m = nullptr;if (!_loadbalance.SmartChoice(&id, &m)){// 选择编译服务失败// LOG(ERROR) << "编译服务已经全部下线!" << endl;break;}// 选择编译服务成功// 5.向编译服务发起请求Client cli(m->ip, m->port);m->IncLoad();//<< "当前服务的负载是: " << m->load LOG(INFO) << "获取编译服务成功!" << m->ip << ":" << m->port<< endl;if (auto res = cli.Post("/compile_server", compile_string, "application/json;charset=utf-8")){*out_json = res->body;LOG(INFO) << "请求编译和运行服务成功!" << endl;m->DecLoad();break;}else{LOG(ERROR) << "当前请求的服务的编号:" << id <<" "<< "当前服务" << m->ip << ":" << m->port << " 可能已经离线!" << endl;_loadbalance.OfflineMachine(id);_loadbalance.ShowMachines();}}}
一键上线所有服务
当我们所有的编译服务在后端被我们手动关闭之后,再次手动开启所有的编译服务时,就要重新上线所有的编译服务,即就是离线服务列表 offline 服务的编号全部插入 LoadBlance 类中的 online 在线服务列表中,然后删除离线列表 offline 中的所有元素。
void OnlineMachine(){mtx.lock();//当所有的服务都下线时,就要让所有的服务上线,即将 offline 中的值 先插入 online 中,然后删除 offline 中的所有元素online.insert(online.end(),offline.begin(),offline.end());offline.clear();mtx.unlock();LOG(INFO)<<" 所有的服务都上线啦! "<<endl;}//上线所有服务void RecoveryMachine(){_loadbalance.OnlineMachine();}
在 oj_server 中,我么使用信号捕捉的方式进行所有编译服务的重新上线。具体参考下文的 oj_server 源代码。
oj_server 模块最终源码
#include <iostream>
#include "../comm/httplib.h"
#include "oj_control.hpp"
#include<signal.h>using namespace httplib;
using namespace ns_control;static Control* control_ptr;
void RecoveryMachine(int signo)
{control_ptr->RecoveryMachine();
}int main()
{signal(SIGQUIT,RecoveryMachine);Server svr;Control control;control_ptr=&control;// 用户的三种请求// 1.获取题目列表页面svr.Get("/all_questions", [&control](const Request &req, Response &res){// 返回一张包含所有题目的html网页std::string html;control.AllQuestions(&html);res.set_content(html, "text/html;charset=utf-8");});// 2.根据题目编号,获取题目内容页面svr.Get(R"(/question/(\d+))", [&control](const Request &req, Response &res){std::string number = req.matches[1];// 返回题目编号对应的题目的html网页std::string html;control.Question(number, &html);res.set_content(html, "text/html;charset=utf-8");});// 3.用户提交代码,使用判题功能(1.每个题的测试永和 2.compile和run即compile_server服务)svr.Post(R"(/judge/(\d+))", [&control](const Request &req, Response &res){// res.set_content("提交代码","text/plain;charset=utf8");std::string number = req.matches[1];std::string out_json;std::string in_json=req.body;control.Judge(number,in_json,&out_json);res.set_content(out_json,"application/json;charset=utf-8");});svr.set_base_dir("./wwwroot/");svr.listen("0.0.0.0", 8080);
}
oj_server 服务最终向外提供四个服务。
- oj_server 主界面服务。
- 获取所有题目列表页面服务。
- 获取单个题目编辑页面服务。
- 判题服务(核心服务)。
测试 oj_server 网络服务
测试主界面
测试符合预期。
测试获取所有题目列表界面
测试符合预期。
测试获取单个题目编辑页面
测试符合预期。
测试判题服务
测试用例1:死循环,最终返回CPU运行超时,并且 status 为 24,为 24 号信号。
测试结果符合预期。
测试用例2: new 大块空间,最终返回内存内存超过范围,status 为 6,为 6 号信号。
测试用例3:除零操作,最终显示浮点数溢出,status 为8,表示收到了 8 号信号。
测试结果符合预期。
测试用例4:代码无异常,最终运行成功,status 为0,表示没有收到任何信号。
测试结果符合预期。
综上,oj_server 网络服务无异常且可以成功运行。
构建前端页面
我们的 在线oj 系统的前端页面全是基于 oj_server 服务的前端页面。主要分为三个页面。
- oj_server 服务的主页面。
- oj_server 服务的所有题目的列表页面。
- oj_server 服务的单个题目的页面。
注意:前端的所有代码我们只做了解即可,作为后端程序员关注点在后端的代码运行逻辑和前后端交互代码的运行逻辑。
oj_server 服务主页面
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>这是我的个人OJ系统</title><style>/* 起手式, 100%保证我们的样式设置可以不受默认影响 */* {/* 消除网页的默认外边距 */margin: 0px;/* 消除网页的默认内边距 */padding: 0px;}html,body {width: 100%;height: 100%;}.container .navbar {width: 100%;height: 50px;background-color: rgb(24, 23, 23);/* 给父级标签设置overflow,取消后续float带来的影响 */overflow: hidden;}.container .navbar a {/* 设置a标签是行内块元素,允许你设置宽度 */display: inline-block;/* 设置a标签的宽度,a标签默认行内元素,无法设置宽度 */width: 80px;/* 设置字体颜色 */color: white;/* 设置字体的大小 */font-size: large;/* 设置文字的高度和导航栏一样的高度 */line-height: 50px;/* 去掉a标签的下划线 */text-decoration: none;/* 设置a标签中的文字居中 */text-align: center;}/* 设置鼠标事件 */.container .navbar a:hover {background-color: green;}.container .navbar .login {float: right;}.container .content {/* 设置标签的宽度 */width: 800px;/* 用来调试 *//* background-color: #ccc; *//* 整体居中 */margin: 0px auto;/* 设置文字居中 */text-align: center;/* 设置上外边距 */margin-top: 200px;}.container .content .font_ {/* 设置标签为块级元素,独占一行,可以设置高度宽度等属性 */display: block;/* 设置每个文字的上外边距 */margin-top: 20px;/* 去掉a标签的下划线 */text-decoration: none;/* 设置字体大小font-size: larger; */}.container .footer {width: 100%;height: 50px;text-align: center;line-height: 50px;color: #ccc;margin-top: 20px;}</style>
</head><body><div class="container"><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a></div><!-- 网页的内容 --><div class="content"><h1 class="font_">欢迎来到我的OnlineJudge平台</h1><p class="font_">这是我个人独立开发的一个在线OJ平台</p><a class="font_" href="/all_questions">点击我开始编程!</a></div><div class="footer"><h5><a href="https://blog.csdn.net/qq_55958734">@以棠~</a></h5></div></div>
</body></html>
主界面图示如下。
oj_server 服务所有题目列表页面
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>在线OJ-题目列表</title><style>/* 起手式, 100%保证我们的样式设置可以不受默认影响 */* {/* 消除网页的默认外边距 */margin: 0px;/* 消除网页的默认内边距 */padding: 0px;}html,body {width: 100%;height: 100%;}.container .navbar {width: 100%;height: 50px;background-color: black;/* 给父级标签设置overflow,取消后续float带来的影响 */overflow: hidden;}.container .navbar a {/* 设置a标签是行内块元素,允许你设置宽度 */display: inline-block;/* 设置a标签的宽度,a标签默认行内元素,无法设置宽度 */width: 80px;/* 设置字体颜色 */color: white;/* 设置字体的大小 */font-size: large;/* 设置文字的高度和导航栏一样的高度 */line-height: 50px;/* 去掉a标签的下划线 */text-decoration: none;/* 设置a标签中的文字居中 */text-align: center;}/* 设置鼠标事件 */.container .navbar a:hover {background-color: green;}.container .navbar .login {float: right;}.container .questions_list {padding-top: 50px;width: 800px;height: 100%;margin: 0px auto;/* background-color: #ccc; */text-align: center;}.container .questions_list table {width: 100%;font-size: large;font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;margin-top: 50px;background-color: rgb(243, 248, 246);}.container .questions_list h1 {color: green;}.container .questions_list table .item {width: 100px;height: 40px;font-size: large;font-family:'Times New Roman', Times, serif;}.container .questions_list table .item a {text-decoration: none;color: black;}.container .questions_list table .item a:hover {color: blue;text-decoration:underline;}.container .footer {width: 100%;height: 50px;text-align: center;line-height: 50px;color: #ccc;margin-top: 15px;}</style>
</head><body><div class="container"><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a></div><div class="questions_list"><h1>OnlineJuge题目列表</h1><table><tr><th class="item">编号</th><th class="item">标题</th><th class="item">难度</th></tr>{{#questions_list}}<tr><td class="item">{{number}}</td><td class="item"><a href="/question/{{number}}">{{title}}</a></td><td class="item">{{star}}</td></tr>{{/questions_list}}</table></div><div class="footer"><h5><a href="https://blog.csdn.net/qq_55958734">@以棠~</a></h5></div></div></body></html>
题目列表界面如下。
oj_server 服务单个题目页面
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{{number}}.{{title}}</title><script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ace.js" type="text/javascript"charset="utf-8"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ext-language_tools.js" type="text/javascript"charset="utf-8"></script><!-- 引入jquery CDN --><script src="http://code.jquery.com/jquery-2.1.1.min.js"></script><style>* {margin: 0;padding: 0;}html,body {width: 100%;height: 100%;}.container .navbar {width: 100%;height: 50px;background-color: black;/* 给父级标签设置overflow,取消后续float带来的影响 */overflow: hidden;}.container .navbar a {/* 设置a标签是行内块元素,允许你设置宽度 */display: inline-block;/* 设置a标签的宽度,a标签默认行内元素,无法设置宽度 */width: 80px;/* 设置字体颜色 */color: white;/* 设置字体的大小 */font-size: large;/* 设置文字的高度和导航栏一样的高度 */line-height: 50px;/* 去掉a标签的下划线 */text-decoration: none;/* 设置a标签中的文字居中 */text-align: center;}/* 设置鼠标事件 */.container .navbar a:hover {background-color: green;}.container .navbar .login {float: right;}.container .part1 {width: 100%;height: 600px;overflow: hidden;}.container .part1 .left_desc {width: 50%;height: 600px;float: left;overflow: scroll;}.container .part1 .left_desc h3 {padding-top: 10px;padding-left: 10px;}.container .part1 .left_desc pre {padding-top: 10px;padding-left: 10px;font-size: medium;font-family:'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;}.container .part1 .right_code {width: 50%;float: right;}.container .part1 .right_code .ace_editor {height: 600px;}.container .part2 {width: 100%;overflow: hidden;}.container .part2 .result {width: 300px;float: left;}.container .part2 .btn-submit {width: 120px;height: 50px;font-size: large;float: right;background-color: #26bb9c;color: #FFF;/* 给按钮带上圆角 *//* border-radius: 1ch; */border: 0px;margin-top: 10px;margin-right: 10px;}.container .part2 button:hover {color:green;}.container .part2 .result {margin-top: 15px;margin-left: 15px;}.container .part2 .result pre {font-size: large;}</style>
</head><body><div class="container"><!-- 导航栏, 功能不实现--><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a></div><!-- 左右呈现,题目描述和预设代码 --><div class="part1"><div class="left_desc"><h3><span id="number">{{number}}</span>.{{title}}_{{star}}</h3><pre>{{desc}}</pre></div><div class="right_code"><pre id="code" class="ace_editor"><textarea class="ace_text-input">{{pre_code}}</textarea></pre></div></div><!-- 提交并且得到结果,并显示 --><div class="part2"><div class="result"></div><button class="btn-submit" onclick="submit()">提交代码</button></div></div><script>//初始化对象editor = ace.edit("code");//设置风格和语言(更多风格和语言,请到github上相应目录查看)// 主题大全:http://www.manongjc.com/detail/25-cfpdrwkkivkikmk.htmleditor.setTheme("ace/theme/monokai");editor.session.setMode("ace/mode/c_cpp");// 字体大小editor.setFontSize(16);// 设置默认制表符的大小:editor.getSession().setTabSize(4);// 设置只读(true时只读,用于展示代码)editor.setReadOnly(false);// 启用提示菜单ace.require("ace/ext/language_tools");editor.setOptions({enableBasicAutocompletion: true,enableSnippets: true,enableLiveAutocompletion: true});</script>
</body></html>
单个题目页面图示如下。
在单个题目的页面,我们使用了 ACE 在线编辑器,实现了基于 C/C++ 环境的代码编写功能,大家感兴趣可以自己去了解, 这里大家直接复制粘贴即可。
前后端交互代码
当用户在单个题目页面,编辑好了代码,点击提交代码,就会出发按钮的点击事件并调用响应函数,此时就会涉及到和后端交互。前端将用户编辑的代码打包为 Json 格式的数据,我们使用 jquery 第三方库中的 ajax 将用户打包之后的 Json 格式的数据作为 http 请求的 body 字段,最终生成一个http请求。通过 url 向 oj_server 的判题服务发送 http 请求,最终接收到后端的响应 json 串反序列化之后的 Json 对象,然后 jquery 读取后端返回的 Json 对象的每个字段,将其值传递给前端动态生成的标签,最终将响应显示在前端。前后端交互代码如下。
<script>//初始化对象editor = ace.edit("code");//设置风格和语言(更多风格和语言,请到github上相应目录查看)// 主题大全:http://www.manongjc.com/detail/25-cfpdrwkkivkikmk.htmleditor.setTheme("ace/theme/monokai");editor.session.setMode("ace/mode/c_cpp");// 字体大小editor.setFontSize(16);// 设置默认制表符的大小:editor.getSession().setTabSize(4);// 设置只读(true时只读,用于展示代码)editor.setReadOnly(false);// 启用提示菜单ace.require("ace/ext/language_tools");editor.setOptions({enableBasicAutocompletion: true,enableSnippets: true,enableLiveAutocompletion: true});function submit(){// 1. 收集当前页面的有关数据, 1. 题号 2.代码var code = editor.getSession().getValue();var number = $(".container .part1 .left_desc h3 #number").text();var judge_url = "/judge/" + number;// 2. 构建json,并通过ajax向后台发起基于http的json请求$.ajax({method: 'Post', // 向后端发起请求的方式url: judge_url, // 向后端指定的url发起请求dataType: 'json', // 告知server,我需要什么格式contentType: 'application/json;charset=utf-8', // 告知server,我给你的是什么格式data: JSON.stringify({'code':code,}),success: function(data){//成功得到结果show_result(data);}});// 3. 得到结果,解析并显示到 result中function show_result(data){// 拿到result结果标签var result_div = $(".container .part2 .result");// 清空上一次的运行结果result_div.empty();// 首先拿到结果的状态码和原因结果var _status = data.status;var _reason = data.reason;var reason_lable = $( "<p>",{text: _reason});reason_lable.appendTo(result_div);if(status == 0){// 请求是成功的,编译运行过程没出问题,但是结果是否通过看测试用例的结果var _stdout = data.stdout;var _stderr = data.stderr;var stdout_lable = $("<pre>", {text: _stdout});var stderr_lable = $("<pre>", {text: _stderr})stdout_lable.appendTo(result_div);stderr_lable.appendTo(result_div);}else{// 编译运行出错,啥也不做}}}</script>
MySQL 版题库构建
为了简化 Model 模块中题库的加载逻辑,我们将使用 MySQL 数据库表存储每个题目的详细信息,这样就可以从数据库表中直接读取对应的字段赋值给 Question 对象,而不再去使用文件读写的繁琐方法去读取配置文件中对应题目对应的字段,即数据库表就是天然的题库。
我们需要使用一个用户,一个数据库,一个数据库表。
- 使用 oj_client 作为访问数据库的对象。
- 使用 oj 作为数据库。
- 使用 oj_questions 作为数据库表。
创建 oj 数据库。
创建 oj_client 用户,允许其以任意方式登录 mysqld 服务,并给其赋予在 oj 数据库下的所有表的所有权限。
使用 MySQL Workbench 创建数据库表
在 oj 数据库下创建 oj_questions 表 sql 语句如下。
CREATE TABLE IF NOT EXISTS `oj_questions`(
number int PRIMARY KEY AUTO_INCREMENT COMMENT '题目的编号',
title VARCHAR(64) NOT NULL COMMENT '题目的标题',
star VARCHAR(8) NOT NULL COMMENT '题目的难度',
question_desc TEXT NOT NULL COMMENT '题目描述',
header TEXT NOT NULL COMMENT '题目头部,给用户看的代码',
tail TEXT NOT NULL COMMENT '题目尾部,包含我们的测试用例',
time_limit int DEFAULT 1 COMMENT '题目的时间限制',
mem_limit int DEFAULT 30000 COMMENT '题目的空间限制'
)ENG
向表中插入题目数据。
更改 Model 模块代码
在文件版本的 Model 模块中,我们需要去通过各种配置文件和源文件加载题库,但是在 MySQL 版本的 Model 模块中,我们只需要在对应的 oj_questions 表中录入题目对应的信息即可。
MySQL 版本的 Model 模块主要有三个功能。
- 获取所有的题目信息。
- 通过题号获取单个题目的信息。
- 通过 C 语言链接数据库,并通过 sql 语句查询题目信息。
获取所有题目信息
// 获取所有的题目的题目信息bool GetAllQuestions(vector<Question> *out){string sql = "select *from ";sql += oj_questions;return QueryMysql(sql, out);}
获取单个题目信息
// 通过题目编号获取这个题目的细节信息bool GetOneQuestion(const string &number, Question *q){string sql = "select * from ";sql += oj_questions;sql += " where number= ";sql += number;vector<Question> result;if (QueryMysql(sql, &result)){if (result.size() == 1){*q = result[0];return true;}}return false;}
C 语言链接数据库通过 sql 查询题目信息
bool QueryMysql(const string &sql, vector<Question> *out){// 1.创建mysql句柄MYSQL *my = mysql_init(nullptr);// 2.链接数据库if (nullptr == mysql_real_connect(my, host.c_str(), usr.c_str(), passwd.c_str(), db.c_str(), port, nullptr, 0)){LOG(FATAL) << "链接数据库失败!" << endl;return false;}// 3.设置链接编码格式mysql_set_character_set(my, "utf8");LOG(INFO) << "链接数据库成功!" << endl;// 4.访问数据库if (0 != mysql_query(my, sql.c_str())){LOG(WARRING) << sql << "execute error!" << endl;return false;}LOG(INFO) << sql << "execute success!" << endl;// 5.从MYSQL句柄中获取查询结果MYSQL_RES *res = mysql_store_result(my);// 6.解析结果,读取记录int rows = mysql_num_rows(res);int fileds = mysql_num_fields(res);Question q;for (int i = 0; i < rows; i++){MYSQL_ROW line = mysql_fetch_row(res);q.number = line[0];q.title = line[1];q.star = line[2];q.desc = line[3];q.header = line[4];q.tail = line[5];q.cpu_limit = atoi(line[6]);q.mem_limit = atoi(line[7]);out->push_back(q);}//7.关闭链接mysql_close(my);return true;}
这便是 MySQL 版本的 Model 模块。
项目综合调试
通过 公网ip+端口号 的方式进入 oj_server 服务主页面。
点击开始进行编程,进入题目列表界面。证明 oj_server 的加载主页面功能正常。
在后端检测到了 control 对象查询数据库中所有题目记录,证明 oj_server 获取所有题目信息并构建 html 网页功能正常。
点击判断回文数进入对应题目界面。
同时在后端检测到了 control 对象获取数据库中的单个题目信息,证明 oj_server 的获取单个题目信息并构建 html 网页功能正常。
输入代码点击提交代码,在前端成功接收到后端响应,证明 oj_sever 的判题服务和 compile_server 的编译和运行服务正常。
前端多次提交,后端负载均衡式分配编译服务。
通过后端日志,发现 oj_server 服务的负载均衡功能和一键上线所有服务功能均正常。
综上,在线oj 项目的所有功能均已实现并能成功运行。
项目地址
Online OJ: 在线oj
项目总结
以上便是负载均衡在线 oj 项目的所有内容。
本期内容到此结束^_^