65.9K
CodeProject 正在变化。 阅读更多。
Home

WCF 基于 TCP 的文件服务器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (29投票s)

2009年3月2日

CPOL

12分钟阅读

viewsIcon

189310

downloadIcon

8251

演示如何实现一个基于 WCF 的远程文件存储库。

FileServer

引言

本文演示了如何使用 WCF 构建一个远程文件存储。具体来说

  • 使用 TCP 绑定从存储位置向客户端流式传输文件
  • 从 WinForms 客户端连接到服务器
  • 实现从远程存储下载、上传和删除文件的功能
  • 配置服务器和客户端以进行流式 TCP 传输

背景

您可能希望创建这样一个远程文件存储的原因有很多;其中一个原因可能是您有几个应用程序需要共享用户上传的文件,并且之后需要从单独的管理系统中访问这些文件。这正是我在本文中研究如何使用 WCF 来实现这一点的动机。我的解决方案在本文中进行了介绍。

Using the Code

在本节中,我将详细介绍与主题相关的代码部分,首先概述服务本身。您不一定非要这样做,但为了保持代码的整洁,我将我的解决方案分成了三个项目

  • 一个类库项目,用于存放服务契约、实现以及服务所依赖的任何实用工具类
  • 一个托管服务的控制台应用程序
  • 一个充当客户端的 WinForms 应用程序

服务

我们的服务将提供的操作如下

  • 下载文件
  • 上传文件
  • 删除文件
  • 列出文件存储的内容

对于其中三个操作(下载、上传和删除),服务仅需要文件的虚拟路径。由于客户端不应了解文件存储中文件的实际物理位置,因此我们只能将这些路径传递给这些操作,这些路径是相对于存储根目录的。

最后一个操作有助于解决这个问题,因为我们可以获取可用文件的列表,包括它们的虚拟路径,并通过某个用户界面提供这些信息。

让我们看一下定义我们服务所提供操作的契约

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

    [OperationContract] 
    void PutFile(FileUploadMessage msg); 

    [OperationContract] 
    void DeleteFile(string virtualPath); 

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

这是一个相当标准的 istem 契约实现。请注意,我们从 `GetFile()` 中传递了一个实际的 `Stream` 对象,其中包含文件数据。很快,我们将配置我们的服务以进行流式传输,从而允许我们这样做。上传文件(使用 `PutFile()`)时,我们需要能够发送一个 `Stream` 对象和一个用于保存文件的虚拟路径。但是,在流式传输模式下,任何要流式传输的消息都会对我们的方法施加一些限制(摘自 http://msdn.microsoft.com/en-us/library/ms789010.aspx

  1. 包含流式数据的参数必须是该方法中的唯一参数。
  2. 参数和返回值类型中至少有一种必须是 `Stream`、`Message` 或 `IXmlSerializable`。

第一点指出,如果输入消息要流式传输,则该方法中只能有一个参数。如果输出要流式传输,则只能有一个输出参数或返回值。

因此,如果我们需要的不仅仅是流,还需要传递一些元数据(例如,在这种情况下是虚拟路径),那么我们可以实现一个消息契约来发送这些数据

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

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

在此,`VirtualPath` 属性作为 SOAP 报头发送到服务操作,而 `Stream` 属性作为操作的输入参数发送。这允许我们发送存储文件在服务器上所需的两种信息。`MessageHeader` 属性上的 `MustUnderstand` 设置只是表示消息接收者必须理解并能够处理该成员;否则,通道将出错。

服务实现

让我们完成服务,实现服务契约。除了履行服务契约实现所需的四个方法外,我还添加了一些事件和属性,允许我们监视存储库中发生的情况,以及一个自定义的 `EventArgs` 类和相应的委托

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;
    }
}

然后我们可以开始实现服务

[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
}

查看这里的属性,我指定服务器应包含任何异常详细信息,以防服务出现故障。这样,当服务失败时,我们可以在客户端获得更详细的异常消息。我还声明 `InstanceContextMode` 应为 `Single`。这意味着服务实例将用于多个调用,而不会在每次调用后回收。虽然这确实意味着服务一次只能处理一个调用,但如果我们想处理事件和监视服务状态(正如我们将在设置服务主机时看到的那样),这就成了一个要求。根据您的需求,您可能希望放弃此功能,以便服务能够处理多个调用。

我们还设置了公共事件,可以处理这些事件以监视对文件服务器的访问。

现在来实现这些服务方法。

/// <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();
}

这些方法大多实现标准的文件操作。请记住,这些方法只想处理相对于存储库根目录的文件。此服务类上还有一个名为 `RepositoryDirectory` 的公共属性,指示存储库的根路径在哪里。意图是让服务主机在主机启动时配置此属性。所有需要查找存储库内文件的操作都需要此根路径。

由于我们不希望向客户端暴露物理位置,因此 `List()` 方法会小心地剥离所有敏感的路径信息,只返回相对于 `RepositoryDirectory` 的虚拟路径。您也可以在此点返回有关文件的其他信息;我选择也返回文件的大小(以字节为单位)。

此服务中的每个公共事件都有一个辅助方法,它们是

/// <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));
}

这就是服务本身的内容;让我们看看如何托管此服务。

托管存储库服务

在此示例中,我选择使用 `NetTcpBinding` 进行通信,但您也可以使用 `BasicHttpBinding` 或任何支持流式传输模式的绑定。我将以控制台应用程序的形式运行服务器程序,完全在 *app.config* 文件中配置服务,并订阅服务实现中创建的那些事件。

让我们创建一个控制台应用程序,并设置一个 `ServiceHost` 来托管我们的存储库服务

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();
    }
}

这将使服务在我们的服务配置中指定的位置(稍后将介绍)运行。请注意,在这里我们传递了 `FileRepositoryService` 的实际实例。另一种选择是仅告知主机我们要使用的类型,然后让主机自行创建实例。我这样做是为了能够处理我的服务实现可以引发的各种事件,但这也限制您在服务实现中使用 `InstanceContextMode.Single` 设置;此实例必须在多个服务调用之间保持活动状态才能有用,而确保这一点的方法是不要在每次调用后回收服务。

我还设置了 `RepositoryDirectory` 属性,将服务指向主机运行目录中的“storage”文件夹。此存储目录中的所有文件都被视为存储库的一部分。

事件处理程序只是将一些反馈文本写入控制台窗口,让我们知道存储库正在发生什么

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));
}

配置服务就像设置其托管一样简单明了

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

在这里,我们做了几件事

  • 指定要使用的绑定是 `NetTcpBinding`。
  • 指定服务应托管的地址是“net.tcp://:5000”。
  • 告诉服务我们要公开哪个服务契约。
  • 指定一些自定义绑定设置。我们需要这个才能设置流式传输模式。我已经将 `maxReceivedMessageSize` 从默认值 65,536 字节提高,因为我希望此服务能够传输大于 64K 的文件。

配置中 `service` 元素上的 `name` 属性与服务实现的完整类型名称相同;这就是 ServiceHost 在创建主机时能够获取正确配置节的方式。

因此,您现在应该能够启动您的服务器程序并让它运行,准备好接受连接。

客户端

我构建的连接到我服务器的客户端是一个 WinForms 应用程序。它是一个简单的 GUI,用于显示服务器上的文件列表,并允许您上传、下载和删除文件。在这里,我将介绍此客户端执行的主要操作,而不是提供整个列表的代码转储。

首先,让我们创建一个客户端代理,我们可以用它来访问服务。有关创建代理类的各种方法的更多信息,请参阅这篇 关于客户端代理生成的文章。我将使用手工制作的代理,这意味着在我的客户端中,我直接引用了我的 Services 项目。如果您使用 Visual Studio 创建了代理,那么您将不需要此引用。

这是我的客户端代理类

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
}

这里的所有方法都只是反映了服务器上可用的方法,除了 `List()` 的重载(因为对于这个特定的客户端,我想始终从存储库的根目录列出文件,而不必一直传递 null)。另外请注意缺少错误处理;在生产环境中,您几乎肯定希望在此处实现适当的错误处理例程。

在我的客户端的构造函数中,我给它指定了它在设置通信通道时应使用的配置元素的名称。此配置节如下所示

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

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

它与主机配置非常相似。请注意我们给定的终结点名称;这正是我们上面创建的客户端类中客户端构造函数参数所设置的。另外请注意,它使用了与主机相同的绑定(`netTcpBinding`)并指向了与主机相同的地址。在绑定配置中,我还已将传输模式设置为“`Streamed`”并将 `maxReceivedMessageSize` 属性设置为与主机相同的值。

但是,消息大小属性设置不是必需的;它在两者上都设置为相同的值,因为我将向服务器发送和从服务器接收较大的文件。如果我只从服务器下载文件,那么我只需要在客户端设置它。同样,如果我只向服务器发送大文件,我只需要在主机端设置它。另外请注意,只有当您希望在单次传输中传输超过 64K 的数据时,才需要手动设置这些设置。

好的,让我们继续实际使用客户端。

我们可以在四个地方利用远程存储服务

  • 使用“Upload”按钮将文件上传到存储库。
  • 使用“Download”按钮从存储库下载文件。
  • 使用“Delete”按钮从存储库删除文件。
  • 在窗体加载时列出文件,并在存储库发生更改时(例如,上传和删除文件)列出文件。

我将逐一介绍这些按钮,并列出获取文件列表的方法。每个函数只需使用我们上面创建的客户端调用服务,因此实现客户端应用程序非常容易。

要列出文件,我们使用 `List()` 方法

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);
    }
}

正如您所看到的,大部分代码都在处理 UI 的排序,包括设置 `ListView` 控件的列宽和创建实际的列表项。从远程存储获取文件的实际调用仅用几行代码完成。

上传、下载和删除文件的操作以同样简单的方式执行

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);
        }
    }
}

上传和下载函数特别值得一提,因为它们与 `Stream` 对象一起工作,但正如您所看到的,您对它们的操作与正常处理流没有不同。您只需要创建一个流对象并将其传递给服务;我们已经为流式传输模式配置了服务,因此数据传输会得到适当的处理。

这里我使用的一个技巧是 `Stream` 的一个扩展方法,它只是在两个流之间复制数据。该扩展方法名为“`CopyTo`”,看起来如下

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);
        }
    }
}

我将此扩展方法保留在我的服务库中,因为我的客户端和服务的本身都使用它。

最后,删除方法

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();
    }

}

当您删除文件时,服务只需要您要删除的文件的虚拟路径;我将此虚拟路径存储在 `ListItem` 对象的一个 `SubItem` 中,当填充 `ListView` 控件时。我在这里所做的只是读取该值并将其发送回文件服务。

当您下载、上传和删除文件时,您还应该能够看到来自我们主机在控制台窗口中的日志消息。这样,如果您有远程客户端(而不是像我们在本地环境中拥有的同一个计算机上的客户端),您可以直接跟踪它们的行为。

进一步发展

显然,这是一个相当基本的实现,旨在向您展示如何使用 WCF 实现此类功能。要在生产环境中使用,您很可能需要考虑安全要求。如何限制多用户环境下的文件访问?如果您要在公共终结点上托管服务,如何保护它,使其只有授权的客户端才能连接?关于更技术性的考虑,例如限制文件大小和强制执行传输限制,可能按用户可配置?

其中一些要求可以通过对服务进行适当的更改来实现;例如,为服务提供用户名和密码,服务将对照数据库进行身份验证,或者使用 Windows 安全性来控制对文件的访问。

这些类型的考虑我还没有自己触及,但至少这里提供的代码将为您应对这些问题打下坚实的基础。

就是这样。这里的所有代码都可以在附加到本文的存档中查看和进行实验。如果您有任何问题,我很乐意回答!

感谢阅读。

历史

  • 2009/03/02:第一个版本。
© . All rights reserved.