本文介紹如何使用非阻塞方式的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這本書很不錯,值得一看。