项目实战---在线OJ

我的梦境 提交于 2020-04-04 22:33:55

在线OJ

项目功能:类似于LeetCode及牛客网的在线答题系统,浏览器请求服务器可以获得所有试题信息,包括题目编号、题目名称、题目难度,用户可以选择某一道题进行作答,服务器返回题目描述信息以及预定义好的代码模板,用户编写完代码后浏览器将用户提交的代码返回给服务器,服务器将用户提交的代码与预定义好的题目测试用例结合编译运行,并将结果返回给浏览器告知用户通过率。
上述的功能依赖于几个模块相互配合实现,如下图
项目实战---在线OJ
接下来就对这几个模块详细的介绍

1.试题模块

  • 在本地创建一个目录保存所有的试题,描述某一道试题时将试题编号、试题名称、试题所在路径、试题难度通过结构体组织起来,试题所在路径中保存着这道题目的描述(desc.txt)、这道题的预定义代码(header.cpp)以及这道题的测试代码(tail.cpp)
    class Exam    
    {                                                                                                                      
    public:    
    std::string _id;    
    std::string _name;    
    std::string _path;    
    std::string _star;//试题难度    
    }; 
  • 所有试题通过unordered_map保存,通过题目编号就可以在unordered_map中得到该试题的所有信息,这样也使查询效率最高。
  • 当浏览器请求所有试题时,试题模块遍历整个unordered_map拿到所有试题信息,然后通过渲染模块返回给浏览器,此时浏览器就可以进行题目选择并作答了。
    项目实战---在线OJ
  • 当用户选择某一道题时浏览器发出“请求单个题目”的请求,服务器收到此请求,在unordered_map中查找对应的题目编号,如果有这道题那么就将此题所在路径下的题目描述渲染给浏览器。项目实战---在线OJ
  • 用户编辑好代码并提交给服务器时试题模块中还有一个函数完成了将用户提交的代码与测试用例代码拼接在一起,给编译运行模块提供支持

    2.日志模块

    日志模块主要负责将执行过程中遇到的错误以及提示信息写入日志文件中

  • 通过枚举日志等级将日志信息描述出来
    const char * level[]={    
    "INFO",    
    "WARNING",    
    "ERROR",    
    "FATAL",    
    "DEBUG"    
    }; 
  • 日志模块提供获取当前时间的两种函数,一种是当前的年月日时分秒信息,另一种是当前的时间戳(为后续的编译模块统一同一时间同一用户提交的代码、编译代码、编译错误代码)
  • 约定日志书写格式为:[时间 日志等级 文件:行号] 具体的日志信息
    //提供一个获取当前时间的方法
    static void GetNowTime(std::string * nowtime){
    time_t tm;
    time(&tm);//获取到距离1970年时间的秒数
    struct tm * st = localtime(&tm);//将秒数转化为年月日时间
    //用st填充nowtime
    std::stringstream ss;
    ss<<st->tm_year+1900<<" "<<st->tm_mon+1<<" "<<st->tm_mday<<" "<<st->tm_hour<<":"<<st->tm_min<<":"<<st->tm_sec;
    *nowtime=ss.str();
    } 
    //获取时间戳                                                                                              
    static int64_t GetTimeStamp()
        {                     
            struct timeval tv;      
            gettimeofday(&tv, NULL);
            return tv.tv_sec;
        } 

    日志文件如下图,记录了每一条关键信息,方便后期问题的定位
    项目实战---在线OJ

  • 这个函数通过构造stringstream对象将用户传入的日至等级以及具体描述组织起来写入日志文件,此处要将其设置为inline,在函数调用出展开,否则对应的文件名称和日志行号永远都是日志模块的信息
    inline static void Log(LogLevel lev, const char* file, int line, const std::string& logmsg){    
    //将日志信息写入日志文件中    
    std::string level_info = level[lev];    
    std::string TimeStamp;    
    GetNowTime(&TimeStamp);    
    //[时间 日志等级 文件:行号] 具体的日志信息    
    //std::cout << "[" << TimeStamp << " " << level_info << " " << file << ":"    
        //<< line << "]" << logmsg << std::endl;    
    //构造一个字符串直接写到文件中    
    std::stringstream ss;    
    ss<<"["<<TimeStamp<<" "<<level_info<<" "<<file<<":"<<line<<"]";    
    ss<<logmsg;    
    ss<<std::endl;    
    int fd = open("./LogFile",O_RDWR|O_APPEND);    
    write(fd,ss.str().c_str(),ss.str().size());    
    close(fd);    
    }

    但是在提交日志的时候不需要每一次都将文件名和行号传入,故将其实现为一个宏,调用时只需要传入日志等级以及具体描述信息即可

    #define LOG(lev,msg) Log(lev,__FILE__,__LINE__,msg) 

    3.渲染模块

  • 在构造请求与响应时需要将数据渲染到浏览器,这里涉及到一点前端的知识,即构造html页面响应给浏览器,这里采用谷歌提供的模板技术
  • 以渲染所有试题信息页面为例
    //通过传入的vec使用谷歌模板技术将str填充好    
    static void DrowAllExam(std::string * str,std::vector<Exam> & vec){    
      //建立一个叫做allques的字典    
      ctemplate::TemplateDictionary dict("all_questions");    
      for(const auto &e:vec){    
        //构建一个子字典存放每一条题目信息    
        //ctemplate::TemplateDictionary* section_dict = dict.AddSectionDictionary("question");    
        ctemplate::TemplateDictionary * section_dict = dict.AddSectionDictionary("question");    
        section_dict->SetValue("id",e._id);    
        section_dict->SetValue("id",e._id);    
        section_dict->SetValue("name",e._name);    
        section_dict->SetValue("star",e._star);    
      }                                                                                                                
       //2.获取模板类指针,加载预定义的html页面到内存当中    
      ctemplate::Template* tl =  ctemplate::Template::GetTemplate("./template/all_questions.html", ctemplate::DO_NOT_STRIP);        
         //3.渲染 拿着模板类的指针,将数据字典当中的数据更新到html页面的内存中    
      tl->Expand(str, &dict);     
    }

这个过程就是通过函数传入的参数,填充模板类中的预定义字段,如section_dict->SetValue("id",e._id); 然后再获取模板类指针,加载预定义的html页面到内存当中 ,此时调用这个模板类提供的渲染方法就可以完成对数据的渲染。
--->获取单个题目并渲染到浏览器的html如下,还是以这一个为例作为说明:

<html>    
<head>    
  <title>在线OJ</title>    
</head>    
<body>    
    <div>{{id}}.{{name}} {{star}}</div>    
    <div>{{desc}}</div>    
<div>    
      <form action="/question/{{id}}" method="POST"><!--要提交到哪里-->    
        <textarea name="code" rows=40 cols=70>{{writ}}</textarea><!--代码框的大小和其中的内容-->    
        <br>    
        <input type="submit" formenctype="appliaction/json" value="提交"><!--设置提交按钮-->    
    </form>    

</div>    
</body>    
</html>  

{{}}内中对应的字段就是上文中提到的预定义字段,也就是将上文中提交的预定义字段中的数据填充到这个位置,构成html请求与响应的内容。

4.工具模块

工具模块中实现的一些方法主要是配合各个模块工作

  • 字符切割功能:在保存单个题目信息时将题目ID、题目名称、题目路径、题目难度保存在一行并用空格间隔,此时就需要将每一部分切割并保存到unordered_map中,采用的是boost库提供的切割函数,将要切割的字符串和切割标志以及切割完后的每一块内容保存的vector传入,就可以完成对字符串的切割
  • 工具模块还提供了一些文件相关的操作,比如读取文件,这里使用的技术是利用ifstream流打开文件并用getline方法每次读取文件中的一行数据放到string串中,还是以这个简单的函数为例

      static int ReadDataFromFile(const std::string& filename, std::string* content){
        std::ifstream file(filename.c_str());                                                              
        if(!file.is_open()){                                                                                           
          LOG(ERROR,"File Open Faild");
          return -1;
        }
        std::string line;
        while(std::getline(file, line))
        {
          *content += line + "\n";
        }
        file.close();
        return 0;
      }
    
  • 此时要注意当从浏览器中将用户提交的代码获取到时是经过URL编码后的字符串,也就是说一些特殊字符被转换为%十六进制的形式,在使用时要对其进行URL解码

    5.编译模块

  • 在用户提交代码到服务器时要经过编译模块对用户提交的代码编译运行,并将结果响应到浏览器
  • 在接受用户代码和将编译结果响应给浏览器时采用的都是Json串的格式,构造时将不同的字段构造不同的内容
  • 规定编译时有以下几种错误码,在构造Json串返回将对应的信息填入“erronno”字段
    //0 : 编译运行没有错误
    //1.编译错误                                                                                                          
    //2.运行错误
    //3.参数错误
    //4.内存错误
    enum ErrorNo
    {
    OK = 0,
    COMPILE_ERROR,
    RUN_ERROR,
    PRAM_ERROR,
    INTERNAL_ERROR
    };
  • 整个编译运行模块向外提供一个函数接口,只需要传入从浏览器提交回来的Json串,从Json串中将“code”字段中的数据转化为字符串并以当前时间戳+后缀的形式命名临时文件,统一放到预定义的临时目录下
  • 编译时首先要构造编译命令,构造完成就可以创建子进程,让子进程替换当前进程,子进程替换为g++去执行编译文件的过程,父进程等待子进程退出,在这个过程中如果编译错误这个错误信息对我们是极其重要的,也是要返回给浏览器的重要部分,所以在使用时要采用dup2函数将标准错误重定向到统一时间戳的标准错误文件中,即将编译错误信息写到响应Json串中的reason字段
  • 如果编译正常结束说明程序通过编译,继续向下运行即运行模块,也就是创建子进程,子进程程序替换为刚刚生成的可执行文件,这个过程中要将标准输出以及标准错误使用dup2函数重定向到统一时间戳的对应文件中,在构造响应Json串时依旧将其写入reason字段。利用alarm函数以及setrlimit函数限制程序执行的时间以及最大内存,在超过这些限制后会触发信号异常退出,所以父进程要对子进程退出码解析,如果最后一个字节低7位不为0则表示异常退出,反之说明程序正常结束
  • 在整个过程正常结束后将产生的临时文件清理掉(unlink函数)
    编译运行模块是整个项目的灵魂所在,参考代码如下

        class Compile{
        public:
    static void CompileAndRun(const Json::Value& req,Json::Value * resp){
      if(req["code"].empty()){
        (*resp)["errorno"] = PRAM_ERROR;
        (*resp)["reason"] = "Pram error";
        LOG(ERROR,"Request Code Is Empty");
      } 
      //将代码写到文件中去
      std::string code = req["code"].asString();//先将代码由Josn转化为字符串
      //文件名称进行约定 tmp_时间戳.cpp
      std::string tmp_filename = WriteTmpFile(code);
      if(tmp_filename == "")
      {
        (*resp)["errorno"] = INTERNAL_ERROR;
        (*resp)["reason"] = "Create file failed";
        LOG(ERROR, "Write Source failed");
        return;
      }
      //3.编译
      if(!compile(tmp_filename))
      {
        (*resp)["Errorno"] = COMPILE_ERROR;
        //从错误文件中读取,构造编译错误的响应     
        std::string reason;
       FileTools::ReadDataFromFile(ErrorPath(tmp_filename), &reason);                                                  
       (*resp)["reason"] = reason; 
       LOG(ERROR, "Compile Error\n");
       return;
      }
      //4.运行
                  int sig = run(tmp_filename);
      if(sig != 0)
      {
       (*resp)["errorno"] = RUN_ERROR;
       //reason字段保存运行失败所被哪个信号所杀
       (*resp)["reason"] = "Program exit by sig " + std::to_string(sig);
       LOG(ERROR, "Run Error\n");
       return;
      }
      //5.构造响应
      //正常编译运行后响应
      (*resp)["errorno"] = OK;
      (*resp)["reason"] = "Compile and run is okey!";
      //标准输出
      std::string stdout_reason;
      FileTools::ReadDataFromFile(StdoutPath(tmp_filename), &stdout_reason);
      (*resp)["stdout"] = stdout_reason;
      //标准错误
      std::string stderr_reason;
      FileTools::ReadDataFromFile(StderrPath(tmp_filename), &stderr_reason);
      (*resp)["stderr"] = stderr_reason;
      //程序正常的话就清理掉临时文件
      Clean(tmp_filename);
      return;
    }
    
        private:
    static std::string WriteTmpFile(const std::string& code)
    {
      //1.组织文件名称,组织文件的前缀名称,用来区分源码文件,可执行文件是同一组数据
      std::string nowtime;
      std::string tmp_filename = "/tmp_" +std::to_string(GetTimeStamp());
      //写文件
      int ret = FileTools::WriteDataToFile(SrcPath(tmp_filename), code); 
      if(ret < 0)
      {
        LOG(ERROR, "Write code to source failed");
        return "";
      }
      return tmp_filename;
    }
    
    static std::string SrcPath(const std::string& filename)
    {
      return "./tmp_files" + filename + ".cpp";
    }
    
    static std::string ErrorPath(const std::string& filename)
    {
      return "./tmp_files" + filename + ".err";
    }
    
    static std::string ExePath(const std::string& filename)
    {
      return "./tmp_files" + filename + ".executable";
    }
    static std::string StdoutPath(const std::string& filename)
    {
      return "./tmp_files" + filename + ".stdout";                                                                     
    }
    static std::string StderrPath(const std::string& filename)
    {
      return "./tmp_files" + filename + ".stderr";
    }
    
    static bool compile(const std::string & filename){
      //构造编译命令进行文件的编译
      //构造编译命令:g++ src -o des -std=c++11
      //程序替换时使用execvp函数,替换g++,第二个参数是char*类型的数组,所以要构造Commond
      const int commondcount = 20;
      char buf[commondcount][50] = {{0}};
      char * Commond[commondcount] = {0};
      for(int i = 0 ; i < commondcount ; ++i){
        Commond[i]=buf[i];
      }
      snprintf(Commond[0],49,"%s","g++");
      snprintf(Commond[1],49,"%s",SrcPath(filename).c_str());
      snprintf(Commond[2],49,"%s","-o");
      snprintf(Commond[3],49,"%s",ExePath(filename).c_str());
      snprintf(Commond[4],49,"%s","-std=c++11");
      //snprintf(Commond[5],49,"%s","-D");
      //snprintf(Commond[6],49,"%s","CompileOnline");
      Commond[5]=NULL;
      int pid = fork();
      if(pid < 0){
        LOG(ERROR,"Fork ERROR\n");
        return false;
      }else if(pid == 0){
        //子进程
        int fd = open(ErrorPath(filename).c_str(), O_CREAT | O_RDWR, 0664);
        if(fd < 0){
          LOG(ERROR,"Open File Faild\n");
          exit(1);
        }
        //程序替换
        dup2(fd, 2);
        execvp(Commond[0], Commond);//程序替换为g++编译
        exit(0);
      }else{
        //父进程
        waitpid(pid,NULL,0);
      }
      //3.验证是否生产可执行程序                                                                                       
      struct stat st;//stat结构体是描述文件属性的,包括inode节点等信息 
      int ret = stat(ExePath(filename).c_str(), &st);//这里通过返回值判断是否有这个文件
      if(ret < 0)
      {
        std::stringstream ss;
        ss<<"Compile ERROR! Exe filename is"<<ExePath(filename)<<std::endl;
        LOG(ERROR, ss.str());
        return false;
      }
      return true;
    }
    static int run(const std::string &filename){
      //创建子进程,父进程等待,子进程执行替换后的程序
      int pid = fork();
      if(pid < 0){
        LOG(ERROR,"Run Fork Faild\n");
        return -1;
      }else if(pid == 0){
        //子进程,要去执行filename所对应的文件
        //对子进程执行的时间以及内存作出限制
        alarm(1);//执行时间为1秒,超过执行时间会发出SIG_ALARM信号
        struct rlimit rl;
        rl.rlim_cur = 1024 * 20000;//软限制,以字节为单位
        rl.rlim_max = RLIM_INFINITY;//硬限制,相当于操作系统所能提供的最大资源
        setrlimit(RLIMIT_AS, &rl);
        //子进程将标准输出和标准错误重定向到文件中
        int stdout_fd = open(StdoutPath(filename).c_str(), O_CREAT | O_RDWR, 0664);                                    
        if(stdout_fd < 0)
        {
        std::stringstream ss;
        ss<<"Open stdout file failed"<<StdoutPath(filename)<<std::endl;
        LOG(ERROR,ss.str());
          return -1;
        }
        dup2(stdout_fd, 1);
        //  标准错误--》重定向到文件
        int stderr_fd = open(StderrPath(filename).c_str(), O_CREAT | O_RDWR, 0664);
        if(stderr_fd < 0)
        {
        std::stringstream ss;
        ss<<"Open stderr file failed"<<StderrPath(filename)<<std::endl;
        LOG(ERROR,ss.str());
          return -1;
        }
        dup2(stdout_fd, 2);
        //替换子进程去执行filename所对应文件
        execl(ExePath(filename).c_str(), ExePath(filename).c_str(), NULL);
        exit(1);
      }
      //父进程,等待子进程
      int sta = 0;
      waitpid(pid,&sta,0);
      //退出状态码是正常退出或被信号所杀,将退出状态码返回
      return sta & 0x7f;
    }
    static void Clean(std::string filename)
        {
            unlink(SrcPath(filename).c_str());
            unlink(ExePath(filename).c_str());                                                                         
            unlink(ErrorPath(filename).c_str());
            unlink(StdoutPath(filename).c_str());
            unlink(StderrPath(filename).c_str());
        }
    };

    6.服务器

    服务器使用开源库httplib.h构造请求与响应方法

    using namespace httplib;    
    Server server;     
    Oj_Model oj_model;    
    //要请求的内容是当前目录下的all_ques,然后组织一个响应,把所有试题返回去    
        server.Get("/all_questions", [&oj_model](const Request& req, Response& resp) {    
        //(void)req;     
        std::vector<Exam> vec;    
        oj_model.GetAllExam(&vec);    
        //通过模板技术将vec发送给浏览器    
        std::string html;    
        Oj_View::DrowAllExam(&html,vec);    
        //LOG(INFO, html);     
        resp.set_content(html, "text/html; charset=UTF-8");    
        });    
  • 使用Server类创建服务器对象,就可以调用其类中提供的Get以及Post方法接收请求与构造响应,使用开源库的好处在于其内部帮我们解决了高并发问题,所以在程序设计时就不需要再考虑高并发的问题
  • 需要注意的是这个函数的第一个参数就是请求的资源路径,在浏览器请求时要与其保持一致
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!