如何為可擴充套件系統進行Java Socket程式設計

TP_funny發表於2015-05-22
從簡單I/O到非同步非阻塞channel的Java Socket模型演變之旅
上世紀九十年代後期,我在一家線上視訊遊戲工資工作,在哪裡我主要的工作就是編寫Unix Unix Berkley Socket和Windows WinSock程式碼。我的任務是確保視訊遊戲客戶端和一個遊戲伺服器通訊。很幸運有這樣的機會寫一些Java Socket程式碼,我對Java流式網路程式設計和簡潔明瞭的API著迷。這一點都不讓人驚訝,Java最初就是設計促進智慧裝置之間的通訊,這一點很好的轉移到了桌面應用和伺服器應用。
1996年,JavaWorld刊登了Qusay H. Mahmoud的文章”Sockets programming in Java: A tutorial“。文章概述了Java的Socket程式設計模型。從那以後的18年,這個模型少有變化。這篇文章依然是網路系統Java socket程式設計的入門經典。我將在此基礎之上,首先列出一個簡單的客戶端/伺服器例子,開啟Java I/O謙卑之旅。此例展示來自java.io包和NIO——Java1.4引起的新的非阻塞I/O API的特性,最後一個例子會涉及Java 7引入的 NIO2 某些特性。

Java的Socket程式設計:TCP和UDP
Socket程式設計拆分為兩個系統之間的相互通訊,網路通訊有兩種方式:ransport Control Protocol(TCP)和User Datagram Protocol(UDP)。TCP和UDP用途不一,並且有各自獨特的約束:
  •  TCP協議相對簡單穩定,可以幫助客戶端與一臺伺服器建立連線,這樣兩個系統就可以通訊。在TCP協議中,每個實體都能保證其通訊載荷(communication payload)會被接受。
  •  UDP是一種非連線協議,適用於那些無需保證每個包都能抵達終點的場景,比如流媒體。
如何區分這兩者的差異?試想,倘若你在自己喜歡的網站上觀看流媒體視訊,這時掉幀會發生什麼。你是傾向於客戶端放緩視訊接收丟失的幀,還是繼續觀看視訊呢?典型的流媒體協議採用UDP協議,因為TCP協議保障傳輸,HTTP、FTP、SMTP、POP3等協議會選擇TCP。

以往的Socket程式設計
早在NIO以前,Java TCP客戶端socket程式碼主要由java.net.Socket類來實現。下面的程式碼開啟了一個對伺服器的連線:
Socket socket = new Socket( server, port );
一旦Socket例項與伺服器相連,我們就可以獲得伺服器端的輸入輸出流。輸入流用來讀取伺服器端的資料,輸出流用來將資料寫回到伺服器端。可以執行以下的方法獲取輸入輸出流:
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
這是基本的流——用來讀取或者寫入一個檔案的流是相同的,所以我們能夠將其轉換成最好的形式服務於用例中。比如,我們可以用一個PrintStream 包裝 OutputStream,這樣我們就能輕易地用println()等方法對文字進行寫的操作。再比如,我們用BufferedReader包裝 InputStream,再通過InputStreamReader可以很容易的用readLine()等方法對文字進行讀操作。

Java I/O示例第一部分:HTTP客戶端
通過一個簡短的例子來看如何執行HTTP GET獲取一個HTTP服務。HTTP比本例更加複雜成熟,在我們只寫一個客戶端程式碼去處理簡單案例。發出一個請求,從伺服器端獲取一個資源,同時伺服器端返回響應,並關閉流。本案例所需的步驟如下:
  • 建立埠為80的網路伺服器所對應的客戶端Socket。
  • 從伺服器端獲取一個PrintStream,同時傳送一個GET PATH HTTP/1.0請求,其中PATH就是伺服器上的請求資源。比如,假設你想開啟一個網站根目錄,那麼path就是 / 。
  • 獲取伺服器端的InputStream,用一個BufferedReader將其包裝,然後按行讀取響應。
列表1、 SimpleSocketClientExample.java
package com.geekcap.javaworld.simplesocketclient;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.Socket;

public class SimpleSocketClientExample
{
    public static void main( String[] args )
    {
        if( args.length < 2 )
        {
            System.out.println( "Usage: SimpleSocketClientExample <server> <path>" );
            System.exit( 0 );
        }
        String server = args[ 0 ];
        String path = args[ 1 ];

        System.out.println( "Loading contents of URL: " + server );

        try
        {
            // 建立與埠為80的網路伺服器對應的客戶端socket
            Socket socket = new Socket( server, 80 );

            //從伺服器端獲取一個PrintStream
            PrintStream out = new PrintStream( socket.getOutputStream() );
            //獲取伺服器端的InputStream,用一個BufferedReader將其包裝
            BufferedReader in = new BufferedReader( new InputStreamReader( socket.getInputStream() ) );

            //傳送一個GET PATH HTTP/1.0請求到伺服器端
            out.println( "GET " + path + " HTTP/1.0" );
            out.println();

            //按行的讀取伺服器端的返回的響應資料
            String line = in.readLine();
            while( line != null )
            {
                System.out.println( line );
                line = in.readLine();
            }

            // 關閉流
            in.close();
            out.close();
            socket.close();
        }
        catch( Exception e )
        {
            e.printStackTrace();
        }
    }
}
列表1接受兩個命令列引數:需要連線的伺服器,需要取回的資源。建立一個Socket指向伺服器端,並且顯式地為其指定埠號80,接著程式會指向這個命令:
GET PATH HTTP/1.0
比如
GET / HTTP/1.0

這個過程中發生了什麼?
當你準備從一個web伺服器獲取一個網頁,比如 www.google.com, HTTP client利用DNS伺服器去獲取伺服器地址:從最高域名伺服器開始查詢com域名,哪裡存有 www.google.com 的權威域名伺服器,接著 HTTP client詢問域名伺服器 www.google.com 的IP地址。接下來,它會開啟一個Socket通向埠80的伺服器。最後, HTTP Client執行特定的HTTP方法,比如GET、POST、PUT、DELETE、HEAD 或者OPTI/ONS。每種方法都有自己的語法,如上述的程式碼列表中,GET方法後面依次需要一個path、HTTP/版本號、一個空行。如果想加入 HTTP headers,我們必須在進入新的一行之前完成。
在列表1中,獲取了一個 OutputStream,並用 PrintStream 包裝了它,這樣我們就能容易的執行基於文字的命令。 同樣,從 InputStream 獲取的程式碼,InputStreamReader 包裝之後,流被轉化成一個Reader,再用 BufferedReader 包裝。這樣我們就能用PrintStream執行GET方法,用BufferedReader 按行讀取響應直到獲取的響應為 null 時結束,最後關閉Socket。
現在我們執行這個類,傳入以下的引數:
java com.geekcap.javaworld.simplesocketclient.SimpleSocketClientExample www.javaworld.com /
你應該能夠看到類似下面的輸出:
Loading contents of URL: www.javaworld.com
HTTP/1.1 200 OK
Date: Sun, 21 Sep 2014 22:20:13 GMT
Server: Apache
X-Gas_TTL: 10
Cache-Control: max-age=10
X-GasHost: gas2.usw
X-Cooking-With: Gasoline-Local
X-Gasoline-Age: 8
Content-Length: 168
Last-Modified: Tue, 24 Jan 2012 00:09:09 GMT
Etag: "60001b-a8-4b73af4bf3340"
Content-Type: text/html
Vary: Accept-Encoding
Connection: close

<!DOCTYPE html>
<html lang="en">
<head>
        <meta charset="utf-8" />
        <title>Gasoline Test Page</title>
</head>
<body>
<br><br>
<center>Success</center>
</body>
</html>
本輸出顯示了JavaWorld網站測試頁面,網頁HTTP version 1.1,響應200 OK.

Java I/O示例第二部分:HTTP伺服器
剛才我們說了客戶端,幸運的是,伺服器端的通訊也是很容易。從一個簡單的視角看,處理過程如下:
  • 建立一個ServerSocket,並指定一個監聽埠。
  • 呼叫 ServerSocket的 accept() 方法監聽來自客戶端的連線。
  • 一旦有客戶端連線伺服器,accept() 方法通過伺服器與客戶端通訊,返回一個Socket。在客戶端用過同樣的Socket類,那麼處理過程相同,獲取 InputStream 讀取客戶端資訊,OutputStream 寫資料到客戶端。
  • 如果伺服器需要擴充套件,你需要將Socket傳給其他的執行緒去處理,因此伺服器可以持續的監聽後來的連線。
  • 再次呼叫 ServerSocket的 accept() 方法監聽其它連線。
正如你所看到的,NIO處理此場景略有不同。可以直接建立ServerSocket,並將一個埠號傳給它用於監聽(關於 ServerSocketFactory 的更多資訊會在後面討論):
ServerSocket serverSocket = new ServerSocket( port );
通過 accept() 方法接收傳入的連線:
Socket socket = serverSocket.accept();
// 處理連線……

多執行緒Socket程式設計
在如下的列表2中,所有的伺服器程式碼放在一起組成一個更加健壯的例子,本例中執行緒處理多個請求。伺服器是一個ECHO伺服器,就是說會將所有接收到的訊息返回。
列表2中的例子不是很複雜,但已經提前介紹了一部分NIO的內容。線上程程式碼上花費一些精力,是為了構建一個處理多併發請求的伺服器。
列表2、SimpleSocketServer.java
package com.geekcap.javaworld.simplesocketclient;

import java.io.BufferedReader;
import java.io.I/OException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class SimpleSocketServer extends Thread
{
    private ServerSocket serverSocket;
    private int port;
    private boolean running = false;

    public SimpleSocketServer( int port )
    {
        this.port = port;
    }

    public void startServer()
    {
        try
        {
            serverSocket = new ServerSocket( port );
            this.start();
        }
        catch (I/OException e)
        {
            e.printStackTrace();
        }
    }

    public void stopServer()
    {
        running = false;
        this.interrupt();
    }

    @Override
    public void run()
    {
        running = true;
        while( running )
        {
            try
            {
                System.out.println( "Listening for a connection" );

                // 呼叫 accept() 處理下一個連線
                Socket socket = serverSocket.accept();

                // 向 RequestHandler 執行緒傳遞socket物件進行處理
                RequestHandler requestHandler = new RequestHandler( socket );
                requestHandler.start();
            }
            catch (I/OException e)
            {
                e.printStackTrace();
            }
        }
    }

    public static void main( String[] args )
    {
        if( args.length == 0 )
        {
            System.out.println( "Usage: SimpleSocketServer <port>" );
            System.exit( 0 );
        }
        int port = Integer.parseInt( args[ 0 ] );
        System.out.println( "Start server on port: " + port );

        SimpleSocketServer server = new SimpleSocketServer( port );
        server.startServer();

        // 1分鐘後自動關閉
        try
        {
            Thread.sleep( 60000 );
        }
        catch( Exception e )
        {
            e.printStackTrace();
        }

        server.stopServer();
    }
}

class RequestHandler extends Thread
{
    private Socket socket;
    RequestHandler( Socket socket )
    {
        this.socket = socket;
    }

    @Override
    public void run()
    {
        try
        {
            System.out.println( "Received a connection" );

            // 獲取輸入和輸出流
            BufferedReader in = new BufferedReader( new InputStreamReader( socket.getInputStream() ) );
            PrintWriter out = new PrintWriter( socket.getOutputStream() );

            // 向客戶端寫出頭資訊
            out.println( "Echo Server 1.0" );
            out.flush();

            // 向客戶端回寫資訊,直到客戶端關閉連線或者收到空行
            String line = in.readLine();
            while( line != null && line.length() > 0 )
            {
                out.println( "Echo: " + line );
                out.flush();
                line = in.readLine();
            }

            // 關閉自己的連線
            in.close();
            out.close();
            socket.close();

            System.out.println( "Connection closed" );
        }
        catch( Exception e )
        {
            e.printStackTrace();
        }
    }
}
在列表2中,我們建立了一個新的 SimpleSocketServer 例項,並開啟了這個伺服器。繼承 Thread 的 SimpleSocketServer 建立一個新的執行緒,處理存在於 run() 方法中的阻塞方法 accept() 呼叫。
run() 方法中存在一個迴圈,用來接收客戶端請求,並建立RequestHandler執行緒去處理這些請求。再次強調,這是一個相對簡單的程式設計,但涉及了相當的執行緒程式設計。
RequestHandler 處理客戶端通訊程式碼與列表1相似:PrintStream 包裝後的 OutputStream 更容易進行寫操作。同 樣,BufferedReader 包裝後的InputStream 更易於讀取。只要伺服器在跑,RequestHandler 就會將客戶端的資訊按行讀取,並將它們返回給客戶端。如果客戶端發過來的是空行,那對話就結束了,RequestHandler 關閉Socket 。

NIO、NIO2 Socket程式設計
對於多數應用而言,Java基礎的Socket程式設計,我們已經做了充分的探討。對於涉及到高強度的 I/O 或者非同步輸入輸出,大家就有了熟悉Java NIO和NIO.2中非阻塞API的需要。
JDK1.4 NIO包提供瞭如下重要特性:
  • Channel 被設計用來支援塊(bulk)轉移,從一個NIO轉到另一個NIO。
  • Buffer 提供了連續的記憶體塊,由一組簡單的操作提供介面。
  • 非阻塞I/O 是一組class檔案,它們可以將 Channel 開放給普通的I/O資源,比如檔案和Socket。
用NIO編碼時,你可以開啟一個到目的地的Channel,接著從目的地讀取資料到一個buffer中;寫入資料到一個buffer中,接著將其傳送到目的地。我會建立一個Socket,併為此獲取一個Channel。但首先讓我們回顧一下buffer的處理流程:
  • 寫資料到一個buffer中。
  • 呼叫buffer的 flip() 方法準備讀的操作。
  • 從buffer中讀取資料。
  • 呼叫buffer中的 clear() 或者 compact() 方法準備讀取更多的資料。
當資料寫入buffer後,buffer知道寫入其中的資料量。它維護了三個屬性,在讀模式和寫模式中其含義不盡相同。
  • Position:在寫模式中,初始position值為0,它儲存的是寫入buffer後的當前位置;一旦flip一個buffer使其進入讀模式,它會將位置的值重置為0,然後儲存讀取buffer後的當前位置。
  • Capacity:指的是buffer的固定大小。
  • Limit:在寫模式中,limit定義了寫入buffer的資料大小;在讀模式中,limit定義了可以從buffer中讀取的資料大小。

Java I/O示例第三部分:基於NIO.2的ECHO伺服器
JDK 7引入的NIO.2新增了非阻塞I/O庫去支援檔案系統任務,比如 java.nio.file 包和 java.nio.file.Path 類,並提供了一個 新的檔案系統API。記住,我們採用IO.2 AsynchronousServerSocketChannel 寫一個新的ECHO伺服器。
 ”NIO在提供處理效能方法大放異彩,但NIO的結果跟底層平臺緊密相連。比如,或許你會發現,NIO加速應用效能不光取決於OS,還跟特定的JVM有關,主機的虛擬化上下文、大儲存特性、甚至資料……”
——摘自”Five ways to maximize Java NIO and NIO.2
AsynchronousServerSocketChannel 提供了一個非阻塞非同步Channel作為流定向監聽的Socket。為了用這個Channel,首先需要執行它的 open() 靜態方法。然後呼叫 bind() 為其繫結一個埠號。接著,將一個實現CompletionHandler介面的類傳給 accept() 並執行。多數時候,你會發現 handler作為匿名內部類被建立。
列表3顯示新的非同步ECHO伺服器原始碼。
列表3、SimpleSocketServer.java
package com.geekcap.javaworld.nio2;

import java.io.I/OException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class NioSocketServer
{
    public NioSocketServer()
    {
        try
        {
            // 建立一個 AsynchronousServerSocketChannel 偵聽 5000 埠
            final AsynchronousServerSocketChannel listener =
                    AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(5000));

            // 偵聽新的請求
            listener.accept( null, new CompletionHandler<AsynchronousSocketChannel,Void>() {

                @Override
                public void completed(AsynchronousSocketChannel ch, Void att)
                {
                    // 接受下一個連線
                    listener.accept( null, this );

                    // 向客戶端傳送問候資訊
                    ch.write( ByteBuffer.wrap( "Hello, I am Echo Server 2020, let's have an engaging conversation!n".getBytes() ) );

                    // 分配(4K)位元組緩衝用於從客戶端讀取資訊
                    ByteBuffer byteBuffer = ByteBuffer.allocate( 4096 );
                    try
                    {
                        // Read the first line
                        int bytesRead = ch.read( byteBuffer ).get( 20, TimeUnit.SECONDS );

                        boolean running = true;
                        while( bytesRead != -1 && running )
                        {
                            System.out.println( "bytes read: " + bytesRead );

                            // 確保有讀取到資料
                            if( byteBuffer.position() > 2 )
                            {
                                // 準備快取進行讀取
                                byteBuffer.flip();

                                // 把快取轉換成字串
                                byte[] lineBytes = new byte[ bytesRead ];
                                byteBuffer.get( lineBytes, 0, bytesRead );
                                String line = new String( lineBytes );

                                // Debug
                                System.out.println( "Message: " + line );

                                // 向呼叫者回寫
                                ch.write( ByteBuffer.wrap( line.getBytes() ) );

                                // 準備緩衝進行寫操作
                                byteBuffer.clear();

                                // 讀取下一行
                                bytesRead = ch.read( byteBuffer ).get( 20, TimeUnit.SECONDS );
                            }
                            else
                            {
                                // 在我們的協議中,空行表示會話結束
                                running = false;
                            }
                        }
                    }
                    catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                    catch (ExecutionException e)
                    {
                        e.printStackTrace();
                    }
                    catch (TimeoutException e)
                    {
                        // 使用者達到20秒超時,關閉連線
                        ch.write( ByteBuffer.wrap( "Good Byen".getBytes() ) );
                        System.out.println( "Connection timed out, closing connection" );
                    }

                    System.out.println( "End of conversation" );
                    try
                    {
                        // 如果需要,關閉連線
                        if( ch.isOpen() )
                        {
                            ch.close();
                        }
                    }
                    catch (I/OException e1)
                    {
                        e1.printStackTrace();
                    }
                }

                @Override
                public void failed(Throwable exc, Void att) {
                    ///...
                }
            });
        }
        catch (I/OException e)
        {
            e.printStackTrace();
        }
    }

    public static void main( String[] args )
    {
        NioSocketServer server = new NioSocketServer();
        try
        {
            Thread.sleep( 60000 );
        }
        catch( Exception e )
        {
            e.printStackTrace();
        }
    }
}
在列表3中,我們首先建立了一個新的AsynchronousServerSocketChannel,然後為其繫結埠號5000:
final AsynchronousServerSocketChannel listener =
    AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(5000));
呼叫 AsynchronousServerSocketChannel 的 accept(),通知其監聽一個連線,並將一個典型的CompletionHandler傳給它。一旦呼叫 accept(),結果會立即返回。注意,本例不同於列表2中的ServerSocket類;除非一個客戶端與ServerSocket相連,否則accept()會被阻塞。AsynchronousChannelGroup 的 accept() 會為我們解決這個問題。

完整的Handler處理
接 下來的主要任務就是建立一個 CompletionHandler 類,並實現 completed() 和 failed() 方法。當 AsynchronousServerSocketChannel 接收一個客戶端連線,這個連線包含一個連線客戶端的 AsynchronousSocketChannel,completed()方法就會被呼叫。completed()方法第一次被呼叫從AsynchronousServerSocketChannel 處接收連線,開始與客戶端進行通訊。首先它做的事情向客戶端寫入一個“hello”訊息:建立一個字串,並將其轉換成位元組陣列並將其傳給 ByteBuffer.wrap(),完了構造一個ByteBuffer。接著ByteBuffer傳給 AsynchronousSocketChannel的 write() 方法。
為了更夠從客戶端那裡讀取資料,我們建立了一個新的ByteBuffer,並呼叫它的allocate(4096)。接 著我們呼叫了AsynchronousSocketChannel的 read() 方法,此方法會返回一個 Future<Integer>,呼叫後者的 get() 方法可以獲取讀自客戶端的位元組數。在本例中,我們傳遞了20秒的timeout引數給 get();如果20分鐘沒有得到響應,那 get() 就會丟擲一個TimeoutException。本回響伺服器的應對策略是,如果20秒沒有響應,就終止這個對話。
非同步計算中的Future
“The Future<V>介面顯示一個非同步計算的結果,此結果作為一個Future,因為它直到未來的某個時刻才存在。你可以呼叫它的方法去取消一個任務,返回任務的結果——如果任務沒有完成,無限等待或者超時退出——並且決定任務是否已取消或者完成……”。
——摘自”Java concurrency without the pain, Part 1
接下來我們會檢測buffer的position,它會定位到最後一個來自客戶端的byte。倘若客戶端發來的是一個空行,接收兩個位元組:一個回車和一個換行。檢測確保客戶端發出一個空白行,我們以此作為客戶端對話結束的訊號。如果我們擁有有意義的資料,那我們就呼叫ByteBuffer的 flip() 方法去進入讀的狀態。我們可以建立一個臨時byte陣列去儲存讀自客戶端的資料,然後呼叫ByteBuffer的 get() 載入資料到byte陣列中。最後,我們通過建立一個新的String物件將陣列轉換成一行字串。我們將這行字串返回給客戶端:將字串line轉換成一個byte陣列,作為引數傳遞給 ByteBuffer.wrap(),然後呼叫 AsynchronousSocketChannel的write() 方法。接著呼叫ByteBuffer的clear(),這樣position被重置為0並將ByteBuffer置於寫的模式,接著我們讀取客戶端下一行。
需要注意的是 main() 方法。它 建立了伺服器,同時建立了一個讓應用跑60秒的計時器。這是因為AsynchronousSocketChannel的 accept() 會理解返回,如果執行緒 Thread.sleep() 不執行,應用將會立即停止。為了進行測試,啟動伺服器後用telnet客戶端進行連線:
telnet localhost 5000
傳送少量的字串給伺服器,觀察它們向你返回結果,然後傳送一個空行結束對話。

結語
本文展示了兩種Socket Java程式設計方式:傳統的Java 1.0引入的編寫方式,Java 1.4和Java 7中分別引入的非阻塞 NIO 和 NIO.2 方式。採用客戶端伺服器幾次迭代的例子,展示了基本 Java I/O的使用,以及一些場景下非阻塞I/O對Java socket程式設計模型的改進和簡化。利用非阻塞I/O,你可以編寫網路應用來處理多併發連線,而無需管理多執行緒集合。同樣,你也可以利用構建在NIO和 NIO.2上新的伺服器擴充套件特性。
來自:碼農網
相關閱讀
評論(1)

相關文章