🌠 作者:@阿亮joy.
🎆专栏:《项目设计》
🎇 座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
目录
- 👉项目地址👈
- 👉所用技术与开发环境👈
- 👉项目宏观结构👈
- LeetCode 结构
- 项目各模块之间的关系
- 项目编写思路
- 👉compiler 服务设计👈
- 第一个功能:compiler 编译功能
- 第二个功能:runner 运行功能
- 资源限制
- 认识 Jsoncpp
- 第三个功能:compile_run 编译并运行功能
- 清理临时文件
- 认识 cpp-httplib
- 把编译并运行功能形成网络服务
- 👉基于 MVC 结构的 OJ 服务设计👈
- 第一个功能:用户请求的服务路由功能
- 文件版题目设计
- 第二个功能:model 功能,提供对数据的操作
- 认识 ctemplate
- 第三个功能:view 功能展现用户界面
- 第四个功能:controller 逻辑控制模块
- 第五个功能:负载均衡功能
- 👉前端页面设计👈
- 丐版首页
- 所有题目的列表
- 指定题目的代码编写页面
- 👉MySQL 版题目设计👈
👉项目地址👈
项目源码链接: https://gitee.com/DreamyHorizon/online-judge
👉所用技术与开发环境👈
所用技术:
- C++ STL 标准库:string 和 unordered_map 等
- Boost 准标准库:主要用来处理字符串的切分
- cpp-httplib 第三方开源网络库:可以直接使用 http 的服务端和客户端
- ctemplate 第三方开源前端网页渲染库:进行网页渲染。
- jsoncpp 第三方开源序列化、反序列化库
- 负载均衡设计
- 多进程、多线程
- MySQL C connect:实现数据库版的负载均衡式 OJ。
- Ace 前端在线编辑器(了解)
- html/css/js/jquery/ajax (了解)
开发环境:
- Centos 7 云服务器
- vscode
- Mysql Workbench
👉项目宏观结构👈
负载均衡式在线 OJ 这个项目核心是三个模块:
- comm:公共模块,提供文件操作、字符串处理和网络请求等等。
- compile_server:编译与运行模块,对用户提交的文件进行编译生成临时文件并运行,得到运行结果。
- oj_server:获取题目列表,查看题目,编写题目界面,负载均衡,其他功能。
LeetCode 结构
本项目暂时只实现类似 LeetCode 的题目列表和在线编程功能。
题目列表
在线编程
项目各模块之间的关系
- OjServer 中包含 oj_server 和 compile_server、题目列表文件或者数据库。其中 oj_server 主要负责题目列表的获取、查看题目和编写题目的界面以及负载均衡选择一个编译服务来编译并运行用户提交的代码等。 compile_server 主要就是负责编译和运行 oj_server 收到的代码并将结果返回给 oj_server。
- 客户端主要是由浏览器来充当,客户端的主要请求有请求题目列表、请求特定题目的编写以及提交代码等。当客户端发来的请求时请求题目列表或请求特定题目的编写,oj_server 只需要将文件版的题目列表或 MySQL 中存储的题目列表返回给用户即可。而当用户提交代码时,oj_server 就需要负载均衡地选择一个编译服务 compile_server 进行编码的编译和运行,将编译或运行的结果返回给 oj_server,oj_server 再将结果返回给用户。
项目编写思路
- 先进行编译服务 compile_server 的编写,这个模块的内容主要是系统编程和网络编程的知识。
- 然后再来编写 oj_server。
- 再然后就是设计出基于文件版的在线 OJ。
- 接着就是前端页面的设计,这部分内容只需了解即可,有时间的话可以深入地学习一下。
- 最后就是引入数据库,设计出基于 MySQL 的在线 OJ。
👉compiler 服务设计👈
compiler 提供的服务就是将 oj_server 获取到的用户代码进行编译并运行代码,将结果进行格式化并返回给 oj_server。
编译服务的整体工作流程
- 编译服务收到远端提交过来的代码时,首先要调用一些设计好的接口来形成临时的代码文件,然后才能进行代码的编译。
- 编译的方式时通过 fork 创建子进程的方式来编译的,当进行编译时,就有可能会出现编译错误。而编译错误信息是会向标准输出上打印,但我们不能让其打印在标准输出上,这时候就要将错误信息重定向到临时文件 Stderr 中。如果代码编译成功,则由运行模块进行代码的运行,然后将代码运行的结果会被重定向到临时文件 Stdout 中。
- 如果编译成功,那么 Stdout 文件中的内容将会被读取,经过一系列的处理将代码运行的结果返回给用户。如果编译失败,那么 Stderr 文件的内容将会被读取,将导致编译错误的原因返回给用户。
第一个功能:compiler 编译功能
// compile_server/compiler.hpp#pragma once#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#include "../comm/util.hpp" // 引入公共的方法
#include "../comm/log.hpp" // 引入日志功能// 该模块只负责代码的编译namespace ns_compiler // 带上命名空间, 方便管理以及避免命名冲突
{using namespace ns_util; // 引入公共的方法using namespace ns_log; // 引入日志功能class Compiler{public:Compiler(){}~Compiler(){}// file_name: 输入参数,需要编译代码的文件名(不带.cpp后缀)// file_name: 1234// 1234 -> ./temp/1234.cpp (源文件)// 1234 -> ./temp/1234.exe (可执行程序)// 1234 -> ./temp/1234.stderr (标准错误文件)// ./temp 是存放临时文件的文件夹// Compile 函数的功能是对存在的源文件进行编译// 返回值: 编译成功, 返回 true; 编译失败, 返回 falsestatic bool Compile(const std::string& file_name){pid_t id = fork(); // 创建子进程来负责编译if(id < 0){LOG(ERROR) << "编译时创建子进程失败" << "\n";return false; // 创建子进程失败, 直接返回 false}else if(id == 0) // 子进程{umask(0);// 创建存储编译错误信息的文件int _stderr = open(PathUtil::CompilerError(file_name).c_str(), O_CREAT | O_WRONLY, 0644); // 创建标准错误文件if(_stderr < 0){LOG(WARNING) << "创建 stderr 文件失败" << "\n";exit(1); // 创建文件失败, 直接退出子进程}// 将编译错误信息重定向到 _stderr 中dup2(_stderr, 2); // 程序替换并不会影响进程的文件描述符表// 子进程: 调用编译器完成对代码的编译工作// l: 可变参数列表// p: 不需要执行可执行程序所在的路径,只需要指明可执行程序的名字即可// g++ -o target src -std=c++11execlp("g++", "g++", "-o", PathUtil::Exe(file_name).c_str(),\PathUtil::Src(file_name).c_str(), "-std=c++11", nullptr/*以 nullptr 表示参数的结束*/);LOG(ERROR) << "启动编译器失败, 传入的参数可能有误" << "\n";exit(2); // 进程替换失败}else // 父进程{// 父进程阻塞等待子进程退出waitpid(id, nullptr, 0);// 编译是否成功可以通过是否形成了可执行程序来判断if(FileUtil::IsFileExists(PathUtil::Exe(file_name))){LOG(INFO) << PathUtil::Src(file_name) << " 文件编译成功" << "\n";return true;}}LOG(ERROR) << "编译失败, 没有形成可执行程序" << "\n";return false; // 编译失败}};
}
功能详解
- compiler.hpp 这个模块只负责代码(源文件)的编译,其中包含了 Compiler 类,该类只包含用于编译的 Compile 函数。该函数将来会被 compile_run.hpp 中的 XX 函数调用。在 Compile 函数中,会创建子进程进行代码的编译。如果编译出错,会将错误信息重定向到 compiler_error文件中。
- 因为 Compile 函数的参数是不带后缀的文件名,所以在 Compile 函数中要调用 util.hpp 中的 PathUtil 类的静态方法加上后缀名。同时 Compile 函数中还引入了日志功能,将是否完成编译记录下来,方便进行开发人员的监测。
公共方法
// comm/util.hpp#pragma once#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/time.h>namespace ns_util
{const std::string temp_path = "./temp/"; // 临时文件所在的路径// PathUntil 是用来构建文件路径的类class PathUtil{public:// 添加后缀名static std::string AddSuffix(const std::string& file_name, const std::string& suffix){std::string path_name = temp_path;path_name += file_name;path_name += suffix;return path_name;}// 构建源文件路径+后缀的完整文件名// 1234 -> ./temp/1234.cppstatic std::string Src(const std::string& file_name){return AddSuffix(file_name, ".cpp");}// 构建可执行程序路径+后缀的完整文件名// 1234 -> ./temp/1234.exestatic std::string Exe(const std::string& file_name){return AddSuffix(file_name, ".exe");}// 构建编译错误文件路径+后缀的完整文件名// 1234 -> ./temp/1234.compiler_errorstatic std::string CompilerError(const std::string& file_name){return AddSuffix(file_name, ".compiler_error");}};// FileUtil 是用于文件操作的类class FileUtil{public:// 判断文件是否存在static bool IsFileExists(const std::string& file_name){struct stat st;if(stat(file_name.c_str(), &st) == 0){// 获取文件属性成功, 则说明文件存在return true;}return false; // 获取文件属性失败, 则说明文件不存在}};// TimeUtil 是与时间操作相关的类class TimeUtil{public:// 将时间戳转换成字符串static std::string GetTimeStamp(){struct timeval _time;gettimeofday(&_time, nullptr);return std::to_string(_time.tv_sec);}};
}
IsFileExists 和 GetTimeStamp 函数的介绍
- IsFileExists 函数是用来判断文件是否存在的函数,其中调用了 stat 函数。stat 函数是一个用于获取文件或目录信息的系统调用函数。它提供了关于文件的详细元数据,如文件类型、权限、大小、最后访问时间等信息。stat 函数的原型如下:
int stat(const char *pathname, struct stat *buf);
- 其中,
pathname
是一个指向要获取信息的文件或目录路径的字符串,buf
是一个指向struct stat
类型的指针,用于存储返回的文件信息。 struct stat
结构体包含了许多字段,用于存储不同的文件信息。下面是struct stat
的定义:
struct stat
{dev_t st_dev; // 文件的设备编号ino_t st_ino; // 文件的i-node节点号mode_t st_mode; // 文件的类型和权限nlink_t st_nlink; // 文件的硬链接数uid_t st_uid; // 文件的所有者IDgid_t st_gid; // 文件的所属组IDdev_t st_rdev; // 若文件为设备文件,则为其设备编号off_t st_size; // 文件的大小(以字节为单位)blksize_t st_blksize; // 文件系统的块大小blkcnt_t st_blocks; // 分配给文件的块数量time_t st_atime; // 文件的最后访问时间time_t st_mtime; // 文件的最后修改时间time_t st_ctime; // 文件的最后状态改变时间
};
- 如果返回值为 0,则表示调用成功,文件信息将被填充到传入的
struct stat
结构体中,也就说明文件存在;如果返回值为 -1,则表示调用失败,可能是由于文件不存在或者权限不足等原因。
- GetTimeStamp 函数是将时间戳转换成字符串的函数,其中调用了 gettimeofday 函数。gettimeofday 函数用于获取当前的时间和日期信息,包括秒数和微秒数。它提供了比较高精度的时间信息,通常用于计时、时间戳生成和性能分析等场景。gettimeofday 函数的原型如下:
int gettimeofday(struct timeval *tv, struct timezone *tz);
- 其中,
tv
是一个指向struct timeval
类型的指针,用于存储获取到的时间信息。tz
是一个指向struct timezone
类型的指针,用于存储时区信息,通常可以传递为nullptr
。struct timeval
结构体定义如下:
struct timeval
{time_t tv_sec; // 秒数suseconds_t tv_usec; // 微秒数
};
tv_sec
字段存储了从 1970 年 1 月 1 日起的秒数,tv_usec
字段存储了微秒数。gettimeofday 函数会将当前的时间信息填充到传入的struct timeval
结构体中。成功调用时,返回值为 0;失败时,返回值为 -1,并设置相应的错误码。
日志功能
注:该日志功能是将日志信息直接向显示屏上打印的,也可以将日志信息写入到日志文件中。
#pragma once#include <iostream>
#include <string>
#include "util.hpp"namespace ns_log
{using namespace ns_util;// 日志等级enum{INFO, // 用于传递信息的等级DEBUG, // 用于 debug WARNING, // WARNING 并不会影响后续代码的运行ERROR, // 代码出现错误FATAL // 系统无法运行, 不能再提供服务了};inline std::ostream& Log(const std::string& level, const std::string& file_name, int line){// 添加日志等级std::string message = "[";message += level;message += "] ";// 添加调用 Log 函数的文件名称message += "[";message += file_name;message += "] ";// 添加调用 Log 函数的行号message += "[";message += std::to_string(line);message += "] ";// 添加时间戳message += "[";message += TimeUtil::GetTimeStamp();message += "] ";// cout 内部是包含缓冲区的std::cout << message; // 不要使用 endl 进行刷新return std::cout;}// # 加上宏参数可以将其转换成字符串// 开放式日志调用方式: LOG(level) << "message" << "\n";#define LOG(level) Log(#level, __FILE__, __LINE__)
}
#
加上宏参数的作用
#
加上宏参数是一种预处理器操作符,称为字符串化操作符(stringification operator)。它的作用是将宏参数转换为字符串常量。当宏的参数被使用在带有#的宏定义中时,预处理器会将参数转换为字符串,并在编译时替换宏中的#操作符和参数。这样,在宏的定义中可以将参数作为字符串来处理,而不仅仅是其本身的值。
Makefile
compile_server:compile_server.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -f compile_server
功能测试
进行编译功能测试前,需要在 compile_server 目录下新建 temp 目录,然后再手动写一个代码来进行测试编译功能。
然后在 compile_server.cc 中调用 Compiler 类的静态方法 Compile 对代码进行编译,生成可执行程序或编译出错并将错误信息写入到 compiler_error 文件中。
- 成功编译生成可执行程序并运行可执行程序
- 编译失败并将错误信息写入到 compiler_error 文件中
第二个功能:runner 运行功能
运行功能是通过 fork 创建子进程的方式来运行已经生成好的可执行程序,而运行结果会有三种情况,分别是运行完结果正确、运行完结果错误和运行时出现异常。而运行结果需要输出到临时文件 stdout 和 stderr 临时文件中。注:生成的临时文件 stdin 是没有什么用处的,不需要进行任何的处理。
// compile_server/runner.hpp
#pragma once#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include "../comm/util.hpp"
#include "../comm/log.hpp"namespace ns_runner
{using namespace ns_util;using namespace ns_log;class Runner{public:Runner() {}~Runner() {}public:/****************************************************** 程序运行结果 * 1. 代码跑完, 结果正确* 2. 代码跑完, 结果不正确 * 3. 代码没跑完, 出现异常 * Run 需要考虑代码是否跑完, 不需要考虑运行结果是否正确 * 运行结果是否正确, 是由测试用例决定的* * 一个可执行程序启动是默认会打开* 标准输入: 不进行处理* 标准输出: 程序的运行结果* 标准错误: 程序运行时的错误信息* * 返回值大于 0, 程序出现异常, 收到了信号, 返回值就是对应的信息编号* 返回值等于 0, 程序运行完毕, 结果保存在对应的临时文件中* 返回值小于 0, 出现了内部错误******************************************************/// 指明文件名即可, 不需要带路径和后缀static int Run(const std::string& file_name){// 构建临时文件的文件名std::string _excute = 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); // 设置文件权限掩码// 在 temp 目录下创建临时文件int _stdin_fd = open(_stdin.c_str(), O_CREAT | O_RDONLY, 0644);int _stdout_fd = open(_stdout.c_str(), O_CREAT | O_WRONLY, 0644);int _stderr_fd = open(_stderr.c_str(), O_CREAT | O_WRONLY, 0644);if(_stdin_fd < 0 || _stdout_fd < 0 || _stderr_fd < 0){LOG(ERROR) << "运行时打开标准文件失败" << "\n";return -1; // 文件打开失败}pid_t pid = fork();if(pid < 0){ LOG(ERROR) << "运行时创建子进程失败" << "\n";close(_stdin_fd);close(_stdout_fd);close(_stderr_fd);return -2; // 子进程创建失败}else if(pid == 0){// 重定向dup2(_stdin_fd, 0);dup2(_stdout_fd, 1);dup2(_stderr_fd, 2);// 进程替换执行可执行程序execl(_excute.c_str()/*要执行的程序*/, _excute.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; // 判断程序运行时是否收到信号}}};
}
添加公共方法
class PathUtil
{
public:// 运行时生成的临时文件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");}// 构建标准错误文件路径+后缀的完整文件名// 1234 -> ./temp/1234.stderrstatic std::string Stderr(const std::string& file_name){return AddSuffix(file_name, ".stderr");}
};
功能测试
- 在 compile_server.cc 中调用 Compiler 类的静态方法 Compile 对代码进行编译生成可执行程序,然后再调用 Runner 类 中的 静态方法 Run 运行可执行程序。运行的结果会被写入到 stdout 和 stderr 临时文件中。
// compile_server/compile_server.cc
#include "compiler.hpp"
#include "runner.hpp"
#include "../comm/util.hpp"using namespace ns_compiler;
using namespace ns_runner;
using namespace ns_util;int main()
{std::string code = "code";Compiler::Compile(code);// 生成了可执行程序, 才启动 Run 运行功能if(FileUtil::IsFileExists(PathUtil::Exe(code))) {Runner::Run(code);}return 0;
}
- 运行 compile_server 程序
- 查看运行结果
资源限制
在 OJ 平台上刷题的时候,那些题目往往会现实我们所使用的资源,而资源包括内存资源和 CPU 资源等。那么我们也要对用户所能使用的内存资源 和 CPU 资源进行限制。
setrlimit 函数的介绍
setrlimit 函数用于设置进程的资源限制,即调整进程能够使用的系统资源的限制值。这些资源可以包括进程的文件描述符数量、CPU 时间限制、内存限制等。通过设置资源限制,可以对进程的资源使用进行控制和管理。setrlimit 函数的原型如下:
int setrlimit(int resource, const struct rlimit *rlim);
resource
参数指定要设置的资源类型,可以是以下常量之一:
-
RLIMIT_CPU:进程的 CPU 时间限制(秒)。
-
RLIMIT_AS:进程的虚拟内存的限制(字节)。
-
RLIMIT_FSIZE:进程能创建的文件的最大字节数。
-
RLIMIT_DATA:进程数据段的最大字节数。
-
RLIMIT_STACK:进程栈的最大字节数。
-
RLIMIT_CORE:核心文件的最大字节数。
-
RLIMIT_NOFILE:进程能打开的最大文件描述符数量。
-
等等,还有其他可选的资源类型。
rlim
参数是一个指向struct rlimit
结构体的指针,用于指定资源的限制值。
struct rlimit
结构体定义了资源的软限制和硬限制:
struct rlimit
{rlim_t rlim_cur; // 软限制(当前限制值)rlim_t rlim_max; // 硬限制(最大限制值)
};
- rlim_cur 字段表示资源的软限制,即当前限制值。它指定了进程目前允许使用的资源数量。
- rlim_max 字段表示资源的硬限制,即最大限制值。它指定了资源的最大可用数量。通常设置为 RLIM_INFINITY,表示无穷。
- setrlimit 函数成功返回 0,表示设置资源限制成功;失败返回 -1,并设置适当的错误码。
测试资源限制
测试代码
#include <iostream>
#include <sys/resource.h>
#include <sys/time.h>
#include <signal.h>
#include <unistd.h>void handler(int signal)
{std::cout << "signal: " << signal << std::endl;
}int main()
{// 超出资源限制会导致操作系统使用信号来终止进程// 对信号进行捕捉// for(int i = 1; i <= 31; ++i) signal(i, handler);// 限制累计运行时长struct rlimit r;r.rlim_cur = 1; // 1sr.rlim_max = RLIM_INFINITY;setrlimit(RLIMIT_CPU, &r);while(1); // 死循环// // 对虚拟内存进行限制 // struct rlimit r;// r.rlim_cur = 1024 * 1024 * 40; // 40M// r.rlim_max = RLIM_INFINITY;// setrlimit(RLIMIT_AS, &r);// int count = 0;// while(true)// {// int *p = new int[1024 * 1024];// count++;// std::cout << "count: " << count << std::endl;// sleep(1);// } return 0;
}
查看资源超出限制的结果
资源超出限制时操作系统所发送的信号
注:程序加载时所使用的虚拟内存也会被算入到虚拟内存限制中。
给 runner 添加资源限制
因为每道题目的资源限制都是不一样的,所以我们给 Runner 类的静态方法多加两个参数 cpu_limit 和 mem_limit,分别表示 CPU 限制和虚拟内存限制。
// compile_server/runner.hpp
#pragma once#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>
#include "../comm/util.hpp"
#include "../comm/log.hpp"namespace ns_runner
{using namespace ns_util;using namespace ns_log;class Runner{public:Runner() {}~Runner() {}public:// mem_limit 的单位是 KBstatic void SetProcLimit(int cpu_limit, int mem_limit){struct rlimit _cpu_limit;_cpu_limit.rlim_cur = cpu_limit;_cpu_limit.rlim_max = RLIM_INFINITY;setrlimit(RLIMIT_CPU, &_cpu_limit);struct rlimit _mem_limit;_mem_limit.rlim_cur = mem_limit * 1024; // 转换为 KB_mem_limit.rlim_max = RLIM_INFINITY;setrlimit(RLIMIT_AS, &_mem_limit);} /****************************************************** 程序运行结果 * 1. 代码跑完, 结果正确* 2. 代码跑完, 结果不正确 * 3. 代码没跑完, 出现异常 * Run 需要考虑代码是否跑完, 不需要考虑运行结果是否正确 * 运行结果是否正确, 是由测试用例决定的* * 一个可执行程序启动是默认会打开* 标准输入: 不进行处理* 标准输出: 程序的运行结果* 标准错误: 程序运行时的错误信息* * 返回值大于 0, 程序出现异常, 收到了信号, 返回值就是对应的信息编号* 返回值等于 0, 程序运行完毕, 结果保存在对应的临时文件中* 返回值小于 0, 出现了内部错误******************************************************/// 指明文件名即可, 不需要带路径和后缀// cpu_limit: 该进程运行时, 可以使用的最大的 CPU 资源限制// mem_limit: 该进程运行时, 可以使用的最大的内存资源限制(KB)static int Run(const std::string& file_name, int cpu_limit, int mme_limit){// 构建临时文件的文件名std::string _excute = PathUtil::Exe(file_name);std::string _stdin = PathUtil::Stdin(file_name); // 该文件暂时用不上std::string _stdout = PathUtil::Stdout(file_name);std::string _stderr = PathUtil::Stderr(file_name);umask(0);int _stdin_fd = open(_stdin.c_str(), O_CREAT | O_RDONLY, 0644);int _stdout_fd = open(_stdout.c_str(), O_CREAT | O_WRONLY, 0644);int _stderr_fd = open(_stderr.c_str(), O_CREAT | O_WRONLY, 0644);if(_stdin_fd < 0 || _stdout_fd < 0 || _stderr_fd < 0){LOG(ERROR) << "运行时打开标准文件失败" << "\n";return -1; // 文件打开失败}pid_t pid = fork();if(pid < 0){ LOG(ERROR) << "运行时创建子进程失败" << "\n";close(_stdin_fd);close(_stdout_fd);close(_stderr_fd);return -2; // 子进程创建失败}else if(pid == 0){// 重定向dup2(_stdin_fd, 0);dup2(_stdout_fd, 1);dup2(_stderr_fd, 2);// 设置资源限制SetProcLimit(cpu_limit, mme_limit);// 进程替换执行可执行程序execl(_excute.c_str()/*要执行的程序*/, _excute.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; // 判断程序运行时是否收到信号}}};
}
认识 Jsoncpp
Jsoncpp 的介绍
Jsoncpp 是一个用于处理 JSON 数据的 C++ 库。它提供了一组简单而强大的 API,用于解析、构建和操作J SON 数据。Jsoncpp 具有跨平台性、易于使用和高性能等特点,是处理 JSON 数据的常用工具之一。
以下是 Jsoncpp 库的一些特点和功能:
-
解析和序列化:Jsoncpp 能够将 JSON数据解析为 C++ 对象,并且可以将 C++ 对象序列化为 JSON 字符串。
-
支持标准 JSON:Jsoncpp 符合 JSON(JavaScript Object Notation)标准规范,可以处理包括对象、数组、字符串、数字、布尔值和 null 值等在内的所有 JSON 数据类型。
-
高性能:Jsoncpp 在解析和序列化 JSON 数据时具有较高的性能,并且在大多数场景下表现出很好的效率。
-
面向对象的 API:Jsoncpp 提供了面向对象的 API,使得操作 JSON 数据更加方便和直观。可以通过访问 JSON 对象的属性、数组元素和值来操作 JSON 数据。
-
错误处理:Jsoncpp 提供了错误处理机制,能够检测和处理 JSON 数据解析过程中的错误,包括语法错误和数据类型错误等。
-
跨平台支持:Jsoncpp 可以在多个平台上使用,包括 Linux、Windows、Mac 和其他操作系统。
Jsoncpp 库的安装
sudo yum install -y jsoncpp-devel
Jsoncpp 库的使用
- Json::Value 类是 Jsoncpp 库中的一个核心类,用于表示和操作 JSON 数据。它是一个多态类,可以表示 JSON 对象、数组和值,并提供了一系列的方法来访问、操作和构建 JSON 数据。
- 在 Jsoncpp 库中,Json::StyledWriter 和 Json::FastWriter 是用于将 Json::Value 对象序列化为 JSON 字符串的两个类。它们具有不同的特点和用途。
- Json::StyledWriter 生成的 JSON 字符串具有格式化和可读性,包括缩进、换行符和可读性的空白字符。适合用于调试、日志记录和人类可读的输出。
- Json::FastWriter 生成的 JSON 字符串紧凑,没有额外的空白字符、缩进和换行符,适合在网络传输和存储中使用。它提供了快速的 JSON 序列化能力,并且生成的字符串较小。
#include <iostream>
#include <jsoncpp/json/json.h>using namespace std;int main()
{// Value 是一个 Json 的中间类, 可以填充 KV 值// Json 通常用于网络通信中, 将传输的数据进行序列化和反序列化Json::Value person;person["name"] = "John Doe";person["age"] = 30;person["city"] = "New York";Json::StyledWriter writer1;string str1 = writer1.write(person);cout << "\nstr1:\n" << str1 << endl;Json::FastWriter writer2;string str2 = writer2.write(person);cout << "\nstr2:\n" << str2 << endl;return 0;
}
第三个功能:compile_run 编译并运行功能
软件结构
该模块主要的作用如下:
- 适配用户请求,通过 Json 来定制通信协议字段。
- 通过毫秒级时间戳加上原子性递增的值来形成唯一的文件名。
- 正确调用 Compile 方法和 Run 方法,编译并运行用户的代码,将运行结果保存起来供后续使用。
// compile_server/compile_run.hpp
#pragma once#include "compiler.hpp"
#include "runner.hpp"
#include "../comm/log.hpp"
#include "../comm/util.hpp"
#include <jsoncpp/json/json.h>
#include <signal.h>namespace ns_compile_and_run
{using namespace ns_log;using namespace ns_util;using namespace ns_compiler;using namespace ns_runner;class CompileAndRun{public:// 将信号编号转成出错原因// status > 0: 进程收到信号然后崩溃// status < 0: 非运行时出错// status = 0: 整个过程(编译 + 运行)全部完成static std::string StatusToDesc(int status){std::string desc;switch(status){case -1:desc = "用户提交的代码为空";break;case -2:// 服务端出现了形成源文件失败或尝试运行程序时出错// 这些错误不需要呈现给用户desc = "出现未知错误";break;// case -3:// desc = "编译出错";// break; case 0:desc = "编译并运行成功";break;case SIGABRT: // 6 号信号desc = "使用的内存资源超出限制";break;case SIGFPE: // 8 号信号desc = "浮点数异常";break;case SIGSEGV: // 11 号信号desc = "发生段错误";break;case SIGXCPU: // 24 号新号desc = "使用的 CPU 资源超出限制";break;default:desc = "未知状态码: " + std::to_string(status); break;}return desc;}/********************************************* 输入: * code: 用户提交的代码* input: 用户提交的代码所对应的输入, 该字段不进行处理* cpu_limit: 时间要求* mem_limit: 空间要求* * 输出:* 必填字段* status: 状态码* reason: 出错原因* 选填字段* stdout: 程序运行完的输出结果* stderr: 程序运行完的错误结果* 输入型参数 in_json: {"code":"#include...", "input":"", "cpu_limit":3, "mem_limit":10240}* 输出型参数 out_json: {"status":0, "reason":"", "stdout":"...", "stderr":"..."}********************************************/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(); // CPU 资源限制int mem_limit = in_value["mem_limit"].asInt(); // 内存资源限制Json::Value out_value;int status = 0; // 表示错误原因的状态码int ret = 0; // 保存运行的结果std::string file_name; // 唯一文件名if(code.size() == 0) // 用户没有写代码{status = -1;goto END;}// 形成唯一文件名(没有路径和后缀)// 文件名: 毫秒级时间戳 + 原子性递增的唯一值file_name = FileUtil::UniqueFileName();if(!FileUtil::WriteFile(PathUtil::Src(file_name), code)) // 形成临时源文件, 其保存在 temp 目录下{status = -2;// 形成源文件失败, 该错误原因不需要呈现给用户goto END;}if(!Compiler::Compile(file_name)) // 对源代码进行编译{// 代码编译时出现错误status = -3;goto END;}if(FileUtil::IsFileExists(PathUtil::Exe(file_name))) // 判断是否形成可执行程序{ret = Runner::Run(file_name, cpu_limit, mem_limit); // 执行程序if(ret < 0){// 尝试运行可执行程序的过程中发生了错误status = -2;}else if(ret > 0){// 运行用户的代码时出现了异常status = ret; // 此时 ret 是信号编号}else{// 运行成功status = 0;}}// 统一在这里进行差错处理END:// 设置状态码out_value["status"] = status;if(status == -3) // 编译报错, 读取编译错误信息{std::string desc;FileUtil::ReadFile(PathUtil::CompilerError(file_name), &desc, true);out_value["reason"] = desc;}else // 将状态码转换成描述信息out_value["reason"] = StatusToDesc(status);// 整个过程(编译 + 运行)全部成功if(status == 0) {// _stdout 和 _stderr 为输出型参数std::string _stdout;std::string _stderr;FileUtil::ReadFile(PathUtil::Stdout(file_name), &_stdout, true);FileUtil::ReadFile(PathUtil::Stderr(file_name), &_stderr, true);out_value["stdout"] = _stdout;out_value["stderr"] = _stderr;}// 序列化Json::StyledWriter writer;*out_json = writer.write(out_value);}};
}
Start 函数的介绍
-
Start 函数有两个参数,分别是 in_json 和 out_json,这两个参数都是 JSON 格式的字符串。其中,in_json 为输入型参数,该字符串中包含了用户提交的代码 code、用户代码对应的输入 input(该字段不会进行处理)、CPU 资源限制 cpu_limit 和内存资源限制 mem_limit。而 out_json 为输出型参数,该字符串包含了两个必填字段状态码 status 和出错原因 reason、两个选填字段程序运行完的输出结果 stdout 和程序运行完的错误结果 stderr,选填字段只有在程序运行完的情况下才会被填写。
-
Start 函数首先通过 Json::Reader 将 in_json 反序列化到 in_value 中,然后通过 asString 等函数将 code、input、cpu_limit 和 mem_limit 等字段提取出来。
-
如果用户提交的代码 code 为空,将 status 设置为 -1,表示用户没有写代码。当用户写了代码时,则调用文件操作类 FileUtil 的静态方法 UniqueFileName 形成唯一文件名。然后再给该文件名添加上后缀,并调用 FileUtil 的静态方法 WriteFile 将代码写入到临时的源文件中。如果形成临时源文件失败,将 status 设置为 -2,表示出现了不需要程序呈现给用户的内部错误。
-
形成临时源文件后,那么就要对该源文件进行编译。如果编译失败,则将 status 设置为 -3,表示用户写的代码有语法问题,出现了编译报错。编译完成生成可执行程序后,则需要调用 Runner 类的静态方法 Run 来运行程序,并将 Run 函数的返回值保存在 ret 中。
-
如果 ret 小于 0,则表示尝试运行可执行程序的过程中出现错误,如创建子进程失败等,这些错误不需要呈现给用户,因此将 status 设置为 -2。如果 ret 大于 0,则表示程序运行时出现了异常,接受到了信号,此时 ret 的值为信号编号,将 status 设置为 ret。如果 ret 等于 0,则表示程序运行成功,将 status 设置为 0。注:程序运行成功并不代表运行结果正确,换句话说这并不代表用户提交的代码能够完美地解决这一到题目。
-
最后,就是根据状态码 status 的不同来进行不同的处理并进行序列化。如果 status 等于 -3,说明用户提交的代码没有通过编译,则读取报错信息。否则调用 StatusToDesc 函数将状态码装换成描述信息。如果 status 等于 0,那么就读去 stdout 和 stderr 临时文件并填充相应的字段。
新增的公共方法
// comm/utli.hpp
#include <atomic>
#include <fstream>namespace ns_util
{// TimeUtil 是与时间操作相关的类class TimeUtil{public:// 获得毫秒级时间戳static std::string GetTimeMs(){struct timeval _time;gettimeofday(&_time, nullptr);// 将秒和微妙转换成毫秒return std::to_string(_time.tv_sec * 1000 + _time.tv_usec / 1000); }};// FileUtil 是用于文件操作的类class FileUtil{public:// 形成唯一文件名(毫秒级时间戳 + 原子性递增值)static std::string UniqueFileName(){static std::atomic_uint id(0);id++;std::string ms = TimeUtil::GetTimeMs();std::string unique_id = std::to_string(id);return ms + "_" + unique_id;}// 形成临时源文件static bool WriteFile(const std::string& target, const std::string& code){std::ofstream out(target);if(!out.is_open()) return false; // 打开文件失败out.write(code.c_str(), code.size()); // 写入用户提交的代码形成源文件out.close();return true; }// 读取临时文件的内容static bool ReadFile(const std::string& target, std::string* content, bool keep = false){std::ifstream in(target);if(!in.is_open()) return false; // 打开文件失败// 按行读取文件内容std::string line;// 细节一: getline 的返回值为 istream, 而 // istream 类重载 operator bool 函数, 能// 将返回值转换成 bool 值进行判断// 细节二: getline 函数不会保存 '\n' 的, // 但有些时候需要保留 '\n', 那么可以通过 // keep 参数来决定是否保留 '\n', 其中 // false 表示不保留while(std::getline(in, line)){*content += line;if(keep) *content += "\n";}in.close(); // 关闭文件return true;} };
}
测试 compile_run 模块
compile_server.cc 中真正调用的是 CompileAndRun 类的静态方法 Start 来编译并运行用户的代码。因为 Start 函数需要输入型参数 in_json,那么我们就通过手动构建 JSON 字符串来模拟用户请求测试该模块。
原始字符串 R"(Raw String)"
在C++11中,引入了原始字符串(raw string)的特性,它是一种用于表示字符串文字的新语法。原始字符串提供了一种简化的方式来处理包含转义字符和特殊字符的字符串,使得字符串的书写更加直观和易读。
原始字符串的特点如下:
- 转义字符无效:原始字符串中的转义字符(如
\"
、\n
、\t
等)将被视为普通字符,而不会进行转义。这样可以避免转义字符带来的可读性问题。 - 多行字符串:原始字符串允许跨越多行,而无需使用转义字符或连接运算符。这在书写大段文本或多行代码时非常有用。
- 特殊字符无需转义:在原始字符串中,特殊字符(如反斜杠
\
)无需进行转义,可以直接使用。
以下是一些示例代码,展示了原始字符串的使用:
#include <iostream>int main()
{std::string rawStr = R"(C:\path\to\file.txt)";std::cout << rawStr << std::endl;std::string multiLineStr = R"(This is amulti-linestring)";std::cout << multiLineStr << std::endl;std::string specialChars = R"(\n \t \\)";std::cout << specialChars << std::endl;return 0;
}
注:原始字符串中的换行符和空格将被保留,因此在需要精确控制字符串内容的情况下,需要注意字符串中的空白符。
测试代码
// compile_server/compile_server.cc
#include "compile_run.hpp"using namespace ns_compile_and_run;// 编译服务随时可能被多个人请求, 所以必须要保证用户提交代码后,
// 所形成形成的源文件和各种临时文件具有唯一性, 否则多个用户之间
// 的请求会相互影响// 将编译服务打包形成一个网络服务(cpp-httplib)int main()
{// 客户端通过 HTTP 请求将 JSON 格式的字符串传递给服务端// 手动构建 JSON 请求std::string in_json;Json::Value in_value;in_value["code"] = R"(#include <iostream>using namespace std;int main()
{cout << "You can see me!" << endl;// 1. 测试 CPU 超出限制// while(1);// 2. 测试内存超出限制// int *p = new int[1024 * 1024 * 40];// 3. 测试段错误// int *p = nullptr;// *p = 100;// 4. 测试浮点数异常// int a = 10;// a /= 0;// 5. 测试编译出错// aaaa;return 0;
})";in_value["input"] = "";in_value["cpu_limit"] = 1; // 1sin_value["mem_limit"] = 10240 * 3; // 30MJson::FastWriter writer;in_json = writer.write(in_value);std::cout << in_json << std::endl;// out_json 是将要给用户返回的 JSON 字符串std::string out_json;CompileAndRun::Start(in_json, &out_json);std::cout << out_json << std::endl;return 0;
}
清理临时文件
每个用户提交代码时,都是在 /compile_server/temp 目录下生成的文件,久而久之这些临时文件会占用大量的磁盘空间,因此我们需要及时地将它们清理掉。RemoveTempFile 是清理临时文件的函数,它只需在 Start 函数的末尾调用即可。
namespace ns_compile_and_run
{class CompileAndRun{// 清理临时文件static void RemoveTempFile(const std::string& file_name){std::string _src = PathUtil::Src(file_name);if(FileUtil::IsFileExists(_src)) unlink(_src.c_str());std::string _exe = PathUtil::Exe(file_name);if(FileUtil::IsFileExists(_exe)) unlink(_exe.c_str());std::string _complier_error = PathUtil::CompilerError(file_name);if(FileUtil::IsFileExists(_complier_error)) unlink(_complier_error.c_str());std::string _stdin = PathUtil::Stdin(file_name);if(FileUtil::IsFileExists(_stdin)) unlink(_stdin.c_str());std::string _stdout = PathUtil::Stdout(file_name);if(FileUtil::IsFileExists(_stdout)) unlink(_stdout.c_str());std::string _stderr = PathUtil::Stderr(file_name);if(FileUtil::IsFileExists(_stderr)) unlink(_stderr.c_str());}};
}
认识 cpp-httplib
想要将编译并运行功能形成一个网络服务,可以自己手写网络编程。但是这样太麻烦了,我们可以通过第三方库 cpp-httplib 来完成这个工作。
cpp-httplib 的介绍
cpp-httplib 是一个基于 C++11 标准的轻量级 HTTP 客户端/服务器库,用于在 C++ 应用程序中实现 HTTP 通信。它提供了简单易用的 API,使得在 C++ 中进行 HTTP 请求和响应的处理变得非常方便。
cpp-httplib的特点和功能如下:
-
简单易用:cpp-httplib 提供了简单易用的 API,使得进行 HTTP 请求和响应的处理变得简单和直观。
-
轻量级:cpp-httplib 是一个轻量级的库,没有过多的依赖,使得它在嵌入式系统和资源受限的环境中也能很好地运行。
-
跨平台:cpp-httplib 是基于 C++11 标准编写的,因此可以在多个平台上运行,包括 Windows、Linux、macOS 等。
-
支持 HTTP/1.1:cpp-httplib 支持HTTP/1.1协议,并支持 GET、POST、PUT、DELETE 等常见的 HTTP 方法。
-
支持 HTTPS:除了支持 HTTP 协议外,cpp-httplib 还支持通过 TLS/SSL 进行加密的 HTTPS 通信。
-
多线程支持:cpp-httplib 可以与多线程应用程序一起使用,使得在多线程环境下进行 HTTP 通信更加便捷。
-
JSON 支持:cpp-httplib 内置了 JSON 的解析器和构建器,可以方便地处理 HTTP 请求和响应中的 JSON 数据。
cpp-httplib 的安装和使用
安装
cpp-httplib 有众多版本,个人推荐使用比较稳定的版本 v0.7.15。如果想使用其他版本,可以点击下方链接进行查询并下载。
cpp-httplib gitee链接:https://gitee.com/yuanfeng1897/cpp-httplib?_from=gitee_search
v0.7.15 版本链接: https://gitee.com/yuanfeng1897/cpp-httplib/tree/v0.7.15
# v0.7.15 版的下载
git clone https://gitee.com/yuanfeng1897/cpp-httplib.git
使用
cpp-httplib 是一个 header-only 的库,也就是只需要将 http.h 头文件拷贝到项目路径下就可以使用了。当然也可以将它拷贝到系统头文件路径下(本人使用的是建立软连接的方式)。使用 cpp-httplib 库需要高版本的 gcc,建议使用 gcc 7,8,9。如果 gcc 没升级,使用 cpp-httplib 时,要么编译报错,要么运行出错。
通过 scl 工具集升级 gcc
scl
(Software Collections)是一组用于管理多个软件版本的工具集。它允许用户在同一系统上同时安装多个不同版本的软件,而无需覆盖或影响系统中已安装的默认软件包。scl
工具集通常用于解决在使用旧版软件的同时需要使用新版软件的问题。
# 安装scl
sudo yum install centos-release-scl scl-utils-build
# 安装新版本gcc,这里也可以把7换成8或者9,我用的是9,也可以都安装
sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++ls /opt/rh/
# 细节:命令行启动只能在本会话有效
scl enable devtoolset-7 bash #启动gcc -v
注:如果想每次登陆的时候,都是较新的gcc,需要把上面的命令添加到你的 ~/.bash_profile 中。
演示使用 httplib
#include <httplib.h> using namespace httplib;int main()
{Server svr;// 注册回调函数svr.Get("/hello", [](const Request& req, Response& resp){resp.set_content("hello httplib, 你好 httplib!", "text/plain; charset=utf8");});svr.listen("0.0.0.0", 8080); // 启动 http 服务return 0;
}
注:cpp-httplib 是一个阻塞式多线程的网络 http 库,进行编译时需要加上 -lpthread 选项。
设置网页根目录
只需要在上面的那段代码中加上svr.set_base_dir("./wwwroot");
和编写网页代码即可。网页代码如下:
<!-- wwwroot/index.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Test</title>
</head>
<body><p>Freedom is everything!<p>
</body>
</html>
把编译并运行功能形成网络服务
将编译并运行功能打包形成网络服务是非常简单的,只需要调用 Server 类的 Pos 函数即可,而绑定的回调函数中需要调用 CompileAndRun 类的 Start 函数。
#include <httplib.h>
#include "compile_run.hpp"using namespace ns_compile_and_run;
using namespace httplib;void Usage(std::string proc)
{std::cerr << "Usage: " << "\n\t" << proc << " port" << std::endl;
}int main(int argc, char* argv[])
{if(argc != 2){Usage(argv[0]);return 1;}Server svr;// 注册回调函数svr.Get("/hello", [](const Request& req, Response& resp){// 基本测试resp.set_content("hello httplib, 你好 httplib!", "text/plain; charset=utf8");});svr.Post("/compile_and_run", [](const Request& req, Response& resp){// 用户请求的服务正文就是我们想要的 json stringstd::string in_json = req.body;std::string out_json;if(!in_json.empty()){CompileAndRun::Start(in_json, &out_json);resp.set_content(out_json, "application/json; charset=utf-8");}});svr.listen("0.0.0.0", atoi(argv[1])); // 启动 http 服务return 0;
}
使用 Postman 进行测试
Postman 是一款广泛使用的 API 测试工具和开发环境,旨在简化 API 的开发、测试、调试和文档编写过程。它为开发人员、测试人员和 API 提供者提供了一种直观、易用的方式来构建、测试和管理 HTTP 请求和响应。Postman 的下载去官网即可完成。
因为一台机器可以部署多个编译并运行服务,所以我们编译并运行服务可以绑定多个端口,因此我们运行服务是需要制定其端口。
测试用例
{"code": "#include <iostream>\nint main(){std::cout << \"hello compile_and_run\" << std::endl;\nreturn 0;}","input": "","cpu_limit": 1,"mem_limit": 50000
}
👉基于 MVC 结构的 OJ 服务设计👈
MVC 的介绍
MVC(Model-View-Controller)是一种常见的软件设计模式,用于组织和管理应用程序的代码和逻辑。它将应用程序分为三个主要组件:Model(模型)、View(视图)和Controller(控制器),每个组件负责不同的任务,相互之间解耦,从而实现代码的可维护性和扩展性。
- Model(模型): 模型是应用程序的数据和业务逻辑的表示。它负责管理数据的状态和访问,处理数据的验证、操作和更新。模型部分通常包含数据库、文件系统、网络请求等,用于获取和持久化数据。模型不涉及用户界面或外部交互,只专注于数据的管理和处理。
- View(视图): 视图是用户界面的表示层。它负责将数据从模型中提取出来并以可视化的方式呈现给用户。视图可以是一个图形界面、网页、控制台窗口等,它们展示了用户所需的信息,并提供用户与应用程序交互的接口。视图不包含应用程序逻辑,它只负责向用户展示信息和接收用户输入。
- Controller(控制器): 控制器是模型和视图之间的桥梁,它接收用户的输入并根据输入更新模型或调整视图。控制器负责处理用户操作,调用相应的模型方法来处理数据,并更新视图以反映模型的变化。它充当了应用程序逻辑的中心,确保模型和视图之间的解耦,使得应用程序更加灵活和可扩展。
工作流程:
- 用户与视图进行交互,比如点击按钮、输入文本等。
- 视图将交互的信息传递给控制器。
- 控制器根据接收到的信息调用模型的相应方法,处理数据或更新状态。
- 模型执行业务逻辑并更新数据。
- 视图观察模型的变化,从模型中提取更新后的数据,并相应地更新用户界面,以展示最新的数据给用户。
优点:
- 分离关注点:MVC 将应用程序的不同方面(数据、用户界面、业务逻辑)进行分离,使得代码更加清晰、易于维护和扩展。
- 可重用性:由于组件之间的解耦,每个组件可以独立使用,从而增加代码的可重用性。
- 多人协作:不同开发人员可以同时处理不同组件,加快开发过程并提高团队效率。
基于 MVC 结构的 OJ 服务设计
当设计一个基于 MVC 结构的 Online Judge 服务时,我们需要考虑以下三个主要组件:Model(模型)、View(视图)、Controller(控制器)。下面是一个简单的Online Judge服务的概念设计:
- Model(模型): 模型负责管理所有与题目、用户和提交相关的数据。它包括以下几个主要部分:
- 题目数据库:存储题目的信息,包括题目标题、描述、输入输出规范等。
- 用户数据库:存储用户的信息,包括用户名、密码、已解决的题目列表等。
- 提交记录数据库:存储用户提交的代码记录,包括用户、提交时间、评测结果等。
模型还应该包含与数据库交互的方法,例如添加题目、获取用户信息、存储提交记录等。
- View(视图): 视图是用户与 Online Judge 服务交互的界面。在这个简单的设计中,我们可以考虑以下几个视图:
- 题目列表视图:展示所有可用的题目供用户选择。
- 题目详情视图:展示选定题目的详细信息,包括题目描述、输入输出样例等。
- 提交代码视图:允许用户提交代码解答选定的题目。
- 评测结果视图:展示用户提交的代码评测结果,如通过、错误等。
视图应该能够接收用户输入,并将输入传递给控制器进行处理。同时,视图还应该根据控制器传回的数据更新界面以反映最新的状态。
- Controller(控制器): 控制器是 Online Judge 服务的核心。它负责处理用户输入和数据逻辑。主要任务包括:
- 根据用户选择的题目,从模型中获取题目信息并传递给视图展示。
- 接收用户提交的代码,并将其存储到模型的提交记录数据库中。
- 调用评测引擎对提交的代码进行评测,并将评测结果返回给视图展示给用户。
- 处理用户的登录、注册、查看已解决题目等其他操作。
控制器应该根据用户的行为调用模型中的方法,并将得到的结果传递给相应的视图进行展示。
总体流程:
- 用户访问 Online Judge 服务的首页,查看题目列表。
- 用户选择一个题目并查看题目详情。
- 用户提交代码进行解答。
- 控制器接收到用户提交的代码,将其存储到提交记录数据库,并调用评测引擎对代码进行评测。
- 评测引擎完成评测后,将评测结果传递给控制器。
- 控制器将评测结果传递给相应的视图,展示给用户。
其实,基于 MVC 结构的 OJ 服务设计本质就是设计一个小型的在线刷题网站。上面诉述的功能,本人只能实现一部分,无法做到面面俱到。那么,我要实现的主要功能如下:
- 获取首页,首页使用题目列表来充当。
- 编辑代码的区域页面。
- 提交判题功能(编辑并运行)。
第一个功能:用户请求的服务路由功能
用户请求的服务器功能就是根据用户的不同需求,给用户返回特定的结果展现给用户,如获取所有的题目列表、根据题目编号获取题目的内容等等。
// oj_server/oj_server.cc
#include <iostream>
#include <httplib.h>using namespace httplib;int main()
{// 用户请求的服务路由功能Server svr;// 获取所有的题目列表svr.Get("/all_questions", [](const Request& req, Response& resp){resp.set_content("这是所有题目的列表", "text/plain; charset=utf-8");});// 根据题目编号获取题目内容// \d 匹配数组, + 匹配任意个字符// /qustion/100 -> 正则匹配// R"()"": 原始字符串, 保存字符串的原貌, 不进行转义svr.Get(R"(/question/(\d+))", [](const Request& req, Response& resp){std::string number = req.matches[1]; // 题目编号resp.set_content("这是指定的一道题: " + number, "text/plain; charset=utf-8");});// 判题功能svr.Get(R"(/judge/(\d+))", [](const Request& req, Response& resp){std::string number = req.matches[1]; // 题目编号resp.set_content("指定题目的判题功能: " + number, "text/plain; charset=utf-8");});svr.set_base_dir("./wwwroot"); // 设置网页根目录svr.listen("0.0.0.0", 8080);return 0;
}
网站首页代码
<!-- oj_server/wwwroot/index.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Personal Online judgment</title>
</head>
<body><h1>Welcome to my Online Judgment Planform</h1><p>This is an online judgment platform independently developed by me</p>
</body>
</html>
文件版题目设计
一道题目通常会包括:题号、标题、难度、描述、体面、时间要求和空间要求等。总的来说,这需要两批文件来组成,分别是 qution.list 题目列表(不需要题目的内容)和包括题目的描述、题目预设置的代码(header.cpp)、测试用例的代码(tail.cpp)的文件,而这两个文件是通过题目编号来产生关联的。
目录结构
题目列表
// ##questions.list: 题目编号 题目标题 题目难度 题目时间限制(s) 题目空间限制(KB)##
1 回文数 简单 1 50000
题目描述
判断一个整数是否是回文数。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。示例 1:
输入: 121
输出: true示例 2:
输入: -121
输出: false
解释: 从左向右读, 为 -121 。 从右向左读, 为 121- 。因此它不是一个回文数。示例 3:
输入: 10
输出: false
解释: 从右向左读, 为 01 。因此它不是一个回文数。
header.cpp
// 你可以添加你需要的头文件
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>using namespace std;class Solution
{
public:bool isPalindrome(int x){// 请把你的代码写到这里}
};
tail.cpp
题目尾部就是测试用例,后台判定用户提交上来的代码过还是不过的参考依据。
#ifndef CompileOnline
// 这是为了编写用例的时候有语法提示. 实际线上编译的过程中这个操作是不生效的.
#include "header.cpp"
#endif/***************************************************
*
* 此处约定:
* 1. 每个用例是一个函数
* 2. 每个用例从标准输出输出一行日志
* 3. 如果用例通过, 统一打印 [TestName] ok!
* 4. 如果用例不通过, 统一打印 [TestName] failed! 并且给出合适的提示.
***************************************************/void Test1()
{// 通过定义临时对象来完成方法的调用bool ret = Solution().isPalindrome(121);if (ret)std::cout << "Test1 ok!" << std::endl;elsestd::cout << "Test1 failed! input: 121, output expected true, actually false" << std::endl;
}void Test2()
{bool ret = Solution().isPalindrome(-10);if (!ret)std::cout << "Test2 ok!" << std::endl;elsestd::cout << "Test2 failed! input: -10, output expected false, actually true" << std::endl;
}int main()
{Test1();Test2();return 0;
}
当用户提交代码时,不只是将 header.cpp 的代码提交给 compile_and_run,而是将 header.cpp 和其对应的测试用例 tail.cpp 提交给 compile_and_run。代码提交到 compile_and_run 进行编译并运行后,读取标准输出文件 stdout 中的内容并返回给用户,就能够完成判题的功能了。
注:当我们完成全部功能之后,需要给编译模块添加 —D 条件编译掉测试用例中的头文件 incldue。
第二个功能:model 功能,提供对数据的操作
Model 模块主要用来和数据进行交互, 对外提供访问数据的接口,如:获取所有题目 GetAllQuestions 和获取一道题目 GetOneQuestion 等。
#pragma once#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>
#include <fstream>
#include <cassert>
#include "../comm/log.hpp"
#include "../comm/util.hpp"// 根据题目列表文件, 加载所有的题目信息到内存中
// Model: 主要用来和数据进行交互, 对外提供访问数据的接口namespace ns_model
{using namespace std;using namespace ns_log;struct Question{std::string number; // 题目编号std::string title; // 题目标题std::string star; // 难度: 简单 中等 简单int cpu_limit; // 题目的时间要求(s)int mem_limit; // 题目的空间要求(KB)std::string desc; // 题目描述std::string header; // 题目预设给用户在线编辑器的代码std::string tail; // 题目的测试用例, 需要和 header 进行拼接形成完整代码 };const string questions_list = "./quetions/quetions.list";const string questions_path = "./questions/";class Model{public:Model() {assert(LoadQuestionList(questions_list));}~Model() {}// 将题目加载到内存中bool LoadQuestionList(const string& questions_list){// 加载配置文件ifstream in(questions_list);if(!in.is_open()) {LOG(FATAL) << "加载题库失败, 请检查题库文件是否存在\n"; return false;}std::string line;while(getline(in, line)){vector<string> tokens;StringUtil::SplitString(line, &tokens, " ");// 1 回文数 简单 1 5000if(tokens.size() != 5) {LOG(WARNING) << "加载部分题目失败, 请检查文件格式\n";continue;} Question q;q.number = tokens[0];q.title = tokens[1];q.star = tokens[2];q.cpu_limit = stoi(tokens[3]);q.mem_limit = stoi(tokens[4]);// 构建文件路径string path = questions_path + q.number + "/";// 读取文件内容q.desc = FileUtil::ReadFile(path + "desc.txt", &(q.desc), true);q.header = FileUtil::ReadFile(path + "header.cpp", &(q.header), true);q.tail = FileUtil::ReadFile(path + "tail.cpp", &(q.tail), true);_questions[q.number] = q; // 将这道题插入到哈希表中}LOG(INFO) << "加载题库成功\n";in.close();}// 获取所有题目bool GetAllQuestions(vector<Question>* out){if(_questions.empty()) {LOG(ERROR) << "用户获取题库失败\n";return false;}for(auto& q : _questions){out->push_back(q.second);}return true;}// 获取一道题目bool GetOneQuestion(const string& number, Question* q){if(!_questions.count(number)) {LOG(ERROR) << "用户获取题目失败, 题目编号为 " << number << "\n";return false;}*q = _questions[number];return true;}private:unordered_map<string, Question> _questions; // 建立题号和题目细节的映射};
}
Boost 库的安装
Boost 是一个广泛使用的 C++ 库集合,它提供了许多高质量、高效和跨平台的功能。Boost 库被认为是 C++ 标准库的扩展,它包含了很多在 C++ 标准库中没有提供的功能。Boost 库的目标是在保持高度可移植性的前提下提供更多的功能。
sudo yum install -y boost-devel
Boost 字符串切分
Boost 库中有一个常用的字符串切分算法,称为 split。它可以基于定的分隔符将一个字符串分割成多个子字符串,并将这些字符串存在容器中。spilt 函数原型如下:
template<typename SequenceT, typename RangeT>
void split(SequenceT& result,RangeT& input,const typename SequenceT::value_type& separator,boost::algorithm::token_compress_mode_type eCompress = boost::algorithm::token_compress_off);
- result:用于存储切分后子字符串的目标容器,通常是一个标准库容器,如
std::vector<std::string>
。 - input:要切分的输入字符串,可以是 C 风格字符串、std::string 等。
- separator:用于切分字符串的分隔符,可以是单个字符或字符串。如果是字符串的话,则字符串里面的单个字符都是分割符。
- eCompress:指定是否启用压缩模式,用于控制是否压缩连续分隔符。可选值包括:
- boost::algorithm::token_compress_off:不压缩,保留所有连续分隔符生成空字符串。
- boost::algorithm::token_compress_on:压缩,连续分隔符只生成一个空字符串。
#include <iostream>
#include <vector>
#include <boost/algorithm/string.hpp>int main()
{const std::string str = "1:::回文数:简单:1:5000";std::vector<std::string> tokens;const std::string sep = ":";boost::split(tokens, str, boost::is_any_of(sep), boost::algorithm::token_compress_on);// boost::split(tokens, str, boost::is_any_of(sep), boost::algorithm::token_compress_off);for(auto& s : tokens){std::cout << s << std::endl;}return 0;
}
新增的公共方法
#include <vector>
#include <boost/algorithm/string.hpp>namespace ns_util
{// 字符串操作类class StringUtil{public:/***************************** str: 输入型参数, 要切分的字符串* target: 输出型参数, 保存切分后的字符串* sep: 分割符*****************************/static void SplitString(const std::string& str, std::vector<std::string>* target, std::string sep){// boost 字符串切分boost::split(*target, str, boost::is_any_of(sep), boost::algorithm::token_compress_on);}};
}
认识 ctemplate
ctemplate 的介绍
ctemplate 是一个 C++ 模板库,用于生成文本输出。它提供了一种简单、灵活和高效的方式来生成各种类型的文本输出,如 HTML、XML、配置文件、代码等。ctemplate 的设计灵感来自于Google内部使用的模板引擎,因此也被称为 Google ctemplate。
主要特点和功能:
-
模板标签:ctemplate 使用特定的模板标签来标识模板中的变量和逻辑控制。模板标签使用
{{variable}}
的格式来表示要替换的变量,以及{{#if condition}}
和{{#else}}
的格式来表示条件控制。 -
支持多种数据类型:ctemplate 支持多种数据类型,包括简单的字符串、整数、浮点数,以及复杂的对象和数据结构。
-
数据绑定:通过在模板中指定模板标签,可以将实际数据绑定到模板中,从而生成动态文本输出。
-
模板继承:ctemplate 支持模板继承,可以定义一个基础模板,并在派生模板中重写或扩展内容,以实现模板的重用和组合。
-
高性能:ctemplate 在性能上进行了优化,生成的模板处理代码高效且快速,适合在高并发和大规模应用中使用。
-
跨平台:由于 ctemplate 是一个纯 C++ 库,它可以在各种平台上运行,包括 Linux、Windows、macOS 等。
-
开源:ctemplate 是一个开源项目,代码和文档都可以在 GitHub上找到。
ctemplate 的安装
# 1. 将远程仓库克隆到本地
git clone https://gitee.com/DreamyHorizon/ctemplate.git
# 2. 进入 ctemplate-master
cd ctemplate-master/
# 3. 按顺序执行下面两个程序
./autogen.sh
./configure
# 4. 编译
make
# 5. 将头文件和库文件拷贝到系统目录
make install
ctemplate 的使用
模板文件
<!-- test.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>My Page Title<Title></Title></title>
</head>
<body><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p>
</body>
</html>
在这个示例中,{{key}}
是模板标签,将在后续步骤中用实际数据替换。
// Test.cc
#include <iostream>
#include <string>
#include <ctemplate/template.h>int main()
{std::string in_html = "./test.html";std::string value = "不自由,毋宁死!";// 形成数据字典ctemplate::TemplateDictionary dict("test"); // 类似于 unordered_map<...> test;dict.SetValue("key", value); // 类似于 test["key"] = value;// 获取要待渲染的网页对象ctemplate::Template* tpl = ctemplate::Template::GetTemplate(in_html, ctemplate::DO_NOT_STRIP);// 将字典数据添加到待渲染的网页中std::string out_html;tpl->Expand(&out_html, &dict);// 查看替换后的网页std::cout << out_html << std::endl;
}
代码详解
- 创建模板字典对象:在使用 ctemplate 之前,需要创建一个
ctemplate::TemplateDictionary
对象,用于存储模板数据。模板字典是一个键值对的集合,可以用于存储模板中的变量和逻辑控制。 - 设置模板数据:通过
SetValue
方法为模板字典中的模板标签指定实际的值。模板标签使用{{variable}}
的格式,而SetValue
方法用于为模板标签指定值。SetValue
方法有多个版本,可以用于设置不同类型的值,如字符串、整数、浮点数等。 - 加载模板文件:通过
ctemplate::Template::GetTemplate
方法从文件中加载模板。在示例中,我们将模板文件的路径传递给该方法,并使用ctemplate::DO_NOT_STRIP
选项,表示不移除未使用的模板标签。GetTemplate
方法将返回一个指向ctemplate::Template
对象的指针。 - 渲染模板:使用
Expand
方法将数据渲染到模板中。Expand
方法将模板字典中的数据替换模板中的标签,并生成最终的输出文本。在示例中,我们将渲染后的输出保存在out_html
字符串中,然后将其输出到控制台。
如果出现形成的可支持程序找不到动态的情况,可以将下面的语句添加到家目录的 .bash_profile
文件中。
export LD_LIBRARY_PATH="/usr/local/lib:/usr/local/lib64:$LD_LIBRARY_PATH"
第三个功能:view 功能展现用户界面
view 的功能是从 controller 模块获取题目列表和题目信息后,借助 ctemplate 将其转换成为 html 文件,然后交给浏览器,经过浏览器解析后呈现给用户。
认识表格
因为我们的题目列表将来会以表格的形式进行呈现,所以我们要先来学习一下 html 的表格。表格由 <table>
标签来定义。表格的表头使用 <th>
标签进行定义。大多数浏览器会把表头显示为粗体居中的文本:每个表格均有若干行(由 <tr>
标签定义),每行被分割为若干单元格(由 <td>
标签定义)。字母 td 指表格数据(table data),即数据单元格的内容。数据单元格可以包含文本、图片、列表、段落、表单、水平线、表格等等。
<table><tr><th>题目编号</th><th>题目标题</th><th>题目难度</th></tr><tr><td>1</td><td>两数之和</td><td>简单</td></tr><tr><td>2</td><td>回文数</td><td>简单</td></tr><tr><td>3</td><td>最大值</td><td>简单</td></tr>
</table>
在浏览器显示如下:
网站首页添加跳转功能
模板文件
<!-- oj_server/template_html/all_questions.html -->
<!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>{{#question_list}} <!-- 循环渲染 --><tr><td>{{number}}</td><td><a href="/question/{{number}}">{{title}}</a></td> <!-- a 标签是跳转功能 --><td>{{star}}</td></tr>{{/question_list}}</table>
</body>
</html>
<!-- oj_server/template_html/one_question.html -->
<!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" cols="30" rows="10">{{header}}</textarea> <!-- 文本编辑框 -->
</body>
</html>
all_questions.html 是用于生成题目列表的 html 文件,而 one_question.html 是具体某一道题的 html 文件。有了这两个文件,我们就可以再来编译 view 模块了。
第四个功能:controller 逻辑控制模块
控制器是 Online Judge 服务的核心,它负责处理用户输入和数据逻辑。它需要做到能够根据用户的行为调用模型中的方法,并将得到的结果传递给相应的视图进行展示。那么控制器 controller 就需要包含 model 模块和 view 模块。
oj_controller.hpp
// oj_server/oj_controller.hpp
#pragma once#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include "oj_model.hpp"
#include "oj_view.hpp"
#include "../comm/log.hpp"
#include "../comm/util.hpp"namespace ns_controller
{using namespace std;using namespace ns_log;using namespace ns_util;using namespace ns_view;using namespace ns_model;class Controller{public:Controller() {}~Controller() {}// 根据题目数据构建网页// html: 输出型参数// 获取题目列表bool AllQuestions(string* html){bool ret = true;vector<Question> all;if(_model.GetAllQuestions(&all)){// 解决题号乱序问题sort(all.begin(), all.end(), [](const Question& q1, const Question& q2){return stoi(q1.number) < stoi(q2.number);});// 获取全部题目信息成功, 将所有的题目信息构建成 html 网页_view.AllExpandHtml(all, html);}else{*html = "获取题目失败, 形成题目列表失败!";ret = false;}return ret;}// 获取具体的某一道题目bool OneQuestion(const string& number, string* html){bool ret = true;Question q;if(_model.GetOneQuestion(number, &q)){// 获取指定题目信息成功, 将该题的信息构建成 html 网页_view.OneExpandHtml(q, html);}else {*html = "指定题目 " + number + " 获取失败!";ret = false;}return ret;}private:Model _model; View _view;};
}
oj_server.cc
#include <iostream>
#include <httplib.h>
#include "./oj_controller.hpp"using namespace httplib;
using namespace ns_controller;int main()
{// 用户请求的服务路由功能Server svr;Controller ctrl;// 获取所有的题目列表svr.Get("/all_questions", [&ctrl](const Request& req, Response& resp){// 返回一张包含所有题目的 html 网页std::string html;ctrl.AllQuestions(&html);resp.set_content(html, "text/html; charset=utf-8");});// 根据题目编号获取题目内容// \d 匹配数组, + 匹配任意个字符// /qustion/100 -> 正则匹配// R"()"": 原始字符串, 保存字符串的原貌, 不进行转义svr.Get(R"(/question/(\d+))", [&ctrl](const Request& req, Response& resp){std::string number = req.matches[1]; // 题目编号std::string html;ctrl.OneQuestion(number, &html);resp.set_content(html, "text/html; charset=utf-8");});// 判题功能svr.Get(R"(/judge/(\d+))", [&ctrl](const Request& req, Response& resq){std::string number = req.matches[1]; // 题目编号resq.set_content("指定题目的判题功能: " + number, "text/html; charset=utf-8");});svr.set_base_dir("./wwwroot");svr.listen("0.0.0.0", 8080);return 0;
}
效果演示
第五个功能:负载均衡功能
负载均衡是在分布式系统或计算机网络中,用于将任务或网络请求分配到多个服务器上,以实现资源的合理利用和避免单个服务器过载的技术。负载均衡的重要性:
- 提高系统性能:负载均衡可以将请求或任务均匀地分配到多个服务器上,从而充分利用系统资源,避免某些服务器过度负载,提高整体的吞吐量和响应速度。通过将负载合理分配,可以充分发挥系统的潜力,提高系统的性能和效率。
- 高可用性:当系统中有多台服务器时,如果某一台服务器出现故障或宕机,负载均衡能够将请求自动转发到其他正常运行的服务器上,保障系统的持续可用性。这样即使出现故障,用户也能够继续访问系统,减少因单点故障导致的服务中断风险。
- 避免过载:在高并发情况下,某些服务器可能会面临过载的风险,导致性能下降甚至崩溃。负载均衡能够监控服务器的负载情况,根据不同的负载情况,动态地将请求分配给负载较轻的服务器,从而避免过载,保障系统的稳定性和可靠性。
- 灵活扩展:当系统的负载逐渐增加,通过负载均衡可以方便地进行扩展,向系统中添加更多的服务器,实现更大规模的资源扩展,适应不断增长的用户需求。
- 跨地域就近访问:在分布式系统中,负载均衡可以根据用户的地理位置,将请求转发到离用户最近的服务器,降低网络延迟,提高用户体验。
主机配置文件
#pragma once#include <vector>
#include <mutex>
#include <httplib.h>
#include "../comm/log.hpp"
#include "../comm/util.hpp"namespace ns_controller
{using namespace std;using namespace ns_log;using namespace ns_util;using namespace ns_view;using namespace httplib;using namespace ns_model;const string service_machine = "./conf/service_machine.conf"; // 主机配置文件路径// 逻辑上的主机, 它们可以在同一台机器上, 也可能是在不同机器上struct Machine{Machine(): _ip(""), _port(0), _load(0){} ~Machine() {} // 提升主机负载void IncLoad(){if(_mtx) _mtx->lock();++_load;if(_mtx) _mtx->unlock();} // 减少主机负载void DescLoad(){if(_mtx) _mtx->lock();--_load;if(_mtx) _mtx->unlock();}// 重置主机负载void ResetLoad(){if(_mtx) _mtx->lock();_load = 0;if(_mtx) _mtx->unlock();}// // 获取主机负载(该接口仅仅是为了统一接口)// uint64_t Load()// {// uint64_t load = 0;// if(_mtx) _mtx->lock();// load = _load;// if(_mtx) _mtx->unlock();// return load;// }std::string _ip; // 编译服务的 IP 地址int _port; // 编译服务的端口号uint64_t _load; // 编译服务的负载mutex* _mtx; // 保护 _load 的互斥锁};// 负载均衡模块class LoadBalance{public:LoadBalance(){assert(LoadConf(service_machine));}~LoadBalance() {}bool LoadConf(const string& machine_conf){ifstream in(machine_conf);if(!in.is_open()){LOG(FATAL) << "加载配置文件失败!\n";return false;}string line;while(getline(in, line)){vector<string> tokens;// line: "127.0.0.1:8081"StringUtil::SplitString(line, &tokens, ":");if(tokens.size() != 2){LOG(WARNING) << "切分 " << line << " 失败!\n";continue;}Machine m;m._ip = tokens[0];m._port = stoi(tokens[1]);m._mtx = new mutex();// 让主机和下标建立映射关系_online.push_back(_machines.size());_machines.push_back(m);}LOG(INFO) << "加载配置文件成功\n";return true; }// 选择负载最低的在线主机// id 和 m 都是输出型参数bool SmartChoice(int* id, Machine** m){_mtx.lock();int online_num = _online.size();if(online_num == 0){_mtx.unlock();LOG(FATAL) << "所有主机已经离线, 无法提供编译运行服务\n";return false;}// 遍历找出负载最小的主机uint64_t min_load = _machines[_online[0]]._load;*id = _online[0];*m = &_machines[_online[0]];for(int i = 1; i < min_load; ++i){if(min_load > _machines[_online[i]]._load){min_load = _machines[_online[i]]._load;*id = _online[i];*m = &_machines[_online[i]];}}_mtx.unlock();return true;}// 将主机离线: 将主机下标从 _online 中移到 _offline 中void OfflineMachine(int id){_mtx.lock();for(auto it = _online.begin(); it != _online.end(); ++it){if(*it == id){_machines[id].ResetLoad();_online.erase(it);_offline.push_back(id);break; // 因为 break 的存在, 所以不需要考虑迭代器问题}}_mtx.unlock();}// 将主机上线: 将主机下标从 _offline 中移到 _online 中void OnlineMachine(){_mtx.lock();_online.insert(_online.end(), _offline.begin(), _offline.end());_offline.clear();_mtx.unlock();LOG(INFO) << "所有主机上线成功!\n";}// 用于测试的接口void ShowMachines(){_mtx.lock();cout << "当前在线的主机: ";for(auto &id : _online) cout << id << " ";cout << endl;cout << "当前离线的主机: ";for(auto &id : _offline) cout << id << " ";cout << endl;_mtx.unlock();}private:// 可以提供编译服务的主机// 每台主机都有自己的下标, 下标用来充当 idvector<Machine> _machines; // 所有在线主机的 idvector<int> _online;// 所有离线主机的 idvector<int> _offline;// 保证负载均衡模块的安全mutex _mtx;};class Controller{// in_json 包括 code 和 inputvoid Judge(const string& number, const string& in_json, string* out_json){// 1. 根据题目编号获取题目信息Question q;_model.GetOneQuestion(number, &q);// 2. in_json 进行反序列化, 获取到用户提交的代码Json::Reader reader;Json::Value in_value;reader.parse(in_json, in_value); // 反序列化string code = in_value["code"].asString(); // 用户提交的代码// 3. 将用户提交的代码和测试用例代码进行拼接Json::Value compile_value;compile_value["input"] = in_value["input"].asString();compile_value["code"] = code + "\n" + q.tail; // 拼接代码compile_value["cpu_limit"] = q.cpu_limit;compile_value["mem_limit"] = q.mem_limit;// 4. 将 compile_value 进行序列化, 并将 JSON 格式// 的字符串通过网络请求提交给编译运行服务Json::FastWriter writer;// copile_json 是提交给编译运行服务的 JSON 格式的字符串string compile_json = writer.write(compile_value);// 5. 选择负载最低的主机来编译运行代码// 一直选择直到有主机可用, 否则主机都挂掉了while(true){int id = 0;Machine* m = nullptr;if(!_load_balance.SmartChoice(&id, &m)){// 主机全部挂掉了, 直接 break 退出循环break;}// 6. 向编译运行服务发起 http 请求, 拿到结果Client client(m->_ip, m->_port);m->IncLoad();LOG(INFO) << "主机信息:" << id << " " << m->_ip << " " << m->_port << " 负载: " << m->_load << endl;if(auto ret = client.Post("/compile_and_run", compile_json, "application/json; charset=utf-8")){// 7. 将结果赋值给 out_jsonif(ret->status == 200) // 此次请求成功{*out_json = ret->body;LOG(INFO) << "请求编译运行服务成功\n";}else{// 状态码不是 200 的情况LOG(WARNING) << "此次请求的状态码为 " << ret->status << endl;}m->DescLoad(); break;}else{// 请求失败LOG(ERROR) << "主机信息:" << id << " " << m->_ip << " " << m->_port << " 可能已经离线\n";_load_balance.OfflineMachine(id);_load_balance.ShowMachines(); // 测试使用}}}// 将所有主机重新上线void RecoveryMachine(){_load_balance.OnlineMachine();}private:Model _model; View _view;LoadBalance _load_balance;};
}
server.cc
#include <iostream>
#include <signal.h>
#include <httplib.h>
#include "./oj_controller.hpp"using namespace httplib;
using namespace ns_controller;static Controller* ptr = nullptr;void Recovery(int signo)
{ptr->RecoveryMachine();
}int main()
{signal(SIGINT, Recovery); // 利用信号一键上线所有主机// 用户请求的服务路由功能Server svr;Controller ctrl;ptr = &ctrl;// 获取所有的题目列表svr.Get("/all_questions", [&ctrl](const Request& req, Response& resp){// 返回一张包含所有题目的 html 网页std::string html;ctrl.AllQuestions(&html);resp.set_content(html, "text/html; charset=utf-8");});// 根据题目编号获取题目内容// \d 匹配数组, + 匹配任意个字符// /qustion/100 -> 正则匹配// R"()"": 原始字符串, 保存字符串的原貌, 不进行转义svr.Get(R"(/question/(\d+))", [&ctrl](const Request& req, Response& resp){std::string number = req.matches[1]; // 题目编号std::string html;ctrl.OneQuestion(number, &html);resp.set_content(html, "text/html; charset=utf-8");});// 判题功能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;
}
利用 Postman 进行测试
{"code": "#include <iostream>\n#include <algorithm>\nusing namespace std;\nclass Solution{\npublic:\n bool isPalindrome(int x)\n {\n while(1);\nreturn true;\n}\n};","input": ""
}
👉前端页面设计👈
后端开发不需要关心前端页面,如果大家不想写前端页面的代码,可以直接粘贴复制。但是任何一个完整的项目都会有前后端,我们需要了解前后端是如何交互的。
丐版首页
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Personal Online Judge</title><!-- 设置样式 --><style>/* 起手式,100%保证我们的样式设置不受默认的影响 *//* 星号表示选中所有标签 */* {/* 消除网页的默认外边距 */margin: 0px;/* 消除网页的默认内边距 */padding: 0px;}html,body {width: 100%;height: 100%;}.container .navbar {width: 100%;height: 50px;background-color: black;/* 给父级标签设置 overflow,取消后续 float 带来的影响 */overflow: hidden;}.container .navbar a {/* 设置 a 标签为行内块标签, 允许设置宽度 */display: inline-block;/* 设置 a 标签的宽度 */width: 80px;/* 设置字体颜色 */color: white;/* 设置字体大小 */font-size: large;/* 设置文字的高度和导航栏的高度一致 */line-height: 50px;/* 去掉 a 标签的下划线 */text-decoration: none;/* 设置 a 标签的文字居中 */text-align: center;}/* 设置鼠标事件 */.container .navbar a:hover {background-color: antiquewhite;}/* 设置登录居右 */.container .navbar .login {float: right}/* . 是类选择器 */.container .content {/* 设置标签宽度 */width: 800px;/* 用来调试 *//* background-color: #ccc; *//* content 整体左右居中 */margin: 0px auto;/* content 中的文字左右居中 */text-align: center;/* 设置上外边距 */margin-top: 200px;}.container .content .font_ {/* 设置标签为块级元素,独占一行,可以设置高度和宽度等属性 */display: block;/* 设置每行文字的上外边距 */margin-top: 20px;/* 去掉 a 标签的下划线 */text-decoration: none;/* 设置字体大小 *//* font-size: large; */}</style>
</head><body><!-- container 包含网页的所有内容 --><div class="container"><!-- 导航栏,导航栏的功能不实现 --><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a><a href="/all_questions">竞赛</a><a href="/all_questions">讨论</a><a href="/all_questions">求职</a><a class="login" href="#">登录</a></div><!-- 网页的内容 --><div class="content"><h1 class="font_">欢迎来到我的 OJ 平台</h1><p class="font_">这是我个人独立开发的 OJ 平台</p><a class="font_" href="/all_questions">点击我即可跳转到题目列表</a></div></div>
</body></html>
所有题目的列表
<!-- oj_server/template_html/all_questions.html -->
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>在线 OJ 题目列表</title><style>/* 起手式,100%保证我们的样式设置不受默认的影响 *//* 星号表示选中所有标签 */* {/* 消除网页的默认外边距 */margin: 0px;/* 消除网页的默认内边距 */padding: 0px;}html,body {width: 100%;height: 100%;}.container .navbar {width: 100%;height: 50px;background-color: black;/* 给父级标签设置 overflow,取消后续 float 带来的影响 */overflow: hidden;}.container .navbar a {/* 设置 a 标签为行内块标签, 允许设置宽度 */display: inline-block;/* 设置 a 标签的宽度 */width: 80px;/* 设置字体颜色 */color: white;/* 设置字体大小 */font-size: large;/* 设置文字的高度和导航栏的高度一致 */line-height: 50px;/* 去掉 a 标签的下划线 */text-decoration: none;/* 设置 a 标签的文字居中 */text-align: center;}/* 设置鼠标事件 */.container .navbar a:hover {background-color: antiquewhite;}/* 设置登录居右 */.container .navbar .login {float: right}.container .question_list {padding-top: 50px;width: 800px;height: 100%;/* height: 800px; */margin: 0px auto;/* 调试使用 *//* background-color: #ccc; */text-align: center;}.container .question_list table {width: 100%;font-size: large;font-family: "Microsoft YaHei", Arial, Helvetica, sans-serif, "宋体";margin-top: 50px;/* background-color: rgb(236, 246, 237); */background-color: rgb(243, 248, 246);}.container .question_list h1 {color: bisque;}.container .question_list table .item {width: 100px;height: 40px;/* padding-top: 5px; */font-size: large;font-family: "Microsoft YaHei", Arial, Helvetica, sans-serif, "宋体";}.container .question_list table .item a {/* 去掉 a 标签的下划线 */text-decoration: none;color: black;}/* 设置鼠标事件 */.container .question_list table .item a:hover {color: cadetblue;/* font-size: larger; */text-decoration: underline;}.container .footer {width: 100%;height: 50px;text-align: center;/* background-color: #ccc; */line-height: 50px;color: burlywood;margin-top: 15px;}</style>
</head><body><div class="container"><!-- 导航栏,具体功能可以自己实现 --><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a><a href="/all_questions">竞赛</a><a href="/all_questions">讨论</a><a href="/all_questions">求职</a><a class="login" href="#">登录</a></div><div class="question_list"><h1>Online Judge 题目列表</h1><table><tr><th class="item">题目编号</th><th class="item">题目标题</th><th class="item">题目难度</th></tr>{{#question_list}} <!-- 循环渲染 --><tr><td class="item">{{number}}</td><td class="item"><a href="/question/{{number}}">{{title}}</a></td> <!-- a 标签是跳转功能 --><td class="item">{{star}}</td></tr>{{/question_list}}</table></div><div class="footer"><h4>@快乐刷题</h4></div></div>
</body></html>
指定题目的代码编写页面
<!-- oj_server/template_html/one_question.html -->
<!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><!-- 引入 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><style>* {margin: 0;padding: 0;}html,body {width: 100%;height: 100%;}div .ace_editor {width: 100%;}.container .navbar {width: 100%;height: 50px;background-color: white;/* 给父级标签设置 overflow,取消后续 float 带来的影响 */overflow: hidden;}.container .navbar a {/* 设置 a 标签为行内块标签, 允许设置宽度 */display: inline-block;/* 设置 a 标签的宽度 */width: 80px;/* 设置字体颜色 */color: black;/* 设置字体大小 */font-size: large;/* 设置文字的高度和导航栏的高度一致 */line-height: 50px;/* 去掉 a 标签的下划线 */text-decoration: none;/* 设置 a 标签的文字居中 */text-align: center;}/* 设置鼠标事件 */.container .navbar a:hover {background-color: antiquewhite;}/* 设置登录居右 */.container .navbar .login {float: right}.container .part1 {width: 100%;height: 600px;overflow: hidden;}.container .part1 .left_desc {width: 50%;height: 600px;float: left;/* 添加滚动条 */overflow: scroll;}/* 滚动条整体 */.container .part1 .left_desc::-webkit-scrollbar {height: 5px;width: 5px;background-color: rgb(20, 19, 19);}/* 两个滚动条交接处 -- x轴和y轴 */.container .part1 .left_desc::-webkit-scrollbar-corner {background-color: transparent;}/* 滚动条滑块 */.container .part1 .left_desc::-webkit-scrollbar-thumb {border-radius: 10px;-webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);background-color: rgb(58, 135, 163);}/* 滚动条轨道 */.container .part1 .left_desc::-webkit-scrollbar-track {-webkit-box-shadow: inset 0 0 5px rgba(255, 255, 255, 0);border-radius: 10px;background-color: rgb(15, 15, 16);}.container .part1 .left_desc h4 {padding-top: 15px;padding-left: 10px;}.container .part1 .left_desc pre {padding-top: 15px;padding-left: 10px;font-size: medium;font-family: "Microsoft YaHei", Arial, Helvetica, sans-serif, "宋体";}.container .part1 .right_code {width: 50%;float: right;height: 600px;}.container .part1 .right_code .ace_editor {height: 600px;}.container .part2 {width: 100%;overflow: hidden;}.container .part2 .result {width: 300px;float: left;}.container .part2 .bt {width: 86.6px;height: 50px;font-size: larger;float: right;background-color: #26bb9c;color: #fff;/* 给按钮带上圆角 */border-radius: 0.3pc;border: 0.5px;margin-top: 10px;margin-right: 10px;margin-bottom: 10px;}/* 提交按钮的鼠标事件 */.container .part2 button:hover {color: burlywood;}.container .part2 .result {margin-top: 15px;margin-left: 15px;margin-bottom: 15px;}.container .part2 .result pre {font-size: large;}</style></head><body><div class="container"><!-- 导航栏,具体功能可以自己实现 --><div class="navbar"><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a><a href="/all_questions">竞赛</a><a href="/all_questions">讨论</a><a href="/all_questions">求职</a><a class="login" href="#">登录</a></div></div><!-- part1:题目信息 + 代码编辑区 --><div class="part1"><!-- 左边的题目信息 --><div class="left_desc"><!-- span --><h4><span id="number">{{number}}</span>.{{title}}.{{star}}</h4><pre>{{desc}}</pre></div><!-- 右边的代码编辑区域 --><div class="right_code"><pre id="code" class="ace_editor"><textarea class="ace_textinput">{{header}}</textarea></pre></div></div><!-- part2:代码的返回结果 --><div class="part2"><div class="result"></div><!-- 提交按钮 --><button class="bt" onclick="submit()">提交</button></div></div><script>//初始化对象editor = ace.edit("code");//设置风格和语言(更多风格和语言,请到github上相应目录查看)// 主题大全:http://www.manongjc.com/detail/25-cfpdrwkkivkikmk.htmleditor.setTheme("ace/theme/cobalt");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() {// alert("嘿嘿!");// console.log("哈哈!");// 1. 收集当前页面的题目和代码var code = editor.getSession().getValue(); // 获取到代码var number = $(".container .part1 .left_desc h4 #number").text(); // 获取到题号var judge_url = "/judge/" + number; // 构建 url// 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) {// 响应的结果会保存在 data 中// console.log(data);show_result(data);}});// 3. 得到后端返回的结果,解析显示给用户function show_result(data) {// 拿到 result_div 结果标签var result_div = $(".container .part2 .result");// 清空上一次的运行结果result_div.empty();// 首先拿到结果的状态码和原因var _status = data.status;var _reason = data.reason;var reason_lable = $("<p>", {text: _reason});reason_lable.appendTo(result_div);if (status == 0) {// 编译运行成功,但是否通过测试是不清楚的var _stdout = data.stdout;var _stderr = data.stderr;var stdout_lable = $("<pre>", {text: _stdout});var stderr_lable = $("<pre>", {text: _stderr});stdout_lable.appendTo(result_div);stderr_lable.appendTo(result_div);}else {// 编译运行出错 Do Nothing}}}</script></body></html>
效果展示
👉MySQL 版题目设计👈
要将题目设计数据库化,那么要做到一下几步:
- 在数据库中设置可以远程登录的 MySQL用户,给他赋权。
- 设计表结构:数据库 oj,表 oj_questions。
- 开始编码:使用代码来访问数据库拿到数据。
创建用户
# 输入密码登录 mysql
mysql -uroot -puse mysql;
# 查看 mysql 的用户信息
select User, Host from user;
# 创建用户(任意 ip 地址登录, 密码为 123456)
create user oj_client@'%' identified by '123456';
# 创建 oj 数据库
create database oj;
# 给 oj_client 用户赋权
grant all on oj.* to oj_client@'%';# 如果上述过程出现错误, 可能权限表没有刷新, 使用下面的语句进行刷新
flush privileges;
使用 MySQL Workbench 创建表结构
注:MySQL Workbench 在 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 '题目的难度',`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;
录题
重新设计 oj_model
重新设计 oj_model 就是用代码的方式来访问数据库,获得题目信息。那么我们先要下载 MySQL 的开放包,下载链接:https://downloads.mysql.com/archives/c-c/。
下载完成后,使用 rz 命令将压缩包发送到 Linux 系统中,然后再使用 tar 命令进行解压。
将连接库引入到项目中
因为我们的 oj_server 模块是基于 MVC 模式的,和数据打交道的只有一个 oj_model 模块,所以只需要更改该文件即可。
oj_model1.hpp
#pragma once
// MySQL 版
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>
#include <fstream>
#include <cassert>
#include "../comm/log.hpp"
#include "../comm/util.hpp"
#include "include/mysql.h"// 根据题目列表文件, 加载所有的题目信息到内存中
// Model: 主要用来和数据进行交互, 对外提供访问数据的接口namespace ns_model
{using namespace std;using namespace ns_log;struct Question{std::string number; // 题目编号std::string title; // 题目标题std::string star; // 难度: 简单 中等 简单std::string desc; // 题目描述std::string header; // 题目预设给用户在线编辑器的代码std::string tail; // 题目的测试用例, 需要和 header 进行拼接形成完整代码 int cpu_limit; // 题目的时间要求(s)int mem_limit; // 题目的空间要求(KB)};const string oj_questions = "oj_questions";const string host = "127.0.0.1";const string user = "oj_client";const string passwd = "123456";const string db = "oj";const int port = 3306;class Model{public:Model() {}~Model() {}bool QueryMysql(const string& sql, vector<Question>* out){// 创建 MYSQL 句柄MYSQL* my = mysql_init(nullptr);if(mysql_real_connect(my, host.c_str(), user.c_str(), passwd.c_str(), db.c_str(), port, nullptr, 0) == nullptr){LOG(FATAL) << "连接数据库失败!\n";return false;}// 一定要设置该链接的编码格式, 要不然会出现乱码问题mysql_set_character_set(my, "utf8");LOG(INFO) << "连接数据库成功!\n";// 执行 SQL 语句if(mysql_query(my, sql.c_str()) != 0){LOG(WARNING) << "执行 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(int i = 0; i < rows; ++i){MYSQL_ROW row = mysql_fetch_row(res); // MYSQL_ROW 是 char**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);}// 释放结果空间free(res);// 关闭 mysql 连接mysql_close(my);return true;}// 获取所有题目bool GetAllQuestions(vector<Question>* out){string sql = "select * from ";sql += oj_questions;return QueryMysql(sql, out);}// 获取一道题目bool GetOneQuestion(const string& number, Question* q){bool res = false;string sql = "select * from ";sql += oj_questions;sql += " where id=";sql += number;vector<Question> result;if(QueryMysql(sql, &result)){if(result.size() == 1) {*q = result[0];res = true;}}return res;}};
}
只需要将原来的 oj_model 模块替换掉即可。
Makefile
oj_server:oj_server.ccg++ -o $@ $^ -I./include -L./lib -std=c++11 -lpthread -lctemplate -ljsoncpp -lmysqlclient
.PHONY:clean
clean:rm -f oj_server
解决无法找到 mysqlclient 动态库的问题
cd /etc/ld.so.conf.d/# 创建配置文件
sudo vim oj_search.conf
# 在文件中输入 mysqlclient 库所在的路径, 保存并退出
/home/Joy/project/online-judge/oj_server/lib
# 加载配置文件
sudo ldconfig
# 最后可以通过 ldd 命令来查看是否已经找到动态库了