Click here to Skip to main content
15,867,141 members
Articles / Programming Languages / C#

WCF TCP-based File Server

Rate me:
Please Sign up or sign in to vote.
4.96/5 (30 votes)
2 Mar 2009CPOL12 min read 182.7K   8.2K   161   35
Shows you how to implement a remote file repository using WCF.

FileServer

Introduction

This article shows you how to build a remote file store using WCF. Specifically:

  • Using TCP binding to stream files to a client from a storage location
  • Connecting to the server from a WinForms client
  • Implementing the ability to download, upload, and delete files from remote storage
  • Configuring both server and client for streamed TCP transfer

Background

There are a few reasons why you might want to create such a remote file store; one of them might be because you have several applications which need to share files which have been uploaded by a system user and then access from a separate administration system. This is the very reason which prompted me to investigate how this can be done using WCF, and my solution is presented in this article.

Using the Code

In this section, I shall detail the sections of code which are relevant to the topic, starting with an overview of the service itself. You don't necessarily have to do this, but to keep things clean and tidy, I have split my solution up into three projects:

  • A class library project to house the service contract and the implementation, plus any utility classes which the service is dependant upon
  • A console application which hosts the service
  • A WinForms application to act as the client

The Service

The operations which our service will provide are as follows:

  • Downloading a file
  • Uploading a file
  • Deleting a file
  • Listing the contents of the file store

For three of the operations (downloading, uploading, and deleting), the service requires only a virtual path to the file. Since the client should know nothing about the actual physical locations of the files in the file store, all we can pass to these operations is the path relative to the root of the store.

The last operation helps with this, since we can get a list of the available files complete with their virtual paths and make them available through some user interface.

Let's have a look at the contract which defines the operations our service provides:

C#
[ServiceContract] 
public interface IFileRepositoryService 
{ 
    [OperationContract] 
    Stream GetFile(string virtualPath); 

    [OperationContract] 
    void PutFile(FileUploadMessage msg); 

    [OperationContract] 
    void DeleteFile(string virtualPath); 

    [OperationContract] 
    StorageFileInfo[] List(string virtualPath); 
}

This is a pretty standard implementation of a service contract. Note that we are passing an actual Stream object from GetFile() which will contain the file data. Soon, we will configure our service for streaming which allows us to do this. When uploading a file (using PutFile()), we need to be able to send a Stream object and a virtual path to save the file to. However, in streaming mode, any messages which are to be streamed enforces a couple of restrictions on our methods (taken from http://msdn.microsoft.com/en-us/library/ms789010.aspx):

  1. The parameter which contains the streamed data must be the only parameter in the method.
  2. At least one of the types of the parameter and the return value must be Stream, Message, or IXmlSerializable.

The first point says that if the input message is to be streamed, there can only be one parameter in that method. If the output is to be streamed, then there can only be one output parameter or a return value.

So, if we need to pass some metadata along with our stream (such as a virtual path, in this case), then we can implement a message contract to send this data:

C#
[MessageContract]
public class FileUploadMessage
{
    [MessageHeader(MustUnderstand=true)]
    public string VirtualPath { get; set; }

    [MessageBodyMember(Order=1)]
    public Stream DataStream { get; set; }
}

Here, the VirtualPath property is sent to the service operation as the SOAP header, whilst the Stream property is sent as the input parameter to the operation. This allows us to send both bits of information that we require to store a file on the server. The MustUnderstand setting on the MessageHeader attribute simply indicates that the message receiver must understand and be able to process that member; otherwise, the channel is faulted.

Service Implementation

Let's finish up the service by implementing the service contract. In addition to the four methods which are required in order to fulfill the implementation of the service contract, I've added some events and properties which allow us to monitor what is happening to the repository, along with a custom EventArgs class and an appropriate delegate:

C#
public delegate void FileEventHandler(object sender, FileEventArgs e);

public class FileEventArgs : EventArgs
{
    /// <summary>
    /// Gets the virtual path.
    /// </summary>
    public string VirtualPath
    {
        get { return _VirtualPath; }
    }
    string _VirtualPath = null;

    /// <summary>
    /// Initializes a new instance of the <see cref="FileEventArgs"/> class.
    /// </summary>
    /// <param name="vPath">The v path.</param>
    public FileEventArgs(string vPath)
    {
        this._VirtualPath = vPath;
    }
}

We can then start to implement the service:

C#
[ServiceBehavior(IncludeExceptionDetailInFaults=true,
InstanceContextMode=InstanceContextMode.Single)]
public class FileRepositoryService : IFileRepositoryService
{
    #region Events

    public event FileEventHandler FileRequested;
    public event FileEventHandler FileUploaded;
    public event FileEventHandler FileDeleted;

    #endregion
}

Looking at the attributes here, I've specified that the server should include any exception details if the service were to fault. This way, we get more detailed exception messages at the client should the service fail. I've also declared that the InstanceContextMode should be Single. This means that the service instance should be used for multiple calls, and not be recycled after every call. While this does mean that the service can only handle one call at a time, it becomes a requirement if we want to handle events and monitor service status (as we shall see when we set up the service host). Depending on your requirements, you might want to abandon this facility to allow the service to handle multiple calls.

We've also set up our public events which can be handled in order to monitor access to the file server.

Let's implement those service methods now.

C#
/// <summary>
/// Gets or sets the repository directory.
/// </summary>
public string RepositoryDirectory { get; set; }

/// <summary>
/// Gets a file from the repository
/// </summary>
public Stream GetFile(string virtualPath)
{
    string filePath = Path.Combine(RepositoryDirectory, virtualPath);

    if (!File.Exists(filePath))
        throw new FileNotFoundException("File was not found", 
                                        Path.GetFileName(filePath));

    SendFileRequested(virtualPath);

    return new FileStream(filePath, FileMode.Open, FileAccess.Read);
}

/// <summary>
/// Uploads a file into the repository
/// </summary>
public void PutFile(FileUploadMessage msg)
{
    string filePath = Path.Combine(RepositoryDirectory, msg.VirtualPath);
    string dir = Path.GetDirectoryName(filePath);

    if (!Directory.Exists(dir))
        Directory.CreateDirectory(dir);

    using (var outputStream = new FileStream(filePath, FileMode.Create))
    {
        msg.DataStream.CopyTo(outputStream);
    }

    SendFileUploaded(filePath);
}

/// <summary>
/// Deletes a file from the repository
/// </summary>
public void DeleteFile(string virtualPath)
{
    string filePath = Path.Combine(RepositoryDirectory, virtualPath);

    if (File.Exists(filePath))
    {
        SendFileDeleted(virtualPath);
        File.Delete(filePath);
    }
}

/// <summary>
/// Lists files from the repository at the specified virtual path.
/// </summary>
/// <param name="virtualPath">The virtual path.
/// This can be null to list files from the root of
/// the repository.</param>
public StorageFileInfo[] List(string virtualPath)
{
    string basePath = RepositoryDirectory;

    if (!string.IsNullOrEmpty(virtualPath))
        basePath = Path.Combine(RepositoryDirectory, virtualPath);

    DirectoryInfo dirInfo = new DirectoryInfo(basePath);
    FileInfo[] files = dirInfo.GetFiles("*.*", SearchOption.AllDirectories);

    return (from f in files
           select new StorageFileInfo()
           {
               Size = f.Length,
               VirtualPath = f.FullName.Substring(
                 f.FullName.IndexOf(RepositoryDirectory) + 
                 RepositoryDirectory.Length + 1)
           }).ToArray();
}

Most of these methods implement standard file operations. Remember that these methods only want to work with files relative to the root of the repository. This service class also has a public property on it called RepositoryDirectory, which indicates where the root path of the repository is. The intention is for the service host to configure this when the host is started. All operations which need to find files inside the repository should need this root path.

Since we don't want to reveal physical locations to the client, the List() method takes care to strip off all the sensitive path information, and only returns virtual paths relative to RepositoryDirectory. You could also return additional information about the file at this point; I've chosen to also return the size of the file in bytes.

There is one helper method for each public event on this service, and they are:

C#
/// <summary>
/// Raises the FileRequested event.
///  </summary>
protected void SendFileRequested(string vPath)
{
    if (FileRequested != null)
        FileRequested(this, new FileEventArgs(vPath));
}

///  <summary>
/// Raises the FileUploaded event
///  </summary>
protected void SendFileUploaded(string vPath)
{
    if (FileUploaded != null)
        FileUploaded(this, new FileEventArgs(vPath));
}

/// <summary>
/// Raises the FileDeleted event.
/// </summary>
protected void SendFileDeleted(string vPath)
{
    if (FileDeleted != null)
        FileDeleted(this, new FileEventArgs(vPath));
}

That wraps up the service itself; let's look at how to host this service.

Hosting the Repository Service

For this example, I have chosen to use NetTcpBinding for communication, but you could also use BasicHttpBinding, or any binding which supports the Streaming Transfer Mode. I'm going to run the server program as a Console application, configure the service entirely in the app.config file, and subscribe to those events that were created as part of the service implementation.

Let's create a console app, and set up a ServiceHost to host our repository service:

C#
static void Main(string[] args)
{

    FileRepositoryService service = new FileRepositoryService();
    service.RepositoryDirectory = "storage";

    service.FileRequested += new FileEventHandler(Service_FileRequested);
    service.FileUploaded += new FileEventHandler(Service_FileUploaded);
    service.FileDeleted += new FileEventHandler(Service_FileDeleted);

    host = new ServiceHost(service);

    try
    {
        host.Open();
        Console.WriteLine("Press a key to close the service");
        Console.ReadKey();
    }
    finally
    {
        host.Close();
    }
}

This will start the service running at the location specified in our service configuration (coming shortly). Note here that we pass in an actual instance of FileRepositoryService. The other option is to simply tell the host what type we want to work with, and let the host create the instance for itself. I've done it this way so that I can handle the various events which can be raised from my service implementation, but this is what limits you to using the InstanceContextMode.Single setting on your service implementation; this instance must stay alive across multiple service calls for it to be useful, and the only way to ensure this is to not recycle the service after every call.

I've also set the RepositoryDirectory property here, pointing the service towards the "storage" folder in whatever directory the host is running from. All the files within this storage directory are counted as being part of the repository.

The event handlers simply write out some feedback text to the console window to let us know what is going on with the repository:

C#
static void Service_FileRequested(object sender, FileEventArgs e)
{
    Console.WriteLine(string.Format("File access\t{0}\t{1}", e.VirtualPath, DateTime.Now));
}

static void Service_FileUploaded(object sender, FileEventArgs e)
{
    Console.WriteLine(string.Format("File upload\t{0}\t{1}", e.VirtualPath, DateTime.Now));
}

static void Service_FileDeleted(object sender, FileEventArgs e)
{
    Console.WriteLine(string.Format("File deleted\t{0}\t{1}", e.VirtualPath, DateTime.Now));
}

Configuring the service is just as short and sweet as setting up the hosting for it:

XML
<configuration>
    <system.serviceModel>
        <services>
            <service name="FileServer.Services.FileRepositoryService">
                <endpoint name="" binding="netTcpBinding"
                    address="net.tcp://localhost:5000"
                    contract="FileServer.Services.IFileRepositoryService"
                    bindingConfiguration="customTcpBinding" />
            </service>
        </services>
        <bindings>
            <netTcpBinding>
                <binding name="customTcpBinding" 
                  transferMode="Streamed" 
                  maxReceivedMessageSize="20480000" />
            </netTcpBinding>
        </bindings>
    </system.serviceModel>
</configuration>

Here, we do several things:

  • Specify that the binding to be used is NetTcpBinding.
  • Specify that the address which the service should be hosted on should be "net.tcp://localhost:5000".
  • Tell the service which service contract we want to expose.
  • Specify some custom binding settings. We need this in order to be able to set up the streaming transfer mode. I've bumped up the maxReceivedMessageSize from the default value of 65,536 bytes, since I'd like this service to be able to transfer files larger than 64K.

The name attribute on the service element in the configuration is the same as the full type name of the service implementation; this is how the ServiceHost can pick up the correct configuration section when creating the host.

So, you should now be able to fire up your server program and leave it running, ready to accept connections.

The Client

The client that I built to connect to my server is a WinForms application. It's a simple GUI for displaying the file list from the server, and allows you to upload, download, and delete files. Here, I'll run through the major operations that this client performs rather than give you a code dump of the entire listing.

First, let's create a client proxy that we can use to access the service. For more information on the various methods of creating your proxy classes, please have a look at this article on client proxy generation. I'm going to use a hand-crafted proxy, which means that in my client, I have a reference to my Services project directly. If you created a proxy using Visual Studio, then you won't need this reference.

Here's my client proxy class:

C#
public class FileRepositoryServiceClient : 
    ClientBase<IFileRepositoryService>, 
    IFileRepositoryService, IDisposable
{
    public FileRepositoryServiceClient()
        : base("FileRepositoryService")
    {
    }

    #region IFileRepositoryService Members

    public System.IO.Stream GetFile(string virtualPath)
    {
        return base.Channel.GetFile(virtualPath);
    }

    public void PutFile(FileUploadMessage msg)
    {
        base.Channel.PutFile(msg);
    }

    public void DeleteFile(string virtualPath)
    {
        base.Channel.DeleteFile(virtualPath);
    }

    public StorageFileInfo[] List()
    {
        return List(null);
    }

    public StorageFileInfo[] List(string virtualPath)
    {
        return base.Channel.List(virtualPath);
    }

    #endregion

    #region IDisposable Members

    void IDisposable.Dispose()
    {
        if (this.State == CommunicationState.Opened)
            this.Close();
    }

    #endregion
}

All of the methods here simply reflect the methods which are available on the server, with the exception of the overload on List() (since for this particular client, I want to always list files from the root of the repository and not have to pass in null all the time). Note also the lack of error handling; in a production environment, you will almost certainly want to implement the appropriate error handling routines here.

In the constructor for my client, I've given it the name of the configuration element that it should use when setting up the communication channel. This configuration section looks like the following:

XML
<system.serviceModel>
    <client>
        <endpoint name="FileRepositoryService"
            address="net.tcp://localhost:5000"
            binding="netTcpBinding"
            contract="FileServer.Services.IFileRepositoryService"
            bindingConfiguration="customTcpBinding" />
    </client>

    <bindings>
        <netTcpBinding>
            <binding name="customTcpBinding" 
                maxReceivedMessageSize="20480000" 
                transferMode="Streamed" />
        </netTcpBinding>
    </bindings>
</system.serviceModel>

It is pretty similar to the host configuration. Note the endpoint name that we've given; this is what the client's constructor parameter is set to in the client class we created above. Also note that it uses the same binding (netTcpBinding) and points to the same address that the host is using. In the binding configuration, I've also set the transfer mode to 'Streamed' and have set the maxReceivedMessageSize attribute to the same value as the host.

The message size attribute setting isn't a necessity however; it's set to the same value on both, since I'm going to be streaming larger files to and from the server. If I was only downloading files from the server, then I would only need to set this on the client side. Similarly, if I was only going to be sending large files to the server, I would only need to set that on the host's side. Note also that these settings only need to be set manually if you wish to transfer more than 64K of data in a single transfer.

OK, lets go ahead and actually use the client.

There are four places where we can make use of the remote storage service:

  • Uploading a file to the repository using the 'Upload' button.
  • Downloading a file from the repository using the 'Download' button.
  • Deleting a file from the repository using the 'Delete' button.
  • Listing the files when the form loads, and also when the repository changes (such as uploading and deleting a file).

I'll go through each of these buttons and also list the method which fetches the file list from the repository. Each function simply makes a call to the service using the client which we crafted above, and as a result, makes implementing the client application very easy.

To list the files, we use the List() method:

C#
private void RefreshFileList()
{
    StorageFileInfo[] files = null;

    using (FileRepositoryServiceClient client = new FileRepositoryServiceClient())
    {
        files = client.List(null);
    }

    FileList.Items.Clear();

    int width = FileList.ClientSize.Width - SystemInformation.VerticalScrollBarWidth;

    float[] widths = { .2f, .6f, .2f };

    for (int i = 0; i < widths.Length; i++)
        FileList.Columns[i].Width = (int)((float)width * widths[i]);

    foreach (var file in files)
    {
        ListViewItem item = new ListViewItem(Path.GetFileName(file.VirtualPath));

        item.SubItems.Add(file.VirtualPath);

        float fileSize = (float)file.Size / 1024.0f;
        string suffix = "Kb";

        if (fileSize > 1000.0f)
        {
            fileSize /= 1024.0f;
            suffix = "Mb";
        }
        item.SubItems.Add(string.Format("{0:0.0} {1}", fileSize, suffix));

        FileList.Items.Add(item);
    }
}

As you can see, most of this code deals with sorting out the UI, including setting column widths on the ListView control and creating the actual list items. The actual call to get the files from the remote store is done in only a few lines.

The upload, download, and delete file operations are executed in the same simple manner:

C#
private void UploadButton_Click(object sender, EventArgs e)
{
    OpenFileDialog dlg = new OpenFileDialog()
    {
        Title = "Select a file to upload",
        RestoreDirectory = true,
        CheckFileExists = true
    };

    dlg.ShowDialog();

    if (!string.IsNullOrEmpty(dlg.FileName))
    {
        string virtualPath = Path.GetFileName(dlg.FileName);

        using (Stream uploadStream = new FileStream(dlg.FileName, FileMode.Open))
        {
            using (FileRepositoryServiceClient client = new FileRepositoryServiceClient())
            {
                client.PutFile(new FileUploadMessage() { VirtualPath = virtualPath, 
                                                         DataStream = uploadStream });
            }
        }

        RefreshFileList();
    }
}

private void DownloadButton_Click(object sender, EventArgs e)
{

    if (FileList.SelectedItems.Count == 0)
    {
        MessageBox.Show("You must select a file to download");
    }
    else
    {
        ListViewItem item = FileList.SelectedItems[0];

        // Strip off 'Root' from the full path
        string path = item.SubItems[1].Text;

        // Ask where it should be saved
        SaveFileDialog dlg = new SaveFileDialog()
        {
            RestoreDirectory = true,
            OverwritePrompt = true,
            Title = "Save as...",
            FileName = Path.GetFileName(path)
        };

        dlg.ShowDialog(this);

        if (!string.IsNullOrEmpty(dlg.FileName))
        {
            // Get the file from the server
            using (FileStream output = 
                   new FileStream(dlg.FileName, FileMode.Create))
            {
                Stream downloadStream;

                using (FileRepositoryServiceClient client = 
                       new FileRepositoryServiceClient())
                {
                    downloadStream = client.GetFile(path);
                }

                downloadStream.CopyTo(output);
            }

            Process.Start(dlg.FileName);
        }
    }
}

The upload and download functions get special mention because they work with Stream objects, but as you can see, you don't do anything different with them than what you'd normally do when working with streams. You just have to create some sort of stream and pass it to the service; we have already configured our service for streamed mode, so the transfer of the data is handled appropriately for us.

One thing I do use here is an extension method on Stream which simply copies data between any two streams. The extension method is called 'CopyTo', and looks like the following:

C#
public static class StreamExtensions
{
    /// <summary>
    /// Copies data from one stream to another.
    /// </summary>
    /// <param name="input">The input stream</param>
    /// <param name="output">The output stream</param>
    public static void CopyTo(this Stream input, Stream output)
    {
        const int bufferSize = 2048;
        byte[] buffer = new byte[bufferSize];
        int bytes = 0;

        while ((bytes = input.Read(buffer, 0, bufferSize)) > 0)
        {
            output.Write(buffer, 0, bytes);
        }
    }
}

I've kept this extension method in my service library, since both my client and the service itself use it.

Finally, the delete method:

C#
private void DeleteButton_Click(object sender, EventArgs e)
{

    if (FileList.SelectedItems.Count == 0)
    {
        MessageBox.Show("You must select a file to delete");
    }
    else
    {
        string virtualPath = FileList.SelectedItems[0].SubItems[1].Text;

        using (FileRepositoryServiceClient client = new FileRepositoryServiceClient())
        {
            client.DeleteFile(virtualPath);
        }

        RefreshFileList();
    }

}

When you delete a file, the service simply requires the virtual path to the file you wish to delete; I store this virtual path in a SubItem of the ListItem object when populating the ListView control, so all I do here is read that value out and send it back to the file service.

When you download, upload, and delete files, you should also be able to see the log messages coming through from our host in the console window. This way, if you have remote clients (and not a client on the same computer like we have here in our local environment), you can keep a tab on their actions directly.

Taking it Further

Obviously, this is a pretty basic implementation designed to show you how you could go about implementing something like this using WCF. To use in a production environment, you'll more than likely want to take into account security requirements. How can you restrict access to files in a multi-user environment? If you were to host the service on a public endpoint, how can you secure that so that only authorized clients can connect? What about more technical considerations such as restricting file size and enforcing transfer limits, possibly configurable per user?

Some of these requirements can be implemented by making the appropriate changes to the service; for example, supplying the service with a username and password which the service would authenticate against a database, or by using Windows security to control access to the files.

These sorts of considerations I have yet to touch on myself, but at least the code presented here will stand you in good stead to begin to tackle these issues.

And, that's all there is to it. All of the code here is available to view and mess around with in the archive attached to this article. If you have any questions, I'd be happy to answer them!

Thanks for reading.

History

  • 02/03/2009: First version.

License

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


Written By
Software Developer Orchid Software
United Kingdom United Kingdom
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionSystem.ServiceModel.CommunicationException Pin
creation485029-May-17 5:04
creation485029-May-17 5:04 
QuestionHow to change repository in run-time? Pin
Octavia Pearson11-Dec-16 8:52
Octavia Pearson11-Dec-16 8:52 
QuestionStorageFileInfo Pin
AbdelrhmanRaafat25-Jul-15 22:59
AbdelrhmanRaafat25-Jul-15 22:59 
Questionrunning on iis? Pin
arasx9-Dec-14 3:37
arasx9-Dec-14 3:37 
QuestionHow can I host the server service in IIS. Pin
jayantbramhankar24-Nov-13 20:40
jayantbramhankar24-Nov-13 20:40 
QuestionInstalling Service on Remote Server Pin
polczym7-Oct-13 5:25
polczym7-Oct-13 5:25 
AnswerRe: Installing Service on Remote Server Pin
polczym8-Oct-13 4:39
polczym8-Oct-13 4:39 
QuestionIs this application a P2P system? Pin
Amir Jalilifard22-Mar-13 23:42
professionalAmir Jalilifard22-Mar-13 23:42 
QuestionHow to use the sample without app.config?? Pin
Stupid Guy 201226-Sep-12 3:57
Stupid Guy 201226-Sep-12 3:57 
QuestionCopyTo now out of the box Pin
warnov18-Aug-12 18:56
warnov18-Aug-12 18:56 
QuestionGetting an Error Pin
varun surana18-Oct-11 3:05
varun surana18-Oct-11 3:05 
AnswerRe: Getting an Error Pin
Herman<T>.Instance7-Mar-18 0:17
Herman<T>.Instance7-Mar-18 0:17 
QuestionResuming uploads and downloads Pin
Christian Suarez29-Jun-11 6:10
Christian Suarez29-Jun-11 6:10 
Great article!! Any ideas about resuming uploads and downloads?? I was looking at the msdn article about chunking channel but it's still confused, maybe there is another approach. Thanks.
GeneralMultiple Downloads not supported Pin
Gil Zhaiek17-Jan-11 11:41
Gil Zhaiek17-Jan-11 11:41 
GeneralGood Article But Need Help Pin
SerialSuccess11-Jan-11 10:44
SerialSuccess11-Jan-11 10:44 
GeneralRe: Good Article But Need Help Pin
Gil Zhaiek17-Jan-11 11:43
Gil Zhaiek17-Jan-11 11:43 
QuestionIs there a way to add windows credentials? Pin
Driftware9-Mar-10 1:26
Driftware9-Mar-10 1:26 
QuestionWCF limits Pin
Darinko30-Dec-09 22:03
Darinko30-Dec-09 22:03 
AnswerRe: WCF limits Pin
Gil Zhaiek17-Jan-11 11:51
Gil Zhaiek17-Jan-11 11:51 
GeneralStream and Memory leaks Pin
Oleh Mykhaylovych1-Sep-09 11:31
Oleh Mykhaylovych1-Sep-09 11:31 
GeneralHost as a Windows Service Pin
moldie8-Apr-09 6:09
moldie8-Apr-09 6:09 
GeneralRe: Host as a Windows Service Pin
StevenHobbs8-Apr-09 22:27
StevenHobbs8-Apr-09 22:27 
GeneralRe: Host as a Windows Service Pin
moldie9-Apr-09 4:59
moldie9-Apr-09 4:59 
GeneralRe: Host as a Windows Service Pin
StevenHobbs9-Apr-09 5:13
StevenHobbs9-Apr-09 5:13 
QuestionHow to handle big file Pin
Bruce Zhang2-Mar-09 18:33
Bruce Zhang2-Mar-09 18:33 

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

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