一、项目宏观结构
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-4
INFO, // 常规的,只是一些提示信息
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.cpp
static 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.stderr
static 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);
}
//重定向标准错误到_stderr
dup2(_stderr,2);
//程序替换,并不影响进程的文件描述符表
//子进程:执行调用编译器完成对代码的编译工作
//g++ -o target src -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++失败,可能是参数错误" << "\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; //转化成KB
mem_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;
}
}
};
}