大話 HTTP 協(xié)議前世今生
HTTP 是一種通過(guò)網(wǎng)絡(luò)傳輸數(shù)據(jù)的協(xié)議。我們不希望數(shù)據(jù)在傳輸?shù)倪^(guò)程中出現(xiàn)丟失或者損壞的問(wèn)題。所以 HTTP 選用 TCP 作為底層網(wǎng)絡(luò)協(xié)議,因?yàn)?TCP 是一種可靠的傳輸層協(xié)議。
通信雙方就建立 TCP 連接后立馬發(fā)現(xiàn)一個(gè)新問(wèn)題:服務(wù)端要給客戶端發(fā)送什么數(shù)據(jù)呢?所以客戶端必需在連接建立后將自己想要的內(nèi)容發(fā)送給服務(wù)端,這就是所謂的「請(qǐng)求」,也就 HTTP Request。由此就確立了 HTTP 協(xié)議最根本的設(shè)計(jì),即由客戶端主導(dǎo)的請(qǐng)求應(yīng)答式協(xié)議。
客戶端上來(lái)就給服務(wù)端發(fā)了一個(gè)「請(qǐng)求」。但服務(wù)端有可能收到的內(nèi)容跟客戶端并不完全一樣。等等,TCP不是可靠傳輸協(xié)議嗎?接收到的數(shù)據(jù)怎么會(huì)不一樣?這就涉及到數(shù)據(jù)分段的問(wèn)題。比如客戶端發(fā)送”abcdef”,底層 TCP 協(xié)議可能分兩次傳輸 “abc” 和 “def”,也可能分好多次傳輸。不論分幾次,它們的順序是固定的,跟客戶端發(fā)送的順序完全一致。服務(wù)端可能會(huì)收到多段數(shù)據(jù),所以服務(wù)端需要把收到的數(shù)據(jù)「攢」起來(lái),等到客戶端的數(shù)據(jù)全部收到之后才能看到客戶端「請(qǐng)求」的全貌。
那到什么時(shí)候算全部收到呢?這是 TCP 通信的一個(gè)基本問(wèn)題。解決這個(gè)問(wèn)題有兩個(gè)流派:長(zhǎng)度流和分隔符流。
所謂長(zhǎng)度流就是在實(shí)際發(fā)送數(shù)據(jù)之前,先發(fā)送數(shù)據(jù)的長(zhǎng)度。服務(wù)端先讀取長(zhǎng)度信息,然后再根據(jù)長(zhǎng)度來(lái)「攢」后面的數(shù)據(jù)。那服務(wù)端在讀取長(zhǎng)度的時(shí)候不會(huì)碰到分段問(wèn)題嗎?其實(shí)不會(huì),因?yàn)?TCP 只會(huì)對(duì)比較長(zhǎng)的數(shù)據(jù)做分段。前面說(shuō)的”abcdef”分兩段只是一種極端的例子,實(shí)際上很難發(fā)生。所以,只要先發(fā)送的長(zhǎng)度數(shù)據(jù)不要太長(zhǎng),服務(wù)端就能一次性收到。退一步,即便是真的會(huì)分段,這類長(zhǎng)度流協(xié)議都會(huì)規(guī)定長(zhǎng)度數(shù)據(jù)自身的長(zhǎng)度。比如用兩個(gè)字節(jié)表示長(zhǎng)度,那范圍就是數(shù)據(jù)長(zhǎng)度的范圍就是0-65535。服務(wù)端可以先收兩個(gè)字節(jié),然后再根據(jù)數(shù)據(jù)長(zhǎng)度來(lái)接收后面的內(nèi)容。
長(zhǎng)度流最大的優(yōu)點(diǎn)就是實(shí)現(xiàn)簡(jiǎn)單,內(nèi)存效率高,服務(wù)端不用事先分配很多內(nèi)存。但缺點(diǎn)也比較突出,長(zhǎng)度的范圍不夠靈活。如果我們規(guī)定長(zhǎng)度字段為兩個(gè)字節(jié),但就不能傳輸超過(guò)64k的數(shù)據(jù)。但如果規(guī)定長(zhǎng)度字段為八個(gè)字節(jié),那在傳輸比較短的數(shù)據(jù)時(shí)就造成浪費(fèi)。如何設(shè)置最優(yōu)長(zhǎng)度字段,大家可以參考我的另一篇文章。
此外,長(zhǎng)度流的擴(kuò)展性也比較差。如果我們想在長(zhǎng)度之外傳輸其他信息,比如數(shù)據(jù)類型、版本號(hào)之類,我們都需要提前規(guī)定好這些數(shù)據(jù)的長(zhǎng)度。長(zhǎng)度一旦定好,以后就很難擴(kuò)展了。最典型的長(zhǎng)度流協(xié)議就是 IP 報(bào)文。有興趣的朋友可以去看看 IP 協(xié)議是怎么規(guī)定數(shù)據(jù)長(zhǎng)度的。
有鑒于長(zhǎng)度流的不足,人們又搞出了分割符流。簡(jiǎn)單來(lái)說(shuō)就是用一個(gè)特殊的分割符表示數(shù)據(jù)的結(jié)尾。最經(jīng)典的例子就是C語(yǔ)言的字符串,結(jié)尾用\0來(lái)表示。使用這個(gè)流派的服務(wù)端程序要不停地從客戶端接收數(shù)據(jù),直到收到某一個(gè)分割符,就表明已經(jīng)收到了完整的「請(qǐng)求」。
因?yàn)椴恍枰孪戎付〝?shù)據(jù)的長(zhǎng)度,所以分割符流派一下子就解決了長(zhǎng)度流長(zhǎng)度范圍不靈活的問(wèn)題。分割符流派的協(xié)議可以接收任意長(zhǎng)度的數(shù)據(jù)。但是,分割符流派為些也付出了代價(jià)。因?yàn)殚L(zhǎng)度不固定,服務(wù)端必須分配比較大的內(nèi)存或者多次動(dòng)態(tài)分配內(nèi)存,這會(huì)產(chǎn)生比較大的資源消耗。惡意用戶可能通過(guò)構(gòu)造很長(zhǎng)的數(shù)據(jù)來(lái)占滿服務(wù)器的內(nèi)存。
但是 HTTP 協(xié)議還是加入了這個(gè)流派,它用的分割符是\r\n。這里的\r表示回車,就是讓打印機(jī)把打印頭回到最左邊的位置。\n表示換行,就是讓打印機(jī)把紙向上挪一行,準(zhǔn)備打印新的實(shí)符。上古時(shí)代的電腦沒(méi)用現(xiàn)在的液晶屏,用電傳打印機(jī)來(lái)「顯示」內(nèi)容,所以需要傳輸\r\n兩個(gè)字符。現(xiàn)在這些都淘汰了,理論上用\n也可以,像 Nginx 就支持只用\n。
所以,一個(gè)最簡(jiǎn)單的 HTTP 請(qǐng)求長(zhǎng)這個(gè)樣子:
GET?/mypage.html\r\n
這里的GET是一種擬人的說(shuō)法,從服務(wù)拿什么東西。這也是 HTTP 語(yǔ)義化設(shè)計(jì)的開(kāi)端(所謂語(yǔ)義化就是普通人能看懂)。后面跟一個(gè)空格,再后面是文件的路徑。最后是分割符\r\n。因?yàn)樽詈笫荺r\n,所以上面的數(shù)據(jù)也叫請(qǐng)求行(request line)。
客戶端跟服務(wù)器建立連接后就立即發(fā)送上面的數(shù)據(jù)。服務(wù)端等收到\r\n后開(kāi)始解析,也就是把/mypage.html提取出來(lái),然后找到對(duì)應(yīng)的文件,把文件內(nèi)容發(fā)送給客戶端。
到這里,客戶端就收到了服務(wù)端發(fā)送的文件內(nèi)容,也叫「響應(yīng)」。但是,客戶端馬上面臨服務(wù)端同樣的問(wèn)題:如何確定已經(jīng)收到了 mypage.html 的完整的內(nèi)容呢?服務(wù)端要不要在最后發(fā)送分割符\r\n呢?不能!因?yàn)?mypage.html 的內(nèi)容里本身就可能包含\r\n。如果客戶端還是以\r\n當(dāng)作結(jié)束標(biāo)記,那可能會(huì)丟失數(shù)據(jù)。
為此 Tim Berners-Lee (HTTP 協(xié)議之父) 采用了更簡(jiǎn)單的辦法——關(guān)閉連接。也就是說(shuō),服務(wù)器在傳輸完成之后要主動(dòng)關(guān)閉 TCP 連接,這樣客戶端就明確知道所有的內(nèi)容已經(jīng)傳輸完成了。
以上就是最原始的 HTTP 協(xié)議,大約在1990發(fā)布?,F(xiàn)在稱這個(gè)時(shí)代的 HTTP 協(xié)議為 HTTP/0.9,主要是跟后面標(biāo)準(zhǔn)化之后的 1.x 進(jìn)行區(qū)分。就這樣,萬(wàn)維網(wǎng)的時(shí)代開(kāi)啟了。
HTTP/0.9 發(fā)布后得到了廣泛的應(yīng)用。但它的功能太簡(jiǎn)單了,所以很多瀏覽器都在它的基礎(chǔ)上做了擴(kuò)展。最主要的擴(kuò)展功能有如下幾個(gè):
-
添加版本信息 -
添加擴(kuò)展頭信息 -
添加返回狀態(tài)信息
添加版本信息是為了方便客戶端和服務(wù)端相互識(shí)別,這樣才能開(kāi)啟擴(kuò)展功能。添加之后的請(qǐng)求行如下:
GET?/mypage.html?HTTP/1.0\r\n
添加擴(kuò)展頭信息是為了傳遞更多的擴(kuò)展信息。比如,這時(shí)候不同的瀏覽器會(huì)在請(qǐng)求中標(biāo)記自己的身份。為方便后續(xù)添加各種不同的擴(kuò)展信息,HTTP協(xié)議繼續(xù)使用「行」和分割符的概念。
首先,跟請(qǐng)求行保持一致,每一條擴(kuò)展信息占一行,以冒號(hào)分割,以\r\n結(jié)尾,比如:
User-Agent:?NCSA_Mosaic/2.0?(Windows?3.1)\r\n
其次,這種信息可以有多行。那服務(wù)端怎么確定到底有幾行呢,這還得用到分割符\r\n。HTTP 協(xié)議用一個(gè)空行表示后面擴(kuò)展信息都結(jié)束了。所以完整的請(qǐng)求是:
GET?/mypage.html?HTTP/1.0\r\n
Host:?taoshu.in\r\n
User-Agent:?NCSA_Mosaic/2.0?(Windows?3.1)\r\n
\r\n
服務(wù)端先接收一行,提取文件路程,然后再根據(jù)\r\n逐行提取擴(kuò)展信息。如果收到一個(gè)空行,則說(shuō)明擴(kuò)展信息接收完成。這些擴(kuò)展信息也叫頭信息(header),后續(xù) HTTP 協(xié)議的各種特性都是基于它來(lái)實(shí)現(xiàn)。
HTTP/0.9 收到請(qǐng)求后直接傳輸文件內(nèi)容。但用些場(chǎng)景需要返回其他信息,比如文件不存在之類的,所以人們給它添加了返回狀態(tài)信息。此外,擴(kuò)展后的 HTTP 協(xié)議也支持服務(wù)端在發(fā)送數(shù)據(jù)前返回多個(gè)頭信息。一個(gè)典型的擴(kuò)展響應(yīng)為:
200?OK\r\n
Date:?Tue,?15?Nov?1994?08:12:32?GMT\r\n
Server:?CERN/3.0?libwww/2.17\r\n
Content-Type:?image/gif\r\n
\r\n
(image?content)
服務(wù)器首先會(huì)發(fā)一行數(shù)據(jù)200 OK\r\n。這里的200是狀態(tài)碼,表示成功。后面的OK是給人看的語(yǔ)義部分。這一行也叫 status code line。緊接著就是擴(kuò)展信息,形式跟請(qǐng)求里的一模一樣,每行一條,以空行表示結(jié)束。最后才是文件內(nèi)容。
因?yàn)橛辛祟^信息,HTTP協(xié)議的擴(kuò)展性直接起飛。人們不斷給 HTTP 協(xié)議添加各種種樣的特性。
HTTP/0.9 只能傳輸純文本文件。因?yàn)橛辛?Header,我們可以傳輸更多的描述信息,比如文件在的類型、長(zhǎng)度、更新時(shí)間等等。這些傳輸數(shù)據(jù)的描述信息也被稱為 Entity Header,數(shù)據(jù)本身稱為 Entiy。
常見(jiàn)的 Entiy Header 有:
-
Content-Type 內(nèi)容類型 -
Content-Length 內(nèi)容長(zhǎng)度 -
Content-Encoding 數(shù)據(jù)編碼
Content-Type 表示數(shù)據(jù)類型,比如 gif 的類型是image/gif。類型的取值最終被標(biāo)準(zhǔn)化為 Multipurpose Internet Mail Extensions(MIME)。
Content-Length 表示數(shù)據(jù)長(zhǎng)度。但我們前面說(shuō)過(guò),HTTP/0.9 的服務(wù)器不需要返回文件長(zhǎng)度,等傳輸完畢后關(guān)閉 TCP 連接就好了。為什么又要定義長(zhǎng)度信息呢?
這里有兩個(gè)問(wèn)題。第一個(gè)是在請(qǐng)求里支持上傳內(nèi)容,第二個(gè)是連接優(yōu)化問(wèn)題。
HTTP/0.9 只有一種 GET 請(qǐng)求。顯然光下載是不夠的。人們陸續(xù)引入了 HEAD 和 POST 等請(qǐng)求,用來(lái)給服務(wù)器提交數(shù)據(jù)。一但要提交數(shù)據(jù),光用分割符就不夠了。因?yàn)樘峤坏臄?shù)據(jù)本身就可能包含分割符。所以需要事先指定數(shù)據(jù)的長(zhǎng)度。這個(gè)長(zhǎng)度用的就是 Content-Length 頭來(lái)指定。
另外一個(gè)是連接優(yōu)化問(wèn)題。其實(shí) HTTP 協(xié)議的發(fā)展史很大程度上就是傳輸性能的優(yōu)化史。
HTTP/0.9每次請(qǐng)求都會(huì)創(chuàng)建一個(gè) TCP 連接,讀取結(jié)束后連接就會(huì)被關(guān)閉。如果一次只下載一個(gè)文件也沒(méi)什么問(wèn)題。但后來(lái) HTML 頁(yè)面支持嵌入圖片等內(nèi)容,一個(gè)頁(yè)面可能有多個(gè)圖片。這樣瀏覽器打開(kāi)一個(gè) HTML 頁(yè)面的時(shí)候就需要發(fā)起多次 HTTP 請(qǐng)求,每次請(qǐng)求都要反復(fù)建立和關(guān)閉 TCP 連接。不但浪費(fèi)服務(wù)器資源,還會(huì)拖慢頁(yè)面的加載速度。
所以,大家就想辦法復(fù)用底層的 TCP 連接。簡(jiǎn)單來(lái)說(shuō)就是服務(wù)器在內(nèi)容發(fā)送完成后不主動(dòng)關(guān)閉連接。但不關(guān)閉就會(huì)出現(xiàn)前面說(shuō)的問(wèn)題,客戶端不知道響應(yīng)內(nèi)容什么時(shí)候傳輸完畢。所以需要事先指定數(shù)據(jù)的長(zhǎng)度。因?yàn)?HTTP 協(xié)議已經(jīng)有了 header 機(jī)制,所以添加 Content-Length 就是最自然的辦法。
這里還有一個(gè)兼容性問(wèn)題。如果客戶端不支持復(fù)用 TCP 連接,那服務(wù)端不關(guān)閉連接的話客戶端就會(huì)一直在等待。所以復(fù)用 TCP 連接這個(gè)功能不能默認(rèn)開(kāi)啟,而是應(yīng)該由客戶端決定要不要使用。這就引出了Connection:Keep-Alive這個(gè)頭信息。如果客戶在請(qǐng)求中指定 Keep-Alive,服務(wù)端才不會(huì)主動(dòng)關(guān)閉 TCP 連接。
除了復(fù)用 TCP 連接之外,HTTP/0.9 另一個(gè)值得優(yōu)化的地方就是數(shù)據(jù)壓縮。那個(gè)時(shí)代網(wǎng)速很慢,如果能把數(shù)據(jù)壓縮之后再傳輸可以顯著降低傳輸耗時(shí)。服務(wù)端不能隨意壓縮,因?yàn)橛械目蛻舳丝赡懿恢С?。所以就先引入了Accept-Encoding這個(gè)頭,可能的取值如compress或者gzip。服務(wù)端收到這個(gè)請(qǐng)求之后才對(duì)內(nèi)容做壓縮。因?yàn)闉g覽器可能支持多種壓縮算法,瀏覽器需要選擇一種自己也支持的來(lái)壓縮數(shù)據(jù),所以就需要在返回內(nèi)容的時(shí)候指定自己用了哪種算法。這就是Content-Encoding頭的用途。
不論是前面的 Connection 還是后面的 Accept-Encoding,為了盡可能地兼容不同客戶端,HTTP 協(xié)議會(huì)通過(guò)添加新的 header 來(lái)協(xié)商是否使用擴(kuò)展特性。這種協(xié)商由客戶端來(lái)主導(dǎo),服務(wù)器需要根據(jù)客戶端的請(qǐng)求來(lái)配合完成。
還是因?yàn)榫W(wǎng)絡(luò)比較慢而且成本很高,HTTP協(xié)議需要進(jìn)一步優(yōu)化數(shù)據(jù)傳輸效率。一個(gè)典型的場(chǎng)景是客戶端已經(jīng)下載過(guò)某文件內(nèi)容。當(dāng)客戶端再次請(qǐng)求的時(shí)候,服務(wù)端還要不要返回。如果不返回,則客戶端拿不到最新的內(nèi)容;如果返回,當(dāng)服務(wù)端的文件沒(méi)有變化的時(shí)候,客戶端會(huì)花很長(zhǎng)時(shí)間加載一個(gè)已經(jīng)下載過(guò)的文件。怎么優(yōu)化這個(gè)問(wèn)題呢?
人們引入了如下 Entity Header:
-
Last-Modified 最近修改時(shí)間 -
Expires 過(guò)期時(shí)間
如果文件不經(jīng)常改動(dòng),服務(wù)器可以對(duì)過(guò) Last-Modified 把最近修改時(shí)間發(fā)送給瀏覽器。瀏覽器如果支持,可以在下次請(qǐng)求該資源的時(shí)候帶上這個(gè)時(shí)間,也就是在請(qǐng)求里添加下面的頭:
If-Modified-Since:?Sat,?29?Oct?1994?19:43:31?GMT\r\n
服務(wù)器收到后會(huì)跟文件的當(dāng)前修改時(shí)間做對(duì)比,如果沒(méi)有修改則直接返回304:
304?Not?Modified\r\n
這種叫作條件請(qǐng)求,可以顯著減少不必要的網(wǎng)絡(luò)傳輸。
即使如此,客戶端還是發(fā)起一次 HTTP 請(qǐng)求才能拿到 304 響應(yīng),也會(huì)產(chǎn)生網(wǎng)絡(luò)傳輸和服務(wù)端開(kāi)銷。為了進(jìn)一步優(yōu)化,HTTP又引入了 Expires 頭,它的含義是一個(gè)未來(lái)的過(guò)期時(shí)間。在這個(gè)時(shí)間之前瀏覽器可以安全使用本地緩存的副本,不需要從服務(wù)器下載。這樣連條件請(qǐng)求都不需要發(fā)起了。
不過(guò) Expires 特性有一個(gè)副作用,文件一旦下發(fā),在過(guò)期之前根本無(wú)法修改。
大約是在1991-1995這個(gè)時(shí)間,各瀏覽器廠商陸續(xù)實(shí)現(xiàn)了上述功能。但不同瀏覽器和服務(wù)端軟件支持的功能不同,帶來(lái)各種兼容問(wèn)題。于是到 1996 年,IETF 發(fā)布 RFC1945。RFC1945 只能說(shuō)是當(dāng)前最佳實(shí)踐的總結(jié),并不是推薦標(biāo)準(zhǔn)。但人們還是稱它為 HTTP/1.0。
沒(méi)過(guò)一年,也就是1997年,IETF就發(fā)布了RFC2068,也就是大名鼎鼎的 HTTP/1.1 協(xié)議規(guī)范。
HTTP/1.1 是對(duì) HTTP/1.0 的梳理和擴(kuò)展。核心的改動(dòng)有:
-
默認(rèn)開(kāi)啟 TCP 連接復(fù)用,客戶端不需要再發(fā)送 Connection:Keep-Alive -
添加了所謂 pipeline 特性,進(jìn)一步優(yōu)化傳輸效率 -
支持 chunked 傳輸編碼 -
擴(kuò)展緩存控制 -
內(nèi)容協(xié)商,包括語(yǔ)言、傳輸編碼、類型等 -
在同一IP上建立多個(gè) HTTP 網(wǎng)站
所謂的 pipeline 特性是對(duì) HTTP 協(xié)議傳輸效率的進(jìn)一步優(yōu)化,但最終失敗了。
HTTP 協(xié)議是請(qǐng)求應(yīng)答式協(xié)議??蛻舳税l(fā)一個(gè)請(qǐng)求,然后等待服務(wù)端返回內(nèi)容。雖然在 HTTP/1.0 時(shí)代就有了 TCP 連接復(fù)用、內(nèi)容壓縮和條件請(qǐng)求等優(yōu)化機(jī)制,但客戶端發(fā)起新請(qǐng)求之前必須等待服務(wù)器返回內(nèi)容。換言之就是客戶端無(wú)法在一個(gè)連接上并行發(fā)起多個(gè)請(qǐng)求。為此,HTTP/1.1 的 pipeline 就規(guī)定客戶端可以依次發(fā)起多個(gè) HTTP 請(qǐng)求,然后等待服務(wù)器返回結(jié)果。服務(wù)器需要按照請(qǐng)求順序依次返回對(duì)應(yīng)的響應(yīng)內(nèi)容。
??c????????s??????????????c????????s
??|??req1??|??????????????|??req1??|
??|------->|??????????????|------->|
??|??resp1?|??????????????|??req2??|
??|<-------|??????????????|------->|
??|??req2??|??????????????|??req3??|
??|------->|??????????????|------->|
??|??resp2?|??????????????|??resp1?|
??|<-------|??????????????|<-------|
??|??req3??|??????????????|??resp2?|
??|------->|??????????????|<-------|
??|??resp3?|??????????????|??resp3?|
??|<-------|??????????????|<-------|
??
without?pipeline?????????with?pipeline
雖然服務(wù)器收到多個(gè)請(qǐng)求的時(shí)候可以并發(fā)處理,這種并發(fā)帶來(lái)的優(yōu)化有限,而且 pipeline 特性并沒(méi)有減少實(shí)際的網(wǎng)絡(luò)傳輸。幾乎沒(méi)有軟件實(shí)現(xiàn) pipeline 特性,所以這個(gè)優(yōu)化設(shè)計(jì)以失敗告終。
chunked 編碼是一項(xiàng)非常成功的優(yōu)化,主要解決服務(wù)端動(dòng)態(tài)生成響應(yīng)內(nèi)容的情況。
HTTP/1.0 只能使用 Content-Length 指定內(nèi)容長(zhǎng)度,而且是先發(fā)送 header 再發(fā)送 body。這就要求必須在傳輸內(nèi)容之前確定內(nèi)容的長(zhǎng)度。對(duì)于靜態(tài)文件,這當(dāng)然不是問(wèn)題。但如果要加載一個(gè)由 PHP 動(dòng)態(tài)渲染的 HTML 就有問(wèn)題了。因?yàn)?HTML 是程序動(dòng)態(tài)生成的,沒(méi)法事先確定內(nèi)容長(zhǎng)度。如果還用原來(lái)的辦法,只能先把內(nèi)容生成好保存到一個(gè)臨時(shí)文件,再發(fā)送給客戶端。顯然這種性能太差。
為了解決這個(gè)問(wèn)題,HTTP/1.1 引入 chunked 編碼。簡(jiǎn)單來(lái)說(shuō)就是回到之前的長(zhǎng)度流,將數(shù)據(jù)逐段發(fā)送給客戶端,每一段前面加上長(zhǎng)度信息:
HTTP/1.1?200?OK\r\n
Content-Type:?text/plain\r\n
Transfer-Encoding:?chunked\r\n
7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
\r\n
Transfer-Encoding 指定為 chunked。接下來(lái)的數(shù)據(jù)也是分行傳輸。一行長(zhǎng)度,一行數(shù)據(jù)。結(jié)束的時(shí)候長(zhǎng)度指定為零,然后再加一個(gè)空行。這樣服務(wù)端就不需要事先確定響應(yīng)內(nèi)容的長(zhǎng)度,PHP 就可以有邊渲染一邊發(fā)送。這個(gè)特性還是 WebSocket 沒(méi)有普及的年代被用于實(shí)現(xiàn)消息推送。大家可以搜索 Comet 或者 HTTP 長(zhǎng)輪詢了解更多信息。
HTTP/1.1 對(duì)緩存做了更粗細(xì)化的定義,引入了 Cache-Control 擴(kuò)展信息。這一部分內(nèi)容比較復(fù)雜,除了會(huì)影響瀏覽器的緩存行為之外,還會(huì)影響 CDN 節(jié)點(diǎn)的行為。部分 CDN 廠商還會(huì)擴(kuò)展 標(biāo)準(zhǔn)緩存指令的語(yǔ)義。限于篇幅,在此就不展開(kāi)了。
但 HTTP/1.1 對(duì)條件請(qǐng)求做了擴(kuò)展,可以說(shuō)一下。
操作系統(tǒng)會(huì)自動(dòng)記錄文件的修改時(shí)間,讀取該時(shí)間也非常方便,但 Last-Modified 不能覆蓋所有情況。有時(shí)候我們需要用程序定時(shí)生成某些文件,它的修改時(shí)間會(huì)周期性變化,但內(nèi)容不一定有改變。所以光用 Last-Modified 還是可能產(chǎn)生不必要的網(wǎng)絡(luò)傳輸。于是 HTTP 協(xié)議引入了一個(gè)新的頭信息 Etag。
Etag 的語(yǔ)義是根據(jù)文件內(nèi)容計(jì)算一個(gè)值,只有在修改內(nèi)容的時(shí)候才會(huì)產(chǎn)生新的 Etag。客戶端每次請(qǐng)求的時(shí)候把上一次的 Etag 帶回來(lái),也就是添加下面的頭:
If-None-Match:?"c3piozzzz"\r\n
服務(wù)端收到后會(huì)對(duì)比 Etag,只有發(fā)生變化的時(shí)候才會(huì)返回新的文件內(nèi)容。
那個(gè)時(shí)候的網(wǎng)絡(luò)很不穩(wěn)定,斷網(wǎng)是家常便飯。想想一個(gè)文件下載到99%然后斷網(wǎng)了是一種怎樣的體驗(yàn)。為了減少不必要的數(shù)據(jù)傳輸,人們很快就給 HTTP 協(xié)議添加了「斷點(diǎn)續(xù)傳」功能。其實(shí)斷點(diǎn)續(xù)傳是從客戶端視角來(lái)看的。從協(xié)議角度來(lái)看,需要添加的功能是根據(jù)指定范圍傳輸數(shù)據(jù)。也就是說(shuō)原來(lái)的文件是100字節(jié),客戶端可以指定只下載最后的10字節(jié):
Content-Range: bytes 91-100/100\r\n 這里的91-100表示要下載的范圍,后面的100表示整個(gè)文件的長(zhǎng)度。如果服務(wù)器支持,則會(huì)返回:
HTTP/1.1?206?Partial?content\r\n
Date:?Wed,?15?Nov?1995?06:25:24?GMT\r\n
Last-modified:?Wed,?15?Nov?1995?04:58:08?GMT\r\n
Content-Range:?bytes?91-100/100\r\n
Content-Length:?10\r\n
Content-Type:?image/gif\r\n
\r\n
(image?data)
該功能除了用于斷點(diǎn)續(xù)傳外,還可以實(shí)現(xiàn)并行下載加速??蛻舳丝梢云鸲鄠€(gè)線程,建立多條 TCP 連接,每個(gè)線程下載一部分,最后把有的內(nèi)容連到一直。就這么簡(jiǎn)單。
另外,HTTP/1.1 還要求客戶端在請(qǐng)求的時(shí)候必須發(fā)送 Host 頭信息。這里面保存著當(dāng)前請(qǐng)求對(duì)應(yīng)的網(wǎng)站域名。服務(wù)器收到請(qǐng)求后會(huì)根據(jù) Host 里的域名和請(qǐng)求行里的路徑來(lái)確定需要返回的內(nèi)容。這樣就能實(shí)現(xiàn)在同一個(gè) IP 上搭建不同域名的網(wǎng)站,也就是所謂的虛擬主機(jī)。這大大降低了網(wǎng)站的建設(shè)成本,對(duì) Web 生態(tài)的發(fā)展起到了至關(guān)重要的作用。
除了擴(kuò)展 HTTP/1.0 原來(lái)的功能外,HTTP/1.1 還引入了連接升級(jí)功能。其實(shí)這個(gè)功能后面用的不多,但有一個(gè)重量級(jí)的協(xié)議 WebSocket 在用,所以不得不說(shuō)。
所以連接升級(jí)就是把當(dāng)前用于 HTTP 會(huì)話的 TCP 連接切換到其他協(xié)議。以 WebSocket 為例:
GET?/chat?HTTP/1.1
Host:?taoshu.in
Upgrade:?websocket
Connection:?Upgrade
這里把 Connection 設(shè)成了 Upgrade,表示希望切換協(xié)議。而 Upgrade:websocket 表示要切換到 websocket 協(xié)議。在切換之前,這還是一個(gè)普通的 HTTP 請(qǐng)求。服務(wù)器可以對(duì)該請(qǐng)求做各種鑒權(quán)等 HTTP 動(dòng)作。服務(wù)器如果接受用戶的請(qǐng)求,則會(huì)返回:
HTTP/1.1?101?Switching?Protocols
Upgrade:?websocket
Connection:?Upgrade
從這一該起,雙方就不能在該 TCP 連接上發(fā)送 HTTP 協(xié)議數(shù)據(jù)了。因?yàn)閰f(xié)議已經(jīng)切換到 WebSocket。
從 1999 年開(kāi)始,到 2015 年 HTTP/2 發(fā)布,HTTP 協(xié)議有15年的時(shí)候沒(méi)有大的變化。與此同時(shí),互聯(lián)網(wǎng)蓬勃發(fā)展,從 Web 1.0 過(guò)渡到 Web 2.0,從 PC 互聯(lián)網(wǎng)發(fā)展到移動(dòng)互聯(lián)網(wǎng),從明文 HTTP 也切換到加密 HTTPS。整個(gè)過(guò)程 HTTP 協(xié)議都發(fā)揮了核心作用。這從側(cè)面也說(shuō)明 HTTP 協(xié)議是一種擴(kuò)展性非常好的協(xié)議。
但 HTTP/1.1 畢竟是九十年代設(shè)計(jì)的協(xié)議。2010年之后,移動(dòng)互聯(lián)網(wǎng)興起,業(yè)界希望對(duì) HTTP 的問(wèn)題做夠進(jìn)一步優(yōu)化。那還有哪些問(wèn)題可以優(yōu)化呢?主要有幾個(gè)方面:
-
協(xié)議使用文本格式,傳輸和解析效率都比較低 -
Header 部分信息無(wú)法壓縮,但現(xiàn)實(shí)情況是 Header 體積也不?。ū热?cookie) -
無(wú)法在單一 TCP 連接上并發(fā)請(qǐng)求資源(pipeline 失敗了) -
服務(wù)端無(wú)法主動(dòng)給客戶發(fā)送內(nèi)容
文本格式其實(shí)是 HTTP 的一大特色。我們?cè)谡{(diào)試的時(shí)候可以直接使用 telnet 連接服務(wù)器,然后用肉眼看服務(wù)器的返回結(jié)果。但對(duì)人類友好的設(shè)計(jì)對(duì)機(jī)器一定不友好。HTTP協(xié)議使用\r\n作為分割符,雙不限制頭信息的數(shù)量,這必然導(dǎo)致解析的時(shí)候需要?jiǎng)討B(tài)分配內(nèi)存。而且還要把數(shù)字、日期等信息轉(zhuǎn)換成對(duì)應(yīng)的二進(jìn)制格式,這都需要額外的解析成本。
HTTP/1.x 支持壓縮數(shù)據(jù)內(nèi)容,而且使用頭信息保存壓縮算法。所以就不能用相同的算法壓縮頭信息了。只能另辟蹊徑。
HTTP/1.1 的 pipeline 已然失敗,無(wú)法充分復(fù)用 TCP 連接。HTTP 從一開(kāi)始就是請(qǐng)求應(yīng)答式的設(shè)計(jì),服務(wù)器沒(méi)辦法主動(dòng)推送內(nèi)容到客戶端。
為了解決這幾個(gè)問(wèn)題,Google 挾 YouTube 和 Chrome 兩大殺器,推出了 SPDY 協(xié)議。該協(xié)議有兩個(gè)特點(diǎn):
-
兼容 HTTP 語(yǔ)義 -
使用二進(jìn)行格式傳輸數(shù)據(jù) SPDY 引入了幀做為最小的傳輸單位:
+-----------------------------------------------+
|?????????????????Length?(24)???????????????????|
+---------------+---------------+---------------+
|???Type?(8)????|???Flags?(8)???|
+-+-------------+---------------+-------------------------------+
|R|?????????????????Stream?Identifier?(31)??????????????????????|
+=+=============================================================+
|???????????????????Frame?Payload?(0...)??????????????????????...
+---------------------------------------------------------------+
??????????????????????Figure?1:?Frame?Layout
每一幀前三個(gè)字節(jié)表示數(shù)據(jù)長(zhǎng)度,然后用一個(gè)字節(jié)表示類型,再用一個(gè)字節(jié)保存一些擴(kuò)展標(biāo)記。然后就是四個(gè)字節(jié)的 stream ID,最后是真正的數(shù)據(jù)。這其實(shí)就表明 HTTP 協(xié)議從分割符流轉(zhuǎn)向了長(zhǎng)度流。
在同一個(gè) TCP 連接上,數(shù)據(jù)幀可以交替發(fā)送,不再受請(qǐng)求應(yīng)答模式制約。也就是說(shuō)服務(wù)端也可以主動(dòng)給客戶端發(fā)消息了。同一個(gè)請(qǐng)求的 header 和數(shù)據(jù)部分也可以分開(kāi)發(fā)送,不再要求先發(fā) header 再發(fā) body。也正是因?yàn)閿?shù)據(jù)幀交錯(cuò)傳輸,同一個(gè)會(huì)話下的數(shù)據(jù)需要能關(guān)聯(lián)起來(lái),所以 SPDY 給每一幀添加了 stram ID。換句話說(shuō) SPDY 在一個(gè) TCP 連接上虛擬出了多個(gè) stream,每一個(gè) stream 從效果看都是一個(gè) TCP 連接。不同的 HTTP 請(qǐng)求和響應(yīng)數(shù)據(jù)可以使用自己的 stream 并發(fā)傳輸,互不影響。這樣一下子就解決了上面的一、三和四這三個(gè)問(wèn)題。
第二個(gè)問(wèn)題比較麻煩。但解決思路也很簡(jiǎn)單。HTTP/1.x 的頭信息都是 K-V 型的,而且都是字符串。這里的 K-V 都很少變化。比如只要是訪問(wèn)我的博客,不論有多少請(qǐng)求,都得發(fā)送 Host: taoshu.in。對(duì)于這種不變的,我們完全可以在兩端各保存一張映射表,給每個(gè) Key 和 Value 都指定一個(gè)編號(hào)。這樣后續(xù)的請(qǐng)求只要傳 Key 和 Value 的編號(hào)就行了,從而實(shí)現(xiàn)壓縮的效果。單看 Host 可能不覺(jué)得有多少進(jìn)步。但大家想想自己的 cookie,里面有登錄會(huì)話信息,每次都重復(fù)發(fā)送浪費(fèi)相當(dāng)驚人。所以壓縮頭信息帶來(lái)的優(yōu)化還是驚人的。
因?yàn)楣雀枰贿吙刂浦袌?chǎng)份額最大的 Chrome 瀏覽器,另一邊又控制像 Google/YouTube 這樣的內(nèi)容服務(wù),所以開(kāi)發(fā)下一代 HTTP 協(xié)議便一件非常容易的事情。SPDY 于 2012 年發(fā)布,最終在 IETF 完成標(biāo)準(zhǔn)化,并于 2015 年發(fā)布,也就是RFC7540。
隨著社會(huì)的發(fā)展,隱私保護(hù)成了人們關(guān)注的重要課題。為了保護(hù)用戶信息,業(yè)界一真在推動(dòng) HTTP + TLS 也就是 HTTPS 的普及。HTTPS 服務(wù)使用 443 端口。我們前面講過(guò),HTTP/2 使用二進(jìn)制編碼,跟 HTTP/1.x 并不兼容。但客戶端又不會(huì)一夜之間都升級(jí)的 HTTP/2。那怎么才能在一個(gè)端口上同時(shí)支持兩種 HTTP 協(xié)議呢?這就用到了 TLS 協(xié)議的 ALPN 擴(kuò)展。簡(jiǎn)單來(lái)說(shuō)就是客戶端在發(fā)起 TLS 會(huì)話的時(shí)候會(huì)通過(guò) ALPN 擴(kuò)展附帶自己支持的應(yīng)用層協(xié)議,比如 http/1.1 和 h2。服務(wù)端收到后會(huì)把自己支持的應(yīng)用層協(xié)議返回給客戶端。這樣雙方就能確定接下來(lái)在 TLS 會(huì)話是使用什么協(xié)議。
理論上 HTTP/2 可以通過(guò) HTTP/1.1 的升級(jí)機(jī)制來(lái)協(xié)商,這樣也能解決兩個(gè)版本共用 TLS 會(huì)話的問(wèn)題。但這種升級(jí)會(huì)再來(lái)額外的延遲,所以主流的瀏覽器都不支持。
HTTP/2 發(fā)布之后,整個(gè)業(yè)界都在積極遷移到新的協(xié)議。但實(shí)踐證明,HTTP/2并沒(méi)有想象中的那么好。為什么呢?因?yàn)閷?duì)于同一個(gè)域名,瀏覽器默認(rèn)只會(huì)開(kāi)一個(gè)連接,所有請(qǐng)求都使用一個(gè)TCP連接收發(fā)。雖然不同的請(qǐng)求使用不同的 stream,但底層的連接只有一個(gè)。如果網(wǎng)絡(luò)出現(xiàn)抖動(dòng),不論是哪一個(gè)請(qǐng)求的數(shù)據(jù)需要重傳,其他請(qǐng)求的數(shù)據(jù)都必須等待。這就是所謂的 Head of Line blocking 問(wèn)題。HTTP/2 非但沒(méi)有優(yōu)化,甚至還比 HTTP/1.x 還要差。因?yàn)樵?HTTP/1.x 時(shí)代,瀏覽器自知 HTTP 無(wú)法復(fù)用連接,所以會(huì)為同一個(gè)域名創(chuàng)建多個(gè) TCP 連接。不同的請(qǐng)求可能會(huì)分布到不同的連接上,出現(xiàn)網(wǎng)絡(luò)抖動(dòng)的影響比只用一個(gè)連接要好一點(diǎn)。
HTTP/2 的另一個(gè)問(wèn)題就是功能太復(fù)雜。比如它支持在服務(wù)器主動(dòng)推送資源(比如 CSS 文件)到瀏覽器,這樣客戶端在加載的時(shí)候就需要等待網(wǎng)絡(luò)傳輸。但該功能非常復(fù)雜,而且效果有限,最終連 Chrome 自己都放棄支持該功能了。這部分功能被 HTTP 103 Early Hints 狀態(tài)碼代替,具體可以參考RFC8297。
一計(jì)不成,再生一計(jì)。谷歌的工程師跟 Head of Line blocking 問(wèn)題死磕。這次他們把矛頭指向了問(wèn)題的根源 TCP 協(xié)議。因?yàn)?TCP 是可靠傳輸協(xié)議,數(shù)據(jù)必須按順序收發(fā),而且要邊確認(rèn)邊發(fā)送。如果底層用 TCP 連接,就不可能解決 Head of Line blocking 問(wèn)題。為此,他們基于 UDP 協(xié)議設(shè)計(jì)了 QUIC 協(xié)議。
QUIC 協(xié)議簡(jiǎn)單來(lái)說(shuō)就是一種面向消息的傳輸協(xié)議(TCP 是面向數(shù)據(jù)流的傳輸協(xié)議)。QUIC 也有 stream 的概念,每個(gè)會(huì)話可以有多個(gè)流。不同的流的數(shù)據(jù)都使用 UDP 收發(fā),互不干擾。跟 TCP 一樣,數(shù)據(jù)發(fā)出后也需要對(duì)方確認(rèn)。然后再把 QUIC 跟 HTTP/2 的幀映射到一起,最終形成 HTTP/3 協(xié)議,也就是RFC9114。
那 QUIC 有沒(méi)有問(wèn)題呢?也有,但基本都不是設(shè)計(jì)上的問(wèn)題。
第一個(gè)問(wèn)題就是運(yùn)營(yíng)商可能對(duì) UDP 流量做限流,很多防火墻可能會(huì)阻止 QUIC 流量。這是之前 UDP 通信使用不廣泛導(dǎo)致的。隨著 HTTP/3 技術(shù)的普及,這些問(wèn)題會(huì)逐漸改善。
第二個(gè)問(wèn)題是 HTTP/3 啟動(dòng)延遲的問(wèn)題。HTTP/3 使用 UDP 通信,跟 HTTP/1.x 和 HTTP/2 不兼容,所以瀏覽器沒(méi)法判斷服務(wù)器是否支持 HTTP/3。
目前主流的做法是網(wǎng)站同時(shí)支持 HTTP/2 和 HTTP/3。瀏覽器先通過(guò)過(guò) TCP 連接訪問(wèn)服務(wù)器。服務(wù)器在第一個(gè)響應(yīng)中返回一個(gè)特殊的 Header:
Alt-Svc:?h3=":4430";?ma=3600
這里的意思是在 UDP 的 4430 端口提供 HTTP/3 服務(wù),該信息的有效時(shí)間為 3600 秒。后面瀏覽器就可以使用 QUIC 連接 4430 端口了。
明眼人一看就知道這里有問(wèn)題,建立 HTTP/3 會(huì)話之前還得先用一下 HTTP/2 啟動(dòng)有把。這不科學(xué)??而且這會(huì)帶來(lái)額外的耗時(shí)。為此,人們又開(kāi)始想別的辦法,這就是 DNS SVCB/HTTPS 記錄。
DNS SVCB/HTTPS 簡(jiǎn)單來(lái)說(shuō)就是用一種特殊的 DNS 記錄把前面的 Alt-Svc 信息曝露出來(lái)。瀏覽器在訪問(wèn)網(wǎng)站之前先通過(guò) DNS 查詢是否支持 HTTP/3 以及對(duì)應(yīng)的 UDP 端口,然后就直接發(fā)起 HTTP/3 會(huì)話就好。這樣就完全不依賴 TCP 連接了。關(guān)于 DNS SVCB/HTTPS 記錄的更多信息請(qǐng)看我的專門文章。
順便說(shuō)一句,HTTP/3 默認(rèn)可以工作在任意 UDP 端口,不像 HTTPS 那樣默認(rèn)工作在 443 端口。如果運(yùn)營(yíng)商封掉 443 就沒(méi)法對(duì)外服務(wù)。等 HTTP/3 普及了,所有人都可以使用自家的寬帶搭建網(wǎng)站??具體做法可以參考我的這篇文章。
好了,到現(xiàn)在快肝了一萬(wàn)字了。我認(rèn)為基本講清楚了 HTTP 協(xié)議的發(fā)展脈絡(luò)。現(xiàn)于篇幅,沒(méi)能詳細(xì)討論 HTTP/2 和 HTTP/3 的技術(shù)細(xì)節(jié),不能說(shuō)不是個(gè)遺憾。先開(kāi)個(gè)坑,后面有時(shí)間再補(bǔ)上。希望本文能幫助你更好地理解 HTTP 協(xié)議。
鏈接:https://taoshu.in/net/http.html
(版權(quán)歸原作者所有,侵刪)