通信的本質是信息的交換,在HTTP協議中,客戶端是信息的請求者,伺服器是信息的提供者,且對於任意一次通信,客戶端與伺服器的角色都是明確的,形如「一問一答」。

HTTP協議規定的這種「一問一答」的通信形式,直接導致了任何一次通信都是由客戶端發起的。也就是說除非是特殊定製的,伺服器無法主動與客戶端發起一次通信。

在實際通信中,通信雙方的身份可能會互換,但是他們的身份依然能夠通過HTTP協議規定的內容給標識出來。這個會在後面介紹到,下面我們直觀地看一個HTTP通信實例。

HTTP通信實例

上圖是我隨手在瀏覽器控制台挑選的一次通信內容,圖中可以清晰地看到此次通信中請求的地址、請求的方法、響應狀態等內容。常見的,HTTP通信報文中還應註明協議版本號、報文長度、請求報文內容格式、期望接收的響應報文內容格式等。這張圖中的內容有很多,如果正在讀文的你一頭霧水也沒關係,我們只需要抓住了它的幾個要點,那些欄位所代表的含義都能根據字面意思得到。

通信是由請求和響應的交換達成,此處我希望通過一個登錄的例子來更為直觀地說明這一點。這個例子是這樣的,我們希望通過輸入用戶名與密碼來登錄知乎網站(為了簡單說明,假設此處無需驗證碼)。我們的輸入構成一個表單,之後我們將這個表單提交到後台進行驗證,這個驗證的地址假設為「知乎網站的域名(或是IP地址加埠)」+「/login/doVerifyAction」。提交到這個地址進行驗證,並返回驗證是否成功的消息。

要做的事情很簡單,那接下來我們看看為了達成這個目的,我們需要做些什麼。

發送請求

首先,我將HTTP協議的版本號填入,這個是在網站開發的時候就約定好了的。

其次,我們需要傳輸的用戶名與密碼我們希望它是隱秘的而不是赤裸裸地掛在URL的後面,故我使用提交表單的POST方法而不是GET方法。

再次之,我我們需要將這個表單發往「zhihu.com/login/doVerif」,因此此處需要指定URI。

再次之,我還希望聲明我發送的數據格式為Json格式(這個也需要在網站開發的時候約定好,因為我們需要指定伺服器以何種格式解析請求的內容實體),且發送的內容主體有n個位元組,故我又聲明了發送的數據格式與內容長度。

最後,在空開一行後,填入需要發送的內容主體。

上述內容在請求報文的實際應用中組成形式如下:

請求報文構成

上述的請求報文再經瀏覽器發出後到達伺服器的該地址進行處理,而後伺服器會將需要返回的內容進行封裝,最後返回給瀏覽器,並由瀏覽器進行解讀。

接收響應

這個響應報文是這樣的:

響應報文構成

此時,瀏覽器已經拿到了它能用來解析並顯示給用戶看的消息了。

這個簡單的小栗子只是幫助對HTTP協議一點頭緒都沒有的同學建立一個初步的概念。更多關於這些欄位的進一步理解與學習會在後面的文檔中陸續聊到。

圖一圖二分別提及了GET請求以及POST請求,那麼他們分別都是幹什麼的,為什麼有這些區分呢。

HTTP1.1中定義的HTTP方法有以下幾種:

  1. GET:獲取資源
  2. POST:提交表單
  3. PUT:傳輸文件
  4. HEAD:獲得報文首部
  5. DELETE:刪除文件
  6. OPTIONS:詢問支持的方法
  7. TRACE:追蹤路徑
  8. CONNECT:要求使用隧道協議鏈接代理

如果你大致明白現在流行的RESTful api,那麼理解這個會更加輕鬆。說白了,我們進行的HTTP通信逃不離對資源的操作,或查詢,或刪除,或修改,或新增。如果我們對於不同的操作使用不同的方法,這樣伺服器就能直接理解我們的意圖了,既整齊又直觀。但是需要注意的是,現階段並不是所有的網站都採用RESTful 標準的。也就是說,實際應用中,除了採用RESTful標準開發的網站會開放上述3~6的方法以外,其他大多隻使用GET方法與POST方法。筆者這裡其實只是淺薄地通過一些文章對RESTful開發標準的幾個層級有一個概念上的認知,還沒有過具體實踐,所以更多的細節請讀者自行了解,此處不便展開聊了。

需要多提一下的上GET方法與POST方法的區別。如果你在知乎中搜索「GET方法與POST方法的區別」這一主題,你能十分迅速地得到一個巨大的列表,諸如「編碼類型」、「是否能被緩存」、「能攜帶的參數長度」、「攜帶數據的可見性」、「安全性的差別」等。這些都能使得我們直觀地看到這兩個最常用方法的區別。

但是此處我想提及的是,GET方法一開始被設計用作「只讀操作」,至少協議是這麼規定的,即從伺服器端請求一個資源,而不對伺服器的任何資源進行修改,但是實際操作中並不是這樣,比如我們在做一個刪除介面的時候,我們僅僅是需要一個待刪除資源的ID而已,那麼我們可能會使用GET方法,在URL後追加參數,形如「/user/remove?userId=10001」。那麼此處我們就是對伺服器的資源做出了修改了,因此我們需要著重關注協議的具體應用而不是記牢它的概念。

而POST方法被設計用作「處理我所攜帶著的數據」,而不是說像協議規定的GET方法那樣,需求一個結果,也就是說我目的就是為了修改伺服器端的數據而被設計出來的。它得到的響應主體內容大多是通知客戶端關於伺服器端這邊對數據處理後的結果,成功或失敗。

HTTP協議是不保存狀態的,也就是說伺服器沒長「記性」這個東西,你上次與它進行過通信了一點也不妨礙它下回通信不認識你。我記得在知乎上看到過一個特有意思的比喻,那位作者是這麼打比方的。說小明常去樓下理髮店剪頭,去了很多回也沒見這家店老闆記住他,剪頭給他個熟人價什麼的。後來老闆也覺得自己老記不住回頭客不大好,得回饋捧場的老客戶才行,於是就給到店剪頭的客人發了一個編號讓客人收下,自己也用小本本記下了這個編號,之後再有客人來,問下客人有編號沒有,有的話拿著去查小本本,一看有這麼個編號,那麼就認定是老客戶就給個剪頭88折什麼的。

那麼這個例子映射到咱HTTP協議上來呢,就是說,使用HTTP協議的伺服器不記客戶端的信息,上一次雙方通信與本次雙方通信都像是混沌初開頭一回,為了使得伺服器能記住客戶端信息而引入了Cookie技術。

剪頭店老闆(伺服器)給客人(客戶端)發送了一個唯一的編號(sessionId),客人揣兜里(客戶端把登錄驗證通過後伺服器返回的sessionId存進Cookie中,類似於一個客戶端的文件夾)。下次到店剪頭(下次訪問伺服器),掏出兜里的唯一編號(攜帶sessionId發送請求),店老闆到小本本上一查找(伺服器使用sessionId查詢「用戶登錄狀態列表」,一般是一個伺服器緩存),一瞅,老客戶(在表中找到此sessionId,即已登錄)。

當然,實際上的實現遠不止如此簡單,它還得檢測sessionId的過期時間(你這個客人都是三年前來店裡剪的頭,一點也不捧我場,只有兩次剪頭間隔不超過兩個月的編號才有效喲)當然這個不屬於Cookie技術的範疇了,而是伺服器緩存對於過期一個元素的具體設定,它是主動檢查過期還是等要用到了再檢查是否過期...這裡筆者不敢再多扯否則這篇文章就太啰嗦。

HTTP協議設計成不保存狀態的形式,在《圖解》中的解釋是為了儘可能多地處理大量事務、確保協議的可伸縮性。第一句好理解,處理的東西簡單效率高是肯定的。協議的可伸縮性可以這麼理解:在咱們Java世界中,你會看到有許多功能強大的東西頂級其實就是一個十分簡單的介面,裡面可能就定義了一個到兩個方法,自己不實現,給實現它的幾個抽象類實現一部分公用的,再根據具體需求擴展一部分方法。最終幹活的可能是最終繼承這幾個抽象類的幾個具體類。

上述的,為HTTP協議提供狀態的除了Cookie技術,常見的還有一種Token技術,這兩種技術本質上是不同的,Cookie技術要求伺服器開闢內存來管理sessionId,同時給客戶端發送一份保存到客戶端本地,下次訪問時攜帶這個sessionId訪問就好了。而Token技術則不需要開闢內存管理sessionId,而是將sessionId生成後加上生成的時間進行摘要、加密、簽名、打包成token發送給客戶端,下次訪問發送這個token,這個token除了伺服器誰都看不懂,因此伺服器雖然沒了內存的開銷,但卻額外加了CPU的開銷 -- 為了解讀這個token,具體哪個好用怎麼取捨就見仁見智。這兩種技術要往細了說都值得開一篇單章了,此處只做簡單介紹。

值得一提的是Cookie第一次由伺服器給到客戶端是通過響應頭欄位Set-Cookie完成的,而客戶端給伺服器發送請求攜帶cookies的時候是通過請求頭欄位Cookie這個欄位完成的。至於Token,我所見過的採用這種做法的一般通過捏造一個請求頭來傳遞的 -- 畢竟它是加密後的,放到Headers中裸奔也無所謂。

在「第一回」中咱們說到,HTTP協議在傳輸短小信息的時候是十分合適的,但是並不適合傳輸稍大的信息,這是怎麼回事呢。我們知道,HTTP每次通信都會導致一次TCP連接的一次開閉,而每次TCP連接的開閉都有三次握手、四次揮手這種繁複的操作。這一頓操作都是消耗資源的。這在很久以前的通信情況下一點問題都沒有,以前的Web內容形式比較單一,多為文本,體量小。圖片都比較少,質量也低。

那麼到了現在,請求一個HTML頁面裡面可能包含了十數個圖片。那麼為了完成載入這個HTML頁面,還得頻繁開閉TCP連接來建立通信傳輸這些圖片 -- 這些資源實際上是一個展示的整體。客戶端都一次性要的,倒不如優化一下,只要這個HTML頁面沒有載入完所有需要的資源,我就通知TCP連接不要斷開,這樣就直接省下了一大部分的開閉連接的開銷,順手還把時間給省下了。

HTTP協議里有「Keep-Alive」這麼個欄位來保持連接狀態。但是悲傷的是,這個欄位需要客戶端與伺服器共同支持使用。雖然在HTTP1.1中都默認開啟了,但是許多伺服器都選擇關閉掉 -- 這樣做也是可以理解的,伺服器能提供的連接數有限,如果大家各自都長久地佔用(或惡意佔用)一個連接,那我伺服器還要不要給別人服務了。這些伺服器代碼往往都有自己的一套保持連接的實現,例如發送心跳包探聽之類的,這樣就將提供服務的選擇權更多地握在了自己的手裡。

除此之外,原文還這麼說道:

持久連接使得多數請求以管道化方式發送成為可能

我們知道,通信是以「一問一答」的形式進行的,我們在前文實現了TCP保持連接的前提下,假設有A與B兩個請求排成隊列交由TCP連接 -- t1處理,A請求在伺服器受阻了,堵著半天才反應,這個時候B請求因為A請求占著t1過長時間而被延遲處理了。聰明的人們想了,乾脆,發明一個東西,不要求「一問一答」了,你直接把所有的請求一併丟過來,我伺服器挨個處理了返回給你就是了,你到時按照你發送的順序依次解讀返回的實體內容。

那麼,A請求B請求就愉快地一併被伺服器接收,而後處理了返回。敏銳的同學能立刻感覺到,驚了!你這個是把線程安全的操作變得線程不安全了啊(同步變並行)。那麼如果B請求的參數依賴於A請求結果的情況下,這個小東西會直接導致B請求的結果不是我想要的結果啊。

沒錯!就是如你想的一般,所以人官方特地強調了形如GET方法、HEAD這種「冪等的」方法(此處筆者粗暴地理解為只讀的方法)管線化隨便用。但是POST方法這種「非冪等的」方法你就別用了,省的出現線程不安全問題。

人還說了,管線化的快速還體現在另一方面:

several HTTP requests in the same TCP packet, HTTP pipelining allows fewer TCP packets to be sent over the network, reducing network load

將多個HTTP請求塞到一個TCP包裹中,HTTP管道能減少TCP數據包裹在網路上傳輸,降低網路負載。

好了,讀薄《圖解》第二章總算是寫完了,我寫在知乎的文章大多是本地讀書文檔整理上傳,將只有自己看的懂的片段與文字寫成大家都能看的懂的文字確實有些難度的,我老是會陷入文字不夠精簡的泥沼,還請讀者能多多包涵。限於水平,文章也有很多不足與錯誤的地方請讀者或大佬斧正。也歡迎大家多多留言交流,有人看是俺繼續寫下去的動力,蟹蟹。

推薦閱讀:

相关文章