mini服務器簡介
mini服務器功能
1.實現了GET和POST方法的HTTP request和HTTP respond的構建和發送,使服務器可以完成基本通信功能。
2.使用了線程池技術,使服務器可以一次接收更多的鏈接和加快了服務器處理數據的速度。
3.實現了簡易的CGI,使得服務器可以完成接收表單提交的數據,然后返回后臺處理好的數據給用戶。
4.cgi功能分別實現了簡易計算器,使用MySQL往database插入數據兩個功能。
5.實現了簡易的錯誤處理,比如若訪問的資源不存在,可以返回404 NOT FOUND
mini服務器技術棧關鍵字
- Linux系統編程
- Linux網絡編程
- HTTP協議和TCP協議
- CGI
mini服務器的大體實現邏輯
圖片描述:
文字描述:
1.首先客戶端(瀏覽器)和我們的server連接了,監聽套接字accept到了之后創建了一個新的socket,用于數據傳輸。socket里面有兩個重要成員,一個是sk_write_queue,一個是sk_receive_queue。從socket里面讀數據本質是從sk_receive_queue里面讀數據。寫數據進socket本質上是寫進sk_write_queue里面。總結來說就是用戶層從內核中讀和寫數據,內核中負責幫我們把數據發出去。(要有這個意識,后面寫代碼要關注這個問題)
2.拿到新的socket的文件描述符之后,把它丟到ThreadPool里面,具體處理的如何,HTTPServer已經不關心了,HTTPServer只負責監聽到連接,然后把對應的socket的fd丟到ThreadPool里面。(解耦)
3.處理手段就是三個:1.接收HTTP請求報文 2.構建HTTP響應報文 3.發送HTTP響應報文。在這中途,要抽空判斷一下是否需要去調用CGI程序。等到HTTP響應報文成功寫入到了socket的發送隊列中時,整一套流程就結束了。
mini服務器各部分的實現邏輯
總共有幾部分:
- socket套接字接口的封裝
- TCP server和HTTP server封裝
- ThreadPool
- LOG類
socket套接字接口的封裝
服務端標準的一套流程:
- socket
- setsockopt
- bind
- listen
- accept
這里重點講一個setsockopt。有可能這個mini服務器會因為連接過多然后崩掉(很正常,沒有用epoll很容易崩)。服務器崩潰就意味著服務器成了先退出的那一方。那么服務器最后就會進入到TIME_WAIT狀態等待2MSL。這段時間內由于內核的一些數據結構還沒有被清除(因為沒有進入closed狀態),無法對同一個端口進行重復綁定。這樣會讓服務器在一定時間內跑不起來(bind error),很不方便調試。因此要使用setsockopt來避免這個問題。
這是setsockopt函數說明
int setsockopt(int sockfd, int level int optname, const void* optval, socklen_t optlen)
-
框框那一句話翻譯一下就是:操作套接字選項時,必須指定選項所在的級別和選項的名稱。 要在套接字 API 級別操作選項,請將級別指定為SOL_SOCKET。
-
選項我們設置為SO_REUSEADDR,這個可以讓我們對同一個端口進行重復綁定。
-
這是關于optval和optlen的說明:
? 谷歌翻譯一下就是:大多數套接字級選項都使用 int 參數作為optval。 對于 setsockopt(),參數應為非零以 啟用布爾選項,如果要禁用該選項,則參數應為零
因此參數這么填:
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof opt);
TCP server和HTTP server封裝
TCP負責socket,bind,listen。
HTTP server負責accept,并且把accept到的socket fd放進線程池當中。
具體代碼看tcpServer.hpp && HTTP server.hpp
ThreadPool 封裝
ThreadPool既是一個生產消費模型。HTTP server充當生產者,后面處理服務的EndPoint(我起名叫EndPoint,叫其他也可以)充當消費者。那么這個線程池就是一個臨界資源了。因此就要實現同步機制了。
這里的同步機制采用條件變量+互斥鎖來實現。生產者消費者模型用阻塞隊列的模式來完成。這種模式可以減輕代碼編寫的負擔。但效率并不高。若想提高效率使用環形隊列+信號量來完成。
下面是項目中實現threadpool的代碼,使用的是C++11的特性:
class threadPool
{public:mutex mtx;condition_variable cv;size_t cap;queue<Task> q;threadPool(){cap = 5;vector<thread> v(cap);for(int i = 0; i < 5; i++){v[i] = thread([&]{while(true){Task t;get(t);t.handler();}});}for(int i = 0; i < 5; i++) v[i].detach();}void put(Task& task){unique_lock<mutex> lck(mtx);cv.wait(lck, [&]{return q.size() < cap;});q.push(task);cv.notify_one();}void get(Task& task){unique_lock<mutex> lck(mtx);cv.wait(lck, [&]{return q.size() > 0;});task = q.front();q.pop();}
};
與下面使用POSIX庫的代碼效果是一樣:
class threadPool
{public:pthread_mutex_t lock;pthread_cond_t cond;size_t cap;queue<Task> q;threadPool(){cap = 5;pthread_mutex_init(&lock, nullptr);pthread_cond_init(&cond, nullptr);for(size_t i = 0; i < cap; i++){pthread_t tid;pthread_create(&tid, nullptr, routine, this);}}~threadPool(){pthread_mutex_destroy(&lock);pthread_cond_destroy(&cond);}static void* routine(void* arg){threadPool* threadpool = (threadPool*)arg;while(true){Task t;threadpool->get(t);t.handler();}}void put(Task task){pthread_mutex_lock(&lock);while(q.size() > cap) pthread_cond_wait(&cond, &lock);q.push(task);pthread_cond_signal(&cond);pthread_mutex_unlock(&lock);}void get(Task& task){pthread_mutex_lock(&lock);while(q.size() <= 0) pthread_cond_wait(&cond, &lock);task = q.front();q.pop();pthread_mutex_unlock(&lock);}
};
注: 這里的同步機制保證的是:當沒有連接到來的時候,thread全部在等待,一旦HTTP server把連接放進來的時候,就會喚醒其中一個線程,然后讓他去處理這個連接的情況。處理完后無需再次喚醒生產者了,生產者是客戶端連接還會有socket產生的,沒有就算了。
與HTTP協議有關的函數封裝
里面寫了關于接收HTTP報文,解析HTTP報文,判斷是否需要CGI,構建HTTP響應,發送HTTP報文等一系列函數,后面再說。
日志類LOG
為了方便調試,寫了一個簡單的日志類。該日志類可以打印出消息,代碼對應的行數,代碼所在的文件。
由于要經常和HTTP報文里面的信息打交道,因此最好可以把它們都打印出來,那樣就可以驗證是否成功的做到了一些事情。
代碼很短,可以貼在這看一下。
由于這個log函數每次都要輸入四個參數,太麻煩了,因此用一個LOG宏來封裝一下,文件名和行數可以用系統給的宏__FILE__和__LINE__來獲取
#define LOG(info, msg) log(info, msg, __FILE__, __LINE__)void log(const char* info, const char* msg, const char* file, int line)
{int t = time(nullptr);printf("[%s][%d][%s][%s][%d]\n", info, t, msg, file, line);
}
HTTP請求和響應解釋(與項目有關的點)
HTTP請求
HTTP請求由以下幾點組成如下:
- 請求行(請求方法只實現了GET和POST,因此關注這兩個即可)
- 請求報頭
- 空行
- 正文(可有可無,看具體的請求方法)
ps:每一行都要以\r\n結尾,這是用來解決TCP粘包問題的。TCP只保證按序到達,但是沒有對一個明確的包給定邊界(面向字節流),因此HTTP要用\r\n來做定界符。讀到\r\n就代表這一行讀完了。繼續往下讀就是下一行的內容了。
fiddle抓包結果:
請求行
url和uri
我自己說的不嚴謹,以下說法參考**《圖解HTTP》**
url就是往瀏覽器輸入的網址。url表示資源的地點。比如
uri表示某一個互聯網上的資源,這是一個比url更詳細的信息。比如:
uri會比url多一個斜杠,這個斜杠代表是web根目錄,一般請求web根目錄資源,服務器會返回當前目錄的主頁給用戶。也就是說uri指定了我要訪問baidu的首頁的html文件,而url并沒有指定說我要訪問什么文件。
事實上,當我們回車www.baidu.com之后,瀏覽器會自動幫我們補上一個/,用fiddle抓包就可以看出來這個效果了。
總結一下:url是uri的子集。url就好像今天我要去你家,要干嘛不知道。uri就好像我今天要去你家吃飯,有具體的事情。
uri也分為絕對uri和相對uri。
指定了所有所有信息的叫絕對uri。比如https://www.baidu.com/,這就是絕對uri
絕對的uri格式如下:
http://user:pass@www.example.cn:80/dir/index.html?uid=1#ch1
-
http是協議方案,可以是ftp等等
-
user:pass是登陸信息,一般不填。
-
www.example.cn是服務器地址,必須要填的,要么是DNS可解析的名稱,要么是ip地址
-
80是端口號,由于一般的服務器都是使用http協議的,http協議端口是80(https是443),因此一般不用填。
-
/dir/index.html就是用戶想要訪問的文件了
-
?后面接的是參數,可以不傳參數。是可選的
-
#這個一般不用。
相對uri是相對于服務器的根目錄的 , 若在百度服務器上了, /就等價于上面的絕對uri所代表的信息。怎么設置取決于客戶端。一般都是使用相對uri。(fiddle上面會把相對uri給補全成絕對uri,但我們實現的時候使用相對的uri)
請求方法
方法是用來告知服務器意圖的。不同的方法代表著客戶有著不同的意圖。
GET方法:用來請求訪問網站資源的。如果請求的是文本資源,服務器就把這個文本傳給你。如果請求的是CGI程序,那么就返回執行后的結果。
例如:GET /index.html HTTP/1.1服務器看到之后,就會把web root的index.html(主頁)返回給用戶
POST方法:POST方法也是用來請求網站資源的,但是POST和GET有點不同。如果url當中有參數需要傳入,POST方法會把參數以正文的方式傳輸,而GET方法會把參數放在url當中。
像下面這種就是用GET方法的uri,因為參數都放在uri上面了。可以用fiddle抓包驗證一下。
https://cn.bing.com/search?mkt=zh-cn&pc=LVBT&form=CNTP59&ensearch=0&q=www.baidu.com
如果像輸入賬號密碼這種隱私一點的信息的時候,參數肯定不能放在uri里面,用POST方法可以更加隱私一點,因為POST方法可以用正文傳參。
大部分web服務器對于這兩種方法的使用策略是:
- POST用來請求動態資源,不允許請求靜態資源
- GET即可以用來請求靜態資源也可以請求動態資源
那我們也這么實現。
其他
由于http request是瀏覽器發送的,因此我們不需要了解那么多。了解到這里就足夠寫項目了。
HTTP回應
HTTP回應由幾個部分組成
- 狀態行
- 回應報頭。重點關注Content-Type。Content-Type的意思是正文的文件類型和Content-Length是正文的大小,還是為了解決tcp粘包問題而出現的。有了Content-Type,我們就不會少讀也不會多讀一個字節。(這兩個在項目中要使用到)
- 空行
- 正文
可以看到返回來的是一個text/html,也就是文本類型,編碼方式是utf8.正是百度服務器向瀏覽器發送了這個HTTP respond,瀏覽器把正文部分的html文件解析成了一張頁面。
html文件被瀏覽器解析后就成了這張網頁。
狀態碼
在狀態行中有一個字段是狀態碼。狀態碼決定了這次網絡通信的狀態。
1xx 接收的請求正在處理
2xx 成功狀態碼
3xx 重定向
4xx 客戶端錯誤狀態碼
5xx 服務器錯誤狀態碼
了解404和200即可,這個項目并沒有返回其他的狀態碼。404就是客戶端訪問的資源不存在,200就是成功返回的意思。
協議類的實現
里面的功能包括
- 接收HTTP請求
- 解析HTTP請求報文
- 構建HTTP響應報文
- 發送HTTP響應報文
接收HTTP請求
總共有這么幾個函數,最重要的是recvRequestLine,recvRequestHeader,recvRequestBody。
void recvRequest();
bool hasBody();
bool recvRequestLine();
bool recvRequestHeader();
bool recvRequestBody();
recvRequestLine
請求行只有一行,直接讀一行就好了。思路非常簡單,問題來了,怎么從sk_receive_queue里面讀一行呢?
我們之前講了,為了解決粘包問題,http加入了\r\n的定界符。因此我們一個一個字符讀,如果讀到了\n,就停下來。
由于讀一行這個操作使用頻率很高,因此封裝成一個函數ReadLine
實現如下:
static int ReadLine(int sock, string& str)
{char ch = '0';while(ch != '\n'){ssize_t s = recv(sock, &ch, 1, 0);if(s > 0){if(ch == '\r'){recv(sock, &ch, 1, MSG_PEEK);if(ch == '\n') recv(sock, &ch, 1, 0);else ch = '\n';}str += ch;}else if(s == 0) {LOG("INFO", "client quit");return -1;}else {LOG("WARNING", "ReadLine recv error"); return -1;}}return str.size();}
這里實現的時候多考慮的一些情況。由于HTTP并沒有嚴格的說明定界符一定是\r\n,有可能定界符是\r,也有可能定界符是\n.因此這里處理的時候多考慮了這兩種不標準的情況。
因此總體思路就是:
-
如果讀到\r,就去探測一下緩沖區(也就是sk_receive_queue里面還未讀取的第一個字符),如果這個字符是\n,那么就讀它出來,此時\r\n都讀到了,那么ReadLine完成。
-
如果下一個字符不是\n,那么證明定界符是\r,下一個字符也不讀了。我們手動為它添加一個\n,這樣最終也變成了\r\n結尾的了。(不添加也可以,那樣就是\r\r結尾的了,不過只要解決了粘包問題,怎么樣都可以,畢竟是mini server)
-
如果\n是定界符的話,那么讀到\n就直接退出循環了。
recv的第四個選項是一個flags,填入MSG_PEEK就可以窺探緩沖區的下一個字符。且不讀出來。文檔中描述是return data from the beginning of the receive queue without removing that data,也就是窺探的意思了
recvRequestHeader
讀請求報頭思路也非常簡單,直接一行一行讀。直到讀到空行即代表所有請求報頭都被讀完了。讀到空行就跳出循環即可。ps:這里把空行也讀了出來的,因此下一步就是讀正文了
實現如下:
bool EndPoint::recvRequestHeader()
{string s;while(true){s.clear();if(Util::ReadLine(sock, s) < 0){stop = true;break;}if(s == "\n") break;s.pop_back();//方便打印,帶著endl不方便打印request->request_header.push_back(s);LOG("INFO", s.c_str());}return stop;
}
hasBody
這里寫的很暴力,只要是GET方法默認它沒有body了(畢竟大部分GET都沒有body)。
因此判斷它是否有body就變成了判斷它的請求方法,如果是GET就默認沒有body,如果是POST就默認它有正文,即使沒有也沒關系,因為Content-Length是0,不會越界讀到下一個包的內容的。
bool EndPoint::hasBody()
{if(request->method == "POST"){request->Content_Length = request->header["Content-Length"];return true;}return false;
}
recvRequestBody
這個和上面的思路都一樣,就是一個一個字節讀,把Content-Length全部讀完即可。
代碼不貼了,詳見源文件。
解析HTTP請求
parseRequestLine
解析請求行的主要目的是為了拿出請求方法 uri,當然可以順便把http版本拿出來,但是這個項目用不上這個字段。
由于請求行是以空格分開三個字段的,因此使用stringstream很容易就可以把它們提取出來。
void EndPoint::parseRequestLine()
{string &method = request->method, &uri = request->uri, &version = request->version;stringstream ss;ss << request->request_line;ss >> method >> uri >> version;for(size_t i = 0; i < method.size(); i++) method[i] = toupper(method[i]); //處理method輸入大小寫問題
}
parseRequestHeader
請求報頭的每一行都是 xxx: xxxx這樣的格式,因此找到冒號就可以把請求報頭的信息解析出來了。
這里選擇使用map來存儲,冒號前作為key,冒號后作為val。
void EndPoint::parseRequestHeader()
{for(size_t i = 0; i < request->request_header.size(); i++){string s = request->request_header[i];int pos = s.find(": ");request->header.insert({s.substr(0, pos), s.substr(pos + 2)}); }
}
parsePathAndArgs
之前說過,中間的uri是會包含文件路徑和參數的(參數是可選的)。因此我們還要對uri進行解析,拿到路徑和參數。
由于我們使用的是相對uri,因此只要沒有參數,相對uri就是我們需要的文件路徑。因此我們只需要找到?所在的位置,?前面就是文件路徑,?后面就是參數。
void EndPoint::parsePathAndArgs()
{size_t pos = request->uri.find('?');if(pos == string::npos) request->path = request->uri;else{request->path = request->uri.substr(0, pos);request->args = request->uri.substr(pos + 1);}
}
構建HTTP響應
總共有幾個函數
- buildRespond**(通過uri知道用戶需要什么資源并為用戶準備好這些資源)**
- buildOKRespond
- build404Respond
buildRespond
文件路徑總共有這么些情況
-
請求根目錄,也就是uri為/。 eg.
GET / HTTP/1.1
-
請求的資源仍然是一個目錄,但不是根目錄。 eg.
GET /dir HTTP/1.1
-
請求的是一個靜態的資源(不是目錄) eg.
GET /index.html HTTP/1.1
-
請求的是一個cgi程序 eg.
GET /cgi.py HTTP/1.1
這四種需求我們都要實現。
在這里做個約定,GET方法實現這四種情況,POST方法只實現可以請求cgi程序的功能。原因在上面介紹請求方法的時候說過了。
GET請求根目錄
題外話:
其實這里不僅僅處理了根目錄,還處理了另外一種情況。
我們知道linux里面
/home/user/dir/
和/home/user/dir
這兩種寫法是等價的,都表示的是dir目錄.因此只要最后一個符號是’/',要么它是根目錄,要么它采用的寫法是第一種寫法。不想那么多也是可以的,基本不會在非根目錄后面加上這一個斜杠
如果最后一個字符是’/',那么請求的就是目錄文件。
因此我們要返回對應的首頁。并且我們要填上最重要的一個字段Content-Length。Content-Length的大小就是首頁的大小。那么如何在程序中拿到一個文件的大小是多少呢?
使用stat函數。stat函數讓你傳一個輸出型參數,拿到對應的文件狀態stat,stat里面有一個字段是文件的大小st_size.
最后把狀態碼也給附上,由于成功構建respond了,因此狀態碼附上200。
代碼實現如下:
if((request->path[request->path.size() - 1]) == '/')
{//目錄,返回主頁request->path += Home_Page;path += request->path;request->path = path;struct stat buf;stat(request->path.c_str(), &buf);//該目錄下index.html的大小,是respond的正文大小respond->size = buf.st_size;respond->status_code = OK;
}
GET請求目錄
如果訪問的路徑存在且是目錄,那么還是直接返回一張主頁,和GET請求根目錄思路是一樣的。
這里講一下如何使用stat判斷路徑是否存在和如何用stat判斷該文件是否為目錄。
stat有返回值,存在該路徑返回0,否則返回-1
使用stat里的st_mode字段,并把它傳入S_ISDIR這個宏里面就可以了。
if(stat(request->path.c_str(), &buf) == 0)//如果路徑存在
{if(S_ISDIR(buf.st_mode))//如果這個路徑還是目錄{//返回主頁,代碼和上面一致}
}
GET請求靜態資源
GET方法只要沒有傳參就是請求的靜態資源了。因此我們的判斷標準是:uri有沒有?(或者直接看parsePathAndArgs有沒有解析到參數),沒有就證明是靜態資源了。反之,有參數就正常請求的是cgi程序了。
size_t pos = request->uri.find('?');
if(pos == string::npos)
{respond->size = buf.st_size;//資源的大小,也就是respond正文的大小respond->status_code = OK;
}
GET請求動態資源
剩下最后一種情況,留到后面講cgi的時候再講。這里可以給他打個標記,cgi = true。代表訪問的資源是cgi程序
request->cgi = true;
POST請求動態資源
要講這個就要先知道表單提交的概念。下面這種框框輸入的就是表單提交。
也就是說,一旦用戶輸入完成并點擊提交按鈕之后,瀏覽器會根據這個主頁的form action和method自動生成一個url,然后發送http請求給服務器。
我們可以看一下瀏覽器生成的url是什么。可以看到點擊提交之后,瀏覽器自動幫我們訪問這個cgi程序(cgi程序名字叫mysql_conn)
上面說一堆就是為了說明一個東西:按點擊之后瀏覽器會幫你自動生成一個新的url,然后發送給服務器。
好了,現在回到服務器視角,那么POST請求動態資源的處理也變得很簡單了,和GET請求動態資源的方法沒有區別。
這里說一下這個request->path是什么?指的是在服務器上cgi程序所在的文件路徑。
request->cgi = true;
string root = Web_Root;
string path = root + request->uri;
request->path = path;
respond->status_code = OK;
至此,所有資源的準備工作都已經做好了,可以準備構建真正的respond了。
buildOKRespond
由于respond分為兩種,一種是經過cgi后的respond,一種是沒有經過cgi之后的respond。因此也得分情況討論。
經過cgi后的respond,處理的極其粗暴,直接返回一張html的大字報。沒有做任何處理。(因為不會html)
沒有經過cgi的respond,就直接返回對應的資源即可。這種操作挺頻繁的,可以封裝成一個函數processNonCgi
void EndPoint::buildOKRespond()
{//bug, cgi傳回來的respond_body的類型不知道,因為目前cgi返回的都是html,因此暫時也不需要這個功能。if(request->cgi){respond->version = request->version;respond->status_line = respond->version + ' ' + to_string(respond->status_code) + ' ' + respond->codeDesc + "\r\n";respond->respond_header.push_back("Content-Length: " + to_string(respond->respond_body.size()) + "\r\n");respond->respond_header.push_back("Content-Type: text/html\r\n");}else//非cgi{processNonCgi(200); }
}
build404Respond
直接返回一張404的html文件即可。剩下的http respond字段根據需要自行填充即可。
void EndPoint::build404Respond()
{respond->version = request->version;respond->status_line = respond->version + ' ' + to_string(respond->status_code) + ' ' + code2Desc(respond->status_code) + "\r\n";struct stat st;request->path = Web_Root;request->path += "/404_NOT_FOUND.html";stat(request->path.c_str(), &st);respond->size = st.st_size;respond->respond_header.push_back("Content-Length: " + to_string(respond->size) + "\r\n");respond->respond_header.push_back("Content-Type: text.html\r\n");request->cgi = false;
}
processNonCgi
要做的事情有以下幾個
- 把Content-Type設置好
- 把Content-Length設置好
Content-Length和之前一樣,用stat.st_size即可,Content-Type要說明一下。
可以看到,資源最后是有一個.jpg的,說明這個東西的格式是jpg。不同的資源有著不同的文件格式,因此Content-Type也不同,我們要根據uri的文件后綴來設置Content-Type。
但是請注意,.jpg文件的Content-Type并不是.jpg,這是有http協議規定的。詳情請看
比如:jpg的Content-Type是application/x-jpg
我們把常用的設置一下即可。
map<string, string> suffixDesc = { {".html", "text/html"}, {".css", "text/css"}, {".js", "application/javascript"}, {".jpg", "application/x-jpg"}, {".xml", "application/xml"}};
為了方便,寫一個后綴轉Content-Type的函數。
如果map里面沒有這個文件格式,那么就返回text/html回去。(其實這么做在這個項目里面沒什么意義)
string& suffix2Desc(const string& suffix)
{if(suffixDesc.find(suffix) != suffixDesc.end()) return suffixDesc[suffix];else return "text/html";
}
剩下的工作就是填字符串了,非常簡單。
void EndPoint::processNonCgi(int code)
{respond->version = request->version;respond->status_code = code;respond->codeDesc = code2Desc(200);respond->status_line = respond->version + ' ' + to_string(respond->status_code) + ' ' + respond->codeDesc + "\r\n";size_t pos = request->path.rfind('.');//找后綴if(pos != string::npos) respond->suffix = request->path.substr(pos);else respond->suffix = ".html";respond->respond_header.push_back("Content-Length: " + to_string(respond->size) + "\r\n");respond->respond_header.push_back("Content-Type: " + suffix2Desc(respond->suffix) + "\r\n");//respond_body在sendRespond那里直接發送出去,不在這處理了
}
發送HTTP響應
發送HTTP響應要干的幾件事
- 發送狀態行
- 發送響應報頭
- 發送空行
- 發送正文
狀態行,響應報頭全部都在構建HTTP響應的時候構建好了,直接發送即可。處理一下正文即可。
若請求的是靜態資源,那么正文就是對應的文件。常規思路是把一個文件先讀到用戶的緩沖區,然后再從用戶的緩沖區寫進socket文件里面(sk_write_queue),但是這樣寫起來麻煩且效率低,因此這樣就涉及從內核到用戶的轉換了。
因此使用接口sendfile,sendfile可以直接從內核當中把文件進行拷貝。
sendfile(sock, fd, 0, respond->size);
sock是目標文件的fd,fd是源文件的fd,0是源文件開始拷貝的偏移量,respond->size是拷貝的字節數量。
代碼實現:
int fd = open(request->path.c_str(), O_RDONLY);
send(sock, respond->status_line.c_str(), respond->status_line.size(), 0);
for(size_t i = 0; i < respond->respond_header.size(); i++)
{string s = respond->respond_header[i];send(sock, s.c_str(), s.size(), 0);
}
send(sock, respond->blank.c_str(), respond->blank.size(), 0);
sendfile(sock, fd, 0, respond->size);
close(fd);
若請求的是cgi程序,處理cgi的時候已經把正文處理好了,因此全部一次性發送即可。
send(sock, respond->status_line.c_str(), respond->status_line.size(), 0);
for(size_t i = 0; i < respond->respond_header.size(); i++)
{string s = respond->respond_header[i];send(sock, s.c_str(), s.size(), 0);
}
send(sock, respond->blank.c_str(), respond->blank.size(), 0);
send(sock, respond->respond_body.c_str(), respond->respond_body.size(), 0);
至此,接收,解析,構建,發送都完成了,所有架構也完成了,唯一剩下一個cgi要講了。
CGI邏輯實現
cgi的原理就是http server創建一個子進程,然后讓子進程exec變成對應的cgi程序,然后執行完再將結果返回給http server。
圖示:
道理很簡單,但是涉及的知識點比較多。有管道,exec,環境變量,最重要的是對底層結構的熟悉程度。
這次先貼代碼,然后按代碼解釋各個細節原理。
int EndPoint::processCgi()
{int code = 200;int output[2];int input[2];if(pipe(output) == -1){LOG("ERROR", "output pipe create error");code = 404;exit(1);}if(pipe(input) == -1){LOG("ERROR", "input pipe create error");code = 404;exit(1);}//千萬不要一創建好管道就開始關閉fd,fork之后才能關string env_method = "METHOD=" + request->method;string env_content_length = "CONTENT_LENGTH=" + request->Content_Length;putenv((char*)env_method.c_str());//為了cgi程序可以知道如何處理,傳入方法環境變量putenv((char*)env_content_length.c_str());//加入正文長度的環境變量給cgi程序,方便它讀取正文if(fork() == 0){//childclose(output[1]), close(input[0]);//關閉子進程的無用fddup2(output[0], 0), dup2(input[1], 1);//重定向,原因替換之后原來的fd數據就消失了if(request->method == "GET"){string env_args = "ARGS=" + request->args;putenv((char*)env_args.c_str());}//cerr << request->path << __LINE__ << endl;if(execl(request->path.c_str(), nullptr) < 0){cerr << "execl error" << endl;}//程序替換成cgi程序}else{close(output[0]), close(input[1]);//關閉httpServer(父進程)的兩個無用fdif(request->method == "POST"){write(output[1], request->request_body.c_str(), request->request_body.size());}char ch;while(read(input[0], &ch, 1) > 0) respond->respond_body.push_back(ch); int st;waitpid(-1, &st, 0);if(WIFEXITED(st)) code = 200;else code = 404;}return code;
}
環境變量的導入
總共有三個東西要導入:
- 參數
- 方法
- Content-Length
對于GET方法來說:GET方法的參數是在uri上的,是比較短的,因此可以讓父進程(http server)通過導入環境變量使子進程(cgi)獲得參數。
對于POST方法來說:由于參數在正文當中,有可能很長,因此不采用環境變量,采用的方式是父進程(http server)用管道寫,子進程(cgi)用管道讀。(這部分不寫了,看代碼應該沒問題)
對于POST方法,Content-Length是必須要導入的,GET如果沒有正文也可以不需要Content-Length。實現的時候全部都導入了。
導入環境變量用putenv這個函數,使用很簡單。
函數聲明:
int putenv(char *string);
這里說個題外話,環境變量是可以繼承的,為什么呢?
原因就是環境變量也存在進程的虛擬進程空間mm_struct當中。如圖:
fork之后,子進程“繼承”了父進程的大部分沒有經過修改的數據。因此子進程可以獲得父進程的環境變量。
管道
管道是單向通信的,因為我們這里要http server和cgi雙向通信,因此創建兩個管道。一個叫output,一個叫input。命名都是站在http server的角度來講的,output管道用來給http server寫東西,input管道用于給http讀數據。
pipe(output);
pipe(input);
然后fork之后父子進程各解開自己不需要的文件描述符。(切記不要fork前就解綁了,這樣子進程和管道的關系就不正確了)
對于http server來說,output[0]是不需要的,input[1]也是不需要的。
對于cgi來說,output[1]是不需要的,input[0]是不需要的.
解綁后如圖示:
重定向
子進程需要對兩個管道的fd重定向。
dup2(output[0], 0), dup2(input[1], 1);
解釋一下原因:**exec族會把虛擬進程空間的棧,堆,bss segment, data segment , text segment全部清空,換成要換的程序。**然而output[0]是在棧上的一個數組,一旦exec之后,output和input全部都已經消失了,我們無法用output[0]的fd和input[1]的fd來操作管道了。
這里強調一下,是fd沒了,而不是文件描述符表里面存的文件沒了。本質上子進程的文件描述符表里面的某一個下標中還是存著這個管道的,只是我們無法拿到這個文件描述符了。
如果重定向成0和1,那么雖然output[0]和input[1]拿不到了,但是我們通過0和1這個文件描述符就可以訪問到對應的管道了。
題外話:為什么exec不會把文件描述符表給替換掉?其實想多一點就能想到這個問題。
原因是文件描述符表并不存在于mm_struct,而存在于task_struct.
execl
剩下的工作就是用exec族函數替換成cgi程序,這里采用execl。
execl第一個參數是要替換的程序的路徑。第二個參數是需要傳的參數,這是一個可變長的參數,最后要加null來表示參數傳完了。
由于我們沒有參數要傳給cgi程序,因此這么寫:
execl(request->path.c_str(), nullptr)
CGI程序實現
這里實現了兩個CGI程序,一個是簡易加減乘除計算器,一個是連接mysql然后往數據庫里面插入數據。
但是不管是什么cgi程序,都需要把拿一下參數。因此最重要的還是拿參數的函數。
共有兩個函數
- getArgs
- cutArgs(拿到的參數還不能直接用,因為是一個完整的字符串,要取出關鍵部分)
getArgs
思路很簡單。
- 先把方法從環境變量里面拿出來,然后判斷是GET方法還是POST方法。
- 如果是GET方法,那么直接把參數的環境變量拿出來即可。
- 如果是POST方法,那么從正文里面讀數據。
對于第三點要注意:正文是http server把正文寫進管道了,cgi程序直接從管道里面讀即可。
cutArgs
參數一般都長這樣:data1=10&data2=100
因此先把&給去掉,剩下兩邊的字符串。然后再把等號去掉就可以獲得10和100兩個數字了。
void cutArgs(string& s, string& t, string& args, string sep)
{size_t pos = args.find(sep);if(pos == string::npos) s = args;else s = args.substr(0, pos), t = args.substr(pos + 1);
}
ps:其實是有bug的,萬一分隔符不只一個長度就無法拿出正確的參數了。但是mini server不考慮那么多。
加減乘除計算器
之間講過,實現的時候cgi程序統一返回大字報,因此就寫了個html大字報(原因是不會寫html)。
強調一下:cout并不是往屏幕打印東西了,之前重定向把fd = 1變成input[1]了,因此cout是往管道里面寫東西。
string args = getArgs();
string sub1, sub2;
string name1, val1, name2, val2;
cutArgs(sub1, sub2, args, "&");
cutArgs(name1, val1, sub1, "=");
cutArgs(name2, val2, sub2, "=");int x = stoi(val1), y = stoi(val2);cout << "<html>" << endl;
cout << "<body>" << endl;
cout << "<h2>val1 + val2 = " + to_string(x + y) << endl;
cout << "<h2>val1 - val2 = " + to_string(x - y) << endl;
cout << "<h2>val1 * val2 = " + to_string(x * y) << endl;
cout << "<h2>val1 / val2 = " + to_string(x / y) << endl;
cout << "</body>" << endl;
cout << "</html>";
連接mysql往數據庫插入數據
主要代碼就是這段,其他代碼和上面計算器是差不多的,不貼了。
void InsertSql(string& sql)
{MYSQL* conn = mysql_init(nullptr);mysql_set_character_set(conn, "utf8");if(nullptr == mysql_real_connect(conn, "127.0.0.1", "http_test", "1353601324ERIC", "http_test", 3306, nullptr, 0)){cerr << "db connect error" << endl;return;}mysql_query(conn, sql.c_str());mysql_close(conn);
}
args.substr(pos + 1);
}
ps:其實是有bug的,萬一分隔符不只一個長度就無法拿出正確的參數了。但是mini server不考慮那么多。## 加減乘除計算器之間講過,實現的時候cgi程序統一返回大字報,因此就寫了個html大字報(原因是不會寫html)。**強調一下:cout并不是往屏幕打印東西了,之前重定向把fd = 1變成input[1]了,因此cout是往管道里面寫東西。**```c++
string args = getArgs();
string sub1, sub2;
string name1, val1, name2, val2;
cutArgs(sub1, sub2, args, "&");
cutArgs(name1, val1, sub1, "=");
cutArgs(name2, val2, sub2, "=");int x = stoi(val1), y = stoi(val2);cout << "<html>" << endl;
cout << "<body>" << endl;
cout << "<h2>val1 + val2 = " + to_string(x + y) << endl;
cout << "<h2>val1 - val2 = " + to_string(x - y) << endl;
cout << "<h2>val1 * val2 = " + to_string(x * y) << endl;
cout << "<h2>val1 / val2 = " + to_string(x / y) << endl;
cout << "</body>" << endl;
cout << "</html>";
連接mysql往數據庫插入數據
主要代碼就是這段,其他代碼和上面計算器是差不多的,不貼了。
void InsertSql(string& sql)
{MYSQL* conn = mysql_init(nullptr);mysql_set_character_set(conn, "utf8");if(nullptr == mysql_real_connect(conn, "127.0.0.1", "http_test", "1353601324ERIC", "http_test", 3306, nullptr, 0)){cerr << "db connect error" << endl;return;}mysql_query(conn, sql.c_str());mysql_close(conn);
}