異步非阻塞方式的Socket通信

本文介紹如何使用非阻塞方式的Socket通信,並且創建了一個聊天程序的例子來幫助說明。

 

介紹
本文介紹如何在多個應用程序之間創建和使用TCP/IP Socket來進行通信。這些應用程序可以運行在同一臺機器,也可以在局域網內,甚至也可以是跨越Internet的*。這種方法的好處是不需要你自己來使用線程,而是通過調用Socket的非阻塞模式來實現。在例子中:服務器創建病偵聽客戶端的連接,一旦有客戶連接,服務器就將其加入到一個活動客戶的列表中,某個客戶端發送的消息也有服務器發送到各個連接的客戶端,就好像聊天室中的那樣。或許Remoting (遠程調用)是做這種工作更好的辦法,但是我們這裏還是來學習學習如何使用Socket來實現。

*注意:跨越Internet的通訊要求服務器有獨立的IP地址並且不在代理或是放火牆之後。

事件時序
服務器必須要先偵聽,客戶端才能夠連接。下面的圖例說明了在一個異步Socket會話中的事件時序。

 

運行示例
實例代碼分爲兩部分:ChatServer 和ChatClient. 我們首先來創建ChatServer ,然後使用下面的Telnet命令來測試它。

telnet {server machine IP address or machine name} 399
telnet 10.328.32.76 399

 
這時,服務器上應該出現一條消息來表明這個客戶連接的地址和端口。在任一個telnet窗口中鍵入的字符都會回顯到所有與服務器連接的telnet的窗口中。試試從多臺機器上併發連接服務器。不要使用localhost或者127.0.0.1來作爲服務器程序唯一的偵聽地址。

 

 

 

然後運行ChatClient實例作相同的試驗和多個客戶端和多個telnet並存的測試。

 

爲什麼要使用.NET的Socket?
.NET在很多地方都用到了sockets,比如:WebServices和Remoting。但是在那些應用中底層的Socket支持已經做好了,不需要直接使用。但是,和其他非.NET系統的Socket打交道或簡單通信的場合中Socket的使用還是很有必要的。它可以用來和諸如DOS,Windows和UNIX系統進行通信。底層的Socket應用也可以讓你減少了諸如組測,權限,域(domains),用戶ID,密碼等這些麻煩的安全方面的顧慮。

 

ChatServer / Listener
服務器偵聽端口,當有連接請求時,接受該連接並返回一條歡迎信息。在例子中客戶連接被加到一個活動客戶列表m_aryClients中去。這個列表會根據客戶加入和離開作相應的增刪。在某些情況下可能會丟失連接,所以在實際的系統中還應該有輪詢偵測客戶端是否在線的部分。當服務器端的listener收到客戶端發來的信息後,它會把消息廣播到所有連接的客戶端。

下面討論兩種偵聽的方法,一個是用輪詢(polling),另外一個在使用事件來偵測連接的請求。

 

方法1 – 使用輪詢的 TcpListener
System.Net.Sockets中的TcpListener 類爲我們提供了一個偵聽和處理客戶連接的簡單手段。下面的代碼偵聽連接,接受連接,並且向客戶連接發回一個帶有時間戳的歡迎信息。如果有另外一個連接請求到來,原來的連接將會丟失。注意,歡迎信息是採用ASCII編碼,而不是UNICODE。

private Socket client = null;
const int nPortListen = 399;
try
{
    TcpListener listener = new TcpListener( nPortListen );
    Console.WriteLine( "Listening as {0}", listener.LocalEndpoint );
    listener.Start();
    do
    {
        byte [] m_byBuff = new byte[127];
        if( listener.Pending() )
        {
            client = listener.AcceptSocket();
            // Get current date and time.
            DateTime now = DateTime.Now;
            string strDateLine = "Welcome " + now.ToString("G") + "\n\r";
 
            // Convert to byte array and send.
            Byte[] byteDateLine = System.Text.Encoding.ASCII.GetBytes( strDateLine.ToCharArray() );
            client.Send( byteDateLine, byteDateLine.Length, 0 );
        }
        else
        {
            Thread.Sleep( 100 );
        }
    } while( true );    // Don't use this. 
}
catch( Exception ex )
{
    Console.WriteLine ( ex.Message );
}

 

方法2 – 使用帶事件的Socket
一個更爲優雅的方法是創建一個事件來捕捉連接請求。ChatServer實例就採用了這種方法。首先服務器的名字和地址用下面的代碼取得。

 

 

 

IPAddress [] aryLocalAddr = null;
string strHostName = "";
try
{
    // NOTE: DNS lookups are nice and all but quite time consuming.
    strHostName = Dns.GetHostName();
    IPHostEntry ipEntry = Dns.GetHostByName( strHostName );
    aryLocalAddr = ipEntry.AddressList;
}
catch( Exception ex )
{
    Console.WriteLine ("Error trying to get local address {0} ", ex.Message );
}
 
// Verify we got an IP address. Tell the user if we did
if( aryLocalAddr == null || aryLocalAddr.Length < 1 )
{
    Console.WriteLine( "Unable to get local address" );
    return;
}
Console.WriteLine( "Listening on : [{0}] {1}", strHostName, aryLocalAddr[0] );

 
得到地址之後,我們要把listener這個Socket綁定到這個地址。我們這裏使用的偵聽端口是399。此外,從位於"C:\WinNT\System32\drivers\etc\Services"的服務文件中讀取端口號應該是一個很好的練習。下面的代碼綁定Listener並且開始偵聽。一個事件handler把所有的連接請求都指向了OnConnectRequest。這樣程序就可以不需要等待或者輪詢來處理客戶連接了。

 

 

const int nPortListen = 399;
// Create the listener socket in this machines IP address
Socket listener = new Socket( AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp );
listener.Bind( new IPEndPoint( aryLocalAddr[0], 399 ) );
//listener.Bind( new IPEndPoint( IPAddress.Loopback, 399 ) );    // For use with localhost 127.0.0.1
listener.Listen( 10 );
 
// Setup a callback to be notified of connection requests
listener.BeginAccept( new AsyncCallback( app.OnConnectRequest ), listener );

 

 
當客戶連接請求到達時,就會激發下面的處理事件。下面的代碼首先創建了client (Socket),然後發回歡迎信息,接着重新建立了接受事件處理(accept event handler)。

Socket client;
public void OnConnectRequest( IAsyncResult ar )
{
    Socket listener = (Socket)ar.AsyncState;
    client = listener.EndAccept( ar );
    Console.WriteLine( "Client {0}, joined", client.RemoteEndPoint );
 
    // Get current date and time.
    DateTime now = DateTime.Now;
    string strDateLine = "Welcome " + now.ToString("G") + "\n\r";
 
    // Convert to byte array and send.
    Byte[] byteDateLine = System.Text.Encoding.ASCII.GetBytes( strDateLine.ToCharArray() );
    client.Send( byteDateLine, byteDateLine.Length, 0 );
 
    listener.BeginAccept( new AsyncCallback( OnConnectRequest ), listener );
}

 

 

這段代碼可以擴展,維護客戶Socket的列表,監控數據接收和連接斷開。對於連接斷開的偵測放在AsyncCallback 事件處理中。ChatClient部分將在下面細述該機制。

 

ChatClient
ChatClient是一個Windows Form應用程序,用來連接服務器,收發消息。

 

連接
當點擊界面上的連接按鈕使執行下面的程序使客戶連接到服務器。

private Socket m_sock = null;
private void m_btnConnect_Click(object sender, System.EventArgs e)
{
    Cursor cursor = Cursor.Current;
    Cursor.Current = Cursors.WaitCursor;
    try
    {
        // Close the socket if it is still open
        if( m_sock != null && m_sock.Connected )
        {
            m_sock.Shutdown( SocketShutdown.Both );
            System.Threading.Thread.Sleep( 10 );
            m_sock.Close();
        }
 
        // Create the socket object
        m_sock = new Socket( AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp );    
 
        // Define the Server address and port
        IPEndPoint epServer = new IPEndPoint(  IPAddress.Parse( m_tbServerAddress.Text ), 399 );
 
        // Connect to the server blocking method and setup callback for recieved data
        // m_sock.Connect( epServer );
        // SetupRecieveCallback( m_sock );
        
        // Connect to server non-Blocking method
        m_sock.Blocking = false;
        AsyncCallback onconnect = new AsyncCallback( OnConnect );
        m_sock.BeginConnect( epServer, onconnect, m_sock );
    }
    catch( Exception ex )
    {
        MessageBox.Show( this, ex.Message, "Server Connect failed!" );
    }
    Cursor.Current = cursor;
}

 

 

如果連接已經存在就銷燬它。創建一個Socket和指定的端點相連。 被註釋掉部分的代碼採用簡單的阻塞式連接方法。BeginConnect 則用來做一個非阻塞的連接請求。注意,即使是一個非阻塞的用戶連接請求,連接也回被阻塞知道機器名稱被解析爲IP地址。所以,要儘量使用IP地址而不是機器名來避免這種情況。一旦連接請求處理完畢就會調用下面的方法,它顯示連接錯誤或者在成功連接的情況下建立起接收數據的回調。

public void OnConnect( IAsyncResult ar )
{
    // Socket was the passed in object
    Socket sock = (Socket)ar.AsyncState;
 
    // Check if we were sucessfull
    try
    {
        //    sock.EndConnect( ar );
        if( sock.Connected )
            SetupRecieveCallback( sock );
        else
            MessageBox.Show( this, "Unable to connect to remote machine", 
                             "Connect Failed!" );
 
    }
    catch( Exception ex )
    {
        MessageBox.Show( this, ex.Message, "Unusual error during Connect!" );
    }    
}

 

 

接收數據
爲了異步接收數據,有必要建立一個AsyncCallback 來處理被諸如接到數據和連接斷開所激發的事件。用下面的方法。

private byte []    m_byBuff = new byte[256];    // Recieved data buffer
public void SetupRecieveCallback( Socket sock )
{
    try
    {
        AsyncCallback recieveData = new AsyncCallback( OnRecievedData );
        sock.BeginReceive( m_byBuff, 0, m_byBuff.Length, SocketFlags.None, 
            recieveData, sock );
    }
    catch( Exception ex )
    {
        MessageBox.Show( this, ex.Message, "Setup Recieve Callback failed!" );
    }
}

 

 

 

 

SetupRecieveCallback 方法啓動了BeginReceive ,並利用代理指針把回調指向OnReceveData 方法。同時它也把一個用來接收數據的緩衝傳遞過去。

public void OnRecievedData( IAsyncResult ar )
{
    // Socket was the passed in object
    Socket sock = (Socket)ar.AsyncState;
 
    // Check if we got any data
    try
    {
        int nBytesRec = sock.EndReceive( ar );
        if( nBytesRec > 0 )
        {
            // Wrote the data to the List
            string sRecieved = Encoding.ASCII.GetString( m_byBuff, 0, nBytesRec );
 
            // WARNING : The following line is NOT thread safe. Invoke is
            // m_lbRecievedData.Items.Add( sRecieved );
            Invoke( m_AddMessage, new string [] { sRecieved } );
 
            // If the connection is still usable restablish the callback
            SetupRecieveCallback( sock );
        }
        else
        {
            // If no data was recieved then the connection is probably dead
            Console.WriteLine( "Client {0}, disconnected", sock.RemoteEndPoint );
            sock.Shutdown( SocketShutdown.Both );
            sock.Close();
        }
    }
    catch( Exception ex )
    {
        MessageBox.Show( this, ex.Message, "Unusual error druing Recieve!" );
    }
}

 

 

 

 

當上面的事件被激發時,接收到的數據被默認爲是ASCII編碼的。新數據也會被激發的事件顯示出來。儘管可以調用Add() 在列表中顯示新數據,但這並不是一個好主意,因爲收到的數據很有可能要被送到其他線程中去處理。注意,需要在接收之後重建接收回調,來確保可以繼續接收數據。因爲有可能數據很多,超過最初的buffer容量。

創建 AddMessage 委託可以降低Socket線程和用戶界面線程的耦合程度,如下所示:

// Declare the delegate prototype to send data back to the form
delegate void AddMessage( string sNewMessage );
 
namespace ChatClient
{
    . . .
    public class FormMain : System.Windows.Forms.Form
    {
        private event AddMessage m_AddMessage;            
        // Add Message Event handler for Form
        . . .
        
        public FormMain()
        {
            . . . 
            // Add Message Event handler for Form decoupling from input thread
            m_AddMessage = new AddMessage( OnAddMessage );
            . . .
        }
        
        public void OnAddMessage( string sMessage )
        {
            // Thread safe operation here
            m_lbRecievedData.Items.Add( sMessage );
        }
        
 
        public void OnSomeOtherThread()
        {
            . . .
            string sSomeText = "Bilbo Baggins";
            Invoke( m_AddMessage, new string [] { sSomeText } );
        }
        . . .
    }    
}

 
使用UNICODE
當時用比特流來發送接收數據時,數據就需要被適當的編碼。C# 採用多字節字符編碼,儘管這裏使用Encoding.ASCII ,但如果需要也可以使用Encoding.UNICODE

 

 

 

 

不要相信發出什麼就能收到什麼
當接收數據事件被激發,接收的數據被放置到接收緩衝中去。在我們的開發中,分組發送往往對應一個分組接收事件。但是在真正的系統中並非如此。數據並不是都是規規矩矩的在報文中,而有可能被拆分到若干個分組中。不要指望總能收到完整的報文,也不要指望建立自己的符號標記報文的開始和結束就萬事大吉了。

 

結論
儘管使用Socket並不難,但是要用的很好還是需要大量的實踐練習。當然在合適的場合你也應該試試使用WebServices或Remoting。此外,Wrox出版社的Professional ADO.NET Programming這本書很不錯,值得一看。

 

 

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