在我們點擊一個網址,到它能夠呈現在瀏覽器中,展示在我們面前,這個過程中,電腦里,網絡上,究竟發生了什么事情。
服務器啟動監聽模式
那我們就開始了,故事其實并不是從在瀏覽器的地址欄輸入一個網址,或者我們抓著鼠標點擊一個鏈接開始,事情的開端要追溯到服務器啟動監聽服務的時候,在某個未知的時刻,一臺機房里普普通通的刀片服務器,加上電,啟動了操作系統,隨著操作系統的就緒,服務器啟動了 http 服務進程,這個 http 服務的守護進程,(daemon),可能是 apache,也可能是 nginx,不管怎么說,這個 http 服務進程開始定位到服務器上的 www 文件夾,一般是位于 /var/www ,然后啟動了一些附屬的模塊,例如 php,或者,使用 fastcgi 方式連接到 php 的 fpm 管理進程,然后,向操作系統申請了一個 tcp 連接,然后綁定在了 80 端口,調用了 accept 函數,開始了默默的監聽,監聽著可能來自位于地球任何一個地方的請求,隨時準備做出響應。
這個時候,典型的情況下,機房里面應該還有一個數據庫服務器,或許,還有一臺緩存服務器,如果對于流量巨大的網站,那么動態腳本的解釋器可能還有單獨的物理機器來跑,如果是中小的站點,那么,上述的各色服務,甚至都可能在一臺物理機上,這些服務監聽之間的關系。不管怎么說,他們做好了準備,靜候差遣。
客戶端瀏覽器發送請求
1. 解析URL
2. 輸入的是 URL 還是搜索的關鍵字?
當協議或主機名不合法時,瀏覽器會將地址欄中輸入的文字傳給默認的搜索引擎。大部分情況下,在把文字傳遞給搜索引擎的時候,URL會帶有特定的一串字符,用來告訴搜索引擎這次搜索來自這個特定瀏覽器。
3. 轉換非 ASCII 的 Unicode 字符
瀏覽器檢查輸入是否含有不是 a-z, A-Z,0-9, - 或者 . 的字符
這里主機名是 google.com ,所以沒有非ASCII的字符;如果有的話,瀏覽器會對主機名部分使用 Punycode 編碼
4. 檢查 HSTS 列表
瀏覽器檢查自帶的“預加載 HSTS(HTTP嚴格傳輸安全)”列表,這個列表里包含了那些請求瀏覽器只使用HTTPS進行連接的網站
如果網站在這個列表里,瀏覽器會使用 HTTPS 而不是 HTTP 協議,否則,最初的請求會使用HTTP協議發送
注意,一個網站哪怕不在 HSTS 列表里,也可以要求瀏覽器對自己使用 HSTS 政策進行訪問。瀏覽器向網站發出第一個 HTTP 請求之后,網站會返回瀏覽器一個響應,請求瀏覽器只使用 HTTPS 發送請求。然而,就是這第一個 HTTP 請求,卻可能會使用戶收到downgrade attack的威脅,這也是為什么現代瀏覽器都預置了 HSTS 列表。
5. dns查詢
瀏覽器緩存查詢:瀏覽器檢查域名是否在緩存當中(要查看 Chrome 當中的緩存, 打開 chrome://net-internals/#dns)。
本地host查詢:如果緩存中沒有,就去調用 gethostbyname 庫函數(操作系統不同函數也不同)進行查詢,gethostbyname 函數在試圖進行DNS解析之前首先檢查域名是否在本地 Hosts 里,Hosts 的位置 不同的操作系統有所不同
發送DNS 查詢請求:如果 gethostbyname 沒有這個域名的緩存記錄,也沒有在 hosts 里找到,它將會向 DNS 服務器發送一條 DNS 查詢請求。DNS 服務器是由網絡通信棧提供的,通常是本地路由器或者 ISP 的緩存 DNS 服務器。
查詢本地 DNS 服務器
如果 DNS 服務器和我們的主機在同一個子網內,系統會按照下面的 ARP 過程對 DNS 服務器進行 ARP查詢
如果 DNS 服務器和我們的主機在不同的子網,系統會按照下面的 ARP 過程對默認網關進行查詢
6. ARP 過程
要想發送 ARP(地址解析協議)廣播,我們需要有一個目標 IP 地址,同時還需要知道用于發送 ARP 廣播的接口的 MAC 地址。
首先查詢 ARP 緩存,如果緩存命中,我們返回結果:目標 IP = MAC
如果緩存沒有命中:
查看路由表,看看目標 IP 地址是不是在本地路由表中的某個子網內。是的話,使用跟那個子網相連的接口,否則使用與默認網關相連的接口。
查詢選擇的網絡接口的 MAC 地址
我們發送一個二層( OSI 模型 中的數據鏈路層)ARP 請求:
ARP Request:
Sender MAC: interface:mac:address:here
Sender IP: interface.ip.goes.here
Target MAC: FF:FF:FF:FF:FF:FF (Broadcast)
Target IP: target.ip.goes.here
根據連接主機和路由器的硬件類型不同,可以分為以下幾種情況:
直連:
如果我們和路由器是直接連接的,路由器會返回一個 ARP Reply (見下面)。
集線器:
如果我們連接到一個集線器,集線器會把 ARP 請求向所有其它端口廣播,如果路由器也“連接”在其中,它會返回一個 ARP Reply 。
交換機:
如果我們連接到了一個交換機,交換機會檢查本地 CAM/MAC 表,看看哪個端口有我們要找的那個 MAC 地址,如果沒有找到,交換機會向所有其它端口廣播這個 ARP 請求。
如果交換機的 MAC/CAM 表中有對應的條目,交換機會向有我們想要查詢的 MAC 地址的那個端口發送 ARP 請求
如果路由器也“連接”在其中,它會返回一個 ARP Reply
ARP Reply:
Sender MAC: target:mac:address:here
Sender IP: target.ip.goes.here
Target MAC: interface:mac:address:here
Target IP: interface.ip.goes.here
現在我們有了 DNS 服務器或者默認網關的 IP 地址,我們可以繼續 DNS 請求了:
使用 53 端口向 DNS 服務器發送 UDP 請求包,如果響應包太大,會使用 TCP 協議
如果本地/ISP DNS 服務器沒有找到結果,它會發送一個遞歸查詢請求,一層一層向高層 DNS 服務器做查詢,直到查詢到起始授權機構,如果找到會把結果返回
7. 使用套接字建立三次握手
當瀏覽器得到了目標服務器的 IP 地址,以及 URL 中給出來端口號(http 協議默認端口號是 80, https 默認端口號是 443),它會調用系統庫函數 socket ,請求一個 TCP流套接字,對應的參數是 AF_INET/AF_INET6 和 SOCK_STREAM 。
這個請求首先被交給傳輸層,在傳輸層請求被封裝成 TCP segment。目標端口會被加入頭部,源端口會在系統內核的動態端口范圍內選取(Linux下是ip_local_port_range)
TCP segment 被送往網絡層,網絡層會在其中再加入一個 IP 頭部,里面包含了目標服務器的IP地址以及本機的IP地址,把它封裝成一個TCP packet。
這個 TCP packet 接下來會進入鏈路層,鏈路層會在封包中加入 frame頭部,里面包含了本地內置網卡的MAC地址以及網關(本地路由器)的 MAC 地址。像前面說的一樣,如果內核不知道網關的 MAC 地址,它必須進行 ARP 廣播來查詢其地址。
到了現在,TCP 封包已經準備好了,可以進行tcp三次握手(在地址解析完畢后首先進行的是三次握手,就是客戶端和服務器端只發送SYN包,建立三次連接后,在對HTTP的引用進行響應)
最終封包會到達管理本地子網的路由器。在那里出發,它會繼續經過自治區域(autonomous system, 縮寫 AS)的邊界路由器,其他自治區域,最終到達目標服務器。一路上經過的這些路由器會從IP數據報頭部里提取出目標地址,并將封包正確地路由到下一個目的地。IP數據報頭部 time to live (TTL) 域的值每經過一個路由器就減1,如果封包的TTL變為0,或者路由器由于網絡擁堵等原因封包隊列滿了,那么這個包會被路由器丟棄。
8. TLS 握手
客戶端發送一個 Client hello 消息到服務器端,消息中同時包含了它的 Transport Layer Security (TLS) 版本,可用的加密算法和壓縮算法。
服務器端向客戶端返回一個 Server hello 消息,消息中包含了服務器端的TLS版本,服務器選擇了哪個加密和壓縮算法,以及服務器的公開證書,證書中包含了公鑰。客戶端會使用這個公鑰加密接下來的握手過程,直到協商生成一個新的對稱密鑰
客戶端根據自己的信任CA列表,驗證服務器端的證書是否有效。如果有效,客戶端會生成一串偽隨機數,使用服務器的公鑰加密它。這串隨機數會被用于生成新的對稱密鑰
服務器端使用自己的私鑰解密上面提到的隨機數,然后使用這串隨機數生成自己的對稱主密鑰
客戶端發送一個 Finished 消息給服務器端,使用對稱密鑰加密這次通訊的一個散列值
服務器端生成自己的 hash 值,然后解密客戶端發送來的信息,檢查這兩個值是否對應。如果對應,就向客戶端發送一個 Finished 消息,也使用協商好的對稱密鑰加密
從現在開始,接下來整個 TLS 會話都使用對稱秘鑰進行加密,傳輸應用層(HTTP)內容
9. HTTP 服務器請求處理
HTTPD(HTTP Daemon)在服務器端處理請求/響應。最常見的 HTTPD 有 Linux 上常用的 Apache 和 nginx,以及 Windows 上的 IIS。
HTTPD 接收請求
服務器把請求拆分為以下幾個參數:
HTTP 請求方法(GET, POST, HEAD, PUT, DELETE, CONNECT, OPTIONS, 或者 TRACE)。直接在地址欄中輸入 URL 這種情況下,使用的是 GET 方法
域名:google.com
請求路徑/頁面:/ (我們沒有請求google.com下的指定的頁面,因此 / 是默認的路徑)
服務器驗證其上已經配置了 google.com 的虛擬主機
服務器驗證 google.com 接受 GET 方法
服務器驗證該用戶可以使用 GET 方法(根據 IP 地址,身份信息等)
如果服務器安裝了 URL 重寫模塊(例如 Apache 的 mod_rewrite 和 IIS 的 URL Rewrite),服務器會嘗試匹配重寫規則,如果匹配上的話,服務器會按照規則重寫這個請求
服務器根據請求信息獲取相應的響應內容,這種情況下由于訪問路徑是 “/“ ,會訪問首頁文件(你可以重寫這個規則,但是這個是最常用的)。
服務器會使用指定的處理程序分析處理這個文件,假如 Google 使用 PHP,服務器會使用 PHP 解析 index 文件,并捕獲輸出,把 PHP 的輸出結果返回給請求者
請求進入處理函數之后,如果客戶端所請求需要瀏覽的內容是一個動態的內容,那么處理函數會相應的從數據源里面取出數據,這個地方一般會有一個緩存,例如 memcached 來減小 db 的壓力,如果引入了 orm 框架的話,那么處理函數直接向 orm 框架索要數據就可以了,由 orm 框架來決定是使用內存里面的緩存還是從 db 去取數據,一般緩存都會有一個過期的時間,而 orm 框架也會在取到數據回來之后,把數據存一份在內存緩存中的。
orm 框架負責把面向對象的請求翻譯成標準的 sql 語句,然后送到后端的 db 去執行,db 這里以 mysql 為例的話,那么一條 sql 進來之后,db 本身也是有緩存的,不過 db 的緩存一般是用 sql 語言 hash 來存取的,也就是說,想要緩存能夠命中,除了查詢的字段和方法要一樣以外,查詢的參數也要完全一模一樣才能夠使用 db 本身的查詢緩存,sql 經過查詢緩存器,然后就會到達查詢分析器,在這里,db 會根據被搜索的數據表的索引建立情況,和 sql 語言本身的特點,來決定使用哪一個字段的索引,值得一提的是,即使一個數據表同時在多個字段建立了索引,但是對于一條 sql 語句來說,還是只能使用一個索引,所以這里就需要分析使用哪個索引效率最高了,一般來說,sql 優化在這個點上也是很重要的一個方面。
sql 由 db 返回結果集后,再由 orm 框架把結果轉換成模型對象,然后由 orm 框架進行一些邏輯處理,把準備好的數據,送到視圖層的渲染引擎去渲染,渲染引擎負責模板的管理,字段的友好顯示,也包括負責一些多國語言之類的任務。對于一條請求在 mvc 中的生命周期,在視圖層把頁面準備好后,再從動態腳本解釋器送回到 http 服務器,由 http 服務器把這些正文加上一個響應頭,封裝成一個標準的 http 響應包,再通過 tcp ip 協議,送回到客戶機瀏覽器。
10. 客戶端渲染
判斷http響應狀態碼
編碼解析
構建dom樹
根據css樣式和dom樹,構建渲染樹
歷經千辛萬苦,我們請求的響應終于到達了客戶端的瀏覽器,響應到達瀏覽器之后,瀏覽器首先判斷狀態碼,如果是 200 開頭的就好辦,直接進入渲染流程,如果是 300 開頭的就要去相應頭里面找 location 域,根據這個 location 的指引,進行跳轉,這里跳轉需要開啟一個跳轉計數器,是為了避免兩個或者多個頁面之間形成的循環的跳轉,當跳轉次數過多之后,瀏覽器會報錯,同時停止。如果是 400 開頭或者 500 開頭的狀態碼,瀏覽器也會給出一個錯誤頁面。
當瀏覽得到一個正確的 200 響應之后,接下來面臨的一個問題就是多國語言的編碼解析了,響應頭是一個 ascii 的標準字符集的文本,這個還好辦,但是響應的正文本質上就是一個字節流,對于這一坨字節流,瀏覽器要怎么去處理呢,首先瀏覽器會去看響應頭里面指定的 encoding 域,如果有了這個東西,那么就按照指定的 encoding 去解析字符,如果沒有的話,那么瀏覽器會使用一些比較智能的方式,去猜測和判斷這一坨字節流應該使用什么字符集去解碼。相關的筆記可以看這里,瀏覽器對編碼的確定
解決了字符集的問題,接下來就是構建 dom 樹了,在 html 語言嵌套正常而且規范的情況下,這種 xml 標記的語言是比較容易的能夠構建出一棵 dom 樹出來的,當然,對于互聯網上大量的不規范的頁面,不同的瀏覽器應該有自己不同的容錯去處理。構建出來的 dom 本質上還是一棵抽象的邏輯樹,構建 dom 樹的過程中,如果遇到了由 script 標簽包起來的 js 動態腳本代碼,那么會把代碼送到 js 引擎里面去跑,如果遇到了 style 標簽包圍起來的 css 代碼,也會保存下來,用于稍后的渲染。如果遇到了 img 等引用外部文件的標簽,那么瀏覽器會根據指定的 url 再次發起一個新的 http 請求,去把這個文件拉取回來,值得一提的是,對于同一個域名下的下載過程來說,瀏覽器一般允許的并發請求是有限的,通常控制在兩個左右,所以如果有很多的圖片的話,一般出于優化的目的,都會把這些圖片使用一臺靜態文件的服務器來保存起來,負責響應,從而減少主服務器的壓力。
dom 樹構造好了之后,就是根據 dom 樹和 css 樣式表來構造 render 樹了,這個才是真正的用于渲染到頁面上的一個一個的矩形框的樹,對于 render 樹上每一個框,需要確定他的 x y 坐標,尺寸,邊框,字體,形態,等等諸多方面的東西,render 樹一旦構建完成,整個頁面也就準備好了,可以上菜了。
需要說明的是,下載頁面,構建 dom 樹,構建 render 樹這三個步驟,實際上并不是嚴格的先后順序的,為了加快速度,提高效率,讓用戶不要等那么久,現在一般都并行的往前推進的,現代的瀏覽器都是一邊下載,下載到了一點數據就開始構建 dom 樹,也一邊開始構建 render 樹,構建了一點就顯示一點出來,這樣用戶看起來就不用等待那么久了。
當服務器提供了資源之后(HTML,CSS,JS,圖片等),瀏覽器會執行下面的操作:
解析 —— HTML,CSS,JS
渲染 —— 構建 DOM 樹 -> 渲染 -> 布局 -> 繪制
11. 瀏覽器
瀏覽器的功能是從服務器上取回你想要的資源,然后展示在瀏覽器窗口當中。資源通常是 HTML 文件,也可能是 PDF,圖片,或者其他類型的內容。資源的位置通過用戶提供的 URI(Uniform Resource Identifier) 來確定。
瀏覽器解釋和展示 HTML 文件的方法,在 HTML 和 CSS 的標準中有詳細介紹。這些標準由 Web 標準組織 W3C(World Wide Web Consortium) 維護。
不同瀏覽器的用戶界面大都十分接近,有很多共同的 UI 元素:
一個地址欄
后退和前進按鈕
書簽選項
刷新和停止按鈕
主頁按鈕
瀏覽器高層架構
組成瀏覽器的組件有:
用戶界面:用戶界面包含了地址欄,前進后退按鈕,書簽菜單等等,除了請求頁面之外所有你看到的內容都是用戶界面的一部分
瀏覽器引擎:瀏覽器引擎負責讓 UI 和渲染引擎協調工作
渲染引擎:渲染引擎負責展示請求內容。如果請求的內容是 HTML,渲染引擎會解析 HTML 和 CSS,然后將內容展示在屏幕上
網絡組件:網絡組件負責網絡調用,例如 HTTP 請求等,使用一個平臺無關接口,下層是針對不同平臺的具體實現
UI后端:UI 后端用于繪制基本 UI 組件,例如下拉列表框和窗口。UI 后端暴露一個統一的平臺無關的接口,下層使用操作系統的 UI 方法實現
Javascript 引擎:Javascript 引擎用于解析和執行 Javascript 代碼
數據存儲 數據存儲組件是一個持久層。瀏覽器可能需要在本地存儲各種各樣的數據,例如 Cookie 等。瀏覽器也需要支持諸如 localStorage,IndexedDB,WebSQL 和 FileSystem 之類的存儲機制
12. HTML 解析
瀏覽器渲染引擎從網絡層取得請求的文檔,一般情況下文檔會分成8kB大小的分塊傳輸。
HTML 解析器的主要工作是對 HTML 文檔進行解析,生成解析樹。
解析樹是以 DOM 元素以及屬性為節點的樹。DOM是文檔對象模型(Document Object Model)的縮寫,它是 HTML 文檔的對象表示,同時也是 HTML 元素面向外部(如Javascript)的接口。樹的根部是”Document”對象。整個 DOM 和 HTML 文檔幾乎是一對一的關系。
解析算法
HTML不能使用常見的自頂向下或自底向上方法來進行分析。主要原因有以下幾點:
語言本身的“寬容”特性
HTML 本身可能是殘缺的,對于常見的殘缺,瀏覽器需要有傳統的容錯機制來支持它們
解析過程需要反復。對于其他語言來說,源碼不會在解析過程中發生變化,但是對于 HTML 來說,動態代碼,例如腳本元素中包含的 document.write() 方法會在源碼中添加內容,也就是說,解析過程實際上會改變輸入的內容
由于不能使用常用的解析技術,瀏覽器創造了專門用于解析 HTML 的解析器。解析算法在 HTML5 標準規范中有詳細介紹,算法主要包含了兩個階段:標記化(tokenization)和樹的構建。
解析結束之后
瀏覽器開始加載網頁的外部資源(CSS,圖像,Javascript 文件等)。
此時瀏覽器把文檔標記為“可交互的”(interactive),瀏覽器開始解析處于“推遲”模式的腳本(defer屬性的腳本文件),也就是那些需要在文檔解析完畢之后再執行的腳本。之后文檔的狀態會變為“完成”(complete,DOMContentLoaded事件被響應),瀏覽器會進行“加載”事件(onload事件被響應)。
注意解析 HTML 網頁時永遠不會出現“語法錯誤”,瀏覽器會修復所有錯誤,然后繼續解析。
執行同步 Javascript 代碼。