大家一定都用过牛客、力扣等在线oj系统来刷题,这个项目是对这些在线oj平台的简单模仿。
项目源码:https://gitee.com/hehuoren8685/online_-judge
项目所用技术及环境
技术:C++ STL 标准库、Boost 准标准库、cpp-httplib 第三方开源网络库、jsoncpp 第三方开源序列化、反序列化库、负载均衡设计、多进程、多线程、MySql
环境:Centos 7 云服务器、VsCode、MySql Workbench
模块介绍
项目分为三个模块,分别为:
Comm模块:公用模块,其内包含一些复用性强的代码,供其他模块使用,包括文件操作、字符串处理、网络请求等功能。
Compiler_server模块:编译并运行模块,当用户提交代码到服务器上时,要在服务器上形成临时文件,编译并运行,得到运行结果。
OnlineJudge模块:能调用编译运行模块并访问文件或数据库,把题目列表、编辑界面返回给用户,并可以负载均衡的选择服务器
宏观结构
总体流程
Compiler_Server模块
该模块的主要工作是:编译并运行代码,生成格式化的结果。下面对这个模块进行编写。
该模块的结构如下:
temp目录用于存放临时文件,compiler_run.hpp用于给外界提供编译、运行功能;compiler.hpp和runner.hpp分别单独完成编译、运行的具体功能,compiler_server.cc用于使用该方法,进行网络请求。
compiler.hpp的编写
编译只有两种结果:编译通过或者出错。编译通过什么信息都没有,但如果编译出错,那么就会向stderr打印错误信息。但如果用户的代码出现错误,在我们本地的编译服务器出现错误信息是没有意义的,因为我们要把信息返回给用户。所以,编译错误的报错信息是要形成临时文件保存结果的。那么我们如何做到这点呢?我使用了重定向dup2,把本该打印到标准错误的信息重定向到文件。
编译我们要利用g++,所以要进行进程替换。但是又不能把主进程替换,所以这里的思路是fork(),父进程继续执行,而子进程要完成编译的工作,因为它被替换掉也不会影响子进程。这里我们选用execlp函数来进行程序替换。
对于用户传上来的代码,我们要根据其名称生成后缀为.cpp的源文件,还要生成.exe可执行文件,对于编译错误还要生成.compiler_error文件,这个文件量是非常大的。我们设置编译函数的参数为不带后缀的文件名,最后会根据这个文件名生成一系列的文件,放在同目录的temp文件夹下。这一步涉及了对不带后缀的文件名进行拼接生成目标文件名的操作,而且在后面编写运行模块时也会用的到,所以我们将文件名拼接的函数放在comm目录的util.hpp内,公开使用。
由于编译、运行时都要生成临时文件,所以为了避免代码冗余,先写一个通用后缀的函数,在生成不同后缀的临时文件时,在分别调用通用函数。以下是拼接函数的实现。
const std::string temp_path = "./temp/";
class PathUtil
{
public:
static std::string AddSuffix(const std::string& file_name,const std::string& suffix)
{
std::string path_name = temp_path;
path_name += file_name;
path_name += suffix;
return path_name;
}
//编译时需要的临时文件
//构建源文件路径+后缀的完整文件名函数
//abcd -> ./temp/abcd.cpp
static std::string Src(const std::string& file_name)
{
return AddSuffix(file_name,".cpp");
}
//构建可执行程序的完整路径+后缀名
//abcd -> ./temp/abcd.exe
static std::string Exe(const std::string& file_name)
{
return AddSuffix(file_name,".exe");
}
//构建该程序对应的编译错误完整的路径+后缀名
//abcd -> ./temp/abcd.compile_error
static std::string CompilerError(const std::string& file_name)
{
return AddSuffix(file_name,".compile_error");//编译时报错
}
//运行时需要的临时文件
static std::string Stdin(const std::string& file_name)
{
return AddSuffix(file_name,".stdin");
}
static std::string Stdout(const std::string& file_name)
{
return AddSuffix(file_name,".stdout");
}
static std::string Stderr(const std::string& file_name)
{
return AddSuffix(file_name,".stderr");
}
};
目光回到compiler.hpp内。对于父进程,需要等待子进程。那么如何判断是否编译成功呢?我们知道,编译成功后是会生成.exe文件的,那么父进程就可以检测.exe文件是否形成,从而判断该段代码是否编译成功。对于这个检测文件是否生成的函数,复用性也一定很高,所以我们将其也写入util.hpp下。
class FileUtil
{
public:
static bool IsFileExists(const std::string& path_name)
{
//系统接口stat
//检测特定路径下对应的文件,获取他的属性存于第二个参数中。获取成功返回0,失败返回-1.
struct stat st;
if(stat(path_name.c_str(),&st) == 0)
{
//获取成功,文件存在
return true;
}
return false;
}
};
对于子进程,在进行程序替换前,我们要进行重定向的操作,以便让编译错误的信息可以存储在临时文件之中。这里用open函数来打开新的临时文件,如果临时文件不存在则创建。重定向时利用dup2函数。
以下是整个编译模块的代码(函数部分):
namespace ns_compiler
{
// 引入路径拼接、日志功能
using namespace ns_util;
using namespace ns_log;
class Compiler
{
public:
Compiler()
{}
~Compiler()
{}
//编译函数,返回值代表编译成功与否,参数为要编译的文件名(无后缀)
static bool Compile(const std::string& file_name)
{
pid_t pid = fork();
if(pid < 0)
{
//std::cerr << "子进程创建失败!" << std::endl;
LOG(ERROR) << "内部错误:创建子进程失败!" <<std::endl;
return false;
}
else if(pid == 0)
{
//child:调用编译器完成对代码的编译工作
//创建编译错误文件
umask(0);//权限掩码清零,方便接下来设置文件权限
int _stderr = open(PathUtil::CompilerError(file_name).c_str(),O_CREAT | O_WRONLY,0644);
if(_stderr < 0)
{
//std::cerr << "创建错误文件失败!" << std::endl;
LOG(WARNING) << "未能成功形成CompilerError文件!" << std::endl;
exit(1);
}
//g++编译错误时会把错误信息打印到cerr,重定向标准错误到_stderr,以便编译错误时直接打印至文件中而非cerr
dup2(_stderr,2);//本该打印到2号文件描述符的东西重定向至_stderr
//程序替换不会影响进程的文件描述符表!!
//在temp目录下构建出三个文件:.cpp文件、.exe文件、.stderr错误文件
//注意此处需要手动定义COMPILE_ONLINE宏,因为题目的header与tail已经拼接完成,这里如果不定义会报错(文件找不到)
//g++ -o target src -D COMPILER_ONLINE -std=c++11
execlp("g++","g++","-o",PathUtil::Exe(file_name).c_str(),PathUtil::Src(file_name).c_str(),"-D","COMPILER_ONLINE","-std=c++11",nullptr/*不要忘记最后以空结尾*/);
//程序替换失败则退出
LOG(ERROR) << "启动编译器g++失败,可能是参数错误!" << std::endl;
exit(2);
}
else
{
//father
//等待子进程,不关心其退出情况
waitpid(pid,nullptr,0);
//走到此处,子进程编译完成,要处理结果
//可执行程序是否生成则代表编译是否通过
if(FileUtil::IsFileExists(PathUtil::Exe(file_name)))
{
LOG(INFO) << PathUtil::Src(file_name) << " 编译成功" << std::endl;
return true;
}
LOG(ERROR) << "编译失败,未形成可执行程序!" << std::endl;
return false;
}
}
};
}
上面的代码涉及了"LOG(WARNING) << “未能成功形成CompilerError文件!” << std::endl;"的代码,这是打印日志的信息。我们设计日志以宏的形式来调用,宏又来调用日志函数。日志函数返回一个ostream,且其内打印的文件、行号末尾不打印endl,保留缓冲区。当用户使用日志功能时,自行打印输出信息并在末尾添加endl,这样文件名和行号就会先于错误信息打印,最后endl刷新缓冲区。日志头文件放在comm文件夹的log.hpp内。
namespace ns_log
{
using namespace ns_util;
//日志等级
enum
{
INFO, //正常
DEBUG, //调式
WARNING,//警告
ERROR, //错误
FATAL //致命
};
// 用法:LOG(等级) << "message"(开放式日志) << endl(刷新缓冲区)
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::to_string(line);
message += "]";
message += "[";
message += TimeUtil::GetTimeStamp();//时间戳
message += "]";
std::cout << message;//cout是有缓冲区的!这里未进行endl刷新缓冲区,message会暂存在cout里,此时cout返回时内含message。
return std::cout;
}
#define LOG(level) Log(#level,__FILE__,__LINE__)//宏参带#可以将宏参名称直接以字符串的方式进行替换
}
上面的日志信息包含时间戳,获取时间戳也是一个函数,存储在util.hpp内。
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);
//毫秒级=秒*1000+微秒/1000
return std::to_string(_time.tv_sec*1000+_time.tv_usec/1000);
}
};
runner.hpp的编写
运行模块要考虑的事情相对较多。对于运行模块,它根本不关心程序运行结果正确与否,因为那是交给测试用例去决定的,它只需要把程序运行完,得到结果供上层参考。它需要关心的是程序运行有没有出错,如果出错了原因是什么。所以运行模块只需考虑一件事:程序是否正常运行完毕!
运行不同于编译,有多种可能:可能运行成功,也可能运行错误,还可能是内部原因导致运行失败。这样一来就不能返回简单的布尔值。我们以int作为返回值,规定:返回小于0的数代表内部错误,返回0代表运行成功;返回大于0的数代表运行异常,具体的数代表程序收到信号的编号。
运行函数的参数和编译模块同理,只需要去掉后缀的运行文件名即可。运行也需要进行多进程、程序替换,子进程来程序替换运行目标程序,父进程等待子进程。
和编译模块相同,我们还需要关心三种生成文件:标准输入、标准输出和标准错误。本项目中,我们不关心标准输入,即不关心用户自测的情况,只关心后二者。在子进程中用dup2进行重定向。在程序替换时,由于要被执行的文件是compiler模块生成在temp目录下的临时文件,所以不能用execlp函数,因为这个临时文件的路径并不在环境变量内。这里用execl函数更方便些。
不同于compiler.hpp内的.compiler_err文件在子进程内打开,runner模块的三个临时文件要在fork前的父进程内打开。因为父进程打开的文件描述符可以被子进程继承下去,且打开的文件很多,如果打开失败在父进程内也方便直接退出程序。注意,打开文件失败是内部的错误,所以根据规定要返回负数。
父进程打开文件仅仅为了继承给子进程,所以父进程在等待子进程退出前要先关闭打开的文件描述符。同时上面提到过父进程不关心程序运行结果,只关心程序是否正常运行结束,那么wait子进程时就需关心退出结果,而无需关心子进程的退出码。那么父进程该如何知道程序运行是否异常呢?
我们知道,程序如果运行异常,一定是因为收到了信号。而当程序被信号杀死时,他给父进程返回的status是如图所示的结构。所以根据规定,我们只需将父进程收到的退出结果按位与0x7F就可以得到异常所收到的信号。当然如果没收到异常,这个值就是0,所以我们直接把这个值当做返回值即可。
做到这一步时,运行模块就结束了吗?答案是并没有。可能又人会发送恶意代码,死循环、无限申请空间等,所以我们还需要对时间空间进行限制。
这里介绍一个接口setrlimit。这个接口可以设置一个进程时间或空间的上限,通过一个结构体传入时间或空间的限制。结构体成员一个是软限制,一个是硬限制。硬限制是软限制的最高取值,所以我们设置为无穷,软限制就是这个进程可用时间或空间资源的最大值。注意:当程序使用资源过度导致终止时,也是被信号所杀死的,所以可以和前面说到的逻辑相同,返回status即可告知上层。
以下是封装资源控制的函数代码
//该函数可设置进程占用资源大小
//_mem_limit:以KB为单位
static void SetProcLimit(int _cpu_limit,int _mem_limit)
{
//设置cpu时长
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;//因为是以字节为单位,所以将KB转化为B
setrlimit(RLIMIT_AS,&mem_rlimit);
}
以下是运行函数的代码。值得一提的是此时run新增了两个参数,分别是进程运行时可以使用的最大CPU上限(以秒为单位)和最大内存限制(以kb为单位),由调用该函数的上层来决定进程的资源限制。
// 指明文件名即可,理同compiler
/*
返回值:
>0:程序异常,退出时收到信号,返回值为对应信号编号
==0:程序正常运行完毕,结果已保存到对应的临时文件中
<0:内部错误
cpu_limit:该程序运行时可以使用的最大cpu资源上限
mem_limit:该程序运行时可以使用的最大的内存大小(单位KB)
*/
static int Run(const std::string& file_name,int cpu_limit,int mem_limit)
{
/*
程序运行结果:
1.代码跑完结果正确
2.代码跑完结果错误
3.代码异常运行失败
Run模块无需考虑代码跑完结果正确与否!结果是测试用例要关注的!
Run模块只考虑是否正确运行完毕,其余交给上层即可!
一个程序在启动时
标准输入:不处理(忽略用户自测的情况)
标准输出:代表程序运行完成的结果
标准错误:程序运行时错误信息
*/
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) << "运行时打开标准文件失败!" << std::endl;
return -1;//代表打开运行时所需文件失败
}
pid_t pid = fork();
if(pid < 0)
{
LOG(ERROR) << "运行时创建子进程失败!" << std::endl;
close(_stdin_fd);
close(_stdout_fd);
close(_stderr_fd);
return -2;//代表创建子进程失败
}
else if(pid == 0)
{
//child继承father打开的文件描述符表
//重定向stdin、stdout和stderr
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
{
//father
//父进程并不关心打开的文件描述符表,直接先全关闭
close(_stdin_fd);
close(_stdout_fd);
close(_stderr_fd);
//等待子进程,不关心退出码,但关心退出结果是否异常
int status = 0;
waitpid(pid,&status,0);
//程序若运行因为异常而终止,一定是因为收到了信号!
LOG(INFO) << "运行完毕。info: " << (status & 0x7F) << std::endl;
return status & 0x7F;//判断子进程运行完毕是否异常,若无异常返回0,有异常返回>0的数
}
}
conpiler_server.hpp的编写
我们的程序并不会直接调用compiler或run模块,所以这个头文件会将这两个模块统一起来,供上层调用。
编译服务所接受的代码一定是从网络上上传的,所以我们这里用一个第三方库jsoncpp来处理网络请求。定义一个Start函数,输入是一个json串,输出用输出型参数返回一个json串。规定输入的in_json内包含kv模式的代码、输入(不处理)、内存和CPU限制,输出out_json则返回给上层状态码、出错原因(如果)、标准输出和错误。
我们在处理用户提交的代码时,要先根据提交的代码生成.cpp文件,这样才可以交给编译模块编译。但此时有可能同时处理多个用户的请求,当这些请求同时到我们的服务器上时,我们就必须界定唯一的文件名给每个请求。所以我们设计一个函数,用来生成唯一的文件名(无目录无后缀)。逻辑为:毫秒级时间戳+原子性地增值(防止极端情况下同时登陆)来形成文件名。生成完文件名后,我们当然要把用户代码写入文件内,而此时又需要一个写入文件的函数,写入到同路径的temp目录下。这两个函数放在/comm的util.hpp内。
static std::string UniqFileName()
{
//毫秒级时间戳+原子性递增唯一值
//使用C++11提供的原子性递增的计数器
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;
//geiline不保存行分隔符!有时需要保留\n,有时不需要,所以需要根据第三个参数来判断(默认不保留)
//getline内部重载了强制类型转换,作为while循环判断条件时会根据获取内容成功与否有true/false保证
while(std::getline(in,line))
{
(*content) += line;
(*content) += (keep ? "\n" : "");
}
in.close();
return true;
}
同样给出util.hpp内关于时间的函数
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);
//毫秒级=秒*1000+微秒/1000
return std::to_string(_time.tv_sec*1000+_time.tv_usec/1000);
}
};
具体逻辑为:先通过in_json把用户提交的内容提取出来,然后构建一个outjson并填入状态码、错误信息等。由于用户输入数据、写入文件、编译和运行时都有可能出现错误,所以我们把差错处理统一,在编译运行的过程中,如果有一环出现错误,就直接跳入到函数末尾的差错处理部分。我们再额外定义一个函数,作用是根据错误码来形成原因,最后构建outjson并返回。错误码取值规定为0(正常)、-1(用户提交代码为空)、-2(内部错误,但给用户返回时reason为"未知错误")、-3(编译错误,此时reason为生成的compiler_error内的内容)和大于0的情况(进程被信号所杀死,reason为信号对应的描述)。此处还涉及读取文件的函数,一并放在上面的代码内。注意:当且仅当错误码为0时,我们才可以读取stdout和stderr内的内容,并将其加入outjson一并返回。还有,Start函数内生成的错误码,与run函数内部生成的错误码是没有任何关系的。
我们发现,一个程序就会形成2-6份临时文件,如果不清理,那么会造成很大的空间浪费。所以我们在这里再添加一个删除临时文件的函数。我们在删除文件时,虽然不知道一共需要清理文件的个数,但是我们有文件名,也就可以知道这个文件名对应的所有临时文件,最后再依次判断文件是否存在,如果存在就删除即可。删除用系统接口unlink。
static void RemoveTempFile(const std::string& file_name)
{
//清理文件的个数并不确定,但是有哪些,是知道的!
std::string _src = PathUtil::Src(file_name);
if(FileUtil::IsFileExists(_src))
//系统接口unlink直接删除文件,相当于rm -f
unlink(_src.c_str());
std::string _compiler_error = PathUtil::CompilerError(file_name);
if(FileUtil::IsFileExists(_compiler_error))
unlink(_compiler_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());
}
以下是编译并运行的代码:
class CompileAndRun
{
public:
/*
code: >0? <0? =0?
code > 0:进程收到信号异常崩溃,code为信号编号
code < 0:整个过程非运行报错:代码为空/编译报错
code == 0:整个过程全部成功,结果已经存入文件
注:该模块不在意运行结果是否正确!运行结果正确交给测试用例去处理
*/
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:
//编译失败应该打印具体编译失败内容
//desc = "编译发生错误";
FileUtil::ReadFile(PathUtil::CompilerError(file_name),&desc,true);
break;
case SIGABRT:
desc = "内存超过范围";
break;
case SIGXCPU:
desc = "CPU使用超时";
break;
case SIGFPE:
desc = "浮点数溢出";
break;
default:
desc = "debug:unknown: " + std::to_string(code);
break;
}
return desc;
}
/*
输入:
code:用户提交的代码
input:用户对其代码的输入
cpu_limit:时间要求
mem_limit:空间要求
输出:
必填:
status:状态码
0:成功 非0:失败
reason:请求结果,对应状态码
选填:
stdout:程序运行完的结果
stderr:程序运行完的错误结果
in_json:{"code":"..." "input":"..." "cpu_limit":1 "mem_limit":01024}
out_json:{"status(状态码)":"0/1/2...","reason(原因)":"...",
"stdout":"...","stderr":"..."}
*/
static void Start(const std::string& in_json,std::string* out_json)
{
Json::Value in_value;
Json::Reader reader;
//parse:第一个参数是要解析谁(哪个流),第二个是要反序列化到哪里
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();
//形成临时src源文件
if(!FileUtil::WriteFile(PathUtil::Src(file_name),code))
{
status_code = -2;//未知错误
goto END;
}
//编译和运行
if(!Compiler::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;//未知错误
}
else if(run_result > 0)
{
status_code = run_result;//程序运行崩溃
}
else
{
status_code = 0;//运行成功
}
END:
//status_code: >0:运行时错误/<0:编译或内部错误/=0:正常运行
out_value["status"] = status_code;
out_value["reason"] = CodeToDesc(status_code,file_name);
if(status_code == 0)
{
//整个过程全部成功,可以读取stdout与stderr
//注:运行时出错和运行时打印出错误信息是两回事!!
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);
//删除临时文件
RemoveTempFile(file_name);
}
};
compiler_server.cc的编写
compiler_run.cc的任务是将编译运行模块与网络连接,用来收到网络上的json请求。在这里我们使用cpp-httplib第三方库来进行网络交互。
我们规定:在运行本文件形成的可执行文件时,采用./compiler_server port的形式,这里port是自定义的端口号,用来指定编译服务器在本机上的端口。这里我们不设置首页,因为首页在下面的oj_server中设计,这里只暴露一个方法等待上层调用即可。
void Usage(std::string proc)
{
std::cerr << "Usage: " << "\n\t" << proc << " port" << std::endl;
}
// 使用:./compile_server port (端口号自己指定)
int main(int argc,char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return 1;
}
//提供编译服务,打包形成一个网络服务
Server svr;
// svr.Get("/hello",[](const Request& req,Response& resp){
// resp.set_content("hello,http!","text/plain;charset=utf-8");
// });
svr.Post("/compile_and_run",[](const Request& req,Response& resp)
{
//用户请求的正文是我们要的json串
std::string in_json = req.body;
std::string out_json;
if(!in_json.empty())
{
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;
}
oj_server模块的编写
oj服务本质上是建立一个小型网站。其内我们实现:
1.获取首页,我们这里的首页是题目列表
2.编辑代码区域
3.提交判题功能(基于编译运行模块)
我们采用MVC设计模式。M是Model,通常是和数据交互的模块。在这里我们的数据是题目列表,所以实现的是对题库进行增删查改的功能(分为文件和MySql版本);V是View,指拿到数据后构建网页,展示给用户;C代表Control,用于控制前两个模块,是核心业务逻辑。
本模块的结构如下:
下面结合每个模块的编写一并介绍他们的功能。
oj_server.cc的编写
oj_server用于处理用户请求的服务路由功能,是将其他小模块统一起来的外层模块。用户可能的请求是:
1.获取所有的题目列表
2.根据一个特定的题目编号,获取指定题目的内容
3.用户提交代码,使用判题功能(①每道题的测试用例②编译并运行)
我们只需针对这三种情况分别设置网络响应即可。
这里需要特别注意一点:当用户选择具体题目时,请求URL会根据具体题目的不同编号发生改变,所以这里在用httplib的Post功能捕获请求时,要用一个正则匹配,匹配到的内容存放在请求结构体的matches的1号下标处。为防止正则表达式被特殊转义,还要用C++ R"(…)"的原始字符串。判题也涉及到具体的题目,与选择具体的一道题形式类似。最后在本目录底下新建一个wwwroot,设置为我们系统的首页。
static Control* ctrl_ptr = nullptr;
void Recovery(int signo)
{
//注册一个方法,每当ctrl+\时,就尝试上线所有机器
LOG(INFO) << "尝试上线主机成功!" << std::endl;
ctrl_ptr->RecoverMachine();
}
int main()
{
signal(SIGQUIT,Recovery);
//用户请求的服务路由功能
Control ctrl;
ctrl_ptr = &ctrl;
Server svr;
/*
用户可能的请求:
1.获取所有题目列表(首页)
2.根据题目编号获取题目内容
3.提交代码,使用判题功能(1.每道题的测试用例 2.compile_and_run)
*/
svr.Get("/all_questions",[&ctrl](const Request& req,Response& resp)
{
//需要返回一张包含所有题目的html网页
std::string html;
ctrl.AllQuestions(&html);
resp.set_content(html,"text/html;charset=utf-8");
});
//选题:正则表达式 如用户请求 /questions/100 \d代表匹配数字,+代表一或多个,所以可以匹配到用户输入的任意数字
//R"()" 原始字符串,里面是什么样就是什么样,保持字符串内容的原貌,无需做相关强转
svr.Get(R"(/question/(\d+))",[&ctrl](const Request& req,Response& resp)
{
//req.matches[1]代表了正则匹配到的题号
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)
{
//req.matches[1]代表了正则匹配到的题号
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");
//resp.set_content("这是指定的题目的判题:"+number,"text/plain;charset=utf-8");
});
//首页设置为本目录下的wwwroot
svr.set_base_dir("./wwwroot");
svr.listen("0.0.0.0",8081);
return 0;
}
这里涉及了许多其他模块的功能,我们先放在这里有个框架,接下来对每个模块详细介绍。
文件版题库的建立
在编写其他模块之前,我们先要有一个题库,这样在完成其他模块时才能更方便的编写功能。
首先我们要明确题库的位置。在oj_server目录下存放一个questions目录,代表着我们的题库目录。
对于其中每个题目,我们规定要有:
1.题目的编号
2.题目的标题
3.题目的难度
4.题目的描述
5.题目的时间和空间要求(内部设定,不暴露给用户)
在实际的oj平台上时,都会有一个题目列表,当点进去具体的题目时,才会进到这个题目的页面,包含上面提到的内容。所以我们还需要一个文件:一个题目列表,包含所有的题目。
除此之外,当点进去具体的题目做题时,还会有一些预设代码(C++一般为Solution类),让我们在所规定的范围内写代码。这部分预设代码,也应该作为文件保存,我们称之为header.cpp;最后,当用户提交代码之后,我们要结合测试用例进行判题,而这个测试用例当然也是代码,需要放在文件内保存。我们将其称之为tail.cpp,将来判题时将二者结合(通过题目的编号),并交给compiler_run模块进行统一运行判断。
具体格式为:
questions.list:题目编号 题目标题 题目难度 题目时间限制(s) 题目空间限制(kb)
每个具体题目对应一个目录,目录名为其题目编号。目录下有三个文件,分别为desc.txt、header.cpp和tail.cpp。desc.txt内存放对题目的具体描述,其余两个上面已经进行介绍,不再赘述。
以回文数为例,下面是他的目录下的内容:
//header.hpp
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>
using namespace std;
class Solution
{
public:
bool IsPalindrome(int x)
{
//write your code here...
return true;
}
};
//tail.hpp
//采用条件编译,仅为了在拼接之前单独设计测试用例时不要出现语法提示错误!
//在编译时可采用 g++ -D COMPILER_ONLINE编译时定义该宏,在拼接时编译器就会将该段代码自动裁剪!
#ifndef COMPILER_ONLINE
#include "header.cpp"
#endif
void Test1()
{
//通过定义临时对象来完成方法的调用
bool ret = Solution().IsPalindrome(121);
if(ret)
{
std::cout << "通过用例1:121" << std::endl;
}
else
{
std::cout << "未通过用例1:121" << std::endl;
}
}
void Test2()
{
bool ret = Solution().IsPalindrome(-10);
if(!ret)
{
std::cout << "通过用例2:-10" << std::endl;
}
else
{
std::cout << "未通过用例2:-10" << std::endl;
}
}
int main()
{
Test1();
Test2();
return 0;
}
注意在tail.hpp内最开始我们采用了条件编译。这是因为如果不加这一行,虽然在拼接后没什么影响,但是不拼接的情况下tail.hpp文件内会有很多报错影响观感。在将来拼接时我们可以定义宏COMPILER_ONLINE(g++ -D选项)来取消重复引用头文件这一行。
将来用户提交代码时,我们将用户代码、预设代码和测试用例整体拼接,并把整段代码以json串的形式交给编译运行模块。运行结果会以json形式返回。我们将来只需把通过的测试用例的个数返回给客户端,就完成了判题的功能。
oj_model.hpp的编写
根据上面的讲解可以知道,model模块用于和数据进行交互,提供对外访问数据的接口。根据最上层的要求,要有提供所有题目和提供单个具体题目的接口。我们设置一个题目结构体,信息如下:
//问题结构体
struct Question
{
std::string number; //唯一题目编号
std::string title; //题目的标题
std::string star; //题目的难度等级 (简单 中等 困难)
int cpu_limit; //题目的时间要求
int mem_limit; //题目的空间要求
std::string desc; //题目的描述
std::string header; //题目给用户预设的代码
std::string tail; //题目的测试用例,需要与header拼接形成完整代码
};
然后建立一个哈希表,对每个题目建立从题号到题目具体信息的映射。
unordered_map<std::string,Question> questions;
要完成提供所有题目和提供具体题目的接口之前,先要把题目加载到内存中。设计一个Model类,并在构造函数中运行加载配置文件的函数。加载配置文件就先要读取同级目录下的questions/questions.list获取所有题目,然后再根据编号获取具体题目。
因为我们在写入questions.list时是按行写入的,所以这里每次用一个getline就可以获取一个题目的部分信息。这个部分信息里就包含题号,然后我们又可以通过拼接对应的题号到路径下,来获取一个题目的全部信息。
当我们按行读取获取一个文件的部分信息时,这个只是一个字符串,每条信息由空格分割,所以我们这里用了boost标准库的split的接口用于切分字符串,并将每条信息存储在这个题目的Question结构体内。这个切分函数放在comm模块中。
class StringUtil
{
public:
//切分字符串函数 str:原始字符串 target:切分后的若干子串存放地点 sep:分隔符
static void SplitString(const std::string& str,std::vector<std::string>* target,const std::string& sep)
{
/*
boost split 方法
第一个参数:切分子串存放地址
第二个参数:目标切分字符串
第三个参数:分隔符集,只要扫描时碰到is_any_of中的任意字符,就需要切分
第四个参数:是否进行压缩————出现多个连续的分隔符时,每两个连续的分隔符中间的空白
是否需要保留。
不保留:boost::algorithm::token_compress_on
保留:boost::algorithm::token_compress_off
*/
boost::split((*target),str,boost::is_any_of(sep),boost::algorithm::token_compress_on);
}
};
下面是读取文件的函数代码
const std::string questions_list = "./questions/questions.list"; //题目列表路径
const std::string questions_path = "./questions/"; //具体的题目路径,根据具体题目改变
class Model
{
public:
Model()
{
assert(LoadQuestions(questions_list));
}
~Model() {}
bool LoadQuestions(const std::string& question_list)
{
//加载配置文件。路径: questions/questions.list + 题目编号文件
ifstream in(question_list);
if(!in.is_open())
{
LOG(FATAL) << "加载题库失败,请检查是否存在题库对应文件!" << std::endl;
return false;
}
std::string line;
while(std::getline(in,line))
{
std::vector<std::string> tokens;
StringUtil::SplitString(line,&tokens," ");
if(tokens.size() != 5)
{
LOG(WARNING) << "加载部分题目失败,请检查文件格式" << endl;
continue;
}
//std::cout << tokens[0] << " " << tokens[1] << " "<< tokens[2] << " "
//<< tokens[3] << " " << tokens[4] << " ";
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());
std::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);
//for debug
//std::cout << q.number << std::endl << q.desc << std::endl << q.header << endl;
questions.insert({q.number,q});
}
LOG(INFO) << "加载题库成功!" << endl;
in.close();
return true;
}
};
加载全部题目进入内存后,读取单个和全部题目的接口的实现就很简单了。直接用questions来实现即可。这里采用输入输出型参数,单个题目是一个Question结构体指针,全部题目是一个vector。下面直接上代码。
bool GetAllQuestions(vector<Question>* out)
{
if(questions.size() == 0)
{
LOG(ERROR) << "用户获取全部题库失败! " << endl;
return false;
}
for(const auto& q : questions)
{
out->push_back(q.second);
}
return true;
}
bool GetOneQuestion(const std::string& number,Question* q)
{
const auto& iter = questions.find(number);
if(iter == questions.end())
{
LOG(ERROR) << "用户获取题目" << number << "失败! " << endl;
return false;
}
(*q) = iter->second;
return true;
}
oj_view.hpp的编写
当收到上层要求全部题目或者部分题目的请求时,我们要返回一个html网页给用户,这是在oj_server中指明的。当前我们已经可以通过Model模块来获取全部题目的vector或单个题目的Question结构体,那么我们还要将其转换成一个html网页。view.hpp的任务就是完成这个转换。
这个转换具体怎么完成呢?我们这里使用ctemplate网页渲染库来完成。我们在同级目录下新增一个template_html目录,并创建两个待渲染的网页all_questions.html和one_question.html。
ctemplate中需要两个主题内容:保存数据的数据字典、等待被渲染的网页内容。数据字典的键值对中,key可以在.html文件中以{{key}}的形式进行引用,最后在网页中会显示其所对应的value,这样一来,就可以用一个one_question网页来对应不同的题目,与模板类似。具体的使用规则不做细致解释,可以结合代码注释进行理解。
该模块包含两个函数,分别可以通过Question的vector来返回全部题目的网页、通过单个Question来返回单个题目的网页。这里的返回都是通过输出型参数。
对于全部题目的网页,只需要显示每个题目的编号、标题和难度就可以了。这里推荐使用表格来显示;而对于单独题目的网页,要显示题目的编号、标题、难度、描述和预设的代码(也就是每个题目的header.hpp)。详情见代码实现。
class View
{
public:
View(){}
~View(){}
void AllExpandHtml(const std::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)
{
//形成root的子字典
ctemplate::TemplateDictionary* sub = root.AddSectionDictionary("question_list");
//所有添加进子字典的数据经过循环遍历,会被添加到目标html文件"question_list"所对应的区域并依次展开(html网页内循环打印所有题目)
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* tql = ctemplate::Template::GetTemplate(src_html,ctemplate::DO_NOT_STRIP);
tql->Expand(html,&root);
}
};
oj_control.hpp的编写
这个模块是核心控制模块。其内要完成的功能有根据负载选择判题主机、判题、构建网页等。这一模块包含前两个模块的变量,方便对他们的功能进行统一。
首先是配合上层进行文件的读取并返回html网页。这点根据前面的介绍已经没什么好说的了,直接看代码。
//根据题目数据构建网页
bool AllQuestions(std::string* html)
{
bool ret = true;
std::vector<struct Question> all;
if(model_.GetAllQuestions(&all))
{
// 对all进行排序,确保网页题库的题目是有序的
std::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 = "获取题目、形成题目列表失败!";
ret = false;
}
return ret;
}
bool Question(const std::string& number,std::string* html)
{
bool ret = true;
struct Question q;
if(model_.GetOneQuestion(number,&q))
{
//获取一个题目成功,构建html并返回
view_.OneExpandHtml(q,html);
}
else
{
*html = "指定题目:" + number + "不存在!";
ret = false;
}
return ret;
}
接下来对负载均衡的功能加以实现。
我们在远端有多台编译运行服务器,所以在同级目录下定义一个server_machine.conf文件,用于存放可以提供编译运行服务的主机的ip和端口号。
在本头文件中定义两个类:Machine类和LoadBlance类。Machine是为我们提供编译运行服务的主机类,其内信息包括ip、port、当前负载信息和防止冲突的锁,并有增加负载、减少负载、获取负载和清空负载的相关接口。LoadBlance类主要实现对主机的负载均衡,其内包括可提供服务的主机vector、所有在线的主机vector和当前下线的主机vector。主机的id就是其在Machine vector内的下标。这里后两者内存放的是在主机vector内的下标,用于对应主机vector内的主机。当然还有一把锁。其内接口功能包括将可用主机初始化并将其读取到主机vector内、上线全部主机、下线一台具体主机和对主机的负载均衡选择。这里需要注意的是,因为每台机器要被放在vector内管理,所以一定会涉及拷贝,而std的mutex锁禁止拷贝,所以Machine类内的锁设置为锁指针,而对于LoadBlance类则没有类似的需求,直接用锁变量就行。
对于Machine类的相关接口编写很简单,只需注意加锁即可。
//提供服务的主机
class Machine
{
public:
Machine()
:ip("")
,port(0)
,load(0)
,mtx(nullptr)
{}
~Machine(){}
//提升主机负载
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 = 0;
if(mtx)
mtx->lock();
_load = load;
if(mtx)
mtx->unlock();
return _load;
}
std::string ip; //编译服务的ip
int port; //编译服务的port
uint64_t load; //编译服务的负载
std::mutex* mtx;//mutex禁止拷贝,所以用指针
};
接下来是LoadBlance类的编写。
该类的构造函数是运行加载机器文件的接口,如果出错直接assert报错退出程序。在加载机器文件时,思路和之前读取题目列表时一样。由于存放时规则是ip:port,所以在切分时要以:为分隔符。每读取一行机器的信息,就初始化一个Machine类对象并将其存如Machine vector。这里启动时默认全部主机全部上线。
在进行智能选择负载均衡最小的主机时,我们要返回两个值:选择主机的id和选择的主机,他们都以输出型参数的形式返回。因为我们设置的是外层要获取目标主机的地址,所以这里的主机在参数中是一个Machine类的二级指针,方便更改外层主机地址。
这里采用轮询+hash的方案进行选择。先遍历在线主机vector,再通过其内存储的下标O(1)的找到其对应的主机,并进行负载判断,最后找到负载最小的主机返回其id和本体。
//负载均衡模块
const std::string service_machine = "./conf/service_machine.conf";
class LoadBlance
{
public:
LoadBlance()
{
assert(LoadConf(service_machine));
LOG(INFO) << "加载: " << service_machine << " 成功!" << std::endl;
}
~LoadBlance(){}
bool LoadConf(const std::string& machine_conf)
{
std::ifstream in(machine_conf);
if(!in.is_open())
{
LOG(FATAL) << "加载: " << machine_conf << " 失败!" << std::endl;
return false;
}
std::string line;
while(std::getline(in,line))
{
std::vector<std::string> tokens;
StringUtil::SplitString(line,&tokens,":");
if(tokens.size() != 2)
{
LOG(WARNING) << "切分 " << line << " 失败" << std::endl;
continue;
}
Machine m;
m.ip = tokens[0];
m.port = atoi(tokens[1].c_str());
m.load = 0;
m.mtx = new std::mutex();
//先pushonline,因为下代表machine的id
online.push_back(machines.size());
machines.push_back(m);
}
in.close();
return true;
}
//id:输出型参数 ,本次选择主机的编号
//m:输出型参数,本次选择的主机地址
bool SmartChoice(int* id,Machine** m)
{
//使用选择好的主机并更新其负载
//可能需要离线该主机
//负载均衡思路:轮询+hash
//选择时需要先上锁,防止选择时别人把该主机离线等问题
mtx.lock();
int online_num = online.size();
if(online_num == 0)
{
LOG(FATAL) << "当前无可用后端编译主机!" << std::endl;
mtx.unlock();
return false;
}
//通过遍历找到负载最小的机器
*id = online[0];
*m = &machines[online[0]];
uint64_t min_load = machines[online[0]].Load();
for(int i = 0;i < online_num;i++)
{
uint64_t cur_load = machines[online[i]].Load();
if(min_load > cur_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; //找到直接break,防止迭代器失效问题
}
}
mtx.unlock();
}
void OnlineMachine()
{
//全部主机统一上线
mtx.lock();
online.insert(online.end(),offline.begin(),offline.end());
offline.erase(offline.begin(),offline.end());
mtx.unlock();
}
private:
//可以提供编译服务的主机列表,用其下标来充当自己的id
std::vector<Machine> machines;
//所有在线的主机,其内存放的是在machine内的下标
std::vector<int> online;
//所有离线的主机,其内存放的是在machine内的下标
std::vector<int> offline;
//同时可能有多个执行流访问以上的数据结构,所以需要锁保护LoadBlance的数据安全
std::mutex mtx;
};
这里涉及了一个全部主机上线的成员函数。我们在oj_server.cc内重定义了SIGQUIT(ctrl+\)的处理方法,使得每次输入这个指令就会尝试重新上线所有主机。
这个类实现后,也需要作为一个变量加入Control的成员变量内。
最后是判题功能。
我们根据上面oj_server内可以得知,判题功能是对用户提交的指定题目的代码进行判断,所以Judge函数的参数包含了指定题目的编号、用户提交的json串(包含了用户的输入信息(不做处理)、编写的代码等信息),并把最终结果以json串输出型参数的形式进行返回。
进行的工作步骤是:
1.对用户的json串进行反序列化,得到用户提交的代码;
2.将用户代码和测试用例进行拼接,形成整体可执行的程序代码;
3.在多台编译运行主机中选择负载最低的主机(差错处理);
4.对选择的主机发起http请求得到判题结果;
5.将结果返回给out_json。
我们首先要做的工作是根据题号通过Model模块直接获取对应题目的信息。拿到题目信息后,再解析输入json串,获取用户输入的代码。这个代码只是一个程序的片段,所以要和刚刚获得的题目信息进行拼接。由于编译运行模块的Start函数输入是一个json串,所以拼接成完整代码后我们还需填入时间空间要求等信息,将其整体序列化为json串发送给编译运行机器。
在选择负载最低的主机时,有可能发生请求主机失败的情况,所以这里使用一个死循环,一直请求主机,直到成功获取到主机为止。死循环内还包括下面的网络请求,以便网络请求失败后重新来过。
当成功获取主机之后,我们要对编译机发起服务请求。这里再次用httplib库,对远端发起请求。作为请求服务的一端,使用httplib的Client模块。给编译机的compiler_server.cc发送一个json串,编译机收到后给编译链接模块传递下去,最后再把结果返回,整个逻辑就串通了。要注意还需判断返回的结果的status是否为200(正常返回)。
这里还有几个细节:当选择主机成功后,要把这个主机的负载+1;当主机编译完成返回结果后,其负载要-1,并跳出死循环。而如果返回的状态码status不等于200,也要减少负载并进行下一轮选择。特别的,如果网络请求这台主机都失败了,那么就说明这台主机已经离线,需要将这台主机下线(会自动清空负载)。
下面是Judge函数的全部代码:
/*
in_json内容:
"code" : #Include ...
"input" : ...
*/
void Judge(const std::string& number,const std::string& in_json,std::string* out_json)
{
//根据题目编号拿到题目的对应细节
struct Question q;
model_.GetOneQuestion(number,&q);
//对in_json进行反序列化,得到题目信息,如id、用户源代码等
Json::Reader reader;
Json::Value in_value;
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 + "\n" + 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);
//选择负载最低的主机,发起http请求
//规则:一直选择直到主机可用,否则即为全部挂掉
while(true)
{
int id = 0;
Machine* m = nullptr;
if(!load_blance_.SmartChoice(&id,&m))
{
//主机全部挂掉,日志在SmartChoice内已经打印
break;
}
LOG(INFO) << "选择主机成功!主机ID: " << id << "详情: " << m->ip << " " << m->port << std::endl;
//发起http请求并得到结果,采用Client
Client cli(m->ip,m->port);
m->IncLoad();
//Post第一个参数:路由时要请求哪个服务 第二个参数:请求的参数 第三个参数:请求的数据类型
if(auto res = cli.Post("/compile_and_run",compile_string,"application/json;charset=utf-8"))
{
//在进行下一步的逻辑之前,需要判断是否为正常响应
if(res->status == 200)
{
//只有status值为200,才可以将结果返回给用户
*out_json = res->body;
m->DecLoad();
LOG(INFO) << "请求编译和运行服务成功!" << std::endl;
break;
}
//若if未执行说明非正常响应,那就减少负载并执行下一轮选择
m->DecLoad();
}
else
{
//请求失败
LOG(ERROR) << "请求该主机失败!主机ID: " << id << "详情: " << m->ip << " " << m->port << "该主机可能已经离线!" << std::endl;
load_blance_.OfflineMachine(id); //该函数会自动清空负载无需手动处理
}
}
}
MySql版本题库的建立
上面的基本流程已经完毕,但我们觉得题目放在文件里有点挫,所以现在尝试将题目放到MySql内。
在建立MySql题库时,基本可以分为三步:
1.在数据库中设计可以远程登录的MySql用户并给其赋权(用户名:oj_client)
2.设计表结构
数据库:oj 表:oj_questions
3.编码(连接并访问数据库)
我们以root的身份进入MySql,新增一个从任意地点登录的用户oj_client。
添加用户后查看user表:
第一个oj_client即为我们的用户,可以在任意地址登录。
同时建立了一个oj的数据库。
这里再给oj_client用户赋予oj目录下的一切权力。
这里退出root,并以oj_server身份登陆。输入show databases指令,可以看到oj目录。
在oj目录下新建一个oj_questions的表,表的详细信息如下所示。
接下来按照表项依次添加,添加进两道题目的内容。添加完查看一下(这里使用官方工具MySql Workbench来查看)。这里在MySql下也可以查看,但包含header和tail代码比较臃肿,不予展示。
MySql的题目录完之后,与文件版相同,我们也要有一套相同的model.hpp的头文件用于与数据交互。这里与文件版的大同小异,我们定义为model2.hpp。由于与数据打交道的只有model.hpp,所以我们也只需改变这个头文件的内容,并在其他文件内引用model2.hpp就行。注意接口的名称一定不能改变。
这里要与数据库进行交互,肯定少不了相关的接口。从官网下载mysql库的相关开发包,并在oj_server下与其建立软连接。如此一来,我们就可以直接在model2.hpp内引入mysql.h了。
在编写model2.hpp时,我们要保留头文件、命名空间、问题信息结构体,但文件版题库的路径、加载配置文件的函数以及调用它的构造函数、题号对应题目细节的unordered_map全都不需要,因为这些信息我们都可以从MySql直接获取。我们在这里只需要重新设计获取全部题目和单个题目的接口就行,而这两个现在对于我们而言只有sql语句的区别。当然要连接MySql,我们还需把如数据库名、表名、ip、用户名及密码、端口等信息作为常量保存等待使用。
我们新增一个QueryMysql函数,参数为sql语句和一个Question结构体的vector指针。如果获取全部题目就正常放入,如果获取一道题目那存放一个值就好。在获取全部和指定题目的接口中,直接复用这个函数即可。
在连接MySql时,先创建一个mysql句柄,再通过上面的常量进行连接。设置一下链接编码格式,就可以通过远程执行sql语句并获得其返回值。将返回值结果的行和列提取,并遍历每一行,在每次遍历中提取一列的消息,填入到单个Question结构体内,最后存放到输出vector内。在最后别忘了释放结果空间并关闭mysql。
而获取全部和单个题目的接口编写也变得轻而易举。只需要编写sql语句"select * from oj_questions"和"select * from oj_questions where number= x"就可以获得全部题目和编号为x的题目了。
namespace ns_model
{
using namespace std;
using namespace ns_log;
using namespace ns_util;
// 问题结构体
struct Question
{
std::string number; // 唯一题目编号
std::string title; // 题目的标题
std::string star; // 题目的难度等级 (简单 中等 困难)
std::string desc; // 题目的描述
std::string header; // 题目给用户预设的代码
std::string tail; // 题目的测试用例,需要与header拼接形成完整代码
int cpu_limit; // 题目的时间要求
int mem_limit; // 题目的空间要求
};
// 需要的一系列信息
const std::string oj_questions = "oj_questions";
const std::string host = "127.0.0.1";
const std::string user = "oj_client";
const std::string password = "";
const std::string db = "oj";
const int port = 3306;
class Model
{
public:
Model() {}
~Model() {}
bool QueryMysql(const std::string &sql, vector<Question> *out)
{
// 创建mysql句柄
MYSQL *my = mysql_init(nullptr);
// 连接数据库
if (mysql_real_connect(my, host.c_str(), user.c_str(), password.c_str(), db.c_str(), port, nullptr, 0) == nullptr)
{
LOG(FATAL) << "连接数据库失败!" << std::endl;
return false;
}
// 记得要设置链接编码格式!
mysql_set_character_set(my,"utf8");
LOG(INFO) << "连接数据库成功!" << std::endl;
// 执行sql语句
if (0 != mysql_query(my, sql.c_str()))
{
LOG(WARNING) << sql << " execute error!" << std::endl;
return false;
}
// 提取结果
MYSQL_RES *res = mysql_store_result(my);
// 分析执行结果
int rows = mysql_num_rows(res); // 获得行数量
int cols = mysql_num_fields(res); // 获得列数量
struct Question q;
for (int i = 0; i < rows; i++)
{
//拿出一行以空格为分隔的数据
MYSQL_ROW row = mysql_fetch_row(res);
q.number = row[0];
q.title = row[1];
q.star = row[2];
q.desc = row[3];
q.header = row[4];
q.tail = row[5];
q.cpu_limit = atoi(row[6]);
q.mem_limit = atoi(row[7]);
out->push_back(q);
}
// 释放结果空间
free(res);
// 关闭mysql连接
mysql_close(my);
return true;
}
bool GetAllQuestions(vector<Question> *out)
{
std::string sql = "select * from ";
sql += oj_questions;
return QueryMysql(sql, out);
}
bool GetOneQuestion(const std::string &number, Question *q)
{
bool res = false;
std::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];
res = true;
}
}
return res;
}
};
}
至此,本项目的整体流程也全部完成。(还有一些前端代码,请到源码中去查看)
演示
在本地打开三个中端,一个运行oj_server模块,两个运行compiler_server模块,并将他们运行。
在浏览器搜索: 101.43.189.110:8081,进入oj界面。
点击开始编程按钮进入题目列表,与此同时URL改变。
点击单个题目进入题目页面,URL改变。
直接点击提交,结果回显给用户。与此同时后端回显信息。
可以看到分配给了0号主机。
接下来输入错误代码导致无法通过编译并提交。
可以看到下方回显了错误信息,后台也输出了提示信息。
写一个死循环或者申请过大空间也会提示并报错。
此时后端提示编译运行成功,因为编译确实没问题。
接下来测试负载均衡。什么也不写狂点提交,后端显示为:
可以看到,后端负载均衡的选择了两个主机。
此时挂掉0号主机,再点击几次提交按钮:
可以发现后端发现0号主机挂掉,并只把编译服务交给1号主机。
接下来两台主机都挂掉再试试:
后端发现两台主机都挂掉,无可用主机,同时用户那边也没有任何反应。
接下来再将两台主机全部上线,再提交代码,还是无可用主机。这是因为两台主机全部在离线vector内,需要手动将他们再次上线。
键盘输入’crtl+',将全部主机上线。再次测试发现重启成功,整个项目测试完毕。
后记
这个项目对我的挑战实在不小,从后端逻辑的实现到前端代码的编写,耗费了很多精力,也发生了很多意料之外的错误。如引入库时报错、逻辑上一些小细节没能注意导致很简单的错误改了好几天…但是最终还是顺利完成。在日后的学习道路中,我也要不断完善自己。