C++/Linux实战项目 - 负载均衡式在线OJ平台

目录

总览

简述

项目核心的三个模块

项目宏观结构

 compile_server 编译与运行服务

总览分析

compiler.hpp

runner.hpp

compile_run.hpp

compile_server.cc

oj_server 基于MVC结构的oj服务设计

总览分析

Model 提供对数据的操作方法

View 使用后端数据对前端页面进行渲染,获取渲染之后的html

Control 逻辑控制模块,oj_server.cc直接调用Control提供的方法

oj_server.cc

前端

index.html

all_question.html

one_question.html 

结尾


总览

简述

此项目,旨在设计出一款类似于leetcode的在线OJ平台,通过浏览器客户端获取服务端提供的http服务。主要提供的功能有两个: 1. 获取题目列表  2. 获取指定题目的详细信息,并获取一个在线编辑器,用于提交代码,提交后显示出此题的提交结果: 编译错误 / 运行时出错(程序崩溃) / 编译运行成功但没有通过测试用例 / 编译运行成功且通过测试用例

项目核心的三个模块

1. common 公共模块 : 用于提供一些第三方库文件,一些工具类,工具方法。

2. compile_server 编译与运行模块 : 通过提供网络服务的方式,获取通过网络请求发送来的源代码,仅提供编译运行功能,并将编译运行的结果通过网络返回回去。

3. oj_server 在线OJ模块 : 提供http服务,如获取题目列表,进入指定题目的OJ界面,负载均衡。

综上,common就是提供一些工具方法,用于另外两个模块使用。而oj_server相当于是一个在线oj平台的后端服务器,提供http服务,当用户获取列表,编写指定题目之后,将前端代码通过http请求发送给oj_server时,oj_server通过网络请求,负载均衡式地使用compile_server提供的编译运行服务,获取编译运行结果,再返回给用户的前端界面。

项目宏观结构

C++/Linux实战项目 - 负载均衡式在线OJ平台_第1张图片

 compile_server 编译与运行服务

总览分析

compile_server模块旨在实现出一个通过网络请求的方式,提供编译并运行服务的后端。在此项目中此功能用于服务oj_server编译并运行用户提交的oj代码。注意:此模块,仅提供编译运行功能,提交的代码是否通过测试用例,此模块不负责。

C++/Linux实战项目 - 负载均衡式在线OJ平台_第2张图片

 compiler.hpp提供编译服务,runner.hpp提供运行服务。compile_run.hpp整合下方两个功能,提供一个编译并运行功能的接口。而compile_server.cc通过网络请求,获取代码,调用compile_run.hpp提供的的接口。

这里有一个注意点:将来,oj_server模块,将用户提交的代码通过网络请求给compile_server,大体思路是:此模块创建临时文件,将代码写入文件中,也就成为了源文件。下方对源文件进行编译运行,此处文件名等并不重要,我们的目的是编译运行获取结果。那么,如何让compiler.hpp和runner.hpp对正确的源文件进行编译,和对正确的可执行程序进行运行呢?只要统一他们的文件名即可(不包含后缀)

compiler.hpp

#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include "./../common/util.hpp"
#include "../common/log.hpp"

// 只负责进行代码的编译

namespace ns_compiler
{
    using namespace ns_util;
    using namespace ns_log;

    class Compiler
    {
    public:
        // 返回值: 编译成功true,编译失败false
        // 输入参数: 编译的文件名(不带路径,不带后缀)
        static bool compile(const std::string &file_name)
        {
            // 程序替换为g++编译,成功则没有输出,可执行程序生成
            // 失败则g++会向标准错误中输出错误信息,即编译失败的原因
            pid_t pid = fork();
            if (pid < 0)
            {
                LOG(ERROR) << "内部错误,编译时创建子进程失败" << std::endl;
                return false;
            }
            else if (pid == 0)
            {
                // 子进程,进行程序替换g++,编译指定文件
                umask(0);
                int _compile_error_fd = open(PathUtil::CompileError(file_name).c_str(), O_CREAT | O_WRONLY, 0644);
                if (_compile_error_fd < 0)
                {
                    LOG(WARNING) << "内部错误, 编译时没有生成stderr文件" << std::endl;
                    exit(1); // 其实父进程不关心
                }
                // 程序替换,并不影响进程的文件描述符表
                dup2(_compile_error_fd, 2);
                execlp("g++", "g++", "-o", PathUtil::Exe(file_name).c_str(),
                       PathUtil::Src(file_name).c_str(), "-D", "COMPILE_ONLINE", "-std=c++11", nullptr);
                exit(2);  // 其实父进程不关心
            }
            else
            {
                // 父进程
                waitpid(pid, nullptr, 0); // 不关心子进程的退出结果,只关心是否编译成功(exe是否生成)
                // std::cout << "flag" << std::endl;
                if (FileUtil::IsFileExists(PathUtil::Exe(file_name)) == true)
                {
                    // 可执行程序已生成,编译成功
                    // std::cout << "flag 2" << std::endl;
                    LOG(INFO) << PathUtil::Src(file_name) << "编译成功!" << std::endl;
                    return true;
                }
            }
            // 可执行程序没有生成,g++错误信息已打印到CompileError文件中。
            return false;
        }
    };
}

Compiler类提供编译功能接口,对参数传来的指定的文件(不包含后缀,路径),进行编译。编译成功返回true,对应的可执行生成。失败返回false,编译错误原因在对应的CompileError文件中。

思路:要编译,肯定要程序替换为g++,思路是让子进程进行程序替换,替换为g++,父进程通过对应的可执行是否生成来判断是否编译成功。编译失败时,g++会向标准错误中输出错误信息,标准错误原本为显示器,现通过dup2系统调用,将其重定向至CompileError文件中,则若编译失败,失败原因会在对应的CompileError文件中保存。

注意: 在编译运行模块中,会出现很多临时文件,也就是对应某一次编译运行请求所生成的源文件,编译错误文件,可执行等等。此处的策略为,在compile_server编译运行模块下下创建一个temp目录,用于存储临时文件。

runner.hpp

#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include "../common/util.hpp"
#include "../common/log.hpp"

namespace ns_runner
{
    using namespace ns_util;
    using namespace ns_log;

    // 只负责运行编译好的可执行
    class Runner
    {
    public:
        /* 返回值 > 0,oj程序运行异常,收到了信号,返回值为信号编号
         * 返回值 = 0,oj程序运行成功,标准输出和标准错误信息在对应的文件中
         * 返回值 < 0,内部错误。如打开文件失败,创建子进程执行oj程序时失败。
         * cpu_limit: file_name程序运行时,可以使用的CPU资源上限(时间,秒)
         * mem_limit: file_name程序运行时,可以使用的内存资源上限(KB)
         */
        static int run(const std::string file_name, int cpu_limit, int mem_limit)
        {
            std::string _excute = PathUtil::Exe(file_name);
            std::string _stdin = PathUtil::Stdin(file_name);
            std::string _stdout = PathUtil::Stdout(file_name);
            std::string _stderror = PathUtil::Stderror(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);   // OJ程序的输出结果
            int _stderror_fd = open(_stderror.c_str(), O_CREAT | O_WRONLY, 0644); // OJ程序的运行时错误信息
            if (_stdin_fd < 0 || _stdout_fd < 0 || _stderror_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(_stderror_fd);
                return -2; // 代表创建子进程失败
            }
            else if (pid == 0)
            {
                // 子进程进行程序替换,执行可执行程序
                dup2(_stdin_fd, 0);
                dup2(_stdout_fd, 1);
                dup2(_stderror_fd, 2);
                SetProcLimit(cpu_limit, mem_limit);
                execl(_excute.c_str(), _excute.c_str(), nullptr);
                exit(1);
            }
            else
            {
                close(_stdin_fd);
                close(_stdout_fd);
                close(_stderror_fd);
                // 父进程获知程序的执行情况,仅关心成功执行or异常终止
                // 对于成功执行之后的执行结果并不关心,是上层的任务,需要根据测试用例判断
                int status = 0;
                waitpid(pid, &status, 0);
                LOG(INFO) << "OJ题运行完毕, 退出信号: " << (status & 0x7F) << std::endl;
                return status & 0x7F; // 将子进程的退出信号返回(并非退出码)
            }
        }
        // 设置进程占用资源大小的接口(CPU资源,内存资源)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_limit;
            mem_limit.rlim_max = RLIM_INFINITY;
            mem_limit.rlim_cur = _mem_limit * 1024; // 转化为KB
            setrlimit(RLIMIT_AS, &mem_limit);
        }
    };
}

参数:要执行的可执行程序的文件名(不包含路径,后缀),cpu限制,mem限制(OJ题一般会有时间空间限制)。程序执行结果,通过返回值进行判断,大于0,为程序异常终止(收到了信号),等于0,为程序运行成功,此时,标准输出和标准错误信息都在对应的文件中。小于0,为内部错误,如打开文件失败,创建子进程失败等等...

这里思路和compiler.hpp相似,让子进程进行程序替换,执行可执行(在temp目录下,也就是编译模块编译好的可执行),父进程通过waitpid,阻塞式等待子进程退出结果,通过信号判断其运行情况。若崩溃,则信号大于0,此时不考虑标准输出和标准错误。若等于0,则信号为0,表示程序运行成功,此时标准输出和标准错误在对应文件中。这里的关键其实还是,重定向,也就是子进程程序替换为可执行前要dup2,将标准输入输出错误进行重定向(此处不考虑输入),对应的临时文件在temp目录下。

compile_run.hpp

// 需定制通信协议
#pragma once
#include "../compile_server/compiler.hpp"
#include "../compile_server/runner.hpp"
#include "../common/log.hpp"
#include "../common/util.hpp"
#include "jsoncpp/json/json.h"

namespace ns_compile_run
{
    using namespace ns_compiler;
    using namespace ns_runner;
    using namespace ns_log;
    using namespace ns_util;

    class CompileAndRun
    {
    public:
        /***************************************
         * 应用层协议定制:
         * 输入json串:
         * code: 用户提交的OJ代码
         * input: 用户提交的代码对应的输入(不做处理)
         * cpu_limit: OJ程序的时间要求
         * mem_limit: OJ程序的空间要求
         *
         * 输出json串:
         * 必填:
         * status: 状态码
         * reason: 请求结果(状态码描述)
         * 选填:(当OJ程序编译且运行成功时)
         * stdout: OJ程序运行完的标准输出结果
         * stderr: OJ程序运行完的标准错误结果
         * (若编译且运行成功out_json中才会有stdout和stderr字段)
         * 参数:
         * in_json: {"code": "#include...", "input": "","cpu_limit":1, "mem_limit":10240}
         * out_json: {"status":"0", "reason":"","stdout":"","stderr":"",}
         * ************************************/
        static void execute(const std::string &in_json, std::string *out_json)
        {
            // 随便写写:输入的json串中有代码,输入,时间空间限制等等
            // 提取出代码,写入到源文件中。
            // 编译源文件,看编译结果
            // 若编译成功,则运行可执行
            // 若一切顺利则status状态码为0,对应的输出结果也写入
            // 若某一步出现了错误,则status设置为对应的数字
            // reason也写好

            // 对json串进行反序列化
            Json::Value in_value;
            Json::Reader reader;
            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 = 0; // 状态码,最终要写入到out_json中
            std::string file_name;
            int run_result = 0;
            if (code.empty())
            {
                status = -1; // 用户输入的OJ代码为空(非服务端问题)
                goto END;
            }
            file_name = FileUtil::UniqueFileName();
            // 形成临时源文件,代码为传来的code,这个文件名不重要,唯一即可
            // 我们的目的是编译并运行这份代码
            if (FileUtil::WriteFile(PathUtil::Src(file_name), code) == false)
            {
                status = -2; // 未知错误
                goto END;
            }
            if (Compiler::compile(file_name) == false)
            {
                status = -3; // 编译失败,跳过后面的运行
                goto END;
            }
            run_result = Runner::run(file_name, cpu_limit, mem_limit);
            if (run_result < 0)
            {
                status = -2; // 未知错误(不管run内部是打开文件失败还是创建子进程失败,统一称之为内部错误,即服务端错误)
            }
            else if (run_result > 0)
            {
                status = run_result; // 运行错误,程序崩溃,此时status为程序退出信号
            }
            else
            {
                status = 0; // 运行成功(且编译成功)
            }
        END:
            Json::Value out_value;
            out_value["status"] = status;                          // 状态码
            out_value["reason"] = StatusToDesc(status, file_name); // 状态码描述
            if (status == 0)
            {
                // 只有当编译且运行成功时,才有stdout stderr字段
                std::string _stdout;
                FileUtil::ReadFile(PathUtil::Stdout(file_name), &_stdout, true); // 读取OJ程序的标准输出结果
                out_value["stdout"] = _stdout;
                std::string _stderr;
                FileUtil::ReadFile(PathUtil::Stderror(file_name), &_stderr, true); // 读取OJ程序的标准错误结果
                out_value["stderr"] = _stderr;
            }
            Json::StyledWriter writer;
            *out_json = writer.write(out_value); // 将out_value进行序列化
            // RemoveTempFile(file_name);
        }
        static std::string StatusToDesc(int status, const std::string &file_name)
        {
            // 将状态码转化为状态码描述
            std::string desc;
            switch (status)
            {
            case 0:
                desc = "编译且运行成功";
                break;
            case -1:
                desc = "用户输入的代码为空";
                break;
            case -2:
                desc = "未知错误"; // 服务端错误,太羞耻了
                break;
            case -3:
                // 代码编译时发生错误,返回编译错误原因
                FileUtil::ReadFile(PathUtil::CompileError(file_name), &desc, true);
                break;
            case SIGABRT: // 6
                desc = "程序占用内存资源超限";
                break;
            case SIGXCPU: // 24
                desc = "程序占用CPU资源超限";
                break;
            case SIGFPE: // 8
                desc = "浮点数溢出";
                break;
            default:
                desc = "未知: " + std::to_string(status);
                break;
            }
            return desc;
        }
        static void RemoveTempFile(const std::string &file_name)
        {
            // 有哪些文件生成不确定,但是文件名是有的,一共6个可能生成的临时文件,逐一检查。
            if (FileUtil::IsFileExists(PathUtil::Src(file_name)) == true)
                unlink(PathUtil::Src(file_name).c_str());

            if (FileUtil::IsFileExists(PathUtil::CompileError(file_name)) == true)
                unlink(PathUtil::CompileError(file_name).c_str());

            if (FileUtil::IsFileExists(PathUtil::Exe(file_name)) == true)
                unlink(PathUtil::Exe(file_name).c_str());

            if (FileUtil::IsFileExists(PathUtil::Stdin(file_name)) == true)
                unlink(PathUtil::Stdin(file_name).c_str());

            if (FileUtil::IsFileExists(PathUtil::Stdout(file_name)) == true)
                unlink(PathUtil::Stdout(file_name).c_str());

            if (FileUtil::IsFileExists(PathUtil::Stderror(file_name)) == true)
                unlink(PathUtil::Stderror(file_name).c_str());
        }
    };
}

整合编译和运行功能,提供一个编译并运行的接口。注意,此接口直接被compile_server调用,compile_server提供网络服务,应用层协议为http协议。传来一个json串,返回一个json串。而json串的内容就属于这里需要定制的应用层协议的一部分。

传来的json中包含code(需要编译运行的源代码),input(仅象征性地设置一个这个字段,不考虑,也就是未来的OJ题没有输入),cpu_limit,mem_limit(回顾runner的接口参数)。注意,这里传来code,但是没有源文件,我们需要生成唯一的文件名,根据文件名生成源文件,将code写入。后面的编译错误,可执行,标准输出错误等文件的文件名都是这里设定的,文件名随意,唯一即可。

返回的json中包含状态码,状态码描述。可能包含标准输出和标准错误(当编译且运行成功时有这两个字段)

通过调用编译和运行模块的接口,最终,通过合理的设计,若状态码>0,则运行时错误,状态码为对应的信号。状态码=0,则编译且运行成功,此时需要读取标准输出和标准错误文件,添加到对应的out_json的字段中。状态码<0,对应各种情况,当=-3时,为编译失败,则编译错误原因在对应CompileError文件中,此时没有可执行生成,不会执行运行模块方法。添加了一个StatusToDesc方法,也就是状态码转状态码描述。若编译失败,状态码为-3,则状态码描述为编译失败的原因。


整体的设计非常巧妙,通过不同的返回值得到编译或者运行结果。而整体,编译运行服务只接收代码(当然还包含其他字段,如cpu_limit, mem_limit),一次编译运行的所有的临时文件的文件名是统一且唯一的。我们的目的是为了获取编译运行结果。通过json串返回回去。

compile_server.cc

#include "compile_run.hpp"
#include "../common/httplib.h"

using namespace ns_compile_run;
using namespace httplib;

static void Usage(const std::string &proc)
{
    std::cout << "\nUsage: " << proc << " port\n" << std::endl;
}

// ./compile_server post
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
    }
    Server svr;
    // 这里...接收一个req,返回一个resp?不太懂说实话,不过暂时来看不重要。
    // 感觉...就是cpp-httplib的接口使用方法可能
    // 网页,客户端访问/hello时就会有对应数据返回。
    // svr.Get("/hello", [](const Request &req, Response &resp){
    //     // 设置响应正文?或许
    //     resp.set_content("hello httplib, 你好 httplib!", "text/plain;charset=utf-8");
    // });

    // 说实话,有关这里post,get的相关http内容,有印象,但是记不太清了,好像是请求和响应的方法?
    // 我记得是,客户端也就是浏览器请求时可以是post/get方法,区别是账号密码的传输方式。一种是在正文中,一种是在url中。
    // 而http服务端响应时,一般都是post方法?
    // 那下面的req和resp这两个参数如何理解呢?
    svr.Post("/compile_and_run", [](const Request &req, Response &resp)
    {
        // 用户请求的服务正文正是我们想要的json string
        std::string in_json = req.body;
        std::string out_json;
        if(!in_json.empty())
        {
            CompileAndRun::execute(in_json, &out_json);
            resp.set_content(out_json, "application/json;charset=utf-8");
        }
    });
    
    // 不需要首页信息,不是一个对外网站。上面只是用来测试,介绍如何使用httplib库
    // svr.set_base_dir("./wwwroot");

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

使用了第三方httplib库,方便生成一个http服务器。通过http请求获取json串,调用compile_run.hpp的编译运行接口,获取返回的json串。再通过http响应返回回去,也就是未来的oj_server端。(http的相关知识有点遗忘了....)

C++/Linux实战项目 - 负载均衡式在线OJ平台_第3张图片

 上方为编译运行模块的文件组成。temp即存储临时文件的文件夹。

oj_server 基于MVC结构的oj服务设计

总览分析

这里的本质就是建立一个小型的网站,采用的结构是MVC结构。之前的compile_server其实只是提供一个基于网络的编译运行服务。而这里的oj_server才是oj平台的搭建。

这个oj平台,也就是oj_server这里主要提供两(三)个功能
1. 获取题目列表(可以通过点击,跳转至指定题目的在线oj界面)
2. 获取特定题目的在线OJ页面(包含这个题目的描述)此处包含在线编辑区域,提交判题功能(使用compile_server)
3. 一个非常简单的首页...没什么意思其实。可以跳转至题目列表界面

M: Model,通常是和数据交互的模块,比如,对题库进行增删改查(文件版,MySQL版)

V: view, 通常是拿到数据之后,要进行构建网页,渲染网页内容,展示给用户的(浏览器)(此处的数据其实就是Model模块提供的)

C: control, 控制器,就是我们的核心业务逻辑。其实就是提供一些接口,这些接口的实现需要综合Model和View模块。

(MVC结构,第一次听或许觉得有点难以理解,但其实学完之后发现,也就那样...)

Model 提供对数据的操作方法

在一个OJ平台中,数据当然就是一个题库了。由于我还没学MySQL,所以此处只能实现文件版的题库,也就是在oj_server模块下,创建一个questions文件夹,里面存储一些题目数据。

C++/Linux实战项目 - 负载均衡式在线OJ平台_第4张图片

C++/Linux实战项目 - 负载均衡式在线OJ平台_第5张图片

如上,questions文件夹为对应的题库,一个题目包含:题目编号,题目标题,题目难度,cpu_limit,mem_limit,题面描述,给用户设置的此题的预置代码,测试用例。

前面5个题目数据在questions/questions.list文件中,一行为一个题目。而后面三个较长,存储在同目录下的与题目编号同名的文件夹中。

oj_model.hpp

#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include "../common/log.hpp"
#include "../common/util.hpp"

// 根据题目list文件,加载所有的题目信息到内存中(unordered_map)
// model: 主要用来和数据进行交互,对外提供访问数据的接口

namespace ns_model
{
    using namespace ns_log;
    using namespace ns_util;

    class Question
    {
    public:
        std::string number; // 题目编号
        std::string title;  // 题目标题
        std::string star;   // 题目难度
        int cpu_limit;      // 题目的时间要求(s)
        int mem_limit;      // 题目的空间要求(KB)
        std::string desc;   // 题目描述
        std::string header; // 内置代码
        std::string tail;   // 测试用例,和header拼接,形成完整可编译执行的代码
    };

    const std::string g_questions_list = "./questions/questions.list"; // questions_list文件的路径
    const std::string g_questions_path = "./questions/";

    class Model
    {
    private:
        std::unordered_map questions;
    public:
        Model()
        {
            assert(LoadAllQuestions(g_questions_list));
        }
        ~Model() {}
        bool LoadAllQuestions(const std::string &questions_list)
        {
            // 加载所有的题目数据到内存中(unordered_map)
            std::ifstream in(questions_list);
            if(!in.is_open())
            {
                LOG(FATAL) << "加载题库失败,请检查是否存在题目列表文件" << "\n";
                return false;
            }

            std::string line;
            while(std::getline(in, line))
            {
                // 获取到了一行数据
                // 1 两数之和 简单 1 30000
                std::vector tokens;
                StringUtil::SplitString(line, &tokens, " ");
                if(tokens.size() != 5)
                {
                    // 这行数据无效
                    LOG(WARNING) << "加载部分题目失败,请检查文件格式" << "\n";
                    continue; // 继续加载下一个题目
                }
                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());
                FileUtil::ReadFile(g_questions_path + tokens[0] + "/desc.txt", &q.desc, true);
                FileUtil::ReadFile(g_questions_path + tokens[0] + "/header.cpp", &q.header, true);
                FileUtil::ReadFile(g_questions_path + tokens[0] + "/tail.cpp", &q.tail, true);
                questions.insert({q.number, q});
            }
            LOG(INFO) << "加载题库成功!" << "\n";
            in.close();
            return true;
        }
        // 提供获取所有题目数据和获取指定题目数据的接口
        bool GetAllQuestions(std::vector *out)
        {
            if(questions.size() == 0)
            {
                LOG(ERROR) << "用户获取题库失败" << "\n";
                return false;
            }
            for(const auto &iter : questions)
            {
                out->push_back(iter.second); // string : Question
            }
            return true;
        }
        bool GetOneQuestion(const std::string &number, Question *out)
        {
            if(questions.find(number) == questions.end())
            {
                LOG(ERROR) << "用户获取指定题目失败,题目编号: " << number << "\n";
                return false;
            }
            *out = questions[number];
            return true;
        }
    };
} // namespace ns_model

对应文件版题库的结构,这个Model类,在构造时,先逐行读取questions.list文件。再对一行进行字符串切分,获取到5个元素,第一个元素为题号,再读取questions/目录下的对应题号文件夹,读取其中的题面描述,预置代码和测试用例。构造出一个Question对象。再按照 题目编号 : Question的key value映射结构,构造出一个unordered_map。

这个Model模块只提供两个对题库的读取方法,一个是获取全部题目数据,一个是根据题号,获取指定题目的数据。其实就是对应了这个oj平台的两个界面需求(题目列表界面和指定题目的oj界面)。  这里没有提供对题库的增删方法,对题目的增删都在questions目录下手动进行...

View 使用后端数据对前端页面进行渲染,获取渲染之后的html

这块使用了第三方开源的ctemplate渲染库,这个库...是前后端交互的一个库,第一次接触前端,也是第一次接触这个库...有点陌生(实际不是一点)。

其实吧,前端就是一个html网页,它里面用的是前端语法,我们不管。但是,前端显示的有些数据其实是在后端保存着的。我们如何把后端数据显示到前端html网页中呢?在这个项目中用的就是ctemplate渲染库..

oj_view.hpp

#pragma once
#include 
#include 
#include "./oj_model.hpp"
#include 

namespace ns_view
{
    using namespace ns_model;

    const std::string template_path = "./template_html/";

    class View
    {
    public:
        void AllExpandHtml(const std::vector &questions, std::string *html)
        {
            // 利用题目列表数据questions,进行网页渲染形成html
            // html模板为template_html目录下的all_questions.html

            // 题目的编号 题目的标题 题目的难度
            // 推荐使用表格显示
            // 1. 形成路径,即将进行渲染的html路径
            std::string src_html = template_path + "all_questions.html";
            // 2. 形成数字典
            ctemplate::TemplateDictionary root("all_questions");
            for (const auto &q : questions)
            {
                // 对应html中的{{#question_list}}
                ctemplate::TemplateDictionary *sub = root.AddSectionDictionary("question_list");
                sub->SetValue("number", q.number);
                sub->SetValue("title", q.title);
                sub->SetValue("star", q.star);
            }

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

            // 4. 开始完成渲染功能
            tpl->Expand(html, &root);
        }
        void OneExpandHtml(const Question &q, std::string *html)
        {
            // 利用指定题目的数据,进行网页渲染形成html
            // 网页模板为template_html目录下的one_question.html

            // 1. 形成路径
            std::string src_html = template_path + "one_question.html";

            // 2. 形成数字典
            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);

            //3. 获取被渲染的html
            ctemplate::Template *tpl = ctemplate::Template::GetTemplate(src_html, ctemplate::DO_NOT_STRIP);
           
            //4. 开始完成渲染功能
            tpl->Expand(html, &root);
        }
    };
}

// 我自己写的废话: View里面就是,获取数据然后主要任务是渲染前端界面。

在template_html目录下,有两个html,其实就是题目列表页面和指定题目页面。我们View模块也提供两个方法,第一个:使用Model模块传来的全部题目数据,渲染一个显示题目列表的页面。第二个std::string *html参数是一个输出型参数,也就是对应的渲染之后的html。第二个:使用从Model模块获取的指定题目的数据,渲染一个显示指定题目的页面。第二个参数也是输出型参数,对应渲染之后的html网页。   那么,这两个方法的两个输出型参数,输出一个html,其实就是在control模块中需要获取的,根本上也是oj_server.cc需要获取并通过http响应返回给客户端的(浏览器,解析html,显示出来)

对于ctemplate库的使用方法,也就是渲染前端html网页的方法..我也不懂,看看代码浅了解一下吧

Control 逻辑控制模块,oj_server.cc直接调用Control提供的方法

#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include "./oj_model.hpp"
#include "./oj_view.hpp"
#include "../common/httplib.h"

namespace ns_control
{
    using namespace ns_model;
    using namespace ns_view;
    using namespace httplib;

    // 为什么要加锁??????aaaa
    class Machine
    {
    public:
        std::string ip_;
        int port_;
        uint64_t load_;    // 该机器的负载
        std::mutex *mtx_;  // std::mutex不可被拷贝,所以Machine将不能被拷贝,故存储指针,使Machine可以被拷贝(存入容器中)
    public:
        Machine()
        : ip_(), port_(0), load_(0), mtx_(nullptr)
        {}
        ~Machine()
        {
            // if(mtx_ != nullptr)
            //     delete mtx_;
        }
        void IncLoad()
        {
            mtx_->lock();
            ++load_;
            mtx_->unlock();
        }
        void DecLoad()
        {
            mtx_->lock();
            --load_;
            mtx_->unlock();
        }
        void ResetLoad()
        {
            mtx_->lock();
            load_ = 0;
            mtx_->unlock();
        }
        uint64_t Load()
        {
            mtx_->lock();
            uint64_t load = load_;
            mtx_->unlock();
            return load;
        }
    };
    
    const std::string g_file_path = "./conf/service_machine.conf";

    // 负载均衡模块
    class LoadBalance
    {
    private:
        std::vector machines_;   // 所有的机器
        std::vector online_;         // 在线机器,存储所有在线机器的下标
        std::vector offline_;        // 离线机器,存储所有离线机器的下标
        std::mutex mtx_;
    public:
        LoadBalance()
        {
            assert(LoadAllMachines(g_file_path));
            LOG(INFO) << " 加载所有编译运行服务器成功!" << "\n";
        }
        ~LoadBalance() {}
        bool LoadAllMachines(const std::string& file_path)
        {
            // 将配置文件中的所有后端机器加载到machines和online中
            std::ifstream in(file_path);
            if(!in.is_open())
            {
                LOG(FATAL) << "加载 " << file_path << " 失败" << "\n";
                return false;
            }
            // std::cout << "debug 1" << std::endl;
            std::string line;
            while(std::getline(in, line))
            {
                // 127.0.0.1:8081
                
                std::vector tokens;
                StringUtil::SplitString(line, &tokens, ":");
                if(tokens.size() != 2)
                {
                    LOG(WARNING) << "切分" << line << "失败" << "\n";
                    continue;
                }

                // for(auto &i:tokens)
                // {
                //     std::cout << i << std::endl;
                // }
                Machine m;
                m.ip_ = tokens[0];
                m.port_ = atoi(tokens[1].c_str());
                m.load_ = 0;
                m.mtx_ = new std::mutex();

                online_.push_back(machines_.size());
                machines_.push_back(m);
            }

            in.close();

            return true;
        }
        bool SmartChoice(int *id, Machine **m)
        {
            // 进行智能选择
            mtx_.lock();
            int online_num = online_.size();
            if(0 == online_num)
            {
                mtx_.unlock();
                LOG(FATAL) << "所有的后端编译服务器都已离线,请运维同事尽快处理" << "\n";
                return false;
            }
            // 至少有一台可以提供编译服务的服务器
            *id = online_[0];
            for(int i = 1; i < online_num; ++i)
            {
                if(machines_[online_[i]].load_ < machines_[*id].load_)
                {
                    *id = online_[i];
                }
            }
            *m = &machines_[*id];  // 获取一个一级指针,传一个二级指针
            mtx_.unlock();
            return true;
        }
        void OfflineMachine(int id)
        {
            // 离线主机
            mtx_.lock();
            for(auto iter = online_.begin(); iter != online_.end(); ++iter)
            {
                if(*iter == id)
                {
                    machines_[id].ResetLoad();
                    offline_.push_back(id);
                    online_.erase(iter);
                    break;   // 因为break,所以此处不会出现vector的迭代器失效问题。
                }
            }
            // offline_.push_back(id);   // 有问题,不能在这里。
            mtx_.unlock();
        }
        void OnlineMachine()
        {
            mtx_.lock();
            online_.insert(online_.begin(), offline_.begin(), offline_.end());
            offline_.erase(offline_.begin(), offline_.end());
            mtx_.unlock();
            LOG(INFO) << "所有的主机已上线" << "\n";
        }
        // 用于调试
        void ShowMachines()
        {
            mtx_.lock();
            std::cout << "当前在线主机id列表 : ";
            for(auto &i :online_)
                std::cout << i << " ";
            std::cout << std::endl;
            std::cout << "当前离线主机id列表 : ";
            for(auto &i : offline_)
                std::cout << i << " ";
            std::cout << std::endl;
            mtx_.unlock();
        }
    };

    class Control
    {
    private:
        Model model_;
        View view_;
        LoadBalance loadbalance_;
    public:
        bool AllQuestions(std::string *html)
        {
            bool ret = true;
            std::vector questions;
            if(model_.GetAllQuestions(&questions))
            {
                // std::cout << "debug : " << questions[0].title << std::endl;
                // 获取题目列表数据成功,将所有的题目数据渲染构建成网页
                view_.AllExpandHtml(questions, html);
            }
            else
            {
                *html = "获取题目列表失败";
                ret = false;
            }
            return ret;
        }
        bool OneQuestion(const std::string &number, std::string *html)
        {
            bool ret = true;
            Question q;
            if(model_.GetOneQuestion(number, &q))
            {
                // 获取指定题目信息成功, 将题目的所有数据渲染构建成网页
                view_.OneExpandHtml(q, html);
            }
            else
            {
                *html = "获取指定题目失败,题目编号: " + number;
                ret = false;
            }
            return ret;
        }
        void Judge(const std::string &number, const std::string in_json, std::string *out_json)
        {
            // 1. 获取题目信息
            Question q;
            model_.GetOneQuestion(number, &q);

            // 2. 反序列化,从in_json中获取到code和input
            Json::Value in_value;
            Json::Reader reader;
            reader.parse(in_json, in_value);   // 反序列化
            std::string code = in_value["code"].asString();
            std::string input = in_value["input"].asString();

            // 3. 构造给编译模块的json串
            Json::Value compile_value;
            compile_value["code"] = code + q.tail;   // 用户提交的代码 + 测试用例 = 最终进行编译运行的代码
            compile_value["input"] = input;
            compile_value["cpu_limit"] = q.cpu_limit;
            compile_value["mem_limit"] = q.mem_limit;
            Json::FastWriter writer;
            std::string compile_json = writer.write(compile_value);

            // 4. 负载均衡地选择一个后端提供编译运行服务的服务器,获取编译服务,获取编译运行结果传给out_json
            while(true)
            {
                int id = 0;
                Machine *m = nullptr;
                if(!loadbalance_.SmartChoice(&id, &m))
                {
                    // 选取编译服务器失败,即所有的后端编译服务器都已离线
                    break;
                }
                // 选取到了一个编译服务器
                Client cli(m->ip_, m->port_);
                m->IncLoad();   // 增加对应编译服务器负载
                LOG(ERROR) << " 选择编译主机成功,主机id : " << id << " 详情 : " << m->ip_ << ":" << m->port_ << " 当前主机负载为 : " << m->Load() << "\n";
                if(auto res = cli.Post("/compile_and_run", compile_json, "application/json;charset=utf-8"))
                {
                    if(res->status == 200)
                    {
                        // 请求成功,且状态码为200
                        m->DecLoad();
                        *out_json = res->body;  // 编译运行的结果
                        LOG(INFO) << "请求编译和运行服务成功" << "\n";
                        break;
                    }
                    // 请求成功,但是退出码不为200,故需要再次选择编译服务器&&再次请求
                    m->DecLoad();
                }
                else
                {
                    // 请求失败,直接把该主机挂掉即可
                    LOG(ERROR) << " 当前请求的主机 : " << id << " 详情 : " << m->ip_ << ":" << m->port_ << " 可能已离线" << "\n";
                    loadbalance_.OfflineMachine(id);
                    loadbalance_.ShowMachines();   // 仅仅为了调试
                }
            }
        }
        void RecoveryMachine()
        {
            loadbalance_.OnlineMachine();
        }
    };
} // namespace ns_control

// 我自己写的废话:这里是和server.cc直接交互的模块,提供一些方法。

综上,后端数据从Model中获取,渲染出的html从View中获取。也就是题目列表界面和指定题目界面我们都有了,对应的也就是Control类中的AllQuestions和OneQuestion方法。将来直接由主函数调用。

但是,用户需要提交代码,oj_server.cc获取到http请求之后需要判题(客户端浏览器点击提交按钮),也就是需要通过网络提交给compile_server进行编译运行。且这个OJ平台,是负载均衡式的,也就是可以在多台机器上部署compile_server服务,这样,当OJ平台有大量的判题请求时,oj_server可以选择当前负载最低的编译运行服务器。所以我们需要在Control中设计负载均衡式的Judge方法,用于判题。

如上,Machine表示每一台编译运行服务器,LoadBlance有加载后端服务器的方法(所有编译服务器的ip和port在配置文件中),智能选取负载最低服务器的方法,离线主机,上线主机方法。而Control的Judge方法中就是主函数直接调用的判题方法,传来题号,以及一个json(包含代码,input),而题目的cpu_limit mem_limit在题目数据中。Judge构造好给compile_server的json串,就通过LoadBlance获取负载最低的主机,发起请求,获取到返回的json串(回顾Compile_server模块,其实就是:状态码,状态码描述,若状态码为0即编译运行成功,则有stdout和stderr),通过输出型参数返回回去。

以上就是Control的全部功能,其实主要就是三个,AllQuestions OneQuestion Judge,对应这个OJ网站的三个需求。


这里有一个疑惑是:这里的Machine和LoadBalance都有加锁,但是我不太理解为什么要加锁,Machine的IncLoad和DecLoad在我看来好像不可能多线程执行吧... 还有LoadBalance的SmartChoice和OfflineMachine,这里的锁分别保护load_和online_ offline_。

解答:为什么要加锁,首先我们这里是通过第三方网络库,即httplib库获取的http请求,而这个网络库底层是采用的多线程策略,所以我们的judge其实是可能被多线程同时执行的,则就存在多线程并发访问问题,故需要加锁。第二点是:我们这里的LoadBalance和Machine是一个公共模块,他后面可能被多种场景调用,比如多线程场景,所以,虽然我们的judge这里没有设计多线程,但是为了提高代码的健壮性,多场景的适应能力,进行加锁保护。

oj_server.cc

#include 
#include 
#include 
#include "../common/httplib.h"
#include "./oj_control.hpp"

using namespace httplib;
using namespace ns_control;

Control *ctrl_ptr = nullptr;

void Recovery(int signo)
{
    ctrl_ptr->RecoveryMachine();
}

int main()
{
    // 用户请求的服务路由功能
    Server svr;
    Control ctrl;
    ctrl_ptr = &ctrl;
    // std::cout << "hhhhh1" << std::endl;
    signal(SIGQUIT, Recovery);

    // 功能1: 获取所有的题目列表(严格来说,这里需要返回的是所有题目列表构成的一个html网页)
    // 指的是访问这个all_questions资源时,会进行如下响应
    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");
    });
    // std::cout << "hhhhh2" << std::endl;

    // 功能2: 用户根据题目编号,获取指定的题目内容(某指定题目的内容所组成的一个html网页)
    // /question/100 -> 正则匹配(不懂)  ?????????
    // R"()", 原始字符串raw string, 保持字符串内容的原貌, 不用做相关的转义
    svr.Get(R"(/question/(\d+))", [&ctrl](const Request &req, Response &resp){
        std::string number = req.matches[1];  // ???????
        std::string html;
        ctrl.OneQuestion(number, &html);
        resp.set_content(html, "text/html; charset=utf-8");
    });
    // std::cout << "hhhhh3" << std::endl;

    // 用户提交代码,使用我们的判题功能
    svr.Post(R"(/judge/(\d+))", [&ctrl](const Request &req, Response &resp){
        std::string number = req.matches[1];  // ???????(题目编号)
        std::string result_json;  // 编译运行结果,也就是OJ程序的测试结果,非html
        ctrl.Judge(number, req.body, &result_json);
        resp.set_content(result_json, "application/json;charset=utf-8");
        // std::cout << "out_json : " << result_json << std::endl;
        // resp.set_content("指定题目的判题功能: " + number, "text/plain; charset=utf-8");
    });
    // std::cout << "hhhhh4" << std::endl;
    svr.set_base_dir("./wwwroot");  // 首页设置,也就是访问127.0.0.1:8080时,要获取的资源
    svr.listen("0.0.0.0", 8080);  // 端口号直接固定,因为不像编译服务一样要部署到多台机器上
    return 0;
}

就是利用httplib构建一个http服务器,有首页,有题目列表网页,有特定题目oj网页,还有一个判题功能。就是基于http请求和http响应,构造一个网站。使用的方法也是Control提供的(C使用MV的接口,即MVC)

前端

index.html








    
    
    
    这是我的个人OJ系统
    



    

欢迎来到我的OnlineJudge平台

这个我个人独立开发的一个在线OJ平台

点击我开始编程啦!

all_question.html








    
    
    
    在线OJ-题目列表
    



    

OnlineJuge题目列表

{{#question_list}} {{/question_list}}
编号 标题 难度
{{number}} {{title}} {{star}}

one_question.html 








    
    
    
    {{number}}.{{title}}
    
    
    
    
    
    
    
    
    
    

    



    

{{number}}.{{title}}_{{star}}

{{desc}}

这里的前端包含,首页的html编写,all_questions.html和one_question.html。也就是View需要渲染的两个网页。其实这三个(可以忽略首页)html的编写都是前端范畴,不需要学习。但是有一个需要学习和了解的是:前端如何将编辑器的代码获取到,然后构造出json(包含code和input字段),通过http请求访问oj_server的/Judge/x资源。又是如何获取到http响应,将其中的json解析出(包含status reason stdout stderr字段)(若status == 0,则有stdout stderr字段),然后打印到前端页面中的。

其实就是上方one_question.html的最后的两个function,也属于前端范畴,但是了解前后端是如何交互的还是有必要的。其实,就是通过前端的一些语法获取到一些数据,然后发起http请求和http响应。达到前后端互动。


结尾

C++/Linux实战项目 - 负载均衡式在线OJ平台_第6张图片

这个项目中还有一个common模块,其实就是提供一些工具类,工具方法,例如在compile_server模块中经常用到的路径和后缀的拼接,还有字符串切割,还有形成唯一文件名,以及httplib.h,这块代码就不附上了,感觉意义不是太大。 

补充一个点:我们为什么要把编译运行部分,单独设计为一个独立的模块呢?为什么设计为编译运行模块在多台机器执行,然后等待oj_server服务端的编译请求呢?

1. 因为编译运行模块,需要创建临时文件,读写文件,调用编译器编译源文件,执行可执行程序等。这部分是比较耗时的,因此,进行多台主机部署,这样利用多主机的性能,完成比较耗时的编译运行模块。

2. 编译运行模块需要执行用户提交的代码,这需要一定的安全保障。

你可能感兴趣的:(c++,开发语言,linux,负载均衡,服务器)