Advanced Basics

P2P Comm Using Web Services

Carl Franklin

Code download available at:AdvancedBasics0408.exe(195 KB)

Contents

Peer-to-Peer and Web Services
Building the Client
Maintaining State
Logging In Users
Sending and Receiving

Iwanted to use my first Advanced Basics column as an opportunity to strike out into new territory, to do something I haven't seen extolled much in the literature, so I've built a Windows® Forms chat program that uses Web services to communicate with other peers. It's a bit of a sneaky implementation, though, because along the way I'll call Web services asynchronously, synchronize access to the UI, and explain how powerful the Shared keyword can be.

Peer-to-Peer and Web Services

Traditionally, Web services have lived in a stateless, client/server world. With Web Services Enhancements (WSE) 2.0, with toolkits from other vendors, and certainly with "Indigo" (code name for the next-generation of .NET technologies from Microsoft for building and running connected systems), Web services are not restricted to client/server. Peer-to-peer applications exhibit a more connected nature. There are two P2P models. One is just like it sounds: my program contacts your program directly and we talk. Maybe I got your IP address and port number from a server that catalogs available services and their corresponding IP addresses (a model similar to the original Napster), or maybe I discovered you through some intelligent software like WinMX.

In the other peer-to-peer model, all peers connect to a server and talk to each other through the server, the way Internet Relay Chat (IRC) works. IRC is a chat system that defines client-to-server and server-to-server communications protocols. A client connects to the server, announces himself or logs in, and retrieves the names and IDs of the other clients. To send a message to another client, the client sends it to the server and tells the server which other client the message is for; then the server sends the recipient a new message.

That's sort of what I'm trying to accomplish here using Web services over HTTP. A client connects, sends a request, gets a response, and the connection is closed by the server. For a client to get an event notification from a server using HTTP has always required polling on the part of the client, and that is exactly what my sample app does. The Web service I'll build here defines eight methods, which are listed in Figure 1.

Figure 1 My Web Service Methods

Method Description
Hello Simple PING
LogIn Self explanatory
GetUsers Returns a list of users currently logged in
GetNextMessage Polls for the next message addressed to me (the client)
SendMessage Sends a message to a single client
SendMessageToAll Sends a message to all clients logged in
LogOut You guessed it
ResetAll Clears everything, thus all users must log in again

Building the Client

The WebChat client program looks deceptively simple (see Figure 2). The listbox on the left shows the names of the clients that are currently connected. The large listbox on the right shows the messages as they come through. Below that is a multiline textbox where you can type in your message. A Send All button sends the message to all clients, and the Send button sends the message to the selected client. When the client loads, a simple user name/password login dialog is displayed to collect the user's credentials and calls the LogIn WebMethod (see Figure 3).

Figure 3 LogIn Dialog Code

Imports p2pTools Public Class frmLogin Inherits System.Windows.Forms.Form Private server As New net.franklins.www.Service1 Private Sub btnLogIn_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnLogIn.Click Dim resp As LogInReturn Dim bytes() As Byte = server.LogIn(txtUserName.Text, _ txtPassword.Text) resp = CType(Utils.DeSerialize(bytes), LogInReturn) _clients = resp.Clients _peerid = resp.MyPeerID _hash = resp.UserHash Me.Hide() End Sub Private Sub Login_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load server.BeginHello(AddressOf GotResponse, Nothing) End Sub Private Sub GotResponse(ByVal ar As IAsyncResult) End Sub Private _hash As String Public ReadOnly Property UserHash() As String Get Return _hash End Get End Property Private _peerid As String Public ReadOnly Property PeerID() As String Get Return _peerid End Get End Property Private _clients As ClientSideUserCollection Public ReadOnly Property Clients() As ClientSideUserCollection Get Return _clients End Get End Property End Class

Figure 2 WebChat Client

Figure 2** WebChat Client **

Let's jump over to the LogIn WebMethod in the p2pChat Web service application, shown in Figure 4. As you can see, the user name and password are sent in clear text over HTTP. For this reason, you might want to use a Secure Sockets Layer (SSL) connection in the real world. Using SSL is the easiest way to secure your Web service data in transit. There is a bit of overhead involved, but for a chat program you'll never notice it.

Figure 4 LogIn WebMethod

<WebMethod()> Public Function LogIn(ByVal UserName As String, _ ByVal Password As String) As Byte() '-- Do your authentication here '-- Does the user already exist? If StateManager.ExistsUserName(UserName) Then Throw New InvalidOperationException("User already exists") End If '-- Create a new client Dim PeerID As String = StateManager.AddClientAndReturnPeerID(UserName) '-- Return the PeerID, to be used for subsequent calls Dim resp As New LogInReturn resp.MyPeerID = PeerID resp.Clients = StateManager.GetClientSideUsers resp.UserHash = StateManager.GetHash Return Utils.Serialize(resp) End Function

Maintaining State

In Figure 4 you'll notice that I make a call to the ExistsUserName method in a class named StateManager. Yes, I said a class, not an object. StateManager is a class that I've defined inside the Web service project to manage the state of each client. ExistsUserName is one of several methods that are defined with the Shared keyword. They are static methods; you do not need to instantiate an object of the class to call Shared methods. Another way to think of Shared methods is that all instances of the class share all shared methods and variables. One instance serves all requests.

This is what makes the whole thing work. Without keeping some sort of list of the clients around, you'd have no chat architecture. I could have also used the ASP.NET Application state object or the ASP.NET Cache, both accessible through the HttpContext.Current to a Web service hosted in ASP.NET.

Now let's look at the pros and cons of using Shared members and why I've chosen to use them. The first drawback that might concern you is that since this class is exposing shared methods and data, that data lives as long as the host process is alive, so you have to manage it. Another drawback is that the code that accesses the shared code and data must be thread-synchronized, but that's not very hard to do in Visual Basic® .NET. The final drawback you might worry about is that, due to synchronization, the synchronized code becomes a bottleneck because only one thread can access it at any one time. However, this chat program transmits very little data between a small number of clients, so any bottlenecks that might arise should be completely transparent.

Now let's look at the ExistsUserName method:

Public Shared Function ExistsUserName(ByVal UserName As String) As Boolean Dim there As Boolean = False SyncLock Clients For Each obj As P2PClient In Clients If String.Compare(obj.UserName, UserName) = 0 Then there = True Exit For End If Next End SyncLock Return there End Function

This code performs a simple collection lookup on the Clients collection (P2PClientCollection), which is a custom collection of class P2PClient. Didn't get that? OK, let me explain.

I've created some classes (that are used on both the client and server) in a DLL called p2pTools.dll. I have a reference to this DLL in the Web service, and the WebChat client solution file includes p2pTools as a project. This is where the P2PClient class is defined. The P2PClientCollection class is just a custom collection strongly typed to hold P2PClient objects. Note that by creating a DLL that's required on both the client and on the server, I'm increasing the coupling between the two, which goes against some of the tenets of Web services. It's fine for our purposes, though.

The P2PClient class is used on the server side to represent a client. Its properties are PeerID (a String), which is a unique ID created by the server to identify a client; UserName (a String), which is an ID generated by the user; LastAccess (a DateTime), which is the time at which the client last connected to the server to poll for messages; and Messages, which is a custom collection of P2PMessage objects. P2PMessage defines a message: FromUserName, ToUserName, and Message (Strings), and TimeSent (DateTime). See? It's simple.

Logging In Users

Let's go back to the LogIn WebMethod. The purpose of the ExistsUserName call is to see whether or not the user is already logged in. In other words, is there already a user with this name in the shared Clients collection? If so, an exception is thrown; otherwise, we move on.

Next, I call a shared function in the StateManager class: AddClientAndReturnPeerID, shown with the PeerID property of the P2PClient in Figure 5. Anytime you access a reference-type object from more than one thread, you must synchronize access to that object, so that only one thread at a time can access it. This is not unlike how SQL Server™ locks a page or a record when reading or writing. If another request comes in during that operation, it has to wait for the current operation to remove the lock. This ensures that only one client at a time can access a record or a page in the database and keeps conflicts from happening.

Figure 5 AddClientAndReturnPeerID

Public Shared Function AddClientAndReturnPeerID(ByVal UserName _ As String) As String Dim obj As P2PClient SyncLock Clients obj = Clients.Add obj.UserName = UserName End SyncLock Return obj.PeerID End Function <Serializable()> Public Class P2PClient Private _peerid As String = Nothing Public ReadOnly Property PeerID() As String Get If _peerid = Nothing Then '-- create a random ID Dim g As Guid = Guid.NewGuid() _peerid = g.ToString End If Return _peerid End Get End Property '-- More here End Class

The SyncLock statement does this for you in Visual Basic .NET. In C#, the keyword is lock. In this case, I'm locking the shared Clients collection so that multiple threads can access it safely.

The PeerID property of the P2PClient class will generate itself automatically if none exists. It does so by creating a new GUID using Grid.NewGrid and the retrieving string representation of it. Originally, my design called for the Web service to return an object of type LogInReturn, a class defined in the DLL, but since the LogInReturn class contains a property of a type based on CollectionBase, it wouldn't serialize out of the box, so I had to perform some manual serialization instead. I could have also used an array instead of a collection, which would serialize fine.

So, LogIn returns an array of bytes. The Serialize method of the Utils class (also defined in the p2pTools.DLL component) makes this easy. Figure 6 shows the Utils class. The BinaryFormatter object does the work, but you have to serialize to a stream, not an array of bytes. From a memory stream, you can use the ToArray method to return the bytes which, when passed from a Web service, get converted into a Base64 string. It's a roundabout way to move binary data around, but it works. It works especially well for the small amounts of data that a chat program passes around.

Figure 6 The Utils Class

Option Strict On Imports System.IO Imports System.Runtime.Serialization Imports System.Runtime.Serialization.Formatters.Binary Public Class Utils Public Shared Function Serialize(ByVal Obj As Object) As Byte() Dim bf As New BinaryFormatter Dim mem As New MemoryStream Try bf.Serialize(mem, Obj) Catch ex As Exception Throw ex End Try Return mem.ToArray End Function Public Shared Function Deserialize(ByVal bytes() As Byte) As Object Dim bf As New BinaryFormatter Dim mem As New MemoryStream(bytes) Dim obj As Object Try obj = bf.Deserialize(mem) Catch ex As Exception Throw ex End Try Return obj End Function End Class

As you saw back in Figure 3, the response from the Web server comes in as the resp byte array. That is deserialized into a LogInReturn object, which contains the PeerID, a collection of clients, and a unique code that represents the current collection of user names and Peer IDs. You already know that PeerID is a unique GUID identifying the client. The Clients property is a custom collection of ClientSideUser objects. ClientSideUser doesn't have the messages associated with it, so it's appropriate for sending to the client as a list of logged-in users.

The Hash property is a bit different. It's the result of a hash algorithm that is performed on the list of clients. So, if a client logs off, a new client logs on, or the list of clients changes in any way, the hash will also change. I'm using the SHA1 one-way hashing algorithm to compute the hash. This is worth investigating, so take a look at Figure 7 which shows the GetHash method in the StateManager class. The GetHash method returns a unique hash code representing the current state of the client list. It is used in order to determine if there are any new users.

Figure 7 GetHash Method

Public Shared Function GetHash() As String '-- return a unique hash string from the usernames and peerids Dim ret As String SyncLock Clients Dim users As ClientSideUserCollection = _ StateManager.GetClientSideUsers Dim bytes() As Byte = Utils.Serialize(users) Dim SHA1 As New _ System.Security.Cryptography.SHA1CryptoServiceProvider Dim hash() As Byte = SHA1.ComputeHash(bytes) ret = Convert.ToBase64String(hash) SHA1.Clear() End SyncLock Return ret End Function

I lock the Clients object so that it can't be modified by the server while I'm computing the hash. Next, I get a ClientSideCollection that contains just user names and PeerIDs, serialize it to a byte array, and compute a hash on the byte array. Returning the hash as another byte array, it is returned as a Base64 string. Upon polling for new messages, the client receives the latest hash code, which will be different if the list of clients has changed. At that time, the client calls another method in order to get the most current list and the hash code, of course.

Now let's go back to the main form's Load event handler (see Figure 8). First I show the main form. Then I create a new login form and show that modally. When the application has gathered credentials and the login dialog closes, either I wasn't validated (because server-side validation is not implemented), or else I was validated and the return object has a PeerID and a Clients list which is bound to the listbox.

Figure 8 Load Event

Private Sub frmMain_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Me.Show() Dim frm As New frmLogin frm.ShowDialog() myID = frm.PeerID If myID Is Nothing Then Application.Exit() clients = frm.Clients With lbUsers .DataSource = clients .DisplayMember = "UserName" .ValueMember = "PeerID" End With server.BeginGetNextMessage(myID, myHash, AddressOf _ ChatCallback, Nothing) End Sub

Finally, the polling starts. The GetNextMessage WebMethod is called asynchronously. I pass my PeerID and the address of a sub that will be called when the Web service response is received. In this case it's ChatCallBack, which is shown in Figure 9 along with the GetNextMessage WebMethod on the server.

Figure 9 Calling ChatCallBack

Server

<WebMethod()> Public Function GetNextMessage(ByVal MyPeerID As String) _ As Byte() '-- Does the sender exist? Dim resp As New GetNextMessageReturn If StateManager.ClientExists(MyPeerID) Then resp.Message = StateManager.GetNextMessage(MyPeerID) If resp.Message Is Nothing Then resp.Response = "No new messages" Else resp.Response = "OK" End If Else resp.Response = "You must log in first" End If resp.UserHash = StateManager.GetHash Return Utils.Serialize(resp) End Function

Client

Private Sub ChatCallback(ByVal ar As IAsyncResult) Dim bytes() As Byte = server.EndGetNextMessage(ar) Dim resp As GetNextMessageReturn = CType(Utils.DeSerialize(bytes), _ GetNextMessageReturn) If resp.Response = "OK" Then Dim Msg As String = "<" & resp.Message.FromUserName & "> " & _ resp.Message.Message Dim args() As Object = {Msg} Dim dlg As New ShowMessageDelagate(AddressOf ShowMessage) Me.Invoke(dlg, args) End If Thread.Sleep(1000) server.BeginGetNextMessage(myID, AddressOf ChatCallback, Nothing) If resp.UserHash <> myHash Then '-- refresh the user list server.BeginGetUsers(AddressOf GetUsersCallback, Nothing) End If End Sub

Sending and Receiving

On the server, I first check to see if the client exists (is logged in) and, if so, I call StateManager.GetNextMessage which does the work (see Figure 10). If a new message is received, it is displayed. Either way, the thread goes to sleep for a second and then calls the GetNextMessage WebMethod again asynchronously, ensuring that the polling will continue as long as the app is running. Now it's just a matter of getting messages into the client's Messages collection on the server, which is easy enough. It's a collection, so you just add a new message to it. The SendMessage and SendMessageToAll WebMethods do just that.

Figure 10 GetNextMessage

Public Shared Function GetNextMessage(ByVal ToID As String) As P2PMessage Dim msg As P2PMessage Dim index As Int32 '-- Lock the clients collection SyncLock Clients '-- Retrieve the client object from its ID Dim toClient As P2PClient = Clients(ToID) If Not toClient Is Nothing Then '-- Reset the timestamp for last access toClient.LastAccess = Now '-- Lock the messages collection SyncLock toClient.Messages '-- Is there more than one message waiting? If toClient.Messages.Count > 0 Then '-- Yes! Skim the first one off msg = toClient.Messages(0).Clone toClient.Messages.RemoveAt(0) End If End SyncLock End If End SyncLock '-- Return the message (or Nothing) Return msg End Function

The only other code that's interesting here is the code required to synchronize access to the Windows user interface. Because the callback happens on a different thread than the one on which the UI controls were created, you have to use the ISynchronizeInvoke.Invoke method on any control to call a delegate pointing to a local sub that actually modifies the user interface. In this case, Me—the form—works just fine. For a more detailed explanation of this requirement, check out Ian Griffiths' article on multithreading in the February 2003 issue of MSDN®Magazine.

I took a number of shortcuts in getting the chat program ready for this column. I wrote a more robust real-world version that goes way beyond what I could address here. The program addresses the design issues laid out here, and also includes two-way symmetric encryption and file transfer capabilities.

First, it's a bad idea to pass binary serialized objects in the form of a byte array with Web services. It defeats the open nature of the Web service, and requires that the user have the right version of the binary formatter installed on the client. Not that it matters much in this example, but it's still not a good idea. Second, using collections in objects through Web services is the reason that I needed to use the binary formatter in the first place. Collections don't serialize by default through Web services. A better approach is to use an array.

Now go write some code!

Send your questions and comments to  basics@microsoft.com.

Carl Franklin is president of Franklins.Net where he teaches Visual Basic .NET classes and hosts .NET Rocks!, an Internet audio talk show for developers who use the .NET Framework.