首页 前端知识 在线OJ项目

在线OJ项目

2024-08-27 21:08:43 前端知识 前端哥 626 563 我要收藏

目录

项目介绍

项目宏观结构

compile_server 模块设计

compiler类 (编译功能,compiler.hpp)

路径处理工具类(PathUtil类)

文件检验工具类(FileUtil类)

编译错误重定向

日志功能(log.hpp)

时间工具类(TiemUtil类)

测试编译功能

Runner类(运行功能,runner.hpp)

测试运行功能

设置资源限制

整合编译运行功能(compile_run.hpp)

CodeToDesc

UniqueFileName

ReadFile && WriteName

测试compile_run模块

清理临时文件

引入cpp-httplib并测试

开放端口号

oj_server 模块设计

oj_server 的功能路由

题库设计

Model 模块(oj_model.hpp)

struct Question

Model 类

字符串处理工具类(StringUtil::splitString)

Control 模块(oj_control.hpp)&& View 模块(oj_view.hpp)

获取题目列表功能(引入ctemplate)

获取某题的功能

判题功能说明

负载均衡功能

判题功能的实现

OfflineMachine

Postman 调试

前端页面

整体测试

引入MySQL

oj_model

顶层Makefile

总结


项目介绍

        此次的项目是做一个在线的OJ网站,用户可以获取题目,编写题目,编写完成后提交代码,在服务端运行后返回结果,大致就是这些功能。

        现在国内外已经有了很多的刷题网站,比如:LeetCode、牛客网等,所以我们不要求做一个更好的网站,但是我们要了解一个网站是如何搭建的,我们现在所学的知识可以在项目中如何使用,使用一些好用的开源工具,这才是我们的目的。


项目宏观结构

        该项目核心为三个模块:

  • comm:公共模块。
  • complie_server:编译运行模块。
  • oj_server:获取题目列表,查看题目,编写题目,负载均衡等功能。


compile_server 模块设计

        此模块用来实现用户代码的编译服务,需要有一个编译服务的compiler.hpp文件,一个运行服务runner.hpp文件,还需要有个一将两个服务整合起来的compile_run.hpp文件,最后就是将整个服务运行起来的compile_server.cc文件。整个过程应该是这样的:


compiler类 (编译功能,compiler.hpp)

        用户通过网络提交的代码需要形成一个临时文件,然后编译这个临时文件,编译成功返回true,编译失败要返回原因,如果编译出错,那默认是向标准错误,也就是stderr(2号文件描述符)中输入,现在我们不是要让错误信息打印在屏幕上,而是将错误信息返回给用户,所以就需要重定向到文件中。

        首先,我们要先实现一个Compiler类,类的成员函数Compile实现编译功能,会通过创建子进程后进行程序替换调用编译工具。

// compiler.hpp

class Compiler
{
public:
    Compiler()
    {}
    ~Compiler()
    {}
    static bool Compile(const std::string& file_name) // 参数为要编译的文件名
    {
        // 编译成功返回true, 失败返回false

    }
};

路径处理工具类(PathUtil类)

        我们需要将临时文件进行编译,编译好后形成一个临时的可执行程序,如果出错还要有一个存放错误信息的临时文件,假如Compile函数的参数为“xxxx”,我们需要为这些临时文件做一下区分,加上一些文件后缀,形成“xxxx.cpp”、“xxxx.exe”、“xxxx.compile_error” 这样的文件名,所以就需要一个PathUtil类帮我们做这样的工作,该类可以放到comm公共模块下的util.hpp文件中。处理好临时文件的命名后就可以将这些文件放入compile_server模块下的temp临时文件目录中。

// util.hpp

const std::string temp_path = "./temp/";
class PathUtil
{
public:
    static std::string AddSuffix(const std::string& file_name, const std::string& suffix)
    {
        std::string path_name = temp_path;
        path_name += file_name;
        path_name += suffix;
        return path_name;                                                                                                                                                                                                                                                                                                                                                                     
    }

    // 构建源文件路径+后缀
    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, ".compile_error");
    }
};

        PathUtil路径处理函数写好了,之后就使用进程替换,让g++编译源文件,父进程需要等待子进程退出,还需要检测文件是否已经编译好了。

// 引入路径拼接功能
// PathUtil类
// FileUtil类

class Compiler
{
public:
    Compiler()
    {}
    ~Compiler()
    {}
    static bool Compile(const std::string& file_name)
    {
        // 编译成功返回true, 失败返回false
        pid_t pid = fork();
        if (pid < 0) return false; // 创建子进程失败
        else if (pid == 0)
        {
            // 子进程:调用编译器
            // g++ -o exe src -std=c++11
            execlp("g++", "g++", "-o", 
                    PathUtil::Exe(file_name).c_str(), 
                    PathUtil::Src(file_name).c_str(), 
                    "-std=c++11", 
                    nullptr);

            // 进程替换失败就退出
            exit(1);
        }
        else
        {
            // 父进程
            waitpid(pid, nullptr, 0);
            // 判断是否成功,可以看文件是否生成
            if (FileUtil::isFileExits(PathUtil::Exe(file_name)))
                return true;
        }
        return false;
    }
};

文件检验工具类(FileUtil类)

        检测文件是否编译好的函数我们也封装成类,这个类中使用的系统调用,它的作用是获取文件的属性,只要能获取,也就可以确定该文件已经建立成功了。

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

int stat(const char *path, struct stat *buf);

参数:

  • path:文件的路径。
  • buf:输出型参数,用于获取文件的属性。

返回值:成功返回0,失败返回-1,错误码被设置。

        这是第二个参数的属性,但是我们不关心,我只需要确定文件已经建好了。

struct stat {
    dev_t     st_dev;     /* ID of device containing file */
    ino_t     st_ino;     /* inode number */
    mode_t    st_mode;    /* protection */
    nlink_t   st_nlink;   /* number of hard links */
    uid_t     st_uid;     /* user ID of owner */
    gid_t     st_gid;     /* group ID of owner */
    dev_t     st_rdev;    /* device ID (if special file) */
    off_t     st_size;    /* total size, in bytes */
    blksize_t st_blksize; /* blocksize for file system I/O */
    blkcnt_t  st_blocks;  /* number of 512B blocks allocated */
    time_t    st_atime;   /* time of last access */
    time_t    st_mtime;   /* time of last modification */
    time_t    st_ctime;   /* time of last status change */
};
class FileUtil
{
public:
    static bool isFileExits(const std::string& path_name)
    {
        struct stat st;
        if (stat(path_name.c_str(), &st) == 0)
        {
            // 获取文件属性成功,文件已经存在
            return true;
        }
        else
            return false;
    }
};

编译错误重定向

        使用g++编译之后,如果发生了错误,那么g++就会向stderr标准错误中打印错误信息,下一步就是将这个错误信息重定向到临时文件中。

        如何重定向我们之前也使用过,使用dup2就可以实现,就是要注意oldfd 和 newfd 参数,我们需要记住的是,struct file*这个存放文件描述符的结构不会变,所以g++向哪个文件描述符中输入也不会变。

        所以要先关闭stderr,再将我们打开的存放错误信息的临时文件的文件描述符复制到stderr的位置。dup2函数的描述就是先关闭newfd,再将oldfd拷贝到newfd中。

int dup2(int oldfd, int newfd);
static bool Compile(const std::string &file_name)
{
    // 编译成功返回true, 失败返回false
    // ...
    else if (pid == 0)
    {
        umask(0);
        int _compile_error = open(PathUtil::CompilerError(file_name).c_str(), O_CREAT | O_WRONLY, 0644);
        if (_compile_error < 0)
            exit(2);

        // 重定向到_compile_error
        dup2(_compile_error, 2);

        // 子进程:调用编译器
        // g++ -o exe src -std=c++11

        // ...
    }
    // ...
}

日志功能(log.hpp)

        除了处理正常的代码逻辑,我们还想看一下调试信息,所以需要一个日志文件帮我们打印调试信息,虽然有现成的,但是我们还是自己写一个,这个文件也放在conn模块中。

// log.hpp

// 日志等级
enum
{
    INFO,
    DEBUG,
    WARNING,
    ERROR,
    FATAL
};

std::ostream& Log(const std::string& level, const std::string& file_name, int line)
{
    std::string message;
    // 添加日志等级
    message += "[" + level + "]";

    // 添加文件名
    message += "[" + file_name + "]";

    // 添加第几行
    message += "[" + std::to_string(line) + "]";

    // 添加时间戳
    message += "[" + TimeUtil::GetTimeStamp() + "]";

    // 将message刷新到缓冲区中,但是不要endl刷新
    std::cout << message;

    return std::cout;
}
// 我们要实现一个开放式的日志功能
// 使用方式:LOG() << "" << endl;
#define LOG(level) Log(#level, __FILE__, __LINE__)

        首先就是设置日志等级,接下来就添加一些信息,接下来主要是使用LOG这个宏,可以使用“#”,将宏参数变为字符串,后面两个参数__FILE__ 和 __LINE__就表示文件名和文件的哪一行。

时间工具类(TiemUtil类)

        我们也可以给获取时间戳封装一个类。

class TimeUtil
{
public:
    static std::string GetTimeStamp()
    {
        struct timeval tv;
        gettimeofday(&tv, nullptr);
        return std::to_string(tv.tv_sec);
    }
};

        gettimeofday这个函数我们之前也用过,参数为输入输出型参数,获取当前的时间戳,第二个参数为时区,我们不关心,返回时注意类型转换一下。

测试编译功能

        现在我们就可以在执行编译功能的时候打印一些日志。

static bool Compile(const std::string &file_name)
{
    // 编译成功返回true, 失败返回false
    pid_t pid = fork();
    if (pid < 0)
    {
        LOG(ERROR) << "创建子进程失败" << std::endl;
        return false;
    }
    else if (pid == 0)
    {
        umask(0);
        int _compile_error = open(PathUtil::CompilerError(file_name).c_str(), O_CREAT | O_WRONLY, 0644);
        if (_compile_error < 0)
        {
            LOG(WARNING) << "形成compile_error文件失败" << std::endl;
            exit(2);
        }

        // 重定向到_compile_error
        dup2(_compile_error, 2);

        // 子进程:调用编译器
        // g++ -o exe src -std=c++11
        execlp("g++", "g++", "-o",
                PathUtil::Exe(file_name).c_str(),
                PathUtil::Src(file_name).c_str(),
                "-std=c++11",
                nullptr);

        LOG(ERROR) << "g++启动失败" << std::endl;
        // 进程替换失败就退出
        exit(1);
    }
    else
    {
        // 父进程
        waitpid(pid, nullptr, 0);
        // 判断是否成功,可以看文件是否生成
        if (FileUtil::isFileExits(PathUtil::Exe(file_name)))
        {
            LOG(INFO) << PathUtil::Src(file_name) << "编译成功" << std::endl;
            return true;
        }
    }
    LOG(ERROR) << "编译失败,没有形成可执行程序" << std::endl;
    return false;
}

        现在就可以简单测试一下这个段代码,在temp目录下简单写一个源文件,再调用Compiler::Compile。

int main()
{
    std::string code = "code";
    Compiler::Compile(code);
    return 0;
}

        也可以故意写一个错误,在stderr中就可以看到错误信息,一定要把temp中除了源文件以外的文件删除再次测试,不然.exe文件会一直存在,虽然已经编译失败了,但是就看不到编译失败的调试信息了,这点要注意一下,不过我们后面还是要写一个函数帮我们删除这些临时文件的。


Runner类(运行功能,runner.hpp)

        当一个程序跑完就三种结果:代码跑完结果正确,代码跑完结果不正确,代码没跑完结果异常,但是我们这个功能只需要考虑程序能否正确运行,而提交的代码的正确与否,该功能不考虑。

        当一个程序运行起来,会默认打开三个文件描述符:标准输入,标准输出和标准错误。而临时文件又分为两种,一种是编译时形成的临时文件,上面我们已经处理了,而另一种就是运行时形成的临时文件,构建临时文件名的函数就得添加几个。

const std::string temp_path = "./temp/";
class PathUtil
{
public:
    static std::string AddSuffix(const std::string& file_name, const std::string& suffix)
    {
        std::string path_name = temp_path;
        path_name += file_name;
        path_name += suffix;
        return path_name;                                                                                                                                                                                                                                                                                                                                                                     
    }
    // 运行时形成的临时文件
    // 构建标准输入路径+后缀
    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");
    }
};

        那么为什么需要这三个文件呢?原因是:

  • 标准输入:当用户添加自己的测试用例时会传入。
  • 标准输出:程序运行完成的输出结果。
  • 标准错误:运行时的错误信息。

        后续只要有信息,就会输入到对应的文件中,所以就要打开对应的文件描述符。

class Runner
{
public:
    Runner(){}
    ~Runner(){}
public:
    static int Run(const std::string& file_name)
    {
        std::string _execute = PathUtil::Exe(file_name);
        std::string _stdin   = PathUtil::Stdin(file_name);
        std::string _stdout  = PathUtil::Stdout(file_name);
        std::string _stderr  = PathUtil::Stderr(file_name);

        umask(0);
        int _stdin_fd  = open(_stdin.c_str(), O_CREAT | O_RDONLY, 0644);
        int _stdout_fd = open(_stdout.c_str(), O_CREAT | O_WRONLY, 0644);
        int _stderr_fd = open(_stderr.c_str(), O_CREAT | O_WRONLY, 0644);

        if (_stdin_fd < 0 || _stdout_fd < 0 || _stderr_fd < 0)
        {
            return -1; // 代表打开文件失败
        }

        pid_t pid = fork();
        if (pid < 0)
        {
            close(_stdin_fd);
            close(_stdout_fd);
            close(_stderr_fd);
            return -2; // 创建子进程失败
        }
        else if (pid == 0)
        {
            // 子进程将三个默认打开的std文件描述符重定向到我们创建的临时文件
            dup2(_stdin_fd, 0);
            dup2(_stdout_fd, 1);
            dup2(_stderr_fd, 2);
            
            // 运行可执行程序,但是不能直接调用文件名,因为这个文件没有在环境变量中
            execl(_execute.c_str(), _execute.c_str(), nullptr);
            exit(1);
        }
        else
        {
            // 父进程关闭不用的文件描述符
            close(_stdin_fd);
            close(_stdout_fd);
            close(_stderr_fd);

            // 进程等待
            int status;
            waitpid(pid, &status, 0);
            return status & 0x7F; // 拿到后7位表示的信号
        }
    }
};

        之后就是子进程负责运行可执行程序,父进程负责等待执行的结果,而进程执行的三种结果中的第三个,代码没跑完,程序异常,只要是程序运行异常,一定是因为收到了信号,所以Run函数的几种返回值代表:

  • 返回值 > 0:程序异常,返回值就是收到的信号。
  • 返回值 = 0:正常运行结束,结果保存到文件中。
  • 返回值 < 0:表示创建文件失败,或创建子进程失败。

测试运行功能

        代码已经可以编译通过,我们也写好了运行的代码,现在就来测试一下。

int main()
{
    std::string code = "code";
    Compiler::Compile(code);
    Runner::Run(code);
    return 0;
}

        运行测试程序,代码编译通过,之后就会调用运行功能,运行成功后的代码也重定向到了文件中。

设置资源限制

        当我们在刷题的时候,一定会看到该题目限制的时间和空间复杂度,并且一旦我们运行超时,或者内存申请失败就会报错,那么服务器如何得知的呢,这就需要用到这个函数了。

#include <sys/time.h>
#include <sys/resource.h>

int setrlimit(int resource, const struct rlimit *rlim);

        第一个参数使用的就是下面这两个。

        第二个参数结构为:

struct rlimit {
    rlim_t rlim_cur;  // Soft limit , 设置要限制的数值
    rlim_t rlim_max;  // Hard limit (ceiling for rlim_cur) , 设置最大数值,直接设置为RLIM_INFINITY 即可,意为无穷
};
class Runner
{
// ...
public:
    static void SetProcLimit(int cpu_limit, int mem_limit)
    {
        // 设置CPU的超时时长
        struct rlimit cpu_rlimit;
        cpu_rlimit.rlim_cur = cpu_limit;
        cpu_rlimit.rlim_max = RLIM_INFINITY;
        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);
    }

    static int Run(const std::string& file_name, int cpu_limit, int mem_limit)
    {
        // ...
        pid_t pid = fork();
        if (pid < 0){}
        else if (pid == 0)
        {
            // 子进程将三个默认打开的std文件描述符重定向到我们创建的临时文件
            
            // 设置运行资源限制
            SetProcLimit(cpu_limit, mem_limit);

            // 运行可执行程序,但是不能直接调用文件名,因为这个文件没有在环境变量中
        }
        else{}
    }
};

        我们现在已经设置好了限制的数值,如果超过了,我们如何得知呢,其实也是通过信号的方式设置的,这两个错误的信号分别是:

//内存申请失败,申请一块很大的内存
terminate called after throwing an instance of 'std::bad_alloc'
what(): std::bad_alloc
signal : 6

//CPU使用超时,写一个死循环
signal : 24

[dsh@iZ0jldc5virhclpht2f9fnZ temp]$ kill -l
 1) SIGHUP	      2) SIGINT	     3) SIGQUIT	     4) SIGILL	    5) SIGTRAP
 6) SIGABRT	      7) SIGBUS	     8) SIGFPE	     9) SIGKILL	   10) SIGUSR1
11) SIGSEGV	     12) SIGUSR2	13) SIGPIPE	    14) SIGALRM	   15) SIGTERM
16) SIGSTKFLT    17) SIGCHLD    18) SIGCONT	    19) SIGSTOP	   20) SIGTSTP
21) SIGTTIN	     22) SIGTTOU    23) SIGURG	    24) SIGXCPU    25) SIGXFSZ
26) SIGVTALRM	 27) SIGPROF    28) SIGWINCH    29) SIGIO	   30) SIGPWR
31) SIGSYS

        所以这两个信号就可以在父进程等待成功获取的status中拿到,然后作为Run函数的返回值供上层处理。


整合编译运行功能(compile_run.hpp)

        整合无非就是设置一个类,在这个类中调用compiler和runner中的函数,用户提交的代码形成一个源文件,Compile函数先调用,Run再调用,之后将运行的结果返回给上层。

        但是用户的代码是从哪里来的呢,那自然是从网络中来,所以这个CompileAndRun类可以这样设置:

class CompileAndRun
{
public:
    static void Start(const std::string& in_json, std::string* out_json) {}
};

        我们需要用到 json库,以前那篇序列化的文章中就使用过 json,这次我们还是从网络中拿到一个 json字符串,解析处理后,再返回一个 json字符串,就比如:

        用户的输入:

  • code:用户提交的代码
  • input:用户自己提交的测试用例
  • cpu_limit:时间限制
  • mem_limit:内存限制

        用户的输出:

  • status:状态码
  • reason:状态原因
  • stdout:标准输出
  • stdedrr:标准错误

        在该函数调用开始,获取到in_json,需要将string类型反序列化。

static void Start(const std::string& in_json, std::string* out_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();

    // ...
}

        拿到代码就要生成对应的源文件,要生成源文件就要有唯一的文件名,可以使用毫秒级的时间戳+原子性递增的值来确定唯一值。

        将json中的代码写入源文件中,之后就是编译和运行功能的调用,调用后根据调用的返回值确定结果,这其中可能运行成功没有出错,也可能中途有错误的环节,所以一定要根据返回值确定结果,也就是状态码和状态原因。

        这些都做好之后就是构建返回值json。

static void Start(const std::string& in_json, std::string* out_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_code = 0;   // 状态码
    Json::Value out_value; // 返回的json
    int run_result = 0;    // 运行结果
    std::string file_name; // 形成的唯一文件名

    if (code.size() == 0)
    {
        status_code = -1; // 代码为空
        goto END;
    }

    // 如果有多个用户同时访问,那就需要区分不同的文件名
    // 可以使用毫秒级的时间戳 +原子性递增来确定唯一值
    file_name = FileUtil::UniqueFileName();

    if (!FileUtil::WriteFile(PathUtil::Src(file_name), code)) // 形成临时源文件
    {
        status_code = -2; // 写入错误
        goto END;
    }

    if (!Compiler::Compile(file_name))
    {
        status_code = -3; // 编译时错误
        goto END;
    }

    run_result = Runner::Run(file_name, cpu_limit, mem_limit);
    if (run_result < 0)
    {
        status_code = -2; // 服务器错误
    }
    else if (run_result > 0)
    {
        status_code = run_result; // 运行报错
    }
    else
    {
        //运行成功
        status_code = 0;
    }

    END:
    // 最后填入json中
    out_value["status"] = status_code;
    out_value["reason"] = CodeToDesc(status_code, file_name); // 根据状态码获取状态码描述,有时需要获取文件中的错误原因
    if (status_code == 0)
    {
        // 所有步骤都成功
        out_value["stdout"] = FileUtil::ReadFile(PathUtil::Stdout(file_name));
        out_value["stderr"] = FileUtil::ReadFile(PathUtil::Stderr(file_name));
    }
    // 序列化
    Json::StyledWriter writer;
    *out_json = writer.write(out_json);
}

        因为我们使用goto语句,所以一定要在goto前将所有的变量都定义出来。

        其中有很多的函数还没有写,只是将这个功能的结构先写出来,如 UniqueFileName、WriteFile、ReadFile 和 CodeToDesc 这些函数我们还都没有实现,接下来就是实现这些函数。

CodeToDesc

        该函数的作用是将最后的状态码转化为错误码描述符返还给用户的(而且该状态码可能是编译之后形成的,也可能是运行之后形成的),这个状态码也分为:

  • code > 0:整个过程收到信号而异常崩溃。
  • code < 0:整个过程中的运行错误(代码为空,创建子进程,写入文件失败等)。
  • code = 0:整个过程全部完成。
static std::string CodeToDesc(int code, const std::string& file_name)
{
    std::string desc;
    switch (code)
    {
    case 0:
        desc = "编译运行成功";
        break;
    case -1:
        desc = "用户未提交代码";
        break;
    case -2:
        desc = "未知错误";
        break;
    case -3:
        desc = FileUtil::ReadFile(PathUtil::CompilerError(file_name));
        break;
    case SIGABRT: // 6号信号
        desc = "bad_alloc";
        break;
    case SIGXCPU: // 24号信号
        desc = "CPU超时";
        break;
    case SIGFPE: // 8号信号
        desc = "浮点数溢出";
        break;
    default:
        desc = "debug: " + code;
        break;
    }
    return desc;
}

UniqueFileName

        该函数的作用是生成一个唯一的文件名,使用毫秒级的时间戳+原子性递增来确定唯一值,需要引入C++11中的atomic库来帮我们生成一个原子的计数器。

class TimeUtil
{
public:
    static std::string GetTimeMs() // 获得ms级时间戳
    {
        struct timeval tv;
        gettimeofday(&tv, nullptr);
        return std::to_string(tv.tv_sec * 1000 + tv.tv_usec / 1000); // 将秒和微秒转化为毫秒
    }
};

class FileUtil
{
public:
    static std::string UniqueFileName()
    {
        // 引入C++11中的atomic库,这样就可以获得一个原子的计数器
        static std::atomic_uint id(0); 
        id++;
        std::string ms = TimeUtil::GetTimeMs();
        std::string uid = std::to_string(id);
        return ms + "_" + uid;
    }
}

ReadFile && WriteName

        这两个函数就是为了将用户提交的代码输入到源文件中,再将编译运行后的结果拿出放到json中。

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

    static bool ReadFile(const std::string& target, std::string* content, bool keep)
    {
        std::ifstream in(target);
        if (!in.is_open()) return false;
        std::string line;
        while (std::getline(in, line))
        {
            (*content) += line;
            (*content) += (keep ? "\n":"");
        }
        in.close();
        return true;
    }
};

        将代码写入到源文件中没什么问题,从文件中读取的地方还有一些细节,我们使用getline从in中读取到line中,因为getline内部重载了强制类型转换,所以可以根据读取结果判断是否进行循环,而且该函数多了一个keep参数,因为getline不会保存分隔符,所以可以传入参数来确定是否需要。

        所以CompileAndRun类中的代码就需要修改一下。

static void Start(const std::string &in_json, std::string *out_json)
{
    // ...
END:
    // 最后填入json中
    out_value["status"] = status_code;
    out_value["reason"] = CodeToDesc(status_code, file_name);
    if (status_code == 0)
    {
        // 所有步骤都成功
        std::string _stdout;
        FileUtil::ReadFile(PathUtil::Stdout(file_name), &_stdout, true);
        out_value["stdout"] = _stdout;

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

static std::string CodeToDesc(int code, const std::string& file_name)
{
    std::string desc;
    // ...
    case -3:
        // desc = "编译运行错误";
        FileUtil::ReadFile(PathUtil::CompilerError(file_name), &desc, true);
        break;
    // ...
}

测试compile_run模块

        现在我们已经整合了Compiler和Run函数,所以直接调用Start函数就可以了,函数的参数一个是输入型参数,一个是输出型参数,所以我们可以模拟客户端发来的in_json字符串。

int main()
{
    // 充当客户端的请求json串
    std::string in_json;
    Json::Value in_value;

    // R"()" -> raw string,将字符串中的字符保持原貌
    in_value["code"] = R"(#include <iostream>
    int main(){
        std::cout << "这是一段测试代码" << std::endl;
        return 0;
    })";
    in_value["input"] = "";
    in_value["cpu_limit"] = 1;
    in_value["mem_limit"] = 30 * 1024; // 30MB,该单位是KB

    Json::StyledWriter writer;
    in_json = writer.write(in_value);

    std::cout << in_json << std::endl;

    std::string out_json;
    CompileAndRun::Start(in_json, &out_json);

    std::cout << out_json << std::endl;

    return 0;
}

        这是我们自己构建的json,代码、时间和空间的限制都设置好了,返回的json中有状态码,以及状态码描述,还有标准输出都构建好了。

        当我们写一个死循环,状态码和状态码描述也是符合的;还有超出申请的空间;除零错误都已经处理了。

        我们还可以再测试一个编译报错,此时的状态码的描述就要从.compile_error文件中读取。

清理临时文件

        构建好out_json串后,编译运行功能就结束了,之后就是清理整个过程中的临时文件。在代码中清理文件使用的就是unlink这个函数。

#include <unistd.h>

int unlink(const char *pathname);

        在删除一个文件之前还要判断这个文件是否存在。

static void Start(const std::string &in_json, std::string *out_json)
{
    // ...

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

    RemoveTempFile(file_name);
}

static void RemoveTempFile(const std::string& file_name)
{
    std::string _src = PathUtil::Src(file_name);
    if (FileUtil::isFileExits(_src)) unlink(_src.c_str());

    std::string _compile_error = PathUtil::CompilerError(file_name);
    if (FileUtil::isFileExits(_compile_error)) unlink(_compile_error.c_str());

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

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

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

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

引入cpp-httplib并测试

        编译运行功能我们已经写好了,接下来就是用户如何把代码提交过来,那必然是要使用网络的,但是我们这次不再自己写了,我们使用第三方的开源库,使用更简单,而且这个库的是0.7.15版本的,并且需要升级gcc,不能使用服务器默认的4.8版本,不然会报错的。

        该库还是一个hander-only的,只需要把头文件拷贝到我们的目录中就可以了,放到comm这个工具目录中就可以了。

        使用起来也很简单,当然我们也可以去网上搜索一下这个库怎么使用,这里我们先简单看一下效果。

        要使用自然要包含头文件,还要使用命名空间(为了方便),创建一个服务器就是实例化一个Server对象,接下来就是listen,一个传入要绑定的IP和端口号,这样服务器就已经建立好了。

        这个库是阻塞式多线程的http库,里面也使用了原生线程库,所以编译的时候要添加原生线程库。

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

using namespace httplib;

int main()
{
    Server svr;
    svr.listen("0.0.0.0", 8080);

    return 0;
}

        我们可以使用一下Get函数。

int main()
{
    Server svr;

    svr.Get("/hello", [](const Request& req, Response& resp){
        resp.set_content("hello httplib, 你好 httplib!", "text/plain;charset=utf-8");
    });

    svr.listen("0.0.0.0", 8080);

    return 0;
}

        这里的Get获取的是一个纯文本,但是我们不会让这个httplib库这样做。

        Request 和 Response 结构体中就是http的字段,用户请求的服务就放在Request的body中,然后我们把处理好的数据通过set_content放到Response中,第二个参数content-type设置为application/json,可以在content-type对照表中查找,还可以设置字符集为utf-8。

int main()
{
    Server svr;

    svr.Post("/compile_and_run", [](const Request& req, Response& resp){
        std::string in_json = req.body;
        std::string out_json;
        if (!in_json.empty())
        {
            CompileAndRun::Start(in_json, &out_json);
            resp.set_content(out_json, "application/json;charset=utf-8");
        }
    });

    svr.listen("0.0.0.0", 8080);

    return 0;
}

        用户可以通过Post方法提交,我们获取request中的正文部分,调用CompileAndRun中的Start函数,获取out_json,设置到response中,这个过程我们可以通过Postman这个工具进行测试,指定Post方法,通过IP地址和端口号,并且指定compile_and_run服务,构建json数据,点击Send,可以看到我们的服务器收到了,并且处理完后返回了一个json。

开放端口号

        至此我们的compile_server模块编译完成,已将将编译运行打包成服务,我们也可以给main函数添加命令行参数,运行服务的时候要指定端口,这样我们就可以在本地打开多个会话,通过不同的端口访问这个服务。

void Usage(std::string proc)
{
    std::cerr << "\nUsage: " << proc << " port\n" << std::endl; 
}

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        return 1;
    }

    Server svr;
    
    // ...
    
    svr.listen("0.0.0.0", atoi(argv[1]));

    return 0;
}

oj_server 模块设计

        我们该模块使用MVC(Modou-View-Control)结构的oj服务,本质是一个网站,该模块的功能:

  • 获取题目列表,我们不会去做一个精美的首页,只需要获取题目列表即可。
  • 编辑代码区域。
  • 提交并返回结果功能。

        该项目对应的MVC为:

  • Model:数据交互模块,对题库的增删改查。
  • View:构建的网页,用来展示给用户的。
  • Control:控制器,核心业务逻辑。

        所以该模块中需要有一个oj_server.cc文件,三个模块oj_model.hpp、oj_view.hpp 和 oj_control.hpp,oj_server.cc可以调用这三个模块。

oj_server 的功能路由

        我们的oj_server.cc实现的就是这几个功能。

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

using namespace httplib;

int main()
{
    // 用户请求的服务路由功能
    Server svr;

    // 获取所有的题目列表
    svr.Get("/all_questions", [](const Request &req, Response &resp){
        // ...
    });

    // 用户要根据题目编号获取题目内容
    // /question/100 -> 正则匹配,\d表示匹配数字,+表示匹配多个
    // R"()", raw string, 保持原始字符串
    svr.Get(R"(/question/(\d+))", [](const Request &req, Response &resp){
        std::string number = req.matches[1]; // Request中的Match matches下标为1的位置存放正则匹配的结构
        // ...
    });

    // 用户提交代码,使用判题功能(判断测试用例,compile_and_run)
    svr.Get(R"(/judge/(\d+))", [](const Request &req, Response &resp){
        std::string number = req.matches[1];
        // ...
    });

    svr.set_base_dir("./wwwroot/"); // 默认该目录下的网页

    svr.listen("0.0.0.0", 8080);

    return 0;
}

题库设计

        现在我们需要有一个题库,这就需要有一个questions目录来保存所有的题目,这个目录中就可以存放所有的题,但是还需要一个questions.list文件保存题目的基本信息,这基本信息就有:

  1. 题目的编号
  2. 题目的标题
  3. 题目的难度
  4. 题目的描述
  5. 时间要求
  6. 空间要求

        所以questions.list中可以这样写:

// questions.list

1 反转字符串 简单 1 30000

        我们继续在questions目录中新建一个目录为./1,该目录下存放这第一道题目的描述:
 

// desc.txt

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。

不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。

 

示例 1:

输入:s = ["h","e","l","l","o"]
输出:["o","l","l","e","h"]

示例 2:

输入:s = ["H","a","n","n","a","h"]
输出:["h","a","n","n","a","H"]

        光有描述还不够,我们可以是一个接口型的oj,所以还要给用户提供一些常用的接口。

// header.cpp

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <unordered_map>
#include <stack>
#include <queue>

using namespace std;

class Solution {
public:
    void reverseString(vector<char>& s) {
        // 代码编译区
    }
};

        用户代码提交后就要进行测试,只有每个测试用例都过了才算解出这道题。

// tail.cpp

#ifndef COMPILE_ONLINE

#include "header.cpp"

#endif

bool isQual(const vector<char>& v1, const vector<char>& v2)
{
    int n = v1.size();
    for (int i = 0; i < n; i++)
    {
        if (v1[i] != v2[i]) return false;
    }
    return true;
}

void Test1()
{
    vector<char> ret = {'h','e','l','l','o'};
    vector<char> reverse_ret = {'o','l','l','e','h'};
    Solution().reverseString(ret);
    if (isQual(ret, reverse_ret))
    {
        std::cout << "通过测试用例1" << std::endl;
    }
    else
    {
        std::cout << "未通过测试用例1" << std::endl;
    }
}

void Test2()
{
    vector<char> ret = {'H','a','n','n','a','h'};
    vector<char> reverse_ret = {'h','a','n','n','a','H'};
    Solution().reverseString(ret);
    if (isQual(ret, reverse_ret))
    {
        std::cout << "通过测试用例2" << std::endl;
    }
    else
    {
        std::cout << "未通过测试用例2" << std::endl;
    }
}

int main()
{
    Test1();
    Test2();
    return 0;
}

        相信各位已经发现了,虽然创建了两个文件,并且文件的后缀都是 .cpp,最后用户提交的代码和测试的代码需要合并到一起交给编译运行服务,而tail.cpp开始的条件编译不会让编译器报编写错误,只要在g++的时候加上 -D COMPILE_ONLINE就可以裁剪掉。

        所以最后就是将两个文件合并起来交给后端判题。


Model 模块(oj_model.hpp)

struct Question

        刚才已经设计了题库,这个模块就是将questions_list中所有的题目信息加载到内存中,该模块主要用来进行数据交互,对外提供访问数据的接口,也就是对题目的操作。

        首先我们要对所有的题目进行描述,就是给题目构建一个类或结构体。

struct Question
{
    std::string number; // 题目编号
    std::string title;  // 题目标题
    std::string star;   // 难度
    int cpu_limit;      // cpu时间限制
    int mem_limit;      // 空间限制
    std::string desc;   // 题目的描述
    std::string header; // 题目预设给用户在线编译器的代码
    std::string tail;   // 题目的测试用例,要和header拼接
};

Model 类

        之后就是将这些Question组织起来,用unodered_map保存题号和题目的映射,类中就有对题目的操作。

const std::string questions_list = "./questinos/questinos.list";

class Model
{
public:
    Model()
    {
        assert(LoadQuestionList(questions_list));
    }
    ~Model()
    {}

    bool LoadQuestionList(const std::string& question_list)
    {
        // 加载配置文件
    }

    bool GetAllQuestions(std::vector<Question>* out)
    {

    }

    bool GetOneQuestion(const std::string& number, Question* q)
    {

    }

private:
    std::unordered_map<std::string, Question> questions; // 题目 : 题目细节
};

        下面两个函数比较好写,就是对unordered_map的操作。

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

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

        之后就是加载配置文件,我们传入文件路径,再按行读取文件中的内容,而配置文件中的每一行都是按照我们定义的顺序,以空格为分隔符的字符串,所以我们拿到一个行后就要对字符串进行分割。

const std::string questions_list = "./questinos/questinos.list";
const std::string questions_path = "./questions/";

class Model
{
public:
    Model()
    {
        assert(LoadQuestionList(questions_list));
    }
    ~Model()
    {}

    bool LoadQuestionList(const std::string& question_list)
    {
        // 加载配置文件
        // 打开questions_list文件,按行读取
        std::ifstream in(question_list);
        if (!in.is_open())
        {
            LOG(FATAL) << "题库加载失败,题库文件出错!" << std::endl;
            return false;
        }
        std::string line;
        while (std::getline(in, line))
        {
            std::vector<std::string> tokens;
            StringUtil::splitString(line, &tokens, " ");
            // 1 反转字符串 简单 1 30000
            if (tokens.size() != 5)
            {
                LOG(WARNING) << "加载部分题目失败,文件格式出错" << std::endl;
                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());

            std::string path = questions_path;
            path += q.number + "/";

            FileUtil::ReadFile(path + "desc.txt", &(q.desc), true);
            FileUtil::ReadFile(path + "header.cpp", &(q.header), true);
            FileUtil::ReadFile(path + "tail.cpp", &(q.tail), true);

            questions.insert(make_pair(q.number, q));
        }
        LOG(INFO) << "加载题库成功" << std::endl;
        in.close(); 
    }

private:
    std::unordered_map<std::string, Question> questions; // 题目 : 题目细节
};

字符串处理工具类(StringUtil::splitString)

        下面就是该如何切割字符串,使questions.list中的配置文件加载到内存数据结构中,分割字符串的函数我们也可以自己写,但是boost库中已经有现成的库帮我们实现了。

class StringUtil
{
public:
    // 第一个参数str: 输入型参数,传入要切分的字符串
    // 第二个参数target: 将切分好的字符串放入vector中
    // 第三个参数sep: 按照sep分隔符切割
    static void splitString(const std::string& str, std::vector<std::string>* target, const std::string& sep)
    {
        // 第一个参数为将切分好的字符串放到哪
        // 第二个参数为要切分哪个字符串
        // 第三个参数is_any_of传入分隔符
        // 第四个参数token_compress_on为分隔符是否压缩,多个空格可以压缩为一个,
            // 改为off后,每个分隔符都是独立的
        boost::split((*target), str, boost::is_any_of(sep), boost::algorithm::token_compress_on);
    }
};

        Model 模块的代码到这里就写完了,我们已经可以获取题库,获取某个题目了。


Control 模块(oj_control.hpp)&& View 模块(oj_view.hpp)

        该模块是核心业务逻辑的控制器,oj_server中的第一个功能就是获取所有题目列表。

int main()
{
    // 用户请求的服务路由功能
    Server svr;
    Control ctrl;

    // 获取所有的题目列表
    svr.Get("/all_questions", [&ctrl](const Request &req, Response &resp){
        std::string html;
        ctrl.AllQuestions(&html);

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

    // ...
}

        该服务会通过Control中的成员函数,最终获取整个html页面,Response中的content-type就变成了html。

        还有一个功能就是根据题目编号获取某一道题。

int main()
{
    // ...

    // 用户要根据题目编号获取题目内容
    // /question/100 -> 正则匹配,\d表示匹配数字,+表示匹配多个
    // R"()", raw string, 保持原始字符串
    svr.Get(R"(/question/(\d+))", [&ctrl](const Request &req, Response &resp){
        std::string number = req.matches[1]; // Request中的Match matches下标为1的位置存放正则匹配的结构
        std::string html;
        ctrl.Question(number, &html);
        resp.set_content(html, "text/html; charset=utf-8");
    });

    // ...
}

        那么下面我们就来实现一下这两个功能 ,自然是要在Control类中实现的。

class Control
{
private:
    Model model_;
    View view_; 
public:
    Control(){}
    ~Control(){}

    // 根据题目构建网页
    bool AllQuestions(std::string* html)
    {
        bool ret = true;
        std::vector<struct Question> all;
        if (model_.GetAllQuestions(&all))
        {
            // 构建网页之前要先将题目按升序排好
            sort(all.begin(), all.end(), [](const struct Question& q1, const struct Question& q2){
                return atoi(q1.number.c_str()) < atoi(q2.number.c_str());
            });
            // 获取所有题目成功,将所有的题目构建成网页
            view_.AllExpandHtml(all, html);
        }
        else
        {
            *html = "获取题目失败,形成题目列表失败";
            ret = false;
        }
        return ret;
    }
    // 获取某个题目的网页
    bool Question(const std::string& number, std::string* html)
    {
        bool ret = true;
        struct Question q;
        if (model_.GetOneQuestion(number, &q))
        {
            // 获取某题成功,构建网页
            view_.OneExpandHtml(q, html);
        }
        else
        {
            *html = "获取的指定题目 " + number + " 不存在";
            ret = false;
        }
        return ret;
    }
};

        两个功能中都需要返回不同的网页内容,就拿第一个获取题库的这个网页来说,我们一定要有一个可以显示题目列表的网页,而且网页中可以获取我们的题库的信息,这该如何做呢?

        我们引入ctemplate工具,这个工具可以帮助我们进行web开发,我们这是用它做一个简单的网页,这个工具的作用为:

        该工具就可以将网页中{{key}}的key值形式转换为字典中value的形式

        首先我们需要创建一个 ctemplate::TemplateDictionary 根字典,再调用TemplateDictionary中的SetValue函数设置字典,之后调用 GetTemplate 获取被渲染的网页,最后调用Expand完成渲染功能。

获取题目列表功能(引入ctemplate)

        我们先来实现AllExpandHtml函数,要有一个简单的网页存放题目列表。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>在线OJ-题目列表</title>
</head>
<body>
    <table>
        <tr>
            <th>编号</th>
            <th>标题</th>
            <th>难度</th>
        </tr>
        <!-- {{#}}为循环,以{{/}}结束 -->
        {{#questions_list}}
        <tr>
            <td>{{number}}</td>
            <td><a href="/question/{{number}}">{{title}}</a></td>
            <td>{{star}}</td>
        </tr>
        {{/questions_list}}
    </table>
</body>
</html>

        将题目列表放到表格中,表格有标题,还有对应的数据,这个数据就是我们要通过ctemplate渲染的,而且还要将所有的题目都放到表格中,这就需要循环。

        网页写完了就是如何使用ctemplate了。

#include <ctemplate/template.h>

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

    const std::string template_path = "./template.html/";

    bool AllExpandHtml(const std::vector<struct Question>& questions, std::string* html)
    {
        // 题目编号 题目标题 题目难度
        // 形成路径,自己设置即可
        std::string src_html = template_path + "all_questions.html";

        // 形成数据字典
        ctemplate::TemplateDictionary root("all_questions");
        for (const auto& q : questions)
        {
            // 添加子字典,循环放到questions_list中,所以名字要写对
            ctemplate::TemplateDictionary* sub = root.AddSectionDictionary("questions_list");
            sub->SetValue("number", q.number);
            sub->SetValue("title", q.title);
            sub->SetValue("star", q.star);
        }

        // 获取被渲染的html,传入要渲染的html路径和Strip选型,DO_NOT_STRIP选项意为保持原貌
        ctemplate::Template* tpl = ctemplate::Template::GetTemplate(src_html, ctemplate::DO_NOT_STRIP);

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

        return true;
    }
};

获取某题的功能

        上一个函数是显示题目列表的,这个函数是为了进入某一个题目进行编码了,刷题的时候,进入某一个题目时会有题目的基本信息和题目描述,还有一个编码区,编码区中还要给用户预设一些代码,我们还是先使用简单的页面测试一下。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{number}}.{{title}}</title>
</head>
<body>
    <h4>{{number}}.{{title}}.{{star}}</h4>
    <p>{{desc}}</p>
    <textarea name="code" id="" cols="30" rows="10">{{pre_code}}</textarea>
</body>
</html>

        OneExpandHtml 和刚才的函数差不多。

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

    const std::string template_path = "./template.html/";

    bool OneExpandHtml(const struct Question& q, std::string* html)
    {
        // 形成路径
        std::string src_html = template_path + "one_question.html";

        // 形成字典
        ctemplate::TemplateDictionary root("one_question");
        root.SetValue("number", q.number);
        root.SetValue("title", q.title);
        root.SetValue("star", q.star);
        root.SetValue("desc", q.desc);
        root.SetValue("pre_code", q.header);

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

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

        return true;
    }
};

        这样写是肯定不好看的,所以只为测试。

判题功能说明

        当我们把代码编译好后,下一步就是提交代码,交给服务器来判断代码是否正确,所以就需要httplib还要有一个服务,就是通过Post方法提交的judge服务。

int main()
{
    // 用户请求的服务路由功能
    Server svr;
    Control ctrl;

    // 用户提交代码,使用判题功能(判断测试用例,compile_and_run)
    svr.Post(R"(/judge/(\d+))", [&ctrl](const Request &req, Response &resp){
        std::string number = req.matches[1];
        std::string result_json;;
        ctrl.Judge(number, req.body, &result_json);
        resp.set_content(result_json, "application/json;charset=utf-8");
    });

    svr.set_base_dir("./wwwroot/");

    svr.listen("0.0.0.0", 8080);

    return 0;
}

        该服务就会调用Judge,Judge中就要有对应的处理。

class Control
{
private:
    Model model_;
    View view_; 
public:
    Control(){}
    ~Control(){}

    // ...

    bool Judge(const std::string& number, const std::string& in_json, std::string* out_json)
    {
        // 根据题目编号直接拿到对应的题目细节

        // in_json反序列化,获取题目id和源代码

        // 重新拼接用户代码 + 测试用例代码,形成完整的代码

        // 选择负载最低的主机

        // 发起http请求,得到结果
        
        // 将结果赋值给out_json
    }
};

        其中的负载均衡式选择主机运行 complie_and_run 服务,虽然我们只有一台服务器,但是我们可以绑定多个端口,模拟多台服务器,那么我们就要有一个service_machine.conf配置文件来存放已有的机器IP和端口号。

负载均衡功能

        下面就是编写负载均衡模块,放到Control模块中即可。既然我们要加载主机,那就要将主机管理起来,那必然要为它创建一个类,因为我们一定会对每个主机的负载做增加、减少和获取的操作,但是有可能在一段时间内会有多个执行流同时访问一个主机,那就要对负载的操作加锁。

// 提供服务的主机
class Machine
{
public:
    std::string ip;  // 编译服务的ip
    uint16_t port;   // 编译服务的port
    uint64_t load;   // 编译服务的负载情况
    std::mutex* mtx; // 通过锁保护某台主机的负载均衡,注意mutex禁止拷贝
public:
    Machine()
        : ip("")
        , port(0)
        , load(0)
        , mtx(nullptr)
    {}
    ~Machine()
    {}

    // 增加主机负载
    void IncLoad()
    {
        if (mtx) mtx->lock();
        ++load;
        if (mtx) mtx->unlock();
    }
    // 减少主机负载
    void DecLoad()
    {
        if (mtx) mtx->lock();
        --load;
        if (mtx) mtx->unlock();
    }
    // 获取主机的负载
    uint64_t Load()
    {
        uint64_t _load = 0;
        if (mtx) mtx->lock();
        _load = load;
        if (mtx) mtx->unlock();
        return _load;
    }
};

        每个都主机有了自己的属性,接下来就是负载均衡的模块。

        将所有主机加载到内存中,其实和加载题库差不多,都是读取配置文件中的内容,按行获取IP和端口号,实例化该主机的对象,添加到vector中。

const std::string service_machine = "./conf/service_machine.conf";
// 负载均衡模块
class LoadBlance
{
private:
    std::vector<Machine> machines; // 提供服务的所有主机
    std::vector<int> online;       // 所有在线主机的下标
    std::vector<int> offline;      // 所有离线主机的下标
    std::mutex mtx;                // 为负载均衡功能加锁
public:
    LoadBlance()
    {
        assert(LoadConf(service_machine));
        LOG(INFO) << "加载 " << service_machine << " 成功" << std::endl;
    }
    ~LoadBlance()
    {}

    // 将所有主机加载进来
    bool LoadConf(const std::string& machine_conf)
    {
        std::ifstream in(machine_conf);
        if (!in.is_open())
        {
            LOG(FATAL) << "加载: " << machine_conf << "失败" << std::endl;
            return false;
        }

        std::string line;
        while (getline(in, line))
        {
            std::vector<std::string> tokens;
            StringUtil::splitString(line, &tokens, ":");
            if (tokens.size() != 2) 
            {
                LOG(WARNING) << "切分 " << line << " 失败" << std::endl;
                continue;
            }
            Machine m;
            m.ip = tokens[0];
            m.port = atoi(tokens[1].c_str());
            m.load = 0;
            m.mtx = new std::mutex();

            online.push_back(machines.size());
            machines.push_back(m);
        }

        in.close();
        return true;
    }
};

        但是LoadMachine最主要的还是选择负载最低的一台主机,从而实现负载均衡,所以我们需要智能地选择主机。既然主机是共享资源,那么存放主机的数组也是共享资源,获取时也要加锁,通过指针获取主机和主机id,因为要拿到主机的地址,所以使用二级指针传参。

// 负载均衡模块
class LoadBlance
{
public:
    bool SmartChoice(int* id, Machine** m) // 两个都是输出型参数
    {
        // 使用负载较低的主机,并每次更新主机的负载情况

        mtx.lock();
        // 选择轮询式的负载均衡算法
        int online_num = online.size();
        if (online_num == 0)
        {
            mtx.unlock();
            LOG(FATAL) << "所有后端编译主机全部离线" << std::endl;
            return false;
        }
        // 找到负载最小的机器
        *id = online[0];
        *m = &machines[online[0]];
        uint64_t min_load = machines[online[0]].Load();
        for (int i = 0; i < online_num; i++)
        {
            uint64_t curr_load = machines[online[i]].Load();
            if (curr_load < min_load)
            {
                min_load = curr_load; 
                *id = online[i];
                *m = &machines[online[i]];
            }
        }
        mtx.unlock();
        return true;
    }
};

判题功能的实现

        有了负载均衡模块,那么Control中就要添加这个成员。判题功能的实现一定会拿到用户提交的代码,我们需要将代码和测试用例进行拼接,构造一个compile_string,再负载均衡式选择一个主机。

        以上工作都做好后就是使用httplib构建一个client,让clinet通过Post方法请求compile_and_run服务。

class Control
{
private:
    Model model_;
    View view_; 
    LoadBlance load_blance_; // 核心负载均衡

public:
    bool Judge(const std::string& number, const std::string& in_json, std::string* out_json)
    {
        // 根据题目编号直接拿到对应的题目细节
        struct Question q;
        model_.GetOneQuestion(number, &q);

        // in_json反序列化,获取题目源代码code和input,放到in_value中
        Json::Reader reader;
        Json::Value in_value;
        reader.parse(in_json, in_value);
        
        // 重新拼接用户代码 + 测试用例代码,形成完整的compile代码,再
        std::string code = in_value["code"].asString(); // 用户提交的代码
        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::FastWriter writer;
        std::string compile_string = writer.write(compile_value);

        // 选择负载最低的主机
        while (true)
        {
            int id = 0;
            Machine* m;
            if (!load_blance_.SmartChoice(&id, &m)) break;
            LOG(INFO) << "选择主机成功, 主机id: " << id << ", 详细: " << m->ip << ":" << m->port << std::endl;
            
            // 发起http请求,得到结果
            Client cli(m->ip, m->port);
            m->IncLoad(); // 增加主机的负载,如果请求服务完成后还要减少主机的负载
            // Post 返回值的表示请求是否成功
            if (auto res = cli.Post("./compile_and_run", compile_string, "application/json;charset=utf-8")) 
            {
                // http只有状态码为200的时候才代表请求成功
                // 只要转到定义就可以看到Post的返回值其实是使用智能指针封装了一下Response
                // 而Response中的status就可以知道状态码为多少
                if (res->status== 200)
                {
                    // 将结果赋值给out_json
                    *out_json = res->body;
                    m->DecLoad();
                    break;
                }
                m->DecLoad();
            }
            else
            {
                // 请求失败
                LOG(ERROR) << "该主机已经离线, 主机id: " << id << ", 详细: " << m->ip << ":" << m->port << std::endl;
                load_blance_.OfflineMachine(id);
            }
        }
        return true;
    }
};

OfflineMachine

        当client通过Post方法请求编译服务时,选择的主机可能请求失败,失败就表示该主机已经离线了,那么我们就要该主机离线,既然负载均衡式选择主机的时候要加锁,那么离线主机的时候也要访问共享资源,所以也要加锁。

// 负载均衡模块
class LoadBlance
{
private:
    std::vector<Machine> machines; // 提供服务的所有主机
    std::vector<int> online;       // 所有在线主机的下标
    std::vector<int> offline;      // 所有离线主机的下标
    std::mutex mtx;                // 为负载均衡功能加锁

public:
    void OfflineMachine(int id)
    {
        mtx.lock();

        for (auto iter = online.begin(); iter != online.end(); iter++)
        {
            if (*iter == id)
            {
                iter = online.erase(iter);
                offline.push_back(id);
                break;
            }
        }

        mtx.unlock();
    }
};

Postman 调试

        现在我们就可以调试一下,用户可以获取题目了,获取完题目后可以提交代码,提交代码也就是请求Judge服务,我们依旧使用Postman来进行测试,但是测试之前还有一个问题需要处理。

        上面我们也说过,我们在设计题库时,有一个header.cpp存放头文件和用户预编写的部分,还有一个tail.cpp存放测试代码,我们需要将这两个部分拼接到一起,但是tail.cpp文件开始有一个条件编译,当初是为了编写测试用例代码不报错而定义了一个宏,我们直接在替换为g++的时候加上“-D”和“COMPILE_ONLINE”,意为定义这个宏。所以编译的时候“#include header.cpp”就不会生效。

class Compiler
{
public:
    static bool Compile(const std::string &file_name)
    {
        pid_t pid = fork();
        if (pid < 0)
        else if (pid == 0)
        {
            // ...
            // 子进程:调用编译器
            // g++ -o exe src -std=c++11
            execlp("g++", "g++", "-o",
                    PathUtil::Exe(file_name).c_str(),
                    PathUtil::Src(file_name).c_str(),
                    "-D",
                    "COMPILE_ONLINE",
                    "-std=c++11",
                    nullptr);

            LOG(ERROR) << "g++启动失败" << std::endl;
            // 进程替换失败就退出
            exit(1);
        }
        else
        {
            // 父进程
        }
    }
};

        下面就是测试的两个案例。

前端页面

        所有的判题逻辑都已经写完了,只要用户提交上来代码,就可以返回一个运行结果,那我们就得先要有一个前端页面,页面美化一下之前写的,这里我们不过多说明,展示一下就可以了。

        这是题库的列表页面,点击题目标题就可以进入编写代码。

        点进来后就是这样的页面,页面的代码是header.cpp中的代码,在代码编辑区编辑代码即可。

        右侧编译代码区使用的是ACE插件,在下面可以设置编辑区的属性,包括编辑区的初始化、主题设置、字体、水平制表符和提示信息等。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{number}}.{{title}}</title>
    <!-- 引入ACE插件 -->
    <!-- 引入ACE CDN -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ace.js" type="text/javascript"
        charset="utf-8"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ext-language_tools.js" type="text/javascript"
        charset="utf-8"></script>
</head>

<body>
    <div class="container">
        <div class="navbar">
            <!-- 导航栏 -->
        </div>

        <div class="part1">
            <!-- 左右分别为题目描述和预设代码 -->
            <div class="left_desc">
                <h3><span id="number">{{number}}</span>.{{title}}_{{star}}</h3>
                <!-- pre 标签表示按照原文件中的编排 -->
                <pre>{{desc}}</pre>
            </div>

            <div class="right_code">
                <pre id="code" class="ace_editor"><textarea class="ace_text-input">{{pre_code}}</textarea></pre>
            </div>
        </div>

        <!-- 提交并且得到结果,并显示 -->
        <div class="part2">
            <div class="result">请先提交代码</div>
            <button class="btn-submit" onclick="submit()">提交代码</button>
        </div>
    </div>

    <script>
        //初始化对象
        editor = ace.edit("code");
        editor.setTheme("ace/theme/light");
        editor.session.setMode("ace/mode/c_cpp");
        // 字体大小
        editor.setFontSize(16);
        // 设置默认制表符的大小:
        editor.getSession().setTabSize(4);
        // 设置只读(true时只读,用于展示代码)
        editor.setReadOnly(false);
        // 启用提示菜单
        ace.require("ace/ext/language_tools");
        editor.setOptions({
            enableBasicAutocompletion: true,
            enableSnippets: true,
            enableLiveAutocompletion: true
        });

        function submit() {
            // 1. 收集当前页面的有关数据, 1. 题号 2.代码
            // 2. 构建json,并向后台发起请求
            // 3. 得到结果,解析并显示到 result中
        }
    </script>
</body>

</html>

        前端的样式设计都是次要的,这个模块最主要的是点击提交按钮,给按钮绑定了跳转函数submit(),只要点击按钮就会跳转到该函数中,函数要实现的功能已经写好了注释。

        要运行判题功能,首要拿到题号和代码区的代码,我们想要获取网页中的内容可以使用JQuery CDN,只要在head引入JQuery CDN就可以使用了。

<head>
    // ...
    <!-- 引入ACE插件 -->
    <!-- 引入ACE CDN -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ace.js" type="text/javascript"
        charset="utf-8"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ext-language_tools.js" type="text/javascript"
        charset="utf-8"></script>
    <!-- 引入JQuery CDN -->
    <script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
    // ...
</head>

        先声明一个code变量,editor中的getSession().getValue可以获取编辑区的代码,使用console.log(code)还可以查看code中是否存放着代码。

<head>
    // ...
    <script>
        // ...
        function submit() {
            // 1. 收集当前页面的有关数据, 1. 题号 2.代码
            var code = editor.getSession().getValue();
            console.log(code);
            // 2. 构建json,并向后台发起请求
            // 3. 得到结果,解析并显示到 result中
        }
    </script>
</head>

        点击提交代码后,右击后点击检查,再点击控制台就可以看到console.log打印的内容,就类似与cout。

        除了代码,还要拿到题号,也就是number,number放在span标签中,id设置为number,也就可以通过$("").text()中设置类选择器获取。

<head>
    // ...
    <script>
        // ...
        function submit() {
            // 1. 收集当前页面的有关数据, 1. 题号 2.代码
            var code = editor.getSession().getValue();
            console.log(code);
            var number = $(".container .part1 .left_desc h3 #number").text();
            console.log(number);
            // 2. 构建json,并向后台发起请求
            // 3. 得到结果,解析并显示到 result中
        }
    </script>
</head>

        题号和代码都已经可以获取了,接下来就是请求后端的判题服务了,请求成功后会得到data,我们也可以打印出来看看。

<script>
    // ...
    function submit() {
        // 1. 收集当前页面的有关数据, 1. 题号 2.代码

        // 2. 构建json,并向后台发起请求,通过ajax基于http的json请求
        $.ajax({
            method: 'Post',   // 向后端发起请求的方式
            url: judge_url,   // 向后端指定的url发起请求
            dataType: 'json', // 告知server要向前端返回的格式
            contentType: 'application/json;charset=utf-8',  // 告知server向后端请求的格式
            data: JSON.stringify({
                'code': code,
                'input': ''
            }),
            success: function(data){
                // 成功得到结果
                console.log(data);
            }
        });
        // 3. 得到结果,解析并显示到 result中

    }
</script>

        既然已经拿到了返回的结果,接下来就要将结果显示到result区域,如果拿到结果,就调用show_result函数,获取result的标签,清空默认显示或上次的显示结果,之后就是显示返回的内容。

<script>
    // ...
    function submit() {
        // ...
        // 2. 构建json,并向后台发起请求,通过ajax基于http的json请求
        $.ajax({
            // ...
            success: function(data){
                // 成功得到结果
                // console.log(data);
                show_result(data);
            }
        });
        // 3. 得到结果,解析并显示到 result中
        function show_result(data)
        {
            // 拿到result结果标签
            var result_div = $(".container .part2 .result")
            // 清空上一次提交后显示的结果
            result_div.empty();

            // 首先拿到结果的状态码和原因
            var _status = data.status;
            var _reason = data.reason;

            var reason_label = $("<p>", {
                text: _reason
            });
            reason_label.appendTo(result_div);

            if (_status == 0){
                // 请求成功,代码交给后端编译后返回的结果
                var _stdout = data.stdout;
                var _stderr = data.stderr;

                var stdout_label = $("<pre>", {
                    text: _stdout
                })
                var stderr_label = $("<pre>", {
                    text: _stderr
                })

                stdout_label.appendTo(result_div);
                stderr_label.appendTo(result_div);
            }
            else{
                // 出错
            }
        }
    }
</script>


整体测试

        现在我们的服务基本上已经写完了,接下来就是整体测试一下。

        当我们迅速点击提交代码三下,可以看到所有的在线主机和离线主机,三个主机通过负载均衡算法都执行了一次编译服务。

        我们关闭第一台机器,再请求两次。

        这次我们关闭所有主机后,再请求一下。

        以上功能我们已经测试完了,现在所有编译服务主机都已经退出了,如果将这些主机重启后,怎样将所有主机都上线呢?

        所有的主机信息都被保存在LoadBlance负载均衡模块中,他们是否上线都保存在online和offline两个数组中,所以OnlineMachine函数就需要将offline数组中的下标移动到online数组中。

// 负载均衡模块
class LoadBlance
{
private:
    std::vector<Machine> machines; // 提供服务的所有主机
    std::vector<int> online;       // 所有在线主机的下标
    std::vector<int> offline;      // 所有离线主机的下标
    std::mutex mtx;                // 为负载均衡功能加锁
public:
    // ...
    void OnlineMachine()
    {
        mtx.lock();

        online.insert(online.end(), offline.begin(), offline.end());
        offline.erase(offline.begin(), offline.end());

        mtx.unlock();

        LOG(INFO) << "所有主机已经上线" << std::endl;
    }

    void OfflineMachine()
    {
    }
};

        如果关闭服务的机器,即便负载均衡获取了这台主机,请求也会失败,失败就会调用OfflineMachine函数,虽然我们已经写过了,但是还是有一些问题。

        如果此时服务器中的请求特别多,正好某台主机已经停止运行了,当重启这台机器的时候,它原来的负载load并没有被重置,所以负载均衡选择服务的时候,这台主机就会因为负载过高而不被选中,修改一下即可。

// 提供服务的主机
class Machine
{
public:
    std::string ip;  // 编译服务的ip
    uint16_t port;   // 编译服务的port
    uint64_t load;   // 编译服务的负载情况
    std::mutex *mtx; // 通过锁保护负载均衡,但是mutex禁止拷贝
public:
    // ...
    void ResetLoad()
    {
        if (mtx) mtx->lock();
        load = 0;
        if (mtx) mtx->unlock();
    }
};

// 负载均衡模块
class LoadBlance
{
private:
    std::vector<Machine> machines; // 提供服务的所有主机
    std::vector<int> online;       // 所有在线主机的下标
    std::vector<int> offline;      // 所有离线主机的下标
    std::mutex mtx;                // 为负载均衡功能加锁
public:
    // ...
    void OfflineMachine()
    {
        mtx.lock();

        for (auto iter = online.begin(); iter != online.end(); iter++)
        {
            if (*iter == id)
            {
                // 离线主机前将该主机负载清零
                machines[id].ResetLoad();
                
                online.erase(iter);
                offline.push_back(id);
                break;
            }
        }

        mtx.unlock();
    }
};

        之后就是调用OnlineMachine函数。

class Control
{
private:
    Model model_;
    View view_;
    LoadBlance load_blance_; // 核心负载均衡
public:
    // ...
    void RecoverMachine()
    {
        load_blance_.OnlineMachine();
    }
};

        这里可以通过信号捕捉到方式,调用信号的捕捉函数,在该函数中调用这个函数即可,信号就捕捉SIGQUIT,使用组合键 Ctrl+\ 就会触发该信号。

 

#include <signal.h>

// 如果将Control对象设置成全局变量,那么请求服务的lambda表达式就无法捕捉该变量了
// 通过这种方式,不管在全局还是局部都可以使用了
static Control* ctrl_ptr = nullptr;

void Recover(int signo)
{
    ctrl_ptr->RecoverMachine();
}

int main()
{
    signal(SIGQUIT, Recover);
    // 用户请求的服务路由功能
    Server svr;

    Control ctrl;
    ctrl_ptr = &ctrl;
    
    // ...
}

        先运行所有服务,之后关闭所有编译服务,可以看到调试信息。

        此时将三台主机重启,并触发SIGQUIT信号,让所有主机上线,服务又可以正常运行了。


引入MySQL

        我们一开始设计题库的时候就只是将所有的题都放到了文件中,再通过一些处理函数拿到对应的数据,这些数据不仅可以放到文件中,也可以放到数据库中,所以我们就将题库放到数据库中,不只有题库,未来如果有了用户管理,还要将用户数据放到数据库中。

  • 在数据库中设计一个可以远程登录的MySQL用户。
    mysql> use mysql;
    mysql> select User, Host from user;
    mysql> create user oj_server_client@'%' identified by '/*密码,自己设置*/';
  • 创建一个数据库。
    mysql> create database oj;
  • 给oj赋权
    mysql> grant all on oj.* to oj_server_client@'%';
  •  退出MySQL,在命令行上输入,只要可以进入即可:
    mysql -uoj_server_client -p
    mysql -uoj_server_client -h127.0.0.1 -p
  • 下一步我们可以使用一些图形化界面工具远程登录MySQL,登录好之后就可以创建数据库了。
    use oj;
    
    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 '题目的难度',
    	q_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;
  •  创建好数据库后,可以先插入一行数据,插入好数据后就可以从数据库中拿数据了。

        要从数据库中拿数据就要先连接数据库,要连接数据库就要先引入mysql的开发包,我们从官网下载一个开源的。当然,可以查看一下 /usr/lib64/mysql/ 目录,如果里面有libmysqlclient库就不用下载了。

        之后我们选择C语言的,选择好系统,下载即可,下载好压缩包后就可以上传到机器上。

        之后使用 tar -zxvf 命令解压到目录中,解压好后,就可以在我们的oj_server目录中建立两个软连接,分别为 mysql-connector/include 和 mysql-connector/lib,建立好后就可以在vscode中看到。

        接下来就是修改我们原来的代码。

oj_model

        和数据打交道的模块是MVC中的model,所以要修改的部分只有oj_model模块。

        这次我们的数据都会放到数据库中,所以需要获取题目的时候访问数据库就行,所以就需要修改一下。

struct Question
{
    std::string number; // 题目编号
    std::string title;  // 题目标题
    std::string star;   // 难度
    std::string desc;   // 题目的描述
    std::string header; // 题目预设给用户在线编译器的代码
    std::string tail;   // 题目的测试用例,要和header拼接
    int cpu_limit;      // cpu时间限制,单位为S
    int mem_limit;      // 空间限制,单位为KB
};

const std::string oj_questions = "oj_questions";

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

    bool QueryMySql(const std::string& sql, std::vector<Question>* out)
    {

    }

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

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

        获取的逻辑已经有了,接下来就是查询数据库,从数据库中拿出数据,如何连接数据库呢,我们就简单介绍一下。

  • 想要使用数据库,必须要先初始化。
    MYSQL *mysql_init(MYSQL *mysql);
  •  之后必须连接数据库,mysql网络部分是基于 TCP/IP 的。
    MYSQL *mysql_real_connect(MYSQL *mysql,              // 初始化获取的mysql句柄
                              const char *host,          // 主机信息
                              const char *user,          // mysql用户信息
                              const char *passwd,        // 该用户的密码
                              const char *db,            // 使用哪个数据库
                              unsigned int port,         // mysql的端口号
                              const char *unix_socket,   // 使用的套接字(Unix)或命名管道(Windows)
                              unsigned long clientflag); // 该值通常为0

    第一个参数就是初始化获取的,该指针中的内容很多,使用数据库都靠它,最后两个参数我们不用直接设置为nullptr 和 0。

  • 在连接数据库后,一定要设置改连接的编码格式,要不然中文就会乱码。
    int mysql_set_character_set(MYSQL *mysql, const char *csname);
  • 连接好数据库就可以执行查询语句了。
    int mysql_query(MYSQL *mysql, const char *sql);

    该函数调用成功返回0。

  • sql执行完以后,如果是update,insert等语句,那么就看下操作是否成功。如果是查询语句,还要读取数据,mysql_query返回成功后,通过mysql_store_result这个函数来读取结果。
    MYSQL_RES *mysql_store_result(MYSQL *mysql);

    该函数会调用 MYSQL变量 中的 st_mysql_methods 中的 read_rows 函数指针来获取查询的结果。同时该函数会返回一个 MYSQL_RES变量,该变量用于保存查询的结果。同时该函数malloc了一片内存空间来存储查询的数据,所以我们一定要记住释放空间,不然肯定会造成内存泄漏。 执行完 mysql_store_result 以后,数据都已经在MYSQL_RES 变量中了,读取数据就是读取 MYSQL_RES 中的数据。

  • 获取完结果后,还需要分析一下结果。
    // 获取结果行数
    my_ulonglong mysql_num_rows(MYSQL_RES *res);
    
    // 获取结果列数
    unsigned int mysql_num_fields(MYSQL_RES *res);
    
    // 获取每行结果内容
    MYSQL_ROW mysql_fetch_row(MYSQL_RES *result); // 该函数为按行获取,也有按列获取的函数

    为什么最后一个说的是每行呢?那是因为它的类型是个二级指针,我们按行读取出来,之后依次获取每一行的每一个元素,这就是查询到的结果。

  • 最后还要关闭mysql连接。
    void mysql_close(MYSQL *mysql);

        接下来就是编写QueryMySql函数了。

const std::string host = "127.0.0.1";
const std::string user = "oj_server_client";
const std::string passwd = "";
const std::string db = "oj";
const int port = 3306; 

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

    bool QueryMySql(const std::string& sql, std::vector<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) << "连接数据库失败!" << std::endl;
            return false;
        }
        // 一定要设置连接的编码格式,不然会乱码
        mysql_set_character_set(my, "utf8");

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

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

        // 检测结果
        MYSQL_RES* res = mysql_store_result(my);

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

        for (int i = 0; i < rows; i++)
        {
            MYSQL_ROW row = mysql_fetch_row(res);
            struct Question q;
            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);
        }

        // 释放结果空间
        delete res;

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

        return true;
    }
};

        到这一步我们的项目就已经算是写完了,如果想要测试,按照之前的方式测试即可。


顶层Makefile

        首先,我们的 compile_server 和 oj_server 在自己的目录下都有Makefile文件,这个顶层文件要做到将两个 Makefile 都执行,开始添加的“@”符号是因为执行依赖方法会在命令行打印这些语句,添加上“@”就不会打印了。

.PHONY:all
all:
	@cd compile_server;\
	make;\
	cd -;\
	cd oj_server;\
	make;\
	cd -;

.PHONY:clean
clean:
	@cd compile_server;\
	make clean;\
	cd -;\
	cd oj_server;\
	make clean;\
	cd -;

        此外,我们的项目写完了,还要有一个打包上线的功能,一个软件想要上线,首先就要有可执行程序,两个可执行程序 compile_server 和 oj_server,还有编译服务保存的临时文件夹(即使没有,运行的时候也会创建),编译运行服务的主机和端口号的配置文件,还有题库文件(使用MySQL也不用这个文件夹),我们引入MySQL后放到目录中的动态库,最后还有网页,这些文件都要打包放到一个文件夹里,按类别放好。

.PHONY:all
all:
	@cd compile_server;\
	make;\
	cd -;\
	cd oj_server;\
	make;\
	cd -;

.PHONY:output
output:
	@mkdir -p output/compile_server;\
	mkdir -p output/oj_server;\
	cp -rf compile_server/compile_server output/compile_server/;\
	cp -rf compile_server/temp output/compile_server/;\
	cp -rf oj_server/oj_server output/oj_server/;\
	cp -rf oj_server/conf output/oj_server/;\
	cp -rf oj_server/lib output/oj_server/;\
	cp -rf oj_server/questions output/oj_server/;\
	cp -rf oj_server/template.html output/oj_server/;\
	cp -rf oj_server/wwwroot output/oj_server/;\

.PHONY:clean
clean:
	@cd compile_server;\
	make clean;\
	cd -;\
	cd oj_server;\
	make clean;\
	cd -;
	rm -rf output;

总结

        到这里我们的项目主体已经写完了,现在我们来总结一下整个项目使用到的技术,使用的C/C++ 和 Linux 中的系统接口是必须的,还有 STL 标准库,除了STL,还使用了一点 boost 库中的函数。

        整套网络服务我们使用的是 httplib 第三方开源库。前后端交换数据使用json,使用了jsoncpp第三方开源序列化、反序列化库。网页中需要的数据使用ctemplate第三方开源前端网页渲染库。最后我们把所有的数据都放到了MySQL中,使用了MySQL C connent,还使用了一个MySQL的图形化操作工具 MySQL Workbench

        虽然代码中没有明显的多线程,但是我们使用的 httplib 中就是一个基于多线程的,里面也使用了原生线程库。其中还引入了负载均衡,选取多台主机中负载最低的一台进行服务。

        此外也引入了ACE在线编译器,写了简单的网页,简单了解了js、jquery 和 ajax

        下面就是整个项目的思维导图,可以梳理整个项目的思路。

项目源码: https://gitee.com/du-shunhao-1/linux/tree/master/OnlineJudge
转载请注明出处或者链接地址:https://www.qianduange.cn//article/17141.html
标签
评论
发布的文章

npm install 报错解决记录

2024-09-09 00:09:08

大家推荐的文章
会员中心 联系我 留言建议 回顶部
复制成功!