詳解微服務技術中進程間通信
在單體應用中,一個組件調用其它組組件時,是通過語言級的方法或者函數調用,而一個基於微服務的應用是運行於多個服務器上的分佈式系統,每個服務實例是一個典型的進程。所以,如下圖顯示的,服務必須通過內部進程交互機制(IPC)進行交互。
交互風格
在爲一個服務選擇 IPC 的時候,首先考慮一下這些服務是如何交互的是很有用處的。有多種 client/server 的交互風格,它們可以通過兩個維度分類,第一種維度是交互是一對一,還是一對多的:
-
一對一:每個客戶端的請求只被一個服務實例處理
-
一對多:每個客戶端請求被多個服務實例處理
-
第二種維度是交互是同步的還是異步的:
-
同步:客戶端期望從服務得到及時的返回,並且甚至可以因此阻塞片刻
-
異步:客戶端不會在等待返回結果的時候阻塞,返回結果也沒必要立刻被髮送出來
下表顯示出各種交互風格:
有如下幾種一對一的交互形式:
請求 / 響應:客戶端發送一個請求給一個服務,並且等待響應結果,客戶端期望結果能快速的返回,在一個基於線程的應用中,發送請求的線程甚至可能在等待的時候被阻塞。
-
通知(一種單向請求):客戶端發送一個請求到服務,但不期望有響應發送回來。
-
請求 / 異步響應:客戶端往服務發送請求,響應結果異步的返回。客戶端不會在等待的時候阻塞,而且客戶端是基於響應在一段時間之後才返回的假設來設計的。
有如下幾種一對多的交互形式:
-
發佈 / 訂閱:客戶端發佈消息,消息被零或者多個感興趣的服務消費
-
發佈 / 異步響應:客戶端發佈一個請求消息,等待固定的一段時間,以獲得從感興趣的服務返回的響應結果
每個服務一般都使用這幾種交互風格的組合風格。對於一些服務來說,單一的 IPC 機制就足夠了,而其它的服務可能需要組合使用若干種 IPC 機制。下面的圖給出當客戶請求行程時,在一個打車應用可能出現的一些交互。
這些服務使用了通知,請求 / 響應,發佈 / 訂閱的交互方式。比如說,一個乘客的智能手機向行程管理服務發送了一個上車請求,行程管理服務通過請求 / 響應方式向乘客服務確認乘客的賬戶是否是活躍賬戶,行程管理服務於是創建一個行程訂單,並且用發佈 / 訂閱方式通知其它的服務,包括一個分發服務,用以定位空閒的司機。
定義 API
服務的 API 是服務與它所有的客戶端之間的一種契約,不管選用何種 IPC 機制,使用一些接口定義語言 (IDL),對於精確定義服務 API 是很重要的,甚至已經有一些關於使用 API-first approach 來定義服務的好的討論。
開發一個微服務從書寫接口定義以及與客戶端開發人員一起 review 這些接口定義開始,在不斷的對這些 API 定義進行迭代,最終纔算是實現了一個微服務。這種基於前端的設計方式,增加了構建出符合客戶端需求的機會。
在文章的後面你會看到,API 定義的特性依賴與你使用的 IPC 機制,如果你使用消息機制,API 就會涉及到消息通道和消息類型;如果你選用 HTTP 方式,那麼 API 就會包含一些 URL 和請求 / 響應的格式,之後我們會詳細的介紹 IDL。
API 的演進
一個服務的 API 會隨着時間而經常變化。在單體應用中,通常是很直接的修改 API,再更新所有的調用之處,但在基於微服務的應用中,情況要困難得多,甚至你 API 的所有消費者是同一個應用中的其它服務。你通常不能強迫所有的客戶端步調一致的升級它們的服務。而且你可能會大量的開發服務的新版本,於是新舊版本的服務會同時運行,制定一個處理這種問題的戰略原則顯得很重要。
如何處理一個 API 的變化,取決於這種變化的多少。有的變化很少,可以向後兼容之前的版本,比如,你可能只是在請求或者響應格式中增加一些屬性。設計出具有魯棒原則的客戶端和服務是有意義的,那些使用更舊的 API 的客戶端應該能夠繼續和新版本的服務工作得很好,服務會給請求中沒有的屬性提供默認值,客戶端會忽略那些響應中額外的屬性。使用 IPC 機制和消息格式是重要的,讓你能輕易的演進 API。
有時候,你不得不對 API 做一些主要的、不兼容的改動。既然不能強制客戶端立刻升級,那這個服務必須能夠支持舊版本的 API 一定時期。如果你用的是基於 HTTP 的機制,如 REST,一個好的辦法是在 API 的 URL 中嵌入版本號。每個服務實例應該可以同時處理不同版本的 API 請求,或者是部署不同的服務實例來處理不同的 API 版本。
處理部分失敗
在之前關於 API 網關的文章中曾經提到,在分佈式系統中,總會存在部分失敗的風險,既然客戶端和服務是分開的進程,一個服務可能不能對一個客戶端請求及時的返回結果,服務也可能因爲錯誤或者是維護停止了,亦或是因爲過載而對請求響應緩慢。
比如說,如上篇文章中提到的那個產品詳頁的場景,試想一下如果那個推薦服務失去響應了,客戶端的一個本地實現就可能在無限的等待響應中被阻塞了,這不僅會帶來劣質的用戶體驗,而且在很多應用中,這會消耗寶貴的資源,如一個線程,最終運行時環境會線程耗盡,變成無法響應,正如下圖所示。
爲了避免這種問題,把你的服務設計成能處理部分失敗是很有必要的。
Netfix 給我們提出了一個可以遵循的好辦法,其中處理部分失敗的原則包括:
-
網絡超時:永遠不要無限的阻塞,總是在等待響應中使用超時,使用超時來確保資源不會被無限綁定。
-
限制未解決的請求數量:對一個客戶端持有的對一個服務沒有完成的請求,應該設定上限值,這個上限一旦達到,發送更多的請求就會是無意義的,而且這些新的請求需要立刻返回爲失敗。
-
迴路中斷器模式:跟蹤成功請求和失敗請求的數量,如果錯誤率超過了一個事先配置的閾值就開啓迴路中斷器,讓進一步的嘗試立刻失敗。如果大量的請求正處在失敗中,那就預示服務不可用,而且發送請求也是無意義的。經過超時週期之後,客戶端應該再進行嘗試發送請求,如果請求成功,就關閉迴路中短器。
-
提供回滾機制:一個請求失敗時,執行回滾邏輯,比如說返回緩存的數據或者是默認值,也或者諸如一個關於推薦商品的空集合。
Netfix Hystrix 是這些模式的一種開源實現,如果你正在使用 JVM,你肯定會考慮使用 Hystrix 的,如果你運行的是一個非 JVM 的環境,同樣需要考慮使用一個類似的庫。
IPC 技術
有許多 IPC 技術可供選擇,如同步的請求 / 響應機制,這裏面有基於 HTTP 方式的 REST 和 Thrift,另外有基於消息的異步通信機制,如 AMQP 和 STOMP。其中消息的格式也是多種多樣的,有一些是人可讀的,比如 JSON 和 XML,有些是二進制格式的(這種更高效),如 Avro 和緩存協議。稍後我們介紹同步的 IPC 機制,但在這之前,先討論異步的 IPC 機制。
異步 (基於消息的通信)
當使用消息時,進程間通過異步的交換消息來通信。客戶端通過向服務發送消息來發送請求,如果期望服務返回應答,那麼它發送回一個獨立的消息給客戶端。由於通信是異步的,客戶端不會阻塞在等待返回結果上,客戶端應該是基於不會立刻收到返回結果的假設來實現。
消息包含消息頭(如發送者這樣的元數據)和消息體,各種消息在通道上交換,任意數量的生產者都能往通道上發送消息,同樣,任意數量的消費者也能從這個通道接收消息。有兩種類型的通道:點對點通道和發佈 / 訂閱通道。點對點的通道只給連接到這個通道上的衆多消費者中的一個發送消息,服務使用這種通道往往是採用前面提到的一對一的交互風格。發佈 / 訂閱這種通道,是給連接到它之上的所有消費者發送消息,這種通道往往被一對多風格的服務採用。
下圖描述的是,在打車應用中,發佈 / 訂閱的通道是如何使用的
行程管理服務向發佈 / 訂閱通道發送一個行程創建的消息,以此告訴那些對此感興趣的服務(比如說分發器服務),一個新行程創建了。分發器服務找到一個可用的司機,將一個需要提名司機的消息寫入發佈 / 訂閱通道,這樣其它的服務就能得到這個通知。
有許多消息系統可供選擇,你應該選擇那些能支持多種開發語言的。一些消息系統支持 AMQP 和 STOMP 這些標準協議,其它的系統是一些專有而且文檔化的協議。現在有不少開源的消息系統,其中包括 RabbitMQ,Apache Kafka,Apache ActiveMQ 和 NSQ。總體上看,他們都支持消息格式和通道,都是可靠的、高性能的和可擴展的,但它們在消息模型細節方面有着巨大的差異。
使用消息有諸多優點:
-
把客戶端從服務中解耦出來:客戶端只需要簡單的往正確的通道里發消息,它完全不用感知服務實例,它不需要通過發現機制來定位服務實例所在的位置。
-
消息緩衝:在使用 HTTP 這種同步的請求 / 響應協議時,客戶端和服務都必須在交換數據的時候保持可用。與此相反,消息代理會將寫到通道里面的消息隊列化,直到消費者能夠處理這些消息。這意味着,比如,對訂單的消息進行簡單的隊列化之後,即使是訂單填寫系統響應緩慢或者不可用,一個在線商店仍然可以接收到來自客戶的訂單。
-
靈活的客戶——服務交互:消息機制支持之前提到的所有交互風格。
-
顯式的進程間通信:基於 RPC 的機制能夠讓調用遠端的服務看起來如同調用本地服務,但由於存在物理規則和部分失敗的可能,這些機制都有較大不同。消息機制讓這些不同之處變得很顯式,這樣程序員不用陷於安全失誤當中。
當然,消息機制也有缺點:
-
額外的操作複雜性:消息系統是另外一個系統,必須安裝,配置和操作,消息代理必須高可用,要不然整個個系統的可靠性將受到影響。
-
實現基於請求 / 響應的交互比較複雜:請求 / 響應風格的交互要求一些實現上的工作,每個請求消息必須包含一個應答通道 ID 和關聯 ID,服務將相關 ID 包含在響應的消息中,併發送到響應通道,客戶端就通過這個相關 ID 來將響應和請求匹配起來。使用 IPC 機制來直接支持請求 / 響應通常簡單一些。
現在我們已經討論完了基於消息的 IPC,接下來探討一下基於請求 / 響應的 IPC
同步的請求 / 響應 IPC
在同步的、基於請求 / 響應的 IPC 機制中,客戶端向服務發送一個請求,服務處理這個請求,並將響應發回。在許多客戶端的實現中,發送請求的線程會在等待響應的時候阻塞。
而另一些客戶端的實現,可能使用異步的、事件驅動的方式,請求相關的代碼會被封裝在 Futrues 或者 Rx Observables 這樣的庫中。和前面介紹的消息機制不同,在這種 IPC 裏客戶端是假設響應會及時返回。有很多協議可供選擇,其中有兩種很流行:REST 和 Thrift。我們先來看看 REST
REST
目前,使用 RESTful 風格來開發 API 是很流行的做法,REST 是使用 HTTP 的 IPC 機制,REST 的一個關鍵概念是資源,資源代表一個業務對象,比如說一個客戶,一個產品,或者是一些業務對象的集合。REST 使用 HTTP 的方法來操作資源,通過 URL 來引用資源。比如,GET 請求會返回一個資源的信息,返回結果用 XML 文檔或者 JSON 對象來表示,POST 請求創建一個資源,PUT 請求是更新一個資源。REST 的創建者 Roy Fielding 的描述如下:
“REST 提供一個架構約束的集合,當被整體應用時,強調組件交互的擴展性、接口的普遍性,組件的獨立部署,減少交互延時的中間組件,增強的安全性以及對遺留系統的封裝。”
下圖展示了打車應用中使用 REST 的一個場景。
乘客的智能手機向行程管理服務發送創建行程的請求,這個時候一個 POST 請求發送到服務端,請求創建一個 / trips 資源,行程管理服務隨後發送一個 GET 請求到乘客管理服務,來獲取乘客的信息,在確認了這個乘客是一個授權過可以創建行程的用戶之後,行程管理服務正式的創建出行程,並且返回一個 201 結果給智能手機。
很多開發者都聲稱他們的 HTTP API 都是 RESTful 的,但如 Fielding 在他的這篇博客裏描述的,其實他們不一定都是。Leonard Richardson 給出了一個很有用的 REST 成熟度模型,包含如下一些級別:
-
級別 0:客戶端通過發送基於 HTTP 的 POST 請求到唯一的 URL 服務端,每個請求指定要執行的動作,動作的對象(比如業務對象),以及其它任何參數。
-
級別 1:支持資源的概念,爲了在一個資源上執行動作,客戶端需要在 POST 請求中指定執行的動作和所有的參數。
-
級別 2:API 使用 HTTP 的動詞來執行動作:GET 用來獲取,POST 用來創建,PUT 用來修改。請求要求參數和請求體,如果有,還需要指定動作的參數,這樣服務就可以利用頁面系統的一些基礎設施,如緩存 GET 請求。
-
級別 3:這個級別的 API 是基於 HATEOAS(超文本應用狀態引擎)原則的,基本思想是在 GET 請求返回的代表資源的響應中,需要包含一些鏈接,這些鏈接對應與可對這個資源執行的動作。舉個例子,訂單的 GET 請求的返回結果中會包含操作的鏈接,其中有取消訂單的操作鏈接,客戶端可以從結果中找到這個鏈接,使用它取消訂單。
-
HATEOAS 的優勢在於不再需要將 URL 硬編碼到客戶端的代碼裏面去了,另一個好處是由於資源的返回結果中已經包含允許的操作的鏈接,客戶端不用去猜測當前狀態下能對資源做哪些操作了。
使用基於 HTTP 的協議的好處有:
-
HTTP 對與大家來說簡單而熟悉。
-
可以用一些有 Postman 這種插件的瀏覽器來測試 API,也可以用 curl 這種命令行工具來測試(返回結果是用 JSON 或者其它類型的文本格式)
-
直接支持請求 / 響應風格的通信
-
HTTP 是防火牆友好的
-
不需要有中間代理,這讓系統的架構得到簡化
使用 HTTP 也有缺點:
-
只支持請求 / 響應的交互風格,這使得在使用 HTTP 來發送通知的時候,服務端必須總是發送 HTTP 響應回來。
-
因爲客戶端和服務端直接通信(中間沒有緩衝消息),他們在交換信息期間必須同時處於運行狀態。
-
客戶端必須知道每個服務實例的地址(比如 URL),正如在上一篇文章中描述的,在現代應用中,這倒不是個重要的問題,一般客戶端都需要使用服務發現機制來定位服務實例的位置。
開發者社區最近發現了接口定義語言對 RESTful API 的新價值,這方面有一些選擇,包括 RAML 和 Swagger。一些諸如 Swagger 的 IDL 允許定義出請求和響應消息的格式,其它一些諸如 RAML 的 IDL 則要求使用獨立的規範,如 JSON schema。在描述 API 的同時,IDL 一般也有工具來給接口定義生成客戶端樁和服務端骨架。
Thrift
Apache Thrift 是 REST 的一種有趣的替代方案,它是開發跨語言 RPC 客戶端和服務端的框架,Thrift 提供 C 語言風格的 IDL 來定義你的 API,使用 Thrift 編譯器生成客戶樁和服務骨架,編譯器能夠生成各種語言的代碼,包括 C++,Java,Python,PHP,Ruby,Erlang 和 Node.js。
一個 Thrift 接口包含一個或多個服務,定義服務與定義 Java 接口類似,是一些強輸入方法的集合,Thrift 方法可以定義城返回一個值(也可能是 void 的),或者定義成單向方法。返回一個值的方法都會實現請求 / 響應的交互風格。客戶端等待請求,並且有可能拋出異常。單向方法其實是符合通知風格的交互,服務端不會發送響應。
Thrift 支持多鍾消息格式:JSON,二進制,緊湊的二進制。二進制格式通常比 JSON 更高效一些,因爲解析它更快。對於緊湊二進制格式,如它的名字一樣,它是節省空間的消息。而 JSON,當然是對人和瀏覽器友好的一種格式。在 Thrift 中,也可以自己選擇傳輸協議,其中包括原始 TCP 和 HTTP。TCP 一般比 HTTP 更高效一些,當然,HTTP 是對防火牆、瀏覽器和人友好的。
消息格式
前面已經討論過 HTTP 和 Thrift,現在介紹消息格式的問題。如果使用消息系統或者 REST,需要確定消息格式。其它一些如 Thrift 這種 IPC 機制只支持有限的集中消息格式,或許就一種而已。在任何一種情況中,使用跨語言的消息格式是很重要的。甚至你現在只是用一種語言來實現你的微服務,很可能你將來會使用其它的語言。
有兩種主要的消息格式:文本和二進制碼。基於文本的格式有 JSON,XML 這些。它們的優點在於是人可讀的,而且是自描述的。在 JSON 中,對象的屬性被表示成名稱 - 值對的集合。類似的,在 XML 中,屬性被表示成名字元素和值。這可以讓消息消費者能夠找到感興趣的值,同時忽略其它的。而且,對格式的小量改動可以容易的兼顧到後向兼容性。
XML 文檔的結構是在 XML schema 文件中定義的,漸漸的社區的開發者意識到 JSON 也需要類似的機制,其中一個解決辦法是使用 JSON schema,以獨立方式存在或者是如 Swagger 這種 IDL 的一部分。
基於消息的格式的一個缺點是比較繁瑣,尤其是 XML。因爲消息是自描述的,除了包含屬性的值之外,消息裏還包含屬性的名稱。另外一個劣勢是,解析消息文本需要開銷。基於這些,你可能更想使用二進制碼格式
有幾種二進制格式可供選擇。當用 Thrift RPC,你可以選擇二進制的 Thrift。如果使用消息格式,比較流行的選擇是 Protocol Buffers 和 Apache Avro。這兩種格式都提供輸入的 IDL 來定義消息結構。不同之處在於,Protocol Buffers 使用標籤域,而 Avro,它的消費者在翻譯消息前,需要提前知道消息的 schema。這篇博客完美解釋了 Thrift, Protocol Buffers 和 Avro 的異同之處。
總結
微服務必須使用一種進程間通信機制,當設計你的服務如何通信時,需要考慮各種問題:服務如何交互,如何爲每個服務設計 API,如何演進 API,以及如何處理部分失敗問題。有兩種微服務可用的 IPC 機制,異步的消息機制和同步的請求 / 響應機制。在這一系列文章的下一篇文章中,我們會研究在微服務架構中的服務發現問題。
英文原文:
https://www.nginx.com/blog/building-microservices-inter-process-communication/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wJpC5i0NoeuZNASb4ha4xA