文章目錄
- 前言
- 項目結果演示
- 技術棧:
- 結構與總體思路
- compiler編譯功能
- -common/util.hpp 拼接編譯臨時文件
- -common/log.hpp 開放式日志
- -common/util.hpp 獲取時間戳方法-秒級
- -common/util.hpp 文件是否存在
- -compile_server/compiler.hpp 編譯功能編寫(重要)
- runner運行功能
- -common/util.hpp 拼接運行臨時文件
- -compile_server/runner.hpp 運行功能編寫
- compile_run編譯運行
- -common/util.hpp 生成唯一文件名
- -common/uti.hpp 寫入文件/讀出文件
- -compile_server/compile_run.hpp 編譯運行整合模塊(重要)
- compiler_server網絡服務
- 安裝cpp-httplib第三方庫以及升級GCC版本
- -compile_server/compile_server.cpp 編譯運行網絡服務
前言
本篇記錄負載均衡oj項目設計的整體思路和部分代碼。
負載均衡oj項目基于http網絡請求,通過簡單的前后端交互:前端的頁面編輯已經提交代碼,后端控制模塊和編譯運行的模塊分離整合(負載均衡式的選擇后端編譯運行服務),從而實現在線oj的正常使用。
使用語言:C/C++,使用環境:Linux centos7,gcc -v 9.3.1-2。
項目結果演示
后端負載均衡選擇示例:
技術棧:
對于負載均衡在線oj項目,我所使用的開發技術和環境如下:
技術如下:
1.C++STL(選擇C++語言作為后端開發自然需要使用到里面的各種容器,比如string、vector)
2.cpp-httplib** (http應用層通信我們使用第三方庫快速解決,如果編寫套接字接收會很麻煩)
3.jsoncpp (網絡通信時重要的一點就是將一個待發送的信息轉換為json串進行發送。為了簡單完成序列化和反序列化
4.Boost(C++準標準庫內提供了一些好用的方法比如字符串切割spilt方法,能夠快速幫助我們完成字符串切割為幾個小塊并且保存起來而不用自己編碼實現)
5.MySQL C connect(當后端采用mysql數據庫存儲數據的時候,需要程序訪問mysql數據庫需要使用)
6.ctemplate (當想往網頁html利用程序填充變量的時候,需要用到第三方渲染庫,開源的前端渲染庫)
7.ACE前端在線編輯器 (前端知識,讓我們的oj代碼編輯變得好看)
8.js/jquery/ajax (前端向后端發起http請求)
9.多進程多線程 (第三庫中實現,本身代碼沒有體現)
10.負載均衡算法 (前端請求,后端根據編譯服務主機的負載情況進行擇少選擇)
環境:Linux centos 7云服務器、vscode、Navicat。
結構與總體思路
我們對外提供的是在線OJ編譯運行服務。那么在線自然通過實現web服務器在瀏覽器實現功能(利用前端制作網頁,對應web服務器的路由實現功能),后端自然需要提供編譯運行服務。
為了更加靈活的控制后端的編譯運行服務,我們可以將后端編譯運行服務也設置為網絡服務,這樣的化我們可以利用不同的主機部署編譯運行服務,web服務器(在線OJ和前端交互的)后端可以負載均衡式的選擇主機。(這樣的話在未來存在很多人使用的情況下就可以不用導致一臺主機壓力過大而被干掉)題目來源題庫,只需要web服務器根據路由選擇提供題目列表或者單個題目信息了,題庫的話設計版本有文件版本和mysql數據庫版本。
這樣的話我們的思路就被明確出來了。web服務器路由整合和前后端交互內容模塊我們稱之為oj_server模塊,編譯運行服務模塊稱為compiler_server模塊。很明顯,這就是一種bs模式(瀏覽器/服務器模式)。
如上圖,此項目就是設計紅色區域內的內容。
針對此,我們在我們的項目目錄結構上率先劃分出三個區域出來:
1.oj服務 - oj_server(前端提供路由,前后端結合,后端提供功能,并且負載均衡選擇后端)
2.編譯運行服務 - compiler_server(后端提供編譯運行功能)
3.公共模塊 - common (上面兩個模塊共同使用的文件存放處)
下面我們針對不同的模塊分開設計最后整合起來完成項目的編寫。
1.compiler_server模塊編寫
編譯運行服務。很明顯,是存在兩個步驟的:先編譯,后運行。
在打包為網絡服務之前,我們需要設計出編譯功能和運行功能,然后在將兩個功能整合起來。
compiler編譯功能
一個C/C++程序源文件,需要經過gcc/g++工具進行預處理、編譯、匯編、鏈接最終形成可執行文件。
平時,我們將一個源文件編譯形成可執行文件只需要gcc/g++ -o… 一步到位即可。但是現在我們需要在程序中實現這個功能。難道我們要實現gcc/g++的功能?自然不是,需要用到操作系統接口:exec*進程替換。
但是,在執行程序替換時我們需要一些必要的需求。
首先是源文件的來源(之后由編譯運行服務提供,負責將提供的code源碼形成臨時文件CPP提供編譯服務),其次編譯可能出現報錯,報錯的話gcc/g++程序時輸出到stderr文件中的,那么需要我們進行重定向到錯誤文件中去,最后,因為需要返回編譯結果,那么程序替換需要在子進程中進行,父進程等待后返回結果不受程序替換的影響。
所以,此模塊的設計思路如下:
因為需要程序文件的路徑。在項目設計中我們需要將編譯運行服務所需要的臨時文件都存放在一個temp目錄下。那么每次變得就只是文件名。可以利用這個特點,在傳給編譯服務的時候只需要傳文件名即可,拼接路徑由common公共模塊下的util.hpp提供路徑拼接,與此同時,因為編碼運行過程中難免存在差錯,我們需要差錯處理就需要一個簡單的開放式日志功能,我們也可以存放在common下log.hpp。
拼接文件想一下,編譯服務需要的臨時文件存在三種:編譯前的.cpp文件、編譯后的.exe文件、錯誤重定向的compile_error文件(stderr-重定向)。
-common/util.hpp 拼接編譯臨時文件
拼接路徑名我們寫在類PathUtil中即可。
namespace ns_util
{const std::string path = "./temp/";// 合并路徑類class PathUtil{public:// 拼接函數static std::string splic(const std::string& str1, const std::string& str2){std::string temp = path;temp += str1;temp += str2;return temp;}// 編譯臨時文件// 拼接源文件 文件名+cppstatic std::string Src(const std::string& file_name){return splic(file_name, ".cpp");}// 拼接可執行文件 文件名+exestatic std::string Exe(const std::string& file_name){return splic(file_name, ".exe");}// 拼接保存標準錯誤文件static std::string CompileError(const std::string& file_name){return splic(file_name, ".compile_error");}}
}
-common/log.hpp 開放式日志
一個日志需要等級,時間,當前調日志的文件,行數,描述信息。顯示我們利用屏幕顯示即可,所以可以利用cout遇到’\n’刷新到屏幕上的策略編寫日志文件。日志文件中只需要顯示除開描述信息之外的信息即可。
為了方便外層文件調用,我們可以利用宏編寫,減少參數的傳遞。
namespace ns_log
{using namespace ns_util;// 日志等級enum{INFO, // 正常DEBUG, // 調試WARING, // 警告ERROR, // 錯誤FATAL // 致命};// 開放式輸出日志信息inline std::ostream& Log(const std::string& level, const std::string& file_name, int line){
// #ifndef MYLOGDEBUG
// if (level == "DEBUG"){
// return std::cout << "\n";
// }
// #endifstd::string log;// 添加日志等級log += "[";log += level;log += "]";// 添加輸出日志文件名log += "[";log += file_name;log += "]";// 添加日志行號log += "[";log += std::to_string(line);log += "]";// 添加當前日志時間戳log += "(";log += TimeUtil::GetTimeStamp();log += ") ";std::cout << log; // 不使用endl進行刷新,利用緩沖區 開放式日志文件return std::cout;}// 利用宏定義完善一點 - LOG(INFO) << "一切正常\n";#define LOG(level) Log(#level, __FILE__, __LINE__)
}
#endif
上面的獲取時間利用的是時間戳,我們在util工具類中編寫獲取時間戳的代碼即可。(利用操作系統接口:gettimeofday即可)
-common/util.hpp 獲取時間戳方法-秒級
獲取時間軸我們可以寫入TimeUtil時間工具類中。
// 時間相關類class TimeUtil{public:static std::string GetTimeStamp(){// 使用系統調用gettimeofdaystruct timeval t;gettimeofday(&t, nullptr); // 獲取時間戳的結構體、時區return std::to_string(t.tv_sec);}}
準備好這些工作后,就可以進行編譯服務的編寫了,根據傳入的源程序文件名,首先創建子進程,子進程對stderr進行重定向到文件compile_error中,使用execlp(系統路徑下找命令,可變參數傳參)進行程序替換,父進程在外面等待子進程結果,等待成功后根據是否生成可執行程序決定是否編譯成功。
是否生成可執行程序如何確定?可以利用stat系統接口進行查看,查看文件屬性成功說明存在文件,否則就是不存在。我們將此小工具寫入common/util.hpp中。
-common/util.hpp 文件是否存在
因為是和文件相關,我們可以放入FileUtil文件工具類中。
// 文件相關類class FileUtil{public:// 判斷傳入文件(完整路徑)是否存在static bool IsFileExist(const std::string& pathFile){// 使用系統調用stat查看文件屬性,查看成功說明存在文件,否則沒有struct stat st;if (stat(pathFile.c_str(), &st) == 0){// 說明存在文件return true;}return false; // 否則不存在此文件}}
現在就可以編寫compile.hpp文件了,注意利用log文件進行差錯處理。
-compile_server/compiler.hpp 編譯功能編寫(重要)
#ifndef __COMPILER_HPP__
#define __COMPILER_HPP__
// 編譯模塊
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include "../common/util.hpp"
#include "../common/log.hpp"namespace ns_compiler
{using namespace ns_util;using namespace ns_log;class Compiler{public:Compiler(){}~Compiler(){}// 編譯函數 編譯臨時文件// 輸入:需要編譯的臨時文件名(存在./temp/文件名.cpp文件,不需要帶cpp/cc后綴)// 輸出:編譯成功true,false失敗static bool compile(std::string &file_name){pid_t childPid = fork();if (childPid < 0){// 子進程創建失敗LOG(ERROR) << "內部錯誤,當前子進程無法創建" << "\n";return false;}if (childPid == 0){// 子進程// 如果編譯失敗,需要將錯誤信息輸入到錯誤文件內,利用重定向umask(0); // 防止平臺的影響,影響文件權限int errFd = open(PathUtil::CompileError(file_name).c_str(), O_CREAT | O_WRONLY, 0644); // rw_r__r__if (errFd < 0){// 異常,終止程序LOG(ERROR) << "內部錯誤,錯誤輸出文件打開/創建失敗" << "\n";exit(1);}// 打開成功重定向dup2(errFd, 2); // 將標準錯誤重新向到錯誤文件內,這樣出錯就可以將錯誤信息寫入其中execlp("g++", "g++", "-o", PathUtil::Exe(file_name).c_str(), PathUtil::Src(file_name).c_str(), "-D", "COMPILE_ONLINE", NULL); // p默認從環境變量下搜索LOG(ERROR) << "g++執行失敗,檢查參數是否傳遞正確" << "\n";exit(2); // 如果執行這一步說明上面的exec程序替換函數執行失敗}// 父進程waitpid(childPid, nullptr, 0); // 阻塞等待子進程// 判斷是否編譯成功可以根據是否生成exe可執行文件進行判斷if (FileUtil::IsFileExist(PathUtil::Exe(file_name))){// .exe文件存在LOG(INFO) << "編譯成功!" << "\n";return true; // 編譯成功}LOG(ERROR) << "編譯失敗!" << "\n";return false; // exe文件不存在,編譯失敗}};
}#endif
這條 execlp 調用的作用是啟動 g++ 編譯器,編譯指定的源文件,并生成目標可執行文件。具體來說:
“g++”
這是傳遞給 g++ 的第一個參數,通常是程序名本身。在 execlp 中,第一個參數和第二個參數通常是相同的,表示要執行的程序名。
“-o”
這是 g++ 的一個選項,表示指定輸出文件的名稱。-o 后面需要跟一個文件名,表示生成的可執行文件的名稱。
“-D”
這是 g++ 的一個選項,用于定義預處理器宏。-D 后面可以跟一個宏定義,例如 -DDEBUG 或 -DNAME=value。
編譯器:g++
輸出文件:PathUtil::Exe(file_name).c_str() 指定的路徑
輸入文件:PathUtil::Src(file_name).c_str() 指定的路徑
定義的宏:COMPILE_ONLINE
如果 execlp 調用成功,當前進程會被替換為 g++ 編譯器進程,并開始執行編譯操作。如果調用失敗,程序會繼續執行 LOG(ERROR) 和 exit(2)。
execlp 是一個系統調用,用于在當前進程中啟動一個新的程序。它會用指定的程序替換當前進程的映像。execlp 的原型如下:
int execlp(const char *file, const char *arg, ...);
file 是要執行的程序的文件名。
arg 是傳遞給程序的第一個參數(通常是程序名本身)。
后續的參數是傳遞給程序的其他參數。
最后一個參數必須是 NULL,表示參數列表的結束。
runner運行功能
運行模塊很簡單,只需要一個可執行程序即可,還是利用exec*程序替換進行執行即可。
只不過,運行的話就分為運行成功和運行失敗。存在運行成功但是結果不正確的原因,但這不是運行功能處理的事情,運行模塊只需要關心是否運行成功,運行出錯比如段錯誤、空指針引用等。
除此之外,我們需要想到程序運行的時候默認打開的三個文件:stdin、stdout、stderr。需要進行重定向,這也就是運行模塊需要的臨時文件(PathUtil需要編寫)。其中stdin就是未來上層傳來的用戶測試用例,stdout就是運行成功后輸出結果,stderr就是運行失敗后的輸出結果。
因為存在各種函數以及打開文件,那么可能出現內部錯誤(非程序運行報錯),所以在返回上層結果的時候,我們需要規定值表示不同的錯誤類別。為了保持運行出錯所返回的信號中斷,我們可以設置>0就是運行報錯(直接返回運行返回值即可),<0設置為內部錯誤(比如打開文件失敗),等于0就是運行成功。
所以,此模塊的設計思路如下:
另外,程序運行時為了防止破壞計算機的事情(比如申請過大內存、很長時間浪費編譯服務資源)也或者一些編程的限制空間和資源。我們需要對運行程序進行資源限制。資源限制可以利用系統接口setrlimit進行,分別根據傳入的時間和空間進行約束。
我們直到,當OS終止程序的時候,都是通過信號進行終止的。而此資源限制限制的內存就是6號信號,cpu的執行時間限制就是24號信號。可以利用signal函數捕捉驗證一下。(當然也可以查看返回值)
-common/util.hpp 拼接運行臨時文件
和拼接編譯運行文件一樣,同樣放在PathUtil工具類中。
// 運行臨時文件// 拼接保存標準輸入文件static std::string Stdin(const std::string& file_name){return splic(file_name, ".stdin");}// 拼接保存標準輸出文件static std::string Stdout(const std::string& file_name){return splic(file_name, ".stdout");}// 拼接保存標準錯誤文件static std::string Stderr(const std::string& file_name){return splic(file_name, ".stderr");}
-compile_server/runner.hpp 運行功能編寫
同理,我們還是需要創建子進程進行程序替換,父進程阻塞等待獲取子進程結果從而返回結果。需要注意資源限制函數,我們根據接口傳入的參數在子進程的環境下進行限制運行即可。
#ifndef __RUNNER_HPP__
#define __RUNNER_HPP__
// 運行模塊
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/resource.h>
#include "../common/util.hpp"
#include "../common/log.hpp"namespace ns_runner
{using namespace ns_util;using namespace ns_log;class Runner{public:Runner() {}~Runner() {}public:// 資源限制接口// cpu_limit時間限制s men_limit空間限制KBstatic void SetProCLimit(int cpu_limit, int mem_limit){// 時間限制struct rlimit cpuLimit;//rlimit 是一個結構體,用于描述資源限制。cpuLimit.rlim_max = RLIM_INFINITY;//rlim_max 是最大限制值,這里設置為無窮大(RLIM_INFINITY)。cpuLimit.rlim_cur = cpu_limit;//rlim_cur 是當前限制值,這里設置為傳入的 cpu_limit 參數(單位為秒)。setrlimit(RLIMIT_CPU, &cpuLimit);//setrlimit(RLIMIT_CPU, &cpuLimit) 調用系統調用 setrlimit,設置CPU時間限制。// 空間限制struct rlimit memLimit;//同樣使用 rlimit 結構體,設置內存空間限制。memLimit.rlim_max = RLIM_INFINITY;memLimit.rlim_cur = mem_limit * 1024; // byte->kb//mem_limit 的單位是KB,因此乘以1024轉換為字節。setrlimit(RLIMIT_AS, &memLimit);//RLIMIT_AS 表示地址空間限制,這里設置為傳入的 mem_limit 參數。}// 運行模塊// 輸入可執行文件名 在temp目錄下的.exe文件// 返回值:0表示運行成功、<0表示內部錯誤,>0表示運行出錯// 時間限制:cpu_limit 空間限制:men_limit// 返回值: <0 內部錯誤 =0運行成功,成功寫入stdout等文件 >0運行中斷,用戶代碼存在問題//定義了一個靜態方法 Run,用于運行指定的可執行文件,并設置資源限制。static int Run(const std::string& file_name, int cpu_limit, int mem_limit){// 在父進程中先將運行所需要的三個臨時文件打開//使用 open 系統調用打開三個文件://標準輸入文件(stdin):以只讀方式打開。//標準輸出文件(stdout):以寫入方式打開。//標準錯誤文件(stderr):以寫入方式打開。//PathUtil::Stdin(file_name) 等函數是工具函數,用于生成文件路徑。int _stdin = open(PathUtil::Stdin(file_name).c_str(), O_CREAT | O_RDONLY, 0644);int _stdout = open(PathUtil::Stdout(file_name).c_str(), O_CREAT | O_WRONLY, 0644);int _stderr = open(PathUtil::Stderr(file_name).c_str(), O_CREAT | O_WRONLY, 0644);// 打開文件進行差錯處理if (_stdin < 0 || _stdout < 0 || _stderr < 0){// 文件打不開運行程序沒有意義了LOG(ERROR) << "內部錯誤, 標準文件打開/創建失敗" << "\n";return -1;}//使用 fork 創建一個子進程。pid_t childPid = fork();if (childPid < 0){// 創建子進程失敗// 創建失敗,打開的文件需要收回-否則占用無效資源!close(_stdin);close(_stdout);close(_stderr);LOG(ERROR) << "內部錯誤, 創建子進程失敗" << "\n";return -2;}if (childPid == 0)//如果當前是子進程:{// 子進程// 資源限制SetProCLimit(cpu_limit, mem_limit);//調用 SetProCLimit 設置資源限制。//使用 dup2 將 _stdin、_stdout 和 _stderr 分別重定向到文件描述符 0、1 和 2。dup2(_stdin, 0);dup2(_stdout, 1);dup2(_stderr, 2);//使用 execl 執行可執行文件。如果執行失敗,調用 exit(-1) 退出。execl(PathUtil::Exe(file_name).c_str(), PathUtil::Exe(file_name).c_str(), nullptr); // execl函數,不從環境變量下找,直接根據路徑找可執行文件 路徑、可執行文件exit(-1); // 程序替換出錯}// 父進程close(_stdin);close(_stdout);close(_stderr);//父進程等待子進程,查看返回情況int status; //輸出參數,接收狀態信息waitpid(childPid, &status, 0); // 阻塞等待if (status < 0){//信號不等于0說明執行失敗LOG(ERROR) << "內部錯誤, execl參數或者路徑不正確" << status << "\n";return status;}else LOG(INFO) << "運行完畢!退出碼為: " << (status & 0x7F)<< "\n";return (status & 0x7F);}};
}#endif
這段代碼實現了一個簡單的進程運行器,能夠運行指定的可執行文件,并限制其CPU時間和內存空間。它還記錄運行過程中的日志信息,方便調試和監控。
compile_run編譯運行
上面將編譯和運行模塊分開了,正常流程則是先編譯后運行。那么我們需要將這兩個流程整合起來實現編譯運行模塊的后端工作。
想要編譯,那么我們就需要源文件,源文件從哪里來?用戶通過網絡發送而來。所以用戶發送過來就是前端序列化后的json串,反序列化后得到的數據寫入源文件中,交給編譯功能編譯后形成可執行文件然后交給運行功能運行,最后整合結果也向對方返回結果json串完成工作。
那么,上面的四個步驟就是compile_run編譯運行需要執行的步驟:
首先是前端序列化后的json串,我們需要規定其返回什么。一份源文件自然需要一份完整的代碼,如果用戶輸入的話還需要用戶的input數據。另外每個程序都需要對應的時間和空間限制。綜上,用戶返回的json串格式如下:
{"code": code,"input": input,"cpu_limit": S,"mem_limit": kb
}
(需要注意CPP實驗json庫在linux下需要sudo yum install jsoncpp-devel安裝json開發庫,并且在編譯選項中加上-ljsoncpp方可編譯)
反序列化得到數據后,我們需要將code部分寫入.cpp源文件中去。 寫入文件很簡單(利用C++的ofstream簡單IO即可),但是需要注意,之后此模塊的功能是被打包為網絡服務的。也就是說可能同時出現了很多用戶提交的代碼。如果此時名字沖突就會發生問題,不同用戶之間執行的不同題或者編程內容就會出現問題。
所以當一份用戶提交代碼后,我們為其生成的源文件名需要具有唯一性。名字生成唯一性我們可以利用毫秒級時間戳加上原子性的增長計數實現。
毫秒級時間戳可以利用gettimeofday函數調用實現(返回的結構體存在微秒級的屬性,簡單轉換就可以得到微秒),原子性的增長計數(同一時刻不同執行流調用-利用static的變量)利用C++11的特性atomic_uint即可實現。
-common/util.hpp 生成唯一文件名
獲取毫秒時間戳在TimeUtill工具類中,生成唯一文件名在FileUtil工具類中。
// 獲取毫秒級時間戳static std::string GetTimeMs(){struct timeval t;gettimeofday(&t, nullptr);return std::to_string((t.tv_sec * 1000 + t.tv_usec / 1000)); // 秒+微秒}//......// 生成唯一的文件名// 利用微秒時間戳和原子性的唯一增長數字組成static std::string UniqueFileName(){// 利用C++11的特性,生成一個原子性的計數器static std::atomic_uint id(0);//定義一個靜態的原子變量 id,初始值為 0。//std::atomic_uint 是 C++11 提供的原子類型,用于確保對變量的訪問和操作是線程安全的。id++;std::string ms = TimeUtil::GetTimeMs();return ms + "-" + std::to_string(id);}
1.獲取毫秒級時間戳解析:
struct timeval t;
定義一個 timeval 結構體變量 t,用于存儲時間信息。timeval 結構體包含兩個字段:
tv_sec:表示秒數。
tv_usec:表示微秒數(0 到 999999)。
gettimeofday(&t, nullptr);
調用 gettimeofday 函數,獲取當前時間并存儲到 t 中。
gettimeofday 的第一個參數是一個指向 timeval 的指針,第二個參數是一個指向 timezone 的指針(這里傳入 nullptr,表示不獲取時區信息)。
return std::to_string((t.tv_sec * 1000 + t.tv_usec / 1000));
計算當前時間的毫秒級時間戳:
t.tv_sec * 1000:將秒數轉換為毫秒。
t.tv_usec / 1000:將微秒數轉換為毫秒。
兩者相加得到當前時間的毫秒級時間戳。
使用 std::to_string 將結果轉換為字符串并返回。
2.生成唯一的文件名:
id++;
原子性地將 id 的值加 1。每次調用 UniqueFileName 時,id 都會遞增,從而保證生成的文件名是唯一的。
std::string ms = TimeUtil::GetTimeMs();
調用 GetTimeMs 函數獲取當前的毫秒級時間戳,并將其存儲到字符串變量 ms 中。
return ms + “-” + std::to_string(id);
將毫秒級時間戳 ms 和原子變量 id 的值拼接起來,中間用連字符 - 分隔。
返回拼接后的字符串,作為唯一的文件名。
-common/uti.hpp 寫入文件/讀出文件
現在能夠生成唯一文件名后,我們可以根據此路徑寫入文件中。為了方便,我們一并將寫入文件和讀取文件寫入util工具類中,方便項目文件進行調用。利用的就是C++的fstream類。
因為是和文件相關,所以也放入FileUtil工具類中。
// 根據路徑文件進行寫入static bool WriteFile(const std::string& path_file, const std::string& content){// 利用C++的文件流進行簡單的操作std::ofstream out(path_file);// 判斷此文件是否存在if (!out.is_open()) return false;out.write(content.c_str(), content.size());out.close();return true;}// 根據路徑文件進行讀出// 注意,默認每行的\\n是不進行保存的,需要保存請設置參數static std::string ReadFile(const std::string& path_file, bool keep = false){// 利用C++的文件流進行簡單的操作std::string content, line;std::ifstream in(path_file);if (!in.is_open()) return "";while (std::getline(in, line)){content += line;if (keep) content += "\n";}in.close();return content;}
現在已經能夠生成唯一名字的源文件了,我們利用此源文件執行編譯運行流程,最終將結果返回給用戶。需要注意,這個過程中仍然出現很多差錯,我們類似運行模塊那樣,首先將錯誤分為幾類,最后將這些轉換為描述發送出去。
因為運行報錯會返回>0的數(對應信號),所以其余錯誤我們均定為負數,方便后續的錯誤描述。首先,一開始用戶發送給我們的code可能為空,為空的話就沒有繼續執行下去的必要了,可以定義用戶錯誤。此外可能打開文件失敗,寫入文件失敗,以及運行模塊返回<0都是內部的錯誤,對外應該顯示為未知錯誤,之后編譯錯誤、正常返回即可。
狀態碼和狀態描述我們可以將其返回回去。另外,如果運行成功我們還需要將運行生成的stdout和stderr文件返回回去。所以,返回結果完整的json串如下:
{"status": status,"reason": reason,"stdout": stdout,"stderr": stderr
}
對于狀態碼描述我們可以單獨寫一個函數進行整合選擇,寫入json串中。另外因為每次編譯運行會產生很多的臨時文件(temp/),當這一切執行完后臨時文件就沒有意義了,就需要進行清理。刪除文件可以利用unlink接口進行刪除,需要注意其文件是否存在。
綜上,思路我們可以整理為如下圖:
-compile_server/compile_run.hpp 編譯運行整合模塊(重要)
#ifndef __COMPILE_RUN_HPP__
#define __COMPILE_RUN_HPP__
// 編譯運行整合模塊#include <unistd.h>
#include <jsoncpp/json/json.h>
#include "compiler.hpp"
#include "runner.hpp"
#include "../common/util.hpp"
#include "../common/log.hpp"namespace ns_compile_and_run
{using namespace ns_compiler;using namespace ns_runner;using namespace ns_log;using namespace ns_util;class CompileAndRun{public:// 刪除與指定文件名相關的臨時文件static void RemoveFile(const std::string& file_name){// 因為臨時文件的存在情況存在多種,刪除文件采用系統接口unlink,但是需要判斷std::string src_path = PathUtil::Src(file_name);//使用 PathUtil::Src(file_name) 獲取源代碼文件路徑。if (FileUtil::IsFileExist(src_path)) unlink(src_path.c_str());//使用 FileUtil::IsFileExist 檢查文件是否存在。//如果文件存在,調用 unlink 刪除文件。//類似地,代碼依次刪除了標準輸出文件、標準輸入文件、標準錯誤文件、編譯錯誤文件和可執行文件。std::string stdout_path = PathUtil::Stdout(file_name);if (FileUtil::IsFileExist(stdout_path)) unlink(stdout_path.c_str());std::string stdin_path = PathUtil::Stdin(file_name);if (FileUtil::IsFileExist(stdin_path)) unlink(stdin_path.c_str());std::string stderr_path = PathUtil::Stderr(file_name);if (FileUtil::IsFileExist(stderr_path)) unlink(stderr_path.c_str());std::string compilererr_path = PathUtil::CompileError(file_name);if (FileUtil::IsFileExist(compilererr_path)) unlink(compilererr_path.c_str());std::string exe_path = PathUtil::Exe(file_name);if (FileUtil::IsFileExist(exe_path)) unlink(exe_path.c_str());}// 根據不同的狀態碼進行解析結果輸入信息// > 0 運行中斷 6-內存超過范圍、24-cpu使用時間超時、8-浮點數溢出(除0異常)// = 0 運行成功// < 0 整個過程,非運行報錯(內部錯誤、編譯報錯、代碼為空)static std::string CodeToDesc(int status, const std::string& file_name){std::string desc;switch(status){case 0:desc = "運行成功!";break;case -1:desc = "代碼為空";break;case -2:desc = "未知錯誤";break;case -3:desc = "編譯報錯\n";desc += FileUtil::ReadFile(PathUtil::CompileError(file_name), true);//對于某些狀態碼(如 -3 編譯報錯),還會讀取編譯錯誤文件的內容并附加到描述信息中。break;case 6:desc = "內存超過范圍";break;case 24:desc = "時間超時";break;case 8:desc = "浮點數溢出";break;case 11:desc = "野指針錯誤";break;default:desc = "未處理的報錯-status為:" + std::to_string(status);break;}return desc;}/************************ 編譯運行模塊* 輸入:in_json輸入序列化串 *out_json 輸出序列化串(輸出參數)* 輸出* in_json {"code":..., "input":..., "cpu_limit":..., "mem_limit":...}* out_json {"status":..., "reason":...[, "stdout":..., "stderr":...]}***********************/static void Start(const std::string& in_json, std::string* out_json){std::string code, input, file_name;int cpu_limit, mem_limit; // s, kbint status, run_reason; // 狀態碼, 運行返回碼// 首先反序列化in_json,提取信息Json::Value in_value;Json::Reader read;read.parse(in_json, in_value); // 序列化可能出問題//使用 Json::Reader 將輸入的JSON字符串解析為 Json::Value 對象。如果解析失敗,可能會導致后續邏輯出錯。code = in_value["code"].asString();input = in_value["input"].asString();cpu_limit = in_value["cpu_limit"].asInt();mem_limit = in_value["mem_limit"].asInt();//從 Json::Value 對象中提取代碼、輸入數據、CPU限制和內存限制等信息。// 首先檢測code是否存在數據-如果代碼為空,設置狀態碼為 -1 并跳轉到結束標簽 END。if(code.empty()){status = -1; // 用戶錯誤goto END;}// 首先生成唯一的臨時文件名file_name = FileUtil::UniqueFileName();// code寫入文件if(!FileUtil::WriteFile(PathUtil::Src(file_name), code)){// 如果寫入失敗,內部報錯status = -2;goto END;}// 寫入文件成功的話,我們進行編譯步驟if (!Compiler::compile(file_name)){status = -3; // 編譯失敗,導入結果compile_errorgoto END;}// 將input標準輸入寫入文件if(!FileUtil::WriteFile(PathUtil::Stdin(file_name), input)){// 如果寫入失敗,內部報錯status = -2;goto END;}// 編譯步驟成功,我們進行運行步驟run_reason = Runner::Run(file_name, cpu_limit, mem_limit);if (run_reason < 0){// 內部錯誤status = -2;}else{// 運行成功或者中斷status = run_reason;}END:// 構建返回序列化串//使用 Json::Value 構建輸出JSON對象。//添加狀態碼和原因描述字段。Json::Value out_value;out_value["status"] = status;out_value["reason"] = CodeToDesc(status, file_name); // 需要接口// 如果運行成功,那么構建可選字段//如果運行成功,讀取標準輸出和標準錯誤文件的內容,并添加到輸出JSON對象中。if (status == 0){out_value["stdout"] = FileUtil::ReadFile(PathUtil::Stdout(file_name), true);out_value["stderr"] = FileUtil::ReadFile(PathUtil::Stderr(file_name), true);}//使用 Json::StyledWriter 將輸出JSON對象序列化為字符串,并存儲到 out_json 指針指向的變量中。Json::StyledWriter write;*out_json = write.write(out_value);//調用 RemoveFile 方法刪除所有臨時文件。RemoveFile(file_name);}};
}#endif
這段代碼實現了一個完整的編譯和運行流程,包括:
1.解析輸入JSON,提取代碼、輸入數據、資源限制等信息。
2.將代碼寫入文件并進行編譯。
3.如果編譯成功,運行生成的可執行文件,并根據運行結果設置狀態碼。
4.構建輸出JSON,包含運行狀態、原因描述以及運行結果(如果成功)。
5.刪除所有臨時文件,清理資源。
6.通過這種方式,代碼能夠高效地處理編譯和運行任務,并提供詳細的運行結果和錯誤信息。
compiler_server網絡服務
現在已經有了編譯服務這個模塊了,只需要打包成網絡服務就可以完成我們的目的了。
首先,自然可以寫套接字實現http網絡服務,服務器端綁定ip、端口,因為http是基于TCP的,所以需要設置監聽。最后利用多進程、多線程或者epoll高級IO模式(epoll)獲取每一個鏈接,接收json串(應用層處理數據粘包或者不全的問題),然后傳遞給編譯運行模塊執行結果后再通過http發送json串完成一次網絡通信。
首先可行,但是在項目中這么寫太多太麻煩了。就像我們利用C++的STL庫一樣,如果這套http網絡服務我們能直接使用就減輕了網絡服務編寫的負擔了。由于C++官方本身沒有提供網絡庫的相關庫,但是我們可以利用cpp-http第三方庫進行使用,方便我們的網絡服務的創建。
安裝cpp-httplib第三方庫以及升級GCC版本
在gitee搜索cpp-httplib 0.7.15版本,標簽進行查找版本。(點此鏈接直接訪問:cpp-httplib: cpp-httplib - Gitee.com)
下載上傳后,找到httplib.h文件拷貝到common目錄下即可。
但是編譯cpp-httplib需要更高版本的GCC,我們需要進行升級。
// 安裝sclsudo yum install centos-release-scl scl-utils-build// 安裝新版本GCCsudo yum install -y devtoolset-9-gcc devtoolset-9-gcc-c++ls /opt/rh// 啟動 只有本次會話有效scl enable devtoolset-9 bashgcc -v// 可選,每次登錄都是較新的GCC,添加到~/.bash_profile中 自己的家目錄scl enable devtoolset-9 bash 注意:升級到789都可以,換版本只需要將上面的數字進行替換即可。
另外,httplib是一個阻塞式多線程原生庫,需要引入原生線程庫 -lpthread;
現在我們可以利用httplib庫將compile_run打包為一個網絡編譯運行服務。
httplib存在Server服務器對象,其中存在Post和Get方法分別對應客戶端向服務器發送請求報文中請求首行的執行方法。其中Get可以時獲取資源,Post則是提交資源…
我們利用此方法,根據兩個(一個const屬性的請求Request,另一個輸出型的Response)參數的函數對象,進行返回結果(構建Response的正文和響應報頭)。對方提交json串的時候自然使用是Post方法,利用其獲取json(請求正文),調用編譯運行服務構建響應json,再寫入響應報文中(類型 + 正文),完成compile_run路由設置,最后綁定ip(0.0.0.0),端口(需要設置命令行參數,端口不可定死,并且后續存在編譯服務器的配置文件,為上層oj_server靈活的提供主機選擇)從命令行參數設置,cpp-httplib使用listen方法執行即可。
-compile_server/compile_server.cpp 編譯運行網絡服務
這段代碼實現了一個簡單的HTTP服務器,提供以下功能:
- 通過 /hello 路徑返回一條歡迎信息。
- 通過 /compile_and_run 路徑接收POST請求,處理JSON格式的編譯和運行請求。
- 使用 CompileAndRun 類執行編譯和運行操作,并返回結果。
- 通過命令行參數指定服務器的監聽端口。
通過這種方式,代碼將編譯和運行功能封裝為一個網絡服務,便于與其他系統集成。
#include "compile_run.hpp"
//包含了 compile_run.hpp,這是之前定義的編譯和運行模塊的頭文件。
#include "../common/httplib.h"
//包含了 httplib.h,這是用于創建HTTP服務器的庫。using namespace ns_compile_and_run;
using namespace httplib;//定義了一個函數 User,用于打印程序的使用說明。它提示用戶程序需要一個端口號作為參數。
void User(char * use)
{std::cout << "User:" << "\n\t";std::cout << use << " port" << std::endl;
}int main(int argc, char* argv[])
{if (argc != 2){// 滿足傳遞 ./ portUser(argv[0]);return 1;}// 將我們的編譯運行服務打包為網絡服務 - 為負載均衡做準備//創建了一個 Server 對象 svr,用于處理HTTP請求。//注冊了一個GET請求處理器,當訪問 /hello 路徑時,返回一條歡迎信息。//使用 resp.set_content 設置響應的內容和內容類型。Server svr;svr.Get("/hello", [](const Request& req, Response& resp){resp.set_content("你好呀,這里是編譯運行服務,訪問服務請訪問資源/compile_and_run", "text/plain;charset=utf-8");}); // 請求資源//注冊了一個POST請求處理器,當訪問 /compile_and_run 路徑時,處理編譯和運行請求。svr.Post("/compile_and_run", [](const Request& req, Response& resp){// 首先提取用戶提交的json串std::string in_json = req.body;std::string out_json;// 判斷json串是空的嗎?空的拒絕服務//如果 in_json 不為空,調用 CompileAndRun::Start 方法執行編譯和運行操作,并將結果存儲到 out_json 中。if(!in_json.empty())CompileAndRun::Start(in_json, &out_json);// 返回out_json串給客戶端//使用 resp.set_content 設置響應的內容為 out_json,并指定內容類型為 application/jsonresp.set_content(out_json, "application/json;charset=utf-8");}); // 提交json串,返回json串// 注冊運行//調用 svr.listen 方法啟動服務器,監聽所有網絡接口(0.0.0.0)。//使用 atoi(argv[1]) 將命令行參數中的端口號轉換為整數。svr.listen("0.0.0.0", atoi(argv[1]));return 0;
}
注意響應正文對應的響應報頭中寫的類型(ConnectType)可以參考此網站進行對照:HTTP 響應類型 ContentType 對照表 - 愛碼網