C++ -- 负载均衡式在线OJ (一)

一、项目宏观结构

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;

cpp 复制代码
#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类当中

cpp 复制代码
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的第二个参数是一个输出型参数,是一个系统提供的结构体类型。

cpp 复制代码
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,让标准错误文件描述符进行重定向到该文件,如果编译出错,就可以在这个文件当中看见错误原因。

cpp 复制代码
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

cpp 复制代码
//只负责进行代码的编译
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起来。

cpp 复制代码
  程序运行
  1.代码跑完,结果正确
  2.代码跑完,结果不正确
  3.代码没跑完,异常了
         
  进程等待中的status就可以直到 前8位是退出吗 低8位是核心转储 后面7位是进程信号
  信号为0,则退出码有效,不为0,则退出码无效。核心转储需要自己开启,并且核心转储是存储核心错误信息

  但是运行模块,Run,我们是不需要考虑结果正确与否
  结果正确与否是由测试用例决定的。但是跑错了是要报错的。
  错误又分为编译错误和运行错误,运行错误才是在runner模块里该出现的

进程起来之后,默认会打开三个文件描述符,分别是0,1,2号文件描述符,分别对应stdin,stdout,stderr。我们为了方便我们运行的自测输入(我们这里暂时不支持),运行结果,运行错误结果等的查看与返回给用户。我们需要把这三个文件描述符进行重定向

cpp 复制代码
//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)

cpp 复制代码
#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;
            }
        }
    };
}
相关推荐
Swift社区24 分钟前
在 Swift 中实现字符串分割问题:以字典中的单词构造句子
开发语言·ios·swift
没头脑的ht26 分钟前
Swift内存访问冲突
开发语言·ios·swift
没头脑的ht29 分钟前
Swift闭包的本质
开发语言·ios·swift
wjs202431 分钟前
Swift 数组
开发语言
南东山人1 小时前
一文说清:C和C++混合编程
c语言·c++
stm 学习ing2 小时前
FPGA 第十讲 避免latch的产生
c语言·开发语言·单片机·嵌入式硬件·fpga开发·fpga
湫ccc2 小时前
《Python基础》之字符串格式化输出
开发语言·python
mqiqe3 小时前
Python MySQL通过Binlog 获取变更记录 恢复数据
开发语言·python·mysql
AttackingLin3 小时前
2024强网杯--babyheap house of apple2解法
linux·开发语言·python