文章目录
5.Compiler模块compiler_server.cc
这个项目是基于负载均衡的在线OJ平台,用户可以在浏览器访问各个题目,在编辑区编写代码提交,后端能够自动分配服务器资源,保持平衡的情况下为用户提供良好的编程运行环境,让代码快速运行和提交。


可以根据需求增加更多的题目



超清图片我放在了Gitee上面,需要可以自行下载(文章末尾有链接)

我的项目核心是三个模块:
- comm : 公共模块,提供一些时间戳获取,路径拼接,文件操作,字符串处理,网络请求,日志等项目里常用的功能。
- compile_server : 编译与运行模块,让用户提交的代码与测试用例拼接后在服务器上进行编译,运行,得到结果返回给用户。
- oj_server : 请求题目列表;请求一个具体题目,且有编辑区 ;提交判题请求。采用MVC的设计模式,使用负载均衡,访问文件或数据库,调用编译模块,以及把题目列表和编辑界面展示给用户。
注意:我们只实现类似 leetcode 的题目列表+在线编程功能。

用户直接访问的是OJServer模块,OJServer收到请求后会进行功能路由,根据不同的请求给用户返回不同的结果,如果用户是编写题目提交代码,那么OJServer模块会根据后端的CompilerServer服务器的负载情况,负载均衡地选择主机提供的编译运行服务,然后拿到编译运行结果返回给用户。Compiler服务器和OJ服务器,两个模块之间采用http网络通信,把编译运行模块部署在多台服务器上,OJ服务器只需要一台,能够把用户的请求发送给后端CompilerServer。
comm : 公共模块,提供一些时间戳获取,路径拼接,文件操作,字符串处理,网络请求,日志等项目里常用的功能。

- 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 += level;
- message += "]";
-
- // 添加报错文件名称
- message += "[";
- message += file_name;
- message +="]";
-
- // 添加报错行
- message += "[";
- message += std::to_string(line);
- message += "]";
-
- // 添加日志时间戳
- message += "[";
- message += TimeUtil::GetTimeStamp(); // 获取时间戳
- message += "]";
-
- std::cout << message; // 不要endl进行刷新
-
- return std::cout;
- }
-
- // 再用宏封装一下
- // 使用方法:例如:LOG(INFO) << "message" << "\n"
- #define LOG(level) Log(#level, __FILE__, __LINE__)
- }
- // 传入一个文件名,自动形成路径与后缀
- namespace ns_util
- {
- // 引入存储临时文件的路径
- const std::string temp_path = "./temp/";
-
- // 时间功能
- class TimeUtil
- {
- public:
- // 获取秒级别时间戳
- static std::string GetTimeStamp()
- {
- struct timeval _time;
- gettimeofday(&_time, nullptr); // 时区不关心,所以设置成nullptr
-
- return std::to_string(_time.tv_sec);
- }
-
- // 获取毫秒级别时间戳
- static std::string GetTimeMs()
- {
- struct timeval _time;
- gettimeofday(&_time, nullptr);
- return std::to_string(_time.tv_sec * 1000 + _time.tv_usec / 1000); // 秒->毫秒=秒*1000,微秒->毫秒=微秒/1000
- }
- };
-
- // 对路径操作的方法
- class PathUtil
- {
- public:
- // 构建文件路径+后缀的完整文件名
- static std::string AddSuffix(const std::string &file_name, const std::string &suffix)
- {
- std::string path_name = temp_path; // "./temp/"
- path_name += file_name; // "./temp/12345"
- path_name += suffix; // "./temp/12345.后缀"
- return path_name;
- }
-
- /**************************编译时需要有的临时文件**************************/
-
- // 构建源文件路径+后缀的完整文件名
- // 例如:12345 -> ./temp/12345.cpp
- static std::string Src(const std::string &file_name)
- {
- return AddSuffix(file_name, ".cpp");
- }
-
- // 构建可执行程序的完整路径+后缀名
- // 例如:12345 -> ./temp/12345.exe
- static std::string Exe(const std::string &file_name)
- {
- return AddSuffix(file_name, ".exe");
- }
-
- // 构建该程序对应的编译时错误文件的完整路径+后缀名
- // 例如:12345 -> ./temp/12345.compile_error
- static std::string CompilerError(const std::string &file_name)
- {
- return AddSuffix(file_name, ".compile_error");
- }
-
-
- /**************************运行时需要有的临时文件**************************/
-
- // 构建标准输入文件
- static std::string Stdin(const std::string &file_name)
- {
- return AddSuffix(file_name, ".stdin");
- }
-
- // 构建标准输出文件
- static std::string Stdout(const std::string &file_name)
- {
- return AddSuffix(file_name, ".stdout");
- }
-
- // 构建标准错误文件
- static std::string Stderr(const std::string &file_name)
- {
- return AddSuffix(file_name, ".stderr");
- }
- };
-
- // 对文件操作的方法
- class FileUtil
- {
- public:
- // 判断文件是否存在
- static bool IsFileExists(const std::string &path_name)
- {
- struct stat st;
- if (stat(path_name.c_str(), &st) == 0)
- {
- // 获取属性成功,表示文件已经存在
- return true;
- }
-
- return false;
- }
-
- // 形成一个唯一的文件名,没有目录没有后缀
- // 毫秒级时间戳+原子性递增唯一值: 来保证唯一性
- static std::string UniqFileName()
- {
- static std::atomic_uint id(0); // 原子性的计数器
- id++; // 计数器++
-
- std::string ms = TimeUtil::GetTimeMs(); // 毫秒级时间戳
- std::string uniq_id = std::to_string(id); // 获得唯一id
-
- return ms + "_" + uniq_id;
- }
-
- // 将代码写入该文件
- // 参数:target为要写入的文件名,content为要写入的内容
- static bool WriteFile(const std::string &target, std::string &content)
- {
- std::ofstream out(target);
- if (!out.is_open())
- {
- // 如果没有被打开成功
- return false;
- }
-
- out.write(content.c_str(), content.size()); // 写入内容
- out.close(); // 关闭文件
-
- return true;
- }
-
- // 把所有的文件内容读出来
- // 参数:target为要读取的文件名,content用于存储读到的数据, keep用于判断是否保留\n
- static bool ReadFile(const std::string &target, std::string *content, bool keep = false)
- {
- (*content).clear(); // 先清空content,为了不影响第一次读取
-
- std::ifstream in(target, std::ios::binary);
- if (!in.is_open())
- {
- return false;
- }
-
- std::string line;
- // 注意:
- // getline:不能保存行分隔符
- // getline:内部重载了强制类型转化
- while (std::getline(in, line))
- {
- (*content) += line;
- (*content) += (keep ? "\n" : ""); // 判断是否保留\n
- }
-
- in.close(); // 关闭文件
-
- return true;
- }
- };
-
- // 字符串工具
- class StringUtil
- {
- public:
- // 切分字符串
- /******************************************************
- * 参数:
- * str:输入型,目标要切分的字符串
- * target:输出型,保存切分完毕的结果
- * sep:指定的分隔符
- ******************************************************/
- static void SplitString(const std::string &str, std::vector
*target, const std::string sep) - {
- // 用boost库里面的字符串切分功能
- boost::split((*target), str, boost::is_any_of(sep), boost::algorithm::token_compress_on);
- }
- };
-
-
- }
compile_server : 编译与运行模块,让用户提交的代码与测试用例拼接后在服务器上进行编译,运行,得到结果返回给用户。



- // 只负责进行代码的编译
- namespace ns_compiler
- {
- using namespace ns_util; // 引入路径拼接功能
- using namespace ns_log; // 引入日志打印功能
-
-
- // 编译功能
- class Compiler
- {
- public:
- Compiler()
- {}
-
- ~Compiler()
- {}
-
- // 编译代码:
- // 返回值:编译成功:true,编译错误:false
- // 输入参数:编译的文件名:file_name
- // 例如:file_name: 12345(只传进来一个文件名,不带后缀,后缀我们自己添加)
- // 我们会生成三个文件
- // 12345 -> ./temp/12345.cpp 源文件
- // 12345 -> ./temp/12345.exe 可执行文件
- // 12345 -> ./temp/12345.stderr 标准错误文件
- static bool Compile(std::string &file_name)
- {
- pid_t pid = fork(); // 创建一个子进程进行编译代码
-
- if (pid < 0)
- {
- // 如果创建子进程失败就直接退出
- LOG(ERROR) << "内部错误,创建子进程失败" << "\n";
- return false;
- }
- else if (pid == 0)
- {
- umask(0); // 先将umask清零,让其不受平台的影响
-
- // 先将编译时错误文件打开
- int _compile_error = open(PathUtil::CompilerError(file_name).c_str(), O_CREAT | O_WRONLY, 0644);
- if (_compile_error < 0)
- {
- // 如果打开文件失败
- LOG(WARNING) << "没有成功形成compile_error文件" << "\n";
- exit(1);
- }
-
- // 重定向标准错误到_compile_error
- dup2(_compile_error, 2);
-
-
- // 注意:程序替换,并不影响进程的文件描述符表
- // 子进程:调用编译器,完成对代码的编译工作
- // g++ -o target src -std=c++11
- execlp("g++", "g++", "-o", PathUtil::Exe(file_name).c_str(),\
- PathUtil::Src(file_name).c_str(), "-D", "COMPILER_ONLINE", "-std=c++11", nullptr/*不要忘记写nullptr*/); // 用g++替换当前的子进程
-
- // 如果程序替换失败
- LOG(ERROR) << "启动编译器g++失败,可能是参数错误" << "\n";
- exit(2);
- }
- else
- {
- // 父进程
- waitpid(pid, nullptr, 0);
-
- // 判断编译是否成功(判断是否生成可执行文件)
- if (FileUtil::IsFileExists(PathUtil::Exe(file_name)))
- {
- LOG(INFO) << PathUtil::Src(file_name) << " 编译成功!" << "\n";
- return true;
- }
- }
-
- // 如果父进程走到这里,就说明编译失败,没有形成可执行文件
- LOG(ERROR) << "编译失败,没有形成可执行文件!" << "\n";
- return false;
- }
- };
- }
-
-

- namespace ns_runner
- {
- using namespace ns_util; // 引入路径拼接功能
- using namespace ns_log; // 引入日志打印功能
-
- class Runner
- {
- public:
- Runner()
- {}
-
- ~Runner()
- {}
-
- public:
- // 提供设置进程占用资源大小的接口
- static void SetProcLimit(int _cpu_limit, int _mem_limit)
- {
- // 设置cpu占用时长
- struct rlimit cpu_rlimit;
- cpu_rlimit.rlim_max = RLIM_INFINITY;
- cpu_rlimit.rlim_cur = _cpu_limit;
-
- setrlimit(RLIMIT_CPU, &cpu_rlimit);
-
- // 设置内存大小
- struct rlimit mem_rlimit;
- mem_rlimit.rlim_max = RLIM_INFINITY;
- mem_rlimit.rlim_cur = _mem_limit * 1024; // 乘以1024,让单位KB变成字节
-
- setrlimit(RLIMIT_AS, &mem_rlimit);
- }
-
- // 运行并判断运行是否成功
- // 注意:我们这里实现的功能,也只需要指明文件名即可,不需要带路径和后缀
- /***************************************************************
- * 将这里的返回值设置成int而不设置成bool,是因为:
- * 返回值 > 0 : 程序异常了,退出时收到了信号,返回值就是对应的信号编号
- * 返回值 == 0 : 程序正常运行完毕的,结果保存到了对应的临时文件中
- * 返回值 < 0 : 内部错误:打开文件失败、创建子进程失败等
- *
- * 参数介绍:
- * cpu_limit : 该程序运行的时候,可以使用的最大cpu资源上限
- * mem_limit : 该程序运行的时候,可以使用的最大的内存大小(KB)
- ***************************************************************/
- static int Run(const std::string &file_name, int cpu_limit, int mem_limit )
- {
- // 获取运行需要的文件
- std::string _execute = PathUtil::Exe(file_name); // 获得可执行程序的完整文件名
- std::string _stdin = PathUtil::Stdin(file_name); // 获取标准输入文件
- std::string _stdout = PathUtil::Stdout(file_name); // 获取标准输出文件
- std::string _stderr = PathUtil::Stderr(file_name); // 获取标准错误文件
-
- umask(0); // 先将umask清零,让其不受平台的影响
-
- // 打开运行需要的文件
- int _stdin_fd = open(_stdin.c_str(), O_CREAT | O_RDONLY, 0644);
- int _stdout_fd = open(_stdout.c_str(), O_CREAT | O_WRONLY, 0644);
- int _stderr_fd = open(_stderr.c_str(), O_CREAT | O_WRONLY, 0644);
-
- // 如果有任何一个文件打开失败,就退出程序(因为如果打开失败就无法获取相应的数据和输出结果,再继续运行就没有意义了)
- if (_stdin_fd < 0 || _stdout_fd < 0 || _stderr_fd < 0)
- {
- LOG(ERROR) << "运行时打开标准文件失败" << "\n";
- return -1; // 代表文件打开失败
- }
-
-
- // 创建一个子进程运行程序
- pid_t pid = fork();
- if (pid < 0)
- {
- LOG(ERROR) << "运行时创建子进程失败" << "\n";
-
- // 如果创建失败,关闭需要的文件
- close(_stdin_fd);
- close(_stdout_fd);
- close(_stderr_fd);
-
- return -2; // 代表创建子进程失败
- }
- else if (pid == 0)
- {
- // 子进程创建成功
- // 将标准输入,标准错误,标准错误都进行重定向到刚才打开的文件
- dup2(_stdin_fd, 0);
- dup2(_stdout_fd, 1);
- dup2(_stderr_fd, 2);
-
- // 设置资源限制
- SetProcLimit(cpu_limit, mem_limit);
-
- // 然后替换程序,将子进程替换成需要的可执行文件
- // 参数:带路径的可执行文件,要怎么执行......
- execl(_execute.c_str()/*我要执行谁*/, _execute.c_str()/*我想在命令行上如何执行该程序*/, nullptr);
-
- // 如果替换失败
- exit(1);
- }
- else
- {
- // 父进程用不到这些文件,所以先将其关闭
- close(_stdin_fd);
- close(_stdout_fd);
- close(_stderr_fd);
-
- // 让父进程等待子进程
- int status = 0; // 拿到子进程的退出结果
- waitpid(pid, &status, 0);
-
- // 程序如果运行异常,一定是因为收到了信号!
- LOG(INFO) << "运行完毕,info:" << (status & 0x7f) << "\n";
- return status & 0x7f;
- }
- }
- };
- }

- namespace ns_compile_and_run
- {
- using namespace ns_log; // 引入日志打印功能
- using namespace ns_util; // 引入路径拼接功能
- using namespace ns_compiler; // 引入编译功能
- using namespace ns_runner; // 引入运行功能
-
- class CompileAndRun
- {
- public:
- // 清空所有的临时文件
- static void RemoveTempFile(const std::string &file_name)
- {
- // 特点:清理文件的个数是不确定的,但是有哪些我们是知道的
-
- // 删除源文件
- std::string _src = PathUtil::Src(file_name); // 获取源文件的完整路径
- if (FileUtil::IsFileExists(_src)) unlink(_src.c_str()); // 如果文件存在,就将其移除
-
- // 删除编译错误文件
- std::string _compile_error = PathUtil::CompilerError(file_name);
- if (FileUtil::IsFileExists(_compile_error)) unlink(_compile_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());
- }
-
- // 将信号转化成为报错的原因
- // code > 0 : 进程收到了信号导致异常错误
- // code < 0 : 整个过程非运行报错(代码为空,编译报错等)
- // cod == 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: // 代码编译的时候发生了错误
- FileUtil::ReadFile(PathUtil::CompilerError(file_name), &desc, true); // 读取编译报错的文件
- break;
- case SIGABRT: // 信号6
- desc = "内存超过范围";
- break;
- case SIGXCPU: // 信号24
- desc = "CPU使用超时";
- break;
- case SIGFPE: // 信号8
- desc = "浮点数溢出";
- break;
- default:
- desc = "未知" + std::to_string(code);
- break;
- }
-
- return desc;
- }
-
- /*****************************************************************************
- * 输入:
- * code: 用户提交的代码
- * input: 用户给自己提交的代码对应的输入,不做处理
- * cpu_limit: 时间要求
- * mem_limit: 空间要求
- *
- * 输出:
- * 必填
- * status: 状态码
- * reason: 请求结果
- * 选填:
- * stdout: 我的程序运行完的结果
- * stderr: 我的程序运行完的错误结果
- *
- * 参数:
- * in_json: {"code": "#include...", "input": "","cpu_limit":1, "mem_limit":10240}
- * out_json: {"status":"0", "reason":"","stdout":"","stderr":"",}
- *****************************************************************************/
- static void Start(const std::string &in_json, std::string *out_json)
- {
- // 将拿到的数据做反序列化
- Json::Value in_value;
- Json::Reader reader;
- reader.parse(in_json, in_value);
-
- std::string code = in_value["code"].asString(); // 拿到用户提交的代码
- std::string input = in_value["input"].asString(); // 拿到用户输入的数据
- int cpu_limit = in_value["cpu_limit"].asInt(); // 拿到上层给的cpu占用时长
- int mem_limit = in_value["mem_limit"].asInt(); // 拿到上层给的内存大小
-
- // 创建输出返回的数据
- int status_code = 0; // 状态码
- Json::Value out_value;
- int run_result = 0; // 运行结果
- std::string file_name; // 需要内部形成的唯一文件名(因为goto语句之间不能出现定义,所以就在这里定义)
-
- if (code.size() == 0)
- {
- status_code = -1; // 代码为空
- goto END;
- }
-
- // 形成的文件名只具有唯一性,没有目录没有后缀
- file_name = FileUtil::UniqFileName();
-
- // 形成临时的src文件
- if (!FileUtil::WriteFile(PathUtil::Src(file_name), code))
- {
- // 如果写入文件失败
- status_code = -2; // 未知错误
- goto END;
- }
-
- // 编译
- if (!Compiler::Compile(file_name))
- {
- // 如果编译失败
- status_code = -3; // 代码编译的时候发生了错误
- goto END;
- }
-
- run_result = Runner::Run(file_name, cpu_limit, mem_limit); // 运行
- if (run_result < 0)
- {
- // 内部错误
- status_code = -2;
- ; // 未知错误
- goto END;
- }
- else if (run_result > 0)
- {
- // 程序运行崩溃了
- status_code = run_result;
- }
- else
- {
- // 运行成功
- status_code = 0;
- }
- 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);
- }
- };
- }

- // 使用手册
- void Usage(std::string proc)
- {
- std::cerr << "Usage: " << "\n\t" << proc << " port" << std::endl;
- }
-
- // 调用方式:./compile_server port
- int main(int argc, char *argv[])
- {
- if (argc != 2)
- {
- // 如果使用方法不对
- Usage(argv[0]);
- return 1;
- }
-
- Server svr; // 创建一个服务器
-
- // 参数:req:用户的需求,resp:服务器的相应
- svr.Post("/compile_and_run", [](const Request &req, Response &resp){
- // 用户请求的服务正文就是我们想要的json string
- // in_json: {"code": "#include...", "input": "","cpu_limit":1, "mem_limit":10240}
- // out_json: {"status":"0", "reason":"","stdout":"","stderr":"",}
-
- std::string in_json = req.body;
- std::string out_json;
- if (!in_json.empty())
- {
- // 编译并运行用户传过来的代码
- CompileAndRun::Start(in_json, &out_json);
-
- // 要响应的内容
- resp.set_content(out_json, "application/json;charset=utf_8");
- }
- });
-
- // 让服务器在所有ip,指定端口服务
- svr.listen("0.0.0.0", atoi(argv[1])); // 启动http服务
-
- return 0;
- }
OJServer模块是直接和用户交互的,用户访问OJ系统,我需要有一个首页,其次需要有一个题目列表网页供用户选择题目,再者还需要一个可以给用户写代码做题的网页,并且可以提交代码,判断用户提交的代码是否正确。
总结用户的请求分为三种:
整个模块采用的是MVC的设计模式进行设计
通过这个设计模式,把数据,业务逻辑和网页界面进行了分离。
经典MVC模式中,M是指业务模型,V是指用户界面(视图),C则是控制器,使用MVC的目的是将M和V的实现代码分离,从而使同一个程序可以使用不同的表现形式。其中,View的定义比较清晰,就是用户界面。

整个模块就包含四个部分:



oj_model模块主要是和数据交互的,这里的数据就是我们后端文件或者数据库当中的题目信息,题目应该包含如下的信息:
到这里我们就需要有自己对应的题库,我们这个模块当中新增一个目录questions,用来存放我们的题库,这个questions目录下包含题目列表(文件形式)和每个题目的文件夹(其中又包含题目的描述、题目预设给用户在线编辑的代码header和题目的测试用例tail)
- // 文件版
- 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 string questions_list = "./questions/questions.list"; // 配置文件的路径
- const string question_path = "./questions/"; // 题库路径
-
- class Model
- {
- private:
- // 题号:题目细节
- unordered_map
questions; - public:
- Model()
- {
- // 将题目加载进来
- assert(LoadQuestionList(questions_list));
- }
-
- bool LoadQuestionList(const 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
tokens; - StringUtil::SplitString(line, &tokens, " ");
-
- // 例如:1 判断回文数 简单 1 30000
- if (tokens.size() != 5)
- {
- // 如果配置的内容有问题
- LOG(WARNING) << "加载部分题目失败,请检查文件格式" << "\n";
- continue;
- }
-
- // 进行填充question
- Question q;
- q.number = tokens[0];
- q.title = tokens[1];
- q.star = tokens[2];
- q.cpu_limit = atoi(tokens[3].c_str());
- q.mem_limit = atoi(tokens[4].c_str());
-
- // 指定题库路径
- string path = question_path;
- path += q.number; // 加上题号
- path += "/";
-
- // 读取所有文件里面的内容,并填充到q中
- FileUtil::ReadFile(path+"desc.txt", &(q.desc), true);
- FileUtil::ReadFile(path+"header.cpp", &(q.header), true);
- FileUtil::ReadFile(path+"tail.cpp", &(q.tail), true);
-
- // 最后将单个题目q提交到题库中(哈希表)
- questions.insert({q.number, q});
- }
-
- LOG(INFO) << "加载题库...成功!" << "\n";
- in.close(); // 关闭配置文件
-
- return true;
- }
-
- // 获取所有的题目
- bool GetAllQuestions(vector
*out) - {
- if (questions.size() == 0)
- {
- // 如果没有题目
- LOG(ERROR) << "用户获取题库失败" << "\n";
- return false;
- }
-
- for (const auto &q : questions)
- {
- out->push_back(q.second); // first: key, second value
- }
-
- 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()
- {}
- };
- }
- // MySQL版本
- // 根据题目list文件,加载所有的题目信息到内存中
- // model:主要用来和数据进行交互,对外提供访问数据的接口
-
- namespace ns_model
- {
- using namespace std;
- using namespace ns_log;
- using namespace ns_util;
-
- struct Question
- {
- string number; // 题目编号(唯一)
- string title; // 题目的标题
- string star; // 题目的难度:简单 中等 困难
- string desc; // 题目的描述
- string header; // 题目预设给用户在线编辑器的代码
- string tail; // 题目的测试用例,需要和header拼接,形成完整代码
- int cpu_limit; // 题目的时间要求(S)
- int mem_limit; // 题目的空间要求(KB)
- };
-
- const std::string oj_questions = "oj_questions"; // 要访问的表名
- const std::string host = "127.0.0.1"; // ip为本地服务器
- const std::string user = "oj_client"; // MySQL用户名
- const std::string passwd = "xujiaming+520"; // MySQL密码
- const std::string db = "oj"; // 要连接的数据库名
- const int port = 3306; // MySQL的端口号
-
- class Model
- {
- public:
- Model()
- {
- }
-
- // 查询MySQL
- // 参数:sql:sql查询语句,out:输出查询结果
- bool QueryMySql(const std::string &sql, vector
*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);
-
- // 分析结果
- int rows = mysql_num_rows(res); // 获得行数
- int cols = mysql_num_fields(res); // 获得列数
-
- // 获得每行每列的数据
- for (int i = 0; i < rows; ++i)
- {
- MYSQL_ROW row = mysql_fetch_row(res); // 拿到当前这一行的所有数据(这里的row是一个二级指针)
- Question q; // 用于保存结果
-
- // 拿到当前行,每列的所有数据
- q.number = row[0];
- q.title = row[1];
- q.star = row[2];
- q.desc = row[3];
- q.header = row[4];
- q.tail = row[5];
- q.cpu_limit = atoi(row[6]);
- q.mem_limit = atoi(row[7]);
-
- // 将当前题的所有信息放到返回数组里面
- out ->push_back(q);
- }
-
- // 释放结果空间
- free(res);
-
- // 关闭MySQL连接
- mysql_close(my);
-
- return true;
- }
-
- // 获取所有的题目
- bool GetAllQuestions(vector
*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
result; - if (QueryMySql(sql, &result)) // 判断是否获取题目成功
- {
- // 判断获取的题目个数是否只有1个
- if (result.size() == 1)
- {
- *q = result[0];
- res = true;
- }
- }
-
- return res;
- }
-
- ~Model()
- {
- }
- };
- }
题目的属性大致可以分为2类:

oj_view模块负责渲染给用户显示的网页。比如说用户请求访问题目列表,题目列表里的题目信息是从我们后端的题库中得到的,而把这些信息显示到网页上,这就是渲染网页。所有说view模块也应该提供两个接口,一个渲染题目列表,一个渲染单个题目的网页。

我们需要引入一个第三方库ctemplate;功能如下:

- namespace ns_view
- {
- using namespace ns_model;
-
- const std::string template_path = "./template_html/"; // 要渲染的路径
-
- class View
- {
- public:
- View()
- {}
- ~View()
- {}
- public:
- // 将所有的题目数据构建成网页
- 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.star);
- }
-
- // 3. 获取被渲染的网页
- ctemplate::Template *tpl = ctemplate::Template::GetTemplate(src_html, ctemplate::DO_NOT_STRIP);
-
- // 4. 开始完成渲染功能
- tpl->Expand(html, &root); // 这个root字典里面会包含所有的子字典
- }
-
- // 将单个题目的所有数据构建成网页
- void OneExpand(const struct Question &q, std::string *html)
- {
- // 1. 形成路径
- std::string src_html = template_path + "one_question.html"; // 要被渲染的网页
-
- // 2. 形成数据字典
- ctemplate::TemplateDictionary root("one_questions");
-
- // 2.1 插入题目描述
- 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); // 这个root字典里面会包含所有的子字典
- }
- };
- }
oj_control模块是整个OJSever模块的逻辑功能部分,在上层做好了功能路由之后,通过调用control模块实现各个功能,所有oj_control模块既要可以返回对应的网页,还要可以负载均衡的判题。
提供三个功能:

- // 根据题目数据构建网页
- // html:输出型参数
- bool AllQuestions(string *html)
- {
- bool ret = true;
- vector<struct Question> all;
-
- if (model_.GetAllQuestions(&all))
- {
- // 给题目按编号进行排序
- sort(all.begin(), all.end(), [](const struct Question &q1, const struct Question &q2){
- return atoi(q1.number.c_str()) < atoi(q2.number.c_str());
- });
-
- // 获取题目信息成功,将所有的题目数据构建成网页
- view_.AllExpandHtml(all, html);
- }
- else
- {
- // 获取失败
- *html = "获取题目失败,形成题目列表失败";
- ret = false;
- }
-
- return ret;
- }
-
- // 根据题号构建网页
- bool Question(const string &number, string *html)
- {
- bool ret = true;
- struct Question q;
- if (model_.GetOneQuestion(number, &q))
- {
- // 获取指定题目信息成功,将该题目的所有数据构建成网页
- view_.OneExpand(q, html);
- }
- else
- {
- // 获取失败
- *html = "指定题目:" + number + " 不存在!";
- ret = false;
- }
-
- return ret;
- }
负载均衡模块,最重要的功能就是可以负载均衡式的选择主机,我如何得知有哪些主机可以供我选择,怎么实现负载均衡。
所以模块内部要有一个结构包含提供服务的主机信息,用来表述主机的结构命名为Machine,然后用一个vector把所有提供服务的主机组织起来。
Machine类里有主机的IP,端口,还有负载情况。负载均衡判断的依据就是看主机的负载,所有类里还要提供方法,在有新请求请求该机器时增加负载,服务结束时减少负载,如果中途服务主机突然挂了,还要可以清空负载。
因为同一时刻可能有多个执行流在请求同一个主机,所有需要保证对负载操作的安全性,需要一个mutex互斥锁。
将来选择主机可以在vector中选,在此之前需要知道有哪些主机可以选,在当前路径下加一个.conf文件里面会存放所有的可以提供服务的主机信息,包括了IP和端口,每一行是一个主机的信息,负载均衡模块在构建时就可以读取该文件,初始化自己的vector结构。
然后就是选择主机功能,首先在同一时刻可能有很多执行流都在选择主机,所以对主机的选择需要加锁,也就是说负载均衡模块也需要一个互斥锁。
设计在control模块调用负载均衡模块时,如果说后端的编译服务主机出问题挂了,不应该影响我的OJServer服务,OJ服务正常运行,编译服务如果恢复了,那我正常请求,如果有一部分挂了,那我请求别的主机,全挂我就不请求,提示后端。
这个功能就由负载均衡模块负责,负载均衡模块除了可以选择主机,还要能够知道主机的情况,并能够根据情况更新。使用数组的下标表示每一个主机的编号,用两个数组,一个表示上线主机,元素的值就是主机编号,另一个表示下线的主机。提供方法,在后端编译服务重启时可以更新状态让主机上线,当请求主机失败时要更新状态让主机下线。
负载均衡就是尽量让每一台机器负责的请求平均,那就需要从所有在线的主机中选择出对应的主机。一是随机挑选主机,但是这种方法不能排除有时候一直选中某几台,有几台又一直选不上。还有一种比较严格,遍历所有在线的主机,找出负载最小的。

- // 提供服务的主机
- class Machine
- {
- public:
- std::string ip; // 编译服务的ip
- int port; // 编译服务的端口号
- uint64_t load; // 编译服务的负载(计数器)
- std::mutex *mtx; // 用于保护计数器的锁(注意:cpp中的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(); // 解锁
- }
-
- // 将主机的负载清零
- void ResetLoad()
- {
- if (mtx) mtx->lock();
- load = 0;
- if (mtx) mtx->unlock();
- }
-
- // 获取主机负载(没有太大的意义,只是为了统一接口)
- uint64_t Load()
- {
- uint64_t _load = 0;
- if (mtx) mtx->lock(); // 加锁
- _load = load;
- if (mtx) mtx->unlock(); // 解锁
-
- return _load;
- }
-
- };
-
- const std::string service_machine = "./conf/service_machine.conf"; // 提供服务的主机列表的路径
- // 负载均衡模块
- class LoadBalance
- {
- private:
- std::vector
machines; // 可以给我们提供编译服务的主机(每一台主机都有自己的下标,充当当前主机的id) - std::vector<int> online; // 所有在线的主机id
- std::vector<int> offline; // 所有离线的主机id
- std::mutex mtx; // mtx是LoadBalance的锁,是保证LoadBalance它的数据安全(注意:每个Machine也有自己的小锁,不要与这里的mtx搞混)
- public:
- LoadBalance()
- {
- assert(LoadConf(service_machine));
- LOG(INFO) << "加载 " << service_machine << " 成功" << "\n";
- }
-
- ~LoadBalance()
- {}
- public:
- // 将主机加载进来
- // 参数:machine_list:主机列表
- 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))
- {
- // 按行读取machine_conf文件数据
- // 进行字符串切割:将字符串分成两部分:ip和port
- std::vector
tokens; - StringUtil::SplitString(line, &tokens, ":");
-
- if (tokens.size() != 2)
- {
- // 如果切分出来的字符串不是ip和port这两部分
- 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(); // 将选择功能加锁
-
- // 使用的负载均衡算法:轮询 + hash
- 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 = 1; i < online_num; ++i)
- {
- uint64_t cur_load = machines[online[i]].Load();
- if (min_load > cur_load)
- {
- min_load = cur_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); // 注意:这里不能写成offline.push_back(*iter);因为这样为导致迭代器失效
- 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";
- }
-
- // 显示所有在线和离线的主机(仅仅用于测试)
- 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(); // 解锁
- }
- };
得到的参数是需要判的题目编号和用户传进来的json串形式的代码,通过题目编号,调用model模块得到题目相关的信息,然后通过反序列化用户传来的代码,得到代码内容。有了题目的信息和用户的代码,就可以拼接出可以用来编译的源码内容,构建出CompilerServer需要的json串。请求后端编译服务器主机。
选到主机之后通过主机的IP+端口,使用网络请求方式发起请求,除了通过请求的返回值判断请求是否成功,还需要判断请求的状态码,只有状态呢是200才表示请求成功。且需要更新请求时机器的负载情况。

- // 判题功能
- // 参数:
- // number:题号,in_json:客户上传上来的代码,out_json:要返回的结果
- void Judge(const std::string &number, const std::string in_json, std::string *out_json)
- {
- // LOG(DEBUG) << in_json << "\nnumber: " << number << "\n";
-
- // 0. 根据题目编号,直接拿到对应的题目细节
- struct Question q;
- model_.GetOneQuestion(number, &q);
-
- // 1. 将in_json进行反序列化,得到题目id,得到用户提交的源代码(input)
- Json::Reader reader;
- Json::Value in_value;
- reader.parse(in_json, in_value); // 参数:你想要反序列化谁,你想要反序列化的json_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; // 注意这里需要加一个换行符,否则可能会将#ifndef拼接到 ‘;’ 后面,导致拼接错误
- 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_balance_.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"))
- {
- if (res->status == 200)
- {
- // 5. 如果http请求成功,将编译运行后的结果赋值给out_json
- *out_json = res->body;
- 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(); // 仅仅是为了用来调试
- }
- }
- }
搭建一个http服务,通过用户请求的不同资源,完成功能路由的任务,调用oj_control模块的功能。

- static Control *ctrl_ptr = nullptr; // 定义一个控制器指针,让其既可以局部使用,也可以全局使用
-
-
- // 使用手册
- void Usage(std::string proc)
- {
- std::cerr << "Usage: " << "\n\t" << proc << " port" << std::endl;
- }
-
-
- // 写一个恢复主机的回调方法
- void Recovery(int signo)
- {
- ctrl_ptr->RecoveryMachine();
- }
-
- // 调用方式:./compile_server port
- int main(int argc, char *argv[])
- {
- // 捕捉3号信号(ctrl + \).用快捷键(ctrl + \)一键上线所有离线的主机
- signal(SIGQUIT, Recovery);
-
- if (argc != 2)
- {
- // 如果使用方法不对
- Usage(argv[0]);
- return 1;
- }
-
- // 用户请求的服务路由功能
- Server svr; // 创建有个服务器
- Control ctrl; // 创建一个控制器
- ctrl_ptr = &ctrl;
-
- // 获取所有的题目列表
- // 参数:req:用户的需求,resp:服务器的相应
- 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");
- });
-
- // 用户要根据题目编号,获取题目的内容
- // /question/100 -> 正则匹配
- // R"()"作用:原始字符串raw string,保持字符串内容的原貌,不用做相关的转义
- svr.Get(R"(/question/(\d+))", [&ctrl](const Request &req, Response &resp){
- std::string number = req.matches[1]; // 拿到题号
- std::string html;
- ctrl.Question(number, &html);
- resp.set_content(html, "text/html; charset=utf-8");
- });
-
- // 用户提交代码,使用我们的判题功能(1. 每道题的测试用例 2. compile_and_run)
- svr.Post(R"(/judge/(\d+))", [&ctrl](const Request &req, Response &resp){
- std::string number = req.matches[1]; // 拿到题号
- std::string result_json;
- ctrl.Judge(number, req.body, &result_json);
- resp.set_content(result_json, "application/json;charset=utf-8");
- // resp.set_content("指定题目的判题:" + number, "text/plain;charset=utf-8");
- });
-
- svr.set_base_dir("./wwwroot");
-
- // 让服务器在所有ip,指定端口服务
- svr.listen("0.0.0.0", atoi(argv[1])); // 启动http服务
-
- return 0;
- }
前端简单使用:
html/css/js/jquery/ajax
Ace前端在线编辑器
当用户访问根目录时显示的网页
- "UTF-8">
- "X-UA-Compatible" content="IE=edge">
- "viewport" content="width=device-width, initial-scale=1.0">
-
这是我的个人OJ系统 -
- /* 起手式, 100%保证我们的样式设置可以不受默认影响 */
- * {
- /* 消除网页的默认外边距 */
- margin: 0px;
- /* 消除网页的默认内边距 */
- padding: 0px;
- }
-
- h tml,
- 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; */
- }
-
-
- class="container">
-
-
- class="content">
-
class="font_">欢迎来到我的OnlineJudge平台
-
class="font_">这个我个人独立开发的一个在线OJ平台
-
-
-