Creating a Multi-User TCP Chat Application

 

Rockford Lhotka
Magenic Technologies

August 28, 2001

Download the Vbsockets.exe sample file.

In recent weeks, I have encountered a number of people writing Visual Basic® .NET applications that use TCP sockets, to create host or client applications. Most have done this in Visual Basic 6 with the Winsock technology, and many struggle to achieve similar results in Visual Basic .NET because the underlying technology for working with sockets has changed in the .NET world.

This is an interesting topic to address because we get the opportunity to explore not only socket-level programming in Visual Basic .NET, but also to work with multi-threading. The network support built into the .NET system class libraries makes it very easy to create multi-threaded TCP host applications, as well as client applications.

The .NET support for socket-based programming within Visual Basic is dramatically improved over what we had in Visual Basic 6. So even though we approach the programming issue somewhat differently, it is worth it due to all the new capabilities at our disposal.

To illustrate this, we'll create a simple chat client and server. The server will allow many clients to connect, and all users will see the text each user submits to the server displayed on their screen.

Socket Basics

The .NET system class library includes a namespace for working with TCP sockets—the System.Net.Sockets namespace. This namespace includes low-level classes, such as Socket. It also includes some slightly higher-level helper classes, such as TcpListener and TcpClient, which we'll use to build our application.

It is also worth noting that TCP communication operates at the byte level—we are sending simple 8-bit bytes back and forth over the network. Due to this structure, many of the lower level TCP classes operate on arrays of bytes, and we'll be working with these arrays to build our application.

The TcpClient class is slightly higher-level, providing a streaming I/O approach instead of using byte arrays. This means we can simply read and write to the server by reading and writing to the TcpClient object's associated NetworkStream. As we'll see, however, this capability doesn't entirely eliminate the need to work with byte arrays except in the most trivial of uses.

Creating the Chat Client

We'll create our chat client as a Windows Application named SocketClient. The form will contain a read-only TextBox control named txtDisplay, a writable TextBox control named txtSend and a button named btnSend. The form should appear similar to Figure 1.

Figure 1. Socket Client form layout

To update the display, let's create a simple routine in our form:

  Private Sub DisplayText(ByVal t As String)
    txtDisplay.AppendText(t)
  End Sub

The easiest way to build a TCP client application is to use the TcpClient class from the System.Net.Sockets namespace. At the top of our form, we'll import this namespace to simplify our code:

Imports System.Net.Sockets

We'll also need a TcpClient variable to store the connection. This is declared within our class:

  Private mobjClient As TcpClient

Then, as the form loads we can initialize our TcpClient object, causing it to connect to the host computer:

  Private Sub Form1_Load(ByVal sender As System.Object, _
    ByVal e As System.EventArgs) Handles MyBase.Load

    mobjClient = New TcpClient("localhost", 5000)
    DisplayText("Connected to host" & vbCrLf)

In this case, we're connecting to the local computer on the port we'll be using for our chat server when we get it running.

Sending Data

The two key activities that any TCP client must be able to do are to send and receive data. Sending data through the TcpClient class is very easy since it provides us with a Stream interface:

  Private Sub Send(ByVal Data As String)
    Dim w As New IO.StreamWriter(mobjClient.GetStream)
    w.Write(Data & vbCr)
    w.Flush()
  End Sub

The System.IO namespace includes both StreamReader and StreamWriter classes that are used to read from or write to a stream. In this case, we're creating a StreamWriter, passing it a reference to the TcpClient object's NetworkStream object. With the StreamWriter initialized, we can call its Write method to put our data into the stream.

In order to cause the data to be immediately written into the stream, we then call the Flush method. Without this step, we can't tell exactly when the StreamWriter will write the data into the stream. It normally buffers data and sends it to the stream in chunks to improve performance, but in our case we want it written immediately.

We how have a way to easily send String data to the server computer from our program. We'll do this in the Click event of the Button control:

  Private Sub btnSend_Click(ByVal sender As System.Object, _
      ByVal e As System.EventArgs) Handles btnSend.Click
    Send(txtSend.Text)
    txtSend.Text = ""
  End Sub

When the button is clicked, any data in the txtSend control is transmitted over our socket to the server computer.

Receiving Data

Our sending algorithm is synchronous—meaning that the client application will wait while the data is sent to the server before proceeding. This is typically fine for sending data from the client, but is not so good for reading data. We don't want our client application to be suspended while it waits for data to arrive from the server. Instead, we want the application to remain responsive and interacting with the user while it waits for that data.

Fortunately, we can easily achieve this result by using the built-in capabilities of the TcpClient object's NetworkStream. While we can use a StreamReader to synchronously read from the stream, we can also use its BeginRead method to asynchronously get the data. Because the data is read on a background thread, our application's main thread is free to interact with the user.

The drawback to this approach is that our data will arrive in a byte array, rather than through a stream, so we have to do a bit more work to get at the data. To get the data we need to declare a byte array in our class, we use the following:

  Private marData(1024) As Byte

Before we can initialize this read process, we need to create the method that's invoked when data arrives. This method will be running on a background thread, so we need to be careful about threading issues, such as synchronization. Add a DoRead method to our code:

  Private Sub DoRead(ByVal ar As IAsyncResult)
    Dim intCount As Integer

    Try
      intCount = mobjClient.GetStream.EndRead(ar)
      If intCount < 1 Then
        MarkAsDisconnected()
        Exit Sub
      End If

      BuildString(marData, 0, intCount)

      mobjClient.GetStream.BeginRead(marData, 0, 1024, _
        AddressOf DoRead, Nothing)
    Catch e As Exception
      MarkAsDisconnected()
    End Try
  End Sub

The first thing this routine does is call the EndRead method on the stream object. This function returns the number of bytes that were placed into the byte array. This number should always be greater than zero. If it is zero, it means that the remote computer closed the connection and we should stop reading from the socket.

Once we know how many bytes of data are in the array, we can work with that data. The BuildString method does this for us—we'll look at it in more detail shortly.

Finally, we need to restart the asynchronous read process by calling the BeginRead method. If we don't do this, there won't be a waiting read operation and our application will cease processing data.

Building the string

Our application needs to actually process the data that arrives from the socket. There is no guarantee that the data will arrive all at once—it may arrive a character at a time, or in one large chunk. This means we need to keep reading the data until we find a line feed (vbLf) character, which indicates the end of a full line of text.

We also need to convert the array of bytes into a regular String form so it is easier to handle. Once we've converted the bytes into either Char or String data, we can easily use the StringBuilder class from the System.Text namespace to accumulate the data over time. It is declared in our class as:

  Private mobjText As New StringBuilder()

The BuildString method converts the byte data into the Unicode Char data type and accumulates it into the StringBuilder object:

  Private Sub BuildString(ByVal Bytes() As Byte, _
      ByVal offset As Integer, ByVal count As Integer)
    Dim intIndex As Integer

    For intIndex = offset To offset + count - 1
      If Bytes(intIndex) = 10 Then
        mobjText.Append(vbLf)

        Dim params() As Object = {mobjText.ToString}
        Me.Invoke(New DisplayInvoker(AddressOf Me.DisplayText), params)

        mobjText = New StringBuilder()
      Else
        mobjText.Append(ChrW(Bytes(intIndex)))
      End If
    Next
  End Sub

It simply loops through the data in the array looking for a value of 10—a line feed. If the character is not a line feed, it appends it into the StringBuffer, and when we do hit a line feed we can process the text.

Normally we might use the System.Text.ASCIIEncoding class to convert the byte array into a String, but in this case, we need to watch for that line feed character so it is easier to convert the bytes by hand.

Updating the display

For our chat client, processing the text means displaying it in the txtDisplay TextBox control. This turns out to be a little more difficult than it might appear at first. Remember, the code that receives our data is running on a background thread, rather than on the application's primary thread. Windows Forms controls are not thread-safe, which means that only the form's primary thread can safely interact with them. Our background thread cannot directly place the text into the TextBox control because it is not the thread that created the form.

Instead, we can use the form's Invoke method to cause the application's primary thread to do the update on our behalf. Before we can use the Invoke method, we must declare a delegate—essentially the signature of the method we'll be calling. This is declared in our class:

  Public Delegate Sub DisplayInvoker(ByVal t As String)

Notice that this method signature is the same as our existing DisplayText method, since it declares a single parameter of type String. With this declared, we can now use the Invoke method, which is done in the BuildString method with two lines of code:

        Dim params() As Object = {mobjText.ToString}
        Me.Invoke(New DisplayInvoker(AddressOf Me.DisplayText), params)

First, we create an array of type Object, into which we place the parameters we'll be passing to the method. Since the DisplayText method accepts a single parameter of type String, we're initializing the array with our String value—extracting the accumulated data from the StringBuilder object with the ToString method.

Then we can call the Invoke method on our form. This method accepts a delegate as its first parameter and the array of type Object that contains the parameter data as its second parameter. To create the delegate, we create a new instance of the DisplayInvoker data type, providing it with the address of our DisplayText method.

While a bit trickier than simply calling a method on the TextBox control, this bit of code operates safely in our multi-threaded environment.

Creating the Chat Server

The client application was relatively straightforward because it only needs to deal with a single user and a single socket connection. Now let's move on and build the chat server. It's a bit more complex because it accepts socket connections from multiple clients simultaneously, and relays data from any one user to all the other users.

We'll create this as a Windows Application, including a simple status display so we can watch the data as it passes through the server application. Call this application SocketServer, and add a ListBox control to the form. Name the control lstStatus and dock it so it fills the form.

Also, to keep the code simpler, import the System.Net.Sockets namespace at the top of the module:

Imports System.Net.Sockets

Now we're ready to start coding.

Let's start by creating an UpdateStatus method that will update our status display with text. Add this to the form:

  Private Sub UpdateStatus(ByVal t As String)
    lstStatus.Items.Add(t)
    lstStatus.SetSelected(lstStatus.Items.Count - 1, True)
  End Sub

This simply adds the line of text to the ListBox control and ensures that the most recently added status text is highlighted in the control. We'll use this throughout our server application to update the display.

Interacting with Clients

Since we are creating a multi-user server, we'll need to represent each client within our application. The easiest way to do this is to have a Client object for each user. This Client object will handle sending and receiving data to that user—nicely encapsulating all the code necessary for socket communication.

Add a new class to our project and name it Client. At the top of the class import both the socket and System.Text namespaces:

Imports System.Net.Sockets
Imports System.Text

Our Client objects can raise events to the form when needed. In particular, we can raise events to indicate connection, disconnection and the arrival of text. Declare these events in the class:

  Public Event Connected(ByVal sender As Client)
  Public Event Disconnected(ByVal sender As Client)
  Public Event LineReceived(ByVal sender As Client, ByVal Data As String)

We'll raise these events within the class as appropriate. We'll also need some way to uniquely identify this particular Client object within our code. A great way to do this is using a GUID value—a 128-bit numeric value that can also be represented as a string of characters. We can declare a class level variable to both hold and create this value:

  Private mgID As Guid = Guid.NewGuid

Then we can add a property routine to make it available:

  Public ReadOnly Property ID() As String
    Get
      Return mgID.ToString
    End Get
  End Property

The TcpClient connection

Because each Client object represents a connection to a physical client application, it needs access to the TcpClient object that maintains that connection. Declare a variable to hold this object:

  Private mobjClient As TcpClient

To make sure that we always have access to this object, we can build a constructor method that accepts the TcpClient as a parameter , ensuring that we'll always have access to that underlying object:

  Public Sub New(ByVal client As TcpClient)
    mobjClient = client
    RaiseEvent Connected(Me)
    mobjClient.GetStream.BeginRead(marData, 0, 1024, _
      AddressOf DoReceive, Nothing)
  End Sub

This method raises the Connected event and then calls the BeginRead method on the TcpClient object's stream—just like we did when creating the client application. This starts an asynchronous read process so we're ready to accept any data sent from the client.

The read process is set to store the data into a byte array, so we'll need to declare that as a class level variable:

  Private marData(1024) As Byte

Now we can move on to write the code that handles the incoming data.

Receiving data

The BeginRead method refers to the address of a DoReceive method. This method is called on a background thread to handle any incoming data. We can implement this routine in a fashion similar to the data receive routine in our client application:

  Private Sub DoStreamReceive(ByVal ar As IAsyncResult)
    Dim intCount As Integer

    Try
      SyncLock mobjClient.GetStream
        intCount = mobjClient.GetStream.EndRead(ar)
      End SyncLock
      If intCount < 1 Then
        RaiseEvent Disconnected(Me)
        Exit Sub
      End If

      BuildString(marData, 0, intCount)

      SyncLock mobjClient.GetStream
        mobjClient.GetStream.BeginRead(marData, 0, 1024, _
          AddressOf DoReceive, Nothing)
      End SyncLock
    Catch e As Exception
      RaiseEvent Disconnected(Me)
    End Try
  End Sub

As with the client application, we call the EndRead method to terminate the read and get the number of bytes available. We then call a BuildString method to move those bytes into a StringBuilder object—building the string up until we reach a carriage return that indicates the end of a line.

The big difference in this code as compared to the client application is the use of the SyncLock blocks. The SyncLock statement is used for thread synchronization to ensure that two threads don't attempt to use the same object at the same time. We need to use this to protect the TcpClient object's stream, because while we're reading data from it here, some other thread in our server might be attempting to send data to that same stream. This would cause an error, so to prevent that from happening, we'll wrap any access to the stream object within a SyncLock block.

Note In the condition where we know that the client has disconnected, we are raising the Disconnected event. This will allow our main application to remove us from the list of active connections.

The BuildString method

The BuildString method in our Client class is similar to the one we built earlier in the client application. We'll need a class level variable to hold the string as we build it:

  Private mobjText As New StringBuilder()

We can then build the string:

  Private Sub BuildString(ByVal Bytes() As Byte, _
      ByVal offset As Integer, ByVal count As Integer)
    Dim intIndex As Integer

    For intIndex = offset To offset + count - 1
      If Bytes(intIndex) = 13 Then
        RaiseEvent LineReceived(Me, mobjText.ToString)
        mobjText = New StringBuilder()
      Else
        mobjText.Append(ChrW(Bytes(intIndex)))
      End If
    Next
  End Sub

This is the same routine as in the client application, except that we are watching for a character 13 instead of a character 10, and once we find that character we're raising the LineReceived event to return the line of text.

Sending data

At this point, our Client class contains all the code necessary to receive data from a client application. The only remaining bit of functionality we need to implement is the ability to send data to the client application. This can be accomplished by adding a Send method to our class:

  Public Sub Send(ByVal Data As String)
      SyncLock mobjClient.GetStream
        Dim w As New IO.StreamWriter(mobjClient.GetStream)
        w.Write(Data)
        w.Flush()
      End SyncLock
  End Sub

Once again, this is the same code as in our client application, except that we are using the SyncLock statement to ensure that some other thread isn't using this stream object at the same time we're trying to send our data.

Listening for Connections

Our server will need to listen for and accept connections from clients as they try to connect. When our form is first loaded, this process should be started.

The functionality to handle this setup is provided by the TcpListener class in the .NET system class library. A TcpListener object will listen for incoming connections on a TCP port and will return to us either a raw socket or a TcpClient object representing the connection. In our case, we'll use the TcpClient because we're already familiar with its use.

The AcceptTcpClient method is a synchronous method, which means it will sit and wait for a connection, keeping its thread blocked. We don't want to call this method from our application's main thread because that would cause the UI to become non-responsive. Instead, we'll call it from a background thread so the primary thread can continue to operate the UI, giving the user a much better experience.

The DoListen method

Before we can start a background thread, we need to create the method that the thread will invoke. This method initializes the listening process and accepts any incoming connection requests. Because we want to allow multiple client connections, we'll need to keep a collection of the active connections. We'll use a HashTable object to store this list, and declare it as follows in the form:

  Private mcolClients As New Hashtable()

We'll also need to maintain a TcpListener object within our form, so declare this as well:

  Private mobjListener As TcpListener

Add the following code to our form:

  Private Sub DoListen()
    Try
      mobjListener = New TcpListener(5000)

      mobjListener.Start()
      Do
        Dim x As New Client(mobjListener.AcceptTcpClient)

        AddHandler x.Connected, AddressOf OnConnected
        AddHandler x.Disconnected, AddressOf OnDisconnected
        AddHandler x.LineReceived, AddressOf OnLineReceived
        mcolClients.Add(x.ID, x)

        Dim params() As Object = {"New connection"}
        Me.Invoke(New StatusInvoker(AddressOf Me.UpdateStatus), params)
      Loop Until False
    Catch
    End Try
  End Sub

This routine creates a new TcpListener, initializing it to listen for connections on port 5000. It then starts the listening process by calling the Start method. The routine then goes into an endless loop, calling the AcceptTcpClient method, which will return a TcpClient object representing any incoming connection.

We are passing this TcpClient object to the constructor method of a Client object.

In previous versions of Visual Basic, it was not really possible to catch events from objects that were stored in a collection, but in Visual Basic .NET we can catch those events by using the AddHandler method to establish a link between an object and a method to handle the event. In our code, we're using the AddHandler method to set up the event handling for the events that the Client object will be raising, and then we're adding the Client object to our collection so we have a list of all the active connections.

Keeping in mind that this code is running in a background thread, we then use the form's Invoke method to call our UpdateStatus routine to place some text on the form:

          Dim params() As Object = {"New connection"}
        Me.Invoke(New StatusInvoker(AddressOf Me.UpdateStatus), params)

We used this same technique in the client application, including using the delegate. We'll need to add a declaration for this StatusInvoker delegate to our form:

  Public Delegate Sub StatusInvoker(ByVal t As String)

This will safely display the text in the ListBox control.

Starting the listener

Now that we have a DoListen method, we can add code to our form's Load event so it starts a background thread to run this method as the form is loaded:

  Private Sub Form1_Load(ByVal sender As System.Object, _
      ByVal e As System.EventArgs) Handles MyBase.Load
    mobjThread = New Threading.Thread(AddressOf DoListen)
    mobjThread.Start()
    UpdateStatus("Listener started")
  End Sub

The System.Threading namespace includes the Thread class. We can use this class to directly create a thread, providing it with the address of the method to be invoked by that thread. Once we've created the Thread object, passing it the address of our DoListen method, we can call the Start method on that thread to start it processing. At this point, our listening process is running on the background thread and our application will accept incoming connection requests.

Stopping the listener

We need to ensure that the listening process is stopped properly when the form is closed. This is handled by adding code in the form's Closing event to simply stop the TcpListener object:

  Private Sub Form1_Closing(ByVal sender As Object, _
      ByVal e As System.ComponentModel.CancelEventArgs) _
      Handles MyBase.Closing
    mobjListener.Stop()
  End Sub

The TcpListener is stopped while our background thread is waiting for incoming connections. This will cause the background thread to get an error, which is trapped by the Try..Catch block in the DoListen method, ensuring a smooth shutdown of our application.

Handling the Events

All that remains now is to handle the Connected, Disconnected, and LineReceived events that will be raised by the Client objects.

Connected event

As a Client object is initialized, it will raise a Connected event. We've already used the AddHandler method to link that event to a method named OnConnected, so let's create that method by adding the following code to our form:

  Private Sub OnConnected(ByVal sender As Client)
    UpdateStatus("Connected")
  End Sub

In this case, we're updating the status display to indicate that a new client has connected to our server.

Disconnected event

When a client application disconnects from our server, our Client object will raise the Disconnected event so we know that the client is gone. This is handled by a method named OnDisconnected:

  Private Sub OnDisconnected(ByVal sender As Client)
    UpdateStatus("Disconnected")
    mcolClients.Remove(sender.ID)
  End Sub

This method not only updates the status display, but also removes the Client object from our collection of active connections. This is the reason we implemented the ID property on the Client object—making it very easy to remove the object from the HashTable collection.

LineReceived event

Finally, we need to handle the LineReceived event. A Client object raises this event when a line of text is received. In such a case, we need to relay that text to all the active clients currently connected to our server. The event is handled by the OnLineReceived method:

  Private Sub OnLineReceived(ByVal sender As Client, ByVal Data As String)
    UpdateStatus("Line:" & Data)

    Dim objClient As Client
    Dim d As DictionaryEntry

    For Each d In mcolClients
      objClient = d.Value
      objClient.Send(Data & vbCrLf)
    Next
  End Sub

This method loops through all the items in the HashTable collection, and sends the line of text to each of them in turn.

This is complicated slightly by the fact that the HashTable actually contains a collection of DictionaryEntry objects, and our actual Client objects are stored in the Value property of each DictionaryEntry. This means that our For..Each loop needs to pull out each DictionaryEntry object from the HashTable so we can get at its Value. With that done, we can call the Send method on the Client objects to send the data to each client application.

Running the Applications

At this point, we can build and run our two applications. If we run the server and a couple clients, we can have the clients chat back and forth.

The server screen should look something like figure 2.

Figure 2. Socket Server Application

Each of the client screens should display the text that has been typed. They should look something like figure 3.

Figure 3. Socket Client Application

Because we've designed the server to handle sending and receiving of data on multiple threads, it should be capable of handling a large number of concurrent users without difficulty.

Conclusion

While it was possible to create complex TCP socket applications in Visual Basic 6 by using the Winsock technology, we couldn't tap into the power provided by multi-threading and asynchronous processing. Visual Basic .NET not only provides us with comparable functionality to the old Winsock approach, but it also provides us with easy access to advanced capabilities, such as multi-threading. This means we can easily create powerful socket-based applications that can handle many users with high performance and scalability.