负载均衡式OJ系统结项总结

大家一定都用过牛客、力扣等在线oj系统来刷题,这个项目是对这些在线oj平台的简单模仿。
项目源码:https://gitee.com/hehuoren8685/online_-judge

项目所用技术及环境

技术:C++ STL 标准库、Boost 准标准库、cpp-httplib 第三方开源网络库、jsoncpp 第三方开源序列化、反序列化库、负载均衡设计、多进程、多线程、MySql
环境:Centos 7 云服务器、VsCode、MySql Workbench

模块介绍

项目分为三个模块,分别为:
Comm模块:公用模块,其内包含一些复用性强的代码,供其他模块使用,包括文件操作、字符串处理、网络请求等功能。
Compiler_server模块:编译并运行模块,当用户提交代码到服务器上时,要在服务器上形成临时文件,编译并运行,得到运行结果。
OnlineJudge模块:能调用编译运行模块并访问文件或数据库,把题目列表、编辑界面返回给用户,并可以负载均衡的选择服务器

在这里插入图片描述
宏观结构

总体流程

Compiler_Server模块

该模块的主要工作是:编译并运行代码,生成格式化的结果。下面对这个模块进行编写。
该模块的结构如下:
在这里插入图片描述
temp目录用于存放临时文件,compiler_run.hpp用于给外界提供编译、运行功能;compiler.hpp和runner.hpp分别单独完成编译、运行的具体功能,compiler_server.cc用于使用该方法,进行网络请求。

compiler.hpp的编写

编译只有两种结果:编译通过或者出错。编译通过什么信息都没有,但如果编译出错,那么就会向stderr打印错误信息。但如果用户的代码出现错误,在我们本地的编译服务器出现错误信息是没有意义的,因为我们要把信息返回给用户。所以,编译错误的报错信息是要形成临时文件保存结果的。那么我们如何做到这点呢?我使用了重定向dup2,把本该打印到标准错误的信息重定向到文件。

编译我们要利用g++,所以要进行进程替换。但是又不能把主进程替换,所以这里的思路是fork(),父进程继续执行,而子进程要完成编译的工作,因为它被替换掉也不会影响子进程。这里我们选用execlp函数来进行程序替换。

对于用户传上来的代码,我们要根据其名称生成后缀为.cpp的源文件,还要生成.exe可执行文件,对于编译错误还要生成.compiler_error文件,这个文件量是非常大的。我们设置编译函数的参数为不带后缀的文件名,最后会根据这个文件名生成一系列的文件,放在同目录的temp文件夹下。这一步涉及了对不带后缀的文件名进行拼接生成目标文件名的操作,而且在后面编写运行模块时也会用的到,所以我们将文件名拼接的函数放在comm目录的util.hpp内,公开使用。

由于编译、运行时都要生成临时文件,所以为了避免代码冗余,先写一个通用后缀的函数,在生成不同后缀的临时文件时,在分别调用通用函数。以下是拼接函数的实现。

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;
        }

        //编译时需要的临时文件
        //构建源文件路径+后缀的完整文件名函数
        //abcd -> ./temp/abcd.cpp
        static std::string Src(const std::string& file_name)
        {
            return AddSuffix(file_name,".cpp");
        }
        //构建可执行程序的完整路径+后缀名
        //abcd -> ./temp/abcd.exe
        static std::string Exe(const std::string& file_name)
        {
            return AddSuffix(file_name,".exe");
        }
        //构建该程序对应的编译错误完整的路径+后缀名
        //abcd -> ./temp/abcd.compile_error
        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");
        }
    };

目光回到compiler.hpp内。对于父进程,需要等待子进程。那么如何判断是否编译成功呢?我们知道,编译成功后是会生成.exe文件的,那么父进程就可以检测.exe文件是否形成,从而判断该段代码是否编译成功。对于这个检测文件是否生成的函数,复用性也一定很高,所以我们将其也写入util.hpp下。

class FileUtil
    {
    public:
        static bool IsFileExists(const std::string& path_name)
        {
            //系统接口stat
            //检测特定路径下对应的文件,获取他的属性存于第二个参数中。获取成功返回0,失败返回-1.
            struct stat st;
            if(stat(path_name.c_str(),&st) == 0)
            {
                //获取成功,文件存在
                return true;
            }
            return false;
        }
    };

对于子进程,在进行程序替换前,我们要进行重定向的操作,以便让编译错误的信息可以存储在临时文件之中。这里用open函数来打开新的临时文件,如果临时文件不存在则创建。重定向时利用dup2函数。

以下是整个编译模块的代码(函数部分):

namespace ns_compiler
{
    // 引入路径拼接、日志功能
    using namespace ns_util;
    using namespace ns_log;
    class Compiler
    {
    public:
        Compiler()
        {}
        ~Compiler()
        {}

        //编译函数,返回值代表编译成功与否,参数为要编译的文件名(无后缀)
        static bool Compile(const std::string& file_name)
        {
            pid_t pid = fork();
            if(pid < 0)
            {
                //std::cerr << "子进程创建失败!" << std::endl;
                LOG(ERROR) << "内部错误:创建子进程失败!" <<std::endl;
                return false;
            }
            else if(pid == 0)
            {   
                //child:调用编译器完成对代码的编译工作

                //创建编译错误文件
                umask(0);//权限掩码清零,方便接下来设置文件权限
                int _stderr = open(PathUtil::CompilerError(file_name).c_str(),O_CREAT | O_WRONLY,0644); 
                if(_stderr < 0)
                {
                    //std::cerr << "创建错误文件失败!" << std::endl;
                    LOG(WARNING) << "未能成功形成CompilerError文件!" << std::endl;
                    exit(1);
                }
                //g++编译错误时会把错误信息打印到cerr,重定向标准错误到_stderr,以便编译错误时直接打印至文件中而非cerr
                dup2(_stderr,2);//本该打印到2号文件描述符的东西重定向至_stderr

                //程序替换不会影响进程的文件描述符表!!
                //在temp目录下构建出三个文件:.cpp文件、.exe文件、.stderr错误文件
                //注意此处需要手动定义COMPILE_ONLINE宏,因为题目的header与tail已经拼接完成,这里如果不定义会报错(文件找不到)
                //g++ -o target src -D COMPILER_ONLINE -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++失败,可能是参数错误!" << std::endl;
                exit(2);
            }
            else
            {
                //father

                //等待子进程,不关心其退出情况
                waitpid(pid,nullptr,0);
                //走到此处,子进程编译完成,要处理结果
                //可执行程序是否生成则代表编译是否通过
                if(FileUtil::IsFileExists(PathUtil::Exe(file_name)))
                {
                    LOG(INFO) << PathUtil::Src(file_name) << " 编译成功"  << std::endl;
                    return true;
                }
                LOG(ERROR) << "编译失败,未形成可执行程序!" << std::endl;
                return false;
            }
        }
    };
}

上面的代码涉及了"LOG(WARNING) << “未能成功形成CompilerError文件!” << std::endl;"的代码,这是打印日志的信息。我们设计日志以宏的形式来调用,宏又来调用日志函数。日志函数返回一个ostream,且其内打印的文件、行号末尾不打印endl,保留缓冲区。当用户使用日志功能时,自行打印输出信息并在末尾添加endl,这样文件名和行号就会先于错误信息打印,最后endl刷新缓冲区。日志头文件放在comm文件夹的log.hpp内。

namespace ns_log
{
    using namespace ns_util;
     //日志等级
    enum 
    {
        INFO,   //正常
        DEBUG,  //调式
        WARNING,//警告
        ERROR,  //错误
        FATAL   //致命
    };
    // 用法:LOG(等级) << "message"(开放式日志) << endl(刷新缓冲区)
    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 += "]";

        std::cout << message;//cout是有缓冲区的!这里未进行endl刷新缓冲区,message会暂存在cout里,此时cout返回时内含message。
        return std::cout;
    }
    #define LOG(level) Log(#level,__FILE__,__LINE__)//宏参带#可以将宏参名称直接以字符串的方式进行替换
}

上面的日志信息包含时间戳,获取时间戳也是一个函数,存储在util.hpp内。

class TimeUtil
    {
    public: 
        //获得秒时间戳
        static std::string GetTimeStamp()
        {
            //这个结构体内两个成员,一个是秒,一个是微秒
            struct timeval _time;
            gettimeofday(&_time,nullptr);
            return std::to_string(_time.tv_sec);
        }

        //获得毫秒时间戳
        static std::string GetTimeMs()
        {
            struct timeval _time;
            gettimeofday(&_time,nullptr);
            //毫秒级=秒*1000+微秒/1000
            return std::to_string(_time.tv_sec*1000+_time.tv_usec/1000);
        }
    };

runner.hpp的编写

运行模块要考虑的事情相对较多。对于运行模块,它根本不关心程序运行结果正确与否,因为那是交给测试用例去决定的,它只需要把程序运行完,得到结果供上层参考。它需要关心的是程序运行有没有出错,如果出错了原因是什么。所以运行模块只需考虑一件事:程序是否正常运行完毕!

运行不同于编译,有多种可能:可能运行成功,也可能运行错误,还可能是内部原因导致运行失败。这样一来就不能返回简单的布尔值。我们以int作为返回值,规定:返回小于0的数代表内部错误,返回0代表运行成功;返回大于0的数代表运行异常,具体的数代表程序收到信号的编号。

运行函数的参数和编译模块同理,只需要去掉后缀的运行文件名即可。运行也需要进行多进程、程序替换,子进程来程序替换运行目标程序,父进程等待子进程。

和编译模块相同,我们还需要关心三种生成文件:标准输入、标准输出和标准错误。本项目中,我们不关心标准输入,即不关心用户自测的情况,只关心后二者。在子进程中用dup2进行重定向。在程序替换时,由于要被执行的文件是compiler模块生成在temp目录下的临时文件,所以不能用execlp函数,因为这个临时文件的路径并不在环境变量内。这里用execl函数更方便些。

不同于compiler.hpp内的.compiler_err文件在子进程内打开,runner模块的三个临时文件要在fork前的父进程内打开。因为父进程打开的文件描述符可以被子进程继承下去,且打开的文件很多,如果打开失败在父进程内也方便直接退出程序。注意,打开文件失败是内部的错误,所以根据规定要返回负数。

父进程打开文件仅仅为了继承给子进程,所以父进程在等待子进程退出前要先关闭打开的文件描述符。同时上面提到过父进程不关心程序运行结果,只关心程序是否正常运行结束,那么wait子进程时就需关心退出结果,而无需关心子进程的退出码。那么父进程该如何知道程序运行是否异常呢?

在这里插入图片描述
我们知道,程序如果运行异常,一定是因为收到了信号。而当程序被信号杀死时,他给父进程返回的status是如图所示的结构。所以根据规定,我们只需将父进程收到的退出结果按位与0x7F就可以得到异常所收到的信号。当然如果没收到异常,这个值就是0,所以我们直接把这个值当做返回值即可。

做到这一步时,运行模块就结束了吗?答案是并没有。可能又人会发送恶意代码,死循环、无限申请空间等,所以我们还需要对时间空间进行限制。

这里介绍一个接口setrlimit。这个接口可以设置一个进程时间或空间的上限,通过一个结构体传入时间或空间的限制。结构体成员一个是软限制,一个是硬限制。硬限制是软限制的最高取值,所以我们设置为无穷,软限制就是这个进程可用时间或空间资源的最大值。注意:当程序使用资源过度导致终止时,也是被信号所杀死的,所以可以和前面说到的逻辑相同,返回status即可告知上层。

以下是封装资源控制的函数代码

//该函数可设置进程占用资源大小
        //_mem_limit:以KB为单位
        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_max = RLIM_INFINITY;
            mem_rlimit.rlim_cur = _mem_limit * 1024;//因为是以字节为单位,所以将KB转化为B
            setrlimit(RLIMIT_AS,&mem_rlimit);
        }

以下是运行函数的代码。值得一提的是此时run新增了两个参数,分别是进程运行时可以使用的最大CPU上限(以秒为单位)和最大内存限制(以kb为单位),由调用该函数的上层来决定进程的资源限制。

		// 指明文件名即可,理同compiler
        /*
        返回值:
        >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模块无需考虑代码跑完结果正确与否!结果是测试用例要关注的!
            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) << "运行时打开标准文件失败!" << std::endl;
                return -1;//代表打开运行时所需文件失败
            }

            pid_t pid = fork();
            if(pid < 0)
            {
                LOG(ERROR) << "运行时创建子进程失败!" << std::endl;
                close(_stdin_fd);
                close(_stdout_fd);   
                close(_stderr_fd);
                return -2;//代表创建子进程失败
            }
            else if(pid == 0)
            {
                //child继承father打开的文件描述符表
                //重定向stdin、stdout和stderr
                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
            {
                //father
                //父进程并不关心打开的文件描述符表,直接先全关闭
                close(_stdin_fd);
                close(_stdout_fd);   
                close(_stderr_fd);

                //等待子进程,不关心退出码,但关心退出结果是否异常
                int status = 0;
                waitpid(pid,&status,0);

                //程序若运行因为异常而终止,一定是因为收到了信号!
                LOG(INFO) << "运行完毕。info: " << (status & 0x7F) << std::endl;
                return status & 0x7F;//判断子进程运行完毕是否异常,若无异常返回0,有异常返回>0的数


            }
        }

conpiler_server.hpp的编写

我们的程序并不会直接调用compiler或run模块,所以这个头文件会将这两个模块统一起来,供上层调用。

编译服务所接受的代码一定是从网络上上传的,所以我们这里用一个第三方库jsoncpp来处理网络请求。定义一个Start函数,输入是一个json串,输出用输出型参数返回一个json串。规定输入的in_json内包含kv模式的代码、输入(不处理)、内存和CPU限制,输出out_json则返回给上层状态码、出错原因(如果)、标准输出和错误。

我们在处理用户提交的代码时,要先根据提交的代码生成.cpp文件,这样才可以交给编译模块编译。但此时有可能同时处理多个用户的请求,当这些请求同时到我们的服务器上时,我们就必须界定唯一的文件名给每个请求。所以我们设计一个函数,用来生成唯一的文件名(无目录无后缀)。逻辑为:毫秒级时间戳+原子性地增值(防止极端情况下同时登陆)来形成文件名。生成完文件名后,我们当然要把用户代码写入文件内,而此时又需要一个写入文件的函数,写入到同路径的temp目录下。这两个函数放在/comm的util.hpp内。

static std::string UniqFileName()
        {
            //毫秒级时间戳+原子性递增唯一值
            //使用C++11提供的原子性递增的计数器
            static std::atomic_uint id(0);
            id++;//每次++都是原子性的!
            std::string ms = TimeUtil::GetTimeMs();
            std::string uniq_id = std::to_string(id);
            return ms + "_" + uniq_id;
        }

        static bool WriteFile(const std::string& target,const std::string& content)
        {
            std::ofstream out(target);
            if(!out.is_open())
            {
                return false;
            }
            out.write(content.c_str(),content.size());
            out.close();
            return true;
        }

        static bool ReadFile(const std::string& target,std::string* content,bool keep = false)
        {
            (*content).clear();

            std::ifstream in(target);
            if(!in.is_open())
            {
                return false;
            }
            std::string line;
            //geiline不保存行分隔符!有时需要保留\n,有时不需要,所以需要根据第三个参数来判断(默认不保留)
            //getline内部重载了强制类型转换,作为while循环判断条件时会根据获取内容成功与否有true/false保证
            while(std::getline(in,line))
            {
                (*content) += line;
                (*content) += (keep ? "\n" : "");
            }
            in.close();
            return true;
        }

同样给出util.hpp内关于时间的函数

class TimeUtil
    {
    public: 
        //获得秒时间戳
        static std::string GetTimeStamp()
        {
            //这个结构体内两个成员,一个是秒,一个是微秒
            struct timeval _time;
            gettimeofday(&_time,nullptr);
            return std::to_string(_time.tv_sec);
        }

        //获得毫秒时间戳
        static std::string GetTimeMs()
        {
            struct timeval _time;
            gettimeofday(&_time,nullptr);
            //毫秒级=秒*1000+微秒/1000
            return std::to_string(_time.tv_sec*1000+_time.tv_usec/1000);
        }
    };

具体逻辑为:先通过in_json把用户提交的内容提取出来,然后构建一个outjson并填入状态码、错误信息等。由于用户输入数据、写入文件、编译和运行时都有可能出现错误,所以我们把差错处理统一,在编译运行的过程中,如果有一环出现错误,就直接跳入到函数末尾的差错处理部分。我们再额外定义一个函数,作用是根据错误码来形成原因,最后构建outjson并返回。错误码取值规定为0(正常)、-1(用户提交代码为空)、-2(内部错误,但给用户返回时reason为"未知错误")、-3(编译错误,此时reason为生成的compiler_error内的内容)和大于0的情况(进程被信号所杀死,reason为信号对应的描述)。此处还涉及读取文件的函数,一并放在上面的代码内。注意:当且仅当错误码为0时,我们才可以读取stdout和stderr内的内容,并将其加入outjson一并返回。还有,Start函数内生成的错误码,与run函数内部生成的错误码是没有任何关系的。

我们发现,一个程序就会形成2-6份临时文件,如果不清理,那么会造成很大的空间浪费。所以我们在这里再添加一个删除临时文件的函数。我们在删除文件时,虽然不知道一共需要清理文件的个数,但是我们有文件名,也就可以知道这个文件名对应的所有临时文件,最后再依次判断文件是否存在,如果存在就删除即可。删除用系统接口unlink。

static void RemoveTempFile(const std::string& file_name)
        {
            //清理文件的个数并不确定,但是有哪些,是知道的!
            std::string _src = PathUtil::Src(file_name);
            if(FileUtil::IsFileExists(_src))
                //系统接口unlink直接删除文件,相当于rm -f
                unlink(_src.c_str());

            std::string _compiler_error = PathUtil::CompilerError(file_name);
            if(FileUtil::IsFileExists(_compiler_error))
                unlink(_compiler_error.c_str());

            std::string _execute = PathUtil::Exe(file_name);
            if(FileUtil::IsFileExists(_execute))
                unlink(_execute.c_str());

            std::string _stdin = PathUtil::Stdin(file_name);
            if(FileUtil::IsFileExists(_stdin))
                unlink(_stdin.c_str());

            std::string _stdout = PathUtil::Stdout(file_name);
            if(FileUtil::IsFileExists(_stdout))
                unlink(_stdout.c_str());

            std::string _stderr = PathUtil::Stderr(file_name);
            if(FileUtil::IsFileExists(_stderr))
                unlink(_stderr.c_str());
        }

以下是编译并运行的代码:

class CompileAndRun 
    {
    public:
        /*
        code: >0? <0? =0?
        code > 0:进程收到信号异常崩溃,code为信号编号
        code < 0:整个过程非运行报错:代码为空/编译报错
        code == 0:整个过程全部成功,结果已经存入文件
        注:该模块不在意运行结果是否正确!运行结果正确交给测试用例去处理
        */
        static std::string CodeToDesc(int code,const std::string& file_name)
        {
            std::string desc;
            switch(code)
            {
                case 0:
                    desc = "编译、运行成功";
                    break;
                case -1:
                    desc = "用户提交的代码是空";
                    break;
                case -2:
                    desc = "未知错误";
                    break;
                case -3:
                    //编译失败应该打印具体编译失败内容
                    //desc = "编译发生错误";
                    FileUtil::ReadFile(PathUtil::CompilerError(file_name),&desc,true);
                    break;
                case SIGABRT:
                    desc = "内存超过范围";
                    break;
                case SIGXCPU:
                    desc = "CPU使用超时";
                    break;
                case SIGFPE:
                    desc = "浮点数溢出"; 
                    break;
                default:
                    desc = "debug:unknown: " + std::to_string(code);
                    break;
            }
            return desc;
        }


        /*
        输入:
        code:用户提交的代码
        input:用户对其代码的输入
        cpu_limit:时间要求
        mem_limit:空间要求

        输出:

        必填:
        status:状态码
        0:成功 非0:失败
        reason:请求结果,对应状态码

        选填:
        stdout:程序运行完的结果
        stderr:程序运行完的错误结果

        in_json:{"code":"..."  "input":"..." "cpu_limit":1 "mem_limit":01024}
        
        out_json:{"status(状态码)":"0/1/2...","reason(原因)":"...",
                  "stdout":"...","stderr":"..."}

        */
        static void Start(const std::string& in_json,std::string* out_json)
        {
            Json::Value in_value;
            Json::Reader reader;
            //parse:第一个参数是要解析谁(哪个流),第二个是要反序列化到哪里
            reader.parse(in_json,in_value);
            
            std::string code = in_value["code"].asString();
            std::string input = in_value["input"].asString();
            int cpu_limit = in_value["cpu_limit"].asInt();
            int mem_limit = in_value["mem_limit"].asInt();

            int status_code = 0;
            Json::Value out_value;
            int run_result = 0;
            std::string file_name;//内部形成的唯一文件名
            
            if(code.size() == 0)
            {
                status_code = -1;//代码为空
                goto END;
            }
            //获得一个具有唯一性的文件名,无目录和后缀!(毫秒级时间戳+原子性递增的唯一值来保证唯一性)
            file_name = FileUtil::UniqFileName();

            //形成临时src源文件 
            if(!FileUtil::WriteFile(PathUtil::Src(file_name),code))
            {
                status_code = -2;//未知错误
                goto END;
            }
            //编译和运行
            if(!Compiler::Compile(file_name))
            {
                status_code = -3;//编译失败
                goto END;
            }
            
            run_result = Runner::Run(file_name,cpu_limit,mem_limit);
            if(run_result < 0)
            {
                status_code = -2;//未知错误
            }
            else if(run_result > 0)
            {
                status_code = run_result;//程序运行崩溃
            }
            else
            {
                status_code = 0;//运行成功
            }
        END:
            //status_code: >0:运行时错误/<0:编译或内部错误/=0:正常运行
            out_value["status"] = status_code;
            out_value["reason"] = CodeToDesc(status_code,file_name);
            if(status_code == 0)
            {
                //整个过程全部成功,可以读取stdout与stderr
                //注:运行时出错和运行时打印出错误信息是两回事!!
                std::string _stdout;
                FileUtil::ReadFile(PathUtil::Stdout(file_name),&_stdout,true);
                out_value["stdout"] = _stdout;


                std::string _stderr;
                FileUtil::ReadFile(PathUtil::Stderr(file_name),&_stderr,true);
                out_value["stderr"] = _stderr;
            }

            Json::StyledWriter writer;
            *out_json = writer.write(out_value);

            //删除临时文件
            RemoveTempFile(file_name);
        }
    };

compiler_server.cc的编写

compiler_run.cc的任务是将编译运行模块与网络连接,用来收到网络上的json请求。在这里我们使用cpp-httplib第三方库来进行网络交互。

我们规定:在运行本文件形成的可执行文件时,采用./compiler_server port的形式,这里port是自定义的端口号,用来指定编译服务器在本机上的端口。这里我们不设置首页,因为首页在下面的oj_server中设计,这里只暴露一个方法等待上层调用即可。

void Usage(std::string proc)
{
    std::cerr << "Usage: " << "\n\t" << proc << " port" << std::endl;
}
// 使用:./compile_server port (端口号自己指定)
int main(int argc,char* argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        return 1;
    }

    //提供编译服务,打包形成一个网络服务
    Server svr;
    // svr.Get("/hello",[](const Request& req,Response& resp){
    //     resp.set_content("hello,http!","text/plain;charset=utf-8");
    // });

    svr.Post("/compile_and_run",[](const Request& req,Response& resp)
    {
        //用户请求的正文是我们要的json串
        std::string in_json = req.body;
        std::string out_json;
        if(!in_json.empty())
        {
            CompileAndRun::Start(in_json,&out_json);
            resp.set_content(out_json,"application/json;charset=utf-8");
        }
    });

    svr.listen("0.0.0.0",atoi(argv[1]));
    return 0;
}


oj_server模块的编写

oj服务本质上是建立一个小型网站。其内我们实现:
1.获取首页,我们这里的首页是题目列表
2.编辑代码区域
3.提交判题功能(基于编译运行模块)

我们采用MVC设计模式。M是Model,通常是和数据交互的模块。在这里我们的数据是题目列表,所以实现的是对题库进行增删查改的功能(分为文件和MySql版本);V是View,指拿到数据后构建网页,展示给用户;C代表Control,用于控制前两个模块,是核心业务逻辑。

本模块的结构如下:
在这里插入图片描述
下面结合每个模块的编写一并介绍他们的功能。

oj_server.cc的编写

oj_server用于处理用户请求的服务路由功能,是将其他小模块统一起来的外层模块。用户可能的请求是:
1.获取所有的题目列表
2.根据一个特定的题目编号,获取指定题目的内容
3.用户提交代码,使用判题功能(①每道题的测试用例②编译并运行)
我们只需针对这三种情况分别设置网络响应即可。
这里需要特别注意一点:当用户选择具体题目时,请求URL会根据具体题目的不同编号发生改变,所以这里在用httplib的Post功能捕获请求时,要用一个正则匹配,匹配到的内容存放在请求结构体的matches的1号下标处。为防止正则表达式被特殊转义,还要用C++ R"(…)"的原始字符串。判题也涉及到具体的题目,与选择具体的一道题形式类似。最后在本目录底下新建一个wwwroot,设置为我们系统的首页。

static Control* ctrl_ptr = nullptr;

void Recovery(int signo)
{
    //注册一个方法,每当ctrl+\时,就尝试上线所有机器
    LOG(INFO) << "尝试上线主机成功!" << std::endl;
    ctrl_ptr->RecoverMachine();
}

int main()
{
    signal(SIGQUIT,Recovery); 

    //用户请求的服务路由功能
    Control ctrl;
    ctrl_ptr = &ctrl;
    Server svr;
    /* 
       用户可能的请求:
       1.获取所有题目列表(首页)
       2.根据题目编号获取题目内容
       3.提交代码,使用判题功能(1.每道题的测试用例 2.compile_and_run)
    */
    svr.Get("/all_questions",[&ctrl](const Request& req,Response& resp)
    {
        //需要返回一张包含所有题目的html网页
        std::string html;
        ctrl.AllQuestions(&html);
        resp.set_content(html,"text/html;charset=utf-8");
    });

    //选题:正则表达式 如用户请求 /questions/100  \d代表匹配数字,+代表一或多个,所以可以匹配到用户输入的任意数字
    //R"()" 原始字符串,里面是什么样就是什么样,保持字符串内容的原貌,无需做相关强转
    svr.Get(R"(/question/(\d+))",[&ctrl](const Request& req,Response& resp)
    {
        //req.matches[1]代表了正则匹配到的题号
        std::string number = req.matches[1];
        std::string html;
        ctrl.Question(number,&html);

        resp.set_content(html,"text/html;charset=utf-8");

    });

    svr.Post(R"(/judge/(\d+))",[&ctrl](const Request& req,Response& resp)
    {
        //req.matches[1]代表了正则匹配到的题号
        std::string number = req.matches[1];
        std::string result_json;
        ctrl.Judge(number,req.body,&result_json);
        resp.set_content(result_json,"application/json;charset=utf-8");
        //resp.set_content("这是指定的题目的判题:"+number,"text/plain;charset=utf-8");
    });

    //首页设置为本目录下的wwwroot
    svr.set_base_dir("./wwwroot");

    svr.listen("0.0.0.0",8081);
    return 0;
}

这里涉及了许多其他模块的功能,我们先放在这里有个框架,接下来对每个模块详细介绍。

文件版题库的建立

在编写其他模块之前,我们先要有一个题库,这样在完成其他模块时才能更方便的编写功能。

首先我们要明确题库的位置。在oj_server目录下存放一个questions目录,代表着我们的题库目录。
对于其中每个题目,我们规定要有:
1.题目的编号
2.题目的标题
3.题目的难度
4.题目的描述
5.题目的时间和空间要求(内部设定,不暴露给用户)
在实际的oj平台上时,都会有一个题目列表,当点进去具体的题目时,才会进到这个题目的页面,包含上面提到的内容。所以我们还需要一个文件:一个题目列表,包含所有的题目。
除此之外,当点进去具体的题目做题时,还会有一些预设代码(C++一般为Solution类),让我们在所规定的范围内写代码。这部分预设代码,也应该作为文件保存,我们称之为header.cpp;最后,当用户提交代码之后,我们要结合测试用例进行判题,而这个测试用例当然也是代码,需要放在文件内保存。我们将其称之为tail.cpp,将来判题时将二者结合(通过题目的编号),并交给compiler_run模块进行统一运行判断。

具体格式为:
questions.list:题目编号 题目标题 题目难度 题目时间限制(s) 题目空间限制(kb)
每个具体题目对应一个目录,目录名为其题目编号。目录下有三个文件,分别为desc.txt、header.cpp和tail.cpp。desc.txt内存放对题目的具体描述,其余两个上面已经进行介绍,不再赘述。
在这里插入图片描述
以回文数为例,下面是他的目录下的内容:
在这里插入图片描述

//header.hpp
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>
using namespace std;

class Solution
{
public:
    bool IsPalindrome(int x)
    {
        //write your code here...
        
        return true;
    }
};
//tail.hpp

//采用条件编译,仅为了在拼接之前单独设计测试用例时不要出现语法提示错误!
//在编译时可采用 g++ -D COMPILER_ONLINE编译时定义该宏,在拼接时编译器就会将该段代码自动裁剪!
#ifndef COMPILER_ONLINE 
#include "header.cpp"
#endif

void Test1()
{
    //通过定义临时对象来完成方法的调用
    bool ret = Solution().IsPalindrome(121);
    if(ret)
    {
        std::cout << "通过用例1:121" << std::endl; 
    }

    else
    {
        std::cout << "未通过用例1:121" << std::endl; 
    }
}

void Test2()
{
    bool ret = Solution().IsPalindrome(-10);
    if(!ret)
    {
        std::cout << "通过用例2:-10" << std::endl; 
    }

    else
    {
        std::cout << "未通过用例2:-10" << std::endl; 
    }
}

int main()
{
    Test1();
    Test2();

    return 0;
}

注意在tail.hpp内最开始我们采用了条件编译。这是因为如果不加这一行,虽然在拼接后没什么影响,但是不拼接的情况下tail.hpp文件内会有很多报错影响观感。在将来拼接时我们可以定义宏COMPILER_ONLINE(g++ -D选项)来取消重复引用头文件这一行。

将来用户提交代码时,我们将用户代码、预设代码和测试用例整体拼接,并把整段代码以json串的形式交给编译运行模块。运行结果会以json形式返回。我们将来只需把通过的测试用例的个数返回给客户端,就完成了判题的功能。

oj_model.hpp的编写

根据上面的讲解可以知道,model模块用于和数据进行交互,提供对外访问数据的接口。根据最上层的要求,要有提供所有题目和提供单个具体题目的接口。我们设置一个题目结构体,信息如下:

//问题结构体
    struct Question
    {
        std::string number; //唯一题目编号
        std::string title;  //题目的标题
        std::string star;   //题目的难度等级 (简单 中等 困难)
        int cpu_limit;      //题目的时间要求 
        int mem_limit;      //题目的空间要求

        std::string desc;   //题目的描述
        std::string header; //题目给用户预设的代码
        std::string tail;   //题目的测试用例,需要与header拼接形成完整代码
    };

然后建立一个哈希表,对每个题目建立从题号到题目具体信息的映射。

unordered_map<std::string,Question> questions;

要完成提供所有题目和提供具体题目的接口之前,先要把题目加载到内存中。设计一个Model类,并在构造函数中运行加载配置文件的函数。加载配置文件就先要读取同级目录下的questions/questions.list获取所有题目,然后再根据编号获取具体题目。
因为我们在写入questions.list时是按行写入的,所以这里每次用一个getline就可以获取一个题目的部分信息。这个部分信息里就包含题号,然后我们又可以通过拼接对应的题号到路径下,来获取一个题目的全部信息。
当我们按行读取获取一个文件的部分信息时,这个只是一个字符串,每条信息由空格分割,所以我们这里用了boost标准库的split的接口用于切分字符串,并将每条信息存储在这个题目的Question结构体内。这个切分函数放在comm模块中。

class StringUtil
    {
    public:
        //切分字符串函数 str:原始字符串 target:切分后的若干子串存放地点 sep:分隔符
        static void SplitString(const std::string& str,std::vector<std::string>* target,const std::string& sep)
        {
            /*
            boost split 方法
            第一个参数:切分子串存放地址
            第二个参数:目标切分字符串
            第三个参数:分隔符集,只要扫描时碰到is_any_of中的任意字符,就需要切分
            第四个参数:是否进行压缩————出现多个连续的分隔符时,每两个连续的分隔符中间的空白
                       是否需要保留。
                       不保留:boost::algorithm::token_compress_on
                       保留:boost::algorithm::token_compress_off
            */
            boost::split((*target),str,boost::is_any_of(sep),boost::algorithm::token_compress_on);
        }
    };

下面是读取文件的函数代码

const std::string questions_list = "./questions/questions.list"; //题目列表路径
    const std::string questions_path = "./questions/"; //具体的题目路径,根据具体题目改变
    class Model
    {
    public:
        Model() 
        {
            assert(LoadQuestions(questions_list));
        }

        ~Model() {}

        bool LoadQuestions(const std::string& question_list)
        {
            //加载配置文件。路径: questions/questions.list + 题目编号文件
            ifstream in(question_list);
            if(!in.is_open())
            {
                LOG(FATAL) << "加载题库失败,请检查是否存在题库对应文件!" << std::endl;
                return false;
            }

            std::string line;
            while(std::getline(in,line))
            {
                std::vector<std::string> tokens;
                StringUtil::SplitString(line,&tokens," ");
                
                if(tokens.size() != 5)
                {
                    LOG(WARNING) << "加载部分题目失败,请检查文件格式" << endl;
                    continue;
                }
                //std::cout << tokens[0] << " " << tokens[1] << " "<< tokens[2] << " "
                //<< tokens[3] << " " << tokens[4] << " ";
                Question q;
                q.number = tokens[0];
                q.title = tokens[1];
                q.star = tokens[2];
                q.cpu_limit = atoi(tokens[3].c_str());
                q.mem_limit = atoi(tokens[4].c_str());

                std::string path = questions_path;
                path += q.number;
                path += "/";
 
                FileUtil::ReadFile(path+"desc.txt",&(q.desc),true);
                FileUtil::ReadFile(path+"header.cpp",&(q.header),true);
                FileUtil::ReadFile(path+"tail.cpp",&(q.tail),true);

                //for debug
                //std::cout << q.number << std::endl << q.desc << std::endl << q.header << endl;
                questions.insert({q.number,q});
            }
            LOG(INFO) << "加载题库成功!" << endl;
            in.close();

            return true;
        }
    };

加载全部题目进入内存后,读取单个和全部题目的接口的实现就很简单了。直接用questions来实现即可。这里采用输入输出型参数,单个题目是一个Question结构体指针,全部题目是一个vector。下面直接上代码。

bool GetAllQuestions(vector<Question>* out)
        {
            if(questions.size() == 0)
            {
                LOG(ERROR) << "用户获取全部题库失败! " << endl;
                return false;
            }
            
            for(const auto& q : questions)
            {
                out->push_back(q.second);
            }
            return true;
        }

        bool GetOneQuestion(const std::string& number,Question* q)
        {
            const auto& iter = questions.find(number);
            if(iter == questions.end())
            {
                LOG(ERROR) << "用户获取题目" << number << "失败! " << endl;
                return false;
            }
            (*q) = iter->second;
            return true;
        }   

oj_view.hpp的编写

当收到上层要求全部题目或者部分题目的请求时,我们要返回一个html网页给用户,这是在oj_server中指明的。当前我们已经可以通过Model模块来获取全部题目的vector或单个题目的Question结构体,那么我们还要将其转换成一个html网页。view.hpp的任务就是完成这个转换。
这个转换具体怎么完成呢?我们这里使用ctemplate网页渲染库来完成。我们在同级目录下新增一个template_html目录,并创建两个待渲染的网页all_questions.html和one_question.html。
ctemplate中需要两个主题内容:保存数据的数据字典、等待被渲染的网页内容。数据字典的键值对中,key可以在.html文件中以{{key}}的形式进行引用,最后在网页中会显示其所对应的value,这样一来,就可以用一个one_question网页来对应不同的题目,与模板类似。具体的使用规则不做细致解释,可以结合代码注释进行理解。
该模块包含两个函数,分别可以通过Question的vector来返回全部题目的网页、通过单个Question来返回单个题目的网页。这里的返回都是通过输出型参数。

对于全部题目的网页,只需要显示每个题目的编号、标题和难度就可以了。这里推荐使用表格来显示;而对于单独题目的网页,要显示题目的编号、标题、难度、描述和预设的代码(也就是每个题目的header.hpp)。详情见代码实现。

class View
    {
    public:
        View(){}
        ~View(){}

        void AllExpandHtml(const std::vector<struct Question>& questions,std::string* html)
        {
            //显示信息:题目编号、题目标题、题目难度
            //使用表格显示
        
            //先形成要渲染的网页路径
            std::string src_html = template_path + "all_questions.html";
            
            //再形成根数据字典
            ctemplate::TemplateDictionary root("all_questions");
            for(const auto& q : questions)
            {
                //形成root的子字典
                ctemplate::TemplateDictionary* sub = root.AddSectionDictionary("question_list");
                //所有添加进子字典的数据经过循环遍历,会被添加到目标html文件"question_list"所对应的区域并依次展开(html网页内循环打印所有题目)
                sub->SetValue("number",q.number);
                sub->SetValue("title",q.title);
                sub->SetValue("star",q.star);
           }

           //获取被渲染的网页
           ctemplate::Template* tpl = ctemplate::Template::GetTemplate(src_html,ctemplate::DO_NOT_STRIP);

            //完成渲染功能
            tpl->Expand(html,&root);
        }

        void OneExpandHtml(const struct Question& q,std::string* html)
        {
            std::string src_html = template_path + "one_question.html";

            ctemplate::TemplateDictionary root("one_question");
            root.SetValue("number",q.number);
            root.SetValue("title",q.title);
            root.SetValue("star",q.star);
            root.SetValue("desc",q.desc);
            root.SetValue("pre_code",q.header);

            ctemplate::Template* tql = ctemplate::Template::GetTemplate(src_html,ctemplate::DO_NOT_STRIP);

            tql->Expand(html,&root);
        }
    };

oj_control.hpp的编写

这个模块是核心控制模块。其内要完成的功能有根据负载选择判题主机、判题、构建网页等。这一模块包含前两个模块的变量,方便对他们的功能进行统一。
首先是配合上层进行文件的读取并返回html网页。这点根据前面的介绍已经没什么好说的了,直接看代码。

//根据题目数据构建网页
        bool AllQuestions(std::string* html)
        {
            bool ret = true;
            std::vector<struct Question> all;
            if(model_.GetAllQuestions(&all))
            {
                // 对all进行排序,确保网页题库的题目是有序的 
                std::sort(all.begin(),all.end(),[](const struct Question& q1,const struct Question& q2){
                    return atoi(q1.number.c_str()) < atoi(q2.number.c_str());
                });
                //获取题目信息成功,要将所有的题目信息构建成网页
                view_.AllExpandHtml(all,html);
            }
            else
            {
                *html = "获取题目、形成题目列表失败!";
                ret = false;
            }
            return ret;
        }

        bool Question(const std::string& number,std::string* html)
        {
            bool ret = true;
            struct Question q;
            if(model_.GetOneQuestion(number,&q))
            {
                //获取一个题目成功,构建html并返回
                view_.OneExpandHtml(q,html);
            }
            else
            {
                *html = "指定题目:" + number + "不存在!";
                ret = false;
            }
            return ret;
        }

接下来对负载均衡的功能加以实现。
我们在远端有多台编译运行服务器,所以在同级目录下定义一个server_machine.conf文件,用于存放可以提供编译运行服务的主机的ip和端口号。
在这里插入图片描述
在本头文件中定义两个类:Machine类和LoadBlance类。Machine是为我们提供编译运行服务的主机类,其内信息包括ip、port、当前负载信息和防止冲突的锁,并有增加负载、减少负载、获取负载和清空负载的相关接口。LoadBlance类主要实现对主机的负载均衡,其内包括可提供服务的主机vector、所有在线的主机vector和当前下线的主机vector。主机的id就是其在Machine vector内的下标。这里后两者内存放的是在主机vector内的下标,用于对应主机vector内的主机。当然还有一把锁。其内接口功能包括将可用主机初始化并将其读取到主机vector内、上线全部主机、下线一台具体主机和对主机的负载均衡选择。这里需要注意的是,因为每台机器要被放在vector内管理,所以一定会涉及拷贝,而std的mutex锁禁止拷贝,所以Machine类内的锁设置为锁指针,而对于LoadBlance类则没有类似的需求,直接用锁变量就行。
对于Machine类的相关接口编写很简单,只需注意加锁即可。

//提供服务的主机
    class Machine
    {
    public:
        Machine()
        :ip("")
        ,port(0)
        ,load(0)
        ,mtx(nullptr)
        {}
        ~Machine(){}

        //提升主机负载
        void IncLoad()
        {
            if(mtx)
                mtx->lock();
            load++;
            if(mtx)
                mtx->unlock();
        }

        //减少主机负载
        void DecLoad()
        {
            if(mtx)
                mtx->lock();
            load--;
            if(mtx)
                mtx->unlock();
        }

        // 清空主机负载
        void ResetLoad()
        {
            if(mtx)
                mtx->lock();
            load = 0;
            if(mtx)
                mtx->unlock();
        }

        //获取主机负载
        uint64_t Load()
        {
            uint64_t _load = 0;
            if(mtx)
                mtx->lock();
            _load = load;
            if(mtx)
                mtx->unlock();
            return _load;
        }

        std::string ip; //编译服务的ip
        int port;       //编译服务的port
        uint64_t load;  //编译服务的负载
        std::mutex* mtx;//mutex禁止拷贝,所以用指针
    };

接下来是LoadBlance类的编写。
该类的构造函数是运行加载机器文件的接口,如果出错直接assert报错退出程序。在加载机器文件时,思路和之前读取题目列表时一样。由于存放时规则是ip:port,所以在切分时要以:为分隔符。每读取一行机器的信息,就初始化一个Machine类对象并将其存如Machine vector。这里启动时默认全部主机全部上线。
在进行智能选择负载均衡最小的主机时,我们要返回两个值:选择主机的id和选择的主机,他们都以输出型参数的形式返回。因为我们设置的是外层要获取目标主机的地址,所以这里的主机在参数中是一个Machine类的二级指针,方便更改外层主机地址。
这里采用轮询+hash的方案进行选择。先遍历在线主机vector,再通过其内存储的下标O(1)的找到其对应的主机,并进行负载判断,最后找到负载最小的主机返回其id和本体。

//负载均衡模块
    const std::string service_machine = "./conf/service_machine.conf";
    class LoadBlance
    {
    public:
        LoadBlance()
        {
            assert(LoadConf(service_machine));
            LOG(INFO) << "加载: " << service_machine << " 成功!" << std::endl;
        }
        ~LoadBlance(){}

        bool LoadConf(const std::string& machine_conf)
        {
            std::ifstream in(machine_conf);
            if(!in.is_open())
            {
                LOG(FATAL) << "加载: " << machine_conf << " 失败!" << std::endl;
                return false;
            }
            std::string line;
            while(std::getline(in,line))
            {
                std::vector<std::string> tokens;
                StringUtil::SplitString(line,&tokens,":");
                if(tokens.size() != 2)
                {
                    LOG(WARNING) << "切分 " << line << " 失败" << std::endl;
                    continue;
                }
                Machine m;
                m.ip = tokens[0];
                m.port = atoi(tokens[1].c_str());
                m.load = 0;
                m.mtx = new std::mutex();

                //先pushonline,因为下代表machine的id
                online.push_back(machines.size());

                machines.push_back(m);
            }

            in.close();
            return true;
        }

        //id:输出型参数 ,本次选择主机的编号
        //m:输出型参数,本次选择的主机地址
        bool SmartChoice(int* id,Machine** m)
        {
            //使用选择好的主机并更新其负载
            //可能需要离线该主机
            //负载均衡思路:轮询+hash

            //选择时需要先上锁,防止选择时别人把该主机离线等问题
            mtx.lock();
            int online_num = online.size();
            if(online_num == 0)
            {
                LOG(FATAL) << "当前无可用后端编译主机!" << std::endl;
                mtx.unlock();
                return false;
            }

            //通过遍历找到负载最小的机器
            *id = online[0];
            *m = &machines[online[0]];
            uint64_t min_load = machines[online[0]].Load();
            for(int i = 0;i < online_num;i++)
            {   
                uint64_t cur_load = machines[online[i]].Load();
                if(min_load > cur_load)
                {
                    min_load = cur_load;
                    *id = online[i];
                    *m = &machines[online[i]];
                }
            }
            mtx.unlock();
            return true;
        }

        void OfflineMachine(int which)
        {
            mtx.lock();

            for(auto iter = online.begin();iter != online.end();iter++)
            {
                if(*iter == which)
                {
                    machines[which].ResetLoad();
                    //要下线的主机已经找到
                    online.erase(iter);
                    offline.push_back(which);
                    break;  //找到直接break,防止迭代器失效问题
                }                
            }
            mtx.unlock();
        }

        void OnlineMachine()
        {
            //全部主机统一上线
            mtx.lock();
            online.insert(online.end(),offline.begin(),offline.end());
            offline.erase(offline.begin(),offline.end());
            mtx.unlock();   
        }
    private:
        //可以提供编译服务的主机列表,用其下标来充当自己的id
        std::vector<Machine> machines;
        //所有在线的主机,其内存放的是在machine内的下标
        std::vector<int> online;
        //所有离线的主机,其内存放的是在machine内的下标
        std::vector<int> offline;
        //同时可能有多个执行流访问以上的数据结构,所以需要锁保护LoadBlance的数据安全
        std::mutex mtx;
    };

这里涉及了一个全部主机上线的成员函数。我们在oj_server.cc内重定义了SIGQUIT(ctrl+\)的处理方法,使得每次输入这个指令就会尝试重新上线所有主机。
这个类实现后,也需要作为一个变量加入Control的成员变量内。

最后是判题功能。
我们根据上面oj_server内可以得知,判题功能是对用户提交的指定题目的代码进行判断,所以Judge函数的参数包含了指定题目的编号、用户提交的json串(包含了用户的输入信息(不做处理)、编写的代码等信息),并把最终结果以json串输出型参数的形式进行返回。
进行的工作步骤是:
1.对用户的json串进行反序列化,得到用户提交的代码;
2.将用户代码和测试用例进行拼接,形成整体可执行的程序代码;
3.在多台编译运行主机中选择负载最低的主机(差错处理);
4.对选择的主机发起http请求得到判题结果;
5.将结果返回给out_json。

我们首先要做的工作是根据题号通过Model模块直接获取对应题目的信息。拿到题目信息后,再解析输入json串,获取用户输入的代码。这个代码只是一个程序的片段,所以要和刚刚获得的题目信息进行拼接。由于编译运行模块的Start函数输入是一个json串,所以拼接成完整代码后我们还需填入时间空间要求等信息,将其整体序列化为json串发送给编译运行机器。

在选择负载最低的主机时,有可能发生请求主机失败的情况,所以这里使用一个死循环,一直请求主机,直到成功获取到主机为止。死循环内还包括下面的网络请求,以便网络请求失败后重新来过。
当成功获取主机之后,我们要对编译机发起服务请求。这里再次用httplib库,对远端发起请求。作为请求服务的一端,使用httplib的Client模块。给编译机的compiler_server.cc发送一个json串,编译机收到后给编译链接模块传递下去,最后再把结果返回,整个逻辑就串通了。要注意还需判断返回的结果的status是否为200(正常返回)。
这里还有几个细节:当选择主机成功后,要把这个主机的负载+1;当主机编译完成返回结果后,其负载要-1,并跳出死循环。而如果返回的状态码status不等于200,也要减少负载并进行下一轮选择。特别的,如果网络请求这台主机都失败了,那么就说明这台主机已经离线,需要将这台主机下线(会自动清空负载)。
下面是Judge函数的全部代码:

/*
            in_json内容:
            "code" : #Include ...
            "input" : ...
        */
        void Judge(const std::string& number,const std::string& in_json,std::string* out_json)
        {
            //根据题目编号拿到题目的对应细节
            struct Question q;
            model_.GetOneQuestion(number,&q);
            //对in_json进行反序列化,得到题目信息,如id、用户源代码等
            Json::Reader reader;
            Json::Value in_value;
            reader.parse(in_json,in_value);
            std::string code = in_value["code"].asString();
            //重新拼接用户代码+测试用例代码,形成完整的编译运行代码
            Json::Value compile_value;
            compile_value["input"] = in_value["input"].asString();
            compile_value["code"] = code + "\n" + q.tail;
            compile_value["cpu_limit"] = q.cpu_limit;
            compile_value["mem_limit"] = q.mem_limit;
            Json::FastWriter writer;
            std::string compile_string = writer.write(compile_value);
            //选择负载最低的主机,发起http请求
            //规则:一直选择直到主机可用,否则即为全部挂掉
            while(true)
            {
                int id = 0;
                Machine* m = nullptr;
                if(!load_blance_.SmartChoice(&id,&m))
                {
                    //主机全部挂掉,日志在SmartChoice内已经打印
                    break;
                }
                LOG(INFO) << "选择主机成功!主机ID: " << id << "详情: " << m->ip << " " << m->port << std::endl;
                //发起http请求并得到结果,采用Client
                Client cli(m->ip,m->port);
                m->IncLoad();
                //Post第一个参数:路由时要请求哪个服务 第二个参数:请求的参数 第三个参数:请求的数据类型
                if(auto res = cli.Post("/compile_and_run",compile_string,"application/json;charset=utf-8"))
                {
                    //在进行下一步的逻辑之前,需要判断是否为正常响应
                    if(res->status == 200)
                    {
                        //只有status值为200,才可以将结果返回给用户
                        *out_json = res->body;
                        m->DecLoad();
                        LOG(INFO) << "请求编译和运行服务成功!" << std::endl;
                        break;
                    }
                    //若if未执行说明非正常响应,那就减少负载并执行下一轮选择
                    m->DecLoad();
                }
                else
                {
                    //请求失败
                    LOG(ERROR) << "请求该主机失败!主机ID: " << id << "详情: " << m->ip << " " << m->port << "该主机可能已经离线!" << std::endl;
                    load_blance_.OfflineMachine(id);    //该函数会自动清空负载无需手动处理
                }
            }
        }

MySql版本题库的建立

上面的基本流程已经完毕,但我们觉得题目放在文件里有点挫,所以现在尝试将题目放到MySql内。
在建立MySql题库时,基本可以分为三步:
1.在数据库中设计可以远程登录的MySql用户并给其赋权(用户名:oj_client)
2.设计表结构
数据库:oj 表:oj_questions
3.编码(连接并访问数据库)

我们以root的身份进入MySql,新增一个从任意地点登录的用户oj_client。
在这里插入图片描述
添加用户后查看user表:
在这里插入图片描述
第一个oj_client即为我们的用户,可以在任意地址登录。

同时建立了一个oj的数据库。
在这里插入图片描述
在这里插入图片描述

这里再给oj_client用户赋予oj目录下的一切权力。
在这里插入图片描述

这里退出root,并以oj_server身份登陆。输入show databases指令,可以看到oj目录。
在这里插入图片描述
在oj目录下新建一个oj_questions的表,表的详细信息如下所示。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

接下来按照表项依次添加,添加进两道题目的内容。添加完查看一下(这里使用官方工具MySql Workbench来查看)。这里在MySql下也可以查看,但包含header和tail代码比较臃肿,不予展示。
在这里插入图片描述

MySql的题目录完之后,与文件版相同,我们也要有一套相同的model.hpp的头文件用于与数据交互。这里与文件版的大同小异,我们定义为model2.hpp。由于与数据打交道的只有model.hpp,所以我们也只需改变这个头文件的内容,并在其他文件内引用model2.hpp就行。注意接口的名称一定不能改变。
这里要与数据库进行交互,肯定少不了相关的接口。从官网下载mysql库的相关开发包,并在oj_server下与其建立软连接。如此一来,我们就可以直接在model2.hpp内引入mysql.h了。
在这里插入图片描述

在编写model2.hpp时,我们要保留头文件、命名空间、问题信息结构体,但文件版题库的路径、加载配置文件的函数以及调用它的构造函数、题号对应题目细节的unordered_map全都不需要,因为这些信息我们都可以从MySql直接获取。我们在这里只需要重新设计获取全部题目和单个题目的接口就行,而这两个现在对于我们而言只有sql语句的区别。当然要连接MySql,我们还需把如数据库名、表名、ip、用户名及密码、端口等信息作为常量保存等待使用。

我们新增一个QueryMysql函数,参数为sql语句和一个Question结构体的vector指针。如果获取全部题目就正常放入,如果获取一道题目那存放一个值就好。在获取全部和指定题目的接口中,直接复用这个函数即可。
在连接MySql时,先创建一个mysql句柄,再通过上面的常量进行连接。设置一下链接编码格式,就可以通过远程执行sql语句并获得其返回值。将返回值结果的行和列提取,并遍历每一行,在每次遍历中提取一列的消息,填入到单个Question结构体内,最后存放到输出vector内。在最后别忘了释放结果空间并关闭mysql。
而获取全部和单个题目的接口编写也变得轻而易举。只需要编写sql语句"select * from oj_questions"和"select * from oj_questions where number= x"就可以获得全部题目和编号为x的题目了。

namespace ns_model
{
    using namespace std;
    using namespace ns_log;
    using namespace ns_util;
    // 问题结构体
    struct Question
    {
        std::string number; // 唯一题目编号
        std::string title;  // 题目的标题
        std::string star;   // 题目的难度等级 (简单 中等 困难)

        std::string desc;   // 题目的描述
        std::string header; // 题目给用户预设的代码
        std::string tail;   // 题目的测试用例,需要与header拼接形成完整代码
        int cpu_limit;      // 题目的时间要求
        int mem_limit;      // 题目的空间要求
    };
    // 需要的一系列信息
    const std::string oj_questions = "oj_questions";
    const std::string host = "127.0.0.1";
    const std::string user = "oj_client";
    const std::string password = "";
    const std::string db = "oj";
    const int port = 3306;

    class Model
    {
    public:
        Model() {}

        ~Model() {}

        bool QueryMysql(const std::string &sql, vector<Question> *out)
        {
            // 创建mysql句柄
            MYSQL *my = mysql_init(nullptr);

            // 连接数据库
            if (mysql_real_connect(my, host.c_str(), user.c_str(), password.c_str(), db.c_str(), port, nullptr, 0) == nullptr)
            {
                LOG(FATAL) << "连接数据库失败!" << std::endl;
                return false;
            }

            // 记得要设置链接编码格式!
            mysql_set_character_set(my,"utf8");

            LOG(INFO) << "连接数据库成功!" << std::endl;

            // 执行sql语句
            if (0 != mysql_query(my, sql.c_str()))
            {
                LOG(WARNING) << sql << " execute error!" << std::endl;
                return false;
            }

            // 提取结果
            MYSQL_RES *res = mysql_store_result(my);

            // 分析执行结果
            int rows = mysql_num_rows(res);   // 获得行数量
            int cols = mysql_num_fields(res); // 获得列数量
            struct Question q;
            for (int i = 0; i < rows; i++)
            {
            	//拿出一行以空格为分隔的数据
                MYSQL_ROW row = mysql_fetch_row(res);
                q.number = row[0];
                q.title = row[1];
                q.star = row[2];
                q.desc = row[3];
                q.header = row[4];
                q.tail = row[5];
                q.cpu_limit = atoi(row[6]);
                q.mem_limit = atoi(row[7]);

                out->push_back(q);
            }

            // 释放结果空间
            free(res);

            // 关闭mysql连接
            mysql_close(my);

            return true;
        }

        bool GetAllQuestions(vector<Question> *out)
        {
            std::string sql = "select * from ";
            sql += oj_questions;
            return QueryMysql(sql, out);
        }

        bool GetOneQuestion(const std::string &number, Question *q)
        {
            bool res = false;
            std::string sql = "select * from ";
            sql += oj_questions;
            sql += " where number=";
            sql += number;
            vector<Question> result;
            if (QueryMysql(sql, &result))
            {
                if (result.size() == 1)
                {
                    *q = result[0];
                    res = true;
                }
            }
            return res;
        }
    };
}

至此,本项目的整体流程也全部完成。(还有一些前端代码,请到源码中去查看)


演示

在本地打开三个中端,一个运行oj_server模块,两个运行compiler_server模块,并将他们运行。
在这里插入图片描述
在浏览器搜索: 101.43.189.110:8081,进入oj界面。
在这里插入图片描述
点击开始编程按钮进入题目列表,与此同时URL改变。
在这里插入图片描述
点击单个题目进入题目页面,URL改变。
在这里插入图片描述
直接点击提交,结果回显给用户。与此同时后端回显信息。
在这里插入图片描述
在这里插入图片描述
可以看到分配给了0号主机。

接下来输入错误代码导致无法通过编译并提交。
在这里插入图片描述
在这里插入图片描述
可以看到下方回显了错误信息,后台也输出了提示信息。

写一个死循环或者申请过大空间也会提示并报错。
在这里插入图片描述
在这里插入图片描述
此时后端提示编译运行成功,因为编译确实没问题。
在这里插入图片描述

接下来测试负载均衡。什么也不写狂点提交,后端显示为:
在这里插入图片描述
可以看到,后端负载均衡的选择了两个主机。

此时挂掉0号主机,再点击几次提交按钮:
在这里插入图片描述
可以发现后端发现0号主机挂掉,并只把编译服务交给1号主机。

接下来两台主机都挂掉再试试:
在这里插入图片描述
后端发现两台主机都挂掉,无可用主机,同时用户那边也没有任何反应。

接下来再将两台主机全部上线,再提交代码,还是无可用主机。这是因为两台主机全部在离线vector内,需要手动将他们再次上线。
在这里插入图片描述
键盘输入’crtl+',将全部主机上线。再次测试发现重启成功,整个项目测试完毕。
在这里插入图片描述


后记

这个项目对我的挑战实在不小,从后端逻辑的实现到前端代码的编写,耗费了很多精力,也发生了很多意料之外的错误。如引入库时报错、逻辑上一些小细节没能注意导致很简单的错误改了好几天…但是最终还是顺利完成。在日后的学习道路中,我也要不断完善自己。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值