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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.71/5 (90投票s)

2004年5月26日

8分钟阅读

viewsIcon

507852

downloadIcon

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 显示了这些类及其之间的关系。

解决方案中包含几个接口,例如 IClientChannelIInterProcessConnection,所有这些都已编译到 AppModule.InterProcessComm 程序集中。引入这些接口是为了将命名管道实现与参与 IPC 的客户端进行抽象。遵循“松耦合”的基本面向对象原则,我们的客户端应用程序在与服务器交换消息时将使用接口,这样可以根据需要更改特定的 IPC 协议。

下面概述了 .NET 命名管道解决方案中类的主要职责。

  • NamedPipeNative:这个实用类公开了用于命名管道通信的 kernel32.dll 方法。它还定义了一些错误代码和方法参数值的常量。
  • NamedPipeWrapper:这个类是对 NamedPipesNative 的封装。它使用公开的 kernel32.dll 方法提供受控的命名管道功能。
  • APipeConnection:一个 abstract 类,它定义了创建命名管道连接、读取和写入数据的方法。这个类被 ClientPipeConnectionServerPipeConnection 类继承,它们分别由客户端和服务器应用程序使用。
  • ClientPipeConnection:供客户端应用程序使用命名管道与服务器应用程序进行通信。
  • ServerPipeConnection:允许命名管道服务器创建连接并与客户端交换数据。
  • PipeHandle:保存操作系统本地句柄和管道连接的当前状态。
图 1:命名管道 UML 静态图

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 部分。

许可证

本文没有明确附加许可,但可能包含文章文本或下载文件本身的使用条款。如有疑问,请通过下方的讨论区联系作者。作者可能使用的许可列表可以在 此处找到。

© . All rights reserved.