HTTP協議理解及服務端與客戶端的設計實現

HTTP 協議理解及服務端與客戶端的設計實現

版權聲明:轉載必須註明本文轉自嚴振杰的博客: http://blog.yanzhenjie.com

本文主要幫助讀者理解 HTTP 的協作原理、HTTP 相關的各層協議,在服務端和客戶端的架構設計和一些優化的技巧,本文中主要講述邏輯思想和協議遠離,會使用部分 Java 代碼,但會有詳細的講解,非開發應該也讀的明白。

個人實現過一款WebServer/WebFrameWork的 HTTP 服務端框架和兩款標準的 HTTP 客戶端框架,開源在 GitHub 上後獲得廣泛好評,在開發這幾個開源項目時走過許多彎路,本文也是把我走過的彎路做一個記錄和分享,如果能幫助到讀者就更好了,如果有筆誤的地方還請大家留言指正。


本文內容

  1. 網絡參考模型和 HTTP 協議
  2. TCP/IP 和 HTTP 的數據結構
  3. 實現 HTTP 服務端和客戶端
  4. HTTP 的傳輸數據的幾種方式
  5. HTTP 常見響應碼和響應頭的組合使用

讀完本文後可以瞭解到的知識:

  1. TCP/IP 協議、HTTP 協議和 Socket 有什麼區別?
  2. 如何基於 TCP/IP 實現一個 HTTP 服務端或者客戶端?
  3. 服務端 HTTP API 發生未處理異常時爲什麼不會崩潰?
  4. HTTP 在哪些情況下會請求超時?
  5. Cookie 和 Session 有什麼區別和聯繫?

網絡參考模型和 HTTP 協議

HTTP 是超文本傳送協議(HyperText Transfer Protocol)的縮寫,要想具象的描述清楚 HTTP,我們需要先了解OSI 參考模型TCP/IP 參考模型

如果有人要我們介紹一下 HTTP 是什麼,我相信大多數人會這樣回答:

HTTP 是基於 TCP/IP 協議的一個應用層協議。

然而我們真的瞭解 TCP/IP 協議麼?接下來我們一層層抽絲剝繭。

OSI 參考模型

個人認爲 TCP/IP 相當於開放式系統互聯通信參考模型中的的傳輸層和網絡層,根據該模型的英文單詞縮寫,它被簡稱爲OSI 參考模型,OSI 參考模型是一個嘗試讓全世界計算機互聯爲網絡的概念性框架,它只是一個參考模型,並沒有提供某種具體的實現方法或者標準,換句話說它是一個爲定製標準提供參考的概念性框架。

OSI 參考模型中將計算機網絡體系結構劃分爲 7 層,從下至上依次是:

名稱 解釋
物理層 光纖和網卡等,負責通信設備和網絡媒體之間的互通
數據鏈路層 以太網,用來加強物理層功能
網絡層 IP 協議和 ICMP 協議等,負責數據的路由的選擇與數據轉寄
傳輸層 TCP 協議和 UDP 協議等,承上啓下,控制連接,控制流量
會話層 建立和維護會話關係
表達層 把數據轉換爲接受者系統可兼容的格式
應用層 HTTP、FTP、SMTP 和 SSH 等,粗獷的理解爲程序員層

OSI 參考模型定義了開放系統的層次結構和各層次之間的相互關係,它作爲一個框架來協調和組織各層所提供的服務,如果要說的更貼近一點,它更像是一款行爲規範,貼近生活的例子就是一個企業的企業文化。

TCP/IP 參考模型

TCP/IP 協議代表一整個網絡傳輸協議家族,而不僅僅是 TCP 協議和 IP 協議,TCP 協議和 IP 協議是該協議家族中最早通過的最核心的兩個協議標準,因此該協議家族被稱作TCP/IP 協議族,也就是我們通常所說的 TCP/IP 協議。

要完成一個任務需要該協議家族的各種協議分工協作,就好比程序員分爲前端、後端和 DB 等一樣,把這些協議根據它們的責任分類,因此 TCP/IP 產考模型應運而生,在該參考模型中的網絡體系結構一共分爲 4 層,從下至上依次是:

名稱 解釋
網絡連接層 主機與網絡相連的協議,如:以太網
網絡互聯層 IP 協議和 ICMP 協議等,負責數據的路由的選擇與數據轉寄
傳輸層 TCP 協議和 UDP 協議等,控制端對端的連接、流量和穩定性
應用層 HTTP、FTP、SMTP 和 SSH 等,粗獷的理解爲程序員層

TCP/IP 參考模型看起來和 OSI 參考模型有有一定的相似性,然而由於各種應用層實現的不同,它們之間沒有一種絕對的對稱關係,我們可以大致的將它們按照以下對應關係來理解和區分:

TCP/IP 參考模型 OSI 參考模型
網絡連接層 物理層和數據鏈路層
網絡互聯層 網絡層
傳輸層 傳輸層
應用層 會話層、表達層和應用層

然而上述對應關係依舊有點生拉硬拽的感覺,我認爲還是要把它們區分開來理解,從微觀上來看它們本身是兩種不同的參考模型。

HTTP 協議與 TCP/IP 協議

如果認真的看了上面的表格,我們可以知道,HTTP 是 TCP/IP 參考模型中應用層的其中一種實現。HTTP 協議的網絡層基於 IP 協議,傳輸層基於 TCP 協議,因此就引出了我們開頭說到的:HTTP 協議是基於 TCP/IP 協議的應用層協議。

上文中提到,可以把應用層理解爲“程序員層”,TCP/IP 協議需要向程序員提供可編程的 API,該 API 就是 Socket,它是對 TCP/IP 協議的一個重要的實現,幾乎所有的計算機系統都提供了對 TCP/IP 協議族的 Socket 實現。綜上所述,我們就可以使用 Socket 來進行網絡通信了,而 HTTP 協議也需要向程序員提供可編程的 API,該 API 的實現也就基於 Socket 來實現了。

如何理解 Socket 呢?就像在生活中打電話一樣,有打電話的一端,就有接電話的一端,Socket 也是一樣的,作爲 TCP/IP 協議族的的實現,生來就是爲了完成通信。雖然每一臺主機設備都可以作爲打電話的一端(客戶端),也可以作爲接電話的一端(服務端),但是打電話和接電話的動作在行爲上來看是不同的。因此計算機系統的 Socket 實現也提供了兩套 API,我們在這裏約定一下,提供服務端能力的稱作ServerSocket,提供客戶端能力的稱作Socket

Nginx 和本人開發的 AndServer 等都是基於 Socket 實現的 HTTP 服務端,OkHttp、URLCollection 等都是基於 Socket 實現的 HTTP 客戶端,而瀏覽器就是這些 HTTP 客戶端的具象。

到這裏,我們就可以回答第一個問題了:

TCP/IP 協議、HTTP 協議和 Socket 有什麼區別?

我們先來看幾張圖,從縱向來看,它們的繼承關係是這樣的:

從橫向來看,它們的繼承關係是這樣的:

總結一下,TCP/IP 是一個協議族,Socket 是對 TCP/IP 協議族 API 實現;HTTP 是超文本傳輸協議的簡稱,屬於 TCP/IP 參考模型的應用層,HTTP 的 API 實現一般都要依靠 TCP/IP 的 API 實現。也就是說一般情況下, HTTP 服務端或者客戶端都是基於 Socket 來實現的,而像 Nginx、Apache、Chrome 和 IE 等軟件都是基於 HTTP 服務端或者客戶端而開發來的。

TCP/IP 和 HTTP 的數據結構

HTTP 作爲 TCP/IP 參考模型的應用層,談到它的數據結構勢必要了解 TCP/IP 參考模型中其他層的數據結構,把 HTTP 放到 TCP/IP 參考模型中,它們的繼承結構是這樣的:

在上述繼承結構中,每一層都有各自的結構,就好比爺爺、爸爸和兒子雖然是父子孫關係,但是爺爺有爺爺特點,爸爸有爸爸的特點,兒子有兒子的特點。同樣的,上述每一層的結構大致是相同的,基本都是Header + Body這樣的結構,以太網還多一層尾部,所以以太網層的結構式是Header + Body + Footer

如果我們以以太網作爲最底層,在 TCP/IP 參考模型中它們的整體的數據結構是:IP 作爲以太網的直接底層,IP 的頭部和數據合起來作爲以太網的數據,同樣的 TCP/UDP 的頭部和數據合起來作爲 IP 的數據,HTTP 的頭部和數據合起來作爲 TCP/UDP 的數據。我用一個更加形象一點的圖來幫助讀者理解:

上面這個圖還是花了我一點心思和時間的,希望可以切實的幫助到讀者。另外要聲明的是,在上圖中的傳輸成我使用了 TCP 來代替,實際上還可以使用 UDP 來實現,例如 HTTP3 種就使用了 UDP 作爲了傳輸層。本文中還是依然以 TCP 協議作爲傳輸層講解。

在 HTTP 中,以太網層的數據結構對於普通開發者來說太底層了,估計講了也難以理解,我們從普通開發者可以接觸到的 IP 層開始講起。

IP 的數據結構和交互流程

我們都知道在一個成功的 HTTP 請求中,服務端可以在一個請求中獲取到客戶端 IP 地址,也可以獲取到客戶端請求的主機的 IP 地址。然而這是怎麼做到的呢?這就有賴於 IP 協議了,在 IP 協議中規定了,IP 的頭部必須包含源 IP 地址和目的 IP 地址,這也是爲什麼在 TCP/IP 參考模型中IP 處在網絡互聯層,其中一個原因就是可以定位服務端地址和客戶端地址,我們來看一下 IP 的數據結構:

可以很清晰的看到源 IP 地址和目的 IP 地址,在 IP 的頭部各佔 32 位,而 IPV4 的 IP 地址是用點式十進制表示的,例如:192.168.1.1,在 IP 頭部用二進制表示的話,剛好是 4 個字節 32 位。

不過 32 位可以表示的 IP 地址是有限的,目前在全球來看北美擁有 30 多億個 IP 地址,中國擁有近 3 億左右個 IP 地址,無奈中國的網民實在太多了,於是使用了 IP 地址轉換技術 NAT。例如 ABC 三個小區的所有設備可能公用了一個公網 IP,通過 NAT 技術分給每一戶一個私有 IP 地址,大家在小區內交流時可能使用的是私有 IP 地址,但是向外交流時就用公網 IP。

當客戶端要和服務端建立連接時,需要指定服務端的域名或者 IP 地址,在一般情況下,一個主機的 IP 地址是固定且唯一的,在一個主機上也可以部署多個應用。當客戶端使用 IP 地址直鏈服務端的時候,由於通過地址無法確定客戶端要要連接的應用,只能通過指定端口來確定要連接的應用,而要記憶IP + Port對於用戶來說非常不友好,所以要通過同一個端口(甚至基於某個應用的默認端口,例如 HTTP 默認使用 80 端口)要連接同一個主機的多個應用只能從地址來下手。同時由於 IP 地址的特點,讓 IP 地址的數量變得有限,並且 IP 地址實際上都被 ISP 所持有,只是租賃給開發者使用,因此當開發者更換了 ISP 時 IP 地址是不會跟着開發者變更的。基於以上兩點就產生了域名,域名是無限多的,如果讓多個域名都解析到同一個 IP 地址,那麼這麼這個主機就擁有了多個別名,當外部應用通過不同的域名來連接該主機時,在該主機內部就可以通過不同的別名,把該連接指向不同的應用。

上面這段話有點繞對吧?我也這麼覺得,所以,我來舉個栗子:
在你所工作的公司裏,有一臺服務器,IP 地址是192.168.1.11,在這臺服務器上部署了文檔網站、設計網站和辦公網站,在沒有域名的時候分別這樣訪問這三個網站:

文檔網站:http://192.168.1.11:8080
設計網站:http://192.168.1.11:9090
辦公網站:http://192.168.1.11:8899

甚至在另一臺主機上還部署了 CRM 系統和 CMS 系統等等,這樣就要記憶(或者寫到小本本上)多個 IP 和不同的端口,現在假設我們公司的域名是666.com,如果我們使用域名的話,上述的地址嫁將會變得很好記憶:

文檔網站:http://doc.666.com
設計網站:http://ui.666.com
辦公網站:http://oa.666.com
CRM系統:http://crm.666.com

現在,當客戶端使用域名連接服務端的時候,需要通過 IP 尋址來確定服務器在網絡中的位置,主要是以下兩步;

  1. 通過 hosts 或者 DNS 查找主機名對應的 IP 地址
  2. 通過 ARP 尋址查找主機對應的 MAC 地址

上述兩步的主要目的是查到 MAC 地址進行網絡連接層的封裝和數據發送,MAC 地址是根據 IP 地址進行 ARP 尋址找到的。因此首先得知道指定的域名對應的 IP 地址,此時需要通過 DNS 緩存來查找域名對應的 IP 地址,一般情況下有這麼幾級:

  1. 查找系統的 hosts 中配置的 DNS 映射
  2. 查找系統自身的 DNS 緩存
  3. 查找路由器中的 DNS 緩存
  4. 查找 IPS 的 DNS 緩存

每一級的 DNS 緩存都是有時效性的,在上一層找到對應的映射後就不會向下尋找了。獲得了 IP 之後,根據 IP 地址和子網掩碼計算出自己所在的網段,在網段內查找對應主機的 MAC 地址,然後在數據鏈路層進行封裝和數據發送。

此處 ARP 尋址講的比較粗略,因爲涉及到了更多的網絡協議知識,深入下去之後回脫離 IP,因此點到爲止。

TCP 的數據結構和交互流程

我們通常說的 HTTP 的 3 次握手和 4 次揮手都是由 TCP 來完成的,其實這都沒 HTTP 什麼事,但是有不少人喜歡這麼說,嚴格來說我們應該說 TCP 的 3 次握手 4 次揮手。要搞清楚 TCP 的交互流程,首先要清楚 TCP 的數據結構,接下來我們來看一下 TCP 的數據結構:

上述 TCP 的數據結構圖對於後面理解 HTTP 的交互流程非常重要,我們要記住 5 個關鍵的位置:

  • SYN:建立連接標識
  • ACK:響應標識
  • FIN:斷開連接標識
  • seq:seq number,發送序號
  • ack:ack number,響應序號

服務端應用啓動後,會在指定端口監聽客戶端的連接請求,當客戶端嘗試創建一個到服務端指定端口的 TCP 連接,服務端收到請求後接受數據並處理完業務後,會向客戶端作出響應,客戶端收到響應後接受響應數據,然後斷開連接,一個完整的請求流程就完成了。這樣的一個完整的 TCP 的生命週期會經歷以下 4 個步驟:

  1. 建立 TCP 連接,3 次握手
    1. 客戶端發送SYN, seq=x,進入 SYN_SEND 狀態
    2. 服務端迴應SYN, ACK, seq=y, ack=x+1,進入 SYN_RCVD 狀態
    3. 客戶端迴應ACK, seq=x+1, ack=y+1,進入 ESTABLISHED 狀態,服務端收到後進入 ESTABLISHED 狀態
  2. 進行數據傳輸
    1. 客戶端發送ACK, seq=x+1, ack=y+1, len=m
    2. 服務端迴應ACK, seq=y+1, ack=x+m+1, len=n
    3. 客戶端迴應ACK, seq=x+m+1, ack=y+n+1
  3. 斷開 TCP 連接, 4 次揮手
    1. 主機 A 發送FIN, ACK, seq=x+m+1, ack=y+n+1,進入 FNI_WAIT_1 狀態
    2. 主機 B 迴應ACK, seq=y+n+1, ack=x+m+1,進入 CLOSE_WAIT 狀態,主機 A 收到後 進入 FIN_WAIT_2 狀態
    3. 主機 B 發送FIN, ACK, seq=y+n+1, ack=x+m+1,進入 LAST_ACK 狀態
    4. 主機 A 迴應ACk, seq=x+m+1, ack=y+n+1,進入 TIME_WAIT 狀態,等待主機 B 可能要求重傳 ACK 包,主機 B 收到後關閉連接,進入 CLOSED 狀態或者要求主機 A 重傳 ACK,客戶端在一定的時間內沒收到主機 B 重傳 ACK 包的要求後,斷開連接進入 CLOSED 狀態

我把上述流程簡化成了一張圖片,方便讀者理解這個過程:


我們回顧一下 TCP 的數據結構圖,可以看到在第四行中,上述提到的 SYN、ACK 和 FIN 都佔了一位,也就是說它們的值非 1 即 0,而 seq number 和 ack number 都是 32 位,32 能表示最大的數是 2 的 32 次方減 1:4294967295,目前可預見的未來,在人類的計算機體系中,這個值是完全夠用的。

客戶端與服務端建立連接、傳輸數據和斷開連接等全靠這幾個標識,比如 SYN 也可以被用來作爲 DOS 攻擊的一個手段,FIN 可以用來掃描服務端指定端口。

HTTP 的數據結構

前面說到了,Socket 是 TCP/IP 的可編程 API,HTTP 的可編程 API 的實現要依賴 Socket。在我看來,HTTP 服務端應用和 HTTP 客戶端應用的實現,就是對 Socket 的各種封裝和邏輯處理,實際上在我開發 AndServer 和 Kalle 的時候也更加深入的理解了這一點。

因爲 HTTP 是超文本傳輸協議,HTTP 的頭和數據看起來更加直觀,在大多數情況下,它們都是字符或者字符串,所以對於大多數人來說理解 HTTP 的頭和數據格式顯得很簡單。確實,HTTP 的數據格式理解起來非常容易,上部分是頭,下部分是身體。

HTTP 的請求時的數據結構和響應時的數據結構整體上是一樣的,但是有一些細微的區別,我們先來看一下 HTTP 請求時的數據結構:

再看一下 HTTP 響應時的數據結構:

仔細觀察從上述圖片,我們可以發現它們是有一定格式的文本內容。現在我們使用抓包工具對任意 HTTP 請求抓個包,來對比理解上述結構圖,下面是請求某登陸 HTTP API 時的抓包:

結合上面 3 張圖,我們就可以簡單的理解 HTTP 的數據結構了。上文中也提到基於 Socket 就可以實現 HTTP 協議的可編程 API,再結合上面兩張圖,我們可以在腦海裏構想一下,如果讓我們用 Socket 來實現一個 HTTP 服務端或者 HTTP 客戶端,我們可以怎麼做?

現在請讀者不要往下看,先自己思考一下,如果是讓你來實現,你會怎麼實現?思考結束後再繼續往下看,我會給出例子。

實現 HTTP 服務端和客戶端

如果讀者思考結束了可以向下看了,如果換做是我會這麼思考:我們可以使用 Socket 讓客戶端和服務端建立連接,然後使用 Socket 的輸入輸出流按照上圖的數據結構讀寫數據,無論實現 HTTP 服務端還是客戶端都是這個思路。

這裏主要講解代碼思路,有看不懂的同學可以在我 GitHub 上 clone 完整的源代碼:
https://github.com/yanzhenjie/HttpImpl

實現 HTTP 服務端

  • 第一步,啓動服務端,監聽本機 IP 和指定端口的客戶端連接:
ServerSocket server = new ServerSocket();

// 要監聽的IP和端口
SocketAddress address = new InetSocketAddress("192.168.1.111", 8888)
server.bind(address);

while (true) {
  Socket socket = server.accept();
  ... // 讀取請求,發送響應
}

上述代碼,從上至下依次指定了監聽的 IP 和端口,接着調用ServerSocket#bind()綁定到服務端的 Socket 上,那麼這裏的端口被佔用或者拋出任何一場,代碼不會繼續向下執行。如果綁定成功,那麼則循環監聽客戶端連接,如果沒有人連接則會阻塞在ServerSocket#accept()處,如果有人連接到則向下執行,執行完畢後循環回來等待或者接受下一個連接。

  • 第二步,分發客戶端請求,因爲會有很多客戶端來連接服務端,因此若在同一個線程內處理請求,服務端會忙不過來,我們啓動一個新線程來處理請求:
public class RequestHandler extends Thread {

    private Socket mSocket;

    public RequestHandler(Socket mSocket) {
        this.mSocket = mSocket;
    }

    @Override
    public void run() {
        ... // 讀取請求,發送響應
    }
}

接着對監聽客戶端連接做個優化:

while (true) {
  Socket socket = server.accept();

  System.out.println("---->>>> 發現客戶端請求 <<<<----");
  RequestHandler handler = new RequestHandler(socket);
  handler.start();
}

這樣我們就可以監聽無數個客戶端的連接了。

  • 第三步,簡單的讀取請求並打印請求數據:
/**
 * 讀取請求。
 */
private void readRequest(Socket socket) throws IOException {
    InputStream is = socket.getInputStream();
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    byte[] buffer = new byte[2048];
    int len;
    while ((len = is.read(buffer)) > 0) {
        bos.write(buffer, 0, len);
        if (len < 2048) break;
    }
    System.out.println(new String(bos.toByteArray()));
}
  • 第四步,按照格式發送響應頭、換行和響應數據:
/**
 * 發送響應。
 */
private void sendResponse(Socket socket, byte[] data) throws IOException {
    OutputStream os = socket.getOutputStream();

    // 發送響應頭
    PrintStream print = new PrintStream(os);
    print.println("HTTP/1.1 200 Beautiful");
    print.println("Server: HttpServer/1.0");
    print.println("Content-Length: " + data.length);
    print.println("Content-Type: text/plain; charset=utf-8");

    // 發送響應頭和響應數據之間的換行
    print.println();

    // 發送響應數據
    print.write(data);
    os.flush();
}

在上文的結構圖中看到每一行響應頭後面都會有一個換行,在上述代碼中我們調用的是println()方法而不是print()方法,帶ln的這個方法在大多數語言中都會在末尾額外輸入一個換行,因此這裏我們不需要自己再去發送換行符。

現在在線程的run()方法裏面調用一下這幾個封裝方法:

public class RequestHandler extends Thread {

  private static final Charset UTF8 = Charset.forName("utf-8");

    private Socket mSocket;

    public RequestHandler(Socket mSocket) {
        this.mSocket = mSocket;
    }

    @Override
    public void run() {
        readRequest(mSocket);

        String data = null;
        try {
          // 相當於服務端 HTTP API 處理業務,從數據庫查數據等
          data = "天上掉下個林妹妹";
        } catch(Exception e) {
          data = "沒什麼大不了,就是個 HTTP API 異常了,又不會崩潰";
        }

        sendResponse(mSocket, data.getByte(UTF8));

        // 關閉Socket連接
        mSocket.close();
    }
}

現在我們就完成了一個簡單的 HTTP 服務端,現在啓動服務端後,我們在瀏覽器或者任何 HTTP 客戶端請求一下:

http://192.168.1.111:8888

例如我們使用 POST 請求發送一段字符串,現在可以看到控制檯的打印:

POST / HTTP/1.1
User-Agent: Kalle/1.0.1
Accept: */*
Host: 192.168.1.111:8888
Accept-Encoding: gzip, deflate
Content-Type: text/plain; charset=utf-8
Content-Length: 15

恰同學少年

我們可以發現瀏覽器收到了數據:

天上掉下個林妹妹

現在我們就可以回答第三個問題了:

服務端 HTTP API 發生未處理異常時爲什麼不會崩潰?

從上方的例子中可以看到,當有任何一個客戶端鏈接上來時,服務端都會啓動一個新線程來處理這個請求,如果不是Server發生 Crash(上述代碼就是個 Server),而僅僅是某個 API 處理業務發生了異常,那麼Server是對這個 HTTP API 做了異常包裹的,因此服務端不會崩潰,就算沒有對該 HTTP API 做異常包裹,也只是當前這個線程崩潰,客戶端拿不到響應,發生超時而已。

實現 HTTP 客戶端

  • 第一步,使用 Socket 建立和服務端的連接:
// 要連接的主機域名和端口
InetSocketAddress address = new InetSocketAddress("192.168.1.111", 8888);

// 建立連接
Socket socket = new Socket();
socket.setSoTimeout(10 * 1000);
socket.connect(address, 10 * 1000);

上述代碼,從上至下依次指定了主機域名、端口、讀取數據超時時間和連接超時時間。調用了Socket#connect()方法後如果和服務端建立連接失敗,那麼這裏會拋出異常,代碼不會繼續向下執行。

  • 第二步,按照格式發送請求頭、換行和請求數據
// 發送請求頭
PrintStream print = new PrintStream(socket.getOutputStream());
print.println("POST /abc/dev?ab=cdf HTTP/1.1");
print.println("Host: 192.168.1.111:8888");
print.println("User-Agent: HttpClient/1.0");
print.println("Content-Length: 15");
print.println("Accept: *");

// 發送請求頭和請求數據之間的換行
print.println();

// 發送請求數據
print.println("恰同學少年");

在上文的結構圖中看到每一行請求頭後面都會有一個換行,在上述代碼中我們調用的是println()方法而不是print()方法,帶ln的這個方法在大多數語言中都會在末尾額外輸入一個換行,因此這裏我們不需要自己再去發送換行符。

  • 第三步,簡單的讀取響應並打印響應數據:
InputStream is = socket.getInputStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[2048];
int len;
while ((len = is.read(buffer)) > 0) {
  bos.write(buffer, 0, len);
  if (len < 2048) break;
}

String body = new String(bos.toByteArray());
System.out.println(body);

最後記得斷開 Socket 連接:

socket.close();

跑完這段代碼後,我們發現控制檯有如下輸出:

HTTP/1.1 200 Beautiful
Server: HttpServer/1.0
Content-Length: 24
Content-Type: text/plain; charset=utf-8

天上掉下個林妹妹

這裏可以把請求地址換成www.csdn.net,記得修改請求頭Host的值爲www.csdn.net,可以看到也可以正確的收到響應。

至此,我相信讀者已經基本瞭解 HTTP 是什麼了。然而,這只是皮毛毛,還遠遠的不夠呢,因爲我們只是基於 Socket 模擬了 HTTP 請求,而 HTTP 是怎麼傳遞參數的,是怎麼傳輸文件的,參數有幾種傳遞方式等問題,我們還沒了解。

到這裏,在不經意間我們已經回答了第二個問題:

如何基於 TCP/IP 實現一個 HTTP 服務端或者客戶端?

上面已經回答過第三個問題了,接着我們來回答第四個問題:

HTTP 在哪些情況下會請求超時?

HTTP 請求超時發生在客戶端,第一種情況是在建立 Socket 連接的時候,從上文中可知,首先要進行 IP 尋址,在解析域名的時候,如果在 DNS 服務器上找不到該主機,此時還不屬於超時,應該是HostNameCanntResolver,當找到該主機對應的 IP 時,開始建立連接,此時如果在指定的時間內不能在數據鏈路層建立連接則會發生連接超時,此時一般是客戶端主機和服務端主機所在的網絡不通暢。第二種情況是在傳輸數據的時候,在建立連接後,客戶端先發送請求數據,如果數據發送到一半,網絡突然斷開,此時數據一直阻塞在數據鏈路層,則也會發生超時。當客戶端發送完數據,會嘗試接受服務端傳回的響應數據,此時服務端可能在處理業務,比如讀寫數據庫,此時在連接層的流中是沒有數據在傳輸的,當服務端操作數據庫時間太長(如數據庫死鎖等)未作出響應,客戶端等待超過指定的嘗試也會發生超時。

HTTP 傳輸數據的幾種方式

HTTP 是超文本數據傳輸協議,顧名思義,其用途之一是方便開發者傳輸數據的,那麼爲什麼不直接使用 TCP/IP 的實現 Socket 還要設計一個應用層的協議呢?一個原因就是便捷性,包括數據格式,數據分割、數據傳輸和緩存策略等等,如果要爲每一個應用單獨做一套這樣的邏輯,那麼不如設計一套規範出來。

還是從上面的 HTTP 的數據結構來說起,從圖中可以看出,在請求時可以帶數據的位置有 3 個,一是 URL,二是請求頭,三是包體,在響應時也有三個位置可以帶數據,一是狀態消息,二是響應頭,三是包體。

請求頭和響應頭一般是對請求或者響應的整體描述,不做任何與數據層面的相關行爲;狀態消息用來描述服務端本次響應的狀態,是 HTTP 層面的邏輯狀態。因此請求頭、響應頭和狀態消息都不適合用來做傳輸數據。

參數寫在 URL 中

URL 是對本次請求資源的一個指向,告訴 HTTP 服務端要請求的資源是哪個,URL 裏邊也包括 Query 和 Fragment,相當於是對這個資源的特徵描述。因此客戶端請求服務端時 URL 裏面可以帶一些參數,但是這種參數屬於描述類型,服務端只把它當作查詢條件,而不是做作客戶端的結果。

這是一個完整的 URL 的結構圖:

上圖中http://www.example.com:8080是用來描述 TCP/IP 需要的信息,schme表示是否使用SSL建立連接,host表示服務端域名或者 IP,port表示連接的服務端端口。

剩下的/aa/bb/cc?name=kalle&age=18#usage用來描述資源,其中path表示本次請求的資源位置,query表示這個資源的特徵,fragment是錨點片段,只用來指導瀏覽器動作,在 HTTP 請求中無效。

參數寫在 Body 中

在 HTTP 數據結構一節可得知,包體都是在下面空一行開始,包體的內容都是字節流,因此包體裏面是可以寫任何內容的,事實也是如此。

根據 HTTP 協議,更加規範的來看包體的內容類型可以分爲三大類,分別是 URL 參數、任意流和表單。

  • 第一種,發送 URL 參數,在 Body 中的數據是這樣拼接的:
name=harry&gender=man

此時,請求頭Content-Type是:

Content-Type: application/x-www-form-urlencoded

這種方式看起來和 URL 中的 Query 是一樣的,但是意義卻不同,這裏的參數不是條件,而是客戶端的結果或者產物,服務端會當作數據來處理,根據業務的不同,可能寫入數據庫。

  • 第二種,任意流,可以是字節流、字符流或者文件流等。

例一,發送 JSON 等特定格式數據,在 Body 中的數據是這樣的:

{ "name": "harry", "gender": "man" }

此時,請求頭Content-Type是:

Content-Type: application/json

例二,發送任意字符串數據,在 Body 中數據是這樣的:

你怎麼這麼好看?

此時,請求頭Content-Type是:

Content-Type: text/plain

例三,發送已知類型的文件,比如是一張jpeg的圖片,此時 Body 中可以想象成這樣:

~~~~~~~~~~~~~~~~~~~~~~~~~

此時,請求頭Content-Type是對應的文件的MimeType

Content-Type: image/jpeg

例四,未知類型的數據或者文件,此時 Body 中可以想象成這樣:

~~~~~~~~~~~~~~~~~~~~~~~~~

此時,請求頭Content-Type是:

Content-Type: application/octet-stream
  • 第三種,表單數據,因爲表單具有一定的格式,所以只要按照格式上傳就可以傳遞複雜的數據,比如可以在表單中全部傳遞字符串,也可以全部是文件,也可以是文件和字符串的混合,表單實際上可以理解爲一個對象鍵值對,下面是表單的數據結構:

現在我們使用抓包工具對任意 HTTP 表單請求抓個包,來對比理解上述結構圖,下面是使用表單請求某登陸 HTTP API 時的抓包:

此時,請求頭Content-Type是:

Content-Type: multipart/form-data; boundary={boundary}

每一個表單都有一個隨機生成的boundary,用來分割表單數據。我們把表單的每一項都成爲Part,每一個 Part 都以--boundary開始,接着跟上該Part的描述頭信息,接着跟一個換行,然後是該Part的正文數據。按照該規律,依次把所有項都寫完即可。

HTTP 常見響應碼和響應頭的組合使用

HTTP 各種響應碼說明:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Status

HTTP 各種頭說明:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers

更多的響應碼和頭的說明請參考上述鏈接,本小姐舉一些比較常見的例子。

200 段的響應碼

一般表示請求成功,用的最多的是 200。

其中比較常見且特殊的是 206,一般用於下載文件時的斷點續傳,假設服務端的一個文件有200 byte,客戶端下載到100 byte時中斷,下次想從100byte處繼續下載,在發起請求時添加請求頭Range: bytes=100-,如果服務端支持這個操作,那麼返回響應碼 206,並且在響應頭中添加Content-Range: 100-199/200,此時客戶端讀到的數據就是第 101 個字節到第 200 個字節。

Content-Range前面的100-199倆數字表示開始字節和結束字節的posiiton,後面的200表示文件的總大小。

Range也可可以指定要下載的一段數據,例如客戶端想下載第 101 個字節到第 150 個字節,那麼可以指定Range: bytes=100-149,那麼服務端會返回Content-Range: 100-149/200,該特性經常用在多線程下載中,在網絡有保障的提前下可以極大的提高下載效率。

300 段響應碼

一般表示請求重定向,用的較多的是 302,表示重定向到其他 URL 下,客戶端可以通過響應頭的Location發現新的 URL,並重新發起請求。

比較常見且特殊的是304,當某個 URL 的請求返回Cache-Control: public, max-age={int}Last-Modified: Wed, 21 Oct 2020 07:28:00 GMT時,客戶端會緩存本次請求到的數據,在2020年10月21日7點28分前對該 URL 的請求都不會連接到服務端,而是直接讀取緩存數據。在該時間點之後會請求服務端,但是加上請求頭If-Modified-Since: Wed, 21 Oct 2020 07:28:00 GMT,服務端會拿該請求頭的時間和該 URL 對應的數據的修改時間做對比,如果數據發生了變化了則返回200和新數據,如果發現數據沒有變化,則僅僅返回304,客戶端此時還應該讀取緩存。

不過跟該響應碼一起常用的還有ETagIf-Match等相關頭,請參考上方的鏈接,這裏不再贅述。

400 段響應

一般表示客戶端錯誤,用的比較多的是400,表示參數錯誤或者服務端未理解客戶端請求。

  • 401表示需要賬號密碼,或者賬號密碼錯誤
  • 403表示權限不足
  • 404表示 URL 指定的資源未找到
  • 405表示客戶端指定的請求方法不允許
  • 406表示服務端的內容是客戶端不能接受的,一般是服務端指定的Content-Type和客戶端指定的Accept不能匹配。
  • 415表示客戶端的內容是服務端不能接受的,一般是客戶端指定的Content-Type和服務端 API 預定的不符合。
  • 416表示服務端不能支持客戶端指定的Range請求頭,常用於斷點續傳。

500 段響應碼

一般表示服務端發生錯誤,用的比較多的是 500,表示服務端發生了未知異常。

其他的常見的頭

  • Accept,一般用在請求頭中,表示客戶端對服務端內容的期望類型,比如application/json,當服務端生成的內容不是 JSON 時,那麼可能收到 406 響應碼。
  • Content-Type,在請求頭和響應頭中都比較常用,一般表示本次包體的內容類型或者格式,服務端或者客戶端應該按照該格式來解析數據。
  • Connection,表示該請求處理完後是否要關閉連接,在 HTTP1.0 中默認是close,表示要關閉連接,在 HTTP1.1 中默認是keep-alive,表示保持連接。
  • Content-Length,表示內容的長度,這個值對讀取內容非常有用,客戶端或者服務端可以根據以最優的方法讀取流,可以極大的提高讀取 IO 的效率。
  • Cookie,服務端用來標記客戶端,比如用戶訪問過某頁面,那麼給他一個標記Set-Cookie: news=true; expires=...; path=...; domain=...,當用戶下次請求該頁面時,請求頭會帶上Cookie: news=true,服務端會做一些邏輯處理。
  • Host,每一個請求中都必須帶上該頭,表示客戶端要請求的服務端的域名和端口,一般 Host 中沒有指定端口時,HTTP 應用程序默認使用 80 端口連接服務端。
  • Referer,當前頁面的淶源地址,例如從https://www.google.com頁面訪問了https://github.com,那麼 Referer 的值則是https://www.googlt.com

談到 HTTP 頭,我們便來回答一下第五個問題:

Cookie 和 Session 有什麼區別和聯繫?

我們首先要明白,Cookie 是服務端對客戶端的一個標記,它很明白的寫明瞭這個標記的值,例如上方提到的Set-Cookie: news=true...,後面還帶了一些時間和路徑參數,在這個時間之前無論瀏覽器是否重啓(連接重建),針對該domain的請求都會的帶上該 Cookie。

Session 在 HTTP 中的頭或者數據中並沒有具體體現,它是服務端 Server 的一個邏輯實現,通過 Cookie 來實現。事實上,Session 表示會話,當客戶端和服務端建立連接後,Server 會爲該客戶端生成一個 Session,Session 在服務端是一個對象,該對象可以存儲很多數據,因爲這些數據是用戶不能看到的,所以在文件或者數據庫中有個 Session 的列表,該文件或者數據庫是 Session 的持久化實現,用來防止內存數據丟失。因此每一個 Session 都會有一個列表中的 ID,爲了讓該 Session 和當前連接的用戶關聯起來,把該 Session 的 ID 作爲 Cookie 的值發送到客戶端,當客戶端下次請求服務器時會帶上之前的 Cookie 的值,那麼服務端拿到這個 Cookie 值後就可以查詢到這個 Session,也可以拿到該 Session 中保存的很多值了。

例如,客戶端請求服務端登陸的 API 了,現在生成一個 Session:

public void login(Request request, Response response) {
  // 獲取請求參數中的名稱和密碼
  String name = request.get("name");
  String pwd = request.get("pwd");

  // 把名稱和密碼保存一個對象中
  Account account = new Account();
  account.setName(name);
  account.setPwd(pwd);

  // 在用戶的Session中保存上述名稱密碼對象
  Sesssion session = ...;
  session.setObject("user_account", account);

  // 在Server上保存Session並生成ID
  String sessionId = saveSession(session);

  // 把該SessionID加入到Cookie中發送給客戶端
  Cookie cookie = new Cookie();
  cookie.setKey("session_id");
  cookie.setValue(sessionId);
  response.addCookie(cookie);

  ...
}

上述是僞代碼,幫助讀者理解 Cookie 和 Session 的,實際的開發中並不是這麼簡單的處理。

此時會有一個這樣的響應頭髮送給用戶端:

Set-Cookie: session_id=xxxxxx

現在用戶在訪問獲取個人信息的 API 時會帶上這樣一個請求頭:

Cookise: session_id=xxxxxx

此時服務端是這樣處理的:

public String ownerInfo(Request request, Response response) {
  // 獲取key爲session_id的Cookie
  Cookie cookie = request.getCookie("session_id");
  // 獲取該Cookie中的值,也就是Session的ID
  String sessionId = cookie.getValue();

  // 根據SessionID在Server上獲取對應的Session
  Session session = getSession(sessionId);

  // 獲取Session中的對象
  Account account = session.getObject("user_account");
  ...
}

以上代碼服務端就可以拿到之前爲與該用戶的會話保存的各種對象了,這個整個流程就是 Cookie 和 Session 的區別。

本來還要講一下服務端和客戶端的業務設計,因爲篇幅限制,本文到這裏就要結束了。

另外也可以參考我在知乎上的幾個回答:

下次會專門寫一篇 HTTP 的業務設計相關文章,告辭!


本文參考了以下鏈接的內容:


版權聲明:轉載必須註明本文轉自嚴振杰的博客: http://blog.yanzhenjie.com

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章