@TOC
目录
项目介绍
开发环境
主要技术
项目实现
公共模块
日志
工具类
编译运行模块
介绍
编译
运行
编译和运行结合起来
业务逻辑模块
介绍
MVC模式框架
模型(Model)
视图(View)
控制器(Controller)
负载均衡设计
判题
会话模块
项目总结
项目介绍
该项目是基于负载均衡的在线oj,模拟我们平时刷题网站(leetcode和牛客)写的一个在线判题系统。
项目主要分为五个模块:
- 编译运行模块:基于httplib库搭建的编译运行服务器,对用户提交的代码进行测试
- 业务逻辑模块:基于httplib库并结合MVC模式框架搭建oj服务器,负责题目获取,网页渲染以及负载均衡地将用户提交代码发送给编译服务器进行处理
- 数据管理模块:基于MySQL数据库对用户的数据、题目数据进行管理
- 会话模块:基于cookie和session针对登录用户创建唯一的会话ID,通过cookie返回给浏览器
- 公共模块:包含整个项目需要用到的第三方库以及自己编写的工具类的函数
开发环境
Centos7.6、C/C++、vim、g++、MySQL Workbench、Postman
主要技术
- C++ STL 标准库
- cpp-httplib 第三方开源网络库
- ctemplate google第三方开源前端网页渲染库
- jsoncpp 第三方开源序列化、反序列化库
- 负载均衡设计
- MVC模式框架
- ajax
- MySQL
项目实现
公共模块
日志
为了方便后期编码调试和项目演示,这里设计了一个日志打印函数,日志打印的格式如下:
日志的五个级别:
- INFO:正常信息
- DEBUG:调试信息
- WARNING:警告信息
- ERROR:错误信息
- FATAL:致命信息
实现如下:
#include <iostream>
#include <string>
#include <ctime>
#define INFO 1
#define DEBUG 2
#define WARNING 3
#define ERROR 4
#define FATAL 5
#define LOG(level, msg) Log(#level, msg, __FILE__, __LINE__)
void Log(const std::string level, const std::string msg, std::string filename, int line)
{
std::cout << "[" + level + "][" + msg + "][" << time(nullptr) << "][" + filename + "][" << line << "]" << std::endl;
}
工具类
工具类模块中存放着四个工具类:
- 时间工具类:包含了毫秒级时间戳的获取的方法
- 路径工具类:包含了对不同文件添加后缀和拼接临时文件路径的方法
- 文件工具类:包含了对文件读写、判断文件是否存在等文件操作方法
- 字符串工具类:包含了对字符串进行切割等操作字符串的方法
编译运行模块
介绍
该模块负责编译运行oj_server上传过来的代码,并将结果返回给oj_server。oj_server会向编译服务器发送json串,格式如下:
code:用户代码
input:用户自己提交的代码的输入
cpu_limit:时间限制
mem_limit:内存限制
{
"code":"xxx",
"input":"xxx",
"cpu_limit":"xxx",
"mem_limit":"xxx"
}
编译服务器需要将代码提取出来,并进行编译,结果以json串格式返回,如下:
status:代码运行状态码
reason:原因
stderr:代码运行完报错信息
stdout:代码运行完的结果
{
"status":"xxx",
"reason":"xxx",
"stderr":"xxx",
"stdout":"xxx"
}
编译服务器是基于第三方库cpp-httplib进行搭建的,需要注意的是,编译此库需要用安装新版本gcc,需要是7以上即可。compile_server注册了两种请求方式——/check_net和/compile_run,oj_server可以通过请求/check_net,根据响应来判断compile_server是否上线,可以给上线主机发起/compile_run请求对代码进行编译,并将结果响应给oj_server
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage:\n\t" << argv[0] << " port" << std::endl;
return -1;
}
Server svr;
// 用来给oj_server检测网络是否通畅
svr.Get("/check_net", [](const Request &req, Response &rep)
{ rep.set_content("ok", "text/html;charset=utf-8"); });
// 注册POST方法
svr.Post("/compile_run", [](const Request &req, Response &rep)
{
std::string in_json = req.body;
std::string out_json;
CompileRun::Start(in_json, out_json);
rep.set_content(out_json, "application/json;charset=utf-8"); });
LOG(INFO, "begin listen");
svr.listen("0.0.0.0", atoi(argv[1]));
}
简单的描述框图:
编译
用户的代码可以写入到文件中,并保存在我们项目设置的temp目录下。对应每一个用户的代码的文件,我们都需要给它设置一个唯一的文件名,这个文件名我们通过毫秒级时间戳+原子性递增id生成唯一的一个文件名
毫秒级时间戳获取方法:可以通过gettimeofday这个函数先获取到当前时间信息,从struct timeval这个结构体中提取,如下
int gettimeofday(struct timeval *tv, struct timezone *tz);
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
我们可以将tv_sec除以1000,tv_usec乘以1000,二者都转为毫秒,再相加,这样就可以得到当前的毫秒级时间戳
单单靠一个毫秒级时间戳还不能够完全保证唯一性,所以这里再拼接一个原子性递增id,这里使用atomic_uint,从0开始递增,这样即便时间戳相同,id也不是相同的,这样就保证了文件名的唯一性,实现如下:
static std::string GetMsTimeStamp()
{
struct timeval tv;
gettimeofday(&tv, nullptr);
return std::to_string(tv.tv_sec * 1000 + (int)(tv.tv_usec / 1000));
}
static std::string UniqueFilename()
{
// 毫秒级时间戳+原子性递增得出唯一文件名
static std::atomic_uint id(0);
++id;
return TimeUtil::GetMsTimeStamp() + "_" + std::to_string(id);
}
获取到了唯一的文件名之后,就可以给不同的文件添加不同的后缀,我们的项目有这么几个文件:
// 编译 如果编译成功,会生成可执行,编译失败,错误信息会被记录到xxx_x.compile_err文件中
xxx_x.cc
xxx_x.compile_err
xxx_x.exe
// 运行
xxx_x.stdin
xxx_x.stdout
xxx_x.stderr
static bool Compile(const std::string &filename)
{
// 要编译的文件放在了temp目录下
// filename.exe filename.cc filename.err
umask(0); //重置文件描述符的权限
int err_fd = open(PathUtil::CompileError(filename).c_str(), O_CREAT | O_WRONLY, 0644);
if (err_fd < 0)
{
LOG(ERROR, "open error file fail");
exit(1);
}
pid_t id = fork();
if (id == 0)
{
// child
// 1. 打开错误文件,没有就创建
dup2(err_fd, 2);
// 程序替换,编译代码 g++ -o target.ext src.cc -std=c++11
execlp("g++", "g++", "-o", PathUtil::Exe(filename).c_str(), PathUtil::Src(filename).c_str(), "-std=c++11", "-D", "COMPILE_RUN", nullptr);
// 失败才会走到这一步
LOG(ERROR, "compile execlp fail");
exit(2);
}
else if (id < 0)
{
// error
close(err_fd);
LOG(ERROR, "compile fork fail");
return false;
}
// parent
waitpid(id, nullptr, 0);
close(err_fd);
// 判断exe文件是否存在 是否编译成功
if (FileUtil::FileIsExists(PathUtil::Exe(filename)))
{
LOG(INFO, "file: " + filename + " 编译成功");
return true;
}
LOG(ERROR, "file: " + filename + " 编译失败");
return false;
}
编译开始,我们可以打开xxx_x.compile_err,并对标准错误进行重定向,如果编译错误,那么错误信息会被写入到该文件中,编译成功,该文件将为空。这个项目我们通过创建子进程并进行程序替换的方式来编译源文件,编译完成之后,我们只需要让父进程检查temp目录下是否存在可执行程序文件,如果有则说明编译成功,否则编译失败。
运行
编译成功后,就要开始对可执行程序进行执行了,执行之前,需要打开三个文件,也就是上面谈到的xxx_x.stdin、xxx_x.stdout和
xxx_x.stderr三个文件,并将标准输入、标准输出和标准错误分别重定向到三个文件中。执行可执行程序的方式和上面的一样,也是通过创建子进程并进行程序替换的方式运行可执行程序,通过退出码分析出运行结果。
我们这个项目对每道题题目的代码运行时间和内存大小都有限制,所以我们执行可执行程序之前我们需要对内存和时间进行限制,这里使用setrlimit系统函数来进行设置,接口如下:
int setrlimit(int resource, const struct rlimit *rlim);
struct rlimit
结构体(描述软硬限制),原型如下:
struct rlimit {
rlim_t rlim_cur;
rlim_t rlim_max;
};
这里我们需要设置的两个参数分别是RLIMIT_AS
和RLIMIT_CPU
,如下:
RLIMIT_AS // 进程的最大虚内存空间,字节为单位。
RLIMIT_CPU // 最大允许的CPU使用时间,秒为单位。当进程达到软限制,内核将给其发送SIGXCPU信号,这
一信号的默认行为是终止进程的
执行。然而,可以捕捉信号,处理句柄可将控制返回给主程序。如果进程继续耗费CPU时间,
核心会以每秒一次的频率给其发送SIGXCPU信号,
直到达到硬限制,那时将给进程发送 SIGKILL信号终止其执行。
这里我们将二者的硬限制都设置为无穷大RLIM_INFINITY
,软限制设置为题目要求的,具体代码如下:
static void SetProcLimit(int cpu_limit, int mem_limit)
{
struct rlimit climit;
climit.rlim_cur = cpu_limit;
climit.rlim_max = RLIM_INFINITY;
setrlimit(RLIMIT_CPU, &climit);
struct rlimit mlimit;
mlimit.rlim_cur = mem_limit * 1024; // 转为kb
mlimit.rlim_max = RLIM_INFINITY;
setrlimit(RLIMIT_AS, &mlimit);
}
父进程需要分析运行结果,如果waitpid
的返回值小于0,说明父进程等待失败,也是运行错误,否则分析status
,如果是正常退出,我们可以提取出退出码分析,如果是异常退出,此时我们能够知道子进程是被信号所杀,这时,我们只需要提取出信号即可
static int Run(const std::string &filename, int cpu_limit, int mem_limit)
{
std::string execute = PathUtil::Exe(filename);
std::string stdin = PathUtil::Stdin(filename);
std::string stdout = PathUtil::Stdout(filename);
std::string stderr = PathUtil::Stderr(filename);
//生成对应的文件用来存储对应的数据
// 打开三个文件
umask(0);
int in_fd = open(stdin.c_str(), O_CREAT | O_WRONLY, 0644);
int out_fd = open(stdout.c_str(), O_CREAT | O_WRONLY, 0644);
int err_fd = open(stderr.c_str(), O_CREAT | O_WRONLY, 0644);
if (in_fd < 0 || out_fd < 0 || err_fd < 0)//万一失败,就得报错
{
LOG(ERROR, "open std file error");
return -1;
}
pid_t id = fork();
if (id < 0)
{
close(in_fd);
close(out_fd);
close(err_fd);
LOG(ERROR, "run fork error");
return -1;
}
else if (id == 0)
{
// child
// 进行文件描述符的重定向
dup2(in_fd, 0);
dup2(out_fd, 1);
dup2(err_fd, 2);
// 对cpu和内存资源进行限制
SetProcLimit(cpu_limit, mem_limit);
execl(execute.c_str(), execute.c_str(), nullptr);
// 程序替换失败
exit(1);
}
// father
int status = 0;
int ret = waitpid(id, &status, 0);
close(in_fd);
close(out_fd);
close(err_fd);
int sig = 0; // 检验是否是被信号所杀
if (ret > 0)
{
if (WIFEXITED(status)) //检测进程的终止状态,判断子进程是否正常终止
{
// 正常退出
int exit_code = WEXITSTATUS(status);
if (exit_code == 0)
{
LOG(INFO, "run success");
}
else if (exit_code == 1)
{
LOG(ERROR, "run execlp fail");
return -1;
}
else
{
LOG(ERROR, "unknow status code:" + std::to_string(exit_code));
return -1;
}
}
else
{
// 异常退出
sig = status & 0x7f;
LOG(WARNING, "sig: " + std::to_string(sig));
}
}
else
{
// 等待失败
LOG(ERROR, "run wait fail");
return -1;
}
return sig; // 返回收到的信号 正常是0 异常时一个信号
}
综合编译和运行结果进行分析,对返回json串进行设置:
- 如果编译失败,或编译成功运行失败,我只需要设置status、reason两个个字段
- 如果编译运行成功,我们还需要设置stdout和stderr两个字段
编译和运行结合起来
因为编译加上运行可能会有多种情况,如果把这些在编译模块的主函数里面进行结合的话,代码会相当的冗长,难分,为了方便,创建一个compile_run函数来进行进行多种情况的判断。
static void Start(const std::string &in_json, std::string &out_json)
{
Json::Value in_value;
Json::Reader reader;
// 对json串进行反序列化
reader.parse(in_json, in_value); // 将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(); //空间限制
//从in_value中获取我们需要的相关数据
Json::Value out_value;
std::string filename; // 生成的唯一文件名
int status_code = 0; // 状态码
int res = 0;
if (code.size() == 0)
{
// 提交空代码
status_code = -1;
goto END;
}
filename = FileUtil::UniqueFilename();
// 将代码写入文件中
if (!FileUtil::WriteFile(PathUtil::Src(filename), code)) //将代码写进filename.cc文件中
{
// 未知错误:写文件失败
status_code = -2;
goto END;
}
// 编译代码
if (!Compiler::Compile(filename))
{
// 代码编译失败
status_code = -3;
goto END;
}
// 执行代码
res = Runner::Run(filename, cpu_limit, mem_limit);
if (res < 0)
{
// 运行时未知错误
status_code = -2;
}
else
{
//负数 代码有问题
// 0 正常运行
// 1-31 被信号终止
status_code = res;
}
END:
out_value["status"] = status_code;
out_value["reason"] = CodeToDesc(filename, status_code);
if (status_code == 0)
{
out_value["stdout"] = FileUtil::ReadFile(PathUtil::Stdout(filename));
out_value["stderr"] = FileUtil::ReadFile(PathUtil::Stderr(filename));
}
else if (status_code > 0)
{
out_value["stderr"] = FileUtil::ReadFile(PathUtil::Stderr(filename));
}
Json::FastWriter writer;
out_json = writer.write(out_value);
// 清除临时文件
RemoveTempFile(filename);
LOG(INFO, "临时文件已清除");
}
编译运行完之后记得需要将相关文件进行删除,不然后台会保留过多无意义的临时文件。
static void RemoveTempFile(const std::string &filename)
{
std::string src = PathUtil::Src(filename);
if (FileUtil::FileIsExists(src))
unlink(src.c_str());
std::string exe = PathUtil::Exe(filename);
if (FileUtil::FileIsExists(exe))
unlink(exe.c_str());
std::string compile_err = PathUtil::CompileError(filename);
if (FileUtil::FileIsExists(compile_err))
unlink(compile_err.c_str());
std::string in = PathUtil::Stdin(filename);
if (FileUtil::FileIsExists(in))
unlink(in.c_str());
std::string out = PathUtil::Stdout(filename);
if (FileUtil::FileIsExists(out))
unlink(out.c_str());
std::string err = PathUtil::Stderr(filename);
if (FileUtil::FileIsExists(err))
unlink(err.c_str());
}
上面就是整体后端代码的实现,
业务逻辑模块
介绍
该模块是整个项目业务逻辑的核心,包括用户登录注册、题目获取、与数据库进行数据交互、网页渲染以及协调编译服务器的负载均衡,同时该模块也会用到会话模块和数据库模块,进行用户会话管理、数据管理。综合这些利用第三方库cpp-httplib结合MVC模式框架搭建一个oj服务器,该服务器注册了很多Get和Post请求方法,供前端页面发起ajax请求进行前后端数据交互,及时更新前端页面
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage:\n\t" << argv[0] << " port" << std::endl;
return -1;
}
// 控制器
Control ctrl;
pthread_t tid;
pthread_create(&tid, nullptr, check, &ctrl);
// 会话
std::shared_ptr<AllSessionInfo> all_sess(new AllSessionInfo);
std::shared_ptr<Server> svr(new Server);
std::shared_ptr<UserManage> manager(new UserManage);
svr->Get(R"(/all_questions)", [&ctrl](const Request &req, Response &rep)
{
//LOG(INFO, "get questions request");
std::string html;
ctrl.GetAllQuestionsListHtml(html);
rep.set_content(html, "text/html;charset=utf-8"); });
svr->Get(R"(/question/(\d+))", [&ctrl](const Request &req, Response &rep)
{
//LOG(INFO, "get one question request");
std::string number = req.matches[1];
std::string html;
ctrl.GetOneQuestionByNumberHtml(number, html);
rep.set_content(html, "text/html;charset=utf-8"); });
svr->Post(R"(/judge/(\d+))", [&ctrl](const Request &req, Response &rep)
{
//LOG(INFO, "get one judge request");
std::string number = req.matches[1];
std::string out_json;
ctrl.Judge(number, req.body, out_json);
rep.set_content(out_json, "application/json;charset=utf-8"); });
// 注册
/******
* json
* user 账户名
* password 密码
* ******/
svr->Post("/register", [&ctrl](const Request &req, Response &rep)
{
LOG(INFO, "get a register request");
std::string out_json;
ctrl.Register(req.body, out_json);
rep.set_content(out_json, "application/json;charset=utf-8"); });
// 登录
svr->Post("/sign", [&ctrl, &all_sess, &manager](const Request &req, Response &rep)
{
LOG(INFO, "get a sign request");
std::string out_json;
int user_id = ctrl.SignIn(req.body, "user", out_json);
std::string tmp;
if (user_id > 0)
{
Session sess(req.body, user_id, "user");
std::string session_id = sess.GetSessionId();
tmp = "JSESSION=" + session_id;
all_sess->SetSessionInfo(session_id, sess);
// 将用户添加到管理
manager->AddUserToSet(user_id, 1);
}
rep.set_header("Set-Cookie", tmp.c_str());// 返回cookie
rep.set_content(out_json, "application/json;charset=utf-8"); });
svr->Get("/GetUserId", [&all_sess](const Request &req, Response &rep)
{
//1.会话校验
Json::Value resp_json;
resp_json["id"] = all_sess->CheckSessionInfo(req);
std::string out_json;
Json::FastWriter writer;
out_json = writer.write(resp_json);
rep.set_content(out_json, "application/json;charset=utf-8"); });
svr->Post("/GetUserName", [&all_sess, &ctrl](const Request &req, Response &rep)
{
LOG(INFO, "get a get username request");
Json::Value in_json;
Json::Reader reader;
reader.parse(req.body, in_json);
std::string out_json;
int id = in_json["id"].asInt();
std::string strId = in_json["strId"].asString();
std::string table = in_json["table"].asString();
Json::Value out_value;
out_value["username"] = ctrl.GetUserName(id, strId, table);
Json::FastWriter writer;
out_json = writer.write(out_value);
rep.set_content(out_json, "application/json;charset=utf-8"); });
// 修改密码
svr->Post("/forget", [&ctrl](const Request &req, Response &rep)
{
LOG(INFO, "get a forget password request");
std::string out_json;
ctrl.Forget(req.body, out_json);
rep.set_content(out_json, "application/json;charset=utf-8"); });
// 管理员登录
svr->Post("/administrator", [&ctrl, &all_sess, &manager](const Request &req, Response &rep)
{
LOG(INFO, "get a administrator sign request");
std::string out_json;
int administrator_id = ctrl.SignIn(req.body, "administrators", out_json);
std::string tmp;
if (administrator_id > 0)
{
Session sess(req.body, administrator_id, "administrator");
std::string session_id = sess.GetSessionId();
tmp = "JSESSION=" + session_id;
all_sess->SetSessionInfo(session_id, sess);
// 将用户添加到管理
manager->AddUserToSet(administrator_id);
}
rep.set_header("Set-Cookie", tmp.c_str());// 返回cookie
rep.set_content(out_json, "application/json;charset=utf-8"); });
svr->Post("/add_question", [&ctrl](const Request &req, Response &rep)
{
LOG(INFO, "get a add question request");
std::string out_json;
ctrl.AddQuestion(req.body, out_json);
rep.set_content(out_json, "application/json;charset=utf-8"); });
svr->set_base_dir("wwwroot");
svr->listen("0.0.0.0", atoi(argv[1]));
return 0;
}
MVC模式框架
模型(Model)
Model负责与数据库进行交互,听取控制器的调用,往数据库中插入数据或从数据库中获取数据并让View将请求结果返回给用户
插入数据的几种情形:
- 用户注册
- 管理员添加题目
查询数据的几种情形:
- 用户获取题目信息
- 获取用户信息
题目设计:
- 我们项目题目的属性有这么几个:编号、标题、难度、时间限制、内存限制、题目描述、头文件、用户显示代码、测试代码,后序我们需要对header、body和tail这三个部分进行拼接,形成一份新的代码,再提交给编译服务器
- 形成的数据库表结构如下:
struct Question
{
std::string show_num; // 显示编号
std::string num; // 题目编号
std::string title; // 题目标题
std::string level; // 题目难度等级
int cpu_limit; // 题目时间限制 单位 s
int mem_limit; // 题目内存限制 单板 byte
std::string desc; // 题目描述
std::string header; // 用户需要用到的头文件
std::string body; // 显示给用户的代码
std::string tail; // 用来测试用户的代码
};
接口设计:
- 接口主要包括加载配置、单个题目的获取、全部题目的获取、添加题目和用户数据相关操作
- 加载配置主要是将show_num和num建立起映射关系,show_num这个属性是给用户页面显示的,不直接用number显示给用户的原因是:number在数据库中是自增长的,且每次添加题目,其number是从最大的number+1开始增长,在不删除题目的情况下,number是连续的,如果中途删除了某个题目,后序number这个序列就不会是连续的,中间会断开,如:1、2、3、4,删除了3,再增加一个题目,number是5,这时number序列就是1、2、4、5,这样就不是连续的,所以用number显示给用户不太好,设计一个show_num是连续的,并与number建立好映射关系,这样就比较友好
- 单个题目的获取和多个题目的获取,主要是查询数据库,获取到的数据可以交付给View
- 添加题目需要往数据库中插入数据,同时更新show_num和number的映射关系
- 用户数据操作结合数据库操作一起完成
视图(View)
View负责将Model提供的数据以某种方式呈现给用户,这个项目主要是网页界面。View会使用google的开源库ctemplate进行网页渲染,以这种方式将数据呈现给用户
ctemplate的获取
可以在GitHub的国内镜像网站中获取,速度会比较快,链接如下(里面有详细的安装方法说明):
https://hub.fastgit.xyz/OlafvdSpek/ctemplate
ctemplate的简单用法
- {{变量名}}:把它放入我们的网页中,该部分会被替换成我们字典中添加的值,使用如:{{number}}、{{show_num}}
- {{#片断名}}:片断在数据字典中表现为一个子字典,字典是可以分级的,根字典下面有多级子字典。片断可以处理条件判断和循环,循环的结束{{/片段名}}
- TemplateDictionary:可以创建字典
- SetValue:可以往字典中添加模板
- AddSectionDictionary:可以往字典中添加子字典
- GetTemplate和Expand:两个接口可以获取到扩展之后的模板
接口设计:
这里对题目列表和单个题目两张网页进行渲染,将数据添加到网页中,实现如下:
namespace ns_view
{
using namespace ns_model;
using namespace ns_view;
using namespace ctemplate;
const std::string template_path = "wwwroot/template_html/";
const std::string user_path = "wwwroot/";
class View
{
public:
void ExpandAllQuestionsHtml(const std::vector<Question> qs, std::string &outhtml)
{
std::string src_html = template_path + "all_questions.html";
// 创建数据字典
TemplateDictionary root("all_questions");
for (auto &q : qs)
{
// 往root添加子字典
TemplateDictionary *sub = root.AddSectionDictionary("questions_list");
sub->SetValue("number", q.num);
sub->SetValue("show_num", q.show_num);
sub->SetValue("title", q.title);
sub->SetValue("level", q.level);
}
// 获取要渲染的网页 不做删除任何符号的动作
Template *tpl = Template::GetTemplate(src_html, DO_NOT_STRIP);
// 开始渲染
tpl->Expand(&outhtml, &root);
}
void ExpandOneQuestioHtml(Question &q, std::string &outhtml)
{
std::string src_html = template_path + "question.html";
// 创建数据字典
TemplateDictionary root("question");
root.SetValue("show_num", q.show_num);
root.SetValue("title", q.title);
root.SetValue("level", q.level);
root.SetValue("desc", q.desc);
root.SetValue("pre_code", q.body);
// 获取要渲染的网页 不做删除任何符号的动作
Template *tpl = Template::GetTemplate(src_html, DO_NOT_STRIP);
// 开始渲染
tpl->Expand(&outhtml, &root);
}
};
}
控制器(Controller)
Controller是整个项目业务逻辑的控制器负责协调model和view一起完成业务。
负载均衡设计
控制器的核心还包括了一个负载均衡的小模块,帮助控制器根据主机负载选择编译服务器,负载均衡的设计框架如下:
const std::string service = "./conf/service_machine.conf";
// 负载均衡模块
class LoadBlance
{
public:
LoadBlance()
{
assert(LoadConf()); // 加载配置文件
}
bool LoadConf()
{
std::ifstream in(service, std::ifstream::in);
if (!in.is_open())
{
LOG(FATAL, "加载主机配置文件失败");
return false;
}
std::string line;
while (getline(in, line))
{
std::vector<std::string> tokens;
StringUtil::Spilit(line, tokens, ":");
if (tokens.size() != 2)
{
LOG(WARNING, "切分" + line + "失败");
return false;
}
Machine m;
m.ip = tokens[0];
m.port = std::stoi(tokens[1]);
m.status = OFFLINE; // 默认都是下线
m.mtx = new std::mutex; // 记得释放
m.id = _machines.size();
_machines.push_back(std::move(m));
}
in.close();
LOG(INFO, "加载主机配置文件成功");
return true;
}
int AutoChoose(Machine *&machine)
{
_mtx.lock();
if (_count == 0)
{
LOG(FATAL, "所有主机全部下线,请及时核查原因");
_mtx.unlock();
return -1;
}
int id = 0;
int min_load = INT_MAX;
for (int i = 0; i < _machines.size(); ++i)
{
// 当前主机下线就选择另一台主机
if (_machines[i].status == OFFLINE)
continue;
int load = _machines[i].GetLoad();
std::cout << "load " << i << ": " << load << std::endl;
if (load < min_load)
{
min_load = load;
id = i;
}
}
_mtx.unlock();
machine = &_machines[id];
return id;
}
void Online(int id)
{
_mtx.lock();
_machines[id].status = ONLINE;
LOG(INFO, "主机" + std::to_string(id) + "已经上线 详情" + _machines[id].ip + ":" + std::to_string(_machines[id].port));
_count++;
_mtx.unlock();
}
void OfflineMachine(int id)
{
_mtx.lock();
_machines[id].status = OFFLINE;
_count--;
_machines[id].ResetLoad();
_mtx.unlock();
}
void ShowMachines()
{
_mtx.lock();
std::cout << "-------------------online-------------------" << std::endl;
for (auto &m : _machines)
{
if (m.status == ONLINE)
std::cout << m.id << " ";
}
std::cout << std::endl;
std::cout << "-------------------offline-------------------" << std::endl;
for (auto &m : _machines)
{
if (m.status == OFFLINE)
std::cout << m.id << " ";
}
std::cout << std::endl;
_mtx.unlock();
}
public:
std::vector<Machine> _machines; // 可以提供服务的所有主机,下标充当主机id
int _count = 0; // 在线主机数
std::mutex _mtx;
};
主机设计: 主机的属性应该有状态(上线、下线),当前负载,主机id,主机ip,主机绑定端口号,如下:
enum status
{
ONLINE,
OFFLINE
};
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();
}
void ResetLoad()
{
if (mtx)
mtx->lock();
load = 0;
if (mtx)
mtx->unlock();
}
// 获取主机负载
uint64_t GetLoad()
{
uint64_t curload;
if (mtx)
mtx->lock();
curload = load;
if (mtx)
mtx->unlock();
return curload;
}
~Machine()
{
// ...
}
public:
int id; // 主机id
std::string ip;
int port;
enum status status = OFFLINE;
uint64_t load = 0; // 负载
std::mutex *mtx = nullptr;
};
加载配置文件: 我们的配置文件中存放着我们需要用到的主机信息,每行存放一台主机的信息,格式如:ip:port,如下:
127.0.0.1:8082
127.0.0.1:8083
127.0.0.1:8084
通过读取文件,并对每一行进行分析,将主机信息存放到vector容器中
根据负载选择主机: 我们需要遍历所有上线的主机,选出负载最小的那一台主机,如果当前上线主机上为0,则打印出提示信息,发起警告,如果选择主机成功,我们可以返回主机id,具体操作如下:
int AutoChoose(Machine *&machine)
{
_mtx.lock();
if (_count == 0)
{
LOG(FATAL, "所有主机全部下线,请及时核查原因");
_mtx.unlock();
return -1;
}
int id = 0;
int min_load = INT_MAX;
for (int i = 0; i < _machines.size(); ++i)
{
// 当前主机下线就选择另一台主机
if (_machines[i].status == OFFLINE)
continue;
int load = _machines[i].GetLoad();
std::cout << "load " << i << ": " << load << std::endl;
if (load < min_load)
{
min_load = load;
id = i;
}
}
_mtx.unlock();
machine = &_machines[id];
return id;
}
主机上线: 我们需要能够设计一个接口来更改status这个字段,如果主机上线了,我们就把status字段改为ONLINE,否则改为OFFLINE,如何检测主机是否上线呢?还记得我们前面在编译服务器中注册的一个Post /chech_net用来检测网络通畅请求方法吗,我们可以在oj_sever启动时开辟一个线程,该线程会不停地给所有状态为OFFLINE的主机发起请求,如果得到了响应,那么说明,该主机已经上线,我们就可以把该主机的status字段改成ONLINE,表示主机已经上线,检测方法如下:
void *check(void *arg)
{
sleep(1);
pthread_detach(pthread_self());
Control *ctrl = (Control *)arg;
LoadBlance *loadblance = &ctrl->_lb;
while (1)
{
std::vector<Machine> machines = loadblance->_machines;
for (auto &machine : machines)
{
if (machine.status == OFFLINE)
{
Client clt(machine.ip, machine.port);
if (auto res = clt.Post("/check_net", "check", "text/plain;charset=utf-8"))
{
// 得到响应,证明对端主机上线
loadblance->Online(machine.id);
}
}
}
}
}
主机上线了。我们需要对负载均衡模块中的上线主机上count进行加1的操作:
void Online(int id)
{
_mtx.lock();
_machines[id].status = ONLINE;
LOG(INFO, "主机" + std::to_string(id) + "已经上线 详情" + _machines[id].ip + ":" + std::to_string(_machines[id].port));
_count++;
_mtx.unlock();
}
主机下线: 如果自己下线,我们只需要对count进行减1的操作,并对主机的负载进行重置:
void OfflineMachine(int id)
{
_mtx.lock();
_machines[id].status = OFFLINE;
_count--;
_machines[id].ResetLoad();
_mtx.unlock();
}
判题
步骤:
对传入json串进行反序列化,获取题目id
Json::Value in_value;
Json::Reader reader;
reader.parse(in_json, in_value);
std::string code = in_value["code"].asString();
根据题目id获取测试代码,并拼接上用户提交的代码,填充code,code需要用header、body和tail三种进行拼接
Question q;
_model.GetOneQuestionByNumber(number, q);
in_value["code"] = q.header + code + q.tail;
in_value["cpu_limit"] = q.cpu_limit;
in_value["mem_limit"] = q.mem_limit;
Json::FastWriter writer;
std::string compile_json = writer.write(in_value);
选择一台负载最小的主机,调用负载均衡里面的AutoChoose接口完成
选择主机成功,向该主机发起http请求,将json传过去,响应得到json之后再返回给前端
会话模块
用户登录成功之后,服务器会针对该用户创建一个会话,并保存在服务器这一端,同时服务器会根据用户的身份、账号和密码再利用MD5哈希算法生成唯一的Session ID,并通过Cookie返回给浏览器。
class Session
{
public:
Session(){}
Session(const std::string &in_json, int id, const std::string &identity){}
bool SumMd5(){}
//获取会话id
std::string &GetSessionId(){}
public:
std::string _session_id; //当前会话的会话id
std::string _real_str; //用来生成会话id的原生字符串
std::string _identity;
int _id;
};
MD5接口如下:
int MD5_Init(MD5_CTX *c);// 初始化MD5码
int MD5_Update(MD5_CTX *c, const void *data, unsigned long len);// 更新获取MD5码
int MD5_Final(unsigned char *md, MD5_CTX *c);// 生成的16字节MD5码放在md中
实现如下: 其中_str是由用户的账号名+密码+身份组成
bool SumMd5()
{
MD5_CTX ctx;
// 1.初始化
MD5_Init(&ctx);
// 2.更新MD5
if (MD5_Update(&ctx, _str.c_str(), _str.size()) != 1)
{
return false;
}
// 3.取出MD5
unsigned char md5[16] = {0};
if (MD5_Final(md5, &ctx) != 1)
{
return false;
}
char tmp[3] = {0};
char buf[32] = {0};
// 将md5码转为16进制,进行输出
for (int i = 0; i < 16; i++)
{
snprintf(tmp, sizeof(tmp) - 1, "%02x", md5[i]);
strncat(buf, tmp, 2);
}
_session_id = buf;
return true;
}
其中Session ID会返回给浏览器,单个会话会保存在服务器中。用户在获取每一张网页页面,前端页面会发起异步ajax请求,请求获取用户id,后端收到请求需要进行会话校验,如果校验失败,则返回一个小于0的用户ID,不允许用户获取页面,同时提示用户进行登录,如果成功则给用户返回一个大于0的用户ID,并给用户显示页面,且可以如下:
var user_id = -1;
var user_name = "";
function CheckUser() {
console.log(user_id);
if (user_id > 0) {
$(".nav_bar .last_li").text(user_name);
return;
}
// 发起ajax请求获取用户id
$.ajax({
url: "/GetUserId",
method: "Get",
dataType: 'json',
contentType: 'application/json;charset=utf-8',
success: function (data) {
console.log(data);
if (data.id > 0) {
user_id = data.id;
GetUsername();
console.log(user_name);
$(".nav_bar .last_li").text(user_name);
// $(".nav_bar .last_li").attr("href", "#");
} else {
alert("请先进行登录");
window.location.href = "/signin.html";
}
},
});
}
数据库模块
数据库模块主要与MVC模式框架中的Model进行数据交互。该模块模块主要使用MySQL C Connect连接数据库,对项目数据进行存放,该项目主要有三张表:oj_questions、user和administrators,分别存放题目信息,用户信息和管理员信息,如下:
数据库模块代码框架:
namespace ns_database
{
class DataBase
{
public:
DataBase()
{
//初始化mysql操作句柄
_mfp = mysql_init(nullptr);
assert(ConnectMysql());
pthread_mutex_init(&_mtx, nullptr);
}
~DataBase()
{
//关闭连接
mysql_close(_mfp);
pthread_mutex_destroy(&_mtx);
}
bool ConnectMysql()
{
if (nullptr == mysql_real_connect(_mfp, "127.0.0.1", "oj_client", "125000", "oj", 3306, nullptr, 0))
{
LOG(FATAL, "连接数据库失败");
return false; // 给开发人员看
}
mysql_set_character_set(_mfp, "utf8");
LOG(INFO, "连接数据库成功");
return true;
}
std::string GetUserName(int id, const std::string &strId, const std::string &table)
{
std::string sql = "select user from " + table + " where " + strId + " = ";
sql += std::to_string(id) + ";";
// std::cout << sql << std::endl;
int ret = mysql_query(_mfp, sql.c_str());
if (ret != 0)
{
LOG(WARNING, "SQL执行失败: " + std::string(mysql_error(_mfp)));
LOG(WARINNG, sql + " 执行失败");
return ""; // 给开发人员看
}
// 提取结果
MYSQL_RES *result = mysql_store_result(_mfp);
// 分析结果
int row = mysql_num_rows(result);
if (row == 0)
{
LOG(ERROR, "查询结果为空,SQL语句: " + sql);
LOG(ERROR, "无结果");
return "";
}
MYSQL_ROW line = mysql_fetch_row(result);
std::cout << "line[0]" << " " << line[0] << std::endl;
std::cout << "line[1]" << " " << line[1] << std::endl;
std::cout << "line[2]" << " " << line[2] << std::endl;
free(result);
return line[2];
}
void AddQuestion(const std::string &in_json, std::string &out_json)
{
// 1.获取用户信息
Json::Value in_value;
Json::Reader reader;
reader.parse(in_json, in_value);
std::string title = in_value["title"].asString();
std::string level = in_value["level"].asString();
std::string desc = in_value["desc"].asString();
std::string header = in_value["header"].asString();
std::string body = in_value["body"].asString();
std::string tail = in_value["tail"].asString();
std::string cpu_limit = in_value["cpu"].asString();
std::string mem_limit = in_value["mem"].asString();
std::string sql = "insert into oj_questions(title, level, `desc`, `header`, `body`, `tail`, cpu_limit, mem_limit) values('";
sql += title + "', '";
sql += level + "', '";
sql += desc + "', '";
sql += header + "', '";
sql += body + "', '";
sql += tail + "', ";
sql += cpu_limit + ", ";
sql += mem_limit + ");";
// LOG(INFO, sql);
Json::Value out_value;
if (Insert(sql))
{
LOG(INFO, "题目添加成功");
out_value["result"] = "success";
}
else
{
LOG(INFO, "题目添加失败");
out_value["result"] = "fail";
}
Json::FastWriter writer;
out_json = writer.write(out_value);
}
bool QueryMysql(const std::string &sql, MYSQL_RES *&result)
{
pthread_mutex_lock(&_mtx);
int ret = mysql_query(_mfp, sql.c_str());
if (ret != 0)
{
LOG(WARINNG, sql + " 执行失败");
perror("执行失败");
return false;
}
// 提取结果
result = mysql_store_result(_mfp);
pthread_mutex_unlock(&_mtx);
return true;
}
bool Insert(const std::string &sql)
{
int ret = mysql_query(_mfp, sql.c_str());
if (ret != 0)
{
LOG(WARINNG, sql + " 执行失败");
LOG(WARINNG, "MySQL 错误: " + std::string(mysql_error(_mfp)));
return false; // 给开发人员看
}
return true;
}
int Select(const std::string &sql, const std::string &password, std::string &out)
{
// std::cout << sql << std::endl;
int ret = mysql_query(_mfp, sql.c_str());
if (ret != 0)
{
LOG(WARINNG, sql + " 执行失败");
return -1; // 给开发人员看
}
// 提取结果
MYSQL_RES *result = mysql_store_result(_mfp);
// 分析结果
int row = mysql_num_rows(result);
if (row == 0)
{
out = "1";
return -2;
}
MYSQL_ROW line = mysql_fetch_row(result);
if (line[1] != password)
{
out = "2";
return -3;
}
out = "0";
free(result);
return atoi(line[0]);
}
bool Update(const std::string &sql, std::string &out)
{
// std::cout << sql << std::endl;
int ret = mysql_query(_mfp, sql.c_str());
if (ret != 0)
{
LOG(WARINNG, sql + " 执行失败");
return false; // 给开发人员看
}
out = "0";
return true;
}
private:
MYSQL *_mfp;
pthread_mutex_t _mtx;
};
}
项目总结
问题与解决
如何检测编译主机是否上线?通过给编译主机注册一个检测网络畅通的请求方法,oj_server可以单独开一个线程不停地给状态为OFFLINE的主机发起/check_net请求,以此判断网络是否畅通,然后修改主机状态
用户代码提交过快,会导致后端数据库频繁请求,导致数据库报错:Operation now in progress。解决:前端界面通过js控制按钮点击事件,每1s才能够点击一次,后端数据库模块对该sql执行进行加锁,二者结合有效解决问题