讀古今文學網 > Netty實戰 > 第1章 Netty異步和事件驅動 >

第1章 Netty異步和事件驅動

本章主要內容

  • Java網絡編程
  • Netty簡介
  • Netty的核心組件

假設你正在為一個重要的大型公司開發一款全新的任務關鍵型的應用程序。在第一次會議上,你得知該系統必須要能夠擴展到支撐150 000名並發用戶,並且不能有任何的性能損失,這時所有的目光都投向了你。你會怎麼說呢?

如果你可以自信地說:「當然,沒問題。」那麼大家都會向你脫帽致敬。但是,我們大多數人可能會採取一個更加謹慎的立場,例如:「聽上去是可行的。」然後,一回到計算機旁,我們便開始搜索「high performance Java networking」(高性能Java網絡編程)。

如果你現在搜索它,在第一頁結果中,你將會看到下面的內容:

Netty: Home

netty.io/

Netty是一款異步的事件驅動的網絡應用程序框架,支持快速地開發可維護的高性能的面向協議的服務器和客戶端。

如果你和大多數人一樣,通過這樣的方式發現了Netty,那麼你的下一步多半是:瀏覽該網站,下載源代碼,仔細閱讀Javadoc和一些相關的博客,然後寫點兒代碼試試。如果你已經有了紮實的網絡編程經驗,那麼可能進展還不錯,不然則可能是一頭霧水。

這是為什麼呢?因為像我們例子中那樣的高性能系統不僅要求超一流的編程技巧,還需要幾個複雜領域(網絡編程、多線程處理和並發)的專業知識。Netty優雅地處理了這些領域的知識,使得即使是網絡編程新手也能使用。但到目前為止,由於還缺乏一本全面的指南,使得對它的學習過程比實際需要的艱澀得多——因此便有了這本書。

我們編寫這本書的主要目的是:使得Netty能夠盡可能多地被更加廣泛的開發者採用。這也包括那些擁有創新的內容或者服務,卻沒有時間或者興趣成為網絡編程專家的人。如果這適用於你,我們相信你將會非常驚訝自己這麼快便可以開始創建你的第一款基於Netty的應用程序了。當然在另一個層面上講,我們也需要支持那些正在尋找工具來創建他們自己的網絡協議的高級從業人員。

Netty確實提供了極為豐富的網絡編程工具集,我們將花大部分的時間來探究它的能力。但是,Netty終究是一個框架,它的架構方法和設計原則是:每個小點都和它的技術性內容一樣重要,窮其精妙。因此,我們也將探討很多其他方面的內容,例如:

  • 關注點分離——業務和網絡邏輯解耦;
  • 模塊化和可復用性;
  • 可測試性作為首要的要求。

在這第1章中,我們將從一些與高性能網絡編程相關的背景知識開始鋪陳,特別是它在Java開發工具包(JDK)中的實現。有了這些背景知識後,我們將介紹Netty,它的核心概念以及構建塊。在本章結束之後,你就能夠編寫你的第一款基於Netty的客戶端和服務器應用程序了。

1.1 Java網絡編程

早期的網絡編程開發人員,需要花費大量的時間去學習複雜的C語言套接字庫,去處理它們在不同的操作系統上出現的古怪問題。雖然最早的Java(1995—2002)引入了足夠多的面向對像facade(門面)來隱藏一些棘手的細節問題,但是創建一個複雜的客戶端/服務器協議仍然需要大量的樣板代碼(以及相當多的底層研究才能使它整個流暢地運行起來)。

那些最早期的Java API(java.net)只支持由本地系統套接字庫提供的所謂的阻塞函數。代碼清單1-1展示了一個使用了這些函數調用的服務器代碼的普通示例。

代碼清單1-1 阻塞I/O示例

ServerSocket serverSocket = new ServerSocket(portNumber);     ← --  創建一個新的ServerSocket,用以監聽指定端口上的連接請求
Socket clientSocket = serverSocket.accept;    ← --   ❶ 對accept方法的調用將被阻塞,直到一個連接建立
BufferedReader in = new BufferedReader(   
    new InputStreamReader(clientSocket.getInputStream));
PrintWriter out =
    new PrintWriter(clientSocket.getOutputStream, true);     ← --   ❷ 這些流對象都派生於該套接字的流對像
String request, response;
while ((request = in.readLine) != null) {    ← --   ❸ 處理循環開始
    if ("Done".equals(request)) {
        break;     ← --  如果客戶端發送了「Done」,則退出處理循環
    }
    response = processRequest(request);     ← --   ❹ 請求被傳遞給服
務器的處理方法
    out.println(response);     ← --  服務器的響應被發送給了客戶端
}    ← --  繼續執行處理循環  

代碼清單1-1實現了Socket API的基本模式之一。以下是最重要的幾點。

  • ServerSocket上的accept方法將會一直阻塞到一個連接建立❶,隨後返回一個新的Socket用於客戶端和服務器之間的通信。該ServerSocket將繼續監聽傳入的連接。
  • BufferedReaderPrintWriter都衍生自Socket的輸入輸出流❷。前者從一個字符輸入流中讀取文本,後者打印對象的格式化的表示到文本輸出流。
  • readLine方法將會阻塞,直到在❸處一個由換行符或者回車符結尾的字符串被讀取。
  • 客戶端的請求已經被處理❹。

這段代碼片段將只能同時處理一個連接,要管理多個並發客戶端,需要為每個新的客戶端Socket創建一個新的Thread,如圖1-1所示。

圖1-1 使用阻塞I/O處理多個連接

讓我們考慮一下這種方案的影響。第一,在任何時候都可能有大量的線程處於休眠狀態,只是等待輸入或者輸出數據就緒,這可能算是一種資源浪費。第二,需要為每個線程的調用棧都分配內存,其默認值大小區間為64 KB到1 MB,具體取決於操作系統。第三,即使Java虛擬機(JVM)在物理上可以支持非常大數量的線程,但是遠在到達該極限之前,上下文切換所帶來的開銷就會帶來麻煩,例如,在達到10 000個連接的時候。

雖然這種並發方案對於支撐中小數量的客戶端來說還算可以接受,但是為了支撐100 000或者更多的並發連接所需要的資源使得它很不理想。幸運的是,還有一種方案。

1.1.1 Java NIO

除了代碼清單1-1中代碼底層的阻塞系統調用之外,本地套接字庫很早就提供了非阻塞調用,其為網絡資源的利用率提供了相當多的控制:

  • 可以使用setsockopt方法配置套接字,以便讀/寫調用在沒有數據的時候立即返回,也就是說,如果是一個阻塞調用應該已經被阻塞了[1];
  • 可以使用操作系統的事件通知API[2]註冊一組非阻塞套接字,以確定它們中是否有任何的套接字已經有數據可供讀寫。

Java對於非阻塞I/O的支持是在2002年引入的,位於JDK 1.4的java.nio包中。

新的還是非阻塞的

NIO最開始是新的輸入/輸出(New Input/Output)的英文縮寫,但是,該Java API已經出現足夠長的時間了,不再是「新的」了,因此,如今大多數的用戶認為NIO代表非阻塞I/O(Non-blocking I/O),而阻塞I/O(blocking I/O)是舊的輸入/輸出(old input/output,OIO)。你也可能遇到它被稱為普通I/O(plain I/O)的時候。

1.1.2 選擇器

圖1-2展示了一個非阻塞設計,其實際上消除了上一節中所描述的那些弊端。

圖1-2 使用Selector的非阻塞I/O

class java.nio.channels.Selector是Java的非阻塞I/O實現的關鍵。它使用了事件通知API以確定在一組非阻塞套接字中有哪些已經就緒能夠進行I/O相關的操作。因為可以在任何的時間檢查任意的讀操作或者寫操作的完成狀態,所以如圖1-2所示,一個單一的線程便可以處理多個並發的連接。

總體來看,與阻塞I/O模型相比,這種模型提供了更好的資源管理:

  • 使用較少的線程便可以處理許多連接,因此也減少了內存管理和上下文切換所帶來開銷;
  • 當沒有I/O操作需要處理的時候,線程也可以被用於其他任務。

儘管已經有許多直接使用Java NIO API的應用程序被構建了,但是要做到如此正確和安全並不容易。特別是,在高負載下可靠和高效地處理和調度I/O操作是一項繁瑣而且容易出錯的任務,最好留給高性能的網絡編程專家——Netty。

1.2 Netty簡介

不久以前,我們在本章一開始所呈現的場景——支持成千上萬的並發客戶端——還被認定為是不可能的。然而今天,作為系統用戶,我們將這種能力視為理所當然;同時作為開發人員,我們期望將水平線提得更高[3]。因為我們知道,總會有更高的吞吐量和可擴展性的要求——在更低的成本的基礎上進行交付。

不要低估了這最後一點的重要性。我們已經從漫長的痛苦經歷中學到:直接使用底層的API暴露了複雜性,並且引入了對往往供不應求的技能的關鍵性依賴[4]。這也就是,面向對象的基本概念:用較簡單的抽像隱藏底層實現的複雜性。

這一原則也催生了大量框架的開發,它們為常見的編程任務封裝了解決方案,其中的許多都和分佈式系統的開發密切相關。我們可以確定地說:所有專業的Java開發人員都至少對它們熟知一二。[5]對於我們許多人來說,它們已經變得不可或缺,因為它們既能滿足我們的技術需求,又能滿足我們的時間表。

在網絡編程領域,Netty是Java的卓越框架。[6]它駕馭了Java高級API的能力,並將其隱藏在一個易於使用的API之後。Netty使你可以專注於自己真正感興趣的——你的應用程序的獨一無二的價值。

在我們開始首次深入地瞭解Netty之前,請仔細審視表1-1中所總結的關鍵特性。有些是技術性的,而其他的更多的則是關於架構或設計哲學的。在本書的學習過程中,我們將不止一次地重新審視它們。

表1-1 Netty的特性總結

分  類

Netty的特性

設計

統一的API,支持多種傳輸類型,阻塞的和非阻塞的
簡單而強大的線程模型
真正的無連接數據報套接字支持
鏈接邏輯組件以支持復用

易於使用

詳實的Javadoc和大量的示例集
不需要超過JDK 1.6+[7]的依賴。(一些可選的特性可能需要Java 1.7+和/或額外的依賴)

性能

擁有比Java的核心API更高的吞吐量以及更低的延遲
得益於池化和復用,擁有更低的資源消耗
最少的內存複製

健壯性

不會因為慢速、快速或者超載的連接而導致OutOfMemoryError
消除在高速網絡中NIO應用程序常見的不公平讀/寫比率

安全性

完整的SSL/TLS以及StartTLS支持
可用於受限環境下,如Applet和OSGI

社區驅動

發佈快速而且頻繁

1.2.1 誰在使用Netty

Netty擁有一個充滿活力並且不斷壯大的用戶社區,其中不乏大型公司,如Apple、Twitter、Facebook、Google、Square和Instagram,還有流行的開源項目,如Infinispan、HornetQ、Vert.x、Apache Cassandra和Elasticsearch[8],它們所有的核心代碼都利用了Netty強大的網絡抽像[9]。在初創企業中,Firebase和Urban Airship也在使用Netty,前者用來做HTTP長連接,而後者用來支持各種各樣的推送通知。

每當你使用Twitter,你便是在使用Finagle[10],它們基於Netty的系統間通信框架。Facebook在Nifty中使用了Netty,它們的Apache Thrift服務。可伸縮性和性能對這兩家公司來說至關重要,他們也經常為Netty貢獻代碼[11]。

反過來,Netty也已從這些項目中受益,通過實現FTP、SMTP、HTTP和WebSocket以及其他的基於二進制和基於文本的協議,Netty擴展了它的應用範圍及靈活性。

1.2.2 異步和事件驅動

因為我們要大量地使用「異步」這個詞,所以現在是一個澄清上下文的好時機。異步(也就是非同步)事件肯定大家都熟悉。考慮一下電子郵件:你可能會也可能不會收到你已經發出去的電子郵件對應的回復,或者你也可能會在正在發送一封電子郵件的時候收到一個意外的消息。異步事件也可以具有某種有序的關係。通常,你只有在已經問了一個問題之後才會得到一個和它對應的答案,而在你等待它的同時你也可以做點別的事情。

在日常的生活中,異步自然而然地就發生了,所以你可能沒有對它考慮過多少。但是讓一個計算機程序以相同的方式工作就會產生一些非常特殊的問題。本質上,一個既是異步的又是事件驅動的系統會表現出一種特殊的、對我們來說極具價值的行為:它可以以任意的順序響應在任意的時間點產生的事件。

這種能力對於實現最高級別的可伸縮性至關重要,定義為:「一種系統、網絡或者進程在需要處理的工作不斷增長時,可以通過某種可行的方式或者擴大它的處理能力來適應這種增長的能力。」[12]

異步和可伸縮性之間的聯繫又是什麼呢?

  • 非阻塞網絡調用使得我們可以不必等待一個操作的完成。完全異步的I/O正是基於這個特性構建的,並且更進一步:異步方法會立即返回,並且在它完成時,會直接或者在稍後的某個時間點通知用戶。
  • 選擇器使得我們能夠通過較少的線程便可監視許多連接上的事件。

將這些元素結合在一起,與使用阻塞I/O來處理大量事件相比,使用非阻塞I/O來處理更快速、更經濟。從網絡編程的角度來看,這是構建我們理想系統的關鍵,而且你會看到,這也是Netty的設計底蘊的關鍵。

在1.3節中,我們將首先看一看Netty的核心組件。現在,只需要將它們看作是域對象,而不是具體的Java類。隨著時間的推移,我們將看到它們是如何協作,來為在網絡上發生的事件提供通知,並使得它們可以被處理的。

1.3 Netty的核心組件

在本節中我將要討論Netty的主要構件塊:

  • Channel
  • 回調;
  • Future
  • 事件和ChannelHandler

這些構建塊代表了不同類型的構造:資源、邏輯以及通知。你的應用程序將使用它們來訪問網絡以及流經網絡的數據。

對於每個組件來說,我們都將提供一個基本的定義,並且在適當的情況下,還會提供一個簡單的示例代碼來說明它的用法。

1.3.1 Channel

Channel是Java NIO的一個基本構造。

它代表一個到實體(如一個硬件設備、一個文件、一個網絡套接字或者一個能夠執行一個或者多個不同的I/O操作的程序組件)的開放連接,如讀操作和寫操作[13]。

目前,可以把Channel看作是傳入(入站)或者傳出(出站)數據的載體。因此,它可以被打開或者被關閉,連接或者斷開連接。

1.3.2 回調

一個回調其實就是一個方法,一個指向已經被提供給另外一個方法的方法的引用。這使得後者[14]可以在適當的時候調用前者。回調在廣泛的編程場景中都有應用,而且也是在操作完成後通知相關方最常見的方式之一。

Netty在內部使用了回調來處理事件;當一個回調被觸發時,相關的事件可以被一個interface-ChannelHandler的實現處理。代碼清單1-2展示了一個例子:當一個新的連接已經被建立時,ChannelHandlerchannelActive回調方法將會被調用,並將打印出一條信息。

代碼清單1-2 被回調觸發的ChannelHandler

public class ConnectHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelActive(ChannelHandlerContext ctx)
        throws Exception {    ← --  當一個新的連接已經被建立時,channelActive(ChannelHandlerContext)將會被調用
        System.out.println(
            "Client " + ctx.channel.remoteAddress + " connected");
    }
}  

1.3.3 Future

Future提供了另一種在操作完成時通知應用程序的方式。這個對象可以看作是一個異步操作的結果的佔位符;它將在未來的某個時刻完成,並提供對其結果的訪問。

JDK預置了interface java.util.concurrent.Future,但是其所提供的實現,只允許手動檢查對應的操作是否已經完成,或者一直阻塞直到它完成。這是非常繁瑣的,所以Netty提供了它自己的實現——ChannelFuture,用於在執行異步操作的時候使用。

ChannelFuture提供了幾種額外的方法,這些方法使得我們能夠註冊一個或者多個ChannelFutureListener實例。監聽器的回調方法operationComplete,將會在對應的操作完成時被調用[15]。然後監聽器可以判斷該操作是成功地完成了還是出錯了。如果是後者,我們可以檢索產生的Throwable。簡而言之,由ChannelFutureListener提供的通知機制消除了手動檢查對應的操作是否完成的必要。

每個Netty的出站I/O操作都將返回一個ChannelFuture;也就是說,它們都不會阻塞。正如我們前面所提到過的一樣,Netty完全是異步和事件驅動的。

代碼清單1-3展示了一個ChannelFuture作為一個I/O操作的一部分返回的例子。這裡,connect方法將會直接返回,而不會阻塞,該調用將會在後台完成。這究竟什麼時候會發生則取決於若干的因素,但這個關注點已經從代碼中抽像出來了。因為線程不用阻塞以等待對應的操作完成,所以它可以同時做其他的工作,從而更加有效地利用資源。

代碼清單1-3 異步地建立連接

Channel channel = ...;
// Does not block
ChannelFuture future = channel.connect(     ← --  異步地連接到遠程節點
    new InetSocketAddress("192.168.0.1", 25));  

代碼清單1-4顯示了如何利用 ChannelFutureListener。首先,要連接到遠程節點上。然後,要註冊一個新的ChannelFutureListener到對connect方法的調用所返回的ChannelFuture上。當該監聽器被通知連接已經建立的時候,要檢查對應的狀態❶。如果該操作是成功的,那麼將數據寫到該Channel。否則,要從ChannelFuture中檢索對應的Throwable

代碼清單1-4 回調實戰

Channel channel = ...;
// Does not block
ChannelFuture future = channel.connect(  ← -- 異步地連接到遠程節點
    new InetSocketAddress("192.168.0.1", 25));
future.addListener(new ChannelFutureListener {   ← --  註冊一個ChannelFutureListener,以便在操作完成時獲得通知
    @Override
    public void operationComplete(ChannelFuture future) { ← --  ❶ 檢查操作
的狀態
       if (future.isSuccess){ 
            ByteBuf buffer = Unpooled.copiedBuffer(  ← -- 如果操作是成功的,則創建一個ByteBuf以持有數據
               "Hello",Charset.defaultCharset);
           ChannelFuture wf = future.channel
                .writeAndFlush(buffer);   ← -- 將數據異步地發送到遠程節點。
返回一個ChannelFuture
            ....
        } else {
            Throwable cause = future.cause;  ← -- 如果發生錯誤,則訪問描述原因的Throwable
            cause.printStackTrace;
        }
    }
});  

需要注意的是,對錯誤的處理完全取決於你、目標,當然也包括目前任何對於特定類型的錯誤加以的限制。例如,如果連接失敗,你可以嘗試重新連接或者建立一個到另一個遠程節點的連接。

如果你把ChannelFutureListener看作是回調的一個更加精細的版本,那麼你是對的。事實上,回調和Future是相互補充的機制;它們相互結合,構成了Netty本身的關鍵構件塊之一。

1.3.4 事件和ChannelHandler

Netty使用不同的事件來通知我們狀態的改變或者是操作的狀態。這使得我們能夠基於已經發生的事件來觸發適當的動作。這些動作可能是:

  • 記錄日誌;
  • 數據轉換;
  • 流控制;
  • 應用程序邏輯。

Netty是一個網絡編程框架,所以事件是按照它們與入站或出站數據流的相關性進行分類的。可能由入站數據或者相關的狀態更改而觸發的事件包括:

  • 連接已被激活或者連接失活;
  • 數據讀取;
  • 用戶事件;
  • 錯誤事件。

出站事件是未來將會觸發的某個動作的操作結果,這些動作包括:

  • 打開或者關閉到遠程節點的連接;
  • 將數據寫到或者沖刷到套接字。

每個事件都可以被分發給ChannelHandler類中的某個用戶實現的方法。這是一個很好的將事件驅動範式直接轉換為應用程序構件塊的例子。圖1-3展示了一個事件是如何被一個這樣的ChannelHandler鏈處理的。

圖1-3 流經ChannelHandler鏈的入站事件和出站事件

Netty的ChannelHandler為處理器提供了基本的抽像,如圖1-3所示的那些。我們會在適當的時候對ChannelHandler進行更多的說明,但是目前你可以認為每個Channel-Handler的實例都類似於一種為了響應特定事件而被執行的回調。

Netty提供了大量預定義的可以開箱即用的ChannelHandler實現,包括用於各種協議(如HTTP和SSL/TLS)的ChannelHandler。在內部,ChannelHandler自己也使用了事件和Future,使得它們也成為了你的應用程序將使用的相同抽像的消費者。

1.3.5 把它們放在一起

在本章中,我們介紹了Netty實現高性能網絡編程的方式,以及它的實現中的一些主要的組件。讓我們大體回顧一下我們討論過的內容吧。

1.Future、回調和ChannelHandler

Netty的異步編程模型是建立在Future和回調的概念之上的, 而將事件派發到ChannelHandler的方法則發生在更深的層次上。結合在一起,這些元素就提供了一個處理環境,使你的應用程序邏輯可以獨立於任何網絡操作相關的顧慮而獨立地演變。這也是Netty的設計方式的一個關鍵目標。

攔截操作以及高速地轉換入站數據和出站數據,都只需要你提供回調或者利用操作所返回的Future。這使得鏈接操作變得既簡單又高效,並且促進了可重用的通用代碼的編寫。

2.選擇器、事件和EventLoop

Netty通過觸發事件將Selector從應用程序中抽像出來,消除了所有本來將需要手動編寫的派發代碼。在內部,將會為每個Channel分配一個EventLoop,用以處理所有事件,包括:

  • 註冊感興趣的事件;
  • 將事件派發給ChannelHandler
  • 安排進一步的動作。

EventLoop本身只由一個線程驅動,其處理了一個Channel的所有I/O事件,並且在該EventLoop的整個生命週期內都不會改變。這個簡單而強大的設計消除了你可能有的在ChannelHandler實現中需要進行同步的任何顧慮,因此,你可以專注於提供正確的邏輯,用來在有感興趣的數據要處理的時候執行。如同我們在詳細探討Netty的線程模型時將會看到的,該API是簡單而緊湊的。

1.4 小結

在這一章中,我們介紹了Netty框架的背景知識,包括Java網絡編程API的演變過程,阻塞和非阻塞網絡操作之間的區別,以及異步I/O在高容量、高性能的網絡編程中的優勢。

然後,我們概述了Netty的特性、設計和優點,其中包括Netty異步模型的底層機制,包括回調、Future以及它們的結合使用。我們還談到了事件是如何產生的以及如何攔截和處理它們。

在本書接下來的部分,我們將更加深入地探討如何利用這些豐富的工具集來滿足自己的應用程序的特定需求。

在下一章中,我們將要深入地探討Netty的API以及編程模型的基礎知識,而你則將編寫你的第一款客戶端和服務器應用程序。


[1] W. Richard Stevens的Advanced Programming in the UNIX Environment (Addison-Wesley, 1992)第364頁「4.3BSD returned EWOULDBLOCK if an operation on a non-blocking descriptor could not complete without blocking」。

[2] 也稱為I/O多路復用,該接口從最初的selectpoll調用到更加高性能的實現,已經演變了很多年。參見Sangjin Han的文章《Scalable Event Multiplexing: epoll vs. kqueue》(www.eecs.berkeley.edu/~ sangjin/2012/12/21/epoll-vs-kqueue.html)。

[3] 這裡指支撐更多的並發的客戶端。——譯者注

[4] 這裡指熟悉這些底層的API的人員少。——譯者注

[5] Spring框架大概是最出名的,並且實際上是一個完整的應用程序框架的生態系統,處理了對象的創建、批量處理、數據庫編程等。

[6] Netty在2011年榮獲了Duke』s Choice Award的殊榮,參見www.java.net/dukeschoice/2011。

[7] 最新的版本編譯需要JDK 1.8+,參見https://github.com/netty/netty/pull/6392。——譯者注

[8] 還包括炙手可熱的大數據處理引擎Spark。——譯者注

[9] 完整的已知採用者列表參見http://netty.io/wiki/adopters.html。

[10] 關於Finagle的更多信息參見https://twitter.github.io/finagle/。

[11] 第15章和第16章的案例研究描述了這裡提到的公司中的一些是如何使用Netty來解決現實世界的問題的。

[12] Andre B. Bondi的Proceedings of the second international workshop on Software and performance— WOSP』00 (2000)第195頁,「Characteristics of scalability and their impact on performance」。

[13] Java平台,標準版第8版API規範,java.nio.channels,Channel:http://docs.oracle.com/javase/8/docs/ api/java/nio/channels/package-summary.html。

[14] 指接受回調的方法。——譯者注

[15] 如果在ChannelFutureListener添加到ChannelFuture的時候,ChannelFuture已經完成,那麼該ChannelFutureListener將會被直接地通知。——譯者注