Every Internet user is familiar with the basic model for an instant-messaging application. Users log on to some sort of central authority, retrieve a list that indicates who else is currently online, and exchange simple text messages. Some messaging platforms include additional enhancements, such as file-transfer features and group conversations that can include more than two parties.
All current-day instant-messaging applications rely on some sort of central component that stores a list of who is currently online as well as the information needed to contact them. Depending on the way the system is set up, peers may retrieve this information and contact a chosen user directly, or they may route all activity through the central coordinator. This chapter will consider both alternatives. We'll use the central coordinator approach first.
Conceptually, there are two types of applications in Talk .NET: the single server and the clients (or peers). Both applications must be divided into two parts: a remotable MarshalByRefObject that's exposed to the rest of the world and used for communication over the network, and a private portion, which manages the user interface and local user interaction. The server runs continuously at a fixed, well-known location, while the clients are free to appear and disappear on the network. Figure 4-1 diagrams these components.
In order for the server to contact the client, the client must maintain an open bidirectional channel. When a message arrives, the server notifies the client. This notification can take place in several ways—it might use a callback or event, or the server could just call a method on the client object or interface, which is the approach taken in Talk .NET. Communication between these components uses TCP channels and binary formatting in our example, although these details are easy enough to change through the configuration files.
One of the most important aspects of the Talk .NET design is the fact that it uses interfaces to manage the communication process. Interfaces help to standardize how any two objects interact in a distributed system. Talk .NET includes two interfaces: ITalkServer, which defines the methods that a client can call on the server, and ITalkClient, which defines the methods that the server (or another client) can call on a client. Before actually writing the code for the Talk .NET components, we'll define the functionality by creating these interfaces.
You can examine the full code for Talk .NET with the online samples for this chapter. There are a total of four projects that make up this solution; each is contained in a separate directory under the Talk .NET directory.
The first step in creating the system is to lock down the methods that will be used for communication between the server and client components. These interfaces must be created in a separate DLL assembly so that they can be used by both the TalkClient and TalkServer applications. In the sample code, this class library project is called TalkComponent. It contains the following code:
Public Interface ITalkServer ' These methods allow users to be registered and unregistered ' with the server. Sub AddUser(ByVal [alias] As String, ByVal callback As ITalkClient) Sub RemoveUser(ByVal [alias] As String) ' This returns a collection of user names that are currently logged in. Function GetUsers() As ICollection ' The client calls this to send a message to the server. Sub SendMessage(ByVal senderAlias As String, _ ByVal recipientAlias As String, ByVal message As String) End Interface Public Interface ITalkClient ' The server calls this to forward a message to the appropriate client. Sub ReceiveMessage(ByVal message As String, ByVal senderAlias As String) End Interface ' This delegate is primarily for convenience on some server-side code. Public Delegate Sub ReceiveMessageCallback(ByVal message As String, _ ByVal senderAlias As String)
Remember to consider security when designing the interfaces. The interfaces define the methods that will be exposed publicly to other application domains. Don't include any methods that you don't want a user at another computer to be able to trigger.
ITalkServer defines the basic AddUser() and RemoveUser() methods for registering and unregistering users. It also provides a GetUsers() method that allows peers to retrieve a complete list of online users, and a SendMessage() method that actually routes a message from one peer to another. When SendMessage()is invoked, the server calls the ReceiveMessage() method of the ITalkClient interface to deliver the information to the appropriate peer.
Finally, the ReceiveMessageCallback delegate represents the method signature for the ITalkClient.ReceiveMessage() method. Strictly speaking, this detail isn't required. However, it makes it easier for the server to call the client asynchronously, as you'll see later.
One design decision has already been made in creating the interfaces. The information that's being transferred—the sender's user name and the message text—is represented by separate method parameters. Another approach would be to create a custom serializable Message object, which would be added to the TalkComponent project. Both approaches are perfectly reasonable.
In Figure 4-1, both the client and the server are depicted as Windows applications. For the client, this design decision makes sense. For the server, however, it's less appropriate because it makes the design less flexible. For example, it might make more sense to implement the server component as a Windows service instead of a stand-alone application (as demonstrated in the next chapter).
A more loosely coupled option is possible. The server doesn't need to include any user-interface code. Instead, it can output messages to another source, such as the Windows event log. The Talk .NET server will actually output diagnostic messages using tracing code. These messages can then be dealt with in a variety of ways. They can be captured and recorded in a file, sent to an event log, shown in a console window, and so on. In the Talk .NET system, these messages will be caught by a custom trace listener, which will then display the trace messages in aWindows form. This approach is useful, flexible, and simple to code.
In .NET, any class can intercept, trace, and debug messages, provided it inherits from TraceListener in the System.Diagnostics namespace. This abstract class is the basis for DefaultTraceListener (which echoes messages to the Visual Studio .NET debugger), TextWriterTraceListener (which sends messages to a TextWriter or Stream, including a FileStream) and EventLogTraceListener (which records messages in the Windows event log).
The program calls a method such as Debug.Write() or Trace.Write().
The common language runtime (CLR) iterates through the current collection of debug listeners (Debug.Listeners) or trace listeners (Trace.Listeners).
Each time it finds a listener object, it calls its Write() or WriteLine() method with the message.
The solution used in this example creates a generic listener that forwards trace messages to a form, which then handles them appropriately. This arrangement is diagrammed in Figure 4-2.
The following is the outline for a FormTraceListener. This class is implemented in a separate class library project named TraceComponent.
' The form listener is a TraceListener object that ' maps trace messages to an ITraceForm instance, which ' will then display them in a window. Public Class FormTraceListener Inherits TraceListener Public TraceForm As ITraceForm ' Use the default trace form. Public Sub New() MyBase.New() Me.TraceForm = New SimpleTraceForm() End Sub ' Use a custom trace form. Public Sub New(ByVal traceForm As ITraceForm) MyBase.New() If Not TypeOf traceForm Is Form Then Throw New InvalidCastException( _ "ITraceForm must be used on a Form instance.") End If Me.TraceForm = traceForm End Sub Public Overloads Overrides Sub Write(ByVal value As String) TraceForm.LogToForm(value) End Sub Public Overloads Overrides Sub WriteLine(ByVal message As String) ' WriteLine() and Write() are equivalent in this simple example. Me.Write(message) End Sub End Class
The FormTraceListener can send messages to any form that implements an ITraceForm interface, as shown here:
' Any custom form can be a "trace form" as long as it ' implements this interface. Public Interface ITraceForm ' Determines how trace messages will be displayed. Sub LogToForm(ByVal message As String) End Interface
Finally, the TraceComponent assembly also includes a sample form that can be used for debugging. It simply displays received messages in a list box and automatically scrolls to the end of the list each time a message is received.
Public Class SimpleTraceForm Inherits System.Windows.Forms.Form Implements ITraceForm ' (Designer code omitted.) Public Sub LogToForm(ByVal message As String) Implements ITraceForm.LogToForm ' Add the log message. lstMessages.Items.Add(message) ' Scroll to the bottom of the list. lstMessages.SelectedIndex = lstMessages.Items.Count - 1 End Sub End Class
This approach is useful for the Talk .NET server, but because it's implemented as a separate component, it can easily be reused in other projects.