目录
1.项目简介
实现一个类似leetcode的在线刷题网站,当然作为练习,我们只实现展示题目列表、选择特定题目后展示特定题目和提交代码返回结果给用户这三个功能。
简易首页
点击题库或开始编程跳转到题目列表页面
题目的设计和测试用例的设计就不是我们所需要做的,我们就是提供文件版的录题功能和数据库版的录题功能,任选其一,就可以录题扩充题库。
点击特定题目 ,到具体题目页面
用户在代码编辑框中写代码后提交,传到我们服务器处理后,返回结果显示给用户
返回的结果可以是执行成功,通过测试用例的情况、语法错误编译失败的信息、运行超时、使用内存过多、空指针错误等等。
2.所用技术和开发环境
技术: c++、stl标准库、boost准标准库、cpp-httplib第三方开源库、ctemplate第三方开源前端网页渲染库、jsoncpp序列化和反序列化库、负载均衡模块的设计、多进程、多线程、c语言连接mysql库、Ace前端在线编辑器、前端html、css、js的简单使用
开发环境:centos7云服务器、 vscode 、Navicat
3.总体结构讲解
整体分为三大模块,分别是用来实现一些公共方法的公共模块comn、实现对用户提交的代码进行编译和运行并返回结果的编译模块compile,最后是在线oj服务的模块oj_server。
所以我们在项目目录下创建三个目录comn、compile和oj_server
框架图如下
先实现编译模块,再实现oj_server模块,然后再是组建页面,前后端交互,公共模块的工具类我们在需要用到什么方法时就实现什么方法,应该在公共模块再实现一个开放式日志的类,方便在整个项目中打印各种日志,更直观地看到程序运行情况,和排除错误。
4.编译模块
编译模块我们会形成各种文件,对用户传上来的代码,我们要形成源文件,编译形成可执行文件,编译出错文件记录编译出错信息,运行程序输入数据保存在输入临时文件,运行成功标准输出文件存放标准输出,标准错误文件存放输出的标准错误。
- 源文件.cpp
- 可执行文件.exe
- 编译出错.compilerror
- 运行时输入.stdin
- 运行时输出.stdout
- 运行时错误.stderr
这些都是对一份代码进行处理需要用到的临时文件,以便编译模块构建结果返回给oj_server模块,oj_server模块在拿到编译模块的处理结果构建网页元素返回给用户。
在编译模块目录下我们也要再分模块完成编译服务的,编译模块compile.hpp,运行模块runcode.hpp这两个hpp文件分别定义了编译和运行的方法,编译并运行模块compile_run.hpp,这个模块就是统筹compile.hpp和runcode.hpp,在rompile_run.hpp中分别调用前面两个模块的方法进行编译运行处理数据的功能,还有compile_server.cc在其中接入网络功能接受oj_server传来的代码和输入,然后调用compile_run.hpp的方法执行下去,再构建返回结果发送给oj_server。整体的框架图如下
在compile目录下再创建一个temp目录用来存放以上形成的各种临时文件,最后会实现一个方法及时删除临时文件
在编译模块我们的处理策略就是收到一份代码我们就形成唯一的文件名,随后以该文件名,加上当前temp目录加上各种形式的后缀名就形成了一份代码在temp目录下的各种形式的文件,存放相应的数据,所以先来看在公共模块util.hpp中实现的文件名拼接类中的形成各种文件名的方法。
namespace lcy_util
{
using namespace std;
const string& temfile="./temp/";
class Utilpath //各种文件名拼接的静态方法
{
static string jointname(const string&filename,const string&suffix)
{
string file=temfile;
file+=filename;
file+=suffix;
return file;
}
//编译文件时形成的源文件名、可执行文件名、编译出错错误信息写入编译出错文件名
static string makesrc(const string&filename)
{
return jointname(filename,".cpp");
}
static string makeexe(const string&filename)
{
return jointname(filename,".exe");
}
static string compileerror(const string&filename)
{
return jointname(filename,".compilerror");
}
// 编译完成后,我们要运行代码,形成运行时输入数据保存在输入临时文件中,输出数据保存在输出临时文件中,运行出错信息保存在出错临时文件中
// stdinfile stdoutfile stderrfile
static string runstdin(const string&filename)
{
return jointname(filename,".stdin");
}
static string runstdout(const string&filename)
{
return jointname(filename,"stdout");
}
static string runstderr(const string&filename)
{
return jointname(filename,"stderr");
}
};
}
用这样的方式在工具类中实现拼接各种文件名的方式,形成在temp路径目录下的各种文件,方便我们后序读写文件。
5.compile.hpp的编写
#pragma once
#include<iostream>
#include<string>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<fcntl.h>
#include"../comn/log.hpp"
#include"../comn/util.hpp"
namespace lcy_compile
{
using namespace std;
using namespace lcy_log;
using namespace lcy_util;
class Compile
{
public:
Compile(){}
~Compile(){}
static bool compile(const string&filename)
{
pid_t pid=fork();
if(pid<0)
{
exit(1); //出错就终止程序
}
else if(pid==0) //让子进程 执行程序替换去进行编译服务
{ //打开保存编译错误信息的文件 使用相应的文件名拼接函数形成文件名,创建相应的文件。
int fd_comerror=open(Utilpath::compileerror(filename).c_str(),O_CREAT|O_WRONLY);
if(fd_comerror<0)
exit(2);
int dp=dup2(2,fd_comerror);// 把本该打印在标准错误中的信息重定向到我们指定的保存错误信息的文件中
if(dp<0)
{
exit(3);
}
execlp("g++","g++","-o",Utilpath::makeexe(filename).c_str(),Utilpath::makesrc(filename).c_str()\
,"-std=c++11",nullptr);
exit(4); //程序替换去执行编译文件去了,走到这一步就是替换函数执行失败了,就终止程序,正常情况也决定不会走到这里,前面出错再前面就返回了
}
else
{
waitpid(pid,nullptr,0); //父进程等待子进程结束,不关心子进程执行结果,我们就检查有没有形成可执行文件,有就编译成功否则编译失败
if(Filehandler::isexist(filename)) //isexist 在公共模块的工具类中实现
{
return true;
}
else
{
return false;
}
//这个文件就是创建子进程程序替换去编译存在temp目录下的源文件,形成可执行文件
}
}
};
}
6.log.hpp编写
#pragma once
#include<iostream>
#include"util.hpp"
namespace lcy_log
{
using namespace std;
using namespace lcy_util;
enum
{
INFO,
DUBUG,
WARNING,
ERROR,
FATAL
};
inline ostream& Log(const string&level ,const string&fullfile,int line)
{
string massage="<";
massage+=level; //日志等级
massage+=">";
massage+="<";
massage+=fullfile; //文件完整名称
massage+=">";
massage+="<";
massage+=to_string(line); //行号
massage+=">";
massage+="<";
massage+=Timeheadle::gettimestamp(); //带上时间戳 时间的相关函数也在工具类中实现
massage+=">";
cout<<massage; //把日志信息放在在标准输出流中等遇到换行符就输出
return cout; //返回标准输出流的引用,这样我们可以在后面追加信息并换行输出日志
}
#define LOG(level) Log(#level,__FILE__,__LINE__) //#level 日志等级以字符串形式传入
}
还用到了一个记录当前时间的方法,我们在util.hpp实现它
class Timeheadle
{
public:
static string gettimestamp() //返回当前时间戳 单位秒
{
struct timeval tv;
gettimeofday(&tv,nullptr); //系统提供的返回时间戳的函数
return to_string(tv.tv_sec);
}
};
判断文件是否存在的函数isexist
class Filehandler
{
public:
static bool isexist(const string&filename)
{
string exe=Utilpath::makeexe(filename);
struct stat status;
if(stat(exe.c_str(),&status)==0)
{
return true; //stat函数用来获取文件属性,执行成功说明文件存在 返回真
}
return false;
}
}
日志可以在compile.hpp 的函数执行出错处打印,条件判断处打印。如下面示例
if(Filehandler::isexist(filename)) //形成了可执行程序文件就说明编译成功了
{
LOG(INFO)<<"程序编译成功"<<"\n"; //打印日志信息
return true;
}
else
{
LOG(ERROR)<<"程序编译失败"<<"\n";
return false;
}
7.runcode.hpp的编写
runcode.hpp是提供运行可执行程序的方法,正常的oj网站对用户提交的代码运行都是有时间限制和空间限制等资源限制的,所以在文件中我们写一个Runner类,类中实现两个方法,一个是设置资源限制的方法,一个是跟compile.hpp中编译的方法类似,创建子进程程序替换去运行可执行程序,在运行前,我们要打开运行时的临时文件,包括程序运行需要的输入,标准输出,标准错误,分别对三个标准文件描述符重定向到我们打开的三个临时文件中,通过这样的方式获取运行代码的各种结果。运行的方法我们设置返回值问整数,如果返回值小于0,就是执行各种系统函数失败的情况,返回0就是程序运行成功,收到信号时程序运行终止,在父进程中返回时几号信号。
#include<iostream>
#include"../comn/log.hpp"
#include"../comn/util.hpp"
#include<sys/time.h>
#include<sys/resource.h>
#include<sys/types.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/stat.h>
#include<fcntl.h>
namespace lcy_run
{
using namespace lcy_log;
using namespace lcy_util;
class Runner
{
public:
Runner(){}
~Runner(){}
//实现两个方法 一个是设置对用户代码编译后的程序运行的时间限制和空间限制,另一个方法就是打开运行时可能用到的临时文件,创建子进程程序替换运行代码,把运行时的信息写入临时文件返回运行结果代号
static void procrunlimit(int limit_cpu,int limit_mem) //有上层调用该方法的地方传入资源限制的参数
{
struct rlimit rlcpu; //用到系统调用setrlimit 用到系统提供的结构体 rlimit 用来设置参数
rlcpu.rlim_max=RLIM_INFINITY; //可以设置的最大上限统一设置为没有上限
rlcpu.rlim_cur=limit_cpu; //设置cpu运行时间限制
setrlimit(RLIMIT_CPU,&rlcpu); //设置运行时间限制 参数一是一个宏确定限制的是cpu运行
struct rlimit rlmem;
rlmem.rlim_max=RLIM_INFINITY;
rlmem.rlim_cur=1024*limit_mem; //占用内存限制我们设置单位kb
setrlimit(RLIMIT_AS,&rlmem);
}
static int Runcode(const std::string&filename,int limit_cpu,int limit_mem)
{
std::string file_exe=Utilpath::makeexe(filename); //运行代码,我们要用到可执行文件,
std::string file_stdin=Utilpath::runstdin(filename); // 运行时输入文件
std::string file_stdout=Utilpath::runstdout(filename); //运行时输出文件
std::string file_stderr=Utilpath::runstderr(filename); //运行时标准错误文件
//打开三个运行时临时文件 //运行代码就可能会跟这三个文件有数据的交互
umask(0); //避免环境不同文件掩码不同,我们打开文件时统一设置权限
int in_fd=open(file_stdin.c_str(),O_CREAT|O_RDONLY,0644);
int out_fd=open(file_stdout.c_str(),O_CREAT|O_WRONLY,0644);
int err_fd=open(file_stderr.c_str(),O_CREAT|O_WRONLY,0644);
if(in_fd<0||out_fd<0||err_fd<0)
{
LOG(ERROR)<<"打开运行时临时文件失败"<<"\n"; //使用我们设计的开放式日志
return -1;
}
pid_t pid=fork();
if(pid<0)
{
close(in_fd); //系统错误返回时要就是关闭打开的fd
close(out_fd);
close(err_fd);
LOG(ERROR)<<"创建子进程失败"<<"\n";
return -2;
}
else if(pid==0) //子进程 将标准输入、输出、标准错误重定向到我们打开的对应的文件,
{
dup2(in_fd,0);
dup2(out_fd,1);
dup2(err_fd,2);
procrunlimit(limit_cpu,limit_mem); //再执行程序替换前先设置该进程运行的资源限制
execl(file_exe.c_str(),file_exe.c_str(),nullptr);
LOG(ERROR)<<"程序替换失败"<<"\n";
return -3; //就直接终止子进程
}
else
{
int status;
waitpid(pid,&status,0);
close(in_fd);
close(out_fd);
close(err_fd);
if(WIFEXITED(status))
{
LOG(INFO)<<"程序运行成功"<<"\n";
return (status>>8)&0xff;
}
LOG(WARNING)<<"程序运行出错,收到信号终止了"<<"\n";
return status&0x7f;
}
}
};
}
8.compile_run.hpp的编写
在这个文件中我们实现一个allround静态方法,样式
static void allround(const std::string&jsonstr,std::string*jsonres)
我们会在接下来实现的compile_server.cc文件中接入网络模块,接受oj_server 端发送过来的json串,kv键值对有需要编译运行的代码[“code”] 用户输入[“input”] 运行时间和空间限制[“timelimit”],[memorylimit]。在compile_server.cc 中调用allround传入oj_server返送来的json 在allround 中我们解析json串,拿到代码调用工具类中方法形成源文件,调用compile.hpp中的编译方法编译源文件,调用runcode.hpp中的方法运行程序,构建一个json串返回结果,结果json串的形式是执行状态码[“status”],对状态码的解释[“result”] ,如果运行成功 我们也把标准输出和标准错误临时文件中的内容返回,[“stdout”],[“stderr”] 这样在compile_server.cc中就拿到了对用户代码编译运行的结果json串,我们在返回这个json串给oj_server端,oj_server端再把结果显示在网页中让用户看到自己代码运行情况。
#include"compile.hpp"
#include"runcode.hpp"
#include"../common/log.hpp"
#include"../common/util.hpp"
#include<jsoncpp/json/json.h>
#include<signal.h>
namespace lcy_cprun
{
using namespace lcy_log;
using namespace lcy_util;
using namespace lcy_compile;
using namespace lcy_run;
class Compile_run
{
public:
static std::string createresult(int status,const std::string&filename) //根据全过程可能出现的错误,形成执行信息返回
{
std::string message="";
switch (status)
{
case 0:
message="代码运行成功";
break;
case -1:
message="用户提交的代码为空";
break;
case -2:
message="系统错误";
break;
case -3: //编译出错 我们要把出错信息返回 编译出错信息在compileerror 临时文件中
Filehandle::readfile(Utilpath::compileerror(filename),&message,true);
break;
case 11:
message="解引用空指针错误";
break;
case SIGABRT:
message="代码运行空间使用超过限制";
break;
case SIGXCPU:
message="代码运行时间超过限制";
break;
case SIGFPE: //8 除0错误
message="浮点数溢出";
break;
// case : break;
// case : break;
// case : break;
default:
break;
}
return message;
}
//这里整合编译和运行模块,接受网络传来的字符串,解析出用户提交的数据形成源文件,编译、运行返回结果
//接受序列化的字符串,返回序列化的字符串
/*********************************
*json里面的数据结构是k-v形式 我们需要
* 输入
* 用户 提交的代码 code:
* 用户的给自己的代码的输入 input: //可以选择是否处理
* 上层传入的测试时间限制 timelimit:
* 空间限制 memorylimit:
* 输出
* 执行状态码 status: {统一处理 code<0 是代码运行前的错误, code>0就是运行中出错返回的信号 code=0运行成功}
* 请求结果 result:
* 执行结果输出 stdout: 选填
* 执行错误输出 stderr: 选填
* *********************************/
static void allround(const std::string&jsonstr,std::string*jsonres)
{
Json::Value root;
Json::Reader reader; //安装约定的协议获取用户代码,用户给代码的输入 ,上层传来的资源限制参数
reader.parse(jsonstr,root);
std::string code=root["code"].asString();
std::string input=root["input"].asString();
int timelimit=root["timelimit"].asInt();
int memorylimit=root["memorylimit"].asInt();
Json::Value respond; //设置记录编译和运行全过程可能的出错信息
int status_code=0;
int run_code=0;
std::string filename;
if(code.empty()) //goto 和到跳转到的目标位置之间不能定义变量, 所有该范围类要用到的变量先提前定义出来
{ //用户提交的代码为空
status_code=-1 ; //编译前和编译的错误我们统统用负数来记录
goto DEAL; //我们把各种错误的处理都放在第一个地方,不用每个判断的地方都写一份,就根据status_code来判断就行了
}
//获取了代码数据 我们要形成保存代码的源文件 ,并且要形成唯一的文件名
filename=Filehandle::uniquename();
//写入到文件中
if(!Filehandle::writefile(Utilpath::makesrc(filename),code))
{
LOG(ERROR)<<"形成源文件失败"<<"\n";
status_code=-2; //保存代码源文件失败 系统错误
goto DEAL;
}
LOG(INFO)<<"形成源文件成功"<<"\n";
//编译源文件
if(!Compiler::compiler(filename))
{ //如果编译失败
status_code=-3;
goto DEAL;
}
//运行代码
run_code=Runner::Runcode(filename,timelimit,memorylimit);
LOG(INFO)<<"运行完毕,退出码:" <<run_code<<"\n";
if(run_code<0)
{
status_code=-2; //系统错误
goto DEAL;
}
else if(run_code>0)
{ //运行崩溃 我们就返回导致崩溃的信号
status_code=run_code;
goto DEAL;
}
else
{ //运行成功
status_code=0;
}
DEAL:
respond["status"]=status_code;
respond["result"]=createresult(status_code,filename); // 这里调用这个函数对状态码对应情况做说明
if(status_code==0)
{
std::string outdata;
Filehandle::readfile(Utilpath::Runstdout(filename),&outdata,true);
respond["stdout"]=outdata;
std::string errdata;
Filehandle::readfile(Utilpath::Runstderr(filename),&errdata,true);
respond["stderr"]=errdata;
}
Filehandle::removefile(filename); //到这里临时文件的任务就完成了,可以删除临时文件
Json::StyledWriter writer;
*jsonres=writer.write(respond);
}
};
}
在工具类中实现uniquename方法、writefile方法、readfile方法、removefile方法
static std::string uniquename() //形成唯一文件名 我们以毫秒级时间戳和原子性自增值来构成唯一的文件名
{
static std::atomic_uint id(0);
++id;
return to_string(id)+"##"+Timehandle::gettimems(); //形成唯一的文件名
}
static bool writefile(const std::string&src,const std::string&data) //写入文件
{
std::ofstream out(src.c_str());
if(!out.is_open())
{
return false;
}
out.write(data.c_str(),data.size());
out.close();
return true;
}
static bool readfile(const std::string&src,std::string*data, bool need)
{
(*data).clear();
std::ifstream in(src);
if(!in.is_open())
{
return false;
}
std::string line;
while(getline(in,line)) //getlin 按行读取不会保存行分割符,我们可以自己选择要不要分隔符
{
(*data)+=line;
(*data)+=(need?"\n":"");
}
in.close();
return true;
}
static void removefile(const std::string&filename) //一次用户提交上来的数据我们做出来返回后,形成的临时文件可以调用该方法即时删除
{
std::string src=Utilpath::makesrc(filename);
if(isexist(src))
unlink(src.c_str());
std::string comerror=Utilpath::compileerror(filename);
if(isexist(comerror))
unlink(comerror.c_str());
std::string exe=Utilpath::makeexe(filename);
if(isexist(exe))
unlink(exe.c_str());
std::string runin=Utilpath::Runstdin(filename);
if(isexist(runin))
unlink(runin.c_str());
std::string runout=Utilpath::Runstdout(filename);
if(isexist(runout))
unlink(runout.c_str());
std::string runerror=Utilpath::Runstderr(filename);
if(isexist(runerror))
unlink(runerror.c_str());
}
9.compile_server.cc模块
compile_server.cc就是我们的编译端main函数的实现了,在这里我们就引入网络功能,就是一个简单的接受oj_server端的json串,传给底下的compile_run.hpp去执行,调用我们之前实现的各种方法来处理数据,返回结果json串,发送回去给oj_server。
#include"compile_run.hpp"
#include<jsoncpp/json/json.h>
#include<httplib.h>
using namespace std;
using namespace lcy_cprun;
using namespace httplib;
void usage(char*proc)
{
cout<<"usage:"<<proc<<" port"<<endl;
}
int main(int argc,char* args[])
{
// if(lcy_compile::Compiler::compiler("test"))
// lcy_run::Runner::Runcode("test");
//这里引入网络模块, 把客户发起的请求做处理,包括代码,资源设置, 代码写到我们的临时文件中,编译运行临时文件,返回结果给客户 我们在这里引入compile_run合并的模块
if(argc!=2)
{
usage(args[0]);
return 1;
}
Server server;
server.Post("/compile_run",[](const Request&req,Response &resp){
string injson=req.body;
if(!injson.empty())
{
string outjson;
Compile_run::allround(injson,&outjson);
resp.set_content(outjson,"application/json;charset=utf-8");
}
});
server.listen("0.0.0.0",atoi(args[1]));
10.编译端的mekefile
compile_server:compile_server.cc
g++ -o $@ $^ -g -std=c++11 -ljsoncpp -lpthread
.PHONY:clean
clean:
rm -f compile_server
下一个主要大模块oj_server端我们在下篇讲解。
点击继续阅读项目下半部分