负载均衡OJ项目详细解剖
前言
-
本章节记录了负载均衡在线oj项目的部分代码(后期会进行添加功能)和编写思路。
-
负载均衡oj刷题,通过http请求,通过简单的前后端交互,前端页面提供做题功能网页,同时接收用户提交的测试代码,进行负载均衡式的选择后端主机进行编译运行。
-
使用语言:C/C++。
-
开发环境:Linux Ubuntu 8.0,vscode
源代码:https://gitee.com/chuyang785/load-balanced-online-oj-quiz
这里写目录标题
- 前言
- 项目演示
- 所用技术与开发环境
- 项目宏观结构
- 具体模块
- 宏观结构
- 编码思路
- compile_server服务设计
- compile_server服务构架
- complier编译服务设计
- 设计comm功能模块中的util.hpp/PathUtil拼接路径
- 设计comm功能模块中的util.hpp/FileUtil文件操作
- 重定向
- comm/log.hpp下引入日志功能
- 整体代码+测试
- runner运行服务设计
- comm/util.hpp/PathUtil拼接运行时临时文件
- 添加运行时资源限制
- compile_run编译并运行设计
- 下载jsoncpp
- 认识并使用json
- comm/util.hpp/FileUtil生成唯一文件名
- comm/util.hpp/FileUtil写入/读出文件
- 整体代码
- compile_server网络服务
- 安装cpp-httplib
- oj_server服务设计
- oj_server整体框架
- oj_server/oj_server.cc网络服务设计
- 设置题目信息
- 实现version1文件版本oj_model.hpp
- 构建题目描述
- 功能实现
- comm/util.hpp/StringUtil字符串切割
- 整体代码
- version2 MySQL版本
- 创建用户并授予权限,并创建表
- oj_view网页渲染
- 引入ctemlate网络渲染库
- oj_view.hpp网页渲染功能
- oj_control控制器
- 获取所有题目列表
- 获取指定题目信息
- 提交代码判题功能
- 主机对象mechine
- 负载均衡LoadBalance
- 判题功能judge
- 代码上线
- 功能添加
- 用户输入功能添加
项目演示
底层负载均衡演示:
所用技术与开发环境
该项目所用到的技术栈主要有以下内容
- C++ STL:STL所提供的各种容器vector,string,map进行对结构化数据进行管理
- jsoncpp:在网络中进行传输数据的格式,需要进行序列化反序列化操作
- cpp-httplib:第三方库网络服务,实现套接字的编写,完成网络服务
- Boost:boost准标准库,使用了boost库中的快速切割字符串函数
- ctemplate:第三方库,网页渲染
- 多线程,多进程:在第三方库中有所体现
- ACE前端在线编辑器 (前端知识,让我们的oj代码编辑变得好看,了解)
- MySQL C connect:进行数据库连接
- js/jquery/ajax (前端向后端发起http请求,了解)
- 负载均衡算法:采用轮询+hash方法进行负载均衡
项目宏观结构
具体模块
我们的项目核心是三个模块:
- comm:公共模块
- compile_server:编译与运行模块
- oj_server:获取题目列表,查看题目编号,题目界面,负载均衡,以及其他功能。
宏观结构
- 对于compile_server因为这个模块只负责代码的编译与运行,也就是我们负载均衡选中的对象。而至于oj_server的话,其实它才是这真正和客户端进行交流的。of_server它会受到来自客户端的很多的请求,其中请求的内容也是不同的,包括请求所有题目的列表,请求具体一题的信息,也包括提交代码请求测试等。而如果只是请求所有题目的列表或者请求具体一题的信息,只需要将文件或者MySQL中的题目信息返回给客户端即可,但是如果是请求代码编译的话就需要进行负载均衡的选择compiler_server服务。
编码思路
- 先写compile_server
- 在编写oj_server
- 基于文件版本的在线oj
- 前端页面设计
- 基于mysql版本的在线oj
compile_server服务设计
compile_server服务构架
- 首先我们要写出这个模块的整体架构,也就是所需要的大致源文件和头文件。所以根据我们前面的分析这个模块需要编译和运行模块。这里我们把编译功能和运行功能进行分离,做到解耦合,并且再用编译+运行模块驱动编译和运行功能。当让我们的compilers_server也需要支持网路功能,因为最终我们的项目是要提供网络服务的。所以我们的构架如下
- complie.hpp:只负责编译
- runner.hpp:只负责运行
- compile_run.hpp:负责整合编译和运行功能,形成编译运行功能
- compile_server.cc:负责网络功能
complier编译服务设计
- 对于编译功能,首先我们要得到可以编译的代码,而这个代码到大complie的时候肯定是被进行处理过的,我们可以对比我们在leetcode里面进行写代码的时候是不是只有一个让我们实现的函数啊,但是并没有测试代码啊。因此一旦我们客户提交的代码到达compile服务一定是处理过的代码,那这个时候处理过的代码就需要进行临时保存,所以compile是从临时文件中拿到代码进行编译的。而进行编译无非两种情况,编译成功,编译失败。而编译失败就会就会有报错原因,而我们的compile服务也是一个进程,而实现编译服务肯定是需要进行程序替换的,但是我们不能将complie这个进程程序替换了,所以需要让子进程进去帮我们做这件事情。而子进程一旦编译失败了的话,默认是将错误输出到stderr中的,所以这个时候我们就需要将错误信息重定向到临时文件中。
所以我们需要一个compile类,而这个类中其实不需要成员属性,所以我们可以直接使用这个类只提供功能,也就是直接使用static。
namespace ns_compiler
{
class Compiler
{
public:
Compiler()
{}
~Compiler()
{}
static bool compile(const std::string &file_name) // 编译只有成功或者失败,返回值可以设置为bool类型的,而参数就是我们要进行编译的代码存放的临时文件
{
……
}
};
}
所以我们的一切功能都需要在static bool compile(const std::string &file_name)这个函数当中实现,而真正实现编译功能的其实是子进程,所以这里我们需要fork(),同时需要进行程序替换来进行g++编译,并且一旦编译失败了的话我们需要将报错信息重定向到临时文件中,这个时候也需要使用到dup2重定向函数。
所以我们需要使用到的系统调用函数主要有三个:
- fork创建子进程进行编译服务
- execlp程序替换进行编译功能
- dup2将错误信息重定向到临时文件中
设计comm功能模块中的util.hpp/PathUtil拼接路径
- 但是因为我们执行程序替换的时候,也就是编译功能的时候是需要我们提供源文件的也就是我们的.cpp文件,同时因为这个是临时文件,以及后序我们生成的错误信息啊以及我们后面实现运行功能的时候生成的结果也是需要放到临时文件当中的,所以我们需要有一个目录专门来存放这些临时文件,我们把这个目录叫做
temp
。并且static bool compile(const std::string &file_name)这个函数的参数我们只传递文件的名字并不带我文件的后缀,而这个添加后缀的工作我么交给comm模块
因为不仅仅只有compile这个模块只是用文件名。我们这样做的话就需要知道文件名,后缀功能交给comm模块就可以很方便的获取文件的全称了。
例如:
file_name: 1234后期我们自己添加所需要的后缀,这个功能我们交给comm模块,而这些临时文件存放在temp目录下
1234 ---> ./temp/1234.cpp
1234 ---> ./temp/1234.exe
标准错误文件:1234 --> ./tmp/1234.stderr
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 ---> ./tmp/1234.cpp
static std::string Src(const std::string &file_name)
{
return AddSuffix(file_name, ".cpp");
}
// 构建可执行程序路径+后缀的完整文件名
static std::string Exe(const std::string &file_name)
{
return AddSuffix(file_name, ".exe");
}
// 构建该程序对应的标准错误的完成路径+后缀名
static std::string CompilerError(const std::string &file_name)
{
return AddSuffix(file_name, ".compile_error");
}
};
设计comm功能模块中的util.hpp/FileUtil文件操作
- 在子进程进行程序替换执行编译功能的时候,我们怎么知道编译是否成功了呢?所以我们只需要查看一下生成的可执行文件是否存在即可,也就是是否生成了临时文件。而查看文件是否存在这个功能可能其他模块也需要使用到,所以我们就把对文件的操作写道comm模块下的util.hpp下的FileUtil类中。所以这里同样介绍一个系统调用接口,检测特定路径下的对应的文件获取它的属性。
class FileUtil
{
public:
static bool IsFileExists(const std::string &path_name)
{
// stat 检测对应特定路径下的文件,获取它的属性
struct stat st;
if (stat(path_name.c_str(), &st) == 0)
{
// 获取属性成功,文件已经存在
return true;
}
return false;
}
};
重定向
- 但是在进行程序替换的时候我们不知道编译是成功还是失败的,所以我们也不管是不是成功还是失败的,我们必须先将stderr重定向,而进行重定向形成的文件名我们其实也也已经可以知道了。因为我们在comm模块中的util.hpp/PathUtil中已经写了。
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);//将2号文件描述标准错误输出的信息放到我们打开的文件里
comm/log.hpp下引入日志功能
-
在我们项目中日志其实也算是很重的的,因为我们在找bug的时候其实大多数时候都是进行排除法来找bug的,而我们使用最为平常的就是直接使用打印,先找到大概的位置,在具体细分。而我们的实际项目是有很多的文件组成的,同时如果我们只是单单的打印有点满足不了我们的需求。因该将这条打印信息出现在那个文件的哪一行更好。而日志也有很多现成的,但是这里我们简单的实现一个日志功能,粗略的看一下日志功能是怎么实现的。
-
而日志当中我们提供添加时间功能,所以这里我们需要得到时间戳。所以这里我们再次介绍一个获取时间的系统调用函数:gettimeofday。
而这个获取时间戳的功能我们同样也加入到comm模块中的util.hpp下的TimeUtil类中。
class TimeUtil
{
public:
static std::string GetTimeStamp()
{
struct timeval _time;
gettimeofday(&_time, nullptr);
// 返回时间戳信息
return std::to_string(_time.tv_sec);
}
}
log.hpp日志功能。
namespace ns_log
{
using namespace ns_util;
// 日志等级
enum
{
INFO,
DEBUG,
WARNING,
ERROR,
FATAL
};
// LOG() << message;
//日志经常被使用函数跳转可能有点慢,直接设置成内敛
inline std::ostream &Log(const std::string &level, const std::string &file_name, int line)
{
// 添加日志等级
std::string message;
message += "[";
message += level;
message += "]";
// 添加报错文件名
message += "[";
message += file_name;
message += "]";
// 添加错误行
message += "[";
message += std::to_string(line);
message += "]";
// 添加日志时间
message += "[";
message += TimeUtil::GetTimeStamp();
message += "]";
// cout 本质 内部是包含缓冲区的
std::cout << message; // 不要进行endl进行刷新
return std::cout;
}
// 宏替换,使用枚举的时候前面加#可以替换成字符串
// 开放式日志接口
#define LOG(level) Log(#level, __FILE__, __LINE__)
}
整体代码+测试
#pragma once
// 只进行代码的编译功能
#include <iostream>
#include <string>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include "../Comm/util.hpp"
#include <sys/stat.h>
#include <fcntl.h>
#include "../Comm/log.hpp"
#include <sys/stat.h>
namespace ns_compiler
{
//引入公共功能
using namespace ns_util;
using namespace ns_log;
class Compiler
{
public:
Compiler()
{
}
~Compiler()
{
}
// 返回值:编译成功:true,否则:false
// 参数:编译的文件名
// file_name: 1234后期我们自己添加所需要的后缀
// 1234 ---> ./tmp/1234.cpp
// 1234 ---> ./tmp/1234.exe
// 标准错误文件:1234 --> ./tmp/1234.stderr
static bool compile(const std::string &file_name)// 形成的临时文件放到tmp文件中
{
pid_t pid = fork();
if (pid < 0)
{
LOG(ERROR) << "内部错误,创建子进程失败" << "\n";
return false;
}
else if (pid == 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);//将2号文件描述标准错误输出的信息放到我们打开的文件里
//子进程:调用编译器,完成编译的功能
// 执行程序替换的功能, 程序替换并不影响进程的文件描述符表
//g++ -o target src -std=c++11 // 文件名,使用带p的替换
execlp("g++"/*用什么执行*/, "g++"/*执行的方法*/, "-o", PathUtil::Exe(file_name).c_str(), PathUtil::Src(file_name).c_str(), "-D", "COMPILER_ONLINE","-std=c++11", nullptr/*不要忘记这个nullptr*/);
// 这里需要注意的是"-D", "COMPILER_ONLINE"这个是后面拼接代码是所需要定义的,宏定义
// 程序替换失败了
LOG(ERROR) << "启动编译器g++失败,可能是参数错误" << "\n";
// 执行完直接退出
exit(2);
}
else
{
// 父进程
waitpid(pid, nullptr, 0);
//子进程退出,判断编译是否成功,是否形成可执行程序
std::string compilerro;
FileUtil::ReadFile(PathUtil::CompilerError(file_name),&compilerro,false);
if (FileUtil::IsFileExists(PathUtil::Exe(file_name).c_str()) && compilerro.empty())
{
LOG(INFO) << PathUtil::Src(file_name).c_str() <<"编译成功" << "\n";
return true;
}
}
//没有生成可执行程序
LOG(ERROR) << "编译失败,没有形成可执行文件" << "\n";
return false;
}
};
}
#include "compiler.hpp"
#include <string>
using namespace ns_compiler;
int main()
{
std::string code = "code";
Compiler::Compile(code);
return 0;
}
同时我们在temp下创建一个code.cpp的文件并写一些测试代码
#include <iostream>
int main()
{
std::cout << "hello linux" << std::endl;
return 0;
}
编译运行
runner运行服务设计
-
对于运行模块,我们之前正常运行一个程序的时候直接多就是
./可执行程序
,但是在这里我们只需要提供文件名就行了,因为我们已经在comm/util.hpp/PathUtil类中设计了文件名拼接功能。而对于运行这块,运行的结果无非三种,运行成功结果对,运行成功结果不对,异常出错。而对于运行成功我们其实不要管,因为结果的正确与否其实不管运行的事情,而是管理员提供的测试代码所决定的。所以运行这块我们只需要关心异常出错这块。 -
这里同样我们需要使用到程序替换,也就是说要子进程进程运行。而一个程序被启动默认是打开三个文件的:标准输入,标准输出,标准错误(这个是运行时错误,前面是编译错误)。
-
所以我们的执行过程就是:创建三个标准文件的所要重定向到的对应文件,fork()执行程序替换,执行运行功能,而运行功能只关心异常处理,运行结果正确与否不关系。子进程在进行程序替换之前先进行重定向,将标准输入,标准输出,标准错误重定向到临时文件中。父进程在等待子进程退出时获取子进程的退出状态码(异常出错),而程序出现异常一定时因为受到了信号。
所以在runner类中设计的函数static int Run(const std::string &file_name)返回值是int,这个int就是返回的信号。这样我们就可以知道如果返回值大于0程序异常了,退出时受到信号,返回值就是对应的信号编码。如果返回值等于0,正常运行,结果保存在临时文件中。如果返回值小于0了,内部错误。
comm/util.hpp/PathUtil拼接运行时临时文件
class PathUtil
{
public:
static std::string AddSuffix(const std::string &file_name, const std::string &suffix)
{
std::string path_name = temp_path;
path_name += file_name;
path_name += suffix;
return path_name;
}
// 编译时需要的文件
………………
// 运行时需要的文件
// 构建该程序对应的标准错误的完整路径+后缀
static std::string Stdin(const std::string &file_name)
{
return AddSuffix(file_name, ".stdin");
}
static std::string Stdout(const std::string &file_name)
{
return AddSuffix(file_name, ".stdout");
}
static std::string Stderr(const std::string &file_name)
{
return AddSuffix(file_name, ".stderr");
}
};
添加运行时资源限制
-
另外,程序运行时为了防止破坏计算机的事情(比如申请过大内存、很长时间浪费编译服务资源)也或者一些编程的限制空间和资源。我们需要对运行程序进行资源限制。资源限制可以利用系统接口setrlimit进行,分别根据传入的时间和空间进行约束。
-
我们直到,当OS终止程序的时候,都是通过信号进行终止的。而此资源限制限制的内存就是6号信号,cpu的执行时间限制就是24号信号。可以利用signal函数捕捉验证一下。(当然也可以查看返回值)
static void SetProcduLimit(int cup_limit, int mem_limit)
{
// 设置cpu时长
struct rlimit cpu_rlimit;
cpu_rlimit.rlim_cur = cup_limit;
cpu_rlimit.rlim_max = RLIM_INFINITY;
setrlimit(RLIMIT_CPU, &cpu_rlimit);
// 设置内存大小
struct rlimit mem_rlimit;
mem_rlimit.rlim_cur = mem_limit * 1024; //转换成KB
mem_rlimit.rlim_max = RLIM_INFINITY;
setrlimit(RLIMIT_AS, &mem_rlimit);
}
compile_run编译并运行设计
-
现在编译和运行功能都有了,接下来就是将他们整合起来。而之所以需要通过compile_run进行整合,主要有这些原因。首先compile_server会受到大量的客户请求,提交的请求可以能时测试代码,或者提交代码,也就需要对请求进行适配,并且客户提交的请求时通过网络传输的,这也就需要进行协议字段的设计。同时为了避免因为大量的客户请求而产生同名文件而导致程序出错,也需要做到生成的文件名时唯一的。并且最重要的是要正确的调用compile和run功能。
-
并且在网络传输中绝大多数都是通过json来进行数据的传输的,也就得对数据进行序列化和反序列化。所以综上功能,要多客户请求做适配,生成唯一文件,正确使用编译运行功能,制定协议字段,使用json传输,这些功能,我们就可以通过compile_run这个模块来进行实现。
下载jsoncpp
#更新源
sudo apt-get update
#安装
sudo apt-get install libjsoncpp-dev
ls /usr/include/jsoncpp/json/
认识并使用json
使用时包含头文件<jsoncpp/json/josn.h>,并且在进行编译的时候需要连接 -ljsoncpp库。
json 是一种数据交换格式,采用完全独立于编程语言的文本格式来存储和表示数据。也就是将结构化数据转换成字符串这个过程叫做序列化,而将字符串转化成结构化数据这个过程叫做反序列化。
格式:
Json::Value root;
root["code"] = "text.c";
root["who"] = "root";
root["age"] = "19";
……
经过json处理就会变成一串字符串,这个过程就是序列化
{"code":"text.c","who":"root","19"}
同样的反序列化就是放过来,就是{"code":"text.c","who":"root","19"}恢复原来的kv结构式。
comm/util.hpp/FileUtil生成唯一文件名
-
首先我们compile_run模块中提供start函数,他的函数原型是:static void Start(const std::string &in_json, std::string *out_json)因为是网络传输,所以参数都是字符串的,其中in_json是输入参数也是接收到的字符串out_json是输出参数也是我们编译运行完成后需要返回的数据。其中输入json中包含了用户提交的代码,用户自己的输入,cpu_limit运行时间限制,mem_limit运行空间限制。而至于out_json中则需要提供编译运行后的状态码,请求的结果,以及程序运行完的结果,和运行完的错误结果。
-
但是在以上的基础上,无论是编译还是运行都需要有唯一的文件名,不然一旦大量客户进行请求出现同名文件必然会出现差错。
-
所以我们可以利用时间戳通过毫秒级的时间时间戳来生成文件名,但是单单使用毫秒级是不够的,一旦大量客户请求是有可能还会出现同名的文件名的,可以利用gettimeofday函数调用实现(返回的结构体存在微秒级的属性,简单转换就可以得到微秒),原子性的增长计数(同一时刻不同执行流调用-利用static的变量)利用C++11的特性atomic_uint即可实现。
class TimeUtil
{
public:
static std::string GetTimeStamp()
// 获得毫秒级别的时间戳
static std::string GetTimeMs()
{
struct timeval _time;
gettimeofday(&_time, nullptr);
return std::to_string(_time.tv_sec * 1000 + _time.tv_usec / 1000);
}
};
class FileUtil
{
public:
//static bool IsFileExists(const std::string &path_name)
……
static std::string UniqueFileName()
{
// 使用毫秒级时间戳+原子递增唯一值:来保证文件名唯一性
static std::atomic_uint id(0);
id++;
std::string ms = TimeUtil::GetTimeMs();
std::string uniq_id = std::to_string(id);
return ms + "_" + uniq_id;
}
}
comm/util.hpp/FileUtil写入/读出文件
传递过来的in_json串我们需要进行解析,将code代码部分提取出来,放到临时文件中,后序进行编译运行处理。所以这里我们同样需要有一个功能就是将code代码写入到文件的功能。让它形成源文件,为后续编译运行做铺垫。而在编译和运行时约会涉及到将标准输入,标准输出,标准错误,以及运行结果写到临时文件中(所以的临时文件都是由上述生成的临时文件名+后缀形成的),那么out_json返回给上层的时候是需要将标准错误,运行结果返回给上层的,也就同时需要把数据从文件中读取出来的能力。
class FileUtil
{
public:
static bool IsFileExists(const std::string &path_name)
static std::string UniqueFileName()
// 将内容写入文件
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 /*是否保留\n*/ /*可能还需要参数*/)
{
content->clear();
std::ifstream in(target);
if (!in.is_open())
{
return false;
}
std::string line;
// getline:不保存行分隔符,有时候需要保留行分隔符\n
// getline:内部重载了强制类型转换
while (std::getline(in, line))
{
(*content) += line;
(*content) += (keep ? "\n" : "");
}
in.close();
return true;
}
};
-
现在我们有了唯一的文件名,并且也可以写入文件和读出数据。我们就可以利用源文件执行编译运行流程了,并将最终结果返回个上层。而在编译和运行的过程可能会出现很多的错误,也就是会返回很多的错误码,所以在这个模块中我们需要提供一个可以根据错误码返回对应的信息给上层。根据对编译和运行的功能的分析以及信号,我们大致可以分成三类,错误码大于0,错误码等于0,错误码小于0。
-
所以对于现状,我们可以单独写一个函数来做到根据错误码返回对应的错误信息。另外因为每次编译运行会产生很多的临时文件(temp/),当这一切执行完后临时文件就没有意义了,就需要进行清理。删除文件可以利用unlink接口进行删除,需要注意其文件是否存在。
整体过程大致如下:
整体代码
#pragma once
#include "compiler.hpp"
#include "runner.hpp"
#include "../Comm/log.hpp"
#include "../Comm/util.hpp"
#include <string>
#include <jsoncpp/json/json.h>
#include <unistd.h>
using namespace ns_compiler;
using namespace ns_runner;
namespace ns_compile_and_run
{
using namespace ns_log;
using namespace ns_util;
using namespace ns_compiler;
using namespace ns_runner;
class CompileAndRun
{
public:
//>0 : 进程收到了信号导致异常奔溃了,比如超时,内存越界
//<0 : 属于整个非运行报错(代码为空,编译报错等)
//=0 : 这个过程全部成功
// 待完善
static std::string CodeToDesc(int code, const std::string &file_name)
{
std::string desc;
switch (code)
{
case 0:
desc = "编译运行成功";
break;
case -1:
desc = "提交代码为空";
break;
case -2:
desc = "未知错误";
break;
case -3:
// desc = "代码编译时发生了错误";
FileUtil::ReadFile(PathUtil::CompilerError(file_name), &desc, true);
break;
case SIGABRT: // 6号信号
desc = "内存超出范围";
break;
case SIGXCPU: // 24
desc = "运行时间超时";
break;
case SIGFPE: // 8
desc = "浮点数溢出";
break;
default:
desc = "未知:" + std::to_string(code);
break;
}
return desc;
}
static void RemoveTempFile(std::string &file_name)
{
// 清理文件的个数是不确定的,但是有哪些文件我们是知道的
std::string _src = PathUtil::Src(file_name);
if (FileUtil::IsFileExists(_src))
{
// unlink系统调用,快速删除一个指定文件路径的文件
unlink(_src.c_str());
}
std::string _complie_error = PathUtil::CompilerError(file_name);
if (FileUtil::IsFileExists(_complie_error))
unlink(_complie_error.c_str());
std::string _execute = PathUtil::Exe(file_name);
if (FileUtil::IsFileExists(_execute))
unlink(_execute.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());
}
static void Start(const std::string &in_json, std::string *out_json)
{
// 反序列
Json::Value in_value;
Json::Reader read;
read.parse(in_json, in_value); // 差错处理后期处理
std::string code = in_value["code"].asString();
std::string input = in_value["input"].asString();
int cpu_limit = in_value["cpu_limit"].asInt();
int mem_limit = in_value["mem_limit"].asInt();
int status_code = 0;
int run_result = 0;
Json::Value out_value;
std::string file_name; // 需要内部形成的唯一文件名
if (code.size() == 0)
{
status_code = -1; // 代码为空
goto END;
// 序列化过程
}
file_name = FileUtil::UniqueFileName();
// 形成唯一文件名,形成的文件名只具有唯一性,没有目录后缀
// 使用毫秒级时间戳+原子递增唯一值:来保证文件名唯一性
// 将code写入src源文件中,形成临时文件
// 产生file_name.cpp源文件
if (!FileUtil::WriteFile(PathUtil::Src(file_name), code))
{
// 写入失败
status_code = -2; // 未知错误
goto END;
// 序列化过程
}
// 编译+运行
// 产生file_name.exe可执行文件,以及file_name.compile_error编译报错文件
if (!Compiler::compile(file_name))
{
// 编译失败
status_code = -3; // 代码编译时发生了错误
goto END;
// 序列化
}
// 运行时的报错信息
// 产生三个stdout, stdin, stderr文件
run_result = Runner::Run(file_name, cpu_limit, mem_limit);
if (run_result < 0)
{
// 内部错误,打开文件错误
status_code = -2; // 未知错误
goto END;
// 序列化过程
}
else if (run_result > 0)
{
// 程序异常, 程序运行崩溃了
status_code = run_result; // 程序异常了,退出时收到了信号
goto END;
// 序列化过程
}
else
{
// 运行成功
status_code = 0;
goto END;
}
END:
out_value["status"] = status_code;
out_value["reason"] = CodeToDesc(status_code, file_name);
if (status_code == 0)
{
// 整个过程编译成功
std::string _stdout;
FileUtil::ReadFile(PathUtil::Stdout(file_name), &_stdout, true);
out_value["stdout"] = _stdout;
std::string _stderr;
FileUtil::ReadFile(PathUtil::Stderr(file_name), &_stderr, true);
out_value["stderr"] = _stderr;
}
// 反序列化
Json::StyledWriter writer;
*out_json = writer.write(out_value);
// 清理所有的临时文件
RemoveTempFile(file_name);
}
};
}
compile_server网络服务
-
现在已经有了编译服务这个模块了,只需要打包成网络服务就可以完成我们的目的了。
-
首先,自然可以写套接字实现http网络服务,服务器端绑定ip、端口,因为http是基于TCP的,所以需要设置监听。最后利用多进程、多线程或者epoll高级IO模式(epoll)获取每一个链接,接收json串(应用层处理数据粘包或者不全的问题),然后传递给编译运行模块执行结果后在通过http发送json串完成一次网络通信。
-
自己编写套接字是可行的,但是在项目中这么写太多太麻烦了。就像我们利用C++的STL库一样,如果这套http网络服务我们能直接使用就减轻了网络服务编写的负担了。由于C++官方本身没有提供网络库的相关库,但是我们可以利用cpp-httplib第三方库进行使用,方便我们的网络服务的创建。
安装cpp-httplib
首先到gitee搜索cpp-httplib,点击这里跳转,直接点击克隆进行下载。
只需要将httplib.h拷贝到我们项目中的comm目录下就可直接使用了。
但至少这里要注意了,编译http-httplib需要高版本的GCC,如果不是高版本的要么是编译错误,要么就是运行出错。
可以使用gcc -v
查看版本
升级命令
sudo apt upgrade gcc
-
另外,httplib是一个阻塞式多线程原生库,使用时需要引入原生线程库
-lpthread
-
httplib有客户端端和服务器端,这里显然是要使用服务端的,而服务端存在post和get两张方法,分别对应着客户端向服务端提交资源和,客户端向服务端获取资源。而这里compile_server显然是要使用post方法的,因为compile是提供编译运行的模块,而要进行编译和运行就需要客户端提供所需要编译和运行的代码。
-
所以我们要使用post放法,post的参数有两个,第一个是客户端发起的请求,第二个参数是请求的方法,而我们第二个请求的方法使用的是lambda表达式,其中的参数包括Request和Response,其中Request的body中就包含了客户提交的需要编译运行的代码,所以这个是就可进行提取出来并进行编译和运行了。将编译运行好的结果放到使用set_content函数使用json的方式向Response的中返回给客户端,。但是自此之前我们需要设置好监听套接字,监听套接字的ip我们这只为“0.0.0.0”任何客户端都可以访问,至于端口号因为后面是需要进行负载均衡式的选择某一台compile_server的所以端口号我们是要暴露出来的,所以我们可以使用命令行参数指定端口号。
注意响应正文对应的响应报头中写的类型(ConnectType)可以参考此网站进行对照: 对照表
#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;
}
//定制用户请求,定制通信协议字段
// 编译服务随时可能被多个用户请求,必须保证传递上来的code,形成源文件名称时,要具有唯一性
int main(int argv, char *argc[])
{
//命令行参数,指定端口号
if (argv != 2)
{
Usage(argc[0]);
return 1;
}
//提供的编译服务,打包形成一个网络服务
//cpp-httplib
Server sve;
// 这个get可以用来测试httplib是否可以用,直接去浏览器中 主机:端口号/compile 就可看到
sve.Get("/compile", [](const Request &req, Response &res)
{
//测试代码
res.set_content("hello http, 你好http", "text/plain;charset=utf-8");
});
// 到这里我们可以使用postman工具进行测试
sve.Post("/compile_and_run", [](const Request &req, Response &res){
// 用户请求的服务正是我们想要的json string
std::string in_json = req.body;
std::string out_json;
if (!in_json.empty())
{
CompileAndRun::Start(in_json, &out_json);
res.set_content(out_json, "application/json;charset=utf-8");
}
});
sve.listen("0.0.0.0", atoi(argc[1])); //启动http服务
}
这里postman直接到官网下载即可。
此时就可以在里面编写json格式的字符串了。
oj_server服务设计
oj_server整体框架
oj_server本质上其实就是一个网站,向前提供路由功能,渲染网页,向后是连接题库文件,编译运行服务。所以对于oj_server来讲站在浏览器的角度,它是一个服务器,提供和客户端进行对接的服务器。但是站在compile_server的角度他就是一个客户端,提供要进行编译和运行的代码。
所以这个网站因该有这些功能
- 提供能够获取题库的功能
- 能够提供指定行题目的描述
- 可以进行提交代码,进行测试判断提交结果
我们采用MVC模式来进行对oj_server的编写,来自百度百科的MVC解释
所谓MVC所代表的就是
- M:model 业务模式,通常是数据交互模块。在这个项目中主要是用于获取题库信息,以及单个题目的具体信息。
- view:用户界面,通常是拿到数据后对数据进行网页渲染,以网页的形式展现给用户。
- control:控制器,这是业务逻辑的核心,对用户请求进行分析,请求是获取全部题库,还是获取具体题目的信息,还是提交代码进行编译运行,如果是提交代码还需要进行负载均衡式的选择compile_server进行编译运行。
同样的oj_server也需要提供网络服务。所以我们的基本框架就是:
1.oj_server.cc:提供网络服务
2. oj_model.hpp:提供数据交互模块。其中该模块分为文件版本和MySQL版本的。
3. oj_view:提供网页渲染功能
4. oj_control:主控制器
oj_server/oj_server.cc网络服务设计
-
所以从上面给我们我们知道了,网络服务中需要提供三个功能,第一需要提供获取全部题目列表,第二需要获取具体一题的题目信息,第三需要提供提交代码判题功能。而针对前面两种需求均使用get方法,而针对第三种需求则是使用post方法。这里理解起来也是很简单,对于浏览器客户端,前面两种都只是需要获取题目信息就行了,但是第三种需要客户端提交自己编写的代码个oj_server但是oj_server对于compil_server来讲其实也是客户端,他把从浏览器中拿到的代码与测试用例做拼接,在给compile_server进行编译运行,本质上也就是使用post提交数据了。
-
同时像我们一般点击进去一个网页里面,都是直接会出来页面的,所以我们这里也需要设置一个默认首页,也就是只要点击了这个网址我们就会跳转到这个默认网页里面去,一般我们是将默认网页放到wwwroot目录下的。
所以我们oj_server.cc的基本框架如下:
int main()
{
//用户请求的路由功能
Server svr;
// 1. 获取所有的题目列表
svr.Get("/all_questions",[&ctrl](const Request& req, Response &resp){
//返回一张包含所有题目的网页
resp.set_content("这个是所有的题目列表", "text/plain; charset=utf-8");
});
// 2. 用户要根据题目编号,获取题目内容
//\d+正则表达式-->正则匹配
// R"()", raw string 保持字符串的原貌,不用做相关的转义
svr.Get(R"(/question/(\d+))", [&ctrl](const Request& req, Response &resp){
std::string number = req.matches[1]; //拿到正则表达式匹配的序号
// 控制模块返回结果构建渲染单个题目的网页返回resp
resp.set_content("这个指定的一道题目" + number, "text/plain; charset=utf-8");
});
// 3. 用户提交代码,使用我们的判题功能(1. 每道题的测试用例 2. compile_and_run)
svr.Post(R"(/judge/(\d+))", [&ctrl](const Request& req, Response &resp){
std::string number = req.matches[1]; //拿到正则表达式匹配的序号
// 将用户提交大代码与测试用例拼接交给compile_server进行编译运行
resp.set_content("这个指定的一道题目的判题" + number, "text/plain; charset=utf-8");
});
// 设置默认首页
svr.set_base_dir("./wwwroot");//设置首页
// 设置监听套接字
svr.listen("0.0.0.0", 8080);
return 0;
}
设置题目信息
- 按照要求我们需要两批文件构成,第一个适用于存放所有题目的题目列表,第二个是用来存放具体某一体的题目描述,这个具体描述应该包含两部分,第一部分给用户提前准备的预设代码,第二部分,是测试用例,测试用户提交的代码运行是否正确。
题目的具体信息因该包括以下部分
- 题目的编号 number
- 题目的标题 tital
- 题目的难度 star
- 题目的描述 desc
- 时间,空间限制 mem_limit,cpu_limit
- 用户提交的代码 header
- 测试代码的测试用例 tail
实现version1文件版本oj_model.hpp
- 所以我们还要在创建一个目录question,这个目录下面包含了两类文件,第一类就是专门放置题目列表的,这个题目列表中包含了了题目的编号,题目的标题,题目的难度,题目通过的时间和空间限制。另一类就是专门用来存放具体一题目的信息,这类文件也是目录,该目录下需要包含改题目的具体描述,给用户预设的代码,以及代码的测试用例。样例如下:
question.list中的内容,分别对应着question中的1号目录,和2号目录
而至于预设代码和测试用例的样例如下:
- 预设代码
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>
using namespace std;
class Solution {
public:
bool isPalindrome(int x)
{
//请在下面写入你的代码,默认是返回true
return true;
}
};
- 测试用例代码
#ifndef COMPILER_ONLINE
#include "header.cpp" // // 条件编译,引入头文件只是为了不报错和语法提示,之后会进行拼接只需要我们定义COMPILER_ONLINE即可
#endif
void Text1()
{
//通过定义对象,来完成调用
int ret = Solution().isPalindrome(121);
if (ret)
{
std::cout << "通过测试用例1,测试121通过.....OK" <<std::endl;
}
else
{
std::cout << "测试用例1未通过,测试的值是: 121" <<std::endl;
}
}
void Text2()
{
int ret = Solution().isPalindrome(-10);
if (!ret)
{
std::cout << "通过测试用例2,测试-10通过.....OK" <<std::endl;
}
else
{
std::cout << "测试用例2未通过,测试的值是: -10" <<std::endl;
}
}
int main()
{
Text1();
Text2();
return 0;
}
从上面的预设代码,和测试用例我们也不难猜出,它的整个执行过程。
构建题目描述
- 在设置题目信息中我们知道了,要针对性的设置一个题目的测试,需要包含多个信息,而所以信息组合起来才能构建成一个完成的测试题目。而题目肯定是有很多的个的,那怎么对多个题目进行管理起来呢?
先描述,在组织
所以我们需要将题目进行结构化,并使用数据结构进行管理起来。这里我们使用map来组织,通过没有题目的题号来找到对应的题目信息。
下面是机构化的每一道题目;
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)
};
功能实现
- 在oj_server.cc网络服务中我们需要提供三个功能,分别是,获取所有题目列表,获取单个题目的信息,以及提供判题功能。而在model模块中,我们只需要关心前两个功能就行了。上面我们也知道了,我们是使用map数据结构来进行管理每一个题目的,所以第一步我们就需要将question.list的题目列表现在在到map中,然后在提供两个函数,一个加载所有题目列表函数,另一个就是加载具体的一个题目信息。
class Model
{
private:
// 题号 :题目细节
std::unordered_map<std::string, Question> _questions;
public:
Model()
{
// 一开始就加载所有的题目列表
assert(LoadQuestionList(questions_list));
}
bool LoadQuestionList(const std::string &question_list)
{
// 加载配置文件:questions/questions.list + 题目编号
}
bool GetAllQuestions(vector<Question> *out)
{
// 向上层提供获取全部题目列表函数
}
bool GetOneQuestion(const string &number, Question *q)
{
// 向上层提供获取具体的某一题目函数
}
~Model() {}
};
comm/util.hpp/StringUtil字符串切割
-
要获取所有题目列表,就必定要跟文件打交道,也就是读取文件,这里我们是按行读取,也符合我们录入题库时的设计。我们录入题目列表的时候时按照,题目编号,题目标题,题目难度,题目限制时间,题目限制空间的顺序,通过空格进行分割的,所以我们要将这些数据进行分离,依次根据题号放入map中。
-
所以这里字符串切割我们之前也是写过的,不过有点麻烦。所以这里我们使用准标准库boost库,所以在使用之前我们需要安装boost库。使用字符串切割函数时需要包含头文件#include <boost/algorithm/string.hpp>
sudo apt-update
sudo apt-get install libboost-all-dev
class StringUtil
{
public:
/************************************************
* str:输入型,切割目标字符串
* target:输出型,切分后的的结果
* sep:指定的分隔符
* boost::algorithm::token_compress_on:进行压缩
* boost::algorithm::token_compress_off:不进行压缩
*/
static void SplitString(const std::string &str, std::vector<std::string> *target, const std::string sep)
{
//boost split库里面的切割字符串的方法
boost::split(*target, str, boost::is_any_of(sep), boost::algorithm::token_compress_on);
}
};
整体代码
#pragma once
#include "../Comm/util.hpp"
#include "../Comm/log.hpp"
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <fstream>
#include <cstdlib>
#include <cassert>
// 根据题目list文件,加载所有的题目的题目信息到内存中
// model:主要用来和数据进行交互,对外提供访问的接口
namespace ns_model
{
using namespace ns_log;
using namespace std;
using namespace ns_util;
struct Question
{
std::string _number; // 题号,唯一
std::string _title; // 题目标题
std::string _star; // 难度:简单,中等,困难
std::string _desc; // 题目描述
std::string _header; // 题目提前预设给用户在先编译的代码
std::string _tail; // 题目的测试用例,需要和header拼接,形成完整代码
int _cpu_limit; // 时间限制
int _mem_limit; // 空间限制
};
const std::string questions_list = "./questions/question.list";
const string questions_path = "./questions/";
class Model
{
private:
// 题号 :题目细节
std::unordered_map<std::string, Question> _questions;
public:
Model()
{
assert(LoadQuestionList(questions_list));
}
bool LoadQuestionList(const std::string &question_list)
{
// 加载配置文件:questions/questions.list + 题目编号
ifstream in(question_list);
if (!in.is_open())
{
LOG(FATAL) << "加载题库失败,请检查是否存在题库文件" << "\n";
return false;
}
string line;
while (getline(in, line))
{
// 对字符串进行切分
vector<string> tokens;
// 对字符串进行切分
// 1 判断回文数 简单 1 30000
StringUtil::SplitString(line, &tokens, " ");
if (tokens.size() != 5)
{
LOG(WARNING) << "加载部分题目失败,请检查文件格式" << "\n";
continue;
}
Question q;
q._number = tokens[0];
q._title = tokens[1];
q._star = tokens[2];
q._cpu_limit = stoi(tokens[3].c_str());
q._mem_limit = stoi(tokens[4].c_str());
string path = questions_path;
path += q._number;
path += "/";
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});
}
LOG(INFO) << "加载题库成功....." << "\n";
return true;
}
bool GetAllQuestions(vector<Question> *out)
{
if (_questions.size() == 0)
{
LOG(ERROR) << "用户获取题库失败" << "\n";
return false;
}
for (const auto &q : _questions)
{
out->push_back(q.second);
}
return true;
}
bool GetOneQuestion(const string &number, Question *q)
{
const auto &iter = _questions.find(number);
if (iter == _questions.end())
{
LOG(ERROR) << "用户获取题目失败,题目标题:" << number << "\n";
return false;
}
(*q) = iter->second;
return true;
}
~Model() {}
};
} // namespace ns_model
version2 MySQL版本
具体的MySQL连接,下载,函数使用请看这期内容:【MySQL】C/C++连接MySQL客户端,MySQL函数接口认知,图形化界面进行连接
创建用户并授予权限,并创建表
create user 'oj_client'@'%' identified by '123456'; --创建用户,任意ip登录,密码
--root建库
create database oj;
--root赋权
grant all on oj.* to 'oj_client'@'%'; -- 赋予oj_clinet@%用户oj数据库的所有权限
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 '对应题目的最大开辟内存空间'
);
这里我们使用workbench进行连接,详细内容请看上述连接。
#pragma once
// MySQL版本
#include "../Comm/util.hpp"
#include "../Comm/log.hpp"
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <fstream>
#include <cstdlib>
#include <cassert>
#include <mysql/mysql.h>
// MySQL version
// 根据题目list文件,加载所有的题目的题目信息到内存中
// model:主要用来和数据进行交互,对外提供访问的接口
namespace ns_model
{
const std::string oj_questions = "oj_questions";
const std::string host = "127.0.0.1";
const std::string user = "oj_client";
const std::string password = "123456";
const std::string db = "oj";
const unsigned int port = 3306;
using namespace ns_log;
using namespace std;
using namespace ns_util;
struct Question
{
std::string _number; // 题号,唯一
std::string _title; // 题目标题
std::string _star; // 难度:简单,中等,困难
std::string _desc; // 题目描述
std::string _header; // 题目提前预设给用户在先编译的代码
std::string _tail; // 题目的测试用例,需要和header拼接,形成完整代码
int _cpu_limit; // 时间限制
int _mem_limit; // 空间限制
};
class Model
{
public:
Model()
{}
bool QueryMySql(const std::string &sql, vector<Question> *out)
{
// 连接数据库
MYSQL *my = mysql_init(nullptr);
if (nullptr == mysql_real_connect(my, host.c_str(),user.c_str(),password.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) << mysql_error(my) << "\n";
return false;
}
// 提取结果
MYSQL_RES* res = mysql_store_result(my);
// 分析结果
uint64_t rows = mysql_num_rows(res); // 获取行数量
unsigned int cols = mysql_num_fields(res); // 获取列数量
struct Question q;
for (int i = 0; i < rows; i++)
{
MYSQL_ROW row = mysql_fetch_row(res);
q._number = row[0];
q._title = row[1];
q._star = row[2];
q._desc = row[3];
q._header = row[4];
q._tail = row[5];
q._cpu_limit = atoi(row[6]);
q._mem_limit = atoi(row[7]);
out->push_back(q);
}
//释放结果集
mysql_free_result(res);
// 关闭连接
mysql_close(my);
return true;
}
bool GetAllQuestions(vector<Question> *out) // 获取全部数据
{
std::string sql = "select * from ";
sql += oj_questions;
return QueryMySql(sql, out);
}
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;
}
~Model() {}
};
} // namespace ns_model
oj_view网页渲染
引入ctemlate网络渲染库
网页访问:https://gitee.com/baolisheng/ctemplate
选择克隆,或者下载压缩包然后进行解压都可以。
下载好后打开目录执行以下命令进行安装:
$ ./autogen.sh
$ ./configure
$ make //编译
$ sudo make install //安装到系统中
使用时需要做一下工作
头文件:#include <ctemplate/template.h>、
编译选项:-lctemplate -pthread 因为ctemplate中也是用了原生线程库
-
首先需要明确渲染实际上就是网网页上填充数据。我们为什么需要渲染?向题库界面填充题目信息,单个题库界面填充题目描述,用户代码等
-
网页渲染其实本质上就是一种key,value式的替换。首先需要有数据字典(key,value形式的),还需要带渲染网页,带渲染网页中需要渲染的格式是使用双花括号里面存放key值,也就是需要替换成value{{key}}。因为我们网络传输时json格式的,也就是天然的key,values格式的,所以在用户请求所有题目列表和指定题目信息的时候都是以json格式的key,value格式进行传输的,所以网页渲染也就是简单了。
-
简单使用:
首先使用ctemplate::TemplateDictionary root(“text”),这个操作有点类似与unorder_map<> text,也就是形成数据字典,而这个字典的名字就叫做text。接下来就是插入字典,root.SetValue(“key”,value),这个操作也就类似于text.insert({“key”,value});
然后ctemplate::Template *tql = ctemplate::Template::GetTemplate(网页路径, ctemplate::DO_NOT_STRIP(保持原貌)); // 保持原貌,打开html,最后 tql->Expand(&out_html, &root);添加入html中,完成渲染,得到渲染网页数据out_html,view返回结果即可。
oj_view.hpp网页渲染功能
这里需要提供一个专门存放网页的的目录。
利用ctemplate,view模块依次对题库网页、单个题目网页根据传入的数据渲染即可。针对于网页模板,因为涉及前端的知识,这里不在过多赘述,在仓库中自行参考。
#pragma once
#include <iostream>
#include <string>
#include <ctemplate/template.h>
#include "oj_model.hpp" //这个是文件版本的
// #include "oj_mysql_model.hpp" // 这个是MySQL版本的
namespace ns_view
{
using namespace ns_model;
// std::string _number; // 题号,唯一
// std::string _title; // 题目标题
// std::string _star; // 难度:简单,中等,困难
// int _cpu_limit; // 时间限制
// int _mem_limit; // 空间限制
// std::string _desc; // 题目描述
// std::string _header; // 题目提前预设给用户在先编译的代码
// std::string _tail; // 题目的测试用例,需要和header拼接,形成完整代码
const std::string template_path = "./template_html/";
class View
{
public:
View() {}
~View() {}
public:
static void AllExpandHtml(const std::vector<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)
{
// 这个是root的子字典,因为我们要的是循环式的添加题目列表
ctemplate::TemplateDictionary *sub = root.AddSectionDictionary("question_list");
sub->SetValue("number", q._number);
sub->SetValue("title", q._title);
sub->SetValue("star", q._star);
}
//3. 获取被渲染的网页
ctemplate::Template *tpl = ctemplate::Template::GetTemplate(src_html, ctemplate::DO_NOT_STRIP);
//4. 开始完成渲染功能
tpl->Expand(html, &root);
}
static void OneExpandHtml(const Question &q, std::string *html)
{
// 1. 形成路径
std::string src_html = template_path + "one_question.html";
// 2. 形成数组字典
ctemplate::TemplateDictionary root("one_question");
root.SetValue("number", q._number);
root.SetValue("title", q._title);
root.SetValue("star", q._star);
root.SetValue("desc", q._desc);
root.SetValue("pre_code", q._header);
// 3. 获取渲染的网页
ctemplate::Template *tpl = ctemplate::Template::GetTemplate(src_html, ctemplate::DO_NOT_STRIP);
// 4. 开始渲染功能
tpl->Expand(html, &root);
}
};
}
oj_control控制器
-
oj_control是逻辑控制,核心的业务逻辑,可以说是oj_control才是与客户端直接进行对接的。在这里它处理来自多个用户的不同请求,可能是请求所有题目列表,也可能是请求具体题目信息,也可能是提交代码。
-
所以在oj_control中一定需要包含model模块,作为成员属性,因为model才是与数据进行交互的模块。
获取所有题目列表
- model中已经为我们提供了获取整个题目的方法,对象为Question,只需要传入一个vector即可。我们利用其传入view中的题库渲染方法,获取渲染后的网页返回,由http发送给客户端(浏览器渲染)。在此过程中,可以按照题目编号进行排序。
class Control
{
private:
Model _model; // 提供后台数据
View _view; // 提供网页html渲染功能
bool AllQuestions(std::string *html)
{
std::vector<Question> all;
if (_model.GetAllQuestions(&all))
{
//排序
std::sort(all.begin(), all.end(), [](const Question &q1, const Question &q2){
return stoi(q1._number.c_str()) < stoi(q2._number.c_str());
});
// 将获取的题目信息构建成网页
_view.AllExpandHtml(all, html);
}
else
{
*html = "获取题目失败,获取题目列表失败";
return false;
}
return true;
}
在对应oj_server.hpp 获取题库路由就可以这样调用控制模块:(注意返回网页格式的类型)
// 1. 获取所有的题目列表
svr.Get("/all_questions",[&ctrl](const Request& req, Response &resp){
//返回一张包含所有题目的网页
std::string html;
ctrl.AllQuestions(&html);
resp.set_content(html, "text/html; charset=utf-8");
});
获取指定题目信息
- model也为我们提供了获取单个题目的方法,只需要接收Question对象即可,并且传入view模块渲染出单个题目网页,返回网页信息即可。
bool OneQuestion(const std::string &number, std::string *html)
{
Question q;
if (_model.GetOneQuestion(number, &q))
{
// 将获取的题目信息构建成网页
_view.OneExpandHtml(q, html);
}
else
{
*html = "指定题目:" + number + "不存在";
return false;
}
return true;
}
在对应oj_server.hpp 获取单个题目路由就可以这样调用控制模块:(注意返回网页格式的类型)
// 2. 用户要根据题目编号,获取题目内容
//\d+正则表达式-->正则匹配
// R"()", raw string 保持字符串的原貌,不用做相关的转义
svr.Get(R"(/question/(\d+))", [&ctrl](const Request& req, Response &resp){
std::string number = req.matches[1]; //拿到正则表达式匹配的序号
std::string html;
ctrl.OneQuestion(number, &html);
resp.set_content(html, "text/html; charset=utf-8");
});
提交代码判题功能
-
判题功能可以说才是项目的核心部分,这个部分才需要前后端进行联动。总体说,用户通过提交的json串通过路由选择,负载均衡式的选址compile_server主机进行编译运行,然后再以json串方式把结果返回给用户。
-
用户提交的代码是我们所提供的预设代码中,用户在内部进行添加后的代码。所以真正给后端编译运行的代码是需要经过后去我们进行拼接测试用例后才可以的。用户上传的code和input(这里暂且忽略input)我们需要将code按照提交的题目编号将对应编号的预设代码进行拼接,并且还需要加上时间限制,和空间限制形成一个新的json串,交给compile进行编译运行。
// 用户通过浏览器上传的json数据
{
"code": code,
"input": ,
}
// 凭借好准备发送给compile_server的json串数据
{
"code": code+q.tail,
"input": ,
"cpu_limit": q.cpu_limit,
"mem_limit": q.mem_limit,
}
// 注意上面的q为Question对象,根据路由传上来的题目编号决定,通过model模块获取
-
同时我们要确定好发送给哪台编译服务主机。 因为业务众多,不可能存在一台编译运行服务主机(负载压力太大),我们设计为网络服务的原因也就是能在不同的主机上部署此服务,方便于oj_server进行选择。
-
为了减轻压力,我们使用负载均衡的模式进行主机选择。那么我们首先得定义主机对象,并且根据主机的配置文件加载当前的所有主机信息,方便我们进行调用
主机对象mechine
-
既然我们需要做到负载均衡式的选择主机,也就说明了我们可以有多太主机可以选择,那么主机多了起来,就需要对主机进行管理,要进行管理就需要
先描述,在组织
。Machine表示的一台后端编译运行主机,所以必定需要包含主机的ip和port。除此之外因为是需要负载均衡式的选择主机,同样也需要进行添加负载数量。同时因为在同一时刻可能会有多个客户端执行不同的判题功能,所以为了线程安全,需要加锁。可以利用C++中的mutex进行定义,需要注意的是mutex在C++中无法进行拷贝,所以需要定义为指针类型。 -
而mechine也要给我们提供主机负载递增,递减的操作,一旦这台主机被选中了,负载数就++,完成任务后就–操作。但是这个操作可能同时会有很多用户一起操作,所以也带要进行加锁。也可以提供一个返回负载数量的函数,这个操作也需要进行加锁,因为在返回的时候可能有也在操作负载的加减。
// 提供服务的主机
class Machine
{
public:
std::string _ip; // 编译服务的ip
int _port; // 编译服务的端口
uint64_t _load; // 编译服务的负载
std::mutex *_mtx; // C++中的mutex是禁止拷贝的,使用指针来完成
public:
Machine() : _ip(""), _port(0), _load(0), _mtx(nullptr){}
~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;
}
void ResetLoad()
{
if (_mtx) _mtx->lock();
_load = 0;
if (_mtx) _mtx->unlock();
}
};
负载均衡LoadBalance
- 有了主机,我们自然是需要进行添加的,而这需要我们手动配置。配置文件放在oj_server/conf/文件夹下,名字设为server_machine.conf。配置样式如下:
127.0.0.1:8081
127.0.0.1:8082
127.0.0.1:8083
……
-
前面是对服务主机的描述,这里就需要进行组织管理了,使用vector容器进行管理。但是并不是意味着只要配置的有这台主机,这台主机就一定在线。所以,我们需要额外的设置两个数组,分别保存在线主机对象和离线主机对象
-
另外,在线主机和离线主机数组只需要存放保存所有主机数组的下标即可,每个下标就唯一确定一台主机,这样可以节省内存空间。负载均衡的策略也很简单,从当前的在线的主机进行轮询选择,遍历保存当前数组中最小的负载数的主机,最后返回即可。
-
此外LoadBalance需要体统以下功能:加载配置文件(LoadConf),负载均衡选择主机(SmartChoice),上线主机(OnlineMachine),下线主机(OfflineMachine)。
LoadConf:
因为我们已经设置好了配置文件,现在只需要将文件中的配置项按行读取即可,而读取操作我们已经在comm/uitl中已经进行编写过了,直接用即可。并且我们同时也需要将读出来的内容进行切割,区分出ip和port这个操作我们在comm/util中也有实现过字符串切分操作,并把数据放到mechine中,添加到_online上线主机上。
SmartChoice:
因为我们在只能选择的时候其实在查看_online中的在线主机数的,但是在这个操作中,可能有成千上百个用户在访问,也就说明有主机上线,也有主机离线了,所以这操作过程中是由线程线程安全的,必须加锁。而我们采用的负载均衡算法采用的式轮询+hash,也就是遍历所有在线主机找到那个负载最小的一个。
OfflineMachine:
一旦该向该主机发送请求时,没有进行应答,很有可能是因为这台主机已经离线了,所以这个时候据需要将该主机从在线状态移至离线状态,也就是从_online放到_offline里面去,当然这个操作同样需要进行加锁。
OnlineMachine:
这个时一键上线功能,一旦我们的主机出现问题,只有我们后端工作人员知道,这个时候就需要进行一键上线功能,这个工作也很简单也就是将所有的_offline放到_online里面,这个操作也需要进行加锁。所以这里我们设计的是使用信号来进行处理(SIGQUIT),按ctrl+/即可执行。
// 负载均衡模块
const std::string service_machine = "./conf/service_machine.conf";
// 负载均衡模块
class LoadBalance
{
private:
// 可以给我们提供编译服务的所有主机
// 每一台主机都有自己的下标,充当主机的id
std::vector<Machine> _machines;
//所有在线的主机id
std::vector<int> _online;
//所有离线的主机id
std::vector<int> _offline;
//保证LoadBalance它的数据选择安全
std::mutex mtx;
public:
LoadBalance()
{
assert(LoadConf(service_machine));
LOG(INFO) << "加载" << service_machine << "成功" << "\n";
}
~LoadBalance()
{}
public:
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;
}
// id: 输出型参数
// m: 输出型参数
bool SmartChoice(int *id, Machine **m)
{
// 1. 使用选择好的主机(更新对应的负载主机)
// 2. 后序可能要离线该主机
mtx.lock();
// 负载均衡算法
// 1. 随机数 + hash
// 2. 轮询 + hash
int online_num = _online.size();
if (online_num == 0)
{
mtx.unlock();
ShowMachines();
return false;
}
//通过遍历的方式找到负载最小机器
*id = _online[0];
*m = &_machines[_online[0]];
uint64_t min_load = _machines[_online[0]].Load();
for (int i = 1; i < online_num; i++)
{
uint64_t curr_load = _machines[_online[i]].Load();
if (curr_load < min_load)
{
min_load = curr_load;
*id = _online[i];
*m = &_machines[_online[i]];
}
}
mtx.unlock();
return true;
}
void OfflineMachine(int which)
{
mtx.lock();
for (auto iter = _online.begin(); iter != _online.end(); iter++)
{
if (*iter == which)
{
_machines[which].ResetLoad();
// 要离线的注意找到了
_online.erase(iter);
_offline.push_back(which);
break; // 因为break的原因,所以我们不用考虑迭代器失效的问题
}
}
mtx.unlock();
}
// 有待修改......
void OnlineMachine()
{
// 当所有主机都离线的时候,我们统一上线
mtx.lock();
_online.insert(_online.end(), _offline.begin(), _offline.end());
_offline.erase(_offline.begin(), _offline.end());
mtx.unlock();
LOG(INFO) << "所有的主机有上线了!" << "\n";
}
//for text
//离线和在线的主机是谁
void ShowMachines()
{
mtx.lock();
std::cout << "当前在线主机列表: ";
for (auto &iter : _online)
{
std::cout << iter << " ";
}
std::cout << endl;
std::cout << "当前离线主机列表: ";
for (auto &iter : _offline)
{
std::cout << iter << " ";
}
std::cout << std::endl;
mtx.unlock();
}
};
判题功能judge
-
judge判题功能第一需要用用户提交的json串中提取出对应的题目编号,这也就需要对用户的json串进行反序列化,第二,因为用户是在预设代码中写的并提交的,所以到oj_server中时需要将这部分代码与测试用例进行拼接,并加上测试时间限制,和空间限制,进行序列化交给compile_server,第三,形成json串后需要进行复杂均衡式的选择主机进行编译运行,第四确定好主机后就可以进行网络http给对应主机发送请求了,进行编译运行,最后将运行结果返回给用户。
-
前面我们也已经讲过了,对于compile_server来讲,oj_server其实就是客户端,所以使用httplib时应该定义客户端client进行发送请求。同时我们前几期将http的时候也是讲过的,只有当接收到的请求中接收到的status=200才算是成功了。
void Judge(const std::string &number, const std::string &in_json, std::string *out_json)
{
// LOG(DEBUG) << in_json << "\nnumber:" <<number << "\n";
// 0. 根据对应的题目编号,拿到对应题目的细节
Question q;
_model.GetOneQuestion(number, &q);
// 1. 对in_json进行反序列化得到题目的id,得到用户提交的代码
// 反序列
Json::Value in_value;
Json::Reader read;
read.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 complite_string = writer.write(compile_value);
// 3. 选择负载最底的主机(差错处理)
// 规则:一直选择,知道主机可用,否则全部离线
while (true)
{
int id = 0;
Machine *m = nullptr;
if (!_load_balance.SmartChoice(&id, &m))
{
LOG(FATAL) << "所有后端编译主机已经离线" << "\n";
break;
}
//选择到了主机
// 4. 然后发起http请求,得到结果
// 构建client对象
Client cli(m->_ip, m->_port);
m->IncLoad();
LOG(INFO) << "选择主机成功, 主机id: " << id << ",详情" << m->_ip << ":" << m->_port << " 当前主机的负载情况时: "<<m->Load() <<"\n";
// _load_balance.ShowMachines();
//发起请求
if (auto res = cli.Post("/compile_and_run", complite_string, "application/json;charset=utf-8"))
{
// 5. 将得到的结果返回给用户out_json
// std::cout << "cli.Post成功" << std::endl;
if (res->status == 200) // 请求状态码
{
*out_json = res->body;
// std::cout << "res->status == 200" << std::endl;
m->DecLoad();
LOG(INFO) << "请求编译和运行服务成功" << "\n";
break;
}
m->DecLoad();
}
else
{
//请求失败
LOG(ERROR) << "当前请求的主机id: " << id << ",详情" << m->_ip << ":" << m->_port << "可能已经离线" << "\n";
_load_balance.OfflineMachine(id);
_load_balance.ShowMachines(); // for Debug
}
}
}
代码上线
-
当前项目可以告别一段时间了,但是我们还可以设计一个顶层makefie,方便发布时的使用。
-
其作用就是可以分别编译oj_server、compile_server,并且生成一个output将必要的文件拷贝出来,删除的时候将所有新增的文件删除掉。
.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/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;
# cp -rf oj_server/lib output/oj_server;
.PHONY:clean
clean:
@cd compile_server;\
make clean;\
cd -;\
cd oj_server;\
make clean;\
cd -;\
rm -rf output;
功能添加
用户输入功能添加
-
我们在编写compile_server运行的时候我们是重定向了标准输入,标准输出,和标准错误的。而这里的标准输入就是给用户自行输入测试用例所提供好的接口。同样的我们在客户端返回判题json串的时候也是提供了input这个功能的只不过没有实现。所以这里要实现其实还需要再客户返回json串中添加一个test_input选项,如果test_input返回的是true就说明用户提交的是自己输入的测试用例,反知是提交代码测试。
-
用户自行input测试,再compile_server中runner运行模块中其实就是cin了数据了。而在这个模块中我们已经将标准输入重定向到了文件中了,所以我们只需要将用户输入的input写入到我文件中,到时候测试代码中调用cin的时候其实就是去文件中拿内容了。
-
所以这里我们再questions中还需要添加一个拼接代码:test_input.cpp
#ifndef COMPILER_ONLINE
#include "header.cpp" // 条件编译,引入头文件只是为了不报错和语法提示,之后会进行拼接
#endif
#include <iostream>
int main()
{
Solution ues;
int a;
while (cin >> a)
{
if (ues.isPalindrome(a))
{
std::cout << "输入参数: " << a << " 测试通过" << std::endl;
}
else
{
std::cout << "输入参数: " << a << " 测试没有通过" << std::endl;
}
}
return 0;
}
并且在oj_model模块中的question类中还需要添加一个test_input字段,来存放用户的输入,并在加载的时候将用户输入的数据加载进去。
struct Question
{
std::string _number; // 题号,唯一
std::string _title; // 题目标题
std::string _star; // 难度:简单,中等,困难
std::string _desc; // 题目描述
std::string _header; // 题目提前预设给用户在先编译的代码
std::string _tail; // 题目的测试用例,需要和header拼接,形成完整代码
int _cpu_limit; // 时间限制
int _mem_limit; // 空间限制
std::string test_input; // 用户输入
};
所以在oj_control控制模块中需要判断用户提交的是测试代码,还是提交代码
// 反序列
Json::Value in_value;
Json::Reader read;
read.parse(in_json,in_value);
std::string code = in_value["code"].asString();
// 2. 重新拼接用户代码+测试用例,形成新代码
Json::Value compile_value;
if (in_value["test_input"].asBool())
{
compile_value["code"] = code + "\n" + q.test_input; // 重点,将用户写的代码和测试用例拼接
LOG(DEBUG) << "这是一个用户输入测试代码" << "\n";
// std::cout << q.test_input << std::endl;
}
else
{
compile_value["code"] = code + "\n" + q._tail; // 重点,将用户写的代码和测试用例拼接
LOG(DEBUG) << "这是一个用户提交代码" << "\n";
}
compile_value["input"] = in_value["input"].asString();
// std::cout << in_value["input"].asString() << std::endl;
// std::cout << compile_value["code"] << std::endl;
compile_value["cpu_limit"] = q._cpu_limit;
compile_value["mem_limit"] = q._mem_limit;
在runner运行模块中,就需要将input作为参数传递进来,并写入文件中。
static int Run(const std::string &input, const std::string &file_name, int cpu_limit, int mem_limit)
FileUtil::WriteFile(_stdin, input);
而至于前端修改这里就不做过多的解释,细节请看源代码。同时后序还会进行更新新功能。