WCF 基于 TCP 的文件服务器






4.96/5 (29投票s)
演示如何实现一个基于 WCF 的远程文件存储库。
引言
本文演示了如何使用 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)
- 包含流式数据的参数必须是该方法中的唯一参数。
- 参数和返回值类型中至少有一种必须是 `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:第一个版本。