Magazine > Issues > 2004 > August >  Advanced Basics: P2P Comm Using Web Services
Advanced Basics
P2P Comm Using Web Services
Carl Franklin

Code download available at: AdvancedBasics0408.exe (195 KB)
Browse the Code Online
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.

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).
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 
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.
<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.
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.
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.
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.
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.

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.
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.
You can download Zeep Lite, a version of this chat program that uses arrays and classes instead of serialized classes with collections, at my Web site (http://www.franklins.net/zeep).
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.

Page view tracker