在 .NET 中使用命名管道进行进程间通信:第 1 部分






4.71/5 (90投票s)
2004年5月26日
8分钟阅读

507852

8709
本文探讨了一种在 .NET 应用程序之间实现基于命名管道的进程间通信的方法
1. 引言
您是否曾经需要与同一台计算机上运行的两个 .NET 应用程序之间交换数据?例如,一个网站与一个 Windows 服务通信?.NET Framework 提供了几种不错的进程间通信 (IPC) 选项,例如 Web 服务和 Remoting,其中最快的是使用 TCP 通道和二进制格式化的 Remoting。
然而,问题在于 Remoting 相对较慢,大多数情况下这无关紧要,但如果您需要从一个应用程序频繁地“进行大量调用”到另一个应用程序,并且您的首要关注点是性能,那么 Remoting 可能会成为障碍。导致 Remoting 速度慢的,与其说是通信协议,不如说是序列化。
总的来说,Remoting 非常棒,但在 IPC 仅限于本地计算机的情况下,它会带来不必要的开销。这就是我开始研究替代方案的原因,即不涉及二进制序列化且能提供快速轻量级 IPC 的命名管道。
本文的第一部分探讨了在 .NET 应用程序之间实现基于命名管道的 IPC 的一种方法。在第二部分中,我们将研究如何构建一个管道服务器和与之通信的客户端。
请记住,本解决方案最有利的情况是,一个应用程序正在与同一台机器上或同一 LAN 内的另一个应用程序频繁交换简短文本消息。对于结构化数据交换,这些文本消息也可以是 XML 文档或序列化的 .NET 对象。由于命名管道仅在 LAN 内可访问,并且假定安全性将由现有基础结构处理,因此未实现安全层。
有关命名管道的更多信息,请访问 MSDN Library。本解决方案的一部分 NamedPipeNative
类基于 Jonathan Hawkins 的命名管道 Remoting 通道。
2. 类
现在让我们来看一下命名管道解决方案中的一些类和方法。下面的图 1 显示了这些类及其之间的关系。
解决方案中包含几个接口,例如 IClientChannel
和 IInterProcessConnection
,所有这些都已编译到 AppModule.InterProcessComm
程序集中。引入这些接口是为了将命名管道实现与参与 IPC 的客户端进行抽象。遵循“松耦合”的基本面向对象原则,我们的客户端应用程序在与服务器交换消息时将使用接口,这样可以根据需要更改特定的 IPC 协议。
下面概述了 .NET 命名管道解决方案中类的主要职责。
NamedPipeNative
:这个实用类公开了用于命名管道通信的 kernel32.dll 方法。它还定义了一些错误代码和方法参数值的常量。NamedPipeWrapper
:这个类是对NamedPipesNative
的封装。它使用公开的 kernel32.dll 方法提供受控的命名管道功能。APipeConnection
:一个abstract
类,它定义了创建命名管道连接、读取和写入数据的方法。这个类被ClientPipeConnection
和ServerPipeConnection
类继承,它们分别由客户端和服务器应用程序使用。ClientPipeConnection
:供客户端应用程序使用命名管道与服务器应用程序进行通信。ServerPipeConnection
:允许命名管道服务器创建连接并与客户端交换数据。PipeHandle
:保存操作系统本地句柄和管道连接的当前状态。
3. 创建命名管道
作为不同的命名管道操作的一部分,我们首先将看到如何创建服务器命名管道。
每个管道都有一个名称,正如“命名管道”所暗示的那样。服务器管道名称的确切语法是 \\.\pipe\PipeName
。“PipeName
”部分实际上是管道的特定名称。为了连接到该管道,客户端应用程序需要创建一个具有相同名称的客户端命名管道。如果客户端位于不同的机器上,名称还应包含服务器,例如 \\SERVER\pipe\PipeName
。
以下 NamedPipeWrapper
中的 static
方法用于实例化服务器命名管道。
public static PipeHandle Create(string name,
uint outBuffer,
uint inBuffer) {
name = @"\\.\pipe\" + name;
PipeHandle handle = new PipeHandle();
for (int i = 1; i<=ATTEMPTS; i++) {
handle.State = InterProcessConnectionState.Creating;
handle.Handle = NamedPipeNative.CreateNamedPipe(
name,
NamedPipeNative.PIPE_ACCESS_DUPLEX,
NamedPipeNative.PIPE_TYPE_MESSAGE |
NamedPipeNative.PIPE_READMODE_MESSAGE |
NamedPipeNative.PIPE_WAIT,
NamedPipeNative.PIPE_UNLIMITED_INSTANCES,
outBuffer,
inBuffer,
NamedPipeNative.NMPWAIT_WAIT_FOREVER,
IntPtr.Zero);
if (handle.Handle.ToInt32() !=
NamedPipeNative.INVALID_HANDLE_VALUE) {
handle.State = InterProcessConnectionState.Created;
break;
}
if (i >= ATTEMPTS) {
handle.State = InterProcessConnectionState.Error;
throw new NamedPipeIOException("Error creating named pipe "
+ name + " . Internal error: " +
NamedPipeNative.GetLastError().ToString(),
NamedPipeNative.GetLastError());
}
}
return handle;
}
通过调用 NamedPipeNative.CreateNamedPipe
,上述方法会创建一个双向的、消息类型的命名管道,并将其设置为阻塞模式。还指定允许该管道的实例数量不限。
如果管道创建成功,CreateNamedPipe
将返回本地管道句柄,我们将其分配给我们的 PipeHandle
对象。本地句柄是指向命名管道的操作系统指针,并在所有管道相关操作中使用。引入 PipeHandle
类是为了保存本地句柄并跟踪管道的当前状态。命名管道的状态定义在 InterProcessConnectionState
枚举中,它们对应于不同的操作——读取、写入、等待客户端等。
假设服务器命名管道已成功创建,它现在可以开始监听客户端连接了。
4. 连接客户端管道
服务器命名管道需要设置为监听模式,以便客户端管道可以连接到它。这是通过调用 NamedPipeNative.ConnectNamedPipe
方法完成的。因为我们的管道是以阻塞模式创建的,所以调用此方法会将当前线程置于等待模式,直到有客户端管道尝试建立连接。
通过调用 NamedPipeNative.CreateFile
方法创建并连接到监听服务器管道的客户端命名管道,该方法又调用相应的 Kernel32
方法。下面的代码是 NamedPipeWrapper.ConnectToPipe
的一部分,说明了这一点。
public static PipeHandle ConnectToPipe(string pipeName,
string serverName) {
PipeHandle handle = new PipeHandle();
// Build the name of the pipe.
string name = @"\\" + serverName + @"\pipe\" + pipeName;
for (int i = 1; i<=ATTEMPTS; i++) {
handle.State = InterProcessConnectionState.ConnectingToServer;
// Try to connect to the server
handle.Handle = NamedPipeNative.CreateFile(name,
NamedPipeNative.GENERIC_READ | NamedPipeNative.GENERIC_WRITE,
0, null, NamedPipeNative.OPEN_EXISTING, 0, 0);
在创建 PipeHandle
对象并构建管道名称后,我们调用 NamedPipeNative.CreateFile
方法来创建客户端命名管道并将其连接到指定的服务器管道。在我们的示例中,客户端管道配置为支持读取和写入。
如果客户端管道创建成功,CreateFile
方法将返回与客户端命名管道对应的本地句柄,我们将在后续操作中使用它。如果由于某种原因客户端管道创建失败,该方法将返回 -1
,即设置为 INVALID_HANDLE_VALUE
常量的值。
在客户端命名管道可用于读取和写入之前,还需要做一件事。我们需要将其句柄模式设置为 PIPE_READMODE_MESSAGE
,这将允许我们读写消息。这是通过调用 NamedPipeNative.SetNamedPipeHandleState
完成的。
if (handle.Handle.ToInt32() != NamedPipeNative.INVALID_HANDLE_VALUE) {
// The client managed to connect to the server pipe
handle.State = InterProcessConnectionState.ConnectedToServer;
// Set the read mode of the pipe channel
uint mode = NamedPipeNative.PIPE_READMODE_MESSAGE;
if (NamedPipeNative.SetNamedPipeHandleState(handle.Handle,
ref mode, IntPtr.Zero, IntPtr.Zero)) {
break;
}
每个客户端管道都与一个服务器管道实例进行通信。如果服务器管道已达到其最大实例数,则创建客户端管道将返回错误。在这种情况下,检查错误类型、等待一段时间然后再次尝试创建客户端命名管道非常有用。错误类型检查是通过 NamedPipeNative.GetLastError
方法完成的。
if (NamedPipeNative.GetLastError() ==
NamedPipeNative.ERROR_PIPE_BUSY)
NamedPipeNative.WaitNamedPipe(name, WAIT_TIME);
5. 读写数据
命名管道不支持流查找,这意味着在从命名管道读取时,我们无法提前确定消息的大小。作为一种变通方法,我们引入了一种简单的消息格式,该格式允许我们先指定消息的长度,然后再读取或写入消息本身。
我们的解决方案不需要处理非常大的消息,因此我们将使用 System.Int32
变量来指定消息长度。为了表示一个 Int32
,我们需要四个字节,因此我们的消息的前四个字节将始终包含消息长度。
5.1. 向命名管道写入数据
下面的 NamedPipeWrapper.WriteBytes
方法将消息写入命名管道,该管道由作为输入参数提供的句柄表示。消息本身已使用 UTF8 编码转换为字节,并以字节数组的形式传递。
public static void WriteBytes(PipeHandle handle, byte[] bytes) {
byte[] numReadWritten = new byte[4];
uint len;
if (bytes == null) {
bytes = new byte[0];
}
if (bytes.Length == 0) {
bytes = new byte[1];
bytes = System.Text.Encoding.UTF8.GetBytes(" ");
}
获取消息的长度
len = (uint)bytes.Length;
handle.State = InterProcessConnectionState.Writing;
获取消息长度的字节表示,并首先写入这四个字节
if (NamedPipeNative.WriteFile(handle.Handle,
BitConverter.GetBytes(len), 4, numReadWritten, 0)) {
写入消息的其余部分
if (!NamedPipeNative.WriteFile(handle.Handle, bytes,
len, numReadWritten, 0)) {
handle.State = InterProcessConnectionState.Error;
throw new NamedPipeIOException("Error writing to pipe.
Internal error: " + NamedPipeNative.GetLastError().ToString(),
NamedPipeNative.GetLastError());
}
}
else {
handle.State = InterProcessConnectionState.Error;
throw new NamedPipeIOException("Error writing to pipe.
Internal error: " + NamedPipeNative.GetLastError().ToString(),
NamedPipeNative.GetLastError());
}
handle.State = InterProcessConnectionState.Flushing;
最后冲刷管道。冲刷命名管道可确保任何缓冲的数据都已写入管道,并且不会丢失。
Flush(handle);
handle.State = InterProcessConnectionState.FlushedData;
}
5.2. 从命名管道读取数据
为了从命名管道读取消息,我们首先需要通过将前四个字节转换为整数来确定其长度。然后我们可以读取其余的数据。下面的 NamedPipeWrapper.ReadBytes
方法说明了这一点。
public static byte[] ReadBytes(PipeHandle handle, int maxBytes) {
byte[] numReadWritten = new byte[4];
byte[] intBytes = new byte[4];
byte[] msgBytes = null;
int len;
handle.State = InterProcessConnectionState.Reading;
handle.State = InterProcessConnectionState.Flushing;
读取前四个字节并将其转换为整数
if (NamedPipeNative.ReadFile(handle.Handle, intBytes,
4, numReadWritten, 0)) {
len = BitConverter.ToInt32(intBytes, 0);
msgBytes = new byte[len];
handle.State = InterProcessConnectionState.Flushing;
读取其余数据或抛出异常
if (!NamedPipeNative.ReadFile(handle.Handle, msgBytes, (uint)len,
numReadWritten, 0)) {
handle.State = InterProcessConnectionState.Error;
throw new NamedPipeIOException("Error reading from pipe.
Internal error: " + NamedPipeNative.GetLastError().ToString(),
NamedPipeNative.GetLastError());
}
}
else {
handle.State = InterProcessConnectionState.Error;
throw new NamedPipeIOException("Error reading from pipe.
Internal error: " + NamedPipeNative.GetLastError().ToString(),
NamedPipeNative.GetLastError());
}
handle.State = InterProcessConnectionState.ReadData;
if (len > maxBytes) {
return null;
}
return msgBytes;
}
6. 其他命名管道操作
IPC 的其他一些操作包括断开连接、冲刷和关闭命名管道。
DisconnectNamedPipe
将管道的一端与另一端断开。断开服务器管道允许后者通过释放客户端管道来重新使用。本文的第二部分将在构建多线程命名管道服务器时展示此技术。
FlushFileBuffers
将管道中任何缓冲的数据写入。它经常与写入操作结合使用,然后关闭命名管道。
CloseHandle
用于关闭命名管道并释放其本地句柄。在完成使用命名管道后务必关闭管道以释放任何相关资源,因此任何命名管道操作都应包含在 try-catch-finally
块中,并且 CloseHandle
方法应放在 finally
部分。
许可证
本文没有明确附加许可,但可能包含文章文本或下载文件本身的使用条款。如有疑问,请通过下方的讨论区联系作者。作者可能使用的许可列表可以在 此处找到。