從零實現一個http服務器

我始終以爲,天生的出身很重要,但後天的努力更加劇要,因此現在的不少「科班」每每不如後天努力的「非科班」。因此,咱們須要從新給「專業」和「專家」下一個定義:所謂專業,就是別人搞你不搞,這就是你的「專業」;你和別人同時搞,你比別人搞的好,就是「專家」。javascript

說到http協議和http請求,不少人都知道,可是他們真的「知道」嗎?我面試過不少求職者,一說到http協議,他們能口若懸河,而後我問他http協議的具體格式是啥樣子的?不少人不清楚,不清楚就不清楚吧,他甚至能將http協議的頭扯到html文檔頭部<head>。當我問http GET和POST請求的時候,GET請求是什麼形式通常人均可以答出來,可是POST請求的數據放在哪裏,服務器如何識別和解析這些POST數據,不少人又說不清道不明瞭。當說到http服務器時,不少人離開了apache、Nginx這樣現成的http server以外,本身實現一個http服務器無從下手,若是實際應用場景有須要使用到一些簡單http請求時,使用apache、Nginx這樣重量級的http服務器程序實在勞師動衆,你能夠嘗試本身實現一個簡單的。php

上面提到的問題,若是您不能清晰地回答出來,能夠閱讀一下這篇文章,這篇文章在不只介紹http的格式,同時帶領你們從零實現一個簡單的http服務器程序。html

1、項目背景java

最近不少朋友但願個人flamingo服務器支持http協議,我本身也想作一個微信小程序,小程序經過http協議鏈接經過個人flamingo服務器進行聊天。flamingo是一個開源的即時通信軟件,目前除了服務器端,還有pc端、android端,後面會支持更多的終端。關於flamingo的介紹您能夠參考這裏:http://www.javashuo.com/article/p-bqctlzqd-u.html,這是我不斷維護一個項目,其最新代碼下載地址是:https://github.com/baloonwj/flamingo,更新日誌:https://github.com/baloonwj/flamingo/issues/1。下面是flamingo的部分截圖: 輸入圖片說明2、http協議介紹android

  1. http協議是應用層協議,通常創建在tcp協議的基礎之上(固然你的實現非要基於udp也是能夠的),也就是說http協議的數據收發是經過tcp協議的。git

  2. http協議也分爲head和body兩部分,可是咱們通常說的html中的<head>和<body>標記不是http協議的頭和身體,它們都是http協議的body部分。 輸入圖片說明github

那麼http協議的頭到底長啥樣子呢?咱們來介紹一下http協議吧。web

http協議的格式以下:面試

GET或POST 請求的url路徑(通常是去掉域名的路徑) HTTP協議版本號
字段1名: 字段1值\r\n
字段2名: 字段2值\r\n
      ...
字段n名 : 字段n值\r\n
\r\n
http協議包體內容

也就是說http協議由兩部分組成:包頭和包體,包頭與包體之間使用一個\r\n分割,因爲http協議包頭的每一行都是以\r\n結束,因此http協議包頭通常以\r\n\r\n結束。apache

舉個例子,好比咱們在瀏覽器中請求http://www.hootina.org/index_2013.php這個網址,這是一個典型的GET方法,瀏覽器組裝的http數據包格式以下:

GET /index_2013.php HTTP/1.1\r\n
Host: www.hootina.org\r\n
Connection: keep-alive\r\n
Upgrade-Insecure-Requests: 1\r\n
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\r\n
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\r\n
Accept-Encoding: gzip, deflate\r\n
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n
\r\n

上面這個請求只有包頭沒有包體,http協議的包體不是必須的,也就是說GET請求通常沒有包體。

若是GET請求帶參數,那麼通常是附加在請求的url後面,參數與參數之間使用&分割,例如請求http://www.hootina.org/index_2013.php?param1=value1&param2=value2&param3=value3,咱們看下這個請求組裝的的http協議包格式:

GET /index_2013.php?param1=value1&param2=value2&param3=value3 HTTP/1.1\r\n
Host: www.hootina.org\r\n
Connection: keep-alive\r\n
Upgrade-Insecure-Requests: 1\r\n
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\r\n
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\r\n
Accept-Encoding: gzip, deflate\r\n
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n
\r\n

對比一下,你如今知道http協議的GET參數放在協議包的什麼位置了吧。

那麼POST的數據放在什麼位置呢?咱們再12306網站(https://kyfw.12306.cn/otn/login/init)中登錄輸入用戶名和密碼:

輸入圖片說明

而後發現瀏覽器以POST方式組裝了http協議包發送了咱們的用戶名、密碼和其餘一些信息,組裝的包格式以下:

POST /passport/web/login HTTP/1.1\r\n
Host: kyfw.12306.cn\r\n
Connection: keep-alive\r\n
Content-Length: 55\r\n
Accept: application/json, text/javascript, */*; q=0.01\r\n
Origin: https://kyfw.12306.cn\r\n
X-Requested-With: XMLHttpRequest\r\n
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\r\n
Content-Type: application/x-www-form-urlencoded; charset=UTF-8\r\n
Referer: https://kyfw.12306.cn/otn/login/init\r\n
Accept-Encoding: gzip, deflate, br\r\n
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n
Cookie: _passport_session=0b2cc5b86eb74bcc976bfa9dfef3e8a20712; _passport_ct=18d19b0930954d76b8057c732ce4cdcat8137; route=6f50b51faa11b987e576cdb301e545c4; RAIL_EXPIRATION=1526718782244; RAIL_DEVICEID=QuRAhOyIWv9lwWEhkq03x5Yl_livKZxx7gW6_-52oTZQda1c4zmVWxdw5Zk79xSDFHe9LJ57F8luYOFp_yahxDXQAOmEV8U1VgXavacuM2UPCFy3knfn42yTsJM3EYOy-hwpsP-jTb2OXevJj5acf40XsvsPDcM7; BIGipServerpool_passport=300745226.50215.0000; BIGipServerotn=1257243146.38945.0000; BIGipServerpassport=1005060362.50215.0000\r\n
\r\n
username=balloonwj%40qq.com&password=iloveyou&appid=otn

其中username=balloonwj%40qq.com&password=iloveyou&appid=otn就是咱們的POST數據,可是你們須要注意的如下幾種,不要搞錯:

1.個人用戶名是balloonwj@qq.com,到POST裏面變成balloonwj%40qq.com,其中%40是@符號的16進制轉碼形式。這個碼錶能夠參考這裏:http://www.w3school.com.cn/tags/html_ref_urlencode.html

2.這裏有三個變量,分別是username、password和appid,他們之間使用&符號分割,可是請注意的是,這不意味着傳遞多個POST變量時必須使用&符號分割,只不過這裏是瀏覽器html表單(輸入用戶名和密碼的文本框是html表單的一種)分割多個變量採用的默認方式而已。你能夠根據你的需求,來自由定製,只要讓服務器知道你的解析方式便可。好比能夠這麼分割:

方法一: username=balloonwj%40qq.com|password=iloveyou|appid=otn

方法二: username:balloonwj%40qq.com\r\n password:iloveyou\r\n appid:otn\r\n

方法三 :username,password,appid=balloonwj%40qq.com,iloveyou,otn 無論怎麼分割,只要你能本身按必定的規則解析出來就能夠了。

不知道你注意到沒有,上面的POST數據放在http包體中,服務器如何解析呢?可能你沒明白個人意思,看下圖:

如上圖所示,因爲http協議是基於tcp協議的,tcp協議是流式協議,包頭部分能夠經過多出的\r\n來分界,包體部分如何分界呢?這是協議自己要解決的問題。目前通常有兩種方式,第一種方式就是在包頭中有個content-Length字段,這個字段的值的大小標識了POST數據的長度,上圖中55就是數據username=balloonwj%40qq.com&password=iloveyou&appid=otn的長度,服務器收到一個數據包後,先從包頭解析出這個字段的值,再根據這個值去讀取相應長度的做爲http協議的包體數據。還有一個格式叫作http chunked技術(分塊),大體意思是將大包分紅小包,具體的詳情有興趣的讀者能夠自行搜索學習。

2、http客戶端實現

若是您能掌握以上說的http協議,你就能夠本身經過代碼組裝http協議發送http請求了(也是各類開源http庫的作法)。咱們先簡單地介紹一下如何模擬發送http。舉個例子,咱們要請求http://www.hootina.org/index_2013.php,那麼咱們能夠先經過域名獲得ip地址,即經過socket API gethostbyname()獲得www.hootina.org的ip地址,因爲http服務器默認的端口號是80,有了域名和ip地址以後,咱們使用socket API connect()去鏈接服務器,而後根據上面介紹的格式組裝成http協議包,利用socket API send()函數發出去,若是服務器有應答,咱們可使用socket API recv()去接受數據,接下來就是解析數據(先解析包頭和包體)。

3、http服務器實現

咱們這裏簡化一些問題,假設客戶端發送的請求都是GET請求,當客戶端發來http請求以後,咱們拿到http包後就作相應的處理。咱們覺得咱們的flamingo服務器實現一個支持http格式的註冊請求爲例。假設用戶在瀏覽器裏面輸入如下網址,就能夠實現一個註冊功能:

http://120.55.94.78:12345/register.do?p={"username": "13917043329", "nickname": "balloon", "password": "123"}

這裏咱們的http協議使用的是12345端口號而不是默認的80端口。如何偵聽12345端口,這個是很是基礎的知識了,這裏就不介紹了。當咱們收到數據之後:

void HttpSession::OnRead(const std::shared_ptr<TcpConnection>& conn, Buffer* pBuffer, Timestamp receivTime)
{
    //LOG_INFO << "Recv a http request from " << conn->peerAddress().toIpPort();

    string inbuf;
    //先把全部數據都取出來
    inbuf.append(pBuffer->peek(), pBuffer->readableBytes());
    //由於一個http包頭的數據至少\r\n\r\n,因此大於4個字符
    //小於等於4個字符,說明數據未收完,退出,等待網絡底層接着收取
    if (inbuf.length() <= 4)
        return;

    //咱們收到的GET請求數據包通常格式以下:
    /* GET /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22} HTTP/1.1\r\n Host: 120.55.94.78:12345\r\n Connection: keep-alive\r\n Upgrade-Insecure-Requests: 1\r\n User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\r\n Accept-Encoding: gzip, deflate\r\n Accept-Language: zh-CN, zh; q=0.9, en; q=0.8\r\n \r\n */
    //檢查是否以\r\n\r\n結束,若是不是說明包頭不完整,退出
    string end = inbuf.substr(inbuf.length() - 4);
    if (end != "\r\n\r\n")
        return;

    //以\r\n分割每一行
    std::vector<string> lines;
    StringUtil::Split(inbuf, lines, "\r\n");
    if (lines.size() < 1 || lines[0].empty())
    {
        conn->forceClose();
        return;
    }

    std::vector<string> chunk;
    StringUtil::Split(lines[0], chunk, " ");
    //chunk中至少有三個字符串:GET+url+HTTP版本號
    if (chunk.size() < 3)
    {
        conn->forceClose();
        return;
    }

    LOG_INFO << "url: " << chunk[1] << " from " << conn->peerAddress().toIpPort();
    //inbuf = /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22}
    std::vector<string> part;
    //經過?分割成先後兩端,前面是url,後面是參數
    StringUtil::Split(chunk[1], part, "?");
    //chunk中至少有三個字符串:GET+url+HTTP版本號
    if (part.size() < 2)
    {
        conn->forceClose();
        return;
    }

    string url = part[0];
    string param = part[1].substr(2);

    if (!Process(conn, url, param))
    {
        LOG_ERROR << "handle http request error, from:" << conn->peerAddress().toIpPort() << ", request: " << pBuffer->retrieveAllAsString();
    }

    //短鏈接,處理完關閉鏈接
    conn->forceClose();
}

代碼註釋都寫的很清楚,咱們先利用\r\n分割獲得每一行,其中第一行的數據是:

GET /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22} HTTP/1.1

其中%22是雙引號的url轉碼形式,%20是空格的url轉碼形式,而後咱們根據空格分紅三段,其中第二段就是咱們的網址和參數:

/register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22}

而後咱們根據網址與參數之間的問號將這個分紅兩段:第一段是網址,第二段是參數:

bool HttpSession::Process(const std::shared_ptr<TcpConnection>& conn, const std::string& url, const std::string& param)
{
    if (url.empty())
        return false;

    if (url == "/register.do")
    {
        OnRegisterResponse(param, conn);
    }
    else if (url == "/login.do")
    {
        OnLoginResponse(param, conn);
    }
    else if (url == "/getfriendlist.do")
    {

    }
    else if (url == "/getgroupmembers.do")
    {

    }
    else
        return false;


    return true;
}

而後咱們根據url匹配網址,若是是註冊請求,會走註冊處理邏輯:

void HttpSession::OnRegisterResponse(const std::string& data, const std::shared_ptr<TcpConnection>& conn)
{
    string retData;
    string decodeData;
    URLEncodeUtil::Decode(data, decodeData);
    BussinessLogic::RegisterUser(decodeData, conn, false, retData);
    if (!retData.empty())
    {
        std::string response;
        URLEncodeUtil::Encode(retData, response);
        MakeupResponse(retData, response);
        conn->send(response);

        LOG_INFO << "Response to client: cmd=msg_type_register" << ", data=" << retData << conn->peerAddress().toIpPort();;
    }
}

註冊結果放在retData中,爲了發給客戶端,咱們將結果中的特殊字符如雙引號轉碼,如返回結果是:

{"code":0, "msg":"ok"} 會被轉碼成:

{%22code%22:0,%20%22msg%22:%22ok%22} 而後,將數據組裝成http協議發給客戶端,給客戶端的應答協議與http請求協議有一點點差異,就是將請求的url路徑換成所謂的http響應碼,如200表示應答正常返回、404頁面不存在。應答協議格式以下:

GET或POST 響應碼 HTTP協議版本號
字段1名: 字段1值\r\n
字段2名: 字段2值\r\n
      ...
字段n名 : 字段n值\r\n
\r\n
http協議包體內容

舉個例子如:

HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length:42\r\n
\r\n
{%22code%22:%200,%20%22msg%22:%20%22ok%22}

注意,包頭中的Content-Length長度必須正好是包體{%22code%22:%200,%20%22msg%22:%20%22ok%22}的長度,這裏是42。這也符合咱們瀏覽器的返回結果:

固然,須要注意的是,咱們通常說http鏈接通常是短鏈接,這裏咱們也實現了這個功能(看上面的代碼:conn->forceClose();),無論一個http請求是否成功,服務器處理後立馬就關閉鏈接。

固然,這裏還有一些沒處理好的地方,若是你仔細觀察上面的代碼就會發現這個問題,就是不知足一個http包頭時的處理,若是某個客戶端(不是使用瀏覽器)經過程序模擬了一個鏈接請求,可是遲遲不發含有\r\n\r\n的數據,這路鏈接將會一直佔用。咱們能夠判斷收到的數據長度,防止別有用心的客戶端給咱們的服務器亂髮數據。咱們假定,咱們能處理的最大url長度是2048,若是用戶發送的數據累積不含\r\n\r\n,且超過2048個,咱們認爲鏈接非法,將鏈接斷開。代碼修改爲以下形式:

void HttpSession::OnRead(const std::shared_ptr<TcpConnection>& conn, Buffer* pBuffer, Timestamp receivTime)
{
    //LOG_INFO << "Recv a http request from " << conn->peerAddress().toIpPort();

    string inbuf;
    //先把全部數據都取出來
    inbuf.append(pBuffer->peek(), pBuffer->readableBytes());
    //由於一個http包頭的數據至少\r\n\r\n,因此大於4個字符
    //小於等於4個字符,說明數據未收完,退出,等待網絡底層接着收取
    if (inbuf.length() <= 4)
        return;

    //咱們收到的GET請求數據包通常格式以下:
    /* GET /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22} HTTP/1.1\r\n Host: 120.55.94.78:12345\r\n Connection: keep-alive\r\n Upgrade-Insecure-Requests: 1\r\n User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36\r\n Accept-Encoding: gzip, deflate\r\n Accept-Language: zh-CN, zh; q=0.9, en; q=0.8\r\n \r\n */
    //檢查是否以\r\n\r\n結束,若是不是說明包頭不完整,退出
    string end = inbuf.substr(inbuf.length() - 4);
    if (end != "\r\n\r\n")
        return;
    //超過2048個字符,且不含\r\n\r\n,咱們認爲是非法請求
    else if (inbuf.length() >= MAX_URL_LENGTH)
    {
        conn->forceClose();
        return;
    }

    //以\r\n分割每一行
    std::vector<string> lines;
    StringUtil::Split(inbuf, lines, "\r\n");
    if (lines.size() < 1 || lines[0].empty())
    {
        conn->forceClose();
        return;
    }

    std::vector<string> chunk;
    StringUtil::Split(lines[0], chunk, " ");
    //chunk中至少有三個字符串:GET+url+HTTP版本號
    if (chunk.size() < 3)
    {
        conn->forceClose();
        return;
    }

    LOG_INFO << "url: " << chunk[1] << " from " << conn->peerAddress().toIpPort();
    //inbuf = /register.do?p={%22username%22:%20%2213917043329%22,%20%22nickname%22:%20%22balloon%22,%20%22password%22:%20%22123%22}
    std::vector<string> part;
    //經過?分割成先後兩端,前面是url,後面是參數
    StringUtil::Split(chunk[1], part, "?");
    //chunk中至少有三個字符串:GET+url+HTTP版本號
    if (part.size() < 2)
    {
        conn->forceClose();
        return;
    }

    string url = part[0];
    string param = part[1].substr(2);

    if (!Process(conn, url, param))
    {
        LOG_ERROR << "handle http request error, from:" << conn->peerAddress().toIpPort() << ", request: " << pBuffer->retrieveAllAsString();
    }

    //短鏈接,處理完關閉鏈接
    conn->forceClose();
}

但這隻能解決發送非法數據的狀況,若是一個客戶端連上來不給咱們發任何數據,這段邏輯就無能爲力了。若是不斷有客戶端這麼作,會浪費咱們大量的鏈接資源,因此咱們還須要一個定時器去定時檢測哪些http鏈接超過必定時間內沒給咱們發數據,找到後將鏈接斷開。這又涉及到服務器定時器如何設計了,關於這部分請參考我寫的其餘文章。

限於做者經驗水平有限,文中不免有錯亂之處,歡迎拍磚。另外,關於上面的代碼,能夠去github上下載,地址是:

https://github.com/baloonwj/flamingo

全文完。

 

歡迎關注公衆號『easyserverdev』。若是有任何技術或者職業方面的問題須要我提供幫助,可經過這個公衆號與我取得聯繫,此公衆號不只分享高性能服務器開發經驗和故事,同時也免費爲廣大技術朋友提供技術答疑和職業解惑,您有任何問題均可以在微信公衆號直接留言,我會盡快回復您。 輸入圖片說明

相關文章
相關標籤/搜索