消息中間件解析 | 如何正確理解軟件應用系統中關於系統通信的那些事?

蒼穹之邊,浩瀚之摯,眰恦之美;悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》

寫在開頭

隨着業務需求的發展和用戶數量的激增,對於互聯網應用系統或者服務應用程序則提出了新的挑戰,也對從事系統研發的開發者有了更高的要求。作爲一名IT從業研發人員,我們都知道的事,良好的用戶體驗是我們和應用系統間快速反饋,一直以來都是我們考量一個系統是否穩定和是否高效的設計目標,但是保證這個目標的關鍵之一,主要在於如何保證系統間的通信穩定和高效。從而映射出,如何正確理解軟件應用系統中關於系統通信的那些事?是我們必須瞭解和理解的一項關鍵工作,接下來,我們就一起來總結和探討一下。

基本概述

要想理解系統服務間的交流,拿我們人與人的交流來做類比是個不錯的選擇。我們都知道,人與人之間的實現交流的基本元素主要有以下幾個方面:

  • 能夠相互聽懂和理解的交流語言(即雙方要基於相同的"協議"之下)
  • 必要的傳播介質(實在的物理介質,空氣紙張等都行)
  • 約定好的處理信息的方式(常見的一問一答 或是先記錄後處理等表現形式)

從而得知,系統服務間的交流的主要表現在以下幾個方面:

  1. 相同的通信原語:就像人類互相需要使用相同的語言進行交流,計算機服務也必須使用互相能識別的消息格式進行交互。
  2. 傳播信息的介質:人類交流時往往需要某種介質傳播信息,如空氣、紙張甚至是眼神等。同樣的,網絡信息的傳遞也需要物理介質的幫助,以及工作在其上的一系列相關協議。
  3. 處理信息的方式:人類交流時可以是面對面直接問答形式的,也可能是郵件、短信等延時應答形式的,對應的是不同的業務場景,在計算機裏進行通信處理方式。
  4. 實現通信方式:根據不同的協議都能實現通信功能的方式,一般基於一種或者至少一種協議實現。

組成要素

實現系統間通信主要的三個要素:通信格式,通信協議,通信模型。

根據人與人的交流的構成要素,抽象成計算機系統服務中對應的概念(行之有效的概念往往是簡單且趨同的),系統間通信主要考慮以下三個方面:通信格式,通信協議,通信模型。具體詳情如下:

  1. 通信格式(Communication Format): 主要是指實現通信的消息格式(Message Format),是表達消息內容等基本表現形式。常用的消息格式有xml,json,TLV等。
  2. 通信協議(Communication Protocol): 主要是指實現通信的網絡協議(Network Protocol)。常見的TCP/IP協議,UDP協議等。
  3. 通信模型(Communication Model): 主要是指實現通信的網絡模型(Network Model)。常見的模型主要有阻塞式通信模型,非阻塞式通信模型,同步通信模型,異步通信模型。

接下來,我們來詳細解析這些組成要素:

  1. 對於消息格式來說,是幫助我們識別消息和表達消息內容的基本方式:
    • XML:和語言無關,常用於對系統環境進行描述,如常見的maven倉庫配置,或者spring配置等。
    • JSON:輕量級消息格式,和語言無關。攜帶同樣的信息,佔用容量比XML小。
    • Protocol Buffer:Google定義的消息格式,只提供了java,c++和python語言的實現。
    • TLV:比JSON更輕量級的數據格式,連JSON中的"{}"都沒有了。它是通過字節的位運算來實現序列化和反序列化。
  2. 對於網絡協議來說,是幫助我們實現消息傳輸和傳遞的表達方式:
    • 數據在網絡七層模型中傳遞的時候,在網絡層是"數據包",在數據鏈路層被封裝成"幀"(數字信號),在物理層則是"比特"(電信號)。
    • 不同的協議都能實現通信功能,最適合本系統的通信協議纔是最好的。
  3. 對於網絡模型來說,主要是幫助我們理解和選擇適合當前場景的應用框架:
    • 在計算機網路層面來說,常見網絡模型主要有OSI 參考模型和TCP/IP 模型兩種。
    • 除此之外,還有Linux 網絡I/O 模型和Java JDK中的I/O 模型

網絡協議

我們用手機連接上網的時候,會用到許多網絡協議。從手機連接 W i F i 開始, 使用的是 8 0 2 . 11 (即 W L A N ) 協議, 通過 W L A N 接入網絡; 手機自動獲取網絡配置,使用的是 D H C P 協議,獲取配置後手機才能正常通信。這時手機已經連入局域網,可以訪問局域網內的設備和資源, 但還不能使用互聯網應用,例如:微信、抖音等。想要訪問互聯網,還需要在手機的上聯網絡設備上實現相關協議, 即在無線路由器上配置 N AT、 P P P O E 等功能, 再通過運營商提供的互聯網線路把局域網接入到互聯網中, 手機就可以上網玩微信、刷抖音了。常見的網絡主要有:

  1. 局域網 : 小範圍內的私有網絡, 一個家庭內的網絡、一個公司內的網絡、一個校園內 的網絡都屬於局域網。
  2. 廣域網: 把不同地域的局域網互相連接起來的網絡。運營商搭建廣域網實現跨區域的網絡互連。
  3. 互聯網: 互聯全世界的網絡。互聯網是一個開放、互聯的網絡, 不屬於任何個人和任何機構, 接入互聯網後可以和互聯網的任何一臺主機進行通信。

簡單來說,就是手機、無線路由器等設備通過多種網絡協議實現通信。網絡協議就是爲了通信各方能夠互相交流而定義的標準或規則, 設備只要遵循相同的網絡協議就能夠實現通信。那網絡協議又是誰規定的呢? ISO 制定了一個國際標準OSI , 其中的 OSI 參考模型常被用於網絡協議的制定。常見的網絡協議:

  1. 面向連接協議(TCP協議):在發送數據之前, 在收發主機之間連接一條邏輯通信鏈路。好比平常打電話,輸入完對方電話號碼撥出之後, 只有對方接通電話才能真正通話,通話結束後將電話機扣上就如同切斷電源。TCP協議是一種面向有連接的傳輸層協議,能夠對自己提供的連接實施控制。適用於要求可靠傳輸的應用, 例如文件傳輸。
  2. 面向無連接協議(UDP協議):不要求建立和斷開連接。發送端可於任何時候自由發送數據。如同去寄信, 不需要確認收件人信息是否真實存在,也不需要確認收件人是否能收到信件,只要有個寄件地址就可以寄信了。U D P 是一種面向無連接的傳輸層協議,不會對自己提供的連接實施控制。適用於實時應用, 例如: I P 電話、視頻會議、直播等

網絡模型

從計算機網絡層面來說,常見網絡模型主要有OSI 參考模型和TCP/IP 模型兩種,主要表達如下:

OSI 參考模型:

O S I 參考模型將網絡協議提供的服務分成 7 層,並定義每一層的服務內容, 實現每一層服務的是協議, 協議的具體內容是規則。上下層之間通過接口進行交互,同一層之間通過協議進行交互。 O S I 參考模型只對各層的服務做了粗略的界定, 並沒有對協議進行詳細的定義,但是許多協議都對應了 7 個分層的某一層。所以要了解網絡,首先要了解 O S I 參考模型:

  1. 應用層:O S I 參考模型的第 7 層( 最高層)。應用程序和網絡之間的接口, 直接向用戶提供服務。應用層協議有電子郵件、遠程登錄等協議。
  2. 表示層:O S I 參考模型的第 6 層。負責數據格式的互相轉換, 如編碼、數據格式轉換和加密解密等。保證一個系統應用層發出的信息可被另一系統的應用層讀出。
  3. 會話層:O S I 參考模型的第 5 層。主要是管理和協調不同主機上各種進程之間的通信(對話),即負責建立、管理和終止應用程序之間的會話。
  4. 傳輸層:O S I 參考模型的第 4 層。爲上層協議提供通信主機間的可靠和透明的數據傳輸服務, 包括處理差錯控制和流量控制等問題。只在通信主機上處理, 不需要在路由器上處理。
  5. 網絡層:O S I 參考模型的第 3 層。在網絡上將數據傳輸到目的地址, 主要負責尋址和路由選擇。
  6. 數據鏈路層:O S I 參考模型的第 2 層。負責物理層面上兩個互連主機間的通信傳輸, 將由 0、 1 組成的比特流劃分成數據幀傳輸給對端,即數據幀的生成與接收。通信傳輸實際上是通過物理的傳輸介質實現的。 數據鏈路層的作用就是在這些通過傳輸介質互連的設備之間進行數據處理。網絡層與數據鏈路層都是基於目標地址將數據發送給接收端的,但是網絡層負責將整個數據發送給最終目標地址, 而數據鏈路層則只負責送一個分段內的數據。
  7. 物理層:O S I 參考模型的第 1 層( 最底層)。負責邏輯信號( 比特流) 與物理信號(電信號、光信號)之間的互相轉換,通過傳輸介質爲數據鏈路層提供物理連接。
TCP/IP 模型:

由於 OSI 參考模型把服務劃得過於瑣碎,先定義參考模型再定義協議,有點理想化。 TCP / IP 模型則正好相反, 通過已有的協議歸納總結出來的模型,成爲業界的實際網絡協議標準。TCP / IP 是有由 I E T F 建議、推進其標準化的一種協議, 是 IP 、 TCP 、HTTP 等協議的集合。TCP / IP是爲使用互聯網而開發制定的協議族, 所以互聯網的協議就是 TCP / IP。TCP / IP 每層的主要協
議詳情如下:

  1. 網絡接入層:TCP / IP 是以 O S I 參考模型的物理層和數據鏈路層的功能是透明的爲前提製定的,並未對這兩層進行定義,所以可以把物理層和數據鏈路層合併稱爲網絡接入層。網絡接入層是對網絡介質的管理,定義如何使用網絡來傳送數據。但是在通信過程中這兩層起到的作用不一樣, 所以也有把物理層和數據鏈路層分別稱爲硬件、網絡接口層。 TCP / IP分爲四層或者五層都可以,只要能理解其中的原理即可。設備之間通過物理的傳輸介質互連, 而互連的設備之間使用 M A C 地址實現數據傳輸。採用 M A C 地址,目的是爲了識別連接到同一個傳輸介質上的設備。
  2. 網絡層:相當於 OSI 模型中的第 3 層網絡層, 使用 I P 協議。 I P 協議基於 I P 地址轉發分包數據,作用是將數據包從源地址發送到目的地址。TCP / IP 分層中的網絡層與傳輸層的功能通常由操作系統提供。 路由器就是通過網絡層實現轉發數據包的功能。
  3. 傳輸層:相當於 OSI 模型中的第 4 層傳輸層, 主要功能就是讓應用程序之間互相通信,通過端口號識別應用程序, 使用的協議有面向連接的 TCP 協議和麪向無連接的 UDP 協議。
  4. 應用層:相當於 OSI 模型中的第 5 - 7 層的集合, 不僅要實現 O S I 模型應用層的功能,還要實現會話層和表示層的功能。 HTTP 、 POP3 、 TELNET 、 SSH、 F T P 、 SNMP 都是應用層協議。

除此之外,我們還需要知道Linux 網絡I/O 模型和Java JDK中的I/O 模型:

Linux 網絡I/O 模型:

Linux的內核將所的外部設備看作一個文件來操作,對於一個文件的讀寫操作會調用內核提供的系統命令,返回一個文件描述符(fd,File Descriptor);同時,在面對一個Socket的讀寫時也會有相應的套接字描述符(socketfd,Socket File Descriptor),描述符是一個數字,它指向內核中的一個結構體,比如文件路徑,數據區等。Linux 網絡I/O 模型是按照UNIX網絡編程來定義的,主要有:

阻塞I/O模型(Blocking I/O ):

最流行的I/O模型,本書到目前爲止的所有例子都使用該模型。默認情形下,所有套接字都是阻塞的。使用UDP而不是TCP爲例子的原因在於就UDP而言,數據準備好讀取的概念比較簡單:要麼整個數據報已經收到,要麼還沒有。對於TCP而言,諸如套接字低水位標記等額外變量開始起作用,道指這個概念複雜。我們把recvfrom函數視爲系統調用,因爲我們正在區分應用進程和內核。不管如何實現,一般都會從在應用進程空間中國運行切換到在內核空間中運行,一端時間之後再切換回來。 在上圖中,進程調用recvfrom,其系統調用直到數據報到達且被複制到應用進程的緩衝區中或者發送錯誤才返回。最常見的錯誤是系統調用被信號中斷,我們說進程在從調用recvfrom開始到它返回的整段時間內是被阻塞的。recvfrom成功返回後,應用進程開始處理數據報。

非阻塞I/O模型(NoneBlocking I/O):

進程把一個套接字設置成非阻塞是在通知內核:當所有請求的I/O操作非得把本進程投入睡眠才能完成時,不要把本進程投入睡眠,而是返回一個錯誤。前三次調用recvfrom時沒有數據可返回,因此內核轉而立即返回一個EWOULDBLOCK錯誤。第四次調用recvfrom時已有一個數據報準備好,它被複制到應用進程緩衝區,於是recvfrom成功返回。接着處理數據。當一個應用進程像這樣對一個非阻塞描述符循環調用recvfrom時,我們成爲輪詢,應用進程持續輪詢內核,以查看某個操作是否就緒。這麼做往往耗費大量CPU時間,不過這種模型偶爾也會遇到。

I/O複用模型(IO Multiplexing):

I/O複用,我們就可以調用select或者poll,阻塞在這兩個系統調用中的某一個,而不是阻塞在真正的I/O系統調用上。我們阻塞與select調用,等待數據報套接字變爲可讀。當select返回套接字可讀這一條件時,我們調用recvfrom把所可讀數據報復制到應用進程緩衝區。比較上面兩圖,I/O複用並不顯得有什麼優勢,事實上由於使用select需要兩個而不是單個系統調用,其優勢在於可以等待多個描述符就緒。

信號驅動I/O複用模型(Signal Driven IO):

可以用信號,讓內核在描述符就緒時發送SIGIO信號通知我們。稱爲信號驅動式I/O。我們首先開啓套接字的信號驅動式I/O功能,並通過sigaction系統調用安裝一個信號處理函數。該系統調用將立即返回,我們的進程繼續工作,也就是說它沒有被阻塞。當數據報準備好讀取時,內核就爲該進程產生一個SIGIO信號。我們隨後既可以在信號處理函數中調用recvfrom讀取數據報,並通知主循環數據已準備好待處理。也可以立即通知循環,讓它讀取數據報。無論如何處理SIGIO信號,這種模型的優勢在於等待數據報到達期間進程不被阻塞。主循環可以繼續執行,只要等待來自信號處理函數的通知:既可以是數據已準備好被處理,也可以是數據報已準備好被讀取。

異步I/O模型(Asynchronous IO ):

告知內核啓動某個操作,並讓內核在整個操作(包括將數據從內核複製到我們自己的緩衝區)完成後通知我們。這種模型與前一節介紹的信號驅動模型的主要區別在於:信號驅動I/O是由內核通知我們如何啓動一個I/O操作,而異步I/O模型是由內核通知我們I/O操作何時完成。我們調用aio_read函數,給內核傳遞描述符、緩衝區指針。緩衝區大小和文件偏移,並告訴內核當整個操作完成時如何通知我們。該系統調用立即返回,而且在等到I/O完成期間,我們的進程不被阻塞。

Java JDK中的I/O 模型:

在Java語言中,應用程序發起 I/O 調用後,會經歷兩個階段:

  • 內核等待 I/O 設備準備好數據;
  • 內核將數據從內核空間拷貝到用戶空間。

其中,阻塞和非阻塞:

  • 阻塞調用會一直等待遠程數據就緒再返回,即上面的階段1會阻塞調用者,直到讀取結束;
  • 而非阻塞無論在什麼情況下都會立即返回,雖然非阻塞大部分時間不會被block,但是它仍要求進程不斷地去主動詢問kernel是否準備好數據,也需要進程主動地再次調用recvfrom來將數據拷貝到用戶內存。

而我們常說的同步和異步主要如下:

  • 同步方法會一直阻塞進程,直到I/O操作結束,注意這裏相當於上面的階段1,階段2都會阻塞調用者。其中BIO,NIO,IO多路複用,信號驅動IO,這四種IO都可以歸類爲同步IO;
  • 而異步方法不會阻塞調用者進程,即使是從內核空間的緩衝區將數據拷貝到進程中這一操作也不會阻塞進程,拷貝完畢後內核會通知進程數據拷貝結束。
BIO模型

同步阻塞 IO 模型中,服務器應用程序發起 read 系統調用後,會一直阻塞,直到內核把數據拷貝到用戶空間。完整的架構應該是 客戶端-內核-服務器,客戶端發起IO請求,服務器發起系統調用,內核把IO數據從內核空間拷貝到用戶空間,服務器應用程序才能使用到客戶端發送的數據。一般來說,客戶端、服務端其實都屬於用戶空間,藉助內核交流數據。

當用戶進程發起了read系統調用,kernel就開始了IO的第一個階段:準備數據。對於網絡IO來說,很多時候數據在一開始還沒有到達內核(比如說客戶端目前只是建立了連接,還沒有發送數據 或者是 網卡等待接收數據),所以kernel就需要要等待足夠的數據到來。而在服務器進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,然後kernel返回結果,用戶進程才解除阻塞狀態,重新運行起來。

Java中的JDBC也使用到了BIO技術。BIO在客戶端連接數量不高的情況下是沒問題的,但是當面對十萬甚至百萬級連接的時候,無法處理這種高併發情況,因此我們需要一種更高效的 I/O 處理模型來應對。

NIO模型

file

Java 中的 NIO 於 JDK 1.4 中引入,對應 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解爲 Non-blocking,不單純是 New。它支持面向緩衝的,基於通道的 I/O 操作方法。 對於高負載、高併發的(網絡)情況下,應使用 NIO 。

當服務器進程發出read操作時,如果kernel中數據還沒準備好,那麼並不會阻塞服務器進程,而是立即返回error,用戶進程判斷結果是error,就知道數據還沒準備好,此時用戶進程可以去幹其他的事情。一段時間後用戶進程再次發read,一直輪詢直到kernel中數據準備好,此時用戶發起read操作,產生system call,kernel 馬上將數據拷貝到用戶內存,然後返回,進程就能使用到用戶空間中的數據了。

BIO一個線程只能處理一個IO流事件,想處理下一個必須等到當前IO流事件處理完畢。而NIO其實也只能串行化的處理IO事件,只不過它可以在內核等待數據準備數據時做其他的工作,不像BIO要一直阻塞住。NIO它會一直輪詢操作系統,不斷詢問內核是否準備完畢。但是,NIO這樣又引入了新的問題,如果當某個時間段裏沒有任何客戶端IO事件產生時,服務器進程還在不斷輪詢,佔用着CPU資源。所以要解決該問題,避免不必要的輪詢,而且當無IO事件時,最好阻塞住(線程阻塞住就會釋放CPU資源了)。所以NIO引入了多路複用機制,可以構建多路複用的、同步非阻塞的IO程序。

AIO模型

file

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改進版 NIO 2,它是異步 IO 模型。異步 IO 是基於事件和回調機制實現的,也就是進程操作之後會直接返回,不會阻塞在那裏,當後臺處理完成,操作系統會通知相應的線程進行後續的操作。用戶進程發起read操作之後,立刻就可以開始去做其它的事。

內核收到一個asynchronous read之後,首先它會立刻返回,所以不會對用戶進程產生任何阻塞。kernel會等待數據準備完成,然後將數據拷貝到用戶內存,當這一切都完成之後,kernel會給用戶進程發送一個signal,告訴它read操作完成了。

IO多路複用模型

Java 中的 NIO ,提供了 Selector(選擇器)這個封裝了操作系統IO多路複用能力的工具,通過Selector.select(),我們可以阻塞等待多個Channel(通道),知道任意一個Channel變得可讀、可寫,如此就能實現單線程管理多個Channels(客戶端)。當所有Socket都空閒時,會把當前線程(選擇器所處線程)阻塞掉,當有一個或多個Socket有I/O事件發生時,線程就從阻塞態醒來,並返回給服務端工作線程所有就緒的socket(文件描述符)。各個操作系統實現方案:

  • linux:select、poll、epoll
  • MacOS/FreeBSD:kqueue
  • Windows/Solaris:IOCP

IO多路複用題同非阻塞IO本質一樣,只不過利用了新的select系統調用,由內核來負責本來是服務器進程該做的輪詢操作。看似比非阻塞IO還多了一個系統調用的開銷,不過因爲可以支持多路複用IO,即一個進程監聽多個socket,纔算提高了效率。進程先是阻塞在select/poll上(進程是因爲select/poll/epoll函數調用而阻塞,不是直接被IO阻塞的),再是阻塞在讀寫操作的第二階段上(等待數據從內核空間拷貝到用戶空間)。

IO多路複用的實現原理:利用select、poll、epoll可以同時監聽多個socket的I/O事件的能力,而當有I/O事件產生時會被註冊到Selector中。在所有socket空閒時,會把當前選擇器進程阻塞掉,當有一個或多個流有I/O事件(或者說 一個或多個流有數據到達)時,選擇器進程就從阻塞態中喚醒。通過select或poll輪詢所負責的所有socket(epoll是隻輪詢那些真正產生了事件的socket),返回fd文件描述符集合給主線程串行執行事件。

⚠️[特別注意]:

select和poll每次調用時都需要將fd_set(文件描述符集合)從用戶空間拷貝到內核空間中,函數返回時又要拷貝回來(epoll使用mmap,避免了每次wait都要將數組進行拷貝)。

在實際開發過程中,基於消息進行系統間通信,我們一般會有四種方法實現:

基於TCP/IP+BIO實現:

在Java中可基於Socket、ServerSocket來實現TCP/IP+BIO的系統通信。

  • Socket主要用於實現建立連接即網絡IO的操作
  • ServerSocket主要用於實現服務器端口的監聽即Socket對象的獲取

爲了滿足服務端可以同時接受多個請求,最簡單的方法是生成多個Socket。但這樣會產生兩個問題:

  • 生成太對Socket會消耗過多資源
  • 頻繁創建Socket會導致系統性能的不足

爲了解決上面的問題,通常採用連接池的方式來維護Socket。一方面能限制Socket的個數;另一方面避免重複創建Socket帶來的性能下降問題。這裏有一個問題就是設置合適的相應超時時間。因爲連接池中Socket個數是有限的,肯定會造成激烈的競爭和等待。

Server服務端:

//創建對本地端口的監聽
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//向服務器發送字符串信息
out.println("hello");
//阻塞讀取服務端的返回信息
in.readLine();

Client客戶端:

//創建連接
Socket socket = new Socket(目標IP或域名, 目標端口);
//BufferedReader用於讀取服務端返回的數據
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//PrintWriter向服務器寫入流
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//像服務端發送流
out.println("hello");
//阻塞讀取服務端的返回信息
in.readLine();
基於TCP/IP+NIO實現:

Java可以基於Clannel和Selector的相關類來實現TCP/IP+NIO方式的系統間通信。Channel有SocketClannel和ServerSocketChannel兩種:

  • SocketClannel: 用於建立連接、監聽事件及操作讀寫。
  • ServerSocketClannel: 用於監聽端口即監聽連接事件。
  • Selecter: 獲取是否有要處理的事件。

Server服務端:

SocketChannel channel = SocketChannel.open();
//設置爲非阻塞模式
channel.configureBlocking(false);
//對於非阻塞模式,立即返回false,表示連接正在建立中
channel.connect(SocketAdress);
Selector selector = Selector.open();
//向channel註冊selector以及感興趣的連接事件
channel.regester(selector,SelectionKey.OP_CONNECT);
//阻塞至有感興趣的IO事件發生,或到達超時時間
int nKeys = selector.select(超時時間【毫秒計】);
//如果希望一直等待知道有感興趣的事件發生
//int nKeys = selector.select();
//如果希望不阻塞直接返回當前是否有感興趣的事件發生
//int nKeys = selector.selectNow();

//如果有感興趣的事件
SelectionKey sKey = null;
if(nKeys>0){
    Set keys = selector.selectedKeys();
    for(SelectionKey key:keys){
        //對於發生連接的事件
        if(key.isConnectable()){
            SocketChannel sc = (SocketChannel)key.channel();
            sc.configureBlocking(false);
            //註冊感興趣的IO讀事件
            sKey = sc.register(selector,SelectionKey.OP_READ);
            //完成連接的建立
            sc.finishConnect();
        }
        //有流可讀取
        else if(key.isReadable()){
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            SocketChannel sc = (SocketChannel) key.channel();
            int readBytes = 0;
            try{
                int ret = 0;
                try{
                    //讀取目前可讀取的值,此步爲阻塞操作
                    while((ret=sc.read(buffer))>0){
                        readBytes += ret;
                    }
                }
                fanally{
                    buffer.flip();
                }
             }
             finally{
                 if(buffer!=null){
                        buffer.clear();
                 }
             }
        }
        //可寫入流
        else if(key.isWritable()){
            //取消對OP_WRITE事件的註冊
            key.interestOps(key.interestOps() & (!SelectionKey.OP_WRITE));
            SocketChannel sc = (SocketChannel) key.channel();
            //此步爲阻塞操作
            int writtenedSize = sc.write(ByteBuffer);
            //如未寫入,則繼續註冊感興趣的OP_WRITE事件
            if(writtenedSize==0){
                key.interestOps(key.interestOps()|SelectionKey.OP_WRITE);
            }
        }
    }
    Selector.selectedKeys().clear();
}
//對於要寫入的流,可直接調用channel.write來完成。只有在未寫入成功時纔要註冊OP_WRITE事件
int wSize = channel.write(ByteBuffer);
if(wSize == 0){
    key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
}

Server端實體:

ServerSocketChannel ssc = ServerSocketChannel.open();
ServerSocket serverSocket = ssc.socket();
//綁定要監聽的接口
serverSocket.bind(new InetSocketAdress(port));
ssc.configureBlocking(false);
//註冊感興趣的連接建立事件
ssc.register(selector,SelectionKey.OP_ACCEPT);
基於UDP/IP+BIO實現:

Java對UDP/IP方式的網絡數據傳輸同樣採用Socket機制,只是UDP/IP下的Socket沒有建立連接,因此無法雙向通信。如果需要雙向通信,必須兩端都生成UDP Server。
Java中通過DatagramSocket和DatagramPacket來實現UDP/IP+BIO方式和系統間通信:

  • DatagramSocket:負責監聽端口和讀寫數據

  • DatagramPacket:作爲數據流對象進行傳輸
    由於UDP雙端不建立連接,所以也就不存在競爭問題,只是最終讀寫流的動作是同步的。
//如果希望雙向通信,必須啓動一個監聽端口承擔服務器的職責
//如果不能綁定到指定端口,則拋出SocketException
DatagramSocket serverSocket = new DatagramSocket(監聽的端口);
byte[] buffer = new byte[65507];
DatagramPacket receivePacket = new DatagramPacket(buffer,buffer.length);
DatagramSocket socket = new DatagramSocket();
DatagramPacket packet = new DatagramPacket(datas,datas.length,server.length);
//阻塞方式發送packet到指定的服務器和端口
socket.send(packet);
//阻塞並同步讀取流消息,如果讀取的流消息比packet長,則刪除更長的消息
//當連接不上目標地址和端口時,拋出PortUnreachableException
DatagramSocket.setSoTimeout(超時時間--毫秒級);
serverSocket.receive(receivePacket);
基於UDP/IP+NIO實現:

Java中可以通過DatagramClannel和ByteBuffer來實現UDP/IP方式的系統間通信:

  • DatagramClannel:負責監聽端口及進行讀寫
  • ByteBuffer:用於數據傳輸
//讀取流信息
DatagramChannel receiveChannel = DatagramChannel.open();
receiveChannel.configureBlocking(false);
DatagramSocket socket = receiveChannel.socket();
socket.bind(new InetSocketAddress(rport));
Selector selector = Selector.open();
receiveChannel.register(selector, SelectionKey.OP_REEAD);
//之後即可像TCP/IP+NIO中對selector遍歷一樣的方式進行流信息的讀取
//...
//寫入流信息
DatagramChannel sendChannel = DatagramChannel.open();
sendChannel.configureBlocking(false);
SocketAdress target = new InetSocketAdress("127.0.0.1",sport);
sendChannel.connect(target);
//阻塞寫入流
sendChannel.write(ByteBuffer);

發展歷程

從軟件系統的發展歷程來看,在分佈式應用出現之前,市面上幾乎所有的軟件系統都是集中式的,軟件,硬件以及各個組件之間的高度耦合組成了單體架構軟件平臺,即就是所謂的單機系統。

一般來說,大型應用系統通常會被拆分成多個子系統,這些子系統可能會部署在多臺機器上,也有可能只在一臺機器上的多個線程中,這就是我們常說的分佈式應用。

從部署形態上來說,以多臺服務器和多個進程部署服務,都是爲了實現一個業務需求和程序功能。分佈式系統中的網絡通信一般都會採用四層的 TCP 協議或七層的 HTTP 協議,在我的瞭解中,前者佔大多數,這主要得益於 TCP 協議的穩定性和高效性。網絡通信說起來簡單,但實際上是一個非常複雜的過程,這個過程主要包括:對端節點的查找、網絡連接的建立、傳輸數據的編碼解碼以及網絡連接的管理等等,每一項都很複雜。

對於系統間通信來說,我們需要區分集羣和分佈式兩個標準:

  • 分佈式應用:一個業務拆分成多個子業務不熟在不同的服務器
  • 集羣:同一個業務部署在不同的多臺服務器上

實現方式

在分佈式服務誕生以前,主要採用以下幾種方式實現系統間的通信:

  1. Socket通信,基於TCP/UDP二進制通訊;效率最高,編程最複雜,需要自定義通訊格式;
  2. JavaEE體系中的RMI或EJB,在Socket基礎之上封裝的實現,直接面象Java對象編程,編程相對簡單,不需要考慮低層實現,效率也不錯,但只能是Java系統間通信
  3. 基於HTTP的通信,即服務端提供可訪問URL,客戶端模擬http請求完成通信;可跨平臺跨語言,通訊效率相對較低,編程較簡單。http+json。很多項目中應用。但是如果服務越來越多,服務與服務之間的調用關係複雜,調用URL管理複雜,什麼時候添加機器難以確定。
  4. 基於Hessian,Remoting on HTTP,類似於RMI與Socket的關係;
  5. 基於JMS,異步通信等。
  6. 基於WebService,可跨平臺跨語言,工具豐富,複雜通信相對編程簡單,通信效率低。它是基於SOAP協議(http+xml:需要在一個工程中將數據變爲xml格式,再傳輸到另外一個項目,並且xml傳輸數據過於臃腫)。項目中不推薦使用。

在分佈式應用時代,業界通常一般兩種方式可以來實現系統間的通信,主要如下:

  • 基於遠程過程調用的方式(Remote Procedure Call):RPC服務調用,客戶端不需要知道調用具體的實現細節,只用直接調用實際存在於遠程計算機上的某個對象即可,調用方式就像調用本地應用程序的對象一樣。使用dubbo。使用rpc協議進行遠程調用,直接使用scoket通信(底層實現,使用二進制的流,所以效率高)。傳輸效率高,並且可以統計出系統之間的調用關係、調用次數,管理服務。
  • 基於消息隊列的方式(Message Queue):MQ服務是某個系統負責發送消息,對於關心這條消息的系統負責接收消息,並且在接收到消息之後轉給其他系統業務處理。

同時,從各系統間通信的整合方式,可以分爲:

  • ESB方式:有服務順序編排/定義,服務實現隔離、多協議支撐、協議翻譯、轉發代理、事務控制等功能
  • 服務註冊中心(很多產品用zookeeper實現):和ESB最大的不同點是:“服務註冊中心”主要提供各原子系統的服務註冊、服務治理、服務隔離、權限控制。當客戶端進行請求時,“服務治理”將告訴客戶端到哪裏去訪問真實的服務,自己並不提供服務的轉發。Dubbo就是一個典型的服務治理框架。

RPC服務調用(RPC服務)

RPC是一種通過網絡從遠程計算機程序上請求服務,不需要我們瞭解底層網絡技術的協議。主要體現在以下幾個方面:

  1. RPC是一種協議,也是一種規範所有的應用需要遵循這套規範實現。典型的RPC實現主要有Dubbo,Thrift,GRPC等。
  2. RPC通信對於網絡來說是透明的,調用方不用關注網絡之間的通信協議,網絡I/O模型,以及通信的信息格式。
  3. RPC調用來說,是可以跨語言的,而且調用方不用關心服務端使用的是何種語言。

file

在 RPC 框架裏面,我們是怎麼支持插件化架構的呢?我們可以將每個功能點抽象成一個接口,將這個接口作爲插件的契約,然後把這個功能的接口與功能的實現分離,並提供接口的默認實現。在 Java 裏面,JDK 有自帶的 SPI(Service Provider Interface)服務發現機制,它可以動態地爲某個接口尋找服務實現。使用 SPI 機制需要在 Classpath 下的 META-INF/services 目錄裏創建一個以服務接口命名的文件,這個文件裏的內容就是這個接口的具體實現類。

但在實際項目中,我們其實很少使用到 JDK 自帶的 SPI 機制,首先它不能按需加載,ServiceLoader 加載某個接口實現類的時候,會遍歷全部獲取,也就是接口的實現類得全部載入並實例化一遍,會造成不必要的浪費。另外就是擴展如果依賴其它的擴展,那就做不到自動注入和裝配,這就很難和其他框架集成,比如擴展裏面依賴了一個 Spring Bean,原生的 Java SPI 就不支持。

我們將每個功能點抽象成一個接口,將這個接口作爲插件的契約,然後把這個功能的接口與功能的實現分離並提供接口的默認實現。這樣的架構相比之前的架構,有很多優勢。首先它的可擴展性很好,實現了開閉原則,用戶可以非常方便地通過插件擴展實現自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精簡,依賴外部包少,這樣可以有效減少開發人員引入 RPC 導致的包版本衝突問題。

一般一個RPC 框架裏面都有會涉及兩個模塊:

  • 傳輸模塊:RPC 本質上就是一個遠程調用,那肯定就需要通過網絡來傳輸數據。雖然傳輸協議可以有多種選擇,但考慮到可靠性的話,我們一般默認採用 TCP 協議。爲了屏蔽網絡傳輸的複雜性,我們需要封裝一個單獨的數據傳輸模塊用來收發二進制數據。
  • 協議封裝:用戶請求的時候是基於方法調用,方法出入參數都是對象數據,對象是肯定沒法直接在網絡中傳輸的,我們需要提前把它轉成可傳輸的二進制,這就是我們說的序列化過程。但只是把方法調用參數的二進制數據傳輸到服務提供方是不夠的,我們需要在方法調用參數的二進制數據後面增加“斷句”符號來分隔出不同的請求,在兩個“斷句”符號中間放的內容就是我們請求的二進制數據。

除此之外,我們還可以在協議模塊中加入壓縮功能,這是因爲壓縮過程也是對傳輸的二進制數據進行操作。在實際的網絡傳輸過程中,我們的請求數據包在數據鏈路層可能會因爲太大而被拆分成多個數據包進行傳輸,爲了減少被拆分的次數,從而導致整個傳輸過程時間太長的問題,我們可以在 RPC 調用的時候這樣操作:在方法調用參數或者返回值的二進制數據大於某個閾值的情況下,我們可以通過壓縮框架進行無損壓縮,然後在另外一端也用同樣的壓縮算法進行解壓,保證數據可還原。

傳輸和協議這兩個模塊是 RPC 裏面最基礎的功能,它們使對象可以正確地傳輸到服務提供方。但距離 RPC 的目標——實現像調用本地一樣地調用遠程,還缺少點東西。因爲這兩個模塊所提供的都是一些基礎能力,要讓這兩個模塊同時工作的話,我們需要手寫一些黏合的代碼,但這些代碼對我們使用 RPC 的研發人員來說是沒有意義的,而且屬於一個重複的工作,會導致使用過程的體驗非常不友好。

消息隊列(MQ服務)

分佈式子系統之間需要通信時,就發送消息。一般通信的兩個要點是:消息處理和消息傳輸。

  • 消息處理:例如讀取數據和寫入數據。基於消息方式實現系統通信的消息處理可以分爲同步消息和異步消息。同步消息一般採用的是BIO(Blocking IO)和NIO(Non-Blocking IO);異步消息一般採用AIO方式。
  • 消息傳輸:消息傳輸需要藉助網絡協議來實現,TCP/IP協議和UDP/IP協議可以用來完成消息傳輸。

消息隊列本質上是一種系統間相互協作的通信機制。一般使用消息隊列可以業務解耦,流量削峯,日誌收集,事務最終一致性,異步處理等業務場景。在我們實際開發工作中,一般消息隊列的使用需要實現:

  • 消息處理中心(Message Broker):負責消息的接收,存儲,轉發等。
  • 消息生產者(Message Producer):負責產生和發送消息的消息處理中心。
  • 消息消費者(Message Consumber):負責從消息處理中心獲取消息,並進行相應的處理。

當然,在技術選型的時候,我們需要選擇最適合我們的。

版權聲明:本文爲博主原創文章,遵循相關版權協議,如若轉載或者分享請附上原文出處鏈接和鏈接來源。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章