第一章 项目介绍
project: 个人实战项目仓库,持续更新 - Gitee.com
1. 项目亮点
- 项目亮点一:在线OJ的项目
- 项目亮点二:负载均衡的项目
2. Comm-公共模块
- 它能提供一些文件操作,字符串处理,网络请求等等
3. CompilerServer-编译运行模块
它聚焦在我们的服务器中,帮我们进行当我们的用户将代码提交上来之后,我们把用户的代码在我们的服务器上形成临时文件,并且进行编译,进行运行,得到运行结果,
3.OnlineJudge-在线OJ模块
采用MVC的设计模式,使其能调用CompilerServer编译运行模块,以及访问文件或者访问数据库,还有我们的题目列表及编辑界面,展示给用户,让用户能进行操作
其中测试的信息也会在我的项目中有所体现,
4. 演示项目
- 会形成2个文件,一个是编译的服务器compile_server,另一个是在线OJ的服务器oj_server
- 这两个之间采用网络套接字实现通信,这样一来我们就可以把编译模块部署在后端多台服务器上,而我们的oj_server只要一台,
- 这样一来我们的服务器oj_server就可以自动式的负载均衡式的去选择后端服务,来让我们能以集群处理能力的方式对外输出我们的在线OJ服务,
- 其实这个项目是一个完全可扩展的项目,
5. 项目功能
这个项目实现了类似leetcode的题目列表+在线编程功能
compiler_server中:
- 编译功能
- 运行功能
- 编译并运行功能
- 将编译并运行服务打包成网络服务的功能
oj_server中:
- 获取首页(用题目列表充当)
- 编辑区域页面(就是我们写代码的区域)
- 提交判题功能(会调用编译运行)
第二章 项目准备工作
1. 技术栈
1.1 C++ STL 标准库
- 使用原因:会使用STL中的数组,哈希等容器
1.2 Boost 标准库
- 使用原因:项目中会对这字符串进行切割,所以引入了这个库
1.3 cpp-httplib
-
使用原因:主要使用的是这个库中的一个头文件,进行网络套接字通信
1.4 ctemplate
- 使用原因:引入这个第三方库,主要是进行前端网页渲染
1.5 jsoncpp-dev
- 使用原因:引入这个第三方库,可以把用户输入的内容和输出的内容进行 序列化、反序列化
1.6 负载均衡设计(TODO)
1.7 多进程、多线程
- 使用原因:这个项目也考虑到了多进程,多线程的场景,其中网络库cpp-httplib就是多线程的,也进行了加锁解锁处理
1.8 MySQL C connect
- 使用原因:这个项目先做了一个文件版本,后改成了mysql版本,自然也会调用mysql的一些接口
1.9 Ace前端在线编辑器
-
使用原因:引入Ace这个插件,实现前端的编辑,前端的编辑器
1.10 html/css/js/jquery/ajax
- 使用原因:为了跟前后端数据进行交换
2. 开发环境
我这里使用的Ubuntu云服务器+windows中的vecode开发
3. 新建项目
3.1 新建文件夹
3.2 添加readme文件
这个项目核心三个模块
1. comm: 公共模块
2. compile_server:编译与运行模块
3. oj_server:获取题目列表,查看题目编写题目界面,负载均衡,其他功能
4. 项目宏观结构
整个项目采用BS模式,在服务端中会提供2个服务,一个oj_server服务(实现访问文件或数据库,获取题目列表及编辑界面,以及对用户提交的代码进行判断) ,另一个compile_server服务(编译用户提交的代码,形成临时文件,编译并运行代码,得到运行结果)
当有多个客户端,即浏览器发送请求时,只有提交代码的请求,oj_server才会通过负载均衡调度算法去选择complie_server模块进行编译处理,并把最后结果返回给用户
而对应客户端的其他请求,比如请求题目列表,请求特定题目的编写,就只需要通过oj_server模块调用文件或数据库进行返回
5. 项目编写思路
- 先编写compile_server模块
- 再编写oj_server模块
- 实现一个文件版的在线OJ服务
- 最后完成前端的页面设计
在这几步完成之后, 再实现一个基于mysql版的在线OJ服务
第三章 编译功能的开发
1. 新建文件夹
说明一下:
- complier.hpp 提供代码编译的功能
- runner.hpp 提供代码运行的功能
- complie_run.hpp 对外提供接口,整合编译运行的功能
- complie_server.cc 实现整合代码编译运行的相关逻辑,对外提供编译运行服务
2. 编写makefile
compile_server:compile_server.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f compile_server
- 这里肯定会用到其他的第三方库,但用到的时候再修改makefile即可
3. 编写complier.hpp
在编写complier.hpp之前可以顺便把防止重复包含头文件的宏加上 #pragma once
然后我们在定义一下类Compiler,也肯定会用到构造和析构,再用命名空间ns_compiler套一层,避免命名冲突,则代码如下
// 这里只进行代码的编译
namespace ns_compiler
{class Compiler{public:// 构造函数Compiler(){;}// 析构函数~Compiler(){;}};
};
3.1 设计思路
我们这里的编译服务默认已经是有代码的,即用户上传到代码到这里,但用户上传的代码不是一个源文件,需要形成一个临时文件(源文件)才能处理,
但为了让compiler.hpp只做编译服务,则这个功能交个其他模块实现,则走到complier.hpp时默认已经形成了临时文件(源文件),则需要对它进行处理
众所周知编译的结果要么成功-编译通过,要么失败-编译出错(stderr),编译成功就不说了,但要是编译出错了,我们这里就需要形成临时文件,用来保存编译出错的结果,
当编译出错时,如果还是只有这一个进程,那么g++的时候就会替换这个进程,就无法再提供服务了,所以这里就需要使用fork,创建子进程,让子进程继续完成编译工作,父进程继续提供服务,
而当子进程在处理编译出错时,是会把错误信息输出到stderr中,即显示器上,但我们又想把错误信息写入到临时文件中,则必然会用到重定向
- 具体更多细节,再后续代码中会提到
3.2 创建util.hpp && 新建tmp文件夹
static bool Compile(const std::string &file_name)
{pid_t res = fork();if(res < 0){return false;// 创建子进程失败}else if(res == 0){//我是子进程//execlp}else{//我是父进程}
}
说明一下:
- 将编译函数定义为static,再后续调用是就可以不依赖对象直接调用了
- 而让子进程执行编译服务,就是程序替换,替换为g++ -o 生成文件 源文件,则我们这里选用的是execlp这个接口,因为简单,且不需要程序路径
在子进程中,我们要调用execlp执行g++命令,则必然需要源文件,但外部传递的参数file_name就只是一个文件名,比如1234,但我们需要的是1234.cpp,且编译成功会形成1234.exe,编译失败会形成1234.error,这些临时文件都是我们规定的
则我们就可以在这里文件夹下再创建一个文件夹tmp,专门用来保存这些临时文件,还需要一个方法实现对文件名的构建,我们可以在Comm模块中创建一个util.hpp文件,并把文件名的构建封装到这个文件中的一个类中,这中类也被叫做工具类
#pragma once
#include <string>namespace ns_util{// 一个专门实现文件名的构建工具class PathFile{// 将1234 -> ./temp/1234.cppstatic void Src(const std::string& file_name){;}// 将1234 -> ./temp/1234.exestatic void Exe(const std::string& file_name){;}// 将1234 -> ./temp/1234.errorstatic void Error(const std::string& file_name){;}};
}
- 待实现
在这之后我们就在compiler.hpp中使用文件名构造方法了,即包含文件,引入命名空间,使用函数
3.3 编写路径拼接功能
这里可以先定义一个前缀的文件名前缀,且是一个全局变量, const std::string tmp_file = "./temp/"; 这种做法就可以提高代码的健壮性
为了方便execlp能够更好的调用,将文件名拼接功能改成str::string的返回值,同时也可以再抽象一层,别问,问就是提高代码的可维护性
namespace ns_util
{// 一个专门实现文件名的构建工具const std::string tmp_file = "./temp/"; // 前缀class PathFile{public:static std::string AddSuffix(const std::string &file_name, const std::string &suffix){std::string path_file = tmp_file; // "./temp/"path_file += file_name; // "./temp/1234"path_file += suffix; // "./temp/1234.cpp"return path_file;}// 将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.errorstatic std::string Error(const std::string &file_name){return AddSuffix(file_name, ".error");}};
}
而子进程中的execlp,就可以这么调用文件拼接功能了
// 我是子进程
// g++ -o target src -std=c++11
execlp("g++", "-o", PathFile::Exe(file_name).c_str(),PathFile::Src(file_name), "-std=c++11",nullptr);
exit(EXIT_SUCCESS);// 程序替换也是有可能会失败的
- execlp中命令参数传完了,必须以nullptr结尾
- 当程序替换失败就直接exit退出进程
3.4 判断是否编译成功
作为父进程在创建子进程,让子进程进行程序替换之后,是需要等待子进程的,但在这里可以不用关系子进程的退出码错误信息,阻塞式的读取,即waitpid(pid, nullptr, 0);
pid_t waitpid(pid_t pid, int *status, int options);
参数说明
-
pid
:> 0
:等待指定的子进程 ID。-1
:等待任意子进程(等价于wait()
)。0
:等待与当前进程同组的任意子进程。< -1
:等待绝对值等于pid
的进程组中的任意子进程。
-
status
:- 进程状态信息的指针,存储子进程的退出状态,输出型参数
-
options
:0
:默认行为,阻塞父进程直到子进程结束。WNOHANG
:非阻塞模式,如果没有子进程结束,则立即返回0
。WUNTRACED
:子进程如果因信号暂停(如SIGSTOP
)也会返回状态。WCONTINUED
:子进程恢复执行(如SIGCONT
)时,父进程也能获取状态
返回值
- 成功:返回子进程 ID。
- 失败:返回
-1
,并设置errno
。 - 如果使用
WNOHANG
,则可能返回0
(表示没有子进程退出)
static bool Compile(const std::string &file_name)
{pid_t pid = fork();if (pid < 0){return false; // 创建子进程失败}else if (pid == 0){// 我是子进程// g++ -o target src -std=c++11execlp("g++", "-o", PathFile::Exe(file_name).c_str(),PathFile::Src(file_name), "-std=c++11",nullptr);exit(EXIT_FAILURE); // 程序替换也是有可能会失败的}else{// 我是父进程waitpid(pid, nullptr, 0); // 不关心退出码错误信息,且阻塞式等待if (FileUtil::IsExitFile(PathFile::Exe(file_name))){return true;}}return false;
}
而怎么知道execlp替换g++命令时,是否成功生成了可执行文件呢,方法一:直接open,看是否能打开打开这个文件,但我更推荐使用方法二:stat系统调用函数,获取文件属性信息
判断一个文件是否存在的功能,可能其他模块也要用,所以也可以抽象一层,封装一个类
int stat(const char *pathname, struct stat *buf);
参数说明
pathname
:要查询的文件路径(字符串)buf
:struct stat
结构体的指针,存储文件信息
返回值
- 成功:返回
0
,buf
结构体被填充 - 失败:返回
-1
,并设置errno
namespace ns_util
{// 一个专门处理文件的功能class FileUtil{public:static bool IsExitFile(const std::string &file_path){struct stat s; // 文件属性信息的结构体if (stat(file_path.c_str(), &s) == 0){return true; // 文件存在,成功读取到文件信息}return false;}};
}
3.5 重定义标准错误
在最开始我有提到,我们这个项目会把错误信息写入错误文件中,对应的就是标准错误流2
再执行进程替换之前,先打开我们的错误文件,并获取到文件描述符,
g++命令执行完毕之后,会把错误写入标准错误中,而我们就可以使用重定向将错误写入我们的文件描述符
else if (pid == 0)
{// 我是子进程// open的第三个参数是文件权限(创建时给的);int fd_error = open(PathFile::Error(file_name).c_str(), O_WRONLY | O_CREAT, 0644);if (fd_error < 0){exit(EXIT_FAILURE);}dup2(2,fd_error);// 2表示标准错误// g++ -o target src -std=c++11execlp("g++", "-o", PathFile::Exe(file_name).c_str(),PathFile::Src(file_name), "-std=c++11",nullptr);exit(EXIT_FAILURE);// 程序替换也是有可能会失败的
}
- 注:进程替换不影响文件描述符表
4. 编写日志功能
我们上面的编译函数返回的就只有true或中fasle,但这样做后续不方便维护,我们其实也应该写个日志功能,将执行代码的结果写入日志模块中,以便后续维护
4.1 创建 && 编写 log.hpp
在comm文件夹下面创建一个log.hpp,并引入一个命令空间,再抽象一下写个类
这里的日志我想这样调用LOG(日志等级) << "信息",我们可以先开始写个Log函数
inline std::ostream &Log(const std::string level, const std::string file_name, int line)
- level 一共五种等级,使用枚举enum定义
- INFO(正常信息),DEBUG(调试信息),WARING(警告信息),ERROR(错误信息),FATAL(致命信息)
- file_name 就是文件名
- line 就是文件名的行号
#pragma once
#include <iostream>
#include <string>namespace ns_log
{enum{INFO,DEBUG,WARING,ERROR,FATAL};inline std::ostream &Log(const std::string level, const std::string file_name, int line){std::string message;message += "[" + level + "]"; // 添加日志等级// message+= 添加时间message += "[" + file_name + "]"; // 添加报错文件名message += "[" + std::to_string(line) + "]"; // 添加报错文件名行数std::cout << message; // 写入缓冲区 不要刷新return std::cout;}}
- 注 std::cout << message; 如果加上了endl,就会刷新缓冲区,所以不要加,
前面我们有说到,我们想这样用LOG(日志等级) << "信息",且上面那个函数叫Log,
所以.. 没错这里还要封装一下
#define LOG(level) Log(#level,__FILE__,__LINE__)
- 再宏定义中给宏参数带上#就表示是个字符串
由于通常的日志都是会带上时间的,所以我这里就需要一个获取时间戳的函数,我们就可以在util.hpp中新建一个时间的工具类,专门来处理时间,以便后续需要调用
// 一个专门处理时间的功能
class TimeUtil
{static std::string GetTimeStamp(){;}
};
4.2 编写GetTimeStamp
可以直接使用time来获取时间戳,但这里我想使用gettimeofday来获取时间戳
参数说明:
- struct timeval*tv;是一个输出型参数,这个结构体包含一个秒,一个微秒
- struct timezone* tz是时区,我们这里不关心设置为nullptr就行
// 一个专门处理时间的功能
class TimeUtil
{
public:static std::string GetTimeStamp(){struct timeval s; // 结构体gettimeofday(&s, nullptr);return std::to_string(s.tv_sec);}
};
4.3 添加日志功能
首先完善日志功能,如下
#pragma once
#include <iostream>
#include <string>
#include "util.hpp"namespace ns_log
{using namespace ns_util;// 引入命名空间enum{INFO,DEBUG,WARING,ERROR,FATAL};inline std::ostream &Log(const std::string level, const std::string file_name, int line){std::string message;message += "[" + level + "]"; // 添加日志等级message += TimeUtil::GetTimeStamp();message += "[" + file_name + "]"; // 添加报错文件名message += "[" + std::to_string(line) + "]"; // 添加报错文件名行数std::cout << message; // 写入缓冲区 不要刷新return std::cout;}// LOG(INFO) <<"信息" << "\n"#define LOG(level) Log(#level,__FILE__,__LINE__)}
然后我们将目光转到compiler.hpp文件中,引入日志模块
然后再需要添加的地方上添加日志功能
static bool Compile(const std::string &file_name)
{pid_t pid = fork();if (pid < 0){LOG(ERROR) << "创建子进程失败" << "\n";return false;}else if (pid == 0){// 我是子进程// open的第三个参数是文件权限(创建时给的);int fd_error = open(PathFile::Error(file_name).c_str(), O_WRONLY | O_CREAT, 0644);if (fd_error < 0){// waringLOG(WARING) << PathFile::Error(file_name) << "打开失败" << "\n";exit(EXIT_FAILURE);}dup2(2, fd_error); // 2表示标准错误// g++ -o target src -std=c++11execlp("g++", "-o", PathFile::Exe(file_name).c_str(),PathFile::Src(file_name), "-std=c++11",nullptr);LOG(ERROR) << "进程替换失败" << "\n";exit(EXIT_FAILURE); // 程序替换也是有可能会失败的}else{// 我是父进程waitpid(pid, nullptr, 0); // 不关心退出码错误信息,且阻塞式等待if (FileUtil::IsExitFile(PathFile::Exe(file_name))){LOG(INFO) << "编译成功已生成" << PathFile::Exe(file_name) << "\n";return true;}}LOG(ERROR) << "编译失败" << "\n";return false;
}
- 由于我们日志是个开放式接口,所以使用的时候就可以灵活调用
5. 测试编译模块
想要测试编译模块,自然是需要在main调用,则需要在compile_server.cc中调用,所以这里聚焦在compile_server.cc中
这个好像是我们的compile_server模块写的有问题,找找看
5.1 bug1
- 然后我发现是调用execlp时有问题,第一个参数是程序名,第二个参数才是参数,所以需要在最前面添加一个g++
- 且要把PathFile::Src(file_name).c_str(),因为参数应该是const char*类型的,不支持string
然后我们在终端中再次运行一下,make
- 成功编译没有问题
5.2 bug2
接下来我们在故意在code.cpp中出现语法错误,看编译错误内容能否在code.error中出现
手动把上面生成的临时文件清除,再进行测试
然后我发现又有bug,标准错误应该重定向到code.error,但是打印到了显示器上,我一下肯定是重定向的时候写错了(原因就是文件描述符写反了)
需要将 dup2(2,fd_error) 改成dup2(fd_error, STDERR_FILENO);// STDERR_FILENO 就是2
再次手动把上面生成的临时文件清除,再进行测试
- ok 这次就没有问题了
这里日志打印时间戳没有括号,可以加上
走到这里我们这里 compiler.hpp基本就完成了,接下来就是写runner.hpp文件了
第四章 运行功能开发
1. 文件掩码
上面还有一个细节代码,虽然调用open的时候设置了,不存在文件时,创建文件的掩码为0644,但不一定是0644,保险起见应该添加umask(0);// 设置文件掩码
2. 编写runner.hpp
2.1 基本思路
现在我们聚焦在runner.hpp中,这是一个运行代码的功能,大致结构和compiler.hpp是类似的,首先写个命名空间,再在中写个runner的类,其中有个函数run就可以实现运行代码的功能
run函数中的file_name参数和compile的参数是一样的,只是一个文件名比如1234,是需要调用工具类(PathFile)中的工具函数(AddSuffix)构建文件名的,
同样也需要调用fork函数形成子进程,让子进程去运行代码,而至于用那个进程替换函数呢,再后面会说
#pragma once
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
namespace ns_runner
{class runner{public:runner(){}; // 构造函数~runner(){}; // 析构函数static int run(const std::string file_name){pid_t pid = fork(); // 创建子进程if(pid < 0){//创建子进程失败}else if(pid == 0){// 我是子进程// 这里一定是需要进行程序替换的,execlp}else{// 我是父进程}}};
}
2.2 如何处理运行结果
在这里我们需要思考程序运行的结果:1.代码跑完,结果正确,2.代码跑完,结果不正确,3.代码没跑完,异常了
但我们再想一下,我明明是个run功能,为什么要去关心代码是否正确呢,所以这里看似要处理很多情况,其实我们这里只关心,是否正确运行完毕即可
而实现run功能的时候,我们必须要知道可执行程序是谁,且我们还需要这个程序的:1.标准输入(不处理),2.标准输出(程序运行完成,输出结果是什么),3.标准错误(运行时错误信息),这里需要解释意思标准输入是什么,如下图,但这里不处理,只留个接口,
那构建文件名的接口需要调整,比如compile会形成一个1234.compile_error,叫编译报错,需要添加新接口,使其能形成1234.stdin,1234.stdout,1234.stderr,叫运行报错
如果形成了标准输入,标准输出,标准错误,那么就会方便我们去后期查看
// 将1234 -> ./temp/1234.Compile_error
static std::string Compile_error(const std::string &file_name)
{return AddSuffix(file_name, ".Compile_error");
}
// 将1234 -> ./temp/1234.stdin
static std::string Stdin(const std::string &file_name)
{return AddSuffix(file_name, ".stdin");
}
// 将1234 -> ./temp/1234.stdout
static std::string Stdout(const std::string &file_name)
{return AddSuffix(file_name, ".stdout");
}
// 将1234 -> ./temp/1234.stderr
static std::string Stderr(const std::string &file_name)
{return AddSuffix(file_name, ".stderr");
}
现在再在run函数中调用这个几个接口,如下:
static int run(const std::string file_name)
{const std::string _execute = PathFile::Exe(file_name); // 1234.execonst std::string _stdin = PathFile::Stdin(file_name); // 1234.stdinconst std::string _stdout = PathFile::Stdout(file_name); // 1234.stdoutconst std::string _stderr = PathFile::Stderr(file_name); // 1234.stderrpid_t pid = fork(); // 创建子进程if (pid < 0){// 创建子进程失败}else if (pid == 0){// 我是子进程// 这里一定是需要进行程序替换的,execlp}else{// 我是父进程}
}
- 这里是需要引入头文件和命名空间的,才能调用接口
#include "../comm/util.hpp",#include "../comm/log.hpp"
using namespace ns_util; // 引入命名空间,using namespace ns_log;// 引入命名空间
而后面代码是需要使用这些文件的,但是使用的前提是需要打开
// 使用的前提是需要打开
int _stdin_fd = open(_stdin.c_str(), O_CREAT | O_RDONLY); // 只读
int _stdout_fd = open(_stdout.c_str(), O_CREAT | O_WRONLY); // 只写
int _stderr_fd = open(_stderr.c_str(), O_CREAT | O_WRONLY); // 只写
if(_stdin_fd < 0 || _stdout_fd<0||_stderr_fd<0){return -1;//表示文件打开失败
}
- 至于这里为什么返回的是-1(文件打开失败),后面再说
如果创建子进程失败了,那说明打开文件成功了,这里就直接关闭所有的临时文件,并返回-2(创建子进程失败),
if (pid < 0)
{close(_stdin_fd);close(_stdout_fd);close(_stderr_fd);return -2; // 代表创建子进程失败
}
而对应子进程来说,我们后面需要会查看那些临时文件,这里就会发生重定向dup2,且需要程序替换execl,去指向可执行程序
至于为什么要用execl函数,是因为有路径,可执行程序为./temp/1234.exe
else if (pid == 0)
{// 我是子进程// 重定向dup2(_stdin_fd, 0);dup2(_stdout_fd, 1);dup2(_stderr_fd, 2);// 程序替换:第一参数是要执行谁,怎么执行execl(_execute.c_str(), _execute.c_str(), nullptr);exit(EXIT_SUCCESS); // 表示程序替换失败
}
对于父进程,上来就可以直接把临时文件关闭,且父进程是需要调用waitpid等待子进程(阻塞式等待),我们这里不关心退出码,但是需要退出信号(后面再说),所以需要传入一个输出型参数
整个模块代码如下:
#pragma once
#include <iostream>
#include <sys/types.h>
#include <unistd.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() {}; // 析构函数static int run(const std::string file_name){const std::string _execute = PathFile::Exe(file_name); // 1234.execonst std::string _stdin = PathFile::Stdin(file_name); // 1234.stdinconst std::string _stdout = PathFile::Stdout(file_name); // 1234.stdoutconst std::string _stderr = PathFile::Stderr(file_name); // 1234.stderr// 使用的前提是需要打开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){// 我是子进程// 重定向dup2(_stdin_fd, 0);dup2(_stdout_fd, 1);dup2(_stderr_fd, 2);// 程序替换:第一参数是要执行谁,怎么执行execl(_execute.c_str(), _execute.c_str(), nullptr);exit(EXIT_SUCCESS); // 表示程序替换失败}else{// 我是父进程close(_stdin_fd);close(_stdout_fd);close(_stderr_fd);int status = 0;waitpid(pid, &status, 0); // 阻塞式return status & 0x7F;// 获取退出信号}}};
}
2.3 解释返回值问题
如果有细心的朋友,就会发现Run函数的返回值是int,简单来说我们虽然不关心运行结果是否正确,但是需要关心是否出现运行异常
对于返回值设计的原因简单来说:
- 返回值 > 0:说明程序异常,退出时收到了信号(信号都是大于0的),返回值就是对应的信号编号
- 返回值==0:说明程序正常运行,结束输出到对于的临时文件中了
- 返回值<0:说明是内部错误,比如,-1文件打开失败,-2创建子进程失败,
3. 测试运行模块
测试之前,还是先添加日志功能,如下:
LOG(ERROR) << "运行时打开文件失败" << "\n";
LOG(ERROR) << "运行时创建子进程失败" << "\n";
LOG(ERROR) << "运行时程序替换失败" << "\n";
LOG(INFO) << "运行成功 退出信号为" << (status & 0x7F) << "\n";
- 添加到Run函数中对应的位置,这里不多说
现在聚焦在compiler_run.hpp,然后make clean;make
#include "complier.hpp"
#include "runner.hpp"
using namespace ns_compiler;
using namespace ns_runner;
int main()
{std::string file_name = "code";// 单纯的文件名Compiler::Compile(file_name);// 编译runner::run(file_name);// 运行return 0;
}
说明一下:
- 基本的运行功能是没什么问题的,但后续还需加其他功能,比如用户可能会上传恶意代码,吃我们的内存,传递个死循环代码,
再统一一下格式,不然看着有点别扭,
4. 添加资源限制
4.1 setrlimit
再给Run代码运行函数添加资源限制之前,还是需要先写个测试用例并解释一下setrlimit
参数说明:
resource
:指定要限制的资源,例如RLIMIT_CPU
(CPU 时间)RLIMIT_FSIZE
(最大文件大小)RLIMIT_NOFILE
(最大文件描述符数量)RLIMIT_AS
(进程地址空间大小)
rlim
:指向rlimit
结构的指针,该结构定义了资源的软限制和硬限制
再解释一下rlimit结构:
说明一下:
- 软限制 (
rlim_cur
):进程当前可以使用的最大资源量,可以被进程降低 - 硬限制 (
rlim_max
):进程可以设置的最大值,只有特权进程(root)才能提高它
但我们这里直接将rlim_max设置为RLIM_INFINITY(无穷值)
4.2 cpu资源限制案例
#include <iostream>
#include <sys/time.h>
#include <sys/resource.h>
using namespace std;int main()
{struct rlimit s;s.rlim_cur = 1;// 表示限制CPU为1秒s.rlim_max = RLIM_INFINITY;// 无穷大setrlimit(RLIMIT_CPU,&s);while(1){;// 直接就是死循环}return 0;
}
- 这样做就是添加上了cpu资源限制
4.3 内存资源限制
#include <iostream>
#include <sys/time.h>
#include <sys/resource.h>
using namespace std;int main()
{struct rlimit s;s.rlim_cur = 1024*1024*40;// 内存限制为40MBs.rlim_max = RLIM_INFINITY;// 无穷大setrlimit(RLIMIT_AS,&s);int count = 0;while(1){int * p = new int[1024*1024];// 申请1MBcout << count++ << endl;// 能申请1MB的次数}return 0;
}
说明一下:
- 我这里明明是限制为40MB,但最后却发现只申请了7MB就不行了
- 原因是:程序本身加载也是要占用空间的,所以有可能不准确
4.4 捕捉信号
在这里我就很好奇,程序出错了,是会返回信号的,但是cpu超时,内存超时到底是返回的什么信号,以及信号对应的错误信息是什么,这是我们应该关系的,所以我再下面捕捉了测试并捕捉信号
#include <iostream>
#include <sys/time.h>
#include <sys/resource.h>
#include <signal.h>
using namespace std;void handler(int signo)
{std::cout << "signo : " << signo << std::endl;exit(1);
}int main()
{// 捕捉信号for(int i = 0;i < 32;i++){signal(i,handler);}struct rlimit s;s.rlim_cur = 1024*1024*40;// 内存限制为40MBs.rlim_max = RLIM_INFINITY;// 无穷大setrlimit(RLIMIT_AS,&s);int count = 0;while(1){int * p = new int[1024*1024];// 申请1MBcout << count++ << endl;// 能申请1MB的次数}return 0;
}
说明一下:
- 观察发现当内存超过限制的内存时,是会发送6号信息
- 再使用kill -l 查看对应的6号信息->SIGABRT
4.5 给runner设置资源限制
这里需要修改Run接口,换句话说就是增加2个参数,一个cpu_limit一个mem_limit,在增加资源限制的时候将这个功能封装为一个函数,方便调用,
cpu_limit参数表示为:该程序运行的时候,可以使用的最大cpu资源上限,
mem_limit参数表示为:改程序运行的时候,可以使用的最大内存大小(KB)
// 设置进程占用资源的接口
static void SetProcLimit(int cpu_limit, int mem_limit)
{// 添加时间限制struct rlimit cpu_s;cpu_s.rlim_cur = cpu_limit;cpu_s.rlim_max = RLIM_INFINITY; // 无穷大setrlimit(RLIMIT_CPU, &cpu_s);// 添加内存限制struct rlimit mem_s;mem_s.rlim_cur = 1024 * mem_limit;//将kb转换为字节mem_s.rlim_max = RLIM_INFINITY; // 无穷大setrlimit(RLIMIT_AS, &mem_s);
}
- 这个函数直接在Runner这个类中实现就行了
走到这里我们的运行功能才算是完成了,但可能后序还需要修改,但这里我们这里还没有处理返回值的问题,Run函数虽然写了返回值,但怎么处理呢,交给谁处理呢,这都需要再后续中体现
接下来就该写编译运行模块呢,但我们在编译,运行模块中测试时的code.cpp,都是我们提前写好了的,但实际应该是由客户端发过来的代码,
则有重复的文件名该怎么办,客户端发来的请求中怎么提前代码,编译运行就真的是把编译和运行的功能写在一起吗,这些我们都会在后序的代码中,有所体现
第五章 编译运行功能开发
在compile_server.cc中不应该引入编译和运行的头文件,而是直接引入compile_run.hpp,所以一些代码结构是需要修改的,该加命名空间也要加上命名空间
#pragma once
#include "complier.hpp"
#include "runner.hpp"
using namespace ns_compiler;
using namespace ns_runner;namespace ns_compile_and_run
{class CompileAndRun{public:CompileAndRun();//构造~CompileAndRun();//析构};
}
1. 第三方库 jsoncpp-devel
1.1 引入原因
我这里会规定用户传过来的数据是一种结构性数据,因为不一定只是代码
code:用户提交的代码
input:用户给自己提高的代码对应的输入
- 这里个input暂时不实现,需要把接口留出来
而对应的返回给用户的数据也应该是一种结构性数据,因为不一定只返回代码结构
status:状态码
reason:请求结果
stdout:我的程序运行完的结构
stderr:我的程序运行完的错误结果
说明一下:
- status 和 reason是我们必须要返回的数据,如果出错了,就可以通过reason查看错误原因
- 而stdout和stderr是选择性输出
则我们要采用上面的设计模式,就很容易的想到了一种数据传输格式json,自然我们就应该用json传递数据,但我们这里不自己做序列化和序列化的工作,而是引入第三方库
1.2 安装jsoncpp-devel
下面这些指令都是在Ubuntu云服务中输入的,如果你是其他的发行版,请自行探索
安装命令:apt install -y libjsoncpp-dev
检查安装状态:dpkg -l | grep jsoncpp,如果出现了和我类似的界面即安装成功
检查头文件是否存在:find /usr/include -name "json.h",如果出现了和我类似的界面即安装成功
1.3 手动添加 includePath
因为我们是在VS Code中使用,所以可能需要手动配置 c_cpp_properties.json
文件
step1: 在 VS Code 中,打开命令面板(Ctrl + Shift + P
),搜索 "C/C++: Edit Configurations (UI)" 并打开
step2: 找到 includePath
,添加 jsoncpp 头文件路径:
"includePath": ["/usr/include","/usr/include/json"
]
1.4 演示案例
#include <json/json.h>
#include <iostream>int main() {Json::Value root;// 这是json的一个中间类root["code"] = "mycode";root["user"] = "迟迟cool";root["age"] = "23";// 序列化Json::StyledWriter writer;std::string str = writer.write(root);// 返回字符串std::cout << str << std::endl;// 使用 StreamWriterBuilder 来控制输出格式Json::StreamWriterBuilder builder;builder["emitUTF8"] = true; // 允许 UTF-8 直接输出std::string output = Json::writeString(builder, root);// 返回字符串std::cout << output << std::endl;return 0;
}
说明一下:
- 在编译时需要手动指定头文件路径和第三方库的路径:
g++ test.cpp -o test -I/usr/include/jsoncpp -ljsoncpp - Json::StyledWriter类输出时出现乱码,是因为
jsoncpp
默认会对 Unicode 字符进行 Unicode 转义,导致中文字符显示成\uXXXX
形式。 -
使用Json::StreamWriterBuilde并设置允许 UTF-8 直接输出就可以解决上面那个问题
但如果你是使用Json::FastWriter writer; 进行序列化,那么结果就不是那么美观了,如下:
2. 编写compile_run.hpp
2.1 宏观结构
在前面我们已经规定用户传入的是一个json,则我们在compiler_run.hpp中CompileAndRun函数首先要做的是对这个jison进行反序列化(将字符串变成结构化数据),然后提取结构化中的数据
// 传入是json传出也是json
static void Start(const std::string &in_json, std::string &out_json)
{// 反序列化:将字符串变成结构化数据Json::Value in_value; // in_json的中间类Json::Reader reader;reader.parse(in_json, in_value); // 从in_json中把数据写入in_value// 等下处理差错问题// 提取结构化数据std::string code = in_value["code"].asString(); // 以字符串的形式std::string input = in_value["input"].asString(); // 以字符串的形式if (code.size() == 0){// 等下处理差错问题}
}
- 这里只先定义宏观结构,暂不处理差错问题
对应code字段,是需要形成临时源文件,但由于编译服务随时可能被多个人请求,则需要保证每次传递上来的code形成源文件名的时候,要具有唯一性,不然多个用户之间就会互相影响
但这个形成唯一名的源文件的接口,应该从FileUtil工作类中调用,且形成的文件名只具有唯一性,没有目录没有后缀,比如说202503301234001这种文件名
// 一个专门处理文件的功能
class FileUtil
{
public:static bool IsExitFile(const std::string &file_path){struct stat s; // 文件属性信息的结构体if (stat(file_path.c_str(), &s) == 0){return true; // 文件存在,成功读取到文件信息}return false;}static std::string UniqueFileName(){// TODO UniqueFileName待实现return " ";}
};
假设现在已经有了唯一文件名,以前测试编译,运行的时候都是自己写的code.cpp,但现在就需要根据唯一文件名,将代码写入唯一文件名所形成的临时src
毫不意外的是,有需要把这个功能写在 FileUtil工作类中
再有了这两个函数之后,接下来就调用编译,运行模块,但是我在做运行模块时,添加了资源限制,则run函数就必须要传cpu_limit和mem_limit,这个暴露给上层就行了
则in_json中,用户传递的字段还应该包括cpu_limit和mem_limit
// 传入是json传出也是json
static void Start(const std::string& in_json,std::string& out_json)
{// 反序列化:将字符串变成结构化数据Json::Value in_value;// in_json的中间类Json::Reader reader;reader.parse(in_json,in_value);//从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的形式int mem_limit = in_value["mem_limit"].asInt();// 以int的形式if(code.size() == 0){// 等下处理差错问题}// 形成的文件名只有唯一性,没有目录和后置const std::string file_name = FileUtil::UniqueFileName();FileUtil::WriteFile(PathFile::Src(file_name),code);// 将代码写入所形成了临时文件中// 调用编译模块Compiler::Compile(file_name);//暂不处理差错情况// 调用运行模块Runner::Run(file_name,cpu_limit,mem_limit);
}
2.2 解决差错情况
由于为了和Run函数返回的信号对齐,我们这里就可以定义一个status变量,记录错误码,再定义一个reason变量,记录错误原因,而错误原因一定是通过status判断得到的
- status>0 Run函数执行错误,返回信号
- status<0 都是一些内部错误,具体是什么错误原因再分析
- status==0 编译运行成功
这个功能也可以封装为一个函数StatusToReason,进行转换
static std::string StatusToReason(int status){// 根据退出码 返回错误;
}
我们再稍加思考,返回给用户的json是包括必要的status,reason,非必要的stdout,stderr,而stdout和stderr这些字段的内容是需要从文件中读取的,再比如编译错误的reason = 编译错误文件中读取
而这个读取文件的接口,就可以放在FileUtil类中,
// 一个专门处理文件的功能
class FileUtil
{
public:static bool IsExitFile(const std::string &file_path){struct stat s; // 文件属性信息的结构体if (stat(file_path.c_str(), &s) == 0){return true; // 文件存在,成功读取到文件信息}return false;}static std::string UniqueFileName(){// TODO UniqueFileName待实现return " ";}static bool WriteFile(const std::string& file,const std::string& code){// TODO WriteFile待实现return true;}static std::string ReadFile(const std::string& file){// TODO ReadFilereturn "";}
};
现在我们的CompileAndRun函数的内容如下:
class CompileAndRun
{
public:CompileAndRun(); // 构造~CompileAndRun(); // 析构static std::string StatusToReason(int status){// 根据退出码 返回错误;}// 传入是json传出也是jsonstatic void Start(const std::string &in_json, std::string &out_json){// 反序列化:将字符串->结构化数据Json::Value in_value; // in_json的中间类Json::Reader reader;reader.parse(in_json, in_value); // 从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的形式int mem_limit = in_value["mem_limit"].asInt(); // 以int的形式// 序列化:将结构化数据->字符串Json::Value out_value; // out_josn的中间类int status = 0; // 错误码std::string reason; // 错误原因// 形成的文件名只有唯一性,没有目录和后置const std::string file_name = FileUtil::UniqueFileName();int return_signo = 0;if (code.size() == 0){status = -1; // 表示代码为空goto END;}// 将代码写入所形成了临时文件中if (!FileUtil::WriteFile(PathFile::Src(file_name), code)){status = -2; // 未知错误goto END;}// 调用编译模块if (!Compiler::Compile(file_name)){status = -3; // 编译报错// 错误信息从编译报错文件中读取goto END;}// 调用运行模块return_signo = Runner::Run(file_name, cpu_limit, mem_limit);if (return_signo < 0){// 信号status = -2; // 未知错误}else if (return_signo > 0){status = return_signo; // 运行异常返回信号}else{status = return_signo; // return_signo=0表正常运行结束}END:out_value["status"] = status;out_value["reason"] = StatusToReason(status);// 再处理"stdout"和"stderr"这两个字段if (status == 0){// 整个代码的编译 和 运行out_value["stdout"] = FileUtil::ReadFile(PathFile::Stdout(file_name));out_value["stderr"] = FileUtil::ReadFile(PathFile::Stderr(file_name));}// 进行序列化,将结构化数据->字符串Json::StyledWriter writer;out_json = writer.write(out_value);}
};
说明一下:
- 我这里使用了goto语句进行了跳转,当然不使用它也行,就是没有那么优雅
- 但使用goto进行语句跳转的时候,中间不能定义变量
2.3 编写StatusToReason函数
返回信号时,是否要将错误原因写入stderr中,再在stderr中读取数据
这是一个将错误码转换为对应的错误原因的函数,第一参数为错误码,大致就分三种情况:
- 当status > 0:进程收到了信号导致异常崩溃
- 当status < 0:整个进程非运行报错(代码为空o,编译报错等)
- 当status = 0:整个过程全部完成
需要新增第二个参数:文件名,因为编译报错是需要从指定文件中读取
static std::string StatusToReason(int status, const std::string file_name)
{// 根据退出码 返回错误std::string reason;switch (status){case 0:reason = "编译运行成功,没有问题";break;case -1:reason = "代码为空";break;case -2:reason = "内部错误";break;case -3:reason = FileUtil::ReadFile(PathFile::Compile_error(file_name));break;case SIGABRT: // SIGABRT=6reason = "内存超过限制";break;case SIGXCPU: // SIGXCPU=24reason = "CPU超过限制";break;case SIGSEGV: // SIGSEGV=24reason = "程序段错误";break;case SIGFPE: // SIGFPE=24reason = "浮点数溢出";break;default:reason = "未知原因status=" + std::to_string(status);break;}return reason;
}
- 以后要是再遇到其他信号再在Switch中添加case就可以
2.4 编写UniqueFileName函数
在上面我就说过了,由于编译服务随时可能被多个人请求,则需要保证每次传递上来的code形成源文件名的时候,要具有唯一性,不然多个用户之间就会互相影响
而这个文件名我打算通过毫秒级时间戳+原子性递增,来确保唯一性
所以就需要一个获得毫秒级时间戳的函数,我将其封装在TimeUtil类中
注:为了保证UniqueFileName函数能调用这个接口推荐把整个TimeUtil类放在ns_util空间的前面
// 一个专门处理时间的功能
class TimeUtil
{
public:// 获取时间戳static std::string GetTimeStamp(){struct timeval s; // 结构体gettimeofday(&s, nullptr);return std::to_string(s.tv_sec);}// 获得毫秒级时间戳static std::string GetTimeMs(){struct timeval s;// 结构体gettimeofday(&s,nullptr);// 第二个参数为时区return std::to_string(s.tv_sec*1000 + s.tv_usec/1000);}
};
说明一下:
- 成员tv_sec的单位是秒,而成员tv_usec的单位是微秒
- 他们需要转换时,就需要进行上面的转换
而想要实现原子性递增就需要用到atomic这个头文件,用它类置的类型去定义变量,这样就不用加锁解锁了
static std::string UniqueFileName()
{static std::atomic_uint id(0);id++;// 毫秒级时间戳+原子性递增唯一值:来保证唯一性std::string ms = TimeUtil::GetTimeMs();std::string unqiue_id = std::to_string(id);return ms + "_" + unqiue_id;
}
说明一下:
- 这里把id定义为static是为了保证它出了作用域还在,使其生命周期变长
- 如果不加这个staic,那id变量将每次都从栈上开辟空间,数据每次都会重置
2.5 编写WriteFile函数
static bool WriteFile(const std::string &file, std::string &content)
为了把这个函数和下一个ReadFile函数设计相匹配,这里我把code参数名,改为了content
对文件进行写入,我不打算使用系统接口和标准库了,直接用fstream流进行写入
static bool WriteFile(const std::string &file, const std::string &content)
{std::ofstream out(file); // 默认以写的方式打开if (!out.is_open()){return false; // 打开文件失败}out.write(content.c_str(), content.size());out.close();return true;
}
- ofstream是写入文件流,其中write的第一个参数为向哪里写,第二个参数为写多少个
2.6 编写ReadFile函数
static bool ReadFile(const std::string &file,std::string & content,bool keep=false)
为了把WriteFile和ReadFile函数设计相匹配,重新设计了参数,返回值接口如上,
至于这里的第三个参数是什么,我下面会说明
static bool ReadFile(const std::string &file, std::string &content, bool keep = false)
{content.clear(); // 清空数据std::ifstream in(file);if (!in.is_open()){return false;}std::string line;while (std::getline(in, line)){content += line;content += (keep ? "\n" : "");}return true;
}
说明一下:
- getline函数表示按行读,但是它不会读取\n,比如"abcde\n",就只会读取abcde
- 而有时候是需要保留\n的,比如我们自己测试时,如果读取到的数据没有\n,这个数据将十分不好阅读
- 所以我对这个接口添加了第三个参数,且使用了默认参数(默认为false),如果不需要\n,就不传第三个参数,反之需要就传true
由于我更改了ReadFile的参数设计,则compile_run.hpp中的要调用这个函数的接口就需要重新修改,如下:
其中compile_run.hpp走到这里就做的差不多了,但如果这里直接make编译项目,还是会有问题的
- 出现这个的原因是这个项目使用了json进行序列化,和反序列化的过程,但是编译的时候找不到头文件,
这我们在makefile中添加上头文件路径并指定第三方库就可以解决这个问题,如下:
compile_server:compile_server.ccg++ -o $@ $^ -std=c++11 -I/usr/include/jsoncpp -ljsoncpp.PHONY:clean
clean:rm -f compile_server
3. 测试编译运行模块
我们将目光转移到compile_server.cc中,毕竟我们是要通过这个函数进行调用接口的
- 我们这里需要测试这个模块,但这个测试用例该怎么写呢,后面被oj_server服务调用的时候又该怎么做呢,数据的发送与结束是不是要通过网络进行传递,这里应不应该考虑这些呢
3.1 基本测试
首先需要个测试用例,然后直接调用 CompileAndRun::Start的函数就行,它需要一个输入in_json和一个输出out_json,
-
in_json:{"code":"","input":"","cpu_limit":,"mem_limit":}
-
out_json:{"status":"","reason":"","stdout":"","stderr":""}
#include "compile_run.hpp"using namespace ns_compile_and_run;int main()
{// in_json:{"code":"","input":"","cpu_limit":,"mem_limit":}// out_json:{"status":"","reason":"","stdout":"","stderr":""}std::string in_json;Json::Value in_value;in_value["code"] = R"(#include <iostream>int main(){std::cout << "迟迟cool!" << std::endl;return 0;})";in_value["input"] = "";in_value["cpu_limit"] = 1;in_value["mem_limit"] = 1024*10;//10MB// 序列化:将结构化数据->字符串// 使用 StreamWriterBuilder 来控制输出格式Json::StreamWriterBuilder writer;writer["emitUTF8"] = true; // 允许 UTF-8 直接输出in_json = Json::writeString(writer, 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;
}
说明一下:
- 最基本的测试是没有问题的
- R"()"叫做raw string,它能让特殊字符保持原貌,不作特殊解释
3.2 CPU超过限制
3.3 内存超过限制
- 程序运行后,会在屏幕上打印错误信息,但我们做了重定向错误内容会输出到目标的.stderr中
3.4 浮点数溢出
3.5 段错误
3.6 编译报错
4. 清理临时文件
上面我在测试的时候,会生成很多很多临时文件,所以我在这里打算把它们全部清理掉,但我们这里是在compile_run.hpp中Start的最后调用运行模块中的Run函数之后,再清理(调用函数);
我们在清理文件是并不能确定有多少文件是存在的,因为可能前面就已经报错了,导致没有生成临时文件,但是我们是知道总共有哪些文件,一个个的判断是否存在,存在就删除
但是怎么删除一个文件呢,这里就需要引入unlink系统接口
具体代码,如下:
static void RemoveTempFile(const std::string file_name)
{// 清理各种临时文件,存在就删除const std::string _src = PathFile::Src(file_name);if (IsExitFile(_src)){unlink(_src.c_str());}const std::string _compile_error = PathFile::Compile_error(file_name);if (IsExitFile(_compile_error)){unlink(_compile_error.c_str());}const std::string _exec = PathFile::Exe(file_name);if (IsExitFile(_exec)){unlink(_exec.c_str());}const std::string _stdin = PathFile::Stdin(file_name);if (IsExitFile(_stdin)){unlink(_stdin.c_str());}const std::string _stdout = PathFile::Stdout(file_name);if (IsExitFile(_stdout)){unlink(_stdout.c_str());}const std::string _stderr = PathFile::Stderr(file_name);if (IsExitFile(_stderr)){unlink(_stderr.c_str());}
}
说明一下:
- 这个函数放在util.hpp中的FileUtil工具类中
第六章 把编译运行服务形成网络服务
1. 第三方库 cpp-httplib
1.1 引入原因
要把编译运行服务形成网络服务,则必然需要用到socket套接字,但是我们如果要自己写话,实在是有点麻烦,这里直接使用别人写好的第三方开源库,更香
1.2 安装库
cpp-httplib: C++ http 网络库 - Gitee.com
step1:克隆仓库到本地
- git clone https://gitee.com/yuanfeng1897/cpp-httplib.git
step2 : 将httplib.h放入comm文件夹
1.3 演示案例
虽然这库看上去很复杂,但实际上我们就只使用httplib.h的这个头文件中的httplib命名空间中的内容就可以了,使用时十分的简单
在使用之前,我们是知道网络通信是需要一个Request请求,返回一个Response响应的,而在httplib.h中都封装有
#include "compile_run.hpp"
#include "../comm/httplib.h"
using namespace ns_compile_and_run;
using namespace httplib;int main()
{// in_json:{"code":"","input":"","cpu_limit":,"mem_limit":}// out_json:{"status":"","reason":"","stdout":"","stderr":""}Server ser;ser.Get("/hello",[](const Request & req,Response & resp){// 用来做基本测试resp.set_content("迟迟cool","text/pain;charset=utf-8");});ser.listen("0.0.0.0",8080);return 0;
}
说明一下
- 这段代码是在compile_server.cc中编写的,目的为了测试httplib.h是否可用
- resp.set_content的第一参数是字符串,而第二个参数是content-type中的类型
- 这么看来我引入的这个httplib.h的头文件,是没有问题的
修改防火墙进出规则
如果你出现了如下的界面,怎么解决都没成功的话,可以试试修改修改防火墙进出规则,具体的方法是去云服务器控制台中操作。。。
新添加进出规则,如下:
添加-lpthread
cpp-httplib这个库是一个阻塞式多线程库,引入这个库并编译时需要-lpthread,makefile修改如下:
compile_server:compile_server.ccg++ -o $@ $^ -std=c++11 -I/usr/include/jsoncpp -ljsoncpp -lpthread.PHONY:clean
clean:rm -f compile_server
更新gcc
如果你的程序运行时出现了一下其他报错,这里可以建议更新gcc使用7或8或9,请不要使用4点几几,而我这里是11.4.0
2. 打包编译运行服务
对于这个服务,我想通过./compile_server 端口号,的这种方式使用,而让listen时不再ser.listen("0.0.0.0",8080);这样死板的调用,则就需要使用到命令行参数
且我这里规定对于发来的请求Request中的请求正文body就是我们想要的json string
#include "compile_run.hpp"
#include "../comm/httplib.h"
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[])
{// in_json:{"code":"","input":"","cpu_limit":,"mem_limit":}// out_json:{"status":"","reason":"","stdout":"","stderr":""}if (argc != 2){Usage(argv[0]);return 1;}Server ser;ser.Post("/compile_and_run", [](const Request &req, Response &resp){// 用户请求的服务正文就是我们想要的json strinstd::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");} });ser.listen("0.0.0.0", atoi(argv[1]));return 0;
}
这里服务启动起来是没什么问题的,我这里项目还没有写好,不好发请求,但是可以使用第三方的软件,来对我们这个服务发请求,比如postman,apifox
3. 测试服务-Apifox
第一步
第二步
第三步
第四步
第五步
第六步
第七步
第八步-演示编译错误
第九步-演示浮点数错误
走到这里基本就ok了,更多的测试我就不做了,接下来就要开始写oj_server服务了
第七章 基于MVC结构的oj服务设计
1. 准备说明
它的本质就是一个小型网站,主要提供三个功能
- 获取首页,用题目列表充当
- 编辑区域页面
- 提交判题功能(会调用编译运行)
且这个服务采用MVC结构设计,将数据与业务逻辑分离
- M:Model,通常是和数据交互的模块 ,比如,对题库进行增删查改(文件版,Mysql)
- V:View,通常是拿到数据之后,要进行构建网页,渲染网页内容,展示给用户的(浏览器)
- C:Control,控制器,就是我们的核心业务逻辑
2. 新建文件夹
说明一下:
- 这个oj_server不仅仅会提供一个接口, 它会基于MVC模块提供3个接口服务 ,自然也就需要用到cpp-httplib
接下来,就需要在oj_server.cc中写 用户请求服务路由功能
3. 编写oj_server的服务路由功能
现在我们聚焦在oj_server.cc文件中,这里面需要对外提供三个接口,一个获取题目列表,一个获取某个题目的内容,一个判断题目是否正确的接口
值得多说一点的是获取某个题目的内容,后面用户在使用的时候应该是ip:port/question/1,ip:port/question/2,ip:port/question/3 等等,这里的题目号是不确定的,则就需要引入正则表达式来匹配文本,其中\d+
匹配 一个或多个数字
#include "../comm/httplib.h"
using namespace httplib;int main()
{// 用户请求的服务路由功能Server Svr;// 获取所有题目列表Svr.Get("/all_questions",[](const Request& req,Response &resp){resp.set_content("这个是所有题目的列表","text/pain;charset=utf-8");});// 获取某个题目的内容Svr.Get(R"(/question/(\d+))",[](const Request& req,Response &resp){std::string num = req.matches[1];resp.set_content("获取题目" + num + "的内容","text/pain;charset=utf-8");});// 判断某个题目是否正确Svr.Get(R"(/jude/(\d+))",[](const Request& req,Response &resp){std::string num = req.matches[1];resp.set_content("我正在判断题目" + num,"text/pain;charset=utf-8");});Svr.listen("0.0.0.0",8080);return 0;
}
- 使用了正则表达式之后,就可以使用req.matches[1]来提取用户的访问题目的指定题号了
makefile中需要加上-lpthread,否则会报错,因为cpp-httplib是阻塞式多线程库
oj_server:oj_server.ccg++ -o $@ $^ -lpthread.PHONY:clean
clean:rm -f oj_server
这里如果url是117.72.104.209:8080,将会找不网页,原因是没有设置根目录,没有index.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><h1>欢迎来到我的在线OJ平台</h1><p>这将开启你波澜壮阔的一生</p>
</body>
</html>
说明一下:
- Svr.set_base_dir就是设置根目录的函数,如果设置了它那么直接使用ip:port时就可以访问主html,一般网站都有个index.html所以我这里也用了这个
第八章 Model模块设计
1. 建立文件版题库
首先新建一个文件夹来存储题目数据,首先新建一个文本questions_list.txt里面用来存储题目基础信息,
而对应文件版的题目,每个题目都应该用一个单独的文件夹包含起来,如下:
- 这里的问号,等下我会说明的
而对应每个题目的文件都应该包含题目的详细信息desc.txt,题目的预设代码header.cpp(提供用户写入代码的区域),以及测试用例tail.cpp(判断用户函数是否正确)
而后面我们后面是会把header.cpp(包含用户写的代码) + tail.cpp 整合会一个文件,而这个文件就是我们上面的临时文件1234.cpp,后续的各种编译运行的操作都是通过这个文件进行调用的
1.1 desc.txt
给你一个整数 x ,如果 x 是一个回文整数,返回 true ;否则,返回 false 。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。例如,121 是回文,而 123 不是。示例 1:输入:x = 121
输出:true
示例 2:输入:x = -121
输出:false
解释:从左向右读, 为 -121 。 从右向左读, 为 121- 。因此它不是一个回文数。
示例 3:输入:x = 10
输出:false
解释:从右向左读, 为 01 。因此它不是一个回文数。提示:-231 <= x <= 231 - 1进阶:你能不将整数转为字符串来解决这个问题吗?
1.2 header.cpp
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>
using namespace std;class Solution
{
public:bool isPalindrome(int x){}
};
1.3 tail.cpp
#ifndef COMPILER_ONLINE
#include "header.cpp"
#endifvoid Test1()
{// 通过定义临时对象,来完成方法的调用bool ret = Solution().isPalindrome(121);if(ret){std::cout << "通过用例1,测试121通过...OK!" << std::endl;}else{std::cout << "没有通过用例1,测试的值是:121" << std::endl;}
}
void Test2()
{// 通过定义临时对象,来完成方法的调用bool ret = Solution().isPalindrome(-121);if(!ret){std::cout << "通过用例2,测试-121通过...OK!" << std::endl;}else{std::cout << "没有通过用例2,测试的值是:-121" << std::endl;}
}
void Test3()
{// 通过定义临时对象,来完成方法的调用bool ret = Solution().isPalindrome(10);if(ret){std::cout << "通过用例3,测试10通过...OK!" << std::endl;}else{std::cout << "没有通过用例3,测试的值是:10" << std::endl;}
}int main()
{Test1();Test2();Test3();return 0;
}
说明一下:
- 这里加上条件编译是为了在tail.cpp不报错,因为需要引入header.cpp的东西
- 后面是会把header.cpp和tail.cpp中的代码整合在一起,
且后续编译命令-DCOMPILER_ONLINE来裁剪的那段不需要代码
2. 构建Model代码结构
现在我们聚焦在oj_mode.hpp文件中,Model这个模块是用来管理数据的,则这里就需要一个结构体Question记录题目的各种属性信息
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <assert.h>
#include <unordered_map>namespace ns_model
{struct Question{std::string number; // 题目编号唯一std::string title; // 题目标题std::string start; // 题目难度 简单 中等 困难int cpu_limit; // 题目时间要求int mem_limit; // 题目空间要求std::string desc; // 题目描述std::string header; // 题目预设 在线编辑器默认的代码std::string tail; // 题目测试用例,需要和header拼接,形成完整代码};class Model{public:Model() {}~Model() {}private:std::unordered_map<std::string, Question> questions; // 题号:题目各种信息};
}
而在Model类中使用哈希映射的方式管理题目信息,建立了题号 与 question的映射
而它对内需要提供一个加载题目列表的函数,即将文件里面的数据读取到questions成员变量中
对外需要提供获取所有题目接口,和获取某个题目接口
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <unordered_map>
#include "../comm/log.hpp"namespace ns_model
{using namespace std;using namespace ns_log;struct Question{std::string number; // 题目编号唯一std::string title; // 题目标题std::string start; // 题目难度 简单 中等 困难int cpu_limit; // 题目时间要求int mem_limit; // 题目空间要求std::string desc; // 题目描述std::string header; // 题目预设 在线编辑器默认的代码std::string tail; // 题目测试用例,需要和header拼接,形成完整代码};const std::string question_list = "./questions/questions.list";class Model{public:Model(){// 一创建Model类,就可以加载题目了assert(LoadQuestionList(question_list));}~Model() {}bool LoadQuestionList(const std::string &question_list){// 这里需要加载配置文件: question/questions.list 和 题目编号文件}bool GetAllQuestions(std::vector<Question> *out){// out是输出型参数,以数组的形式返回所有题目}bool GetOneQuestions(const std::string &number, Question *q){// 同样q也是输出型参数}private:std::unordered_map<std::string, Question> questions; // 题号:题目各种信息};
}
- 这个文件也可能会需要日志功能,所以还是先加上比较好
其实对于Model模块来说,还应该具有对数据的增删查改等功能的,但这是文件版,录题就手动录就行了,其他操作也手动进行
2.1 完成GetAllQuestions && GetOneQuestions函数
这两个函数比较简单所以这里可以优先实现
GetAllQuestions
bool GetAllQuestions(std::vector<Question> *out)
{// out是输出型参数,以数组的形式返回所有题目if (questions.size() == 0){return false;}for (const auto &q : questions){out->push_back(q.second); // q是键值对,题号:题目信息}return true;
}
GetOneQuestions
bool GetOneQuestions(const std::string &number, Question *q)
{auto it = questions.find(number); // 根据key去找 满足的迭代器if (it == questions.end()){// 没有找到return false;}*q = it->second;return true;
}
2.2 完成LoadQuestionList函数
这个函数加载题目列表,肯定是需要读取文件的,则可以定义全局变量来表示路径的
const std::string question_list = "./questions/questions.list";
const std::string question_path = "./questions/";
使用文件流打开文件,为了健壮性还需处理文件打开出错的情况,
bool LoadQuestionList(const std::string &question_list)
{// 这里需要加载配置文件: question/questions.list 和 题目编号文件ifstream in(question_list); // 打开文件读取流if (!in.is_open()){return false;}// 对文件的操作in.close();
}
这里我采用getline读取文件中的内容,而一行的数据是:1 回文数 简单 1 3000,2 两数之和 简单 1 3000这种,但是我们需要的是题号 1,题目标题 回文数,题目难度 简单,题目cpu限制 1,题目内存限制 3000
自然就需要分割字符串,处理的函数我将它放在 ns_util命名空间中,里面都是工具类
接下来就直接处理数据就可以了,数据放在临时的question中,然后构建键值对插入哈希中
bool LoadQuestionList(const std::string &question_list)
{// 这里需要加载配置文件: question/questions.list 和 题目编号文件ifstream in(question_list); // 打开文件读取流if (!in.is_open()){return false;}// 对文件的操作std::string line;while (getline(in, line)){vector<string> tokens;// line 里面是 1 回文数 简单 1 3000// 以字符串分割, 然后放在tokens数组中StringUtil::SplitString(line, &tokens, " ");if (tokens.size() != 5){continue; // 少一个不完整的题目,不影响}Question q; // 临时对象q.number = tokens[0];q.title = tokens[1];q.start = tokens[2];q.cpu_limit = atoi(tokens[3].c_str());q.mem_limit = atoi(tokens[4].c_str());string path = question_path; // ./questions/path += q.number; // ./questions/1path += "/"; // ./questions/1/// 读取文件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({q.number, q});}in.close();retrun true;
}
说明一下
- 字符串分割函数我还没有实现,但可以先定义了,再引入
2.3 添加日志功能
3. 第三方库 - Boost Split
3.1 引入原因
其实我们也可以自己写个切分字符串函数,但这里还是使用这个库比较好,别问,问就是麻烦
3.2 安装库
sudo apt update
sudo apt install libboost-all-dev
说明一下:
- 安装完成后,Boost 头文件会被安装到
/usr/include/boost/
- 库文件会被安装到
/usr/lib/
或/usr/lib/x86_64-linux-gnu/
目录中
3.3 演示案例
#include <iostream>
#include <vector>
#include <string>
#include <boost/algorithm/string.hpp>int main() {std::string text = "hello,,,,world,,boost,library";std::vector<std::string> results;// 使用 boost::split 进行切分// 第四个参数是决定是否压缩boost::split(results, text, \boost::is_any_of(","),boost::algorithm::token_compress_on);// 输出切分结果for (const auto &s : results) {std::cout << s << std::endl;}return 0;
}
说明一下:
- boost::split 切分字符串的函数,参数一:切好的小字符串放在哪里,参数二:被切分的字符串,参数三:boost::is_any_of(分割符)
- 当第四个参数为boost::algorithm::token_compress_on表示要压缩,存在相同的分割符会忽略
- 当第四个参数为boost::algorithm::token_compress_off表示不压缩,存在相同的分割符不会忽略,处理为空行
3.4 完成SplitString函数
// 字符串工具类
class StringUtil
{
public:// 第一个参数是被分割的字符串 第二个参数是把分好的小字符串放哪里// 第三个参数是分割符static void SplitString(const std::string &str, \std::vector<std::string> *target, std::string sep){boost::split((*target),str,boost::is_any_of(sep),boost::algorithm::token_compress_on);}
};
第九章 Control模块 && View模块设计
1. Control模块整体结构
oj_control.hpp里面都是逻辑控制,但现在我们先将焦点转到oj_server.cc文件中
在oj_server.hpp中,是需要提供3个服务的,但这里先不处理jude判断题目的接口
而提供的获取题目的所有列表,和获取某个题目的内容是通过Control模块控制操作题目实现的
自然Control模块中就会有对应的接口(处理相关的逻辑)
在oj_server.hpp就不再是文本内容了,访问服务时,就会构建一张html并展示出来,
#include "../comm/httplib.h"
#include "oj_control.hpp"
using namespace httplib;
using namespace ns_control;int main()
{// 用户请求的服务路由功能Server Svr;Control 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");// resp.set_content("这个是所有题目的列表", "text/pain;charset=utf-8");});// 获取某个题目的内容Svr.Get(R"(/question/(\d+))", [&ctrl](const Request &req, Response &resp){std::string html;std::string num = req.matches[1];// 提取题号ctrl.Question(num, &html);resp.set_content(html, "text/html;charset=utf-8");// resp.set_content("获取题目" + num + "的内容","text/pain;charset=utf-8");});// 暂不处理// 判断某个题目是否正确Svr.Get(R"(/jude/(\d+))", [](const Request &req, Response &resp){std::string num = req.matches[1];resp.set_content("我正在判断题目" + num,"text/pain;charset=utf-8"); });Svr.set_base_dir("wwwroot");Svr.listen("0.0.0.0", 8080);return 0;
}
- 这里新增一个Control对象,为了操控题目,更准确的说是为了形成一个有题目信息的html
- 只修改了前2个接口,jude判题暂不考虑,别问,问就是有点麻烦,
oj_control.hpp
#pragma once
#include <iostream>
#include "oj_model.hpp"
#include "../comm/log.hpp"
#include "../comm/util.hpp"namespace ns_control
{using namespace ns_log;using namespace ns_util;using namespace ns_model;using namespace std;class Control{public:Control() {}~Control() {}public:// 根据题目数据构建网页bool AllQuestions(string *html){// html为输出型参数}bool Question(const string &number, string *html){// html为输出型参数}private:Model _model;};}
- 这里的AllQuestions和Question,如果要实现就扰不开另一个模块view,
- 因为函数返回的是一个字符串,是一个html的路径,但该怎么形成一个html并把题目数据放在html中,这是我们需要思考的
而要将获取的题目构建成网页,这里就需要介绍一个第三方库 ctemplate
2. 第三方库 - ctemplate
2.1 引入原因
进行前端的网页渲染,能将我们的题目信息显示在网页上,
2.2 安装库
由于我的Ubuntu22.04的版本,而这个版本中是把这个第三方库移除掉了的,所以我采用的是源码安装,如下,安装是一步一步的输入命令即可
- sudo apt update
- sudo apt install git g++ autoconf automake libtool
- git clone https://github.com/OlafvdSpek/ctemplate.git
- cd ctemplate
- autoreconf -i
- ./configure
- make -j$(nproc)
- sudo make install
- sudo ldconfig
2.3 演示案例
#include <iostream>
#include <ctemplate/template.h>int main() {std::string in_html = "./test.html";std::string value = "迟迟cool";// 形成数据字典ctemplate::TemplateDictionary root("test");// 等价于unordered_map<>testroot.SetValue("key",value);// 等价于test.insert({});// 获取被渲染网页对象ctemplate::Template *tp1 = ctemplate::Template::GetTemplate(in_html,ctemplate::DO_NOT_STRIP);// 添加字典数据到网页中std::string out_html;tp1->Expand(&out_html,&root);// 完成渲染std::cout << out_html << std::endl;return 0;
}
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>测试案列</title>
</head>
<body><p>{{key}}</p><p>{{key}}</p><p>{{key}}</p>
</body>
</html>
- g++ test.cpp -o test -lctemplate
- 前端中的被{{}}包括的内容就key
2.4 原理讲解
所谓的对前端代码进行渲染,说人话就是:就是一种key-value之间的替换,
在替换时,是需要形成一个字典,其中每个key都对应一个数据,在前端中出现对应的kay,在后端中出现对应的value,然后再进行替换,就可以实现前端网页的渲染
3. View模块整体结构
对于oj_view.hpp来说暂时就只需要提供2个接口,一个将所有题目渲染成网页,另一个将某个题目渲染成网页
而有了第三方库-ctemplate之后就很好操作了,不过这里可以先把框架写出来,等下再实现
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <ctemplate/template.h>
#include "oj_model.hpp"namespace ns_view
{using namespace std;using namespace ns_model;class View{public:View() {}~View() {}public:// 将所有题目渲染成htmlvoid AllExpandHtml(const vector<struct Question> &questions, std::string *html){// 题目的编号 题目的标题 题目的难度}// 将某个题目渲染成htmlvoid OneExpandHtml(const struct Question &q, std::string *html){}};
}
4. 完成AllQuestions && Question函数
现在我们将焦点聚焦在oj_control.hpp中,在这里面是需要用oj_view中的东西的(而在前面我们已经先定义好了接口)
AllQuestions函数
根据题目数据构建网页其实不是Control模块实现的 ,它这是调用了View中的模块,只处理相关逻辑
// 根据题目数据构建网页
bool AllQuestions(string *html)
{// html为输出型参数bool ret;vector<struct Question> all;if (_model.GetAllQuestions(&all)){// 获取题目形成成功_view.AllExpandHtml(all, html);}else{*html = "获取题目失败,形成题目列表失败";ret = false;}return ret;
}
Question函数
// 将指定题目构建网页
bool Question(const string &number, string *html)
{// html为输出型参数bool ret;struct Question q;if(_model.GetOneQuestions(number,&q)){// 获取题目成功_view.OneExpandHtml(q,html);}else{*html = "指定题目" + number + "获取失败";ret = false;}return ret;
}
5. 完成AllExpandHtml函数
对于渲染所有题目列表的接口,我打算在前端通过列表的形式展示出来,则必然需要写前端代码,这些前端代码可以放在一个单独的文件夹中,但我们这里就简单的写写,具体优化我打算放在后面,就可能会有一点丑
all_question.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><td>{{star}}</td></tr>{{/question_list}}</table>
</body>
</html>
- 前端代码我们可以不用关心,简单的写写就行了
这里顺便也可以给根目录的index.html添加一个a标签,使其能跳转到all_questions.html中
接下来,我们将目光回到oj_view.hpp中的AllExpandHtml函数中,在里面就会调用ctemplate第三方库进行渲染了
AllExpandHtml
// 将所有题目渲染成html
void AllExpandHtml(const vector<struct Question> &questions, std::string *html)
{// 在前端中会使用表格显示 题目的编号 题目的标题 题目的难度// 1. 形成路径std::string src_html = template_path + "all_questions.html";// 2. 形成数字典ctemplate::TemplateDictionary root("all_questions");for (const auto &q : questions){ctemplate::TemplateDictionary *sub = root.AddSectionDictionary("question_list");sub->SetValue("number", q.number);sub->SetValue("title", q.title);sub->SetValue("star", q.start);}// 3. 获取被渲染的htmlctemplate::Template *tpl = ctemplate::Template::GetTemplate(src_html, ctemplate::DO_NOT_STRIP);// 4. 开始完成渲染功能tpl->Expand(html, &root);
}
- 这里需要循环渲染,则就需要形成一个子字典,且子字典的名字必须是 question_list,因为要和前端的{{#question_list}} {{/question_list}}中的名字保持一致
写到这里我就发现有个变量名写错了,就是Question结构体中的star写成了start,但我都写到这里了,暂就这样吧,不改了
6. 完成OneExpandHtml函数
和前面那个函数一样,这里也要先写前端代码,其中用户代码的编写框,这里先暂时使用textarea文本框实现
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">{{pre_code}}</textarea>
</body>
</html>
- 前端代码我们可以不用关心,简单的写写就行了
接下来,我们将目光回到oj_view.hpp中的OneExpandHtml函数中,在里面就会调用ctemplate第三方库进行渲染了
OneExpandHtml函数
// 将某个题目渲染成html
void OneExpandHtml(const struct Question &q, std::string *html)
{std::string src_html = template_path + "one_question.html";// 形成数据字典ctemplate::TemplateDictionary root("q");root.SetValue("number", q.number);root.SetValue("title", q.title);root.SetValue("star", q.start);root.SetValue("desc", q.desc);root.SetValue("pre_code", q.header);// 获取被渲染网页对象ctemplate::Template *tpl = ctemplate::Template::GetTemplate(src_html, ctemplate::DO_NOT_STRIP);// 4. 开始完成渲染功能tpl->Expand(html, &root);
}
7. 负载均衡模块设计
按理说接下来就应该写判题的功能了,但这里会有一个问题,oj_server应该选择后端的那个compile_server呢,这就需要用我们的负载均衡模块来设计了
bool Judge(const std::string in_json,std::string * out_json)
{}
说明一下:在Judge中需要做的内容
- in_json进行反序列化,得到题目的id,得到用户提交的源代码,input
- 重新拼接用户代码 和 测试用例代码,形成新的代码
- 选择负载最低的主机(差错处理)
- 然后发起http请求,得到结果
- 将结果赋值给out_json
7.1 整体结构
负载均衡肯定是需要选择对应的主机,而主机也可能会有多个,所以可以把主机的ip和端口号写入一个配置文件中service_machine.conf
Machine类
接下就需要定义一个主机类,我们将其定义在Control模块中的 ns_control命名空间中
// 提供服务的主机
class Machine
{
public:std::string ip; // 编译服务的ipint port; // 编译服务的portuint64_t load; // 编译服务的负载std::mutex *mtx; // mutex禁止拷贝的,使用指针
};
说明一下:
- 这里之所以没有使用private修饰成员变量,是因为外部也需要使用,如果定义成了private之后就不读取,要读取只能写个函数去拿成员变量,实在是有点麻烦
- 这里我使用的是c++中的锁,是不能被拷贝的,但是后面可能会需要拷贝所以定义为指针
- 而负载均衡的负载,其实就是一个计数器,记录这个机器正在被多少个访问,后面那个类就是根据某种算法,找到合适的负载对应的机器
好了,接下来该定义它的成员函数了,一个提升主机负载,一个减少主机负载,一个获取主机负载函数,如果主机中定义了锁,则先加锁在处理,且后面也要记得解锁
// 提供服务的主机
class Machine
{
public:// 提升主机负载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;}public:std::string ip; // 编译服务的ipint port; // 编译服务的portuint64_t load; // 编译服务的负载std::mutex *mtx; // mutex禁止拷贝的,使用指针
};
LoadBlance类
接下就需要定义一个处理主机类,也叫做负载均衡模块/列,我们将其定义在Control模块中的 ns_control命名空间中
// 负载均衡模块/类
class LoadBlance
{
private:std::vector<Machine>machines;std::vector<int> online;std::vector<int> offline;
};
说明一下:
- 该类中定义一个数组用来管理所有主机的信息
- 这里我规定每个充当编译服务的主机的下标 = 主机id
- 数组online表示所有在线的主机
- 数组offline表示所有离线的主机
好了,接下来该想想它的成员函数应该有哪些,需要提供一个加载配置文件的函数,一个负载均衡选择/智能选择 主机的函数,还有一个上线一台主机的函数 && 下线一台主机的函数
// 从配置文件中读取主机,并加载到machines数组中
// 参数machine_conf 表示配置文件的路径
bool LoadConf(const std::string &machine_conf)
{
}
// 负载均衡时选择/智能的选择 对应主机
bool SmartChoice()
{// 有的是采用随机数+hash的方法,但是我这里是通过轮询+hash的方法// 简单来说就是找各个Machine类中最小的load
}
// 离线一台主机
void OfflineMachine()
{
}
// 上线一台主机
void OnlineMachine()
{
}
说明一下:
- 这些函数的参数,会根据后面实际的情况进行修改,这里只是先把 LoadBlance类的整体结构搭建出来了
- 上线一台主机和下线一台主机的函数,我打算在要用到它的时候,再实现
走到这里还没有完,其实加载配置文件是需要一开始就要做的,换句话说是需要在构造函数中实现的
LoadBlance类的整体结构如下:
// 负载均衡模块/类
class LoadBlance
{
public:LoadBlance(){// 调用加载主机配置函数assert(LoadConf(service_machine));LOG(INFO) << "加载" << service_machine << "成功" << "\n";}~LoadBlance() {}public:// 从配置文件中读取主机,并加载到machines数组中// 参数machine_conf 表示配置文件的路径bool LoadConf(const std::string &machine_conf){}// 负载均衡时选择/智能的选择 对应主机bool SmartChoice(){// 有的是采用随机数+hash的方法,但是我这里是通过轮询+hash的方法// 简单来说就是找各个Machine类中最小的load}// 离线一台主机void OfflineMachine(){}// 上线一台主机void OnlineMachine(){}private:std::vector<Machine> machines;std::vector<int> online;std::vector<int> offline;
};
补充一下: 这里我也用了一个全局变量用来记录配置文件的路径,因为后面加载配置文件的那个函数是需要使用的
7.2 完成LoadConf函数
LoadConf加载配置文件的参数就是外面我们定义的全局变量,还是使用文件流进行读取
bool LoadConf(const std::string &machine_conf)
{std::ifstream in(machine_conf);if (!in.is_open()){LOG(FATAL) << " 加载: " << machine_conf << " 失败" << "\n";return false;}// 主机配置信息in.close();return true;
}
在打开文件之后,就该读取配置信息了,如下:
bool LoadConf(const std::string &machine_conf)
{std::ifstream in(machine_conf);if (!in.is_open()){LOG(FATAL) << " 加载: " << machine_conf << " 失败" << "\n";return false;}// 主机配置信息std::string line;while (std::getline(in, line)){// 需要分割字符串std::vector<std::string> tokens;StringUtil::SplitString(line, &tokens, ":");if (tokens.size() != 2){LOG(WARNING) << " 切分 " << line << " 失败" << "\n";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;
}
- 注:构建完这个主机之后,需要把主机上线(把主机放在online数组中),再将主机放在machines数组中
7.3 完成SmartChoice函数
在调用负载均衡模块时,可能会有多线程同时访问的情况,在这里不仅仅需要给主机加锁(锁的指针),还需要给整个模块加一把大锁
而在选择合适的主机时,是从上线的主机中选择的,如果一台上线的主机都没有了,那什么都做不了了,日志等级就是FATAL
// 负载均衡时选择/智能的选择 对应主机
bool SmartChoice(int *id,Machine **m)
{// 有的是采用随机数+hash的方法,但是我这里是通过轮询+hash的方法// 简单来说就是找各个Machine类中最小的loadmtx.lock();// 从上线中的主机中选择int online_num = online.size();if(online_num == 0){mtx.unlock();LOG(FATAL) << " 所有的后端编译主机已经离线,请运维的同时尽快查看" << "\n";return false;}mtx.unlock();return true;
}
- 在使用函数时,需要加一把大锁,加了锁自然就需要解锁了
而我们的负载均衡选择算法,是采用的轮询+hash的方式,找最小负载的那个主机,就是找到数组中最小的那个数字(负载)一样
bool SmartChoice(int *id, Machine **m)
{// 有的是采用随机数+hash的方法,但是我这里是通过轮询+hash的方法// 简单来说就是找各个Machine类中最小的loadmtx.lock();// 从上线中的主机中选择int online_num = online.size();if (online_num == 0){mtx.unlock();LOG(FATAL) << " 所有的后端编译主机已经离线,请运维的同时尽快查看" << "\n";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 (min_load > curr_load){min_load = curr_load;*id = online[i];*m = &machines[online[i]];}}mtx.unlock();return true;
}
说明一下:
- online数组中存的都是下标,都是machines数组中主机的下标
- machines数组中的机器不一定都是上线了的,所以需要用online中的下标访问机器
参数m的说明:
- 因为m参数是输出型参数,外面传的时候,传的是一级指针,这里中的主机是局部变量,需要指向它的地址,
- 则外面就只能传一级指针的地址,这里用二级指针接收,解引用二级指针 指向 这个主机
走到这里虽然还没有实现上线一台主机的函数,和下线一台主机的函数,但是这里可以在Control类中定义一个提供负载均衡器的成员变量了,如下:
8. 完成Jude函数
bool Judge(const std::string &number, const std::string in_json, std::string *out_json);
步骤流程:
-
根据题目编号,拿到对应的题目
-
in_json进行反序列化,得到题目的属性
-
序列化,重新拼接用户代码+测试用例代码,形成新代码
-
选择负载最低的主机(差错处理)
-
然后发起http请求,得到结果
-
最后将结果赋值给out_json
void Judge(const std::string &number, const std::string in_json, std::string *out_json)
{// 0. 根据题目编号,拿到对应的题目struct Question q;_model.GetOneQuestions(number, &q);// 1.in_json进行反序列化,得到题目的属性Json::Reader reader;Json::Value in_value;reader.parse(in_json, in_value);std::string code = in_value["code"].asString();// 2.序列化,重新拼接用户代码+测试用例代码,形成新代码Json::Value compile_value;compile_value["input"] = in_value["input"].asString();compile_value["code"] = code + q.tail; // 用户代码+测试用例compile_value["cpu_limit"] = q.cpu_limit;compile_value["mem_limit"] = q.mem_limit;Json::StyledWriter writer;std::string compile_string = writer.write(compile_value); // 返回字符串
}
说明一下:
- Jude判断题目函数,需要传递一个题号的字符串的,所以我改了一下参数
接下来就是选择主机,并发起请求(代码走到这里已经有编译运行要的json串了),但这里该怎么发起请求,准确的说怎么在代码中发起请求呢
void Judge(const std::string &number, const std::string in_json, std::string *out_json)
{// 0. 根据题目编号,拿到对应的题目struct Question q;_model.GetOneQuestions(number, &q);// 1.in_json进行反序列化,得到题目的属性Json::Reader reader;Json::Value in_value;reader.parse(in_json, in_value);std::string code = in_value["code"].asString();// 2.序列化,重新拼接用户代码+测试用例代码,形成新代码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;Json::FastWriter writer;std::string compile_string = writer.write(compile_value); // 返回字符串// 3. 选择负载最低的主机(差错处理)// 规则: 一直选择,直到主机可用,否则就是全部挂掉while (true){int id = 0;Machine *m = nullptr;if (!_load_blance.SmartChoice(&id, &m)){break; // 这里表没有可以用的主机了}// 4. 然后发起http请求,得到结果Client cli(m->ip, m->port);m->IncLoad(); // 增加负载LOG(INFO) << " 选择主机成功, 主机id: " << id << " 详情: " << m->ip << ":" << m->port << " 当前主机的负载是: " << m->Load() << "\n";if (auto res = cli.Post("/compile_and_run", compile_string, "application/json;charset=utf-8")){// 5. 将结果赋值给out_jsonif (res->status == 200){*out_json = res->body;m->DecLoad(); // 减少负载LOG(INFO) << "请求编译和运行服务成功..." << "\n";break;}m->DecLoad(); // 减少负载}else{// 请求失败LOG(ERROR) << " 当前请求的主机id: " << id << " 详情: " << m->ip << ":" << m->port << " 可能已经离线" << "\n";_load_blance.OfflineMachine(id);}}
}
这里说一下在代码中发起请求的方式,用的是httplib中的Client类中的Post返回,返回的是一个封装了Response智能指针的Result类,底层上还是用的Response中的body接收的json
9. 完成OfflineMachine函数
离线一台主机只需要从上线中的主机数组中删除,再添加到离线中的主机数组中就行了,但还是存在多线程竞争的问题,还是需要加锁解锁
// 离线一台主机
void OfflineMachine(int which)
{mtx.lock();// 加锁for(auto iter = online.begin();iter != online.end();iter++){if(*iter == which){// 要离线的主机已经找到了online.erase(iter);offline.push_back(which);break;//因为break的存在,所以我们暂不考虑迭代器失效的问题}}mtx.unlock();// 解锁
}
而与下线主机对应的就是上线主机,但这里我打算在用的时候再实现,解决方案就是把所有下线的主机统一上线
// 上线一台主机
void OnlineMachine()
{// 我们统一上线,后面统一解决// TODO
}
10. 新增showMachines函数
之所以要这个展示所有主机的函数,是为了后面的我好测试,所以这个函数仅仅只是为了测试,实现也很简单,就把所以上线和离线的主机打印出来就可以了
// 只是为了好测试代码,显示所有在线和离线的主机
void ShowMachines()
{mtx.lock();std::cout << "当前在线主机列表: ";for (auto &id : online){std::cout << id << " ";}std::cout << std::endl;std::cout << "当前离线主机列表: ";for (auto &id : offline){std::cout << id << " ";}std::cout << std::endl;mtx.unlock();
}
- 这个函数写在的位置是写在LoadBlance模块中的,作为一个成员函数
而调用这个函数,我把它写在了Jude函数中,当请求失败时,给我返回所有的上线和离线的主机,方便观察
11. 对判断功能的相关测试
首先在做测试时,需要先在请求服务中去调用Jude功能,具体就是在oj_server.cc中更改请求
// 判断某个题目是否正确
Svr.Post(R"(/jude/(\d+))", [&ctrl](const Request &req, Response &resp)
{std::string num = req.matches[1];std::string result_json;ctrl.Judge(num,req.body,&result_json);resp.set_content(result_json,"application/json;charset=utf-8");
});
- 注:要使用Post方法
大家还记不记我们在写tail.cpp时,为不报错,我们使用了条件编译,但是实际中header.cpp和tail.cpp中的代码是要拼接在一起的,这整个代码所形成的临时文件,才是交给oj_compile服务的源代码
如果不把这个宏去掉的话,将会报找不到header.cpp头文件的错误,则我们在调用g++命令的后面加上-DCOMPILER_ONLINE,就可以解决这个问题,如下图:
还有一个问题oj_server服务也使用了json,所有在makefile中就应该加上第三方库的路径,不然会报错
oj_server:oj_server.ccg++ -o $@ $^ -std=c++11 -lctemplate -I/usr/include/jsoncpp -ljsoncpp -lpthread.PHONY:clean
clean:rm -f oj_server
11.1 一个bug
后面在我测试的时候,老是有一个运行时报错
后面想了很久,我发现应该是在运行模块时添加了资源限制,限制题目内存的时候可能给少了,导致根本连程序本身都加载不了
- 在questions.list中把内存限制改大点,就好了
11.2 基本测试 - Apifox
{"code": "#include <iostream>\n#include <string>\n#include <vector>\n#include <map>\n#include <algorithm>\nusing namespace std;\nclass Solution{public:vector<int> twoSum(vector<int>& nums, int target){vector<int> ret{1,1};\n return ret;}};","input":""
}
说明一下:
- 由于前端页面还没写好,后面需要优化,
- 这里暂时就只能用Apifox进行测试了,这里code就是我们项目中的head.cpp+用户代码,而在判题的时候,又会把这一部分和tail.cpp拼接在一起,再调用后面的逻辑
11.3 负载均衡测试
step1: 首先准备2个接口
step2: 30个线程,循环3次,一共180个请求
step3: 关掉8082 和 8083端口
说明一下:
- 我先关掉了8083端口,离线主机为2号机,由于现在上线的主机中就只有0号机和1号机
- 将会导致这2个机的负载变高,就是会同时处理多个请求
说明一下:
- 然后我又关闭了8082端口,离线主机为1 2号机,由于现在上线的主机中就只有0号机
- 将会导致整个0号机同时接收多个请求,负载一下变的超级大
step4: 关掉8081端口
说明一下:
- 由于我把所有的主机都关闭了,则就会没有编译运行服务处理请求,
- 通常这时就需要运维的同事去处理去上线主机
第十章 前端页面优化
1. 了解 - 首页编写
index.html
<!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>这是我的个人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标签的宽度,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: green;}.container .navbar .login {float: right;}.container .content {/* 设置标签的宽度 */width: 800px;/* 用来调试 *//* background-color: #ccc; *//* 整体居中 */margin: 0px auto;/* 设置文字居中 */text-align: center;/* 设置上外边距 */margin-top: 200px;}.container .content .font_ {/* 设置标签为块级元素,独占一行,可以设置高度宽度等属性 */display: block;/* 设置每个文字的上外边距 */margin-top: 20px;/* 去掉a标签的下划线 */text-decoration: none;/* 设置字体大小font-size: larger; */}</style>
</head><body><div class="container"><!-- 导航栏, 功能不实现--><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a><a href="#">竞赛</a><a href="#">讨论</a><a href="#">求职</a><a class="login" href="#">登录</a></div><!-- 网页的内容 --><div class="content"><h1 class="font_">欢迎来到我的OnlineJudge平台</h1><p class="font_">这个我个人独立开发的一个在线OJ平台</p><a class="font_" href="/all_questions">点击我开始编程啦!</a></div></div>
</body></html>
2.了解 - 题目列表编写
all_questions.html
<!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>在线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标签的宽度,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: green;}.container .navbar .login {float: right;}.container .question_list {padding-top: 50px;width: 800px;height: 100%;margin: 0px auto;/* background-color: #ccc; */text-align: center;}.container .question_list table {width: 100%;font-size: large;font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;margin-top: 50px;background-color: rgb(243, 248, 246);}.container .question_list h1 {color: green;}.container .question_list table .item {width: 100px;height: 40px;font-size: large;font-family: 'Times New Roman', Times, serif;}.container .question_list table .item a {text-decoration: none;color: black;}.container .question_list table .item a:hover {color: blue;text-decoration: underline;}.container .footer {width: 100%;height: 50px;text-align: center;line-height: 50px;color: #ccc;margin-top: 15px;}</style>
</head><body><div class="container"><!-- 导航栏, 功能不实现--><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a><a href="#">竞赛</a><a href="#">讨论</a><a href="#">求职</a><a class="login" href="#">登录</a></div><div class="question_list"><h1>OnlineJuge题目列表</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><td class="item">{{star}}</td></tr>{{/question_list}}</table></div><div class="footer"><!-- <hr> --><h4>@迟迟cool</h4></div></div></body></html>
3.了解 - ACE前端在线编译器
ACE(Ajax.org Cloud9 Editor)是一个基于浏览器的高性能代码编辑器,支持多种编程语言的语法高亮、自动补全等功能
<!-- 引入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>
4.了解 - 指定题目的编写代码页面 + 代码提交
one_question.html
<!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><!-- 引入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%;}.container .navbar {width: 100%;height: 50px;background-color: black;/* 给父级标签设置overflow,取消后续float带来的影响 */overflow: hidden;}.container .navbar a {/* 设置a标签是行内块元素,允许你设置宽度 */display: inline-block;/* 设置a标签的宽度,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: green;}.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 h3 {padding-top: 10px;padding-left: 10px;}.container .part1 .left_desc pre {padding-top: 10px;padding-left: 10px;font-size: medium;font-family:'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;}.container .part1 .right_code {width: 50%;float: right;}.container .part1 .right_code .ace_editor {height: 600px;}.container .part2 {width: 100%;overflow: hidden;}.container .part2 .result {width: 300px;float: left;}.container .part2 .btn-submit {width: 120px;height: 50px;font-size: large;float: right;background-color: #26bb9c;color: #FFF;/* 给按钮带上圆角 *//* border-radius: 1ch; */border: 0px;margin-top: 10px;margin-right: 10px;}.container .part2 button:hover {color:green;}.container .part2 .result {margin-top: 15px;margin-left: 15px;}.container .part2 .result pre {font-size: large;}</style>
</head><body><div class="container"><!-- 导航栏, 功能不实现--><div class="navbar"><a href="/">首页</a><a href="/all_questions">题库</a><a href="#">竞赛</a><a href="#">讨论</a><a href="#">求职</a><a class="login" href="#">登录</a></div><!-- 左右呈现,题目描述和预设代码 --><div class="part1"><div class="left_desc"><h3><span id="number">{{number}}</span>.{{title}}_{{star}}</h3><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");//设置风格和语言(更多风格和语言,请到github上相应目录查看)// 主题大全:http://www.manongjc.com/detail/25-cfpdrwkkivkikmk.htmleditor.setTheme("ace/theme/monokai");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("嘿嘿!");// 1. 收集当前页面的有关数据, 1. 题号 2.代码var code = editor.getSession().getValue();// console.log(code);var number = $(".container .part1 .left_desc h3 #number").text();// console.log(number);var judge_url = "/jude/" + number;// console.log(judge_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){//成功得到结果// console.log(data);show_result(data);}});// 3. 得到结果,解析并显示到 result中function show_result(data){// console.log(data.status);// console.log(data.reason);// 拿到result结果标签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>
说明一下:
- 这里使用jquery CDN获取前端页面的内容,再通过ajax先后台发起请求
前后端交换也类似于信号与槽的机制进行交互
而响应函数,也就是js对应的函数,需要做的是:
- a. 收集当前页面的有关数据(题号 代码)
- b. 构建json并向后台发起请求
- c. 得到结果,解析并显示到result中
5. 基本测试
5.1 前提补充
以下的内容,其实都是我在后面的内容中补充的,但我想先说明一下,这样测试的时候,可能就会更加流程
给题目列表中的题目排序
// 根据题目数据构建网页
bool AllQuestions(string *html)
{// html为输出型参数bool ret;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;
}
完成OnlineMachine()函数
// 上线一台主机
void OnlineMachine()
{// 我们统一上线,后面统一解决mtx.lock();online.insert(online.end(),offline.begin(),offline.end());offline.erase(offline.begin(),offline.end());mtx.unlock();LOG(INFO) << "所有离线主机都已经上线了!" << "\n";
}
再完成了这个函数之后,也需要在Control模块中封装一下,就是调用一下
但我们的主机全部下线,需要重新上线时,我们可以捕捉3号信号也就是SIGQUIT,将回调函数设置为OnlineMachine函数
- 注:这里把SIGINT改成SIGQUIT
- 这样做了之后,后面就可以通过ctrl + \来上线我们的所有主机了
清空下线主机的所有负载
我在后面测试的时候遇到了一个bug,最开始时是1 2 3号机都在线上,然后我将2号机下线,之后又重新上线,但后面在发起请求时,发现2号机根本就没有处理到任何请求,
这个bug的出现原因是,下线一台主机的时候没有清空它的负载,就会导致再次上线的时候,这台机器的负载很高,而我们又用了负载均衡模块,1 3号机器的负载有可能比2号机小,则2号机器就不能处理负载了
解决这个bug的方案也很简单,就是下线一台主机的时候,把它的负载清空即可
说明一下:
- 这个函数写在oj_contral.hpp中的Machine类中
说明一下:
- 在完成上面那个清空负载的函数之后,我们需要在oj_contral.hpp中的LoadBlance类中的offlineMachine函数中调用
5.2 测试演示
6. 负载均衡测试
这里我暂时只用本地的3个接口,有条件的话可以把编译运行模块放到多个主机上面
首先先运行oj_server和启动3个后端的编译服务,观察能否正常运行
接下来再把2台编译运行服务关掉,看是否符合预期
最后把所有编译运行服务关掉,再ctrl+\重新上线所有主机,注意:这个动作只是告诉oj_server所有主机已经上线了,但是还是需要我们手动启动各个编译运行服务,
第十一章 MySQ版题目设计
1. 安装mysql
在 Ubuntu 中安装 MySQL,可以按照以下步骤进行:
1.1 更新软件包列表
sudo apt update
1.2 安装 MySQL 服务器
sudo apt install mysql-server -y
- 这将安装 MySQL 服务器的最新版本。
1.3 启动 MySQL 服务并设置开机自启
sudo systemctl start mysql
sudo systemctl enable mysql
1.4 (可选)运行安全性配置脚本
sudo mysql_secure_installation
该脚本会引导你进行一些安全性设置,例如:
-
设置 MySQL root 用户密码
-
移除匿名用户
-
禁止远程 root 登录
-
删除 test 数据库
按照提示进行操作即可。
1.5 登录 MySQL
sudo mysql -u root -p
- 输入你在
mysql_secure_installation
过程中设置的 root 密码
1.6 创建新用户并赋予权限
CREATE USER 'your_user'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON *.* TO 'your_user'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;
EXIT;
- 这样,你可以用
your_user
登录 MySQL。
1.7 允许远程连接
如果你想让其他机器访问 MySQL,需要:
修改 MySQL 配置文件
sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf
重启 MySQL 服务
sudo systemctl restart mysql
在防火墙中开放 MySQL 端口(默认 3306)
sudo ufw allow 3306/tcp
sudo ufw reload
测试 MySQL
mysql -u your_user -p
1.8 安装 libmysqlclient-dev
sudo apt update
sudo apt install libmysqlclient-dev
2. 安装Navicat
Navicat 是一款强大的 数据库管理工具,它会提供可视化界面,由于是收费的,请自行网上查询绿色版,而使用方法也简单,这里不过多简洁
3. 演示案例
#include <iostream>
#include <string>
#include <mysql/mysql.h>using namespace std;int main()
{// 1. 创建 MySQL 连接对象MYSQL *ms = mysql_init(nullptr);if (ms == nullptr){cerr << "MySQL 初始化失败!" << endl;return 1;}// 2. 连接数据库if (!mysql_real_connect(ms, "localhost", "用户", "密码", "tmp_0113", 3306, NULL, 0)){cerr << "数据库连接失败! 错误信息: " << mysql_error(ms) << endl;return 1;}cout << "数据库连接成功!" << endl;// 3. 设置字符集mysql_set_character_set(ms, "utf8");// 4. 向数据库表中插入记录const char *sql = "INSERT INTO t1 VALUES (4, '妹妹')";if (mysql_query(ms, sql) != 0){cerr << "插入数据失败! 错误信息: " << mysql_error(ms) << endl;return 2;}cout << "插入数据成功!" << endl;// 5. 关闭数据库mysql_close(ms);cout << "数据库关闭成功!" << endl;return 0;
}
- 我们在编译的时候必须指定第三方库,和库的路径,因为libmysqlclient-dev本质上就是一个第三库
4. 使用常见问题
4.1 无法远程连接
解决方法:创建一个可以访问远程的用户,
CREATE USER 'your_user'@'%' IDENTIFIED BY 'your_password';
Ubuntu中用户默认只能localhost连接数据库,但不建议让root用户能远程访问,我这里只是临时测试使用了一下,请按照上面的方法创建用户
4.2 无法编译
解决方案:编译的时候再最后面加
5. 设计思路
首先,需要在数据库中设计可以远程登录的MySQL用户->oj_client,并给他相应的权限
然后就是设计题目表的结构了,还有数据库->oj,表名->oj_questions,最后就是编码,加上连接访问数据库
6. 准备工作
6.1 创建用户 - oj_client
先查看当前有哪些用户
然后再新增用户->oj_client,一个本地的,一个远程的
6.2 建库 - oj
6.3 给用户oj_client权限
把oj库下的所有权限都给oj_client用户
- grant all on oj.* to oj_client@'%';
- grant all on oj.* to oj_client@'localhost';
6.4 建立表结构
这里我使用的Navicat图形化界面工具建立我们的表结构,但也使用了sql语句
use oj;create table if not exists oj_questions(`number` 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;select * from oj_questions;
desc oj_questions;
6.5 开始录题
这里我就直接使用Navicat图形化界面进行操作了,
成功录题,接下来就是重写oj_model.hpp文件了,将文件版改成数据库版
7. 重写oj_model.hpp
这里为了保留文件版的题目,我新建了一个oj_model_mysql.hpp
#pragma once
// 文件版本
#include "../comm/util.hpp"
#include "../comm/log.hpp"
#include <unordered_map>
#include <cassert>
#include <vector>
#include <mysql/mysql.h>namespace ns_model
{using namespace std;using namespace ns_log;using namespace ns_util;struct Question{string number; // 题目编号,唯一string title; // 题目标题string star; // 难度: 简单 中等 困难int cpu_limit; // 题目的时间复杂度(S)int mem_limit; // 题目的空间复杂度(KB)string desc; // 题目描述string header; // 题目预设给用户在线编辑器的代码string tail; // 题目测试用例,需要和header拼接};const std::string oj_questions = "oj_questions";const std::string host = "127.0.0.1";const std::string user = "oj_client";const std::string passwd = "123456";const std::string db = "oj";const int port = 3306;class Model{public:Model(){}~Model(){;}// 这个out是输出型参数bool QueryMysql(const std::string &sql, vector<Question> *out){// 这里的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) << "连接数据库失败!" << "\n";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! " << "\n";return false;}// 提取结果MYSQL_RES *res = mysql_store_result(my); // 本质就是一个2级指针// 分析结果int rows = mysql_num_rows(res); // 获取行的数量int cols = mysql_num_fields(res); // 获取列的数量Question q;for (int i = 0; i < rows; i++){MYSQL_ROW row = mysql_fetch_row(res);// row拿到一行,一行:1 number title star desc header tail cpu_limit mem_limitq.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);}if (res){// 释放控件mysql_free_result(res);}// 关闭mysql连接mysql_close(my);return true;}// 获取所有题目,这里的out是输出型参数bool GetAllQuestions(vector<Question> *out){std::string sql = "select * from ";sql += oj_questions;return QueryMysql(sql, out);}// 获取指定题目,这里的q是输出型参数bool GetOneQuestion(const string &number, Question *q){bool res = false;std::string sql = "select * from ";sql += oj_questions;sql += " where number=";sql += number;vector<Question> result;if (QueryMysql(sql, &result)){if (result.size() == 1){*q = result[0];res = true;}}return res;}private:// 题号 : 题目细节unordered_map<string, Question> questions;};
}
说明一下:
MYSQL_RES *res
需要用mysql_free_result(res)
释放,而 不是free(res);
,否则会导致 内存泄漏或崩溃
由于这里重写了oj_mdel.hpp,新生成了一个oj_model_mysql.hpp,那么其他头文件中用到oj_mdel.hpp的地方都要修改
下面还有一些对变量名和函数名的优化,如下:
- 将start改成star,其实是我因为原来在oj_model.hpp中就写错了,这里改了一下
第十二章 打包源代码
1. 添加顶层makefile
.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/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/;\cp -rf oj_server/oj_server output/oj_server/;.PHONY:clean
clean:@cd compile_server;\make clean;\cd -;\cd oj_server;\make clean;\cd -;\rm -rf output;
说明一下:
- 我们将项目给别人用不是直接把源代码交给他们用
- 而是将可执行程序,和一些必要的文件交给他们
第十三章 扩展思路
这个项目总共就2000多行,oj平台最核心的东西,我们是做完了的,但如果你有时间,可以从下面几个角度进行扩展,
- 基于注册和登录的录题功能
- 业务扩展:比如自己写个论坛,接入在线OJ中
- 把编译服务部署在docker中
- 将cpphttplib改成rest_rpc库
- 判断一道题目正确之后,自动下一道题目
- 将导航栏中的功能一个一个的都实现一下
- .......