项目要实现的一个整体的功能:
编写一个在线OJ网络服务器,只实现类似 leetcode 的题目列表+在线编程功能
项目宏观结构:
Oj服务器在收到提交的代码时,把代码负载均衡的选择发送给其他几个编译与运行服务器去编译运行代码,判断代码的编译运行结果
- comm : 公共模块
- compile_server : 编译与运行模块
- oj_server : 获取题目列表,查看题目编写题目界面,负载均衡,其他功能
所用技术与开发环境
所用的技术:
C++ STL 标准库
Boost 准标准库(字符串切割)
cpp-httplib 第三方开源网络库
ctemplate 第三方开源前端网页渲染库
jsoncpp 第三方开源序列化、反序列化库
负载均衡设计
多进程、多线程
MySQL C connect
Ace前端在线编辑器(了解)
html/css/js/jquery/ajax (了解)
所用环境
ubuntu 20.04 云服务器
vscode
MySQL Workbench
项目编写思路:
- 先编写 compile_server
- oj_server
- version1 基于文件版的在线OJ
- 前端的页面设计
- version2 基于 MySQL 版的在线OJ
编译运行服务器
先编写 compile_server
1、compile.hpp实现编译功能
通过类的封装实现,创建对象来调用里面的函数来实现编译功能
接收到一个代码文件,然后把他编译为可执行代码,如果出错把错误放到创建的重定向临时错误文件中。
编译成功返回true错误返回false
过程中还实现了:拼接文件路径和后缀、判断文件是否存在、获取时间戳、日志
公共模块
util.hpp(工具类):
1、文件路径功能,在编译、运行的函数中,参数只需要传入一个文件名,路径和后缀根据不同功能而拼接,就能找到打开相应的文件
cpp
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 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,".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");
}
};
2、文件工具: class FileUtil
①、判断文件是否存在 ------用于编译完成之后,判断有没有生成可执行文件,生成了就成功,没有就失败
系统调用stat获取文件的属性,获取成功返回0;否则失败
3、时间工具:
获取时间戳共日志使用,获取毫秒级时间戳供原子性形成文件名使用
日志功能
通过out<<流的特性
以上准备工作都做了,就可以编写编译功能compile.hpp
标准错误文件重定向,子进程程序替换实现编译,形成.exe可执行文件判断成功与否
实现运行功能模块
1、传入的值:file_name、cpu_limit、cpu_limit 返回值为Int
首先根据file_name 调用拼接函数,分别拼接出exe、in、out、err、可执行文件,标准输入,标准输出,标准错误的文件名,因为exe文件是编译的时候就创建的,所以我们不用再创建,其他的文件通过打开没有就创建的方式创建。然后重定向为,标准输入,标准输出,标准错误。之后再根据传入的
cpu_limit、cpu_limit设置资源限制,然后创建子进程替换,执行exe文件,父进程等待,执行结果成功会放入.out文件,失败会放入.err文件中。运行模块不关心结果,只关心退出时父进程接收到的信号,信号>0,运行处异常,信号==0运行成功,return -1服务器内部错误
图:
首先运行后的结果要重定向到标准输出文件,运行出错的结果要重定向到标准错误,
cpp
//运行编译成功的程序
/******************************* *
*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||_stdout_fd<0||_stderr_fd<0)
{
LOG(ERROR)<<"运行时打开标准文件失败"<<"\n";
//任何一个打开失败都结束
return -1;
}
设置资源限制------防止用户提交的代码死循环,或者是恶意代码,------ setrlimit资源限制来保护服务器
cpp
//提供设置进程占用资源大小的接口
static void SetProcLimit(int cpu_limit,int mem_limit)
{
//设置CPU限制时长
struct rlimit cpu_rlimit;
cpu_rlimit.rlim_cur=cpu_limit;
cpu_rlimit.rlim_max=RLIM_INFINITY;//硬约束无约束
setrlimit(RLIMIT_CPU,&cpu_rlimit);
//设置内存大小限制
struct rlimit mem_rlimit;
mem_rlimit.rlim_cur=mem_limit*1024;//*1024转化为KB
mem_rlimit.rlim_max=RLIM_INFINITY;
setrlimit(RLIMIT_AS,&mem_rlimit);
}
程序替换运行代码:运行完成结果保存在重定向的标准输入中,运行时出异常或者资源约束超出限制收到信号,
cpp
//程序替换
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;
总代码:
实现编译运行compile_run.hpp
服务器收到一个请求,请求的正文里面就是用户提交的代码(也有可能有参数input
),编译运行服务要把他序列化后拿到代码做编译运行,然后结果字段要反序列化后发送回去
包含进第三方开源库jsoncpp:用于请求和响应的序列化反序列化
反序列化后拿到代码,然后形成唯一文件名,把代码放入文件中
形成唯一文件名函数方法放在工具模块中:形成名字后,要拼接好路径和后缀,然后写入,写入的时候会自动创建文件
形成唯一文件名:毫秒级时间戳+原子性递增的唯一值:
把代码写入到文件.cpp中
编译时出错的信息在编译时重定向的文件中,运行完成后的结果在重定向的标准输出文件中,所以也要有读文件的操作
一次编译运行服务完后,清理形成的临时文件
系统调用unlink直接删除文件,但是要清理的文件是不确定的,有可能编译时就出现了错误,那么运行时产生的文件就不存在,所以在删除文件的时候要先判断文件是否存在------这个功能在编译模块中判断可执行文件存不存在的时候就写好了,
所以判断存不存在,存在直接调用unlink直接删除文件
引入httplib第三方库,把编译运行服务打包为网络服务
httplib的作用:可以为我们自动创建套接字网络服务,以及构建请求和响应报头,而且是支持多客户端访问的服务,能够自动创建线程去完成每一个请求
我们要做的就只是,把他请求中的正文(json串)拿出来,然后编译运行后得到的out_json传作为响应的正文,他就会自动侯建响应发送回去
利用postman工具测试编译运行服务器
基于MVC 结构的oj 服务设计
------本质上就是一个网站
- 获取首页,用题目列表充当
- 编辑区域页面
- 提交判题功能(编译并运行)
M: Model,通常是和数据交互的模块,比如,对题库进行增删改查(文件版,MySQL)
V: view, 通常是拿到数据之后,要进行构建网页,渲染网页内容,展示给用户的(浏览器)
C: control, 控制器,就是我们的核心业务逻辑
用户请求的服务路由功能oj_server.cc
既然是一个服务器功能,那么首先也是要引用httplib第三方库,很创建一个Server svr 服务器
首先如果客户端URL直接根据ip和port访问的话,返回一个首页网页
ojserver的主要三个功能
设计文件版题库:
model功能,提供对数据的操作:和数据进行交互,对外提供访问数据的接口
题库是设计在文件中的,所以需要这个模块帮我们把他加载到内存,
首先要把文件中的数据读上来,那么就要有一个结构体,里面包含一些字段,来保存这些信息
加载题目
如何让我们通过题号找到对应的题目呢,这里就要用一个map结构把编号,题目信息。关联起来
获取所有题目的接口:
循环遍历map,然后把map中的second插入到vector中,这样数组中就保存了所有的题目信息
根据题号获取单个题目信息的接口
因为是存放在map中的,左移题号就是key值,所以直接在map中根据题号查找,然后把对应的second,放到输出参数Questions中
此外字符串的切割,使用boost库来实现的
control 逻辑控制模块
功能1:获取题目信息,构建网页,返回给oj_server
那么数据如何渲染成html网页的呢,这里就用到吗ctemplate 第三方开源前端网页渲染库
一个小demo来介绍他是如何使用的:
当然html网页渲染功能肯定是不会直接写在控制模块中,而是写在oj_view图片渲染功能中的
oj_view 网页渲染模块
获取所有题目,形成列表html网页
首先一定是oj_server收到了获取所有题目的一个请求:
然后在控制模块中获取所有题目的信息,然后渲染,返回
view模块渲染所有题目------ctemplate 第三方开源前端网页渲染库
最终页面显示:
获取单个题目,并渲染成网页------但是这里还是一个简单的网页,还没有提交代码,整体的框架也有点丑
现在先这样之后把控制模块中的判题功能写完,在优化这个前端网页。
剩下的一个功能就是提交代码,编译运行了
但是我们是负载均衡的选择多台主机来编译的
所以在控制模块中,还应该有一个负载均衡选择的模块
既然要选择那么我们就要把所有主机管理起来(结构体),然后去选择负载最小的那个
主机的结构体:
负载均衡选择:首先要知道有哪些主机,那么我们就要去主机配置文件中按行读取,然后把内个主机都设为一个结构,在保存到数组中,
然后根据每个主机的负载,循环遍历每个主机找到负载最小的那个主机,把id(数组下标),和主机信息返回回去
用户提交代码,根据题号拿到相应的题目信息,把用户提交的代码和对应的测试用例拼接,反序列化后发送给编译运行服务,最终得到结果
还有一个小问题:因为我们的题目信息中预设代码和测试用例是分开写的,为了不报错我们写测试用例的时候是有一个包含预设代码头文件的操作的,但是拼接的时候预设代码是直接作为用户提交的代码被拼接上的,不是从headr.cpp中拿的,所以不用包含头文件。所以我们是用到了一个条件编译的。
至此所有的后端服务都已经编写完成,现在来编译编译界面的一个网页:ACE在线编译器 编译页面
总和测试