C++ -- 负载均衡式在线OJ (一)
一、项目宏观结构
1.项目功能
本项目的功能为一个在线的OJ,实现类似leetcode的题目列表、在线提交、编译、运行等功能。
2.项目结构
该项目一共三个模块:
- comm : 公共模块
- compile_server : 编译与运行模块
- oj_server : 获取题目列表,查看题目编写题目界面,负载均衡,其他功能
代码由客户端编写完成后,上传到服务端oj_server,由oj_server根据compile_server的负载情况选择相应的服务,来进行代码的编译与运行,结果再由oj_server返回给客户端,是基于BS模式(浏览器(客户端)-服务端)编写的。
二、comm公共模块
1.log.hpp
日志,我们想提供
- 日志等级
- 打印日志的文件名称
- 报错行
- 添加日志的时间
- 日志信息
- 开放性输出
注意: 开放性输出就是说我们可以在后面输出自己想输出的东西,比如LOG(DEBUG)<<“我想输出的东西”<<std::endl;
#pragma once#include <iostream>
#include <string>
#include "util.hpp"namespace ns_log
{using namespace ns_util;enum{// 日志等级 0-4INFO, // 常规的,只是一些提示信息DEBUG, // 调试日志WARNING, // 告警,不影响后续使用// 一般碰到ERROR或者FATAL这样的错误,就需要有人来运维了ERROR, // 错误,用户的请求不能继续了FATAL // 整个系统就用不了了};// LOG() << "message" 我们想进行日志打印的方式,是一个开放式的日志功能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 += "]";// cout 本质 内部是包含缓冲区的std::cout << message; // 不要std::endl进行刷新,因为换行就会刷新缓冲区return std::cout; // 返回一个流式缓冲区,上面的信息写到一个缓冲区当种}// LOG(INFO)<<"message"<<"\n"; # \n进行缓冲区的刷新#define LOG(level) Log(#level, __FILE__, __LINE__)
}
注意:
- 其中 __FILE__和__LINE__是C语言中的两个宏,获得文件名称和获得行数。
- #define LOG(level) log(#level,FILE,LINE);这个宏当中,#level的作用是,直接转化成字符串的形式,比如DEBUG对应的枚举是1,那么我们只传DEBUG的话,在预编译阶段就会替换成1,但是我们传入#level的话,他就会认为是字符 串"DEBUG";
2. util.hpp
先编写compile_server模块的compiler.hpp
编译模块的整体结构如下:
首先,我们想要提供编译服务,那么急需要去调用编译器。在Linux当中,我们知道对进程操作可以有进程创建、进程终止、进程等待、进程程序替换,那么我们就需要去进程程序替换成g++来对用户提交的代码进行编译
- 带l的我们可以认为是需要传入一串参数,比如说g++ -o test test.cc,需要以NULL/nullptr结尾
- 带v的我们可以认为是需要数组去进行传递,也就是把我们上面的一串参数,先放入数组再进行调用
- 带p的可以认为是环境变量,也就是说系统已经认识了该程序,无序我们传入相对/绝对地址,而不带p是需要我们传入的。
注意:我们今天选择的是execlp,最符合我们的调用,execlp的调用方式:execlp(“g++”,“g++”,“-o”,“test”,“test.cc”,nullptr); ;(第一个g++代表的是在环境变量当中去找)
进程程序替换
util.hpp 接路径工具类
在客户提交代码之后,要形成一些文件,比如源文件,编译之后形成可执行文件,编译错误的话要形成编译错误文件。
所以,这时候需要一些方法来对这些文件进行构建,我们把这些构建后缀的方法放到comm模块的Util类当中
namespace ns_util
{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;}// 编译时需要有的临时文件// 构建源文件+后缀的完整文件名// 1234 -> ./temp/1234.cppstatic std::string Src(const std::string &file_name){return AddSuffix(file_name, ".cpp");}// 构建可执行程序的完整路径+后缀名static std::string Exe(const std::string &file_name){return AddSuffix(file_name, ".exe");}static std::string CompilerError(const std::string &file_name){return AddSuffix(file_name, ".compiler_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");}};}
检测编译是否成功
我们编译是否成功只有一个标准,就是是否形成可执行文件
- 第一种方式:r读方式打开文件,如果失败了,说明不存在,这种方式太简单粗暴-
- 第二种方式:使用系统调用接口stat检测文件属性。
注意:stat的第二个参数是一个输出型参数,是一个系统提供的结构体类型。
namespace ns_util
{class FileUtil{public:static bool IsFileExists(const std::string &path_name){struct stat st;// stat成功,0被返回,失败-1返回if (stat(path_name.c_str(), &st) == 0){// 获取属性成功,文件已经存在return true;}return false;}}
编译出错
编译出错,g++会向标准错误流里面打印错误信息,所以我们就要形成一个文件,也就是编译错误文件xxx.compiler_error,让标准错误文件描述符进行重定向到该文件,如果编译出错,就可以在这个文件当中看见错误原因。
namespace ns_util
{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;}static std::string CompilerError(const std::string &file_name){return AddSuffix(file_name, ".compiler_error");}};
}
compiler编译模块核心逻辑实现
编译模块核心逻辑 compile_server模块的compiler.hpp
//只负责进行代码的编译
namespace ns_compiler
{//引用路径拼接功能using namespace ns_util;using namespace ns_log;class Compiler{public:Compiler(){}~Compiler(){}//返回值:编译成功true,编译失败false//输入参数:编译的文件名//1234.cpp -> ./temp/1234.cpp//1234 -> ./temp/1234.exe//1234 -> ./temp/1234.stderrstatic bool Compile(const std::string &file_name){pid_t pid = fork();if(pid < 0){LOG(ERROR) << "内部错误,创建子进程失败" << "\n";return false;}else if(pid == 0){ int _stderr = open(PathUtil::Stderr(file_name).c_str(),O_CREAT | O_WRONLY,0644);if(_stderr < 0){LOG(WARNING) << "没有成功形成stderr文件" << "\n";exit(1);}//重定向标准错误到_stderrdup2(_stderr,2);//程序替换,并不影响进程的文件描述符表//子进程:执行调用编译器完成对代码的编译工作//g++ -o target src -std=c++11execlp("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++失败,可能是参数错误" << "\n";exit(2);}else{waitpid(pid,nullptr,0);//编译是否成功?就看有没有形成对应的可执行程序if(FileUtil::IsFileExists(PathUtil::Exe(file_name))){LOG(INFO) << PathUtil::Exe(file_name) << "编译成功" << "\n";return true;}}LOG(ERROR) << "编译失败,没有形成可执行程序" << "\n";return false;}};
}
三、compile_server模块
1. 运行功能开发(runner模块)
编译完成之后,如果成功,则会生成可执行程序,我们现在是想办法把程序run起来。
程序运行1.代码跑完,结果正确2.代码跑完,结果不正确3.代码没跑完,异常了进程等待中的status就可以直到 前8位是退出吗 低8位是核心转储 后面7位是进程信号信号为0,则退出码有效,不为0,则退出码无效。核心转储需要自己开启,并且核心转储是存储核心错误信息但是运行模块,Run,我们是不需要考虑结果正确与否结果正确与否是由测试用例决定的。但是跑错了是要报错的。错误又分为编译错误和运行错误,运行错误才是在runner模块里该出现的
进程起来之后,默认会打开三个文件描述符,分别是0,1,2号文件描述符,分别对应stdin,stdout,stderr。我们为了方便我们运行的自测输入(我们这里暂时不支持),运行结果,运行错误结果等的查看与返回给用户。我们需要把这三个文件描述符进行重定向
//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); // 置权限掩码为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);//文件重定向(打开了才能重定向,打开了才有对应的fd)
dup2(_stdin_fd, 0);
dup2(_stdout_fd, 1);
dup2(_stderr_fd, 2);
资源限制(CPU占用,内存)
我们在leetcode做题的时候通常会发现出现 CPU占用时间超限,内存超限等,其实就是给执行这个运行服务的进程进行了资源的限制
对进程做资源限制,我们需要调用 setrlimit 的系统调用来完成:
注意:
- RLIMIT_AS最大给这个进程的虚拟地址(用字节来衡量)
- RLIMIT_CPU就代表CPU占用时间的限制
而我们看到还有一个对应的struct rlimit结构体,第一个是软件限制,第二个是硬件限制,硬件一般设成无穷的,不加约束 (无限,INFINITY)
#include "../comm/log.hpp"
#include "../comm/util.hpp"namespace ns_runner
{using namespace ns_log;using namespace ns_util;class Runner{public:Runner() {}~Runner() {}public://提供设置进程占用资源大小的接口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_cur = _mem_limit * 1024; //转化成KBmem_rlimit.rlim_max = RLIM_INFINITY;setrlimit(RLIMIT_AS,&mem_rlimit);}// 指明⽂件名即可,不需要代理路径,不需要带后缀/******************************************** 返回值 > 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需要考虑代码跑完,结果正确与否吗??不考虑!* 结果正确与否:是由我们的测试⽤例决定的!* 我们只考虑:是否正确运⾏完毕** 我们必须知道可执⾏程序是谁?* ⼀个程序在默认启动的时候* 标准输⼊: 不处理* 标准输出: 程序运⾏完成,输出结果是什么* 标准错误: 运⾏时错误信息* *******************************************/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 || _stderr_fd < 0 || _stdout_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) << "运行完毕, info :" << (status & 0x7F) << "\n";return status & 0x7F;}}};
}