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

WCF 基于 TCP 的文件服务器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (29投票s)

2009年3月2日

CPOL

12分钟阅读

viewsIcon

189318

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

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

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

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

因此,如果我们需要与流一起传递元数据(例如,在本例中为虚拟路径),我们可以实现一个消息契约来发送此数据。

[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 的文件。如果我只从服务器下载文件,则只需在客户端设置此项。同样,如果我只发送大文件到服务器,则只需在服务器端设置此项。还请注意,只有当您希望在单次传输中传输超过 64K 数据时,才需要手动设置这些设置。

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

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

  • 使用“上传”按钮将文件上传到存储库。
  • 使用“下载”按钮从存储库下载文件。
  • 使用“删除”按钮从存储库删除文件。
  • 在表单加载时以及在存储库发生更改时(例如上传和删除文件)列出文件。

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

要列出文件,我们使用 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();
    }

}

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

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

进一步发展

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

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

我本人还没有涉及这些类型的考虑,但至少此处提供的代码将为您开始解决这些问题奠定坚实的基础。

就这样。此处提供的所有代码都可以在本文附加的存档中查看和使用。如果您有任何问题,我很乐意回答!

感谢阅读。

历史

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