So far this chapter has investigated threading intricacies and rewritten all the parts of the Talk. NET system that are vulnerable to threading problems. At this point, it's worth considering a few additional enhancements that you can make to round out Talk .NET.
Currently, there are only two ways that a client is removed from the user list: if the client logs out or if an error occurs when sending that client a message. To improve the system and ensure it more accurately reflects the clients that are currently connected, you can add an expiry time to the client login information. This expiry date should be fairly generous (perhaps 15 minutes) to prevent the system from being swamped by frequent network messages. Unfortunately, there will always be a trade-off between ensuring the up-to-date accuracy of the system, and ensuring the optimal performance of the system.
In order to use an expiry policy, the ActiveUsers collection will need to store expiry dates (or last update times) and client references. You handle this by creating a new class that aggregates these two pieces of information, such as the ClientInfo class shown here:
Public Class ClientInfo Public ProxyRef As ITalkClient Public LastUpdated As DateTime End Class
The ActiveUsers collection will then only store ClientInfo objects. Once the expiry dates are in place, there are two ways to implement an expiry policy:
You could give the server the responsibility for calling a "dummy" method in the client that simply returns True. If this method can be called without a networking error, the client's expiry date will be updated accordingly.
You can give the client the responsibility of contacting the server and logging in periodically before the expiry date is reached.
Both methods are used in the current generation of peer-to-peer applications. The latter is generally preferred, because it simplifies the server-side coding. It also ensures that the server won't have to wait for a communication error to detect an improperly disconnected client. Instead, it will just inspect the expiry date. Because the server is a critical component in the system, you should reduce its work as much as possible.
In either case, the server needs to periodically examine the list of logged-in users and remove invalid entries. This could be performed on a separate thread or in response to a timer. The separate thread would probably create a copy of the collection before examining it for expired users, in order to minimize locking possibilities. It would then double-check the live collection and call the RemoveUser() method.
In Talk .NET, there's another, potentially more efficient approach. The client expiry date could be refreshed every time the client calls the GetUsers() method. This reduces network traffic because the client is already calling GetUsers() as long as it's active in order to keep its list of contacts up to date. To accommodate this design, you would need to modify the GetUsers() signature so that it accepts the client name (or, in a secure application, a security token of some kind). Here's an example:
We will deal with expiry dates again in more detail when we create a discovery service in the third part of this book.
The current TalkServer makes no effort to prevent duplicate users. This is a problem because if there's more than one user that logs on with the same name, only the most recent user will be entered in the collection (and will be able to receive messages).
To overcome this problem, you simply need to modify the ServerProcess.AddUser() method so that it refuses attempts to create an already existing user.
Public Function AddUser(ByVal [alias] As String, ByVal client As ITalkClient) _ As Boolean Implements TalkComponent.ITalkServer.AddUser SyncLock Me If ActiveUsers.Contains([alias]) Return False Else ActiveUsers[alias] = client Return True End SyncLock End Sub
Similarly, TalkClient should be modified so that it will refuse to continue until it receives acceptance from the server:
Public Shared Sub Main() Dim frmLogin As New Login() Do If frmLogin.ShowDialog() = DialogResult.OK Then ' Create the new remotable client object. Dim Client As New ClientProcess(frmLogin.UserName) If frm.TalkClient.Login() Then ' Create the client form. Dim frm As New Talk() frm.TalkClient = Client ' Show the form. frm.ShowDialog() Else ' Login attempt failed. The loop will try it again. End If Else ' User chose to exit the application. Exit Do End If Loop End Sub
Unfortunately this approach is still a little too restrictive. What happens in the legitimate case that a user wants to log in again after the application disconnected due to network problems? The user could use a new alias or wait for the old information to expire, but this is still far from ideal. One option is to add a "dummy" method to the ClientProcess. When faced with a duplicate login request, the server could then call this dummy method and, if it receives an error, it would determine that the current client is invalid and allow the new login request.
If you implement an authentication system, this code may change. In the case of an authentication system, it's safe to assume that if a user who already exists logs in again, the old information should be replaced without asking any questions, provided the user's identity is confirmed (typically by comparing the supplied password against a database).
Remoting and Windows services make a great match. Currently, the TalkServer component host uses a Windows Form interface. This imposes some limits— namely, it requires someone to launch the application, or at least log on to a server computer so it can be loaded automatically. Windows services, on the other hand, require no user intervention other than starting the computer. The TalkServer, if implemented as a Windows service, will run quietly in the background, logged in under a preset account, even if the computer isn't currently in use, or is still at the Windows Login screen. Administrators using the computer can interact with Windows Services through the Service Control Manager (SCM), which allows services to be started, stopped, paused, and resumed.
This book won't explore Windows services in much detail, as they're already covered in many introductory .NET books, and they aren't specific to peer-to-peer development. However, it's surprisingly easy to create a simple Windows service to host the Talk .NET peer-to-peer system, and it's worth a quick digression.
The first requirement is to understand a few basics about programming aWindows service in .NET. Here's a quick summary of the most important ones:
Windows services use the classes in the System.ServiceProcess namespace. These include ServiceBase (from which every Windows service class must derive), and ServiceInstaller and ServiceProcessInstaller (which are used to install a service).
Windows services cannot be tested in the Visual Studio .NET environment. Instead, you must install the service and start it using the SCM.
When you start a Windows service, the corresponding OnStart() method is called in the service class. This method only has 30 seconds to set up the service, usually by enabling a timer or starting a new thread. The OnStart() method does not actually perform the required task.
When the service is stopped, the OnStop() method is called. This tears down whatever resources the OnStart() method sets up.
To create a Windows service, Visual Studio .NET programmers can start by creating a Windows service project. The project will contain a single class that inherits from ServiceBase as well as the installation classes that you'll generate later.
In Talk .NET, the Windows service plays a simple role. It configures the .NET Remoting channels in the OnStart() method and unregisters them in the OnStop() method. Once these channels are in existence, the Talk .Net ServiceProcess object will be created with the first client requests, and preserved until the service is stopped.
Following is a simple service that does exactly that. The code sample includes a portion of the hidden designer code, so you can better see how it works.
Imports System.ServiceProcess Imports System.Runtime.Remoting Imports System.Runtime.Remoting.Channels Public Class TalkNetService Inherits System.ServiceProcess.ServiceBase Public Sub New() MyBase.New() InitializeComponent() End Sub Private Sub InitializeComponent() ' (The code for all design-time property configuration appears here.) Me.ServiceName = "Talk .NET Service" End Sub <MTAThread()> _ Shared Sub Main() ServiceBase.Run(New TalkNetService()) End Sub ' Register the listening channels. Protected Overrides Sub OnStart(ByVal args() As String) RemotingConfiguration.Configure("SimpleServer.exe.config") End Sub ' Remove all the listening channels. Protected Overrides Sub OnStop() Dim Channel As IChannel For Each Channel In ChannelServices.RegisteredChannels() ChannelServices.UnregisterChannel(Channel) Next End Sub End Class
The lifetime of a service runs something like this:
When the service is installed or when the computer is started, the Main() method is invoked. The Main() method creates a new instance of the service and passes it to the base ServiceBase.Run() method. This loads the service into memory and provides it to the SCM, but does actually start it.
The next step depends on the service configuration—it may be started automatically, or the user may have to manually start it by selecting it with a tool such as the Computer Management utility.
When the service is started, the SCM calls the OnStart() method of your class. However, this method doesn't actually perform the work, it just prepares it (starting a new thread, creating a timer, or something else). If OnStart() doesn't return after approximately 30 seconds, the start attempt will be aborted and the service will be terminated.
Afterward, the service does its actual work. This may be performed continuously on a separate thread, in response to a timer tick or another event, or (as in this example) in response to client requests through the Remoting infrastructure.
Windows service applications cannot be executed from inside Visual Studio .NET. To test your service, you need to create an installer. Visual Studio .NET will perform this step automatically: Just click on your service code file, switch to the design (component) view, and click the Add Installer link that appears in the Properties window (see Figure 5-5). A new ProjectInstaller.vb file will be added to your project, which contains all the code required to install the service.
You can configure some of the default service settings before you install the service by configuring the properties of the ServiceProcessInstaller and ServiceInstaller classes. Set the ServiceProcessInstaller.Account property to LocalService if you want the service to run under a system account, rather than the account of the currently logged-in user. Set the ServiceInstaller.StartType property to Automatic if you want the service to be configured to start automatically when the computer boots up. Both of these details can be configured manually using the Computer Management utility.
At this point, you can either use the generated installation components in a custom setup application, or you can use the InstallUtil.exe utility included with .NET. First, build the project. Then, browse to the directory where the executable was created (typically the bin directory) and type in the following instruction:
The output for a successful install operation is shown in Figure 5-6.
You can now find and start the service using the Computer Management administrative tool. In the Control Panel, select Computer Management from the Administrative Tools group, and right-click the Talk .NET Service (see Figure 5-7).
To update the service, you need to recompile the executable, uninstall the existing service, and then reinstall the new service. To uninstall a service, simply use the /u parameter with InstallUtil:
InstallUtil TalkNetService.exe /u
This implementation works exactly the same as before, except trace messages will no longer be captured by the TraceFormListener. Also, the only way to end the service will be through the SCM, not by closing the trace form.
What happens if you want to capture the trace messages and inspect them later? As discussed in Chapter 4, you can use another type of TraceListener and write messages to a text file or an event log. If you don't want to create a permanent record of messages, but you want to watch the messages "live," and possibly debug the service source code, you can still use the Visual Studio .NET debugger. You simply need to attach the debugger to the service manually.
Here's how it works. First you start Visual Studio .NET. Then, you open the TalkService project. This step isn't required, but it allows you to set breakpoints and single-step through the code easily. Finally, assuming the Talk .NET Service is running, select Tools Debug Processes from the Visual Studio .NET menu. The Processes window will appear, as shown in Figure 5-8.
If you're running the service under a system account, you must select the "Show system processes" check box, or the service won't appear in the list. When you find the TalkService, select it and click Attach. Finally, in the Attach to Process window (Figure 5-9), select the Common Language Runtime check box to debug the code as a CLR application, and click OK.
Trace messages will now appear in the Debug window (as shown in Figure 5-10), and Visual Studio .NET will be able to work its usual debugging magic with breakpoints, single-stepping, and variable watches.