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

Using WSE-DIME for Remoting over Internet.

, 6 Jan 2003
Rate this:
Please Sign up or sign in to vote.
This article describes a design and implementation of the Remoting over Internet using the Advanced Web Services Enhancements - DIME technology. This solution allows to flow the binary formatted Remoting Messages through the Web Server included uploading and downloading any types of the attachments.
<!-- Download Links -->

Contents

Introduction
Usage
Concept and Design
Implementation
Test
Conclusion

Introduction

This article describes a design and implementation of the Remoting over Internet using the Web Services Enhancements - DIME feature as a mechanism to flow the remoting binary messages between the server and client sides. The concept of the Remoting over Internet has been described in my previously article [1] based on the remoting custom channel (client side) and Web Service as a gateway (server side) to convert messages into the remoting infrastructure using the base64 encoded/decoded SOAP formatted messages. This conversion has been necessary (included its performance penalty) to use when the messages crossing the firewall. Using the recently released the Microsoft Web Services Enhancements 1.0 - DIME technology (Direct Internet Message Encapsulation) allows to eliminate this performance overhead and creating a "binary through" between the client and server remoting infrastructures over the web server. This article shows in details how to use the DIME Attachment for Remoting over Internet included uploading/downloading binary files. As I mentioned early, this article is a update of the Remoting over Internet using the newest Microsoft technology - WSE, so I will be concentrated only for parts related with the DIME usage. Basically, this update will allow to use a custom remoting channel for the following features:

  • binary formatted remoting over Internet
  • upload binary files to the remote object over Internet
  • download binary files from remote object over Internet
  • chaining remoting channels (forwarding messages to the different channels)

I am assuming that you have a knowledge of the .Net Remoting and Web Service included WSE -DIME.

Usage

Consuming a remote object over Internet using the Web Service Gateway is very straightforward and full transparently and it requires to install the following assemblies into the GAC on the both sides such as client and server:

The Web Server requires to install the following:

  •  WebServiceRemoting, this is a Web Service (gateway) to listen an incoming remoting message from the client side and forward it to the local remoting infrastructure (incoming message).

Note that the remote object can be hosted by any process and using the chaining channel feature the remoting message can be re-routed to the properly channel published by its host process, for instance, Tcp channel of the windows service.

Configurations

The WSDimeChannel can be configured using the standard and custom properties:

  • id specifies a unique name of the channel that can be used when referring to it (e.g. id="wsdime")
  • type is a full assembly name of the channel that will be loaded
  • ref is the channel template being referenced (e.g. ref="wsdime")
  • name specifies the name of the channel. Each channel has a unique name which is used for properly message routing. Default name is wsdime.
  • priority specifies a preference for the channel. The default value is 1. Higher numbers mean higher priority. So the messages are queued based on this number.
  • ssl specifies a bool value of the security communication protocol such as http or https. The default value is false for http.

machine.config

This file contains common - machine driven configurations. Inserting the following (wsdime) custom channel into the <channels> remoting section will simplify its referencing.

<system.runtime.remoting>
 <application>
 	...
 </application>
 <channels>
 	...
  <channel id="wsdime" type="RKiss.WebServiceRemoting.Sender, WSDimeChannel,
           Version=1.0.0.0, Culture=neutral, PublicKeyToken=a850c3d2d71811e0" />
 </channels>
 <channelSinkProviders>
 	...
 </channelSinkProviders>
</system.runtime.remoting>

server.exe.config

This is a standard configuration file of the server host process. There is no special requirement related to our custom channel. The following example configures a well-known remote object (RemoteObject) on the tcp channel, port 8222.

<configuration>
 <system.runtime.remoting>
  <application >
   <service>
    <wellknown mode="SingleCall" type="MyRemoteObject.RemoteObject, 
               MyRemoteObject, Version=1.0.0.0, 
               Culture=neutral, PulicKeyToken=212418570a471efb" 
               objectUri="endpoint" />
   </service>
   <channels>
    <channel ref="tcp" port="8222" />
   </channels>
  </application>
 </system.runtime.remoting>
</configuration>

client.exe.config

This is an example of the client config file to register our Custom Remoting Channel. The host process has been configured for tcp and wsdime outgoing channels.

<configuration>
 <system.runtime.remoting>
  <application>
   <channels>
    <channel ref="tcp" />
    <channel ref="wsdime" ssl="false" desc="remoting over internet" />
   </channels>
  </application>
 </system.runtime.remoting>
</configuration>

web.config

This is a WebServiceRemoting config file. The following snippet is its part. The remoting section shows a publishing example of the remote object hosted by the Web Server with two outgoing channels - tcp and wsdime. Note that the length of the attachments has been configured for 128MB.

<!-- Remoting part -->
<system.runtime.remoting>
 <application >
  <service>
   <wellknown mode="SingleCall" 
      type="MyRemoteObject.RemoteObject, MyRemoteObject, 
            Version=1.0.0.0, Culture=neutral, 
            PulicKeyToken=212418570a471efb" objectUri="endpoint" />
  </service>
 <channels>
  <channel ref="tcp" />
  <channel ref="wsdime" ssl="false" desc="remoting over internet" />
 </channels>
 </application>
</system.runtime.remoting>

<!-- WSE-DIME Attachments part -->
<system.web>
 <webServices>
  <soapExtensionTypes>
   <add type="Microsoft.Web.Services.WebServicesExtension, 
        Microsoft.Web.Services, 
        Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" 
        priority="1" group="0"/>
  </soapExtensionTypes>
 </webServices>

<!-- set maximum length of the Http Request (128MB) -->
<httpRuntime maxRequestLength="128000" />

Activating a remote object

The well-known remote object (WKO) is activated by its consumer using the GetObject method mechanism. The client proxy is created based on the Interface contract metadata assembly installed in the GAC (see an argument interfaceType). The remoting channel is selected by the objectUrl argument. The url address in this solution has two parts :

  • connectivity to the Web Service Gateway over Internet
  • connectivity to the Remote object over Intranet (within the Web Service gateway)

 Between the primary and secondary addresses is a semicolon delimiter (see the following code snippet):

 string objectUrl = 
@"wsdime://localhost/WebServiceRemoting/Service.asmx; " + 
@"tcp://localhost:8222/endpoint"; 

The delimiter indicates a request to forward the Remoting Messages to the next remoting channel (chained channel) until the target channel is reached.

Note that the wsdime custom remoting channel will trim this primary address and forward only its secondary part. In this solution, the objectUrl represents a physical path of the logical connectivity between the consumer and remote object regardless of how many channels are needed. In this example, the Web Service gateway resides on the localhost and it should be replaced by the real machine name.

Finally, the following code snippet shows an activation of the remote object based on its published interface contract (for instance: IWSDimeRemoting):

 // activate a remote object
 Type interfaceType = typeof(IWSDimeRemoting);
 string objectUrl = @"wsdime://localhost/WebServiceRemoting/Service.asmx; " + 
                    @"tcp://localhost:8222/endpoint";
 IWSDimeRemoting ro = (IWSDimeRemoting)Activator.GetObject(interfaceType, 
                                                            objectUrl);

Now, the client can invoked the remote method using the remote object transparent proxy (ro).  Note that the interfaceType and objectUrl in the above example are hard-coded (programmatically setup), but they can be configured administratively in the config file using the application key/value design pattern.

Upload/Download binary files

The Upload/Download binary files between the remote object and its consumer over Internet is based on the CallContext object. This object flows through the Remoting infrastructure in bi-directional manner within the IMessage stream. For this purpose the special object AttachmentTicket has been created and packaged in the separate assembly WSDimeRemotingInterface. Using the AttachmentTicket class does the upload/download process easy at the both sides as it is shown in the following code snippet:

//AttachmentTicket - Client side
AttachmentTicket at = new AttachmentTicket(Guid.NewGuid().ToString(), 
                                           "senderName"); //create ticket

//upload test
at.Attach("UploadFile", "FileToUpload.bin");
//AttachmentTicket - Server side
AttachmentTicket at = new AttachmentTicket();    //pick-up ticket
if(at.Items.Contains("UploadFile"))
{
   int filesize = at.Detach("UploadFile", "UploadedFile.bin");
}

The client side requires initiating the AttachmentTicket using the request constructor and attaching the uploaded file with its unique logical name. On the other side - server, the uploaded file is detached from the current AttachmentTicket (located in the IMessage) by its unique name.

The download files works in the same manner. In this case the sender side (server) is attaching a file to the AttachmentTicket object.

Note that the AttachmentTicket class has been derived from the ILogicalThreadAffinative and its implementation is not depended from the WSE-DIME technology.

Concept and Design

Concept of the Remoting over Internet is based on dispatching a remoting message over Internet using the Web Services Enhancements - DIME functions as a transport layer. The following picture shows this solution:

Client activates a remote WKO to obtain its transparent proxy. During this process the custom remoting channel (wsdime) is initiated and inserted into the client channel sink chain. Invoking a method on this proxy, the IMessage is created which it represents a runtime image of the method call at the client side. This IMessage is passed into the channel sink. In our solution this is a custom client (sender) channel - wsdime. This channel sink has a responsibility to attach/detach the IMessage stream to/from the SOAP DIME Message.

The Server side is represented by the WebServiceRemoting web service. This is a Gateway to the Remoting infrastructure using a simple WebMethod without any arguments to pass the DIME attachment. Its responsibility is to detach, dispatch and attach the IMessage stream. The Dispatcher is parsing the object url address to decide where IMessage is going to be targeted. There are two kinds of the ways: forwarding the IMessage to either the current channel endpoint or the next channel.

As you can see above flowchart, there is no necessary to make any stream/text/stream conversions between the WebMethod and its consumer. The DIME technology does all magic glue for flowing the binary formatted IMessage over Internet.

Direct Internet Message Encapsulation (DIME)

This is a specification of the multiple binary records with SOAP messages. It allows to package any kind of data including images files, SOAP messages or DIME messages into the records outside of the SOAP envelop within the standard transport protocols such as HTTP, TCP and SMTP. Using the same transmission design pattern, the SOAP DIME message is sent to the Web Server, where can be easy targeted. DIME specifies an efficient encapsulation mechanism - method for attachments to SOAP messages.

Based on the DIME specification, the binary formatted remoting IMessage can be easy packaged into the SOAP DIME message and flowed through the Web Server. The Web Server has a capability to identify what is the SOAP message and what is an attachment. The other word, the DIME is used as a bridge between the heterogeneous infrastructures using the loosely coupled design pattern.

In my solution there is only one attachment such as binary formatted IMessage stream in the DIME package. Note that the IMessage stream can contained a CallContext object as a "vehicle" of the binary images needed for upload/download process. This mechanism is full transparently to the remoting infrastructure.

The following code snippets show an advanced feature of the WSE - DIME Attachments at the both sides: 

//DIME Request - client side
DimeAttachment reqDA = new DimeAttachment("text/plain", 
                                      TypeFormatEnum.MediaType, reqstream);
webservice.RequestSoapContext.Attachments.Clear();
webservice.RequestSoapContext.Attachments.Add(reqDA);

//DIME Request - Server side
Stream stream = HttpSoapContext.RequestContext.Attachments[0].Stream;

Implementation

The implementation is divided into the following assemblies (CustomChannel, WebService and Interface):

WSDimeChannel

This is a Remoting Custom Client Channel assembly to process an outgoing remoting message over Internet. The following code snippet shows a major function of the Sender channel:

// IMessageSink (MethodCall)
public virtual IMessage SyncProcessMessage(IMessage msgReq)
{ 
   IMessage msgRsp = null;
   Service webservice = null;

   try 
   {
      //ws proxy
      webservice = new Service(); 
      webservice.Url = m_outpath;

      // workaround! 
      msgReq.Properties[OBJECTURI] = m_ObjectUri;

      // serialize IMessage
      BinaryFormatter bf = new BinaryFormatter();
      MemoryStream reqstream = new MemoryStream();
      bf.Serialize(reqstream, msgReq);
      reqstream.Position = 0;

      //DIME Request
      DimeAttachment reqDA = new DimeAttachment("text/plain",  
                                       TypeFormatEnum.MediaType, reqstream);
      webservice.RequestSoapContext.Attachments.Clear();
      webservice.RequestSoapContext.Attachments.Add(reqDA);

      // call Web Service
      webservice.SyncProcessMessage();
      reqstream.Close();

      //DIME Response
      if(webservice.ResponseSoapContext.Attachments.Count == 0)
         throw new Exception("Missing the Remoting DIME Message.");

      DimeAttachment rspDA = webservice.ResponseSoapContext.Attachments[0];

      //serializing the stream to the IMessage 
      msgRsp = (IMessage)bf.Deserialize(rspDA.Stream);

      //clean up
      rspDA.Stream.Close();

      //work around to bring back the following properties
      msgRsp.Properties["__Uri"] = msgReq.Properties["__Uri"];
   }
   catch(Exception ex) 
   {
      Trace.WriteLine(string.Format("Client:SyncProcessMessage error = {0}", 
                        ex.Message));
      msgRsp = new ReturnMessage(ex, (IMethodCallMessage)msgReq);
   }
   finally 
   {
      if(webservice != null)
         webservice.Dispose();
   }

   return msgRsp;
}

The binary serialized IMessage is attached to the RequestSoapMessage before invoking the WebMethod using the DIME design pattern. The same mechanism is used also for a Response message.

WebServiceRemoting

This is a Web Service gateway to pick-up a DIME Message. The remoting  IMethodCallMessage can be obtained after detaching and de-serializing a binary attachment from HttpSoapContext.RequestContext. To forward a remoting message to the target endpoint is done by calling the helper function Dispatcher.

The response IMethodReturnMessage from the target endpoint is returned back to the client side using the same manner - the DIME attachment via the HttpSoapContext.ResponseContext object.

The following code snippet shows an implementation of the WebMethod to process the Remoting Request/Response messages:

[WebMethod]
public void SyncProcessMessage()
{
   IMessage iMsgRsp = null;
   IMessage iMsgReq = null;
   BinaryFormatter bf = new BinaryFormatter();
   DimeAttachment reqDA = null;

   try 
   {
      //Dime Message - request
      if(HttpSoapContext.RequestContext.Attachments.Count == 0)
         throw new Exception("Mising DIME Attachment (IMessage)");

      reqDA = HttpSoapContext.RequestContext.Attachments[0];
      iMsgReq = (IMessage)bf.Deserialize(reqDA.Stream);
      iMsgReq.Properties["__Uri"] = iMsgReq.Properties[OBJECTURI]; 
      reqDA.Stream.Close();

      //Dispatcher 
      iMsgRsp = Dispatcher(iMsgReq); 
   }
   catch(Exception ex) 
   {
      //remoting exception
      iMsgRsp = new ReturnMessage(ex, (IMethodCallMessage)iMsgReq);
   }
   finally 
   {
      //serialize response 
      MemoryStream rspStream = new MemoryStream();
      bf.AssemblyFormat = FormatterAssemblyStyle.Full;
      bf.TypeFormat = FormatterTypeStyle.TypesAlways;
      bf.Serialize(rspStream, iMsgRsp);
      rspStream.Position = 0;

      //DIME Message
      DimeAttachment attachment = new DimeAttachment("text/plain", 
                                           TypeFormatEnum.MediaType, rspStream);
      HttpSoapContext.ResponseContext.Attachments.Clear();
      HttpSoapContext.ResponseContext.Attachments.Add(attachment);

      //cleanup
      if(reqDA != null)
         HttpSoapContext.RequestContext.Attachments.Remove(reqDA);
   } 
}

The following code snippet shows a helper function of the WebMethod to dispatch the IMessage to the properly target endpoint. In the case of the chaining channel, the IMessage is forwarded to the next channel. Note that the chained channel (sender channel) has to be registered in the host process such as a Web Server.

private IMessage Dispatcher(IMessage iMsgReq) 
{
   IMessage iMsgRsp = null;

   if(iMsgReq.Properties["__Uri"] != null) 
   { 
      //parse url address
      string strObjectUrl = iMsgReq.Properties["__Uri"].ToString(); 
      string[] urlpath = strObjectUrl.Split(';'); 
      string[] s = urlpath[0].Split('/'); 

      //check endpoint
      if(urlpath.Length == 1 && s.Length == 1) 
      {
         //this is an end channel
         Trace.WriteLine("Endpoint: " + strObjectUrl);
         iMsgRsp = ChannelServices.SyncDispatchMessage(iMsgReq); 
      }
      else 
      {
         //this is a chained channel
         string strDummy = null;
         IMessageSink iMessageSink = null;

         //find a properly channel
         foreach(IChannel ch in ChannelServices.RegisteredChannels)
         { 
            if(ch is IChannelSender)
            {
               IChannelSender iChannelSender = (IChannelSender)ch;
               iMessageSink = iChannelSender.CreateMessageSink(strObjectUrl, 
                                                                   null, out strDummy);
               if(iMessageSink != null)
               {
                  //this is a next channel
                  Trace.WriteLine("Chained channel is " + ch.ChannelName + 
                                  ", url=" + strObjectUrl);
                  break; 
               }
            }
         }

         if(iMessageSink == null)
         {
            //no channel found it
            string strError = string.Format("WSRemoting: A supported channel could not " + 
                                            be found for {0}", strObjectUrl);
            iMsgRsp = new ReturnMessage(new Exception(strError), 
                                        (IMethodCallMessage)iMsgReq);
            Trace.WriteLine(strError);
         }
         else 
         {
            //check for an oneway attribute
            IMethodCallMessage mcm = iMsgReq as IMethodCallMessage;

            if(RemotingServices.IsOneWay(mcm.MethodBase) == true)
               iMsgRsp = (IMessage)iMessageSink.AsyncProcessMessage(iMsgReq, null);
            else
               iMsgRsp = iMessageSink.SyncProcessMessage(iMsgReq);
         }
      }
   }
   else
   {
      //exception
      Exception ex = new Exception("WSRemoting: The Uri address is null");
      iMsgRsp = new ReturnMessage(ex, (IMethodCallMessage)iMsgReq);
   }

   return iMsgRsp;
}

WSDimeRemotingInterface

This is a helper assembly for Upload/Download binary files using the CallContext object. The wrapper AttachmentTicket class hides all mechanism to attach and detach binary stream to/from CallContext object. Here is its full implementation:

namespace RKiss.WebServiceRemoting
{
   [Serializable]
   public class AttachmentTicket : ILogicalThreadAffinative
   {
      const string m_TicketName = "_AttachmentTicket";
      readonly string m_Id; //ticket Id 
      readonly string m_Source; //initiator's Id
      readonly Hashtable m_Items; //items
      //
      public string Id { get {return m_Id; }}
      public string Source { get {return m_Source; }}
      public Hashtable Items { get {return m_Items; } }

      //constructor for Response CallContext
      public AttachmentTicket() 
      {
         object obj = CallContext.GetData(m_TicketName);
         if(obj != null && obj is AttachmentTicket) 
         {
            AttachmentTicket at = obj as AttachmentTicket;
            m_Id = at.Id;
            m_Source = at.Source;
            m_Items = at.Items;
         }
         else 
         {
            throw new Exception("No AttachmentTicket in the CallContext");
         }
      }

      //constructor for Request CallContext
      public AttachmentTicket(string id, string source) 
      {
         m_Id = id;
         m_Source = source;
         m_Items = new Hashtable();

         if(CallContext.GetData(m_TicketName) != null) 
            CallContext.FreeNamedDataSlot(m_TicketName);

         CallContext.SetData(m_TicketName, this);
      }

      //stream
      public int Attach(string key, Stream stream) 
      {
         int length = 0;

         try 
         {
            stream.Position = 0;
            length = (int)stream.Length;
            byte[] buffer = new byte[length];
            stream.Read(buffer, 0, buffer.Length);
            Items[key] = buffer;
         } 
         catch(Exception ex)
         {
            throw ex;
         }
         finally 
         {
            if(stream != null)
               stream.Close();
         }

         return length;
      }

      public int Detach(string key, Stream stream)
      {
         int length = 0;

         try 
         {
            byte[] buffer = Items[key] as byte[];
            length = buffer.Length;
            stream.Write(buffer, 0, length);
            Items.Remove(key);
         } 
         catch(Exception ex)
         {
            throw ex;
         }

         return length;
      } 

      //file
      public int Attach(string key, string path) 
      {
         FileStream fs = new FileStream(path, System.IO.FileMode.Open);
         return Attach(key, fs);
      }

      public int Detach(string key, string path) 
      {
         FileStream fs = null;
         int filesize = 0;

         try 
         {
            fs = new FileStream(path, System.IO.FileMode.Create);
            filesize = Detach(key, fs);
         }
         catch(Exception ex) 
         {
            throw ex;
         }
         finally 
         {
            if(fs != null) 
              fs.Close();
         }
         return filesize;
      }

      //generic serialized object
      public void Attach(string key, object obj) 
      {
         Items[key] = obj;
      }

      public void Detach(string key, out object obj) 
      {
         obj = Items[key];
      }

      public void Close() 
      {
         CallContext.FreeNamedDataSlot(m_TicketName);
      }
   }

   //interface contract
   public interface IWSDimeRemoting
   {
      string Echo(int id, string msg);
      [OneWay]
      void EchoOneWay(int id, string msg);
   }
}

 

Installation

I am assuming that the Web Services Enhancements 1.0 has been installed on your machine. Here are the installation steps:  

  1. install WSDimeChannel assembly into the GAC
  2. modify machine.config file inserting the wsdime channel
  3. install WSDimeRemotingInterface assembly into the GAC
  4. create Virtual Directory for WebServiceRemoting web service and setup its access for ASPNET account (only for server side)

Note that the assemblies need to be installed into the GAG on the both sides such as client and server. It can be done easy using the drag&drop mechanism between the \bin and %Windows%\assembly folders.

The Web Service can be tested invoking the Echo WebMethod on the test page.

Now, it's a time to make a test of the remoting over internet including upload/download binary test files. This step is described in the following chapter.

Test

I built the following package to test functionality of the Remoting Custom Channel over Internet:

  • WindowsClient, Client host process - Windows Form program
  • ConsoleServer, Server host process - Console program
  • MyRemoteObject, Remote test object

The steps to perform the Remoting Upload/Download test over Internet:

  1. Install MyRemoteObject assembly into the GAC
  2. Launch the ConsoleServer program
  3. Launch the WindowsClient program
  4. Select Upload or Download checkBox
  5. Press button Echo
  6. See the response messages on the Form and Console screen

When the button Echo has been pressed, the following remote method is going to be performed at the remote object:

//IWSDimeRemoting
public string Echo(int id, string msg)
{
   string response = "";
   int filesize = 0;
   AttachmentTicket at = null;

   //exception test
   if(msg.IndexOf("throw") >= 0)
      throw new Exception("This is an Exception test");

   //Attachment
   try 
   {
      at = new AttachmentTicket();
      if(at.Items.Contains("UploadFile"))
      {
         filesize = at.Detach("UploadFile", "UploadedFile.bin");
      }
      response = string.Format("Echo: id={0}, msg={1}; upload[{2}bytes]", 
                               id, msg, filesize);
   }
   catch(Exception ex) 
   {
      response = string.Format("Echo: id={0}, msg={1}", id, msg);
   }

   //request to download file
   if(msg.IndexOf("download") >= 0) 
   {
     if(at == null) 
     {
        at = new AttachmentTicket(Guid.NewGuid().ToString(), "RemoteObject");
     }
     at.Attach("DownloadFile", "FileToDownload.bin");
   }

   Console.WriteLine(response);

   return response;
}

The above code snippet shows usage of the AttachmentTicket object for Upload/Download binary files to/from its consumer. Note that the remote object is full transparently to its consumer regardless of where the consumer physically located included over Internet.

Conclusion

In this article has been shown a usage of WSE - DIME Attachment to pass a binary formatted stream between the remoting infrastructures at the both sides - remote object and its consumer. This technology allows to make a plug&play remoting ends over Internet in the loosely coupled design manner including uploading and downloading binary files.

[1] http://www.codeproject.com/cs/webservices/remotingoverinternet.asp

[2] http://msdn.microsoft.com/webservices/building/wse/default.aspx

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

Share

About the Author

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

Comments and Discussions

 
Generalinstallation procedure Pinmembermmary28-Jul-07 1:16 

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 | Terms of Use | Mobile
Web01 | 2.8.141223.1 | Last Updated 7 Jan 2003
Article Copyright 2003 by Roman Kiss
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid