透過C#實作Windows具名管道(Named Pipes)

在設計Console類型的應用程式,對於輸出訊息我們通常會使用Console.WriteLine()來輸出,更進一步頂多就是使用Log之類的概念來輸出到文字檔紀錄,但是有時候在沒有介面的情況下(例如:Windows Services 服務)執行會變得很像瞎子摸象,這時候我們可以透過Windows的具名管道(Named Pipes)來達到這個目的。依據微軟對於Named Pipes的解釋如下:具名管道是一個命名、單向或雙工管道,用於管道伺服器與一或多個管道用戶端之間的通訊。 具名管道的所有實例都會共用相同的管道名稱,但每個實例都有自己的緩衝區和控制碼,並提供個別的管道供用戶端/伺服器通訊使用。 實例的使用可讓多個管道用戶端同時使用相同的具名管道。

建立具名管道類別(NamePipe Class)

/// <summary>
/// 透過NamePipe命名管道來實作共用訊息
/// </summary>
public static class NamedPipe
{
  private static string cPipeName = "MyNamedPipeTest";
  private const int iMaxClients = 5;
  private static readonly System.Collections.Generic.List<System.IO.Pipes.NamedPipeServerStream> _oClients = new System.Collections.Generic.List<System.IO.Pipes.NamedPipeServerStream>();
  private static readonly object _oLock = new object();
  private static bool _bIsListening = false;

  static NamedPipe()
  { System.Threading.Tasks.Task.Factory.StartNew(() => ListenForClients(), System.Threading.Tasks.TaskCreationOptions.LongRunning); }

  private static async System.Threading.Tasks.Task ListenForClients()
  {
    _bIsListening = true;
    while (_bIsListening)
    {
      lock (_oLock)
      {
        if (_oClients.Count >= iMaxClients)
        {
          System.Threading.Monitor.Wait(_oLock, 100);
          continue;
        }
      }

      var oNamedPipeServer = new System.IO.Pipes.NamedPipeServerStream(
          cPipeName,
          System.IO.Pipes.PipeDirection.Out,
          iMaxClients,
          System.IO.Pipes.PipeTransmissionMode.Message,
          System.IO.Pipes.PipeOptions.Asynchronous
      );

      try
      {
        await oNamedPipeServer.WaitForConnectionAsync();
        lock (_oLock)
        { _oClients.Add(oNamedPipeServer); }
        System.Threading.Tasks.Task.Factory.StartNew(() => MonitorClient(oNamedPipeServer), System.Threading.Tasks.TaskCreationOptions.LongRunning);
      }
      catch
      { oNamedPipeServer.Dispose(); }
    }
  }

  private static async System.Threading.Tasks.Task MonitorClient(System.IO.Pipes.NamedPipeServerStream oClient)
  {
    try
    {
      while (oClient.IsConnected)
      { await System.Threading.Tasks.Task.Delay(500); }
    }
    catch
    { /* 異常狀況下視同斷線 */ }
    finally
    {
      lock (_oLock)
      {
        _oClients.Remove(oClient);
        System.Threading.Monitor.Pulse(_oLock);
      }
      oClient.Dispose();
    }
  }

  /// <summary>
  /// 將指定的文字訊息發送給所有連線中的客戶端
  /// </summary>
  /// <param name="cMessage">欲傳送的文字訊息</param>
  public static void Write(string cMessage)
  {
    byte[] buffer = System.Text.Encoding.UTF8.GetBytes(cMessage);
    lock (_oLock)
    {
      foreach (var client in _oClients.ToArray())
      {
        if (client.IsConnected)
        {
          try
          {
            client.Write(buffer, 0, buffer.Length);
            client.Flush();
          }
          catch
          {
            _oClients.Remove(client);
            client.Dispose();
          }
        }
        else
        {
          _oClients.Remove(client);
          client.Dispose();
        }
      }
    }
  }
}

在C#中使用具名管道伺服器(NamePipe Server)

接著我們就可以輕易地在C# Console應用程式中使用具名管道來進行訊息的傳遞。

while (true)
{
  var cDate = $"{System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}\n";
  NamedPipe.Write(cDate);
  Console.Write(cDate);
  System.Threading.Thread.Sleep(100); //測試高速輸出(間接驗證類別效能)
}

在C#中使用具名管道客戶端(NamePipe Client)

假設NamePipe Server已經在執行中,我們可以透過以下程式碼來建立NamePipe Client來接收Server端的訊息。

using var oClient = new System.IO.Pipes.NamedPipeClientStream(".", "MyNamedPipeTest", System.IO.Pipes.PipeDirection.In);
oClient.Connect();
byte[] bytBuffer = new byte[1024];
while (oClient.IsConnected)
{
  int bytRead = oClient.Read(bytBuffer, 0, bytBuffer.Length);
  if (bytRead > 0)
  { Console.Write(System.Text.Encoding.UTF8.GetString(bytBuffer, 0, bytRead)); }
}

在PowerShell中使用具名管道接收伺服器端的訊息

如果已經採用了NamePipe具名管道,這代表我們可以用各式的終端機工具來接收Server端的訊息,這反而是最常見的方法,舉例來說我們可以使用PowerShell來接收伺服器端的訊息。

[Console]::OutputEncoding = [System.Text.Encoding]::UTF8  

$pipeServer = "." # 如果是遠端機器的話請填入伺服器IP位址
$pipeName = "MyNamedPipeTest"
$bufferSize = 1024
$disconnectTimeout = 180
Write-Host "*** NamedPipe Client ***`n"
$disconnectStartTime = $null

while ($true) {
  Write-Host ("Connecting to named pipe ($pipeName)...")
  try {
    $pipe = New-Object System.IO.Pipes.NamedPipeClientStream($pipeServer, $pipeName, [System.IO.Pipes.PipeDirection]::In)
    $pipe.Connect(5000)

    # 連上並重設斷線時間
    $disconnectStartTime = $null

    Write-Host "Connected, starting to receive messages...`n"

    while ($pipe.IsConnected) {
      $buffer = New-Object byte[] $bufferSize
      $bytesRead = $pipe.Read($buffer, 0, $bufferSize)
      if ($bytesRead -gt 0) {
        $receivedData = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $bytesRead)
        [Console]::Write($receivedData)
      }
    }

    Write-Host "`nServer disconnected.`n"
    $pipe.Dispose()

    # 記錄這次斷線發生的時間
    $disconnectStartTime = Get-Date
  }
  catch {
    # 嘗試連線失敗
    if (-not $disconnectStartTime) {
      $disconnectStartTime = Get-Date
    }
    if ($pipe) {
      $pipe.Dispose()
    }
  }

  $elapsed = (Get-Date) - $disconnectStartTime
  if ($elapsed.TotalSeconds -ge $disconnectTimeout) {
    Write-Host ("Disconnection time exceeded $disconnectTimeout seconds. Exiting.")
    break
  }
  else {
    Write-Host ("Waiting for next connection. Elapsed time: {0:N0} seconds.`n" -f $elapsed.TotalSeconds)
    Start-Sleep -Seconds 5
  }
}

跨主機的具名管道之相關說明

A. 如果需要跨主機使用具名管道,類別程式碼就需要添加安全性的參數,例如:

//設定具名管道的安全性(WellKnownSidType.WorldSid = Everyone)
var oSecurity = new System.IO.Pipes.PipeSecurity();
oSecurity.AddAccessRule(new System.IO.Pipes.PipeAccessRule(
    new System.Security.Principal.SecurityIdentifier(System.Security.Principal.WellKnownSidType.WorldSid, null),
    System.IO.Pipes.PipeAccessRights.FullControl,
    System.Security.AccessControl.AccessControlType.Allow
));

var oNamedPipeServer = new System.IO.Pipes.NamedPipeServerStream(
    cPipeName,
    System.IO.Pipes.PipeDirection.Out,
    iMaxClients,
    System.IO.Pipes.PipeTransmissionMode.Message,
    System.IO.Pipes.PipeOptions.Asynchronous,
    4096,
    4096,
    oSecurity  //在此添加安全性參數
);

B. 提供具名管道訊息的機器,需要在具有進階安全性的Windows Defender防火牆(wf.msc)中,針對輸入規則檔案及印表機共用(SMB-In),進行啟用,這樣才能讓其他機器透過具名管道來進行通訊。

C. 進行跨主機具名管道連線前,建議先在Windows底下建立好對方伺服器的帳號密碼(例如在檔案總管輸入\\ServerIP\pipe\NamepipeName),並勾選儲存密碼,這樣在連線時會比較順利。

Windows Console Services C# Class NamedPipes NamedPipeServer NamedPipesClient NamedPipePowerShell