riting networked applications is one of the most interesting aspects of programming. It is both rewarding and intriguing to see your applications successfully communicating over the network. In this first part of a two-part series on network programming, I am going to build a chat application that works similar to MSN Messenger (or ICQ). Using the chat application, I will illustrate how network programming is done in .NET and the various challenges in building a multi-user chat application.
Figure 1 shows the application that I will build in this article.
In an article that will follow in a few weeks, I will expand on the application built in this article to provide more functionality, such as FTP, private chat, encryption, and more.
Using the TcpClient and TcpListener Classes for Network Communications
Creating a chat application generally involves socket programming, creating a connection between a client and server so that messages can be sent and received by both the client and the server. The System.Net.Sockets namespace provides the functionalities required for Socket programming. I will make use of two classes in the System.Net.Sockets namespace for this article: TcpClient and TcpListener.
Figure 1. A Chat Ahead: The screenshot shows the completed chat application built in this article. |
The TcpClient class implements a socket for sending and receiving data using TCP. Because the connection to the remote device is represented as a stream, data can be read and written with .NET Framework stream-handling techniques.
The TcpListener class provides simple methods that listen for and accept incoming connection requests in blocking synchronous mode.
The following code example shows a very simple implementation of a server waiting for an incoming connection:
Imports System.Net.SocketsImports System.TextConst portNo As Integer = 500Dim localAdd As System.Net.IPAddress = _ IPAddress.Parse("127.0.0.1")Dim listener As New TcpListener(localAdd, portNo)listener.Start()Dim tcpClient As TcpClient = listener.AcceptTcpClient()Dim ns As NetworkStream = tcpClient.GetStreamDim data(tcpClient.ReceiveBufferSize) As Byte'---read incoming stream; Read() is a blocking call---Dim numBytesRead As Integer = ns.Read(data, 0, _ CInt(tcpClient.ReceiveBufferSize))'---display data received---Console.WriteLine("Received :" & _ Encoding.ASCII.GetString(data, 0, numBytesRead))
To connect to the server and send it a string, the client code would look like this:
Imports System.Net.SocketsImports System.TextConst portNo = 500Dim tcpclient As New TcpClienttcpclient.Connect("127.0.0.1", portNo)Dim ns As NetworkStream = tcpclient.GetStreamDim data As Byte() = Encoding.ASCII.GetBytes("Hello")'---send the text---ns.Write(data, 0, data.Length)
Author’s Note: The NetworkStream object works with byte arrays, and hence you need to use the Encoding.ASCII.GetString() and Encoding.ASCII.GetBytes() methods from the System.Text namespace to convert the byte array to string and vice versa. |
The example above is relatively simple. But the problem becomes much more pronounced when the server needs to communicate with multiple clients and be able to both send and receive messages from clients, all at the same time. To do so:
- The server must be able to create connections to multiple clients;
- The server must be able to asynchronously read data from the client and be able to send messages to the client at any time;
- The client must be able to asynchronously read data from the server and be able to send messages to the server at any time.
The rest of this article will address these three problems.
Building the ServerThere are two components in our chat application: server and client. I’ll start by building the server. For the server, I will create a console application project using Visual Studio 2005 beta 2 (you can also use Visual Studio .NET 2003). Name the project ‘Server.’
In the default Module1.vb, I’ll first import the System.Net.Sockets namespace; it contains all the relevant classes for my chat application.
Imports System.Net.Sockets
Next, I’ll declare a constant containing the port number to use for this application; I’ve selected port number 500. If you have a firewall installed on the server (or client), be sure to open up port 500 for this application to work.
Const portNo As Integer = 500
I also need to define the local address to listen to and then create an instance of the TcpListen() class to use for listening for connections from TCP clients:
Dim localAdd As System.Net.IPAddress = _ System.Net.IPAddress.Parse("127.0.0.1") Dim listener As New TcpListener(localAdd, portNo)
In the Main() function, I use the Start() method from the TcpListener class to start listening for incoming connection requests. The AcceptTcpClient() method is a blocking call and execution will not continue until a connection is established. As my server needs to service multiple clients at the same time, I will create an instance of the ChatClient (which I will define shortly) for each user. The server will loop indefinitely, accepting clients as they connect:
Sub Main() listener.Start() While True Dim user As New _ ChatClient(listener.AcceptTcpClient) End While End Sub
The source for Module1.vb looks like this:
Imports System.Net.SocketsModule Module1 Const portNo As Integer = 500 Dim localAdd As System.Net.IPAddress = _ System.Net.IPAddress.Parse("127.0.0.1") Dim listener As New _ System.Net.Sockets.TcpListener(localAdd, portNo) Sub Main() listener.Start() While True Dim user As New _ ChatClient(listener.AcceptTcpClient) End While End SubEnd Module
The next step is to define the ChatClient class. The ChatClient class is used to represent information of each client connecting to the server. Add a new Class to your project in Visual Studio 2005 and name it ChatClient.vb. As usual, the first thing to do is to import the System.Net.Sockets namespace:
Imports System.Net.Sockets
In the ChatClient class, first define the various private members (their uses are described in the comments in the code). You also declare a HashTable object (AllClients) to store a list of all clients connecting to the server. The reason for declaring it as a shared member is to ensure all instances of the ChatClient class are able to obtain a list of all the clients currently connected to the server:
Public Class ChatClient '---contains a list of all the clients Public Shared AllClients As New HashTable '---information about the client Private _client As TcpClient Private _clientIP As String Private _ClientNick As String '---used for sending/receiving data Private data() As Byte '---is the nick name being sent? Private ReceiveNick As Boolean = True
When a client gets connected to the server, the server will create an instance of the ChatClient class and then pass the TcpClient variable (client) to the constructor of the class. You will also get the IP address of the client and use it as an index to identify the client in the HashTable object. The BeginRead() method will begin an asynchronous read from the NetworkStream object (_client.GetStream) in a separate thread. This allows the server to remain responsive and continue accepting new connections from other clients. When the reading is complete, control will be transferred to the ReceiveMessage() function (which I will define shortly).
Public Sub New(ByVal client As TcpClient) _client = client '---get the client IP address _clientIP = client.Client.RemoteEndPoint.ToString '---add the current client to the hash table AllClients.Add(_clientIP, Me) '---start reading data from the client in a ' separate thread ReDim data(_client.ReceiveBufferSize) _client.GetStream.BeginRead(data, 0, _ CInt(_client.ReceiveBufferSize), _ AddressOf ReceiveMessage, Nothing) End Sub
In the ReceiveMessage() function, I first call the EndRead() method to handle the end of an asynchronous read. Here, I check if the number of bytes read is less then 1. If it is, the client has disconnected and you need to remove the client from the HashTable object (using the IP address of the client as an index into the hash table). I also want to broadcast a message to all the clients telling them that this particular client has left the chat. I do this using the Broadcast() function (again, I will define this shortly).
For simplicity, assume that the client will send the nickname of the user the first time it connects to the server. Subsequently, you will just broadcast whatever was sent by the client to everyone. Once this is done, the server will proceed to perform the asynchronous read from the client again.
Public Sub ReceiveMessage(ByVal ar As IAsyncResult) '---read from client--- Dim bytesRead As Integer Try SyncLock _client.GetStream bytesRead = _client.GetStream.EndRead(ar) End SyncLock '---client has disconnected If bytesRead < 1 Then AllClients.Remove(_clientIP) Broadcast(_ClientNick & _ " has left the chat.") Exit Sub Else '---get the message sent Dim messageReceived As String = _ System.Text.Encoding.ASCII. _ GetString(data, 0, bytesRead) '---client is sending its nickname If ReceiveNick Then _ClientNick = messageReceived '---tell everyone client has entered ' the chat Broadcast(_ClientNick & _ " has joined the chat.") ReceiveNick = False Else '---broadcast the message to everyone Broadcast(_ClientNick & ">" & _ messageReceived) End If End If '---continue reading from client SyncLock _client.GetStream _client.GetStream.BeginRead(data, 0, _ CInt(_client.ReceiveBufferSize), _ AddressOf ReceiveMessage, Nothing) End SyncLock Catch ex As Exception AllClients.Remove(_clientIP) Broadcast(_ClientNick & _ " has left the chat.") End Try End Sub
One thing to note in the above code is that you need to use the SyncLock statement to prevent multiple threads from using the NetworkStream object. Without the SyncLock statement your application will be unpredictable, perhaps being subject to frequent crashes. This scenario is likely to occur when your server is connected to multiple clients and all of them are trying to access the NetworkStream object at the same time.
The SendMessage() function allows the server to send a message to the client.
Public Sub SendMessage(ByVal message As String) Try '---send the text Dim ns As System.Net.Sockets.NetworkStream SyncLock _client.GetStream ns = _client.GetStream End SyncLock Dim bytesToSend As Byte() = _ System.Text.Encoding.ASCII.GetBytes(message) ns.Write(bytesToSend, 0, bytesToSend.Length) ns.Flush() Catch ex As Exception Console.WriteLine(ex.ToString) End Try End Sub
Finally, the Broadcast() function sends a message to all the clients stored in the AllClients HashTable object.
Public Sub Broadcast(ByVal message As String) '---log it locally Console.WriteLine(message) Dim c As DictionaryEntry For Each c In AllClients '---broadcast message to all users CType(c.Value, _ ChatClient).SendMessage(message & vbCrLf) Next End Sub
Building the Client
Now that the server is built, it is time to build the client. Using Visual Studio 2005, I create a new Windows application (name it WinClient) and populate the default form with the controls shown in Table 1 (see the resulting form in Figure 2).
Table 1. Client Controls
Control(Name) | Property | Value |
Label | Text | Nick |
TextBox | Name | txtNick |
TextBox | Name | txtMessageHistory |
? | MultiLine | True |
? | ReadOnly | True |
TextBox | Name | txtMessage |
Button | Name | btnSignIn |
? | Text | Sign In |
Button | Name | btnSend |
? | Text | Send |
The client application logic is very similar to the server, albeit more straightforward. Double-click on the form to switch to the edit window, and import the following namespace:
Imports System.Net.Sockets
Define the following constant and variables within the form:
Const portNo As Integer = 500 Dim client As TcpClient Dim data() As Byte
Figure 2. Adding Controls: Populate the Windows Form with the various controls from Table 1. |
When the user signs in, the client first connects to the server and sends the nickname of the user using the SendMessage() subroutine (defined shortly). Then it should begin reading data from the server asynchronously and change the name of the Sign In button to “Sign Out.” When the user signs out from the chat application, you invoke the Disconnect() subroutine (defined shortly).
Private Sub btnSignIn_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnSignIn.Click If btnSignIn.Text = "Sign In" Then Try '---connect to server client = New TcpClient client.Connect("127.0.0.1", portNo) ReDim data(client.ReceiveBufferSize) SendMessage(txtNick.Text) '---read from server client.GetStream.BeginRead( _ data, 0, _ CInt(client.ReceiveBufferSize), _ AddressOf ReceiveMessage, Nothing) btnSignIn.Text = "Sign Out" btnSend.Enabled = True Catch ex As Exception MsgBox(ex.ToString) End Try Else '---disconnect from server Disconnect() btnSignIn.Text = "Sign In" btnSend.Enabled = False End If End Sub
When the user clicks on the Send button, the application sends a message to the server:
Private Sub btnSend_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnSend.Click SendMessage(txtMessage.Text) txtMessage.Clear() End Sub
The SendMessage() subroutine, used in the code above, allows the client to send a message to the server:
Public Sub SendMessage(ByVal message As String) Try '---send a message to the server Dim ns As NetworkStream = client.GetStream Dim data As Byte() = _ System.Text.Encoding.ASCII.GetBytes(message) '---send the text--- ns.Write(data, 0, data.Length) ns.Flush() Catch ex As Exception MsgBox(ex.ToString) End Try End Sub
The ReceiveMessage() subroutine asynchronously reads data sent from the server in a separate thread. When the data is received, it will display the data in the txtMessageHistory control. As Windows controls are not thread-safe, you need to use a delegate, delUpdateHistory(), to update the controls:
Public Sub ReceiveMessage(ByVal ar As IAsyncResult) Try Dim bytesRead As Integer bytesRead = client.GetStream.EndRead(ar) If bytesRead < 1 Then Exit Sub Else Dim para() As Object = _ {System.Text.Encoding.ASCII.GetString( _ data, 0, bytesRead)} Me.Invoke(New delUpdateHistory( _ AddressOf Me.UpdateHistory), para) End If client.GetStream.BeginRead( _ data, 0, CInt(client.ReceiveBufferSize), _ AddressOf ReceiveMessage, Nothing) Catch ex As Exception End Try End Sub
The delUpdateHistory() delegate is used to invoke the UpdateHistory() function in the main thread:
'---delegate and subroutine to update the ' TextBox control Public Delegate Sub delUpdateHistory( _ ByVal str As String) Public Sub UpdateHistory(ByVal str As String) txtMessageHistory.AppendText(str) End Sub Private Sub Form1_FormClosing( _ ByVal sender As Object, _ ByVal e As _ System.Windows.Forms.FormClosingEventArgs) _ Handles Me.FormClosing Disconnect() End Sub
Finally, the Disconnect() subroutine disconnects the client from the server:
Public Sub Disconnect() '---Disconnect from server Try client.GetStream.Close() client.Close() Catch ex As Exception MsgBox(ex.ToString) End Try End Sub
Testing the Application
To test the applications, first run the server by pressing F5 in Visual Studio 2005. You want to launch multiple copies of the client to test the multi-user capabilities of the server. To do this, compile the client-side code files provided with this article into an .exe file. Run multiple copies of Winclient.exe and sign in and chat at the same time.
In this article, I've shown how the TcpClient class allows you to perform asynchronous communication between two computers. While the chat application developed in this article is simple, it is a foundation on which to build more complicated chat applications. In my next article I'll do exactly that, adding FTP, private chats, and more to this simple chat application.