官术网_书友最值得收藏!

1.5 HTTP的前世今生

HTTP是全球最大規模的分布式系統網絡的基礎之一,也采用了傳統的服務器-客戶端的通信設計模式。從1.0版本到1.1版本再到2.0版本,HTTP始終占據著分布式系統通信領域重要的一席之地。

1.5.1 HTTP的設計思路

首先,在報文編碼方式上,HTTP采用了面向程序員的文本(ASCII)編碼方式而非面向計算機的二進制編碼方式。該設計非常關鍵,這是因為文本編碼數據很直觀,文本編碼協議甚至不用編寫額外冗長的接口說明文檔就很容易被程序員理解,也非常方便我們準備模擬數據編寫單元測試,而當線上系統出現Bug時,運維人員也很容易根據客戶端記錄的文本報文日志來快速定位故障。文本編碼協議正因為有這么多優點,所以始終在網絡協議中占據著重要的位置,而很多復雜的分布式系統可能會同時采用文本與二進制這兩種編碼方式的協議。

其次,HTTP是無狀態的請求-應答協議。在筆者看來,無狀態的設計是個嚴重缺乏前瞻性的設計,但考慮到在HTTP誕生之初網上沒什么資源,也根本不存在可以跟用戶交互的網站,因此這個設計思路也是完全可以理解的。最初的HTTP(0.9版)只提供了GET方法,這是因為其作者認為網上所有的資源(網頁)都是靜態的,遠程用戶是不能修改的,瀏覽器所能做的就是從遠程服務器上“獲取(GET)”指定網頁并以只讀方式展示給用戶,在用戶獲取網頁之后就立即中斷與服務器的連接,從而節省寬帶和服務器的寶貴資源。

隨著Internet的加速發展,特別是圖片和音視頻等多媒體內容的出現和流行,原先只面向文本資源對象的HTTP已不能滿足人們的需求,所以HTTP做了一個較大的升級(1.0版本):首先,增加了POST方法,使得客戶端可以提交(上傳)文件到服務器端;其次,通過引入Content-Type這個Header,支持除文本外的多媒體數據的傳輸支持。需要注意的是,此時在HTTP的報文里是可以出現二進制數據的,比如文件附件,但從整體來看,HTTP報文仍然是文本協議的報文,只是在報文的尾部可以增加一些二進制編碼數據。在增加POST方法并且支持文件上傳功能之后,在HTTP里出現了一個概率Bug的設計。原本的問題如下:如果用戶通過POST方式可以上傳多個文件,那么我們應該怎么設計HTTP來支持它?”

一個非計算機系的人面對這個問題,可能會這樣考慮:既然HTTP一開始是沒有考慮二進制傳輸的,那么現在的確存在二進制傳輸這種新的需求,所以我們應該考慮如何引入新的二進制傳輸協議來支持此需求,比如文件傳輸的數據可以用[文件名][長度][文件內容]這樣的二進制編碼格式定義,就很容易支持多個文件傳輸。

但對于典型的“IT直男”來說,上面這種突變的設計與之前的設計格格不入,直接違背了他們遵循的一致性審美原則,同時增加了代碼實現的復雜度,這就很難讓人接受了。所以,他們把電子郵件協議(SMTP&Pop3)中處理二機制附件的做法照搬了過來。電子郵件協議采用的是文本協議,用一個隨機生成的boundary字符串來區分多個文件(附件)的數據。這個boundary字符串雖然是隨機生成的,也有一定長度,但誰也無法保證它永遠不會跟文件內容中的一段字符串重復,這就導致了隨機Bug的問題。很有意思的是,當初制定電子郵件協議的人們也從程序邏輯思維的角度制定出來一個無限層附件嵌套附件的協議規范。筆者當初開發Java版的郵件服務器時,特意模擬過一個3層嵌套的電子郵件,結果讓163等常見的Web Mail都掛了,因為其無法識別嵌套的郵件附件。

HTTP在1.0版本中引入了一個重要的設計,即在報文中增加了Header屬性列表,每個Header都是一個Key/Value鍵值對,整個Header列表可以被視為一個Map的數據結構,用來在客戶端(瀏覽器)與服務器端傳遞控制類數據。由于Header與請求或應答的正文內容相互獨立,并且用戶可以靈活擴展,增加新的Header屬性,同時這些Header數據會被HTTP代理服務器透傳到遠程服務器中,所以用HTTP構建分布式系統具有其他應用層協議沒有的獨特優勢。HTTP最大的優勢可以一句話概括為:采用了HTTP作為通信協議的分布式系統天然具備了無侵入性的基礎設施能力全面改進的優勢。

上述優勢使得HTTP在大規模的分布式系統,特別是目前越來越熱的云原生系統中得到應用。隨著HTTP 2.0的進一步升級和發展,基于HTTP 2.0的微服務架構、服務網格風起云涌。所以理解HTTP,有助于我們深入理解常見的分布式系統架構的設計與原理實現。

1.5.2 HTTP如何保持狀態

我們都知道,HTTP在設計之初就是無狀態的協議,但隨著互聯網的快速發展,越來越多的軟件開始以Web網站的方式提供服務,一個Web網站同時服務成千上萬個互聯網用戶。此時,編程人員開始面對一個棘手的問題,即如何識別同一個用戶的連續多次的請求?比如在典型的網購行為中,客戶登錄系統,挑選商品,將商品添加購物車,最后下單付款。一個客戶網購的整個過程會涉及幾十次甚至上百次的網頁交互,這就意味著我們必須為無狀態的HTTP引入某種狀態機制,而具體的實現機制就是HTTP Cookie。

HTTP Cookie新增了兩個擴展性的HTTP Header,其中一個是Set-Cookie。

Set-Cookie是服務端專用的Header,用來告知客戶端(瀏覽器):“剛才的用戶通過了身份驗證,我現在設置了一個Cookie,里面記錄了他的身份信息及有效期,你必須把它的內容保存下來,當該用戶繼續發送請求給我時,你自動在每個請求的HTTP Header上添加這個Cookie的內容后再發送過來,這樣我就可以持續跟蹤這個用戶的后續請求了,請務必遵守要求,直到Cooker有效期結束才能刪除Cookie,我不想用戶反復登錄及證明身份。”

下面是一個典型的Set-Cookie的完整內容,其中,id給出了用戶的標識,Expires部分則指出了該Cookie的有效期,有效期越長,用戶越方便,但風險越大:

Java開發人員最熟悉的是下面這種Set-Cookie例子:

注意,jsessionid是Tomcat服務器用來標識用戶的,而其他JEE Server各有各的名稱,在PHP中則通常使用phpsessionid。

瀏覽器在收到服務器的響應時,會檢查在響應報文中的Header里是否有Set-Cookie指令,如果有,就會遵守規范,從中抽出相關的Cookie信息,并且在該用戶隨后的HTTP請求的Header中自動加入新的Cookie Header,再發送給服務器端。下面是一個對應的例子:

服務器在收到上述請求后,就會檢查Cookie里的數據,抽取用戶ID并對應到服務器端的用戶會話(Session)對象。通常在Session中會保存更多的用戶數據,比如用戶的昵稱、角色、權限及更多的特定數據。與在Cookie中保存的數據相比,在Session中保存的內容通常是一些復雜的對象和結構體。因此,Cookie與Session的關系再清楚不過了:一個用來在瀏覽器端保存用戶狀態數據,一個則用來在服務器端保存用戶會話數據,兩者相輔相成,實現了有狀態的HTTP。

對于Cookie,我們需要注意以下事實。

● Set-Cookie可以多次使用,并且可以放置更多的Key-Value數據,其中的每一個Key-Value數據項都是一個獨立的Cookie,服務器通常會傳送多個不同的Cookie到瀏覽器端,每個Cookie都對應特定的業務目標。

● Cookie的值雖然都是字符串,但可以很長,具體多長呢?RFC規范沒有給出具體的值,但一些測試表明,絕大多數瀏覽器都支持4096個字節長度的Cookie的內容。

● Cookies的內容是需要被保存在瀏覽器中的,通常瀏覽器會用本地文件保存這些Cookie的內容。同時,服務器端需要提供Session對象,因此用戶的狀態是由瀏覽器與服務器雙方配合實現的,任何一方的缺失都會導致用戶狀態信息的缺失。

● 在Cookies中不要存儲用戶的敏感(機密)信息,特別注意不要存儲用戶的明文密碼,但可以考慮存儲某種安全加密的信息,并且定期自動更新,避免被盜用和破解。

一個有趣的問題:在開發電商(類似的)系統時,我們是否可以把用戶的購物車列表數據放入Cookie中呢?會帶來哪些意想不到的好處?又面臨哪些新問題?歡迎探討。

1.5.3 Session的秘密

對于很多Web開發人員甚至架構師來說,服務器端的Session很神秘:只知道應用服務器會給每個用戶都創建一個Session會話來保持其狀態,可以放置任意對象到Session中,也可以查找這些對象來實現業務邏輯判斷并渲染用戶頁面,但往往不太清楚其具體工作原理和工作機制。

1.Session究竟是什么

Cookie是由RFC6265標準規范規定的一個概念,有對應的呈現標準和呈現方式,總體來說,我們可以將Cookie理解為HTTP的一部分,因此所有人都可以準確理解、表達并且進行標準化實現。與Cookie不同,Session屬于Web應用開發中一個抽象的概念,它對應Cookie,用來在應用服務器端表示和保存用戶的信息。但是,Session并沒有標準化的定義及實現方式,因此在不同的Web編程語言里都有不同的理解和實現方式,即使在同一種Web編程語言中,不同的應用服務器的實現方式也有所不同。這就導致了一個顯而易見的事實:不同廠家的應用服務器不通過某種第三方手段是無法做到“單點登錄”的,雖然單點登錄存在Session、鑒權和相互信任的復雜問題。

2.Session是在什么時候被創建的

從前一節Cookie的分析中我們知道,Set-Cookie指令是服務器第一次驗證用戶身份后回應給瀏覽器的,此時服務器已經生成用戶的身份信息(如jsessionid),因此我們可以確定一個事實:該用戶對應的Session會話此時也生成了,并且由我們的Web Server控制整個生命周期。

3.Session中的數據被存儲在哪里

Session中的數據通常被存儲在應用服務器的內存中,準確理解這一點對于我們編程和設計架構來說很關鍵!哪些用戶數據適合被放在Session中?能放多少數據?什么時候清理這些數據?對于這些問題的答案,需要綜合考慮業務層面的要求、性能及內存占用等幾個關鍵因素。

這里主要分析Session中數據占用服務器內存對系統所造成的影響,因為我們在具體實踐中經常忽略了這個問題,導致Session被濫用。在用戶量突然增加以后,很多系統都無法支撐高并發,會出現內存溢出的嚴重問題,而且這個問題很難從根本上解決,只能在前期加以規范和引導并在開發階段予以杜絕。

以電商系統的購物車為例,如果我們把用戶購物車對象放入Session中,則以Java為例,定義如下數據結構(對象):

以ShopCartItem的title為10個中文字符為例,則上述Java對象占據的實際內存將超過2000個字節,而不是幾十個字節!一個用戶的購物車里平均有5件商品,則每個用戶的購物車對象占用的內存超過1萬個字節,如果我們有10萬個用戶,則僅這些用戶的購物車對象占用的內存將達到1GB左右!考慮到這還是個很簡單的Java對象,當我們把某些翻頁查詢的結果集都隨意放入Session中時,后果會有多嚴重?這也是為什么目前Go這種非面向對象的編程語言會在Web服務器領域發力并且對Java造成一定的沖擊。

從上面的分析結果來看,面對大規模的用戶訪問,我們能做的有以下幾方面。

● 盡可能少放“大尺寸”的數據在用戶Session中,并且盡可能及早清除無效數據,釋放Session占用的內存。

● 考慮到把更多的Session數據轉移到瀏覽器端的Cookie中,所以通過“甩鍋”方式減少服務器端的壓力。

● 前端積極采用HTML5技術,Cookie不適合用于大量數據的存儲,并且Cookie每次都會被增加到HTTP的請求頭中并傳輸到服務器端,這也增加了網絡流量的壓力,因此HTML5提供了在用戶端的瀏覽器中存儲數據的新方法:localStorage與sessionStorage,后者就是專門解決服務器端Session存儲難題的“利器”。

● 考慮到引入分布式存儲機制,所以可以采用集群方式來應對單一服務器的Session存儲瓶頸。

1.5.4 再談Token

通過前面的學習,我們知道用戶會話中的用戶身份標識(如SessionID)信息被存放在Cookie中并保留在用戶端的瀏覽器上。實際上,Cookie的內容是被存放在磁盤中的,其他人是有可能直接訪問到Cookie文件的;另外,Cookie中的信息是明文保存的,意味著攻擊者可以通過猜測并偽造Cookie數據破解系統。避免這種漏洞的直接防護手段就是用數字證書對敏感數據進行加密簽名,在加密簽名后這串字符串就是我們所說的Token,這樣攻擊者就無法偽造Token了,因此Token在本質上是Session(SessionID)的改進版。與Session將用戶狀態保留在服務器端的常規做法不同,Token機制則把用戶狀態信息保存在Token字符串里,服務器端不再維護客戶狀態,服務器端就可以做到無狀態,集群也更容易擴展。那么,Token數據是被放在哪里的呢?標準的做法是將其放在專用的HTTP Header“X-Auth-Token”中保存并傳輸,但客戶端在拿到Token以后可以將其在本地保存,比如在App程序中,Token信息可以被保存在手機中,而Web應用中,Token也可以被保存到H5的localstorage中。需要注意,Token與Cookie是完全無關的!總結下來,Token有以下特點。

● 在Token中包含足夠多的用戶信息,JWT能輕松實現單點登錄,因為用戶的狀態已經被傳送到了客戶端。

● 不存在Cookie跨域的限制問題,也不存在Cookie相關的一些攻擊漏洞,例如CSRF。

● 因為有簽名,所以JWT可以防止被篡改。

● 適用于API的安全機制,適用于移動客戶端與PC客戶端的開發,此時Cookie是不被支持的;Token方案則簡單有效,可以用一套Token認證代碼來應對瀏覽器類客戶端和非瀏覽器類客戶端。

● Token已經標準化,有成熟的標準化規范——JSON Web Token(JWT),多種主流語言也都提供了支持(如.NET、Ruby、Java、Python、PHP)。

目前被廣泛使用的JWT規范是一個輕量級的規范,每個完整的JWT對象實際上都是一個字符串,它由三部分組成:頭部(Header)、載荷(Payload)與簽名(Signature),其中Header聲明了該JWT所用的簽名算法是哪種:對稱加密算法(HMAC SHA256,簡稱HS256)還是非對稱加密算法(RSA)。需要注意的是,雖然JWT支持對稱加密算法來做簽名,但正常情況下,我們都應該使用非對稱加密算法即私鑰來簽名,并且我們要妥善保管私鑰,謹防泄密,客戶端用公鑰證書去驗證簽名。Payload部分是我們重點關注的內容,我們可以將Payload理解為一個Map字典,里面的exp字段表明JWT的失效時間,是確保安全的重要字段。此外,我們可以在Payload里增加自定義的私有字段,用來保存更多的用戶特定信息,特別注意的是,Payload是明文傳輸的,所以我們不能把私密信息放入Payload里,比如用戶密碼。Signature部分則是將Header與Payload的內容加在一起,用在Header里聲明的簽名算法進行簽名而得到的一個字符串,即完整的JWT字符串組成為:Header(明文).Payload(明文).Signature(簽名/密文)。

任何一方在收到這個JWT字符串的Token后,都可以通過解析得到Header與Payload的完整內容。為了證實這兩段信息是否是某個組織所發出的真實信息,我們可以用該組織的公鑰證書對簽名信息進行驗證。JWT標準是不加密的,但我們可以再加密,即在生成原始JWT Token后再把這個字符串當作普通字符串加密,但這種做法的意義不大,因為加密解密會涉及大量CPU計算,增加系統的負載。此外,我們需要注意,JWT一旦簽發,在有效期內將會一直有效,有效期的長短也會影響安全的級別。因此,可考慮不同安全等級要求的API接口給予不同時效的Token,對于某些重要的API接口,用戶在使用時應該每次都進行身份驗證。為了減少盜用和竊取,JWT不建議使用HTTP來傳輸代碼,而是使用加密的HTTPS傳輸代碼。

如何采用JWT Token機制代替普通的Session機制呢?答案很簡單,就是在用戶訪問時攔截請求,檢查HTTP Header中的Token是否有效,如果無效則重定向到登錄界面,在登錄成功后,服務端生成JWT Token并將其放入Header中返還客戶端,客戶端保存JWT Token并在隨后的請求里帶上Header發起訪問即可,如下圖所示。注意,如果Token接近失效時間,則需要重新訪問服務端獲取新的Token。

(JWT)Token也多用于服務網關的鑒權架構中,如下圖所示。

Client在訪問系統的內部Service時,通過API網關來完成統一的服務鑒權功能。首先,Client通過Auth Server獲取合法的Token;然后,持有此Token,在后面發起服務調用請求時都帶上此Token;在API網關攔截到請求時,先驗證Token的有效性,再轉發請求到具體的Service。

如果想要更深入地了解JWT相關的技術與應用,則建議繼續學習OAuth 2.0與OpenID的相關技術。

1.5.5 分布式Session

最早的成熟的分布式Session技術被應用于J2EE領域,主要采用Session復制的技術(Session Replication)將用戶會話的數據復制到J2EE集群的其他機器上。考慮到復制的代價和內存占用成本,一個用戶的Session通常只會被復制到集群中的一臺服務器上,即主從復制模式。這種方式類似于MySQL的主從復制技術,當集群中的機器數量多于2臺時,必須要求前置的負載均衡器(軟件或硬件)支持會話親和性(Session Affinity),可以準確地把不同用戶的請求轉到對應的兩臺服務器上。應用Session復制技術的典型代表之一是Weblogic Server,但是Session復制的技術從總體來看相對復雜,而且集群的整體性能下降很明顯,因此在J2EE領域之外很少被使用。

另外一種應用更廣泛的分布式Session技術就是把Session數據徹底從應用服務器中“剝離”,單獨集中存儲在外部的內存中間件(如Redis、Memcache、JBossCache)中,這樣做的好處是整體架構更加清晰,也更加靈活,集群的數量可以輕松達到幾十臺甚至上百臺的規模。同時,整個系統的運維變得更有條理性,故障排查和故障恢復也更為容易。采用這種分布式Session的系統,其整體規模、性能、特性主要取決于不同的后端存儲中間件的能力。目前應用非常廣泛的后端存儲為Redis,并通過Redis集群來獲取更大規模的用戶量支持能力。

如下圖所示是一個典型的基于Spring Boot的分布式Session集群架構案例。

1.5.6 HTTP與Service Mesh

Service Mesh可以說是當前最熱門的一個架構了,自帶云原生的光環,一經問世就立刻吸引了Google與IBM這兩個軟件巨頭,他們聯合發起了相關的重量級開源項目——Istio。不過,在Service Mesh的各種實現類產品中一致選擇了HTTP作為服務之間的基礎通信協議,而不是其他二進制通信協議。并且,Service Mesh的核心功能或特性幾乎全部依賴HTTP的特性才得以實現。為什么呢?筆者的答案是:圍繞HTTP建立一個所有編程語言都適用的、高度統一并且足夠靈活的微服務架構,是非常容易成功的選擇。

所以,Service Mesh從一開始就是圍繞HTTP而“精準”構建的新框架!

如下所示是一張簡化版的Service Mesh架構圖,在該圖中特意將SideCar(邊車)畫成U型,這是為了方便表示Sidecar其實“包圍但又不是完全包裹”它對應的Service實例的這一關鍵特性。即在進程角度,Sidecar是完全獨立的進程(可以是一個或多個),與對應的Service實例不產生任何進程和代碼級別的糾纏,非常像一個獨立進程的代理。

考慮到我們的Service其實是一個HTTP服務器進程(微服務),我們可以理解把SideCar理解為一個特殊定制的Nginx代理。另外一個細節需要注意:進入任意一個Service實例的請求都要從SideCar代理后才能抵達Service實例本身,在這個過程中,SideCar可以做任何HTTP能做的事情,比如黑白名單的檢查、服務限速及服務路由等功能,這些恰恰就是Service Mesh的核心特性之一。而借助于HTTP的特性,整個過程無須修改業務代碼本身,只需要配置一些規則(類似于Nginx的配置)即可生效。

下面以Service Mesh的核心功能之—服務路由為例來簡單說明其中的實現原理。在如下所示的示例中,Service B有兩個實例。Service B的虛擬地址為http://serviceb:8080,兩個實例的地址分別為http://192.168.18.1:8080及http://192.168.18.2:8080,當Service A調用Service B時會有路由選擇問題。

此時,Service A上的SideCar會配置類似于如下路由規則(示例):

然后,當Service A發出對Service B的服務調用(HTTP請求http://serviceb:8080)時,Service A的SideCar代理進程先通過iptables規則“劫持”這一請求,在對照自己的路由配置規則后發現有兩個地址,于是按照默認的輪詢機制選擇一個目的地址轉發出去。比如到達了192.168.18.2這臺機器,此時Service B對應的SideCar進程也采用同樣方式“劫持”進來的請求流量,在經過一定的處理邏輯后,再轉給Service B進程處理。這就實現了基本的負載均衡功能。在這個過程中,已有的用戶業務進程如Service A、Service B等都無須有任何代碼改造,這一切都通過基本的HTTP代理機制即可完成。其他諸如金絲雀流量控制,比如有10%的流量到某個服務的升級版本,有90%的流量到老版本,以及基于不同的終端用戶(或者HTTP URL&Param)來實現更細粒度的路由控制,這對HTTP的代理來說簡直是小菜一碟。

我們知道,大規模的分布式系統都存在一個很難解決的問題,即一旦在運行中出現性能問題或者故障,則很難快速診斷和發現問題的成因。因為在微服務架構下,一個調用鏈從終端到達最終的服務端,中間可能跨越十幾個遠程調用,這意味著我們需要把分布在這十幾臺機器上的獨立請求都“精確串聯”起來,才能知道問題出在哪個環節。解決這個問題的思路有以下兩種。

● 第1種思路,在編程時在調用發起的地方手工生成唯一的TraceID,確保這個TraceID被正確傳遞到后端的所有調用;準確記錄每段調用的耗時、是否異常等必要診斷信息,并在日志中打印出來;最后通過日志分析和匯總每條鏈路的信息。

● 第2種思路,采用面向AOP編程的思路,由框架來實現第1種思路的所有編程。

毫無疑問,第2種思路是最好的,但這里面臨一個棘手的問題:如何在不侵入業務代碼的情況下完整地將每個TraceID都傳輸到后面的調用過程中?答案是用HTTP的自定義Header來實現統一注入TraceID和其他相關參數,并通過SideCar的代理攔截能力去實現所需的細節和數據收集工作。

可以說,Service Mesh之前的任何一種通用的分布式架構都沒能完美解決安全問題,除了ZeroC Ice。很巧的是,二者實現安全機制的做法異曲同工,都是通過自動包裹(代理)SSL安全連接來實現遠程調用的安全加密能力。其中,ZeroC Ice采用的是SSL+TCP;Istio等主流Service Mesh的實現則采用了HTTPS+HTTP來實現自動加密功能,這些只需簡單配置文件和CA證書即可實現。

主站蜘蛛池模板: 内江市| 桃江县| 兰溪市| 夹江县| 西平县| 广德县| 正阳县| 金川县| 富顺县| 德安县| 登封市| 莱阳市| 临泽县| 沂水县| 招远市| 佛学| 丹江口市| 西乌珠穆沁旗| 颍上县| 罗甸县| 天等县| 南汇区| 襄汾县| 滨海县| 龙江县| 玉山县| 仁怀市| 华容县| 禹州市| 大埔县| 北安市| 山阳县| 綦江县| 商南县| 阿克陶县| 来宾市| 商丘市| 广宁县| 荥经县| 崇州市| 虎林市|