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

WS-Transfer Service for Workflow

, 23 Nov 2006
Rate this:
Please Sign up or sign in to vote.
This article describes a design and implementation of the WF workflow connectivity to the Windows Communication Foundation (WCF) Service for WS-Transfer operation contract.

Contents

Notes

This article was written using:

  • .NET Framework 3.0 (RTM)
  • VS 2005 Extensions for Windows Workflow Foundation (RTM)

Introduction

The Microsoft Windows Workflow Foundation (WF) is part of the Windows platform. It is a core component of the next generation .Net Framework - version 3.0. The WF enables decoupling an application into the business workflows driven by activities. The workflows can be loaded into the host process such as Windows NT Service, IIS, WinForm, Console, etc. and attached to the WF Runtime core by the Hosting layer. This layer provides persistence, tracking, scheduling, transaction and communication support. Of course, there is also a capability for creating an extension - custom hosting service, for instance, adding a custom communication service between the host process and workflow based on the interface/event contract.

The workflows can talk to each other locally or remotely via a communication service using an event/delegate fashion pattern. The message exchange pattern (MEP) is based on the Request and Response events passing application specific data contract. This article describes a design and implementation of the workflow hosting connectivity (plumbing) to the Windows Communication Foundation (WCF) Service for WS-Transfer operation contract. It is an extension of my WS-Transfer for WCF article, where the WCF service is encapsulated into the generic service layer and physical adapter for handling a specific resource. In this case, our resource is a workflow instance of the WF.

Note, the workflows included in this project are for usage demonstration and test purposes only.

Having a WS-* driven Workflow model layer, we can plug the Workflows to the "WS-* Service Bus" like it is shown in the following picture:

The WCF Services, with a configurable workflow adapter, enable creating a logical (distributed) model with encapsulating a business logic into the workflows and activities. Note, the above picture shows services only. The WCF/WF clients can be created in a similar way and easily plugged into the bus. Of course, any "legacy" WS-* service/client can also be connected to the bus using a workflow model layer in a transparent manner.

This article will describe the WS-Transfer Service only, but the concept and the implementation is similar and straightforward for other services such as WS-Eventing, WS-Enum, etc. Let's start with a concept of plumbing two infrastructures such as WCF and WF. In order to understand this pattern, I will assume that you are familiar with my article WS-Transfer for WCF and you have at least some experience (knowledge) with Microsoft WCF and WF Technologies.

Concept and design implementation

The concept of the implementation is based on encapsulating a WCF Service layer driven by WS-Transfer stack from the resource (physical) layer by using the Indigo paradigm. In our case the physical resource is represented by a workflow instance of the WF.

The following picture shows the highest level of the connectivity between the WCF and WF:

As you can see, the above model has been decoupled into two layers such as communication and business processing. Both layers are independent and have their own technology. The service layer is responsible for sending and receiving messages with a WS-* stack (in our case WS-Transfer) to and from the specific resource. This is a generic service layer with capability of the service behavior extension made programmatically or administratively via a config file. The other layer - resource layer, is a business specific layer represented by a business workflow model. Both layers can run in their own behavior, synchronously or asynchronously based on the application needs.

The following part of the config file shows the service behavior extension for Memory Storage Workflow operations:

<configuration>
 <system.serviceModel>

  <behaviors>
   <serviceBehaviors>
    <behavior name="WxfServiceExtension" >
      <wsTransferAdapter
        name="PublicStorage"
        type="RKiss.WSTransfer.Adapters.WFAdapter, WFAdapter"
        TransactionScopeRequired="true"
        TransactionAutoComplete="true"
        Topic="Imaging"
        WorkflowTypeCreate=
            "WFLibTest.MemoryStorage.WorkflowCreate, WFLibTest"
        WorkflowTypeGet="WFLibTest.MemoryStorage.WorkflowGet, WFLibTest"
        WorkflowTypePut="WFLibTest.MemoryStorage.WorkflowPut, WFLibTest"
        WorkflowTypeDelete=
            "WFLibTest.MemoryStorage.WorkflowDelete, WFLibTest" />
    </behavior>
   </serviceBehaviors>
  </behaviors>

  <extensions>
   <behaviorExtensions>
    <add name="wsTransferAdapter"
         type="RKiss.WSTransfer.ServiceAdapterBehaviorElement, WSTransfer,
               Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/>
   </behaviorExtensions>
  </extensions>

 </system.serviceModel>
</configuration>

In the above snippet, each WS-Transfer operation (Create, Get, Put and Delete) has been configured for own workflow transactional processing related to the same Topic="Imaging". The name of the storage is "PublicStorage". The plumbing adapter - a connectivity between the WCF Service and WF is implemented in the WFAdapter assembly.

The WS-Transfer adapter for workflow processing has built-in the following properties:

Property Name Default value Comment
name null name of the adapter
type null type of the adapter class
TransactionScopeRequired "false" transactional scope is required
TransactionAutoComplete "true" transactional scope complete
Topic null topic of the resource
WorkflowType null type of the generic workflow class
WorkflowTypeCreate null type of the workflow class for Create resource
WorkflowTypeGet null type of the workflow class for Get resource
WorkflowTypePut null type of the workflow class for Put resource
WorkflowTypeDelete null type of the workflow class for Delete resource

Note, the collection of the properties is passed into the workflow instance during its initializing phase.

Hosting WF Runtime Services

The WF Runtime Services can be attached (must be only one per appDomain) to the WCF hosting process using the ServiceHost Extension mechanism. The following code snippet shows an example for WSTransferService hosted by the Console program:

using (System.ServiceModel.ServiceHost host =
    new System.ServiceModel.ServiceHost(typeof(WSTransferService)))
{
    // Service extension for WF
    WFServiceHostExtension extension =
      new WFServiceHostExtension("WorkflowRuntimeConfig", 
                        "LocalServicesConfig");

    // Add the Extension to the ServiceHost collection
    host.Extensions.Add(extension);

    host.Open();

    Console.WriteLine("Press any key to stop server...");
    Console.ReadLine();
    host.Close();
}

where, the WFServiceHostExtension is the host extension class that is implementing an IExtension interface for WF Runtime Services:

void IExtension<ServiceHostBase>.Attach(ServiceHostBase owner)
{
  // add services from config file
  if(_workflowServicesConfig == null)
    _workflowRuntime = new WorkflowRuntime();
  else
    _workflowRuntime = new WorkflowRuntime(_workflowServicesConfig);

  // not handled exception
  _workflowRuntime.ServicesExceptionNotHandled +=
    new EventHandler<SERVICESEXCEPTIONNOTHANDLEDEVENTARGS>
            (workflowRuntime_ServicesExceptionNotHandled);

  // external services
  _exchangeServices = 
    _workflowRuntime.GetService<EXTERNALDATAEXCHANGESERVICE>();
  if (_exchangeServices == null)
  {
    if (_localServicesConfig == null)
     _exchangeServices = new ExternalDataExchangeService();
    else
     _exchangeServices = new ExternalDataExchangeService(_localServicesConfig);

    // add service for exchange data
    _workflowRuntime.AddService(_exchangeServices);
  }

  // Start all services registered in this container
  _workflowRuntime.StartRuntime();
}
void IExtension<ServiceHostBase>.Detach(ServiceHostBase owner)
{
    _workflowRuntime.StopRuntime();
}

Note, the "WorkflowRuntimeConfig" is the name of the config section, where WF Runtime Services are located and required for specific workflow processing, for instance: System.Workflow.Runtime.Hosting.ManualWorkflowSchedulerService allows to run a workflow activities synchronously within the same thread as WCF service operation.

The following config snippet shows the workflow config sections for adding runtime and local services:

<configSections>
 <section name="WorkflowRuntimeConfig"
    type="System.Workflow.Runtime.Configuration.WorkflowRuntimeSection, 
        System.Workflow.Runtime,
         Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
  <section name="LocalServicesConfig"
    type="System.Workflow.Activities.ExternalDataExchangeServiceSection, 
        System.Workflow.Activities,
         Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
</configSections>

<WorkflowRuntimeConfig >
  <Services>
   <add type="System.Workflow.Runtime.Hosting.ManualWorkflowSchedulerService, 
            System.Workflow.Runtime,
             Version=3.0.00000.0, Culture=neutral, 
            PublicKeyToken=31bf3856ad364e35"/>
  </Services>
</WorkflowRuntimeConfig>

<LocalServicesConfig>
  <Services>
   <add type="RKiss.WSTransfer.Adapters.WSTransferLocalService, WFAdapter,
              Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
  </Services>
</LocalServicesConfig>

WF Local Communication Services (LCS)

The Local Communication Services represent an implementation of the communication interfaces registered with the WorkflowRuntime that enable data exchange between the workflow and host process layer based on the .NET event/delegate fashion pattern. The interface must be decorated by ExternalDataExchange attribute in order to be found by the WF Runtime to provide correct event intercepting.

Let's look at this layer in detail using a Reflector. In order to bring the custom LCS into the plumbing business, we have to process its attaching to the WorkflowRuntime using the built-in ExternalDataExchangeService service.

The following code snippet shows the implementation of the AddService method. Note, the inline comments have been added after Reflector:

public void AddService(object service)
{
    // validation
    if (service == null)
    {
        throw new ArgumentNullException("service");
    }
    if (base.Runtime == null)
    {
        throw new InvalidOperationException
            ("Error_ExternalRuntimeContainerNotFound");
    }

    // plumbing of the local service to the workflow instance 
    this.InterceptService(service, true);

    // add local service into runtime collection 
    base.Runtime.AddService(service);
}

The actual plumbing (the event subscribing) is done in the InterceptService method. The following code snippet was captured by the reflector and manually commented lines shows its implementation:

private void InterceptService(object service, bool add)
{
  bool flag1 = false;
  Type[] typeArray1 = service.GetType().GetInterfaces();

  // walk through all interfaces and select ones with 
  // ExternalDataExchange attributed
  Type[] typeArray2 = typeArray1;
  for (int num2 = 0; num2 < typeArray2.Length; num2++)
  {
    Type type1 = typeArray2[num2];
    object[] objArray1 =
        type1.GetCustomAttributes
        (typeof(ExternalDataExchangeAttribute), false);
    if (objArray1.Length != 0)
    {
      // get all events for this interface contract
      flag1 = true;
      EventInfo[] infoArray1 = type1.GetEvents();
      if (infoArray1 != null)
      {
        // walk through all events and assign 
        // WorkflowMessageEventHandler handler
        EventInfo[] infoArray2 = infoArray1;
        for (int num3 = 0; num3 < infoArray2.Length; num3++)
        {
          EventInfo info1 = infoArray2[num3];
          WorkflowMessageEventHandler handler1 = null;
          int num1 = type1.GetHashCode() ^ info1.Name.GetHashCode();
          lock (this.sync)
          {
            // check collection for this event
            if (!this.eventHandlers.ContainsKey(num1))
            {
                  // create event handler to workflow
                  handler1 = new WorkflowMessageEventHandler
                    (type1,info1,base.Runtime);

                  // add to the collection
                  this.eventHandlers.Add(num1, handler1);
            }
            else
            {
                  // use handler from the collection
                  handler1 = this.eventHandlers[num1];
            }
          }

          // add delegate of the event handler to the local service event
          this.AddRemove(service, handler1.Delegate, add, info1.Name);
        }
      }
    }
  }
  if (!flag1)
  {
        throw new InvalidOperationException("Error_ServiceMissing ...");
  }
}

The concept of the interceptor is based on capturing all correct events in the LCS and subscribing them. The LCS communication interface contract is found by a custom ExternalDataExchange attribute. For these selected interfaces, the interceptor is walking through each event. The interceptor creates a WorkflowMessageEventHandler object and them subscribes handler's delegate to the event. At this time, the event/delegate plumbing is done and ready for use in the runtime.

As mentioned above, the WF Runtime interceptor is responsible for injecting a communication layer for abstracting and delivering a source event to the workflow event sink such as HandleExternalEvent Activity. The following picture shows the major sequences between the Host and Workflow instance:

The host (in our case it is a WCF service - adapter) raises an event, for instance, CreateRequest with application specific arguments and its delegate will invoke an event handler on the WorkflowMessageEventhandler object. The handler is asking for a workflow instance from WorkflowRuntime based on the event source parameter. The event source parameter is a workflow instance id.

After that, the handler will create a MethodMesage and invoke an EnqueueItem method on the workflow instance. This method will process an event enqueuing into the proper queue given by the WorkflowQueuingService. For this job, the workflow instance will load a WorkflowExecutor from the WorkflowRuntime. Once the event (message) is stored in a queue, it can be dequeued by a subscribed listener such as HandleExternalEvent activity.

The response method from the Workflow is easier than the above request method. The WF has a built-in CallExternalMethod activity, which will raise an event on the communication interface in the LCS. The host application needs to implement an event handler for receiving response arguments from the workflow using a standard event/delegate fashion pattern.

WCF/WF Plumbing

The above description shows a communication mechanism to/from a workflow instance. We need a somewhat similar layer on the WCF service side. The following picture shows a plumbing model of the workflow and service:

As you can see, the above plumbing requires to plug-in a specific adapter for a service behavior extension. In our case this adapter is a WS-Transfer oriented with a capability of raising an event to the workflow instance and waiting for its response in the blocking manner. The picture shows an example of the Sequential Workflow with an input activity (receiver of the event), application specific activity (storing resource into the memory) and output activity to send a response message to the Adapter. The workflow can be extended for additional activities based on the business needs. Note, the HandleExternalEvent and CallExternalMethod activities are required for proper communication with the service adapter based on the Request/Response message exchange pattern. Plumbing all layers together, the WS-Transfer message ends in the configurable workflow event sink where it is dispatched. Based on the workflow activity, the result is passed back to the service using an invoking activity. Note, the adapter is waiting for this result in the blocking manner.

Implementation

It is necessary to create the communication interfaces for message exchange between the adapter and workflow as a first step of the implementation. We can have a common event source (Request) for all WS-Transfer interface request methods. Please see the following code snippet:

  [ExternalDataExchange]
  public interface IWSTransferLocalServiceIn
  {
      // to workflow
      event EventHandler<RequestEventArgs> Request;
  }

The next step is to create an application specific EventArgs object for passing a request parameters to the event sink, based on the delegate signature such as a source object and EventArgs. The following code snippet shows this derived class:

  [Serializable]
  public class RequestEventArgs : ExternalDataEventArgs
  {
      private ResourceDescriptor _rd;
      private object _resource;
      public RequestEventArgs
        (Guid InstanceId, ResourceDescriptor rd, object resource) :
        base(InstanceId)
      {
          this._resource = resource;
          this._rd = rd;
      }
      public object Resource
      {
          get { return _resource; }
          set { _resource = value; }
      }
      public ResourceDescriptor ResourceDescriptor
      {
          get { return _rd; }
          set { _rd = value; }
      }
  }

In the above specific EventArgs object, we are passing a resource descriptor and resource body needed for the resource factory implemented in the workflow process.

The response interface contract from a workflow has very simple operation, just a method for passing response parameters. This method is called by the CallExternalMethod workflow activity.

  [ExternalDataExchange]
  public interface IWSTransferLocalServiceOut
  {
      // from workflow
      void RaiseResponseEvent(Guid workflowInstanceId, 
                ResourceDescriptor rd, object resource);
  }

Now, we can create a Local Service, let's called it a WSTransferLocalService. This class represents a communication layer to the workflow from an external "host" layer. Its implementation is straightforward and there is a simple logic for raising an event for request or response. Note, the firing an event to the workflow has an option based on the ManualWorkflowScheduler Service allowing to run workflow and adapter within the same thread. The following code snippet shows an implementation of the Local Service for a WS-Transfer adapter:

 public class WSTransferLocalService :
  IWSTransferLocalServiceIn, IWSTransferLocalServiceOut
 {
    private WorkflowRuntime _workflowRuntime;
    private ManualWorkflowSchedulerService _scheduler;
    public WorkflowRuntime WorkflowRuntime 
                { get { return _workflowRuntime; } }
    public ManualWorkflowSchedulerService Scheduler 
                { get { return _scheduler; } }

    public WSTransferLocalService(WorkflowRuntime workflowRuntime)
    {
      if (workflowRuntime == null)
         throw new ArgumentNullException("workflowRuntime");

      // the workflow runtime core
      _workflowRuntime = workflowRuntime;

      // manual workflow scheduler
      _scheduler = 
        _workflowRuntime.GetService<MANUALWORKFLOWSCHEDULERSERVICE>();
    }

    #region IWSTransferLocalServiceIn Members - to workflow
    public event EventHandler<RequestEventArgs> Request;
    public void RaiseRequestEvent
            (Guid workflowInstanceId, ResourceDescriptor rd)
    {
      RaiseRequestEvent(workflowInstanceId, rd, null);
    }
    public void RaiseRequestEvent(Guid workflowInstanceId,
      ResourceDescriptor rd, object resource)
    {
        // Create the EventArgs for this event
        RequestEventArgs e =
          new RequestEventArgs(workflowInstanceId, rd, resource);

        // Raise the event
        if (this.Request != null)
        {
            if (this.Scheduler == null)
            {
                this.Request(null, e);
            }
            else
            {
                this.Scheduler.RunWorkflow(workflowInstanceId);
                this.Request(null, e);
                this.Scheduler.RunWorkflow(workflowInstanceId);
            }
        }
    }
    #endregion

    #region IWSTransferLocalServiceOut Members - from workflow
    public event EventHandler<ResponseEventArgs> Response;
    public void RaiseResponseEvent(Guid workflowInstanceId,
      ResourceDescriptor rd, object resource)
    {
        // Create the EventArgs for this event
        ResponseEventArgs e =
          new ResponseEventArgs(workflowInstanceId, rd, resource);

        // Raise the event
        if (this.Response != null)
        {
            this.Response(null, e);
        }
    }
    #endregion
 }

That is all from the workflow communication layer. Lets look at the other side such as the WCF service. As I mentioned in my article WS-Transfer for WCF, the layer for handling a physical resource (factory and operation) is encapsulated into the adapter. Each WS-Transfer message will invoke a specific method in the adapter and return back a response to the service layer for mapping to the WS-Transfer message. The logic implemented in the method is straightforward and lightweight and provides the following:

  • Creating a specific workflow based on the config file
  • Registering an event sink for workflow response
  • Raising a request event to the workflow
  • Waiting for a response from the workflow
  • Returning result to the service layer

Note, the above steps are necessary for Request/Response message exchange pattern. In the case of the fire and forget event, we need to send only the request event to the workflow sink.

The following code snippet shows an implementation of the Get method for passing a message into the WorkflowTypeGet:

 public object Get(MessageHeaders resourceIdentifier)
 {
    // transaction context
    base.Properties["TransactionDependentClone"] = 
        Transaction.Current == null ?
        null : Transaction.Current.DependentClone
        (DependentCloneOption.BlockCommitUntilComplete);

    // create workflow
    WorkflowInstance wi = CreateWorkflow(Defaults.Keys.WorkflowTypeGet);

    // register response event
    AsyncResponseFromLocalService ar =
      new AsyncResponseFromLocalService
        (this.wstransferLocalService, wi.InstanceId);

    // resource identifier
    ResourceDescriptor rd = GetResourceIdentifier(resourceIdentifier);

    // fire request
    this.wstransferLocalService.RaiseRequestEvent(wi.InstanceId, rd);

    // processing
    ar.WaitForResponse(workflowTimeoutInSec);

    // result
    object result = ar.Response.Resource;

    // convert xml text formatted resource back to the XmlElement
    XmlDocument doc = new XmlDocument();
    doc.LoadXml(result as string);
    result = doc.DocumentElement;

    return result;
 }

In the sync Request/Response message exchange, the response is waiting for the message in the blocking fashion pattern. The following code snippet shows an internal class for this purpose. Calling a WaitForResponse method, the thread will waiting for an event from the workflow invoking a localservice_FireResponse method. This method will store a response message and then it will set a synchronization object to unblock a thread. If the workflow threw an exception, this class will re-throw it.

  internal class AsyncResponseFromLocalService
  {
    AutoResetEvent _waitForResponse = new AutoResetEvent(false);
    WorkflowTerminatedEventArgs _e;
    WSTransferLocalService _localservice;
    ResponseEventArgs _response;
    Guid _istanceId;
    public AsyncResponseFromLocalService
        (WSTransferLocalService localservice, Guid instanceid)
    {
        _istanceId = instanceid;
        _localservice = localservice;

        // response handler
        _localservice.Response +=
          new EventHandler<ResponseEventArgs>(localservice_FireResponse);

        // exception handler
        _localservice.WorkflowRuntime.WorkflowTerminated +=
          new EventHandler<WorkflowTerminatedEventArgs>(OnWorkflowTerminated);
    }
    public void OnWorkflowTerminated
        (object sender, WorkflowTerminatedEventArgs e)
    {
        if (_istanceId == e.WorkflowInstance.InstanceId)
        {
            this._e = e;
            _waitForResponse.Set();
            _localservice.WorkflowRuntime.WorkflowTerminated -=
              new EventHandler<WorkflowTerminatedEventArgs>
                        (OnWorkflowTerminated);
        }
    }
    public void WaitForResponse(int secondsTimeOut)
    {
        bool retval = _waitForResponse.WaitOne(secondsTimeOut * 1000, false);
        if (_e != null)
            throw this._e.Exception;
        if (retval == false)
            throw new FaultException("The workflow timeout expired");
    }
    public ResponseEventArgs Response
    {
        get { return _response; }
    }
    public WorkflowTerminatedEventArgs WorkflowTerminatedEventArgs
    {
        get { return _e; }
    }
    public void localservice_FireResponse(object sender, ResponseEventArgs e)
    {
        if (_istanceId == e.InstanceId)
        {
            _response = e;
            _waitForResponse.Set();
            _localservice.Response -=
              new EventHandler<ResponseEventArgs>(localservice_FireResponse);
        }
    }
  }

Test

The complete solution has been created and decoupled into small projects. The following picture shows its layout:

I made a copy of the WSTransfer folder from my previous article WS-Transfer for WCF and plugged-into the solution making one consistent package. Then, I created a folder WFAdapter for the implementation of the connectivity between the WCF and Workflow. Attaching Workflow to the host process is implemented in the fully reusable assembly via WFServiceHostExtension. The application specific Workflows are located in the WSTransferWorkflowLibrary. Note, this is a test library and it must be created for the specific resource. I created MemoryStorage Workflows for demonstration purpose only.

Finally, we need a test client and service, therefore I created a separate folder for this project. In addition, I added my Logger, based on the message interceptor with publishing message on the host console programs.

Of course, in order to build this solution, we have to install the WF extension.

After downloading the solution and its compilation, we are ready for testing. The service host program must be started first before the clientApplication console program. The following screenshot will be produced during the testing. The test is very simple, creating a resource in the MemoryStorage handled by activity in the Workflow and then the client calls an operation for get, put, delete, etc. The Logger will display WS-Transfer conversation via WFAdapter and Workflow.

Conclusion

In this article, I have described how to plumb the WCF and Workflow for WS-Transfer Service. Encapsulating a business logic into the workflow activities enables us to create a logical business model where connectivity is full transparent based on the deployment schema. The WCF Transfer Service with a WFAdapter unifies communications between the workflows based on the WS-Transfer spec. Based on this concept, we can build other WS-* spec driven workflow services and plug them into the "Service Bus" and take advantage of the SOA.

License

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

About the Author

Roman Kiss
Software Developer (Senior)
United States United States
No Biography provided

Comments and Discussions

 
GeneralReally cool work PinmemberMartin Bohring23-Nov-06 20:28 
GeneralNew version (patch) has been uploaded PinmemberRoman Kiss22-Nov-06 19:25 
GeneralCannot open Lib project VS 2005 PinmemberJian123456716-May-06 5:13 
GeneralRe: Cannot open Lib project VS 2005 PinmemberRoman Kiss16-May-06 5:29 
GeneralRe: Cannot open Lib project VS 2005 PinmemberJian123456716-May-06 5:55 
GeneralNice! Pinmemberoaix12-May-06 18:16 
GeneralRe: Nice! PinmemberRoman Kiss13-May-06 10:05 
GeneralRe: Nice! Pinmemberoaix13-May-06 18:49 
GeneralReal-World Examples PinmemberJimmy Seow8-May-06 20:02 
GeneralRe: Real-World Examples PinmemberRoman Kiss9-May-06 12:08 
GeneralInteresting but seems convoluted PinmemberJim Crafton8-May-06 4:20 
GeneralRe: Interesting but seems convoluted PinmemberRoman Kiss8-May-06 10:22 

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
Web02 | 2.8.140721.1 | Last Updated 23 Nov 2006
Article Copyright 2006 by Roman Kiss
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid