【C++ 项目】负载均衡在线 OJ

文章目录

  • [🌈 一、项目介绍](#🌈 一、项目介绍)
  • [🌈 二、项目源码](#🌈 二、项目源码)
  • [🌈 三、项目演示](#🌈 三、项目演示)
    • [⭐ 1. 前端界面展示](#⭐ 1. 前端界面展示)
    • [⭐ 2. 后端界面展示](#⭐ 2. 后端界面展示)
  • [🌈 四、项目准备](#🌈 四、项目准备)
    • [⭐ 1. 项目所用技术](#⭐ 1. 项目所用技术)
    • [⭐ 2. 项目开发环境](#⭐ 2. 项目开发环境)
    • [⭐ 3. 项目宏观结构](#⭐ 3. 项目宏观结构)
  • [🌈 五、comm 公共模块](#🌈 五、comm 公共模块)
    • [⭐ 1. util.hpp 工具](#⭐ 1. util.hpp 工具)
    • [⭐ 2. log.hpp 日志](#⭐ 2. log.hpp 日志)
  • [🌈 六、compile_server 编译与运行模块](#🌈 六、compile_server 编译与运行模块)
    • [⭐ 1. compiler.hpp 编译服务设计](#⭐ 1. compiler.hpp 编译服务设计)
    • [⭐ 2. runner.hpp 运行服务设计](#⭐ 2. runner.hpp 运行服务设计)
    • [⭐ 3. compile_run.hpp 整合编译和运行服务](#⭐ 3. compile_run.hpp 整合编译和运行服务)
    • [⭐ 4. compile_server.cpp 对外提供编译和运行服务](#⭐ 4. compile_server.cpp 对外提供编译和运行服务)
  • [🌈 七、oj_server 用户交互模块](#🌈 七、oj_server 用户交互模块)
    • [⭐ 1. oj_server.cpp 网络路由功能](#⭐ 1. oj_server.cpp 网络路由功能)
    • [⭐ 2. MySQL 题库设计](#⭐ 2. MySQL 题库设计)
    • [⭐ 3. oj_model.hpp 数据交互模块](#⭐ 3. oj_model.hpp 数据交互模块)
    • [⭐ 4. oj_view 网页构建模块](#⭐ 4. oj_view 网页构建模块)
    • [⭐ 5. oj_control.hpp 控制器模块](#⭐ 5. oj_control.hpp 控制器模块)
  • [🌈 八、顶层 makefile 实现](#🌈 八、顶层 makefile 实现)
  • [🌈 九、项目补充](#🌈 九、项目补充)
    • [⭐ 1. 安装并测试 jsoncpp](#⭐ 1. 安装并测试 jsoncpp)
    • [⭐ 2. 安装并测试 cpp-httplib](#⭐ 2. 安装并测试 cpp-httplib)
    • [⭐ 3. 安装并测试 boost](#⭐ 3. 安装并测试 boost)
    • [⭐ 4. 安装并测试 ctemplate](#⭐ 4. 安装并测试 ctemplate)

🌈 一、项目介绍

  • 本项目主要实现的是类似于 leetcode 的题目列表 + 在线编程功能。
  • 该项目采用负载均衡算法 (轮询检测) 使得多个服务器协同处理大量的提交请求和编译请求。
  • 本项目分为文件版和 MySQL 版,本文只演示 MySQL 版本的代码。
    • 文件版:使用文件存储题目信息。
    • MySQL 版:使用数据库存储题目信息。

🌈 二、项目源码

🌈 三、项目演示

⭐ 1. 前端界面展示

1. 首页展示

  • 由于个人不擅长前端,因此首页的制作比较挫,但该展示的还是展示出来了。

2. 题目列表展示

  • 由于录题是个重复性很高的大工程,因此没有花费较多精力在这上面,只录了 2 题作为基本功能展示。


3. 指定题目展示

  1. 用户提交的代码的结果正确展示。
  1. 用户提交的代码的结果错误展示。
  1. 用户提交的代码发生编译时报错展示。
  1. 用户提交的代码运行超时。
  1. 用户的代码超出内存限制。

⭐ 2. 后端界面展示

  • 用户在提交代码后,oj_server 会负载均衡的向多台主机请求提供编译与运行服务,并将结果返回给用户。
  • 如果有哪个服务器挂掉了,也会自动将对应的主机离线。
  • 如果所有的服务器都挂掉了的话,再将服务器重新启动之后,也能够将这批服务器重新上线。

🌈 四、项目准备

⭐ 1. 项目所用技术

  • C++ STL 标准库。
  • Boost 准标准库 (字符串切割)。
  • cpp-httplib 第三方开源网络库。
  • ctemplate 第三方开源前端网页渲染库。
  • jsoncpp 第三方开源序列化、反序列化库。
  • 负载均衡设计。
  • 多进程、多线程。
  • Ace 前端在线编辑器
  • MySQL C connect。
  • html / css / js / jquery / ajax。

⭐ 2. 项目开发环境

  • Ubuntu 22.04 云服务器
  • vscode

⭐ 3. 项目宏观结构

1. 项目的核心模块

  1. comm:公共模块。
  2. compile_server:编译与运行模块。
  3. oj_server:获取题目列表,查看题目编写题目界面,负载均衡的选择后台的编译服务,其他功能。
  • compile_server 和 oj_server 会采用网络套接字的方式进行通信,这样就能将编译模块部署在服务器后端的多台主机上。
  • 而 oj_server 只有一台,这样子就会负载均衡的选择后台的编译服务。

2. 项目的宏观结构

🌈 五、comm 公共模块

  • 该模块主要为所有模块提供文件操作、字符串处理、网络请求、打印日志等公共功能。

模块结构

  • 其中的 httplib.h 文件是第三方开源网络库 cpp-httplib 所提供的,因此之后不展示其代码。
文件名 功能
httplib.h 提供网络服务
util.hpp 提供各种工具类
log.hpp 提供日志打印功能

⭐ 1. util.hpp 工具

  • 该模块主要提供的工具类及其说明如下:
类名 说明 提供的功能
time_util 时间工具 获取 秒 级时间戳
time_util 时间工具 获取 毫秒 级时间戳
path_util 路径工具 根据文件名和路径构建 .cpp 后缀的文件完整名
path_util 路径工具 根据文件名和路径构建 .exe 后缀的完整文件名
path_util 路径工具 根据文件名和路径构建 .compile_error 后缀的完整文件名
path_util 路径工具 根据文件名和路径构建 .stdin 后缀的完整文件名
path_util 路径工具 根据文件名和路径构建 .stdout 后缀的完整文件名
path_util 路径工具 根据文件名和路径构建 .stderr 后缀的完整文件名
file_util 文件工具 判断指定文件是否存在
file_util 文件工具 用 毫秒级时间戳 + 原子性递增的唯一值 形成一个具有唯一性的文件名
file_util 文件工具 将用户代码写到唯一的目标文件中, 形成临时的 .cpp 源文件
file_util 文件工具 读取目标文件中的所有内容
string_util 字符串工具 根据指定的分隔符切割字符串,并将切分出的子串用数组存储返回
cpp 复制代码
#pragma once

#include <atomic>
#include <vector>
#include <string>
#include <fstream>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <boost/algorithm/string.hpp>

using std::atomic_uint;
using std::getline;
using std::ifstream;
using std::ofstream;
using std::string;
using std::to_string;
using std::vector;

namespace ns_util
{
    const string temp_path = "./temp/"; // 临时目录的路径

    // 时间工具
    class time_util
    {
    public:
        // 获取 秒 级时间戳
        static string get_time_stamp()
        {
            struct timeval _time;
            gettimeofday(&_time, nullptr);
            return to_string(_time.tv_sec);
        }

        // 获取 毫秒 级时间戳
        static string get_time_ms()
        {
            struct timeval _time;
            gettimeofday(&_time, nullptr);
            return to_string(_time.tv_sec * 1000 + _time.tv_usec / 1000);
        }
    };

    // 路径工具
    class path_util
    {
    public:
        // 添加后缀
        static string add_suffix(const string &file_name, const string &suffix)
        {
            string path_name = temp_path + file_name + suffix;
            return path_name;
        }

        /* ---------- 编译时需要有的临时文件 ---------- */

        // 构建源文件路径 + 后缀的完整文件名
        static string Src(const string &file_name)
        {
            // xxx -> ./temp/xxx.cpp
            return add_suffix(file_name, ".cpp");
        }

        // 构建可执行程序的完整路径 + 后缀名
        static string Exe(const string &file_name)
        {
            // xxx -> ./temp/xxx.exe
            return add_suffix(file_name, ".exe");
        }

        // 创建用来存储编译时报错的文件名
        static string compiler_error(const string &file_name)
        {
            // xxx -> ./temp/xxx.compiler_error
            return add_suffix(file_name, ".compile_error");
        }

        /* ---------- 运行时需要有的临时文件 ----------*/

        // 形成一个标准输入文件的文件名
        static string Stdin(const string &file_name)
        {
            // xxx -> ./temp/xxx.stdin
            return add_suffix(file_name, ".stdin");
        }

        // 形成一个标准输出文件的文件名
        static string Stdout(const string &file_name)
        {
            // xxx -> ./temp/xxx.stdout
            return add_suffix(file_name, ".stdout");
        }

        // 形成一个标准错误文件的文件名
        static string Stderr(const string &file_name)
        {
            // xxx -> ./temp/xxx.stderr
            return add_suffix(file_name, ".stderr");
        }
    };

    // 文件工具
    class file_util
    {
    public:
        // 判断指定文件是否存在
        static bool is_file_exists(const string &path_name)
        {
            // 可使用 stat 函数获取对应文件的属性,如果获取成功则说明该文件存在
            struct stat st;
            if (0 == stat(path_name.c_str(), &st))
                return true;
            return false;
        }

        // 形成一个具有唯一性的文件名
        //  用 毫秒级时间戳 + 原子性递增的唯一值 来保证唯一性
        static string unique_file_name()
        {
            static atomic_uint id(0); 				// 原子性递增唯一值
            string ms = time_util::get_time_ms();	// 获取毫秒级时间戳
            string unique_id = to_string(++id);		// 拼接形成唯一的文件名

            return ms + "_" + unique_id;
        }

        // 将用户代码 content 写到唯一的 target 文件中, 形成临时 src 源文件
        static bool write_file(const string &target, const string &content)
        {
            ofstream out(target); // 打开输出流
            if (!out.is_open())
                return false;
            out.write(content.c_str(), content.size());
            out.close();

            return true;
        }

        // 读取目标文件中的所有内容
        static bool read_file(const string &target, string *content, bool keep = false)
        {
            (*content).clear();

            ifstream in(target); // 打开输入流
            if (!in.is_open())
                return false;

            string line;
            while (getline(in, line))
            {
                // getline 内部重载了强制类型转化
                (*content) += line;
                // getline 不保存行分割符, 有些时候需要保留 \n
                (*content) += (true == keep ? "\n" : "");
            }
            in.close();

            return true;
        }
    };

    // 字符串工具
    class string_util
    {
    public:
        // 切割字符串
        static void split_string(const string &str, vector<string> *target, const string &sep)
        {
            // 根据指定的 sep 分隔符切割字符串 str
            // 然后将切割好的所有子串放进输出参数 target 中
            boost::split(*target, str, boost::is_any_of(sep),
                         boost::algorithm::token_compress_on);
        }
    };
}

⭐ 2. log.hpp 日志

  • 该模块主要是提供打印日志的功能,方便后续代码调试。

1. 日志主要内容

  • 日志等级
  • 打印该日志的文件名
  • 对应日志所在的行号
  • 添加对应日志的时间
  • 日志信息

2. 日志使用方式

cpp 复制代码
LOG(日志等级) << "message" << "\n";	// 如: LOG(INFO) << "这是一条日志" << "\n";

3. 日志代码展示

cpp 复制代码
#pragma once

#include <string>
#include <iostream>

#include "util.hpp"	// 工具库

using std::cout;
using std::endl;
using std::ostream;
using std::string;
using std::to_string;

namespace ns_log
{
    using namespace ns_util;

    // 日志等级
    enum
    {
        INFO,    // 常规提示信息
        DEBUG,   // 调试日志
        WARRING, // 警告信息,不影响后续使用
        ERROR,   // 只影响用户的请求
        FATAL    // 导致整个系统不能使用
    };

    // 制作日志信息,并将其写入到缓冲区中
    inline ostream &log(const string &level, const string &file_name, int line)
    {

        string message = "[" + level + "]";                 // 1.添加日志等级
        message += "[" + file_name + "]";                   // 2.添加报错文件名
        message += "[" + to_string(line) + "]";             // 3.添加报错行
        message += "[" + time_util::get_time_stamp() + "]"; // 4.添加日志时间戳
        cout << message;                                    // 5.不写 endl 刷新缓冲区

        return cout;
    }

    // 使用方式: LOG(日志等级) << "message" << "\n";
    //	__FILE__ 会用调用该宏的文件名替换
    //	__LINE__ 会用调用该宏的行号替换
    #define LOG(level) log(#level, __FILE__, __LINE__)
}

🌈 六、compile_server 编译与运行模块

  • 该模块的主要功能:把用户提交的代码在服务器上形成临时文件,对临时文件进行编译并运行,最后得到运行结果。

1. 模块所需文件

2. 各文件功能说明

文件名 文件类型 说明
compiler.hpp 文件 为用户提交的代码提供编译服务,并将编译时产生的信息重定向到指定文件
runner.hpp 文件 为编译生成的可执行程序提供运行服务,并将运行时产生的信息重定向到指定文件
compile_run.hpp 文件 整合编译与运行服务
compile_server.cpp 文件 对外提供编译与运行服务
makefile 文件 一键编译 compile_server.cpp 文件,以及一键删除产生的 .exe 文件
temp 目录 用来存放编译和运行用户的代码时所产生的临时文件

⭐ 1. compiler.hpp 编译服务设计

  • 当用户提交代码的时候,需要为提交的代码提供编译服务,可以将提交的代码打包,使用进程替换的方式进行 g++ 编译。
  • 为了防止远端代码是程序错误的代码或者恶意代码,需要 fork 出子进程去执行进程替换对用户提交的代码执行编译功能。
  • 编译服务只关心编译有没有出错,如果出错,则需要知道是什么原因导致的错误。
    • 需要形成一个临时文件,保存编译出错的结果。
cpp 复制代码
#pragma once

#include <string>
#include <fcntl.h>
#include <iostream>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/types.h>

#include "../comm/log.hpp"  // 日志库
#include "../comm/util.hpp" // 工具库

using std::cout;
using std::endl;
using std::string;

using namespace ns_log;
using namespace ns_util;

namespace ns_compiler
{
    class compiler
    {
    public:
        compiler()
        {}

        // 执行编译功能
        //  a.返回值: 编译成功 true,编译失败 false
        //  b.输入参数: 编译的文件名
        //  c.将临时文件全部放到 temp 目录下
        static bool compile(const string &file_name)
        {
            // 1.创建子进程去进行编译
            pid_t pid = fork();

            if (pid < 0)       // 子进程创建失败
            {
                LOG(ERROR) << " 内部错误,创建子进程失败" << "\n";
                return false;
            }
            else if (0 == pid) // 子进程创建成功
            {
                umask(0);

                // 创建特定路径下的 .compiler_error 文件,用来存储编译错误时的错误信息
                int _compiler_error = open(path_util::compiler_error(file_name).c_str(), O_CREAT | O_WRONLY, 0644);
                if (_compiler_error < 0)
                {
                    LOG(WARRING) << " 没有成功形成 .compiler_error 文件" << "\n";
                    exit(1);
                }

                // 将本应该输出到标准错误件文件的内容重定向到形成的 .compiler_error 临时文件中
                dup2(_compiler_error, 2);

                // 使用进程替换函数让子进程去调用 g++ 编译器完成对代码的编译
                // 	file_name 只有文件名,没有后缀,需要添加上 .cpp 和 .exe 后缀
                execlp("g++", "g++", "-o", path_util::Exe(file_name).c_str(),
                       path_util::Src(file_name).c_str(), "-std=c++11", "-D", "COMPILER_ONLINE", nullptr);

                // 如果走到这里说明进程替换失败
                LOG(ERROR) << " 启动 g++ 编译器失败, 可能是参数错误" << "\n";
                exit(2);
            }
            else                    // 父进程
            {
            	// 父进程阻塞等待子进程退出
                waitpid(pid, nullptr, 0);

                // 判断编译是否成功 (即判断是否形成了对应的可执行程序)
                if (file_util::is_file_exists(path_util::Exe(file_name)))
                {
                    LOG(INFO) << path_util::Src(file_name) << " 编译成功!" << "\n";
                    return true;
                }
            }

            LOG(DEBUG) << " " << path_util::Src(file_name) << "\n";
            LOG(ERROR) << " 编译失败, 没有形成可执行程序" << "\n";
            return false;
        }

        ~compiler()
        {}
    };
}

⭐ 2. runner.hpp 运行服务设计

  • 编译完成也要能将代码运行起来才能知道代码的结果是否正确,因此还需要体提供运行服务。运行服务也是需要 fork 出子进程执行运行服务。
  • 运行服务需要有的临时文件分别有 4 个:
    • .exe 可执行程序,没有这个代码可没法运行,在编译时已经创建好了该文件,直接用就行。
    • .stdin 标准输入文件,用来重定向保存用户的输入。
    • .stdout 标准输出文件,只用来保存程序运行完成后的结果。
    • .stderr 标准错误文件,如果用户代码在运行时发生错误了,需要用该文件保存运行时的错误信息。
  • 运行服务只关心程序是否正常运行完成,有没有收到信号 (使用进程等待的方式查看) 即可。运行结果是否正确由测试用例决定。
  • 同时还需要限制用户代码所占用的资源,不能让用户无限制的占用 CPU 资源以及内存资源。这就是平时刷题时最常见的资源限制。
    • 可以借助 setrlimit() 函数去限制用户代码所占用的时空资源。
cpp 复制代码
#pragma once

#include <string>
#include <fcntl.h>
#include <iostream>
#include <unistd.h>
#include <sys/time.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/resource.h>

#include "../comm/log.hpp"
#include "../comm/util.hpp"

using std::cout;
using std::endl;
using std::string;

using namespace ns_log;
using namespace ns_util;

namespace ns_runner
{
    class runner
    {
    public:
        runner()
        {}

        // 设置调用该函数的进程所占用的资源
        static void set_proc_limit(int _cpu_limit, int _mem_limit)
        {
            // 限制进程所能占用的时间资源
            struct rlimit cpu_rlimit;
            cpu_rlimit.rlim_cur = _cpu_limit;	// 设置资源上限
            cpu_rlimit.rlim_max = RLIM_INFINITY;// 资源上限得在这个范围内,设置成无穷即可
            setrlimit(RLIMIT_CPU, &cpu_rlimit);	// RLIMIT_CPU 选项限制的是占用 CPU 的时间

            // 限制进程所能占用的内存资源
            struct rlimit mem_rlimit;
            mem_rlimit.rlim_cur = _mem_limit * 1024; // 转化成为 KB
            mem_rlimit.rlim_max = RLIM_INFINITY;	 // 资源上限得在这个范围内,设置成无穷即可
            setrlimit(RLIMIT_AS, &mem_rlimit);	     // RLIMIT_AS 选项限制的是占用的内存
        }

        // 指明要运行的文件名即可,不需要带路径,也不需要带后缀
        //  返回值 > 0: 程序运行异常,退出时收到了信号,返回值就是对应的信号编号
        //  返回值 = 0: 正常运行完毕,但是不关心结果是什么
        //  返回值 < 0: 内部错误
        // cpu_limit: 表示该程序运行时,可占用的 CPU 时间的上限
        // mem_limit: 表示该程序运行时,可占用的内存空间的上限 (KB)
        static int Run(const string &file_name, int cpu_limit, int mem_limit)
        {
            umask(0);

            string _execute = path_util::Exe(file_name);   // 获取要执行的可执行程序的文件名称
            string _stdin = path_util::Stdin(file_name);   // 获取要创建的标准输入文件的文件名
            string _stdout = path_util::Stdout(file_name); // 获取要创建的标准输出文件的文件名
            string _stderr = path_util::Stderr(file_name); // 获取要创建的标准错误文件的文件名
            
            int _stdin_fd = open(_stdin.c_str(), O_WRONLY | O_CREAT, 0644);   // 以写方式(打开/创建)标准输入文件
            int _stdout_fd = open(_stdout.c_str(), O_WRONLY | O_CREAT, 0644); // 以写方式(打开/创建)标准输出文件
            int _stderr_fd = open(_stderr.c_str(), O_WRONLY | O_CREAT, 0644); // 以写方式(打开/创建)标准错误文件

            // 这 3 个文件任何一个打开失败都直接退出
            if (_stdin_fd < 0 || _stdout_fd < 0 || _stderr_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 (0 == pid)
            {
                // 子进程创建成功
                // 子进程会继承父进程的文件描述符表
                dup2(_stdin_fd, 0);  // 将标准输入文件重定向到打开的 _stdin_fd  文件
                dup2(_stdout_fd, 1); // 将标准输出文件重定向到打开的 _stdout_fd 文件
                dup2(_stderr_fd, 2); // 将标准错误文件重定向到打开的 _stderr_fd 文件

                // 限制子进程能够使用的资源上限
                set_proc_limit(cpu_limit, mem_limit);

                // 让子进程进程替换去执行对应的 .exe 可执行程序
                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) << " 运行完毕, 退出信号: " << (status & 0x7f) << "\n";
                return status & 0x7F; // 如果出现了异常,返回值会大于 0
            }
        }

        ~runner()
        {}
    };
}

⭐ 3. compile_run.hpp 整合编译和运行服务

  • 该模块需要整合编译和运行功能、适配用户请求,定制通信协议字段并正确的调用 compile 和 run 模块。

该模块需要实现的功能

  1. 远端会传来序列化的报文,使用 jsoncpp 第三方开源序列化、反序列化库,把报文进行反序列化,切割出以下 4 部分,调用 compiler 和 runner 模块进行编译和运行,把结果最后再进行序列化,作为 htttp 协议的响应正文,日后使用。

    • code:用户提交的代码;
    • input:用户给自己提交的代码所做的输入,不做处理;
    • cpu_limit:对指定题目的运行时间限制;
    • mem_limit:对指定题目的内存限制,即空间限制;
  2. 能够将编译和运行时产生的状态码转换成对应的状态信息。

  3. 能够删除编译和运行时所产生的临时文件。

cpp 复制代码
#pragma once

#include "runner.hpp"
#include "compiler.hpp"
#include "../comm/log.hpp"
#include "../comm/util.hpp"

#include <signal.h>
#include <jsoncpp/json/json.h>

using std::istream;
using std::ostream;

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

    // 编译并运行
    class compile_and_run
    {
    public:
        // 将状态码转成对应的描述信息 (待完善)
        static string code_to_desc(int status_code, const string &file_name)
        {
            // status_code = 0: 表示整个过程全部完成
            // status_code > 0: 表示进程收到了信号,导致异常崩溃
            // status_code < 0: 表示整个过程非运行报错 (代码为空、编译报错等)

            string desc;

            switch (status_code)
            {
            case 0:
                desc = "编译运行成功";
                break;
            case -1:
                desc = "提交的代码为空";
                break;
            case -2:
                desc = "未知错误";
                break;
            case -3:
                file_util::read_file(path_util::compiler_error(file_name), &desc, true);
                break;
            case SIGABRT:
                desc = "内存超过范围";
                break;
            case SIGXCPU:
                desc = "CPU 使用超时";
                break;
            case SIGFPE:
                desc = "浮点数溢出";
                break;
            default:
                desc = "未知状态码: " + to_string(status_code);
                break;
            }

            return desc;
        }

        // 清理生成的临时文件 (临时文件的个数不确定,但最多会生成 6 个)
        static void remove_all_temp_file(const string &file_name)
        {
            // 删除 .cpp 文件
            string _src = path_util::Src(file_name);
            if (file_util::is_file_exists(_src))
                unlink(_src.c_str());

            // 删除 .compile_error 文件
            string _compile_error = path_util::compiler_error(file_name);
            if (file_util::is_file_exists(_compile_error))
                unlink(_compile_error.c_str());

            // 删除 .exe 文件
            string _exe = path_util::Exe(file_name);
            if (file_util::is_file_exists(_exe))
                unlink(_exe.c_str());

            // 删除 .stdin 文件
            string _stdin = path_util::Stdin(file_name);
            if (file_util::is_file_exists(_stdin))
                unlink(_stdin.c_str());

            // 删除 .stdout 文件
            string _stdout = path_util::Stdout(file_name);
            if (file_util::is_file_exists(_stdout))
                unlink(_stdout.c_str());

            // 删除 .stderr 文件
            string _stderr = path_util::Stderr(file_name);
            if (file_util::is_file_exists(_stderr))
                unlink(_stderr.c_str());
        }

        /*
         * 输入: 一个 json 串,in_json 里面包含如下内容
         *  1.code: 用户提交的代码
         *  2.input: 用户给自己提交的代码的对应的输入 (不做处理)
         *  3.cpu_limit: 时间要求
         *  4.mem_limit: 空间要求
         * 输出: 一个 json 串,out_json 应该带出去如下内容
         *  1.status: 状态码    (必填)
         *  2.reason: 请求结果  (必填)
         *  3.stdout: 程序运行完后的结果    (选填)
         *  4.stderr: 程序运行完的错误结果  (选填)
         */
        static void Start(const string &in_json, string *out_json)
        {
            /* ---------- 反序列化 ---------- */
            // 将 in_json 中的内容反序列化读取到 in_value 中
            Json::Value in_value;
            Json::Reader reader;
            reader.parse(in_json, in_value);
			
			// 拿取 in_json 中包含的信息
            string code = in_value["code"].asString();
            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;
            string file_name; // 需要内部形成的唯一文件名

            // 如果用户一行代码都没写
            if (0 == code.size())
            {
                status_code = -1; // 代码为空
                goto END;
            }

            // 获得一个具有唯一性的文件名,该文件名没有目录也没有后缀
            file_name = file_util::unique_file_name();

            // 形成临时唯一的 .cpp 源文件, 将用户代码 code 写到这个唯一的文件中
            if (false == file_util::write_file(path_util::Src(file_name), code))
            {
                status_code = -2; // 未知错误
                goto END;
            }

            // 调用编译功能
            if (false == 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:
            out_value["status"] = status_code;
            out_value["reason"] = code_to_desc(status_code, file_name);

            if (0 == status_code)
            {
                // 整个过程全部成功,读取写到 stdout 和 stderr 两个文件中的内容
                string _stdout;
                file_util::read_file(path_util::Stdout(file_name), &_stdout, true);
                out_value["stdout"] = _stdout;

                string _stderr;
                file_util::read_file(path_util::Stderr(file_name), &_stderr, true);
                out_value["stderr"] = _stderr;
            }

            // 将 out_value 序列化给 out_json
            Json::StyledWriter writer;
            *out_json = writer.write(out_value);

            // 清理所有的临时文件
            remove_all_temp_file(file_name);
        }
    };
}

⭐ 4. compile_server.cpp 对外提供编译和运行服务

  • 本模块需要用到 cpp-httplib 库。
  • 当用户提交代码时,oj_server 会向该模块请求 /compile_and_run 服务用于编译并运行用户的代码。
  • 服务端要暴露出自己的 ip 地址和端口号,让远端的 oj_server 能够访问到服务器。
  • 因此启动服务端进程的方式位:./compile_run.exe 端口号
cpp 复制代码
#include "compile_run.hpp"
#include "../comm/httplib.h"

using namespace ns_compile_and_run;
using namespace httplib;

using std::cerr;

void usage(string proc)
{
    cerr << "usage: " << "\n\t" << proc << " port" << endl;
}

// ./compile_server.exe port
int main(int argc, char *argv[])
{
    if (2 != argc)
    {
        usage(argv[0]);
        return 1;
    }

    Server svr;

    // 服务器对外只提供一个服务,用户请求的是一个 out_json 串
    svr.Post("/compile_and_run", [](const Request &req, Response &resp)
             {
        string in_json = req.body;  
        string out_json;
        if (!in_json.empty())
        {
            compile_and_run::Start(in_json, &out_json);
            resp.set_content(out_json.c_str(), "application/json; charset=utf-8");
        } });

    svr.listen("0.0.0.0", atoi(argv[1])); // 启动 http 服务

    return 0;
}

🌈 七、oj_server 用户交互模块

  • 该模块会采用 MVC 的设计模式来调用后端的编译模块,以及访问 文件 / 数据库 将题目列表和编辑界面展示给用户。
    • 该模块会统计每台服务器的负载情况,然后智能的选择使用哪台服务器。
  • 在运行时还要进行资源限制,限制用户代码的运行时间以及内存占用,不然用户写个死循环或开辟个很大的空间,就直接让服务器崩掉了。

MVC 设计模式介绍

  • M:model,通常是和数据交互的模块,对外提供访问题目的接口。
  • V:view,通常是拿到数据之后,要进⾏构建网页,渲染网页内容,展示给用户的 (浏览器)。
  • C:control,控制器控制拿什么数据, 什么时候拿数据, 拿多少数据等,通常说的编写核心业务逻辑指的就是这个。

oj_server 目录结构

文件名 文件类型 说明
oj_moder.hpp 文件 数据交互模块,对外提供访问题目的接口
oj_view.hpp 文件 对获取到的题目进行构建网页、网页渲染
oj_control.hpp 文件 提供负载均衡、控制核心业务逻辑、判题功能
include 软链接 负责链接 mysql-connector 下的 include 库
lib 软链接 负责链接 mysql-connector 下的 lib 库
template_html 目录 其中的 all_questions.html 负责展示题目列表;one_question.html 负责展示指定题目的编辑页面
wwwroot 目录 其中的 index.html 展示的是网页的首页

⭐ 1. oj_server.cpp 网络路由功能

  • 实现用户请求的服务路由功能,使用 http 进行路由选择,主要分为了三个路由,获取所有题目、获取指定题目内容、对提交代码进行判题。
cpp 复制代码
#include <string>
#include <signal.h>
#include <iostream>

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

using std::cout;
using std::endl;
using std::string;

using namespace httplib;
using namespace ns_control;

static control *ctrl_ptr = nullptr;

// 将服务器重新上线
void recover(int sig)
{
    ctrl_ptr->recovery_machine();
}

int main()
{
    // 当收到 3 号信号时,重新将所有的主机上线
    signal(SIGQUIT, recover);

    Server svr;
    control ctrl;
    ctrl_ptr = &ctrl;

    /* 获取所有的题目列表 */
    svr.Get("/all_questions", [&ctrl](const Request &req, Response &resp)
            {
                // 返回一张包含所有的题目的 html 网页
                string html;
                ctrl.all_questions(&html);
                resp.set_content(html, "text/html; charset=utf-8"); });

    /* 用户要根据题目编号获取题目信息 */
    //  \d+ 能够适应任意题号 -> 正则匹配
    //  R"()", 原始字符串,保持字符串内容的原貌
    svr.Get(R"(/question/(\d+))", [&ctrl](const Request &req, Response &resp)
            {
                string question_num = req.matches[1]; 
                string html; 
                ctrl.question(question_num, &html);
                resp.set_content(html, "text/html; charset=utf-8"); });

    /* 用户提交代码,使用我们的判题功能 */
    //  判题的构成: a.每道题的测试用例 b.compile_and_run 服务
    svr.Post(R"(/judge/(\d+))", [&ctrl](const Request &req, Response &resp)
             {
                 string question_num = req.matches[1]; // 获取题目编号
                 string result_json;
                 ctrl.judge(question_num, req.body, &result_json);
                 resp.set_content(result_json, "application/json; charset=utf-8"); // 设置响应内容
             });

    svr.set_base_dir("./wwwroot"); // 设置网页首页
    svr.listen("0.0.0.0", 8080);   // 启动 http 服务

    return 0;
}

⭐ 2. MySQL 题库设计

  • 用户在提交代码后,我们接收到的其实不止有用户的代码,还有对特定题目的测试用例代码,只有根据完整代码才能判断题目是否正确。
    • 完整的代码 = 用户提交的代码 + 数据库中存储的测试用例代码。
  • 刷过 leetcode 的都知道,刷题是会提供给你一份预设代码的,因此一开始我们的数据库得准备两部分代码 (预设代码 + 测试代码)。

1. 创建 MySQL 用户及数据库

  1. 要创建的用户为 oj_client:
sql 复制代码
create user oj_client@'%' identified by '123456';	// 123456 是密码,你也可以自己更改
  1. 建立数据库 oj:
sql 复制代码
create database oj;
  1. 赋权,让 oj_client 这个用户只能看见 oj 这个数据库。
sql 复制代码
grant all on oj.* to oj_client@'%';

2. 创建表结构并录入数据

sql 复制代码
create table if not exists `oj_questions`
(
    id            int primary key auto_increment comment '题目编号',
    title         varchar(128) not null comment          '题目标题',
    star          varchar(8)   not null comment          '题目难度',
    question_desc text         not null comment          '题目描述',
    header        text         not null comment          '预设代码',
    tail          text         not null comment          '测试用例',
    cpu_limit     int default 1 		comment          '时间限制',
    mem_limit     int default 50000 	comment          '空间限制'
) engine = innodb
  default charset = utf8;

⭐ 3. oj_model.hpp 数据交互模块

  • 该模块负责执行对应的 sql 语句从数据库中获取全部或指定题目的信息。
cpp 复制代码
#pragma once

#include <vector>
#include <string>
#include <cassert>
#include <fstream>
#include <iostream>
#include <unordered_map>

#include "include/mysql.h"
#include "../comm/log.hpp"
#include "../comm/util.hpp"

using std::cout;
using std::endl;
using std::getline;
using std::ifstream;
using std::ofstream;
using std::stoi;
using std::string;
using std::unordered_map;
using std::vector;

using namespace ns_log;
using namespace ns_util;

namespace ns_model
{
    // 题目的细节
    struct question
    {
        string number; // 题目编号 (唯一)
        string title;  // 题目标题
        string star;   // 题目难度 (简单、中等、困难)
        string desc;   // 题目的描述
        string header; // 给用户预设的代码
        string tail;   // 题目的测试用例 (需要和 header 拼接形成完成代码再交给后端进行编译)
        int cpu_limit; // 题目的时间要求 (S)
        int mem_limit; // 题目的空间要求 (KB)
    };

    const std::string oj_questions = "oj_questions"; // 存储数据的表名
    const std::string host = "127.0.0.1";            // ip
    const std::string user = "oj_client";            // 访问的用户
    const std::string passwd = "123456";             // 对应用户名的用户密码
    const std::string db = "oj";                     // 要连接的数据库
    const int port = 3306;                           // 数据库对应的端口号

    class model
    {
    public:
        model()
        {}

        // 执行对应的 sql 语句去获取题目
        bool query_mysql(const string &sql, vector<struct question> *out)
        {
            // 构建一个 MySQL 句柄
            MYSQL *my = mysql_init(nullptr);

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

            // 连接成功后,需要设置该连接的编码格式,防止出现乱码
            mysql_set_character_set(my, "utf8");

            LOG(INFO) << " 数据库连接成功!" << "\n";

            // 执行 sql 语句进行数据访问
            if (0 != mysql_query(my, sql.c_str()))
            {
                LOG(WARRING) << " 访问失败, 请检查 sql 语句: " << sql << "\n";
                return false;
            }

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

            // 分析结果
            int rows = mysql_num_rows(res);   // 获得行数
            int cols = mysql_num_fields(res); // 获得列数

            for (size_t i = 0; i < rows; i++)
            {
                struct question q;
                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_close(my); // 关闭 MySQL 连接

            return true;
        }

        // 从数据库获取所有的题目
        bool get_all_questions(vector<struct question> *out)
        {
            const string sql = "select * from " + oj_questions;
            return query_mysql(sql, out);
        }

        // 从数据库获取指定一个题目
        bool get_one_question(const string &number, struct question *q)
        {
            bool res = false;

            const string sql = "select * from " + oj_questions + " where id=" + number;
            vector<struct question> result;

            if (query_mysql(sql, &result))
            {
                if (1 == result.size())
                {
                    *q = result[0];
                    res = true;
                }
            }

            return res;
        }

        ~model()
        {}
    };
}

⭐ 4. oj_view 网页构建模块

  • 通常是拿到数据之后,要进⾏构建网页,渲染网页内容,展⽰给⽤⼾的 (浏览器)。
  • 该模块需要用到 ctemplate 库。
cpp 复制代码
#pragma once

#include <string>
#include <iostream>
#include <ctemplate/template.h>

#include "oj_model.hpp"

using std::cout;
using std::endl;
using std::string;

using namespace ns_model;

namespace ns_view
{
    // 要渲染的网页的 html 文件的所在路径
    const string template_path = "./template_html/";

    class view
    {
    public:
        view()
        {}

        // 将所有的题目数据构建成网页
        void all_expand_html(const vector<struct question> &questions, string *html)
        {
            // 需要显示的内容: 题目编号、题目标题、题目难度
            // 推荐使用表格显示

            // 1.形成要被渲染的网页文件的所在路径
            string src_html = template_path + "all_questions.html";

            // 2.形成数据字典
            ctemplate::TemplateDictionary root("all_questions");
            for (const auto &q : questions)
            {
                // 构建子字典
                ctemplate::TemplateDictionary *sub = root.AddSectionDictionary("question_list");

                // 往形成的子字典中添加数据
                sub->SetValue("number", q.number);
                sub->SetValue("title", q.title);
                sub->SetValue("star", q.star);
            }

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

            // 4.添加字典数据到网页中
            tpl->Expand(html, &root);
        }

        // 将指定的题目数据构建成网页
        void one_expand_html(const struct question &q, string *html)
        {
            // 1.形成要被渲染的网页文件的所在路径
            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("header", q.header);

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

            // 4.添加数据字典到网页中
            tpl->Expand(html, &root);
        }

        ~view()
        {}
    };
}

⭐ 5. oj_control.hpp 控制器模块

1. 该模块拥有的类及其说明

类名 说明 提供的功能
machine 主机操作模块 初始化构造主机 (主机的 ip、port、负载、互斥锁)
machine 主机操作模块 增加主机负载
machine 主机操作模块 减少主机负载
machine 主机操作模块 清空主机负载
machine 主机操作模块 获取主机负载
load_blance 负载均衡模块 配合 boost 库加载配置文件中的所有主机
load_blance 负载均衡模块 智能选择负载最低的主机提供编译和运行服务
load_blance 负载均衡模块 展示所有离线和在线的主机 id
load_blance 负载均衡模块 上线主机 (统一上线)
load_blance 负载均衡模块 离线特定主机
control 控制器模块 获取所有的题目, 根据题目数据,构建 html 网页
control 控制器模块 获取指定的题目, 根据题目数据,构建 html 网页
control 控制器模块 提供对特定题目的判题功能
control 控制器模块

2. control 模块的判题功能要执行的操作

  1. 根据题目编号,拿到题目
  2. 对 in_json 进行反序列化,得到用户对指定题目的 code、input
  3. 重新拼接用户代码 + 测试用例,得到新的代码
  4. 利用负载均衡模块,选择负载最低的主机调用编译和运行功能,该模块还得能够将对应得主机上线或下线。
  5. 对负载最低的主机发起 http 请求得到结果。
  6. 结果赋值给输出参数out_json。

3. 代码展示

cpp 复制代码
#pragma once

#include <mutex>
#include <string>
#include <cassert>
#include <fstream>
#include <iostream>
#include <algorithm>
#include <jsoncpp/json/json.h>
#include <boost/algorithm/string.hpp>

#include "oj_view.hpp"
#include "oj_model.hpp"
#include "../comm/log.hpp"
#include "../comm/util.hpp"
#include "../comm/httplib.h"

using std::cout;
using std::endl;
using std::getline;
using std::ifstream;
using std::min;
using std::ofstream;
using std::sort;
using std::string;

using namespace ns_log;
using namespace ns_util;
using namespace ns_view;
using namespace ns_model;
using namespace httplib;

namespace ns_control
{
    // 提供服务的主机
    class machine
    {
    public:
        string ip;       // 该主机的 ip 地址
        int port;        // 该主机中提供编译服务的进程
        uint64_t load;   // 当前主机编译服务的负载情况,需要被锁保护起来
        std::mutex *mtx; // mutex 是禁止拷贝的,使用指针来完成

    public:
        machine()
            : ip(""), port(0), load(0), mtx(nullptr)
        {}

        // 增加主机负载
        void inc_load()
        {
            if (mtx != nullptr) mtx->lock();
            load++;
            if (mtx != nullptr) mtx->unlock();
        }

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

        // 将主机负载清零
        void reset_load()
        {
            if (mtx != nullptr) mtx->lock();
            load = 0;
            if (mtx != nullptr) mtx->unlock();
        }

        // 获取主机负载
        uint64_t get_load()
        {
            uint64_t _load = 0;

            if (mtx != nullptr) mtx->lock();
            _load = load;
            if (mtx != nullptr) mtx->unlock();

            return _load;
        }

        ~machine()
        {}
    };

    // 记录提供服务的主机列表文件的所在路径
    const string service_machine = "./conf/service_machine.conf";

    // 负载均衡模块
    class load_blance
    {
    private:
        vector<machine> machines; // 存放所有能提供编译服务的主机, 用数组下标作为每台主机的 id
        vector<int> online;       // 记录所有 在线 的主机的 id
        vector<int> offline;      // 记录所有 离线 的主机的 id
        std::mutex mtx;           // 保证 load_blance 的数据安全

    public:
        load_blance()
        {
            assert(load_conf(service_machine));
            LOG(INFO) << " 加载" << service_machine << " 成功" << "\n";
        }

        // 加载所有主机
        bool load_conf(const string &machine_conf)
        {
            ifstream in(machine_conf);
            if (!in.is_open())
            {
                LOG(FATAL) << " 加载: " << machine_conf << " 失败" << "\n";
                return false;
            }

            string line;

            // 切分主机信息,获取对应主机的 ip 和 port
            while (getline(in, line))
            {
                string sep = ":";
                vector<string> tokens;
                string_util::split_string(line, &tokens, ":");

                if (tokens.size() != 2)
                {
                    LOG(WARRING) << " 切分 " << line << " 失败" << "\n";
                    continue;
                }

                machine _m;
                _m.ip = tokens[0];
                _m.port = stoi(tokens[1]);
                _m.load = 0;
                _m.mtx = new std::mutex();

                online.push_back(machines.size()); // 一开始所有的主机都应该是在线状态
                machines.push_back(_m);
            }

            in.close();

            return true;
        }

        // 智能选择负载最低的主机
        bool smart_choice(int *id, machine **m)
        {
            // 1.使用选择好的主机 (核心: 更新该主机的负载)
            // 2.可能需要离线该主机

            mtx.lock();
            // 负载均衡的算法: 1.随机数 + hash; 2.轮询 + hash;
            int online_num = online.size(); // 获取在线主机的个数
            if (0 == online_num)            // 所有主机均已离线
            {
                mtx.unlock();
                LOG(FATAL) << " 所有的后端编译主机已全部离线, 请运维的同事尽快查看" << "\n";
                return false;
            }

            // 通过遍历的方式,找到所有负载最小的机器
            *id = online[0];
            *m = &machines[online[0]];
            uint64_t min_load = machines[online[0]].get_load();

            for (size_t i = 1; i < online_num; i++)
            {
                uint64_t curr_load = machines[online[i]].get_load();

                if (min_load > curr_load)
                {
                    min_load = curr_load;      // 获取最小负载的主机的负载
                    *id = online[i];           // 获取负载最小的主机的 id
                    *m = &machines[online[i]]; // 获取负载最小的主机的地址
                }
            }
            mtx.unlock();

            return true;
        }

        // 上线主机 (统一将主机上线)
        void online_machine()
        {
            mtx.lock();

            // 将 offline 的内容全部添加到 online 中, 再将 offline 清空
            online.insert(online.end(), offline.begin(), offline.end());
            offline.erase(offline.begin(), offline.end());

            mtx.unlock();

            LOG(INFO) << " 所有的主机均已上线" << "\n";
        }

        // 离线对应主机
        void offline_machine(int id)
        {
            mtx.lock();

            for (auto iter = online.begin(); iter != online.end(); iter++)
            {
                if (*iter == id)
                {
                    // 将要离线的主机的负载清零
                    machines[id].reset_load();

                    // 已经找到了要离线的主机
                    online.erase(iter);    // 将对应的主机利离线
                    offline.push_back(id); // 在离线主机集中将其添加进去
                    break;
                }
            }

            mtx.unlock();
        }

        // for test
        void show_machine()
        {
            mtx.lock();

            // 展示所有的在线主机
            cout << "当前在线主机列表: ";
            for (auto &id : online)
                cout << id << " ";
            cout << endl;

            // 展示所有的离线主机
            cout << "当前离线主机列表: ";
            for (auto &id : offline)
                cout << id << " ";
            cout << endl;

            mtx.unlock();
        }

        ~load_blance()
        {}
    };

    // 核心业务逻辑的控制器
    class control
    {
    private:
        model _model;             // 提供后台数据
        view _view;               // 提网 html 渲染功能
        load_blance _load_blance; // 核心负载均衡器

    public:
        control()
        {}

        // 将所有的主机重新上限
        void recovery_machine()
        {
            _load_blance.online_machine();
        }

        // 获取所有的题目, 根据题目数据,构建 html 网页
        bool all_questions(string *html)
        {
            bool ret = true;
            vector<struct question> all;

            if (_model.get_all_questions(&all))
            {
                // 将获取到的题目按照题目编号进行升序排序
                sort(all.begin(), all.end(),
                     [](const struct question &q1, const struct question &q2)
                     {
                         return stoi(q1.number) < stoi(q2.number);
                     });

                // 获取所有的题目信息成功, 将所有的题目数据构建成网页
                _view.all_expand_html(all, html);
            }
            else
            {
                // 获取所有的题目失败
                *html = "获取题目失败, 形成题目列表失败";
                ret = false;
            }

            return ret;
        }

        // 获取指定题目信息
        bool question(const string &question_num, string *html)
        {
            bool ret = true;
            struct question q;

            if (_model.get_one_question(question_num, &q))
            {
                // 获取指定的题目信息成功, 将指定的题目数据构建成网页
                _view.one_expand_html(q, html);
            }
            else
            {
                // 获取所有的题目失败
                *html = "指定题目 " + question_num + " 不存在";
                ret = false;
            }

            return ret;
        }

        // 提供判题功能
        //  in_json 应该有的内容: 题目 id、用户提交的代码
        void judge(const string &number, const string in_json, string *out_json)
        {
            // 0.根据题编号,直接拿到对应的题目细节
            struct question q;
            _model.get_one_question(number, &q);

            // 1.对 in_json 反序列化: 得到题目 id,用户提交的源代码,用户的输入
            Json::Value in_value;
            Json::Reader reader;
            reader.parse(in_json, in_value);
            string code = in_value["code"].asString();

            // 2.序列化,拼接 用户提交的源代码 + 测试用例代码,形成新的代码
            Json::Value compile_value;
            compile_value["input"] = in_value["input"].asString();
            compile_value["code"] = code + q.tail;
            compile_value["cpu_limit"] = q.cpu_limit;
            compile_value["mem_limit"] = q.mem_limit;
            Json::StyledWriter writer;
            string compile_string = writer.write(compile_value);

            // 3.选择负载最低的主机
            //  规则: 一直选择,直到主机可用,否则就是服务端全部挂掉
            while (true)
            {
                int id;
                machine *m = nullptr;

                // 如果选择失败,则说明所有主机都挂掉了, 不需要给用户返回
                if (false == _load_blance.smart_choice(&id, &m))
                    break;

                // 4.向负载最低的主机发起 http 请求,得到结果
                Client cli(m->ip, m->port);
                m->inc_load(); // 被请求的主机要先增加负载

                LOG(INFO) << " 选择主机成功, 主机 id: " << id << ", ip: " << m->ip
                          << ", port: " << m->port << ", 负载: " << m->get_load() << "\n";

                if (auto res = cli.Post("/compile_and_run", compile_string, "application/json; charset=utf-8"))
                {
                    // 5.将判题结果交给 out_json
                    if (200 == res->status)    // 此时的 http 请求才算完全成功
                    {
                        *out_json = res->body; // 拿到这次编译并运行的结果
                        m->dec_load();         // 请求的服务已经结束,让该主机的负载减少
                        LOG(INFO) << " 请求编译和运行服务成功" << "\n";
                        break;
                    }
                    else
                    {
                        // 本次请求访问到了目标主机,但结果不对
                        // 对应主机提供的服务结束,让负载减少
                        m->dec_load();
                    }
                }
                else
                {
                    // 请求失败,没得到任何 response
                    LOG(ERROR) << " 当前请求的主机 id: " << id << " ip: " << m->ip
                               << " port: " << m->port << " 可能已经离线" << "\n";

                    _load_blance.offline_machine(id); // 将请求的对应主机离线
                    _load_blance.show_machine();      // for test
                }
            }
        }

        ~control()
        {}
    };
}

🌈 八、顶层 makefile 实现

  • 项目写好之后,不是直接将代码交给别人,只需要把可执行文件和运行该程序需要的配置文件给用户即可。

  • 顶层 makefile 需要完成的任务有 3 个:

    • 一键编译:一键形成对应的 compile_server 和 oj_server 两个模块对应的可执行程序。
    • 一键发布:将要交付的项目的可执行程序及其相关配置文件一键放到统一的目录底下。
    • 一键清除:清除一键编译和一键发布后生成的文件。
shell 复制代码
# 编译
.PHONY:all
all:
	@cd compile_server;\
	make;\
	cd -;\
	cd oj_server;\
	make;\
	cd -;

# 发布
.PHONY:publish
publish:
	@mkdir -p publish/compile_server;\
	mkdir -p publish/oj_server;\
	cp -rf compile_server/compile_server.exe publish/compile_server;\
	cp -rf compile_server/temp publish/compile_server;\
	cp -rf oj_server/conf publish/oj_server;\
	cp -rf oj_server/lib publish/oj_server;\
	cp -rf oj_server/template_html publish/oj_server;\
	cp -rf oj_server/wwwroot publish/oj_server;\
	cp -rf oj_server/oj_server.exe publish/oj_server;

# 清理
.PHONY:clean
clean:
	@cd compile_server;\
	make clean;\
	cd -;\
	cd oj_server;\
	make clean;\
	cd -;\
	rm -rf publish;

🌈 九、项目补充

⭐ 1. 安装并测试 jsoncpp

1. 安装 jsoncpp

  • 按顺序在命令执行如下顺序即可。
bash 复制代码
sudo apt-get update						// 更新源

sudo apt-get install libjsoncpp-dev		// 安装

ls /usr/include/jsoncpp/json/			// 检查是否安装成功

2. 使用 jsoncpp

  • 编代码时要包含头文件 #include <jsoncpp/json/json.h>
  • 编译时要连接 jsoncpp 的库 g++ -ljsoncpp
    • 使用 jsoncpp 进行序列化和反序列化示例:
cpp 复制代码
#include <string>
#include <fstream>
#include <iostream>
#include <jsoncpp/json/json.h>

using namespace std;

struct student
{
    string name;
    int age;
    double weight;

public:
    void print()
    {
        cout << "name:" << name << endl;
        cout << "age:" << age << endl;
        cout << "weight:" << weight << endl;
    }
};

int main()
{
    /* ---------- 序列化 ---------- */
    // 结构化数据
    struct student zs = {"张三", 18, 70};

    // 将结构化对象转换成 json 的 Value 对象
    Json::Value root1;
    root1["name"] = zs.name;
    root1["age"] = zs.age;
    root1["weight"] = zs.weight;

    // 序列化
    Json::FastWriter writer; 					// 序列化出来的字符串就是一整条串
    // Json::StyledWriter writer;              	// 序列化出来的字符串看着像结构体
    string json_string = writer.write(root1);	// 序列化后写入到 json_string 中

    cout << "---------- 序列化 ----------" << endl;
    cout << json_string << endl;            	// 打印序列化后的字符串

    /* ---------- 反序列化 ---------- */
    Json::Value root2;
    Json::Reader reader;
    bool res = reader.parse(json_string, root2);// 将 string 对象中的内容反序列化读取到 root2 中

    struct student sz;

    sz.name = root2["name"].asString();         // 要提取出的 name 的类型为 string
    sz.age = root2["age"].asInt();              // 要提取出的 age 的类型为 int
    sz.weight = root2["weight"].asDouble();     // 要提取出的 weight 的类型为 double

    cout << "---------- 反序列化 ----------" << endl;
    sz.print();                                 // 打印反序列化后的结构化数据

    return 0;
}

⭐ 2. 安装并测试 cpp-httplib

下载方式

  1. 点击链接之后,下载 zip 安装包到 Windows 中。


  1. 将该安装包传到 Linux 机器中,然后将其解压即可。
  1. 最后再将 httplib.h 拷贝到我们的项目中的 comm 目录下。


2. 使用 cpp-httplib

  • 由于 httplib 库的实现用到了原生线程库,因此在编译时需要添加上 -lpthread 选项。
  • 现在由网页向服务端请求一个 /hello 服务,
cpp 复制代码
#include "../comm/httplib.h"

using namespace httplib;

void usage(string proc)
{
    cerr << "usage: " << "\n\t" << proc << " port" << endl;
}

// ./compile_server.exe port
int main(int argc, char *argv[])
{
    if (2 != argc)
    {
        usage(argv[0]);
        return 1;
    }

    Server svr;

    // 获取指定资源 (用来进行基本测试)
    //	req 用于接收请求,resp 用于响应请求
    svr.Get("/hello", [](const Request &req, Response &resp)
            { resp.set_content("hello httplib, 你好 httplib", "text/plain; charset=utf-8"); });
	
	// 启动 http 服务,监听对指定 IP 和 port 的请求
    svr.listen("0.0.0.0", atoi(argv[1])); 

    return 0;
}


⭐ 3. 安装并测试 boost

  • 在命令行中输入以下指令可安装 boost 库。
bash 复制代码
sudo apt install libboost-dev
  • 使用 boost 库。
cpp 复制代码
#include <vector>
#include <string>
#include <iostream>
#include <boost/algorithm/string.hpp>

int main()
{
    vector<string> tokens;							// 存储分割出来的子串
    const string str = "1 判断回文数 简单 1 30000";	// 待被切割的串
    const string sep = " ";							// 分隔符
    boost::split(tokens, str, boost::is_any_of(sep),
                 boost::algorithm::token_compress_on);

    for (auto &s : tokens)
        cout << s << endl;

    return 0;
}

⭐ 4. 安装并测试 ctemplate

  • 将我提供的安装包下载下来,再上传到自己的 Linux 服务器上,然后解压。
  • 解压完之后再在命令行执行以下步骤即可将 ctemplate 安装到系统中。
bash 复制代码
./autogen.sh
./configure
make			// 编译
make install	// 安装到系统中

使用 ctemplate 库

  • ctemplate 会采用 key-value 模型,用后端的 value 值替换掉 html 中用双括号包裹起来的 key 值。
    • 在编码时需要包含头文件 #include <boost/algorithm/string.hpp>;
    • 在编译时需要链接 g++ -lctemplate 库。
cpp 复制代码
#include <vector>
#include <string>
#include <iostream>
#include <ctemplate/template.h>

using namespace std;

int main()
{
    string in_html = "./test.html";
    string value = "这是一个 ctemplate 测试用例";

    // 形成数据字典
    ctemplate::TemplateDictionary root("test"); // 类似于 unordered_map<> test;
    root.SetValue("key", value);                // 类似于 test.insert({});

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

    // 添加字典数据到网页中
    string out_html;
    tpl->Expand(&out_html, &root);

    // 完成了渲染, 输出替换了之后的 html 文本内容
    cout << out_html << endl;

    return 0;
}
html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>用来测试 ctemplate 库</title>
</head>

<body>
    用双花括号括起来的就是要被替换的内容
    <p>{{key}}</p>
    <p>{{key}}</p>
    <p>{{key}}</p>
    <p>{{key}}</p>
    <p>{{key}}</p>
</body>

</html>
相关推荐
码农101号19 分钟前
Linux中容器文件操作和数据卷使用以及目录挂载
linux·运维·服务器
醇醛酸醚酮酯20 分钟前
Qt项目锻炼——TODO清单(二)
开发语言·数据库·qt
jioulongzi25 分钟前
记录一次莫名奇妙的跨域502(badgateway)错误
开发语言·python
PanZonghui37 分钟前
Centos项目部署之Nginx 的安装与卸载
linux·nginx
PanZonghui44 分钟前
Centos项目部署之安装数据库MySQL8
linux·后端·mysql
PanZonghui1 小时前
Centos项目部署之运行SpringBoot打包后的jar文件
linux·spring boot
PanZonghui1 小时前
Centos项目部署之Java安装与配置
java·linux
向阳@向远方1 小时前
第二章 简单程序设计
开发语言·c++·算法
程序员弘羽1 小时前
Linux进程管理:从基础到实战
linux·运维·服务器
PanZonghui1 小时前
Centos项目部署之常用操作命令
linux