故事從我想在兩個獨立執行 PowerShell 程序間傳輸資料說起。

最開始的想法是寫個迷你 WebAPI,但是寫 WebAPI 不是 PowerShell 的強項,改用 C# 實現有點繞遠路,於是我想起 Named Pipe,一個簡單輕巧的 IPC 選項。過去沒認真研究過,剛好利用機會讓技能樹多長一支新芽。

實際動手後發現程式比想像好寫,跨語言應用也很簡單,於是我分別再試寫了 Python、JavaScript 與 Go 版客戶端,完成一場大亂鬥,筆記留念。 先補充背景知識。

跨程序溝通有個專門術語 - Interprocess Communictaion(IPC),在 Windows 平台有以下選擇:Clipboard、COM、Data Copy、DDE、File Mapping、Mailslots、Pipes、RPC、Windows Sockets。(延伸閱讀:Windows IPC 方式整理) 而 Pipes 分為匿名及具名,匿名僅適用於父子程序溝通,具名管道 Named Pipe 便是本次的主角。

我用 PowerShell 實作了一個簡單的 Named Pipe 伺服器,透過 while 無窮迴圈不斷建立新管道等待連線及處理需求。這種寫法使用單一執行緒消化請求,多個請求併發時需排隊,針對高負載情境一般要改成多緒平行處理,但本次的重點在學習原理,就不多花功夫研究。相關說明我寫在註解,直接奉上程式碼:

$pipeName = 'PSNamedPipeDemo'
Write-Host "* Named Pipe Server [$pipeName]"
$running = $true
while ($running) {
    # 不斷建立新管道,採讀寫雙向模式
    $pipe = new-object System.IO.Pipes.NamedPipeServerStream($pipeName, [System.IO.Pipes.PipeDirection]::InOut)
    try {
        # 等待連線
        $pipe.WaitForConnection()
        # 建立 StreamReader 和 StreamWriter 以便讀寫資料
        $sr = new-object System.IO.StreamReader($pipe)
        $sw = new-object System.IO.StreamWriter($pipe)
        $sw.AutoFlush = $true # 寫完訊息立即送出緩衝區內容
        $msg = $sr.ReadLine()
        switch ($msg) {
            '/exit' { # 接受 '/exit' 指令,結束 Server
                Write-Host 'Exiting...'
                $sw.WriteLine('Goodbye!')
                $running = $false
            }
            '/guid' { # 接受 '/guid' 指令,回傳 GUID
                $guid = New-Guid
                Write-Host "Generating GUID: " $guid
                $sw.WriteLine($guid)
            }
            Default { # 其他情況,複誦對方傳送內容
                Write-Host $msg
                $sw.WriteLine("echo: $msg")       
            }
        }
    } 
    finally {
        # 等待管道清空(對方讀取完畢)
        $pipe.WaitForPipeDrain()
        $pipe.Dispose()
    }
}

客戶端的做法很類似,差別在改用 NamedPipeClientStream,若伺服器端在本機,伺服器名稱用 ".",讀寫資料一樣用 StreamWriter、StreamReader 即可。

param(
    [parameter(Mandatory=$true)]
    [string]$msg
)
$pipeName = 'PSNamedPipeDemo'
$pipe = new-object System.IO.Pipes.NamedPipeClientStream(".", $pipeName, [System.IO.Pipes.PipeDirection]::InOut)
$pipe.Connect()
$sw = new-object System.IO.StreamWriter($pipe)
$sw.AutoFlush = $true
$sw.WriteLine($msg)
$pipe.WaitForPipeDrain()
$sr = new-object System.IO.StreamReader($pipe)
Write-Host $sr.ReadLine()
$pipe.WaitForPipeDrain()
$pipe.Dispose()

測試成功。

C# 寫法與 PowerShell 大同小異,接著來試試其他語言。

JavaScript / Node.js 我選擇用 net.createConnection() 實現 IPC 通訊

const net = require('net');
const readline = require('readline');
const pipePath = '\\\\.\\pipe\\PSNamedPipeDemo';

if (process.argv.length > 2) {
    message = process.argv[2];
    sendMessage(process.argv[2]).then((response) => {
        console.log(response);
    });
}
else {
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });
    rl.question('Enter message: ', (answer) => {
        sendMessage(answer).then((response) => {
            console.log(response);
        });
        rl.close();
    });
}

function sendMessage(msg)  {
    return new Promise((resolve, reject) => {
        const client = net.createConnection(pipePath);
        client.write(msg + '\n');
        client.on('data', (data) => {
            resolve(data.toString());
        });
    });
}

Python 把 \\.\pipe\PSNamedPipeDemo 路徑當成檔案讀寫即可:

import os
import sys

if len(sys.argv) > 1:
    message = sys.argv[1]
else:
    message = input("Enter a message: ")

pipe_path = r'\\.\pipe\PSNamedPipeDemo'

try:
    with open(pipe_path, 'r+b', buffering=0) as f:
        f.write((message + "\n").encode())
        f.flush()

        data = f.read(100)
        reply = data.decode('utf-8')
        print(reply)
        
except Exception as e:
    print(f"Error: {e}")

Go 也是將 Named Pipe 路徑視為一般檔案,用 os.openFile() 搞定。

package main

import (
	"bufio"
	"fmt"
	"log"
	"os"
)

func main() {

	args := os.Args
	var message string
	if len(args) > 1 {
		message = args[1]
	} else {
		fmt.Print("Enter a message: ")
		reader := bufio.NewReader(os.Stdin)
		message, _ = reader.ReadString('\n')
	}

	pipePath := `\\.\pipe\PSNamedPipeDemo`
	f, err := os.OpenFile(pipePath, os.O_RDWR, 0777)
	if err != nil {
		log.Fatalf("error opening file: %v", err)
	}
	defer f.Close()

	f.WriteString(message + "\n")
	err = f.Sync()
	if err != nil {
		log.Fatalf("error syncing file: %v", err)
	}
	data := make([]byte, 100)
	n, err := f.Read(data)
	if err != nil {
		log.Fatalf("error reading file: %v", err)
	}
	reply := string(data[:n])
	fmt.Println(reply)

}

啟動 PowerShell Named Pipe 伺服器,分別從 JavaScript、Python、Go 客戶端送出訊息並讀取回應,完成多語言大亂鬥。

最後,我好奇這個 while ($true) 的單執行緒版本伺服器是否能扛住併發請求,便修改了 Go 版客戶端借用前陣子剛學會的 Goroutine 平行運算功能同時發出 100 次請求。其中的眉角是當伺服器唯一的 Instance 忙著處理其他請求時,os.OpenFile() 會得到 All pipe instances are busy 錯誤;若遇到結束上次 NamedPipeServerStream 還來不及新建的空窗期,會得到 The system cannot find the file specified. 錯誤。因此,因應併發情境得自己加上等待重試邏輯或是改用 go-winio 等專用程式庫。

我捏了一個大致可跑完 100 次的土砲重試版:(數量再多需處理 NamedPipeServerStream 空窗問題,先跳過)

package main

import (
	"fmt"
	"log"
	"os"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

func main() {

	pipePath := `\\.\pipe\PSNamedPipeDemo`
	var wg sync.WaitGroup
	var succCount int32
	for i := 0; i < 100; i++ {
		wg.Add(1)
		msg := "Req-" + strconv.Itoa(i) + "\n"
		go func() {
			defer wg.Done()

			var f *os.File
			var err error
			for {
				f, err = os.OpenFile(pipePath, os.O_RDWR, 0777)
				if err != nil {
					if strings.Contains(err.Error(), "All pipe instances are busy") {
						time.Sleep(10 * time.Millisecond) // Wait for 1ms before retrying
						continue
					} else {
						log.Fatalf("error opening file: %v", err)
					}
				}
				break
			}
			defer f.Close()

			f.WriteString(msg)
			err = f.Sync()
			if err != nil {
				log.Fatalf("error syncing file: %v", err)
			}
			data := make([]byte, 100)
			n, err := f.Read(data)
			if err != nil {
				log.Fatalf("error reading file: %v", err)
			}
			reply := string(data[:n])
			fmt.Print(reply)
			atomic.AddInt32(&succCount, 1)
			
		}()
	}
	wg.Wait()
	fmt.Println("Total Success Count: ", succCount)
}

併發 100 次請求測試通過。

演練完畢,收工。

The article discusses using Named Pipes for inter-process communication (IPC) in PowerShell, comparing it with other methods like WebAPI. It provides a PowerShell implementation of a Named Pipe server and clients in PowerShell, JavaScript, Python, and Go, testing cross-language communication. It also explores handling high load with parallel requests in Go.


Comments

# by

看 SQL SERVER 的網路組態設定有一個具名管道,一直沒看懂是做什麼用的,應該就是跟這篇提到的技術一樣?使用具名管道連上 SQL SERVER? 不知道用這種連接方式連上 SQL SERVER 是否有什麼不可取代的優點?或是有不可取代的地方?

# by Jeffrey

to 貴,若伺服器與客戶端在同一台機器,Named Pipe 比 TCP/IP 輕巧,Overhead 低,有些古老客戶端只能使用 Named Pipe (我太嫩了,沒見過)。 除此之外,SQL Server 使用 TCP 還是主流做法,我覺得沒必要刻意選擇 Named Pipe。

Post a comment