Click here to Skip to main content
Click here to Skip to main content

Access Web Services Asynchronously in .NET Design Patterns

By , 4 Jul 2006
Rate this:
Please Sign up or sign in to vote.

Introduction

In the real world, client software usually communicate with web services asynchronously. An asynchronous call returns immediately, and receives the result separately when the processing is completed. This can avoid latency across network freezing the application UI or blocking other processes. With an asynchronous mechanism, an application can provide the user with options to cancel a pending request if the web service call is lengthy or getting stuck.

To access a web service, you can generate a proxy class by using the WSDL tool in the .NET Framework or by adding a web reference in Visual Studio. A proxy encapsulates all the public methods exposed by the web service in the form of both synchronous and asynchronous functions. Please refer to the MSDN documentation for details. One of the instructive articles available is “Asynchronous Web Service Calls over HTTP with the .NET Framework” by Matt Powell.

An asynchronous implementation mainly depends on the generated proxy class. The .NET Framework provides two asynchronous constructs in the proxy. One is the Begin/End design pattern from .NET 1.0, and the other is the event-driven model available in .NET 2.0. In this article, I’ll illustrate both implementations and discuss some interesting and undocumented issues. The sample code includes a simple web service, a client built in VS 2003 for .NET 1.1, and another client built in VS 2005 for .NET 2.0.

A Test Web Service

Since a web service is platform-generic, you can consume it regardless of its origin or version. Here, I created a test service in .NET 2.0 consumed by both clients. Service.cs in the following Listing-1 presents the service, with only method, GetStock(), that accepts a symbol and returns its quote.

// Listing-1. A test web service in Service.cs

using System;
using System.Web.Services;
using System.Threading;

[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]

public class Service : System.Web.Services.WebService
{
    Random  _delayRandom  = new Random();
    Random  _stockRandom = new Random();

    [WebMethod]
    public double GetStock(string symbol, int timeout) 
    {
        int delay = _delayRandom.Next(50, 5000);
        Thread.Sleep(delay > timeout ? timeout : delay);

        if (delay > timeout) return -1;

        double value;
        switch (symbol)
        {
            case "MSFT": value = 27;  break;
            case "ELNK": value = 11;  break;
            case "GOOG": value = 350; break;
            case "SUNW": value = 6;   break;
            case "IBM":  value = 81;  break;
            default: value = 0; break;
        }

        return value + value * 0.1 * _stockRandom.NextDouble();
    }
}

I use two Random objects to simulate the service action. _delayRandom is to mimic the online traffic from 50 to 5000 milliseconds, and _stockRandom is for quote value fluctuations. The service lets a client set timeout, the second parameter of GetStock(). Hence, except for the normal quote returned, GetStock() also sends back zero for an unrecognized symbol, and negative one as a timeout flag.

For simplicity, I host the service under the VS 2005 test server, as shown below:

Launching the test web service

To make it yourself, call WebDev.WebServer.exe with an option for your physical path, where Service.asmx resides like (refer to startWsTest.bat in the Demo):

C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\WebDev.WebServer.EXE
/port:1111
/path:"c:\Articles\CallWsAsync\code\WebService2"
/vpath:"/callwsasync"

Now, I hope to create a client to consume this web service in five scenarios, when you click on the Get Quote button in the following dialog:

Five scenarios in a client

As you see, the first is the OK status with a quote returned. The second is an exception occurred if the server/network is unavailable. The third is for an invalid symbol. The fourth happens when you click the Cancel button immediately to abort a request. The last is the server response to the timeout defined in the UI. In each scenario, the session time is recorded in display. This screenshot just shows the first client UI, and I implement both with the same look and feel.

Begin/End Design Pattern

To generate a proxy in .NET 1.1, you can use the Add Web Reference command in VS 2003, point the URL to a virtual path like: http://localhost:1111/callwsasync/, and choose Service.asmx. The proxy contains BeginGetStock() and EndGetStock(). Let’s name it WsClient1.WsTestRef1.Service, and define an object of type _wsProxy1. The Listing-2 below shows how the first client works:

// Listing-2. Using Begin/End pattern with callback

private void buttonGetQuote_Click(object sender, System.EventArgs e)
{
    textBoxResult.Text = "";
    _tmStart = DateTime.Now.Ticks;

    string symbol = comboBoxSymb.Text.ToUpper();
    int timeout = int.Parse(textBoxTimeout.Text);

    _wsProxy1 = new WsClient1.WsTestRef1.Service();
    _wsProxy1.BeginGetStock(symbol, timeout, 
            new AsyncCallback(OnGetStock), symbol);    
}

private void OnGetStock(IAsyncResult ar)
{
    string symbol = (string)ar.AsyncState;
    string result;
    
    try 
    {
        double value = _wsProxy1.EndGetStock(ar);
        if (value ==0)
            result = "Invalid, " + "'" +symbol +"'";
        else
        if (value <0)
            result = "TimeOut, " + "[" +symbol +"]";
        else
            result = "OK, " + value.ToString("F");
    }
    catch (WebException e)
    {
        if (e.Status == WebExceptionStatus.RequestCanceled)
            result = "Cancelled, " + "<" +symbol +">";
        else
            result = "Exception, " + e.Message;
    }
    catch (Exception e)
    {
        result = "Exception, " + e.Message;
    }

     textBoxResult.Invoke(new ShowResultDelegate(ShowResult), 
                                      new object[] {result});
    _wsProxy1 = null;
}
    
private void buttonCancel_Click(object sender, System.EventArgs e)
{
    if (_wsProxy1 != null)
        _wsProxy1.Abort();
}

private delegate void ShowResultDelegate(string str);
private void ShowResult(string str)
{
    textBoxResult.Text = str + ",  (" + 
      (DateTime.Now.Ticks - _tmStart) / 10000 + " ms)";
}

In buttonGetQuote_Click(), I get a symbol and timeout from the dialog window, and pass them to _wsProxy1.BeginGetStock() to trigger an asynchronous request. The third parameter of BeginGetStock() initializes a callback, OnGetStock(). I give the last parameter AsyncState, and also a symbol name to retrieve later in the callback as an indicator. As soon as BeginGetStock() is called, you can cancel the request by calling _wsProxy1.Abort() in buttonCancel_Click().

Look at OnGetStock(). In the try block, I first call EndGetStock() to get back a result. Remember, a zero means symbol unrecognized, a negative for timeout, and others are considered as a normal quote.

Notice that the cancellation is caught in WebException. If you call the proxy’s Abort(), the request is terminated with a web exception. The callback still gets called, and the WebException is thrown from EndGetStock(). You can detect this by checking the RequestCanceled status to differentiate other web exceptions like server/network down.

You have to realize that this asynchronous callback runs in another thread implicitly managed by the .NET thread pool. The callback may not be in the context of the thread that calls BeginGetStock(). Be conscious when you try to send commands to a form’s control or access the instance object defined in the class. This is why textBoxResult.Invoke() is called instead of setting textBoxResult.Text directly.

Event-Driven Proxy Model

You may find it a bit complicated when you get a proxy in .NET 2.0 by doing the same in VS 2005. Name this proxy WsClient2.WsTestRef2.Service, and define an object as _wsProxy2. Similar to BeginGetStock() in .NET 1.1, this proxy provides GetStockAsync() as a starter. But you should add an event handler that acts just as the previous callback. The following Listing-3 shows the second client code:

// Listing-3. Using event-driven model

private void buttonGetQuote_Click(object sender, EventArgs e)
{
    textBoxResult.Text = "";
    _tmStart = DateTime.Now.Ticks;

    string symbol = comboBoxSymb.Text.ToUpper();
    int timeout = int.Parse(textBoxTimeout.Text);

    _wsProxy2 = new WsClient2.WsTestRef2.Service();
    _wsProxy2.GetStockCompleted += new 
       GetStockCompletedEventHandler(OnGetStockCompleted);
    _wsProxy2.GetStockAsync(symbol, timeout, symbol);
}

private void OnGetStockCompleted(Object sender, 
             GetStockCompletedEventArgs gca)
{
    string symbol = (string)gca.UserState;
    string result;

    if (gca.Cancelled)  // Call CancelAsync
        result = "Cancelled2, " + gca.UserState;
    else
    if (gca.Error != null)
    {
        WebException webEx = gca.Error as WebException;
        if (webEx !=null && webEx.Status == 
                        WebExceptionStatus.RequestCanceled)
            result = "Cancelled, " + "<" + symbol + ">";
        else
            result = "Exception, " + gca.Error.Message;
    }
    else
    if (gca.Result == 0)
        result = "Invalid, " + "'" + symbol + "'";
    else
    if (gca.Result < 0)
        result = "TimeOut, " + "[" + symbol + "]";
    else
        result = "OK, " + gca.Result.ToString("F");

    textBoxResult.Text = result + ",  (" + 
        (DateTime.Now.Ticks - _tmStart) / 10000 + " ms)";
    _wsProxy2 = null;
}

private void buttonCancel_Click(object sender, EventArgs e)
{
    if (_wsProxy2 != null)
    {
        //_wsProxy2.CancelAsync("<<" + 
        //        comboBoxSymb.Text.ToUpper() + ">>");
        _wsProxy2.Abort();
    }
}

In buttonGetQuote_Click(), I add the OnGetStockCompleted() event handler to _wsProxy2, and call GetStockAsync() to start an asynchronous request. Likewise, the first two parameters are a symbol and timeout, and the third UserState, similar to AsyncState in the BeginGetStock(), will be retrieved later in the handler. You also can cancel the request by a subsequent _wsProxy2.Abort().

What’s new in the OnGetStockCompleted()? Once being called, its second parameter gca of type GetStockCompletedEventArgs (see it in Listing-4 shortly) brings in all completed information. gca contains four properties: the UserState of object type, the Result of double, the Error of Exception, and the Cancelled flag. Compare with the logic in the callback (Listing-2), most parts should be understandable without the need to repeat.

The only trick is pertaining to gca.Cancelled. As you see in Listing-3, I purposely check this flag at the beginning of OnGetStockCompleted() and put another check to the RequestCanceled status from gca.Error. Which one has been caught? Certainly hit is gca.Error, not gca.Cancelled, because calling _wsProxy2.Abort() causes the WebException to be thrown.

What if I want to intercept gca.Cancelled as a canceled flag? Let’s dig deeper to fiddle this proxy a little. To distinguish the cancelled response from gca.Error, I display “Cancelled2” for gca.Cancelled as follows:

Using the Cancelled flag in Client2

What I make use of is the proxy’s CancelAsync() that originally does nothing but call its base one. So in buttonCancel_Click() (in Listing-3), I try to call _wsProxy2.CancelAsync() rather than _wsProxy2.Abort().

Here the Listing-4 illustrates the modified proxy where I added four numbered comments to indicate the changes:

// Listing-4. A modified event-driven proxy 

public partial class Service : SoapHttpClientProtocol 
{
    private System.Threading.SendOrPostCallback 
                         GetStockOperationCompleted;
    private bool useDefaultCredentialsSetExplicitly;

    // 1. Added the control flag _done
    private bool _done = true;

    public Service() { ... }
    public new string Url { ... }
    public new bool UseDefaultCredentials { ... }
    
    public event GetStockCompletedEventHandler GetStockCompleted;

    [System.Web.Services.Protocols.SoapDocumentMethodAttribute(...)]
    public double GetStock(string symbol, int timeout) 
    {
        object[] results = Invoke("GetStock", 
             new object[] {symbol, timeout});
        return ((double)(results[0]));
    }

    ... ... ...
    
    public void GetStockAsync(string symbol, int timeout, object userState) 
    {
        // 2. Initialize _dene - Not done
        _done = false;

        if (GetStockOperationCompleted == null) 
        {
            GetStockOperationCompleted = new 
              System.Threading.SendOrPostCallback(
              OnGetStockOperationCompleted);
        }
        
        this.InvokeAsync("GetStock", new object[] {symbol, timeout}, 
            GetStockOperationCompleted, userState);
    }
    
    private void OnGetStockOperationCompleted(object arg) 
    {
        // 3. When completed without cancelling, fire the event
        if (GetStockCompleted != null && !_done)
        {
            _done = true;
            InvokeCompletedEventArgs invokeArgs = (InvokeCompletedEventArgs)(arg);
            GetStockCompleted(this, 
                   new GetStockCompletedEventArgs(invokeArgs.Results, 
                                                  invokeArgs.Error, 
                                                  invokeArgs.Cancelled, 
                                                  invokeArgs.UserState));
        }
    }
    
    public new void CancelAsync(object userState) 
    {
        // 4. If done, we are not in processing 
        if (_done) return;

        // Cancellation is called. Done and fire the event
        _done = true;
        GetStockCompleted(this,
                   new GetStockCompletedEventArgs(null, 
                                                  null, 
                                                  true, 
                                                  userState));
        // base.CancelAsync(userState);
    }
    
    private bool IsLocalFileSystemWebService(string url) { ... }
}

[System.CodeDom.Compiler.GeneratedCodeAttribute( ... )]
public delegate void GetStockCompletedEventHandler(object sender, 
                     GetStockCompletedEventArgs e);
... ...
public partial class GetStockCompletedEventArgs 
     : System.ComponentModel.AsyncCompletedEventArgs 
{
    private object[] results;
    internal GetStockCompletedEventArgs(object[] results, 
                                        System.Exception exception, 
                                        bool cancelled, 
                                        object userState) : 
            base(exception, cancelled, userState) { ... }
    
    public double Result { get { ... } }
}

For clarity, I omitted most irrelevant areas. First, I define a flag _done in the class and set it to true as no request is in processing. Secondly, in GetStockAsync(), I initialize _done to false (not done). GetStockAsync() registers its own internal callback OnGetStockOperationCompleted() and starts an asynchronous request by InvokeAsync(). Once OnGetStockOperationCompleted() is getting called for a completed request, it fires the GetStockCompleted() event that just invokes our handler OnGetStockCompleted() I added earlier to _wsProxy2.

Then, the third change happens in OnGetStockOperationCompleted(). I add a _done checking to prohibit the firing, in case _done is already set true in CancelAsync(), which is the next - fourth change. As soon as a user calls CancelAsync() to cancel, if the request is in pending (_done is false), I set _done to true and fire the cancelled event - the event sends a GetStockCompletedEventArgs argument with the Cancelled flag set to true.

This exercise could be pretty helpful in understanding how an event-driven proxy works. But I will never suggest such a proxy change in a real production scenario. If the proxy is regenerated later, any code changes will be lost. Hence I recommend using Abort() rather than CancelAsync(), at least at the time of this writing.

Centralized Service Management

With the above implementations, you can start multiple asynchronous calls of different stock symbols and receive the results in one callback or in one event handler. You can receive a quote to the symbol indicated by the AsyncState or the UserState member. Probably, you want to make it a service-based DLL for multiple callers. This also works fine, as long as each call creates its own instance of the service class, containing a copy of the proxy.

While in a remote distributed system, we may need to build a service center to manage the multiple calls to the backend. In this centralized processing, we have to authenticate a user, retrieve data, cache status, etc. The service center may work as a singleton to receive multiple calls and maintain the control exclusively. In this case, it must create another asynchronous mechanism to manage multiple calls. The following picture describes this situation:

A service center managing multiple calls

Now, accessing a web service from clients is considered in two phases. I can build a service center class and export a method GetStock() for clients (as the stock example in context). GetStock() triggers an asynchronous call (Phase 1) to start a thread implicitly. That new thread procedure creates a proxy object to access the web service (Phase 2).

The following Listing-5 illustrates an implementation of the ServiceAsync class using the Begin/End asynchronous method.

// Listing-5. A service class with Begin/End method 

// Define an Aync service class
public class ServiceAsync
{
    // Define the private Async method Delegate in Phase 1
    private delegate GetStockStatus 
            AsyncGetStockDelegate(ref string symbol);
    
    // An alternative event if no callbackProc supplied
    public event OnGetStockResult OnGetStock;

    // Web Service proxy
    private Service _wsProxy1;
    
    // This is a public method for user to call
    public void GetStock(string symbol, OnGetStockResult callbackProc)
    {
        // Create the private Async delegate.
        AsyncGetStockDelegate dlgt = new AsyncGetStockDelegate(GetStock);
        
        callbackData data = null;
        if (callbackProc !=null)
        {
            data = new callbackData();
            data._callbackProc = callbackProc;
        }

        // Initiate the asychronous request.
        IAsyncResult ar = dlgt.BeginInvoke(ref symbol, 
                          new AsyncCallback(AsyncGetStockResult), data);
    }

    // This is a private thread procedure 
    private GetStockStatus GetStock(ref string symbol)
    {
        // Phase 2: Use _wsProxy1 to access Web Service. 
        // Return status in GetStockStatus

        _wsProxy1 = new Service();
        ... ... ...
        symbol = value.ToString("F");
        return GetStockStatus.OK;
    }

    // Callback data structure
    private class callbackData
    {
        public OnGetStockResult _callbackProc;
        // Other data followed to pass and retrieve
    }

    // Async Callback when a request completed in Phase 1 
    private void AsyncGetStockResult(IAsyncResult ar)
    {
        AsyncGetStockDelegate dlgt = 
                (AsyncGetStockDelegate)((AsyncResult)ar).AsyncDelegate;
                
        string result = string.Empty;
        GetStockStatus status = dlgt.EndInvoke(ref result, ar);

        callbackData data = (callbackData)ar.AsyncState;
        if (data != null)
        {
            OnGetStockResult callbackProc = data._callbackProc;
            callbackProc(result, status);    // Call user supplied delegate
        }
        else
        if (OnGetStock != null)
            OnGetStock(result, status);      // If no delegate, fire event
    }
    
    public void Cancel()
    {
        _wsProxy1.Abort();
    }
}

// Define result status
public enum GetStockStatus { OK, Exception, TimeOut, Invalid, Cancelled }

// Define a delegate for an event to fire result
public delegate void OnGetStockResult(string result, 
                             GetStockStatus status);

The first public GetStock() accepts a symbol and a user supplied callback. It then creates a AsyncGetStockDelegate object, dlgt, and prepares the callback data. Once dlgt initiates an asynchronous request, the second private GetStock() is called to perform the task with the web service.

The flexibility for a client to call GetStock() is what I make in dual ways. Look at the internal callback, AsyncGetStockResult(). If the user supplies a callback procedure, I use it to send back the result. If no callback is supplied, I fire the event OnGetStock() to inform the caller of incoming results. Thus, you can do like this:

ServiceAsync sa = new ServiceAsync();
sa.GetStock(symbol, new OnGetStockResult(OnGetStock));

Or nullify the callback by subscribing an event handler:

as.OnGetStock += new OnGetStockResult(OnGetStock);
as.GetStock(symbol, null);

An alternative for a singleton service to manage multiple calls is to spawn a thread for each user’s call. The Listing-6 outlines this design.

// Listing-6. A service class spawning a thread 

// Define a Thread service class
public class ServiceThread
{
    // An event to send result
    public event OnGetStockResult OnGetStock;
    
    // Web Service proxy
    private Service _wsProxy2;
    private string  _symbol;
    private int     _timeout;
    
    // This is a public method for user to call
    public void GetStock(string symbol)
    {
        _symbol = symbol;
        _wsProxy2 = new Service();
        _wsProxy2.GetStockWithTimeoutCompleted += 
            new GetStockWithTimeoutCompletedEventHandler(GetStockCompleted);
            
        Thread thread = new Thread(new ThreadStart(GetStock));
        thread.Start();
    }

    // This is a private thread procedure 
    private void GetStock()
    {
        _wsProxy2.GetStockWithTimeoutAsync(_symbol, _timeout, _symbol);
    }

    // Event handler of GetStockWithTimeoutCompletedEventHandler in .NET 2
    private void GetStockCompleted(Object sender,
                          GetStockWithTimeoutCompletedEventArgs gca)
    {
        // Phase 2: Use _wsProxy to access Web Service. 
        // Based on results in gca, fire the event
        ... ... ...
        OnGetStock(_symbol +" " +gca.Result.ToString("F"), GetStockStatus.OK);
    }
        
    public void Cancel()
    {
        _wsProxy2.Abort();
    }
}

// Define result status
public enum GetStockStatus { OK, Exception, TimeOut, Invalid, Cancelled }

// Define a delegate for an event to fire result
public delegate void OnGetStockResult(string result, 
                             GetStockStatus status);

The first public GetStock() explicitly starts a thread to run the second private GetStock(), which initiates an event-driven asynchronous call with a .NET 2.0 proxy. Whether to choose threading or callback depends on your application usage, resource tradeoff, and how many simultaneous calls are made to your system.

Client-Side Timeout

In my test service (Listing-1), I let the server side process the timeout passed by a client. With regards to the web service proxy, it does have a property, Timeout (inherited from WebClientProtocol), which is just for a synchronous request to complete. In an asynchronous mode, the proxy does not provide a timeout straight, probably since you can use Abort() (also from WebClientProtocol) to cancel a pending request.

Sometimes in an asynchronous design, we wait for a request to complete, and still prefer a timeout, while the server and its proxy don’t supply a timeout method. So, the client side has to deal with the timeout itself. Recalling BeginGetStock(), when it triggers a request, it returns an object r like this:

IAsyncResult r = _weProxy1.BeginGetStock(symbol, null, null);

You should not call r.AsyncWaitHandle.WaitOne(timeout, false), because WaitOne() does not release the current thread until it returns, so that it even blocks the cancellation.

One solution is to set a loop to poll the IsCompleted property of IAsyncResult, simulating a timeout period. Listing-7 shows this approach with a combined checking for the timeout and the cancelled flag.

// Listing-7. Polling to achieve client side timeout (wsasyExp4.txt)

// Example 4. Polling to achieve client side timeout (wsasyExp4.txt)

public GetStockStatus GetStock(ref string symbol)
{
    _cancel = true;
    _weProxy1 = new Service();
    IAsyncResult r = _weProxy1.BeginGetStock(symbol, null, null);    
    
    // Poll here, if _cancel is true Abort
    // Simulating timeout with 10 ms interval.
    int i = 0;
    int n =_timeout/10;

    for (; i < n; i++)
    {
        if (_cancel) 
        {
            symbol = "<" +symbol +">";
            _weProxy1.Abort();
            return GetStockStatus.Cancelled;
        }

        if (r.IsCompleted == true) 
            break;

        Thread.Sleep(10);
    }                

    if (r.IsCompleted == false && i==n)
    {
        symbol = "[" +symbol +"]";
        _weProxy1.Abort();
        return GetStockStatus.TimeOut;
    }

//    if (!r.AsyncWaitHandle.WaitOne(_timeout, true))
//        return GetStockStatus.TimeOut;

    double value;
    try 
    {
        value = _weProxy1.EndGetStock(r);
    }
    catch (Exception e)
    {   
       ... ... ...
    }

    ... ... ... 

    symbol = value.ToString("F");
    return GetStockStatus.OK;
}

public void Cancel()
{
    _cancel = true;
}

I use the flag _cancel in Cancel() instead of directly calling _weProxy1.Abort(). When the loop ends, I can detect the timeout and abort the request to the server. Once r.IsCompleted is set to true, the call is completed with a meaningful value returned from _weProxy1.EndGetStock(r).

The disadvantage of this polling is that the loop can eat up a lot of resources in the CPU cycles. Pay close attention to this weakness in your asynchronous implementation.

Summary

Today, software development is evolving into a service based design from the early object/component oriented model. Asynchronous mechanisms could be used very popularly in a service based system, in XML web servers, .NET remoting, and the Windows Communication Foundation in .NET 3. In this article, I presented several design models, typically asynchronous implementations with callbacks, delegates, events, and threads. Two interesting considerations involved are cancellation and timeout. Each approach presented here has its pros and cons that you should be careful about tradeoff in practice. Although the sample projects are built in C# on .NET 1.1 and 2.0, the basic techniques would be advisable to systems across versions, languages, and platforms.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

About the Author

Zuoliu Ding
Software Developer
United States United States
An Adjunct Faculty and Software Developer in Los Angeles and Orange County, CA
 
* Typical articles published in Dr. Dobb’s Journal and Windows Developer Magazine:

- A Silent Component Update for Internet Explorer
- Silent Application Update
- An MDI-Style Web Browser and Load Spy Monitor
- Implementing Wireless Print for WinNT/Win2K
- Multi-State Checkbox Tree Views
- A Generic Tool Tip Class
- An Easy Way to Add Tool Tips to Any MFC Control

- More from Google...

Comments and Discussions

 
GeneralVery helpful--couple comments Pinmemberscotru215-Aug-10 7:59 
GeneralRe: Very helpful--couple comments PinmemberZuoliu Ding15-Aug-10 10:57 
Generaldataset doesn't seems to return PinmemberK Mothish1-Feb-07 1:29 
GeneralGood article! PinmemberKeting19-Nov-06 19:18 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web03 | 2.8.140415.2 | Last Updated 4 Jul 2006
Article Copyright 2006 by Zuoliu Ding
Everything else Copyright © CodeProject, 1999-2014
Terms of Use
Layout: fixed | fluid