异步套接字通信






4.87/5 (124投票s)
2001年11月28日
7分钟阅读

803483

27219
一篇关于使用套接字以非阻塞方式进行通信的文章。示例通过构建一个简单的聊天客户端和服务器来演示。
引言
本文介绍了如何在两个或多个应用程序之间创建 TCP/IP 套接字连接的双方。这些应用程序可以运行在同一台机器上、本地网络连接的机器上,甚至可以通过互联网通信的机器上*。此方法的一个关键特性是它不使用线程,而是使用套接字以非阻塞模式运行。在本例中,创建了一个服务器,用于监听客户端的连接。一旦客户端连接,它就会被添加到活动客户端列表中。如果客户端发送消息,该消息将被广播到所有已连接的客户端,其方式与聊天群组的操作类似。虽然 Remoting 会是更好的实现方式,但我们在这里是为了学习套接字。
*请注意,要通过互联网通信,服务器需要一个位于互联网上的 IP 地址,而不是隐藏在代理服务器后面,因为许多 ISP 都会这样做。
事件顺序
在客户端可以连接之前,服务器必须正在监听。下图显示了构成异步套接字会话的事件顺序。
运行示例
示例代码分为两个应用程序:ChatServer
(客户端连接到它)和 ChatClient
(连接到服务器)。首先构建 ChatServer
,然后使用以下命令通过 Telnet 进行测试:
telnet {server machine IP address or machine name} 399
telnet 10.328.32.76 399
ChatServer
上应该会显示一条消息,指示客户端可以连接的地址和端口号。在 telnet 窗口中输入的任何内容都应该回显到连接到服务器的所有 telnet 窗口。尝试从不同机器进行多次并发连接。不要使用 localhost 或 127.0.0.1 地址,因为服务器应用程序只监听服务器启动消息中显示的地址。
接下来,运行 ChatClient
示例,并通过多台机器上的多个 ChatClient
和 Telnet
实例尝试相同的测试。
为什么在 .NET 中使用套接字?
.NET 在许多场景中使用套接字,例如 WebServices
和 Remoting
,但在这些场景中,底层套接字操作已经为您处理好了,无需直接使用套接字。然而,在与非 .NET 系统进行交互时,套接字是一种必要且简单的通信方法。它们可用于与 DOS、Windows 和 UNIX 系统进行通信。底层套接字还可以让您不必担心注册、权限、域、用户 ID、密码以及其他令人头疼的安全问题。
ChatServer / Listener
服务器监听客户端连接,当收到连接请求时,服务器会接受连接并返回欢迎消息。在示例中,连接被添加到活动客户端数组 m_aryClients
中。随着客户端的连接和断开,此列表将增长和缩小。并非总是能检测到连接丢失,因此在生产系统中,应某种形式的轮询来检测连接是否仍然有效。当在监听器上收到数据时,它将被广播到所有已连接的客户端。
下面讨论了两种监听方法:一种使用轮询,另一种使用事件来检测连接请求。
方法 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] );
在识别地址后,我们需要将监听器绑定到该地址。这里,我们在端口 399 上监听。从位于“C:\WinNT\System32\drivers\etc\Services”的Services 文件读取端口号是一个好习惯。以下代码绑定监听器并开始监听。添加了一个事件处理程序,将所有连接请求指向 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 );
当客户端请求连接时,会触发连接请求事件处理程序,如下所示。以下代码创建一个客户端,发送欢迎消息并重新建立接受事件处理程序。
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 );
}
示例代码中对此进行了扩展,以便将客户端套接字保存在列表中,并监视接收到的数据和断开连接。在 AsyncCallback
事件处理程序中检测到客户端套接字的断开连接。ChatClient
在下方详细介绍了此机制。
ChatClient
ChatClient
是一个 Windows 窗体应用程序,它连接到服务器并显示接收到的消息,并允许发送消息。
连接
按下 **Connect** 按钮时,客户端将使用以下代码连接到服务器:
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 received 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 successful
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
来处理由 Socket 触发的事件,例如新数据和连接丢失。这是使用以下方法完成的:
private byte [] m_byBuff = new byte[256]; // Received 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 Receive Callback failed!" );
}
}
SetupRecieveCallback
方法使用指向以下 OnReceveData
方法的委托启动一个 BeginReceive
。它还传递一个缓冲区用于存储接收到的数据。
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 reestablish the callback
SetupRecieveCallback( sock );
}
else
{
// If no data was received 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 during Receive!" );
}
}
当上述事件触发时,接收到的数据假定为 ASCII。新数据通过调用委托发送到显示。虽然可以通过调用列表的 Add()
来显示新数据,但这非常糟糕,因为接收到的数据很可能在另一个线程中运行。请注意,还必须重新建立接收回调以继续接收更多事件。即使接收到的数据超过了输入缓冲区的大小,重新建立接收回调也会导致其触发,直到所有数据都被读取。
AddMessage
委托被创建,用于将套接字线程与用户界面线程解耦,如下所示:
// 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
当接收或发送数据时,数据存储在 8 位字节数组中。接收到数据时,必须将其编码为适合 .NET 的格式,发送数据时,则必须将其编码为适合接收应用程序的格式。C# 在内部使用多字节字符编码,因此接收到数据时必须将其转换为该格式,并在发送出去之前使用 Encoding.ASCII
或 Encoding.UNICODE
static
方法进行必要的转换。
不要相信已发送的数据就是已接收的数据
当接收数据事件触发时,接收到的数据存储在输入缓冲区中。在开发过程中,发送的数据包通常对应于接收事件的单次触发和接收缓冲区中的一组完整数据。这在生产系统中绝对不是这种情况。数据不是按包发送的,实际上是由字节流组成的,这些字节流可能会被分成许多数据包。不要依赖接收完整的数据包,并开发自己的标签来指示数据包的开始和结束。
结论
套接字虽然相对容易使用,但要使其正常工作需要大量的代码。如果可能,您应该尝试使用 WebServices 或 Remoting 来代替。Wrox 出版的《Professional ADO.NET Programming》是一本关于其他主题的好书,不妨看看。
许可证
本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。
作者可能使用的许可证列表可以在此处找到。