Windows PowerShell 的二进制管道

PowerShell 在众 shell 众最突出的特色就是面向对象,这也招致了无数的误解——这个现象在 PowerShell 开源之后尤其严重。其中最为显著的问题就是 PowerShell 处理控制台程序标准流的行为。在 PowerShell 的 GitHub 仓库 上已经有大量这样的讨论,例如 #2145 issue#707 issue,社区也在致力于解决这个问题,如 #1908 issue#3731 issue#2450 pull request 等。

English speakers might refer to the GitHub repository of this package.

之前我在 PowerShell Gallery 发布过一个 Use-RawPipeline 包,但是它的效率堪忧。今天我发布了该包的全新实现,它真正地解决了 PowerShell 处理控制台程序的痛点。如果你激动到无法听我娓娓道来 PowerShell 的这段血泪史,请直接看 解决方案 或者 文档

PowerShell 对控制台程序做了什么?

正统的命令是面向对象的

PowerShell 的世界里,正统的命令是 cmdlets、statements 等。例如在我的 blog 构建系统的框架大概是这样的:

# PowerShell
$metadata = Get-ChildItem '..\PostsSource' -Directory | ForEach-Object {
    Convert-MarkdownToHtml "$($_.FullName)\post.md" "..\..\BlogSiteRoot\entries\$($_.Name)\index.html";
    Get-Metadata "$($_.FullName)\post.md" | Write-Output;
};
$archive = ($metadata | ForEach-Object { Convert-MetadataToArchiveHtml $_ }) -join '';
$archive | Set-Content '..\..\BlogSiteRoot\archive\index.html';

其中 Get-ChildItem 顾名思义就是获取一个文件夹的子项目,这里就是获取 ..\PostsSource 文件夹下的所有文件夹(每个这样的文件夹代表一篇博文),这个命令将会向管道输出一堆对象,注意是对象而不是单纯的文本。之后我把每个对象通过 ForEach-Object 命令,后面的大括号包裹的一堆内容是对每个这样的文件夹对象要做的事情,每个这样的对象用 $_ 引用,正是因为传入传出的是对象,我才可以 $_.FullName$_.Name 这样用。

注意 如果你交互式地执行 Get-ChildItem(更简短的写法是 gci),你会看到文字出现在控制台上——这是把对象格式化的结果。你可以用各种不同的格式输出,例如 gci | format-list 以列表输出,gci | out-gridview 在窗口中查看,gci | select mode,creationtime,fullname 输出你关心的属性。

控制台命令是字节流进字节流出

传统控制台程序具有三个流(标准输入、标准输出和标准错误),在许多其他的命令处理程序或者 shell 语言中,管道可以连接前后两个进程的流,例如

# bash
cat urls | sort | unique

cat 命令复制文件到标准输出,sort 命令从标准输入按行读取字符串并升序输出到标准输出,unique 命令从标准输入按行读取字符串并删除连续重复元素后输出到标准输出。这些程序之间的合作基于标准流(特殊的字节流),它们之间的通信有一定的格式要求,有时需要 parse。

字节流还是字符串?PowerShell 自作聪明

虽然标准流可以处理任意二进制数据,但是很多程序其实都是在处理字符串。PowerShell 抓住了这个性质,干了一件它现在会很后悔的事情。任何时候都要记住,自作聪明的结果只能是搬起石头砸自己的脚。

PowerShell 自作聪明地把标准流解读为字符串!

我们来举个例子(例子来自 #2145 issue 中楼主遇到的问题),考虑命令

# PowerShell
git format-patch HEAD~3 | Out-File patch.patch -Encoding utf8

我们来分析分析这段命令执行的时候发生了什么:

  1. PowerShell 执行外部命令 git format-patch HEAD~3,并得到其标准输出 stdout1
  2. PowerShell 猜测 stdout1 的编码,并按行分割,向管道中输出许多字符串;
  3. Out-File 命令接收到一堆字符串,它会向 patch.patch 用 UTF8 编码输出每个字符串(然后紧接着一个换行)。

由此分析,我们的结论:

  1. stdout1 的数据进到 Out-File 之前,它就已经被 PowerShell 破坏得不成样子了;
  2. Out-File 会选择 \r\n 做换行符,这跟 git 输出的换行符是 \r\n 还是 \r\n 没有半毛钱关系;注意,通常来说按行分割的规则是每个 \r\n 都要分割,但在 \r 之后立刻出现的 \n 不会造成空行(简而言之,就是 \r\n\r\n 都是可接受的分行符),而按行拼接的时候,换行符自然就是平台首选;
  3. Out-File 的参数 -Encoding UTF8 实际上是掩耳盗铃——它只不过恰好又等于 git 原来产生的编码罢了;如果一个程序输出 UTF16LE(Windows 的说法是 Unicode)编码,那么这样做并不能忠实还原程序的输出。

别忘了在 PowerShell 上 > 只不过是 | Out-File 的语法糖,所以别指望 git format-patch HEAD~3 > patch.patch 在 PowerShell 上输出正确的结果。

但是其实 PowerShell 也是很尴尬,总不能扔一堆字节给用户吧?这个可能真可以!

解决方案

用 Win32 写东西,简直就是从集合论开始定义自然数一路说到实分析和表示论!

——Win32 programming 有感

我的第一版 Use-RawPipeline 的原理可以总结如下:

Start-Process 启动进程并等待结束,把输出输入重定向,并且在管道上输出一个代表了上个程序标准输出的对象,以便下一个命令继续使用或者成为最终消费者。

这里的问题是很大的——它不能交互式执行,前一个程序必须完全结束运行,下一个程序才能开始运行……总之,这并不是一个管道

第二版的 Use-RawPipeline 的模型更加清晰,可以总结如下:

  1. 有两个接口 ITeedProcessITeedProcessStartInfo,后者实例具有 Invoke 方法来启动程序,如果启动成功则返回一个 ITeedProcess,否则抛出一个异常;
  2. 用户通过 Use-RawPipeline 等命令创建、连接 ITeedProcessStartInfo
  3. 用户通过消费 raw pipeline 的命令去 Invoke 先前创建的 ITeedProcessStartInfo;如果没有出现消费者,程序不会启动,文件不会打开。

举个例子,下面的代码达成例子希望的效果:

# PowerShell

# 简短的写法
run git format-patch HEAD~3 | out2 patch.patch

# 展开的写法
Invoke-NativeCommand -FilePath 'git' -ArgumentList @('format-patch', 'HEAD~3') | Set-RawPipelineToFile -OutputFile 'patch.patch'

对输入的重定向:

# PowerShell
# 假设计算机上有 sort.exe 和 unique.exe

$results = stdin my-urls | run sort | run unique | 2ps

$results = `
    Open-FileAsRawPipeline -InputFile 'my-urls' | `
    Invoke-NativeCommand -FilePath 'sort'       | `
    Invoke-NativeCommand -FilePath 'unique'     | `
    Receive-RawPipeline;

# 默认猜测编码、分行
# 当然,原汁原味的 PowerShell 的完成方式是

$results = gc my-urls | sort | gu

这个新的解决方案目前只有 Windows 版本,它使用 CreatePipeCreateProcess,做的是真正的管道重定向,没有临时文件存储,也没有不必要的流复制操作:两个相邻的外部命令调用中,两个外部命令的标准输出、标准输入是直接通过一个匿名管道连接的,而不是各自都和 PowerShell host 通过管道连接,然后 PowerShell host 中把一个管道的内容搬运到另一个管道里面。

文档

要获取 Use-RawPipeline,在 Windows PowerShell 上输入:

Install-Module -Name Use-RawPipeline -Scope CurrentUser

ITeedProcessStartInfo 接口

该接口的实例代表用户对某个命令执行的希望。

实现该接口的类的构造器通常接收可以为空的 ITeedProcessStartInfo 或不接收它。如果接收了空的 ITeedProcessStartInfo 或者不接收,则说明标准输入不会重定向;否则,将来启动之后的标准输入应该来自接收的这个 ITeedProcessStartInfo 启动的程序的标准输出。用户通过 Invoke 方法实际运行命令,但 Invoke 只可调用一次,此后该实例不应再可用于启动命令,要重启命令,用户需要准备一份相同的副本。

目前已经有的实现是 ConcatenateFileStartInfo(用一个文件的字节流产生标准输出)和 PipedProcessStartInfo(用一个进程产生标准输出)。

ITeedProcess 接口

该接口的实例代表已经启动的一个命令,实例通常由 ITeedProcessStartInfo.Invoke 的调用产生。

  • 调用 ReleaseStandardOutputReadHandle 方法来取得该实例的标准输出流句柄的所有权;
  • HasExited 指示标准输出流在未来是否可以产生更多输出;
  • 调用 Terminate 递归地结束这一串调用;多次调用 Terminate 没有副作用(幂等)。

上述接口的例子

考虑命令

# PowerShell

Open-FileAsRawPipeline -InputFile 'my-urls' | `
    Invoke-NativeCommand -FilePath 'sort'   | `
    Invoke-NativeCommand -FilePath 'unique' | `
    Receive-RawPipeline;

第一个管道前的命令创建了一个 ConcatenateFileStartInfo(a),保存要打开的文件的信息;第二个管道和第一个管道之间的命令创建一个 PipedProcessStartInfo(b) 并把它和之前的 ConcatenateFileStartInfo(a) 串接,以便将来启动的时候通过 ConcatenateFileStartInfo 的标准输出读取句柄获得自己的标准输入读取句柄;第三个管道和第二个管道之间的命令创建另一个 PipedProcessStartInfo(c) 并把它和前一个 PipedProcessStartInfo(b) 串接;第三个管道之后的命令消费前面管道传来的 ITeedProcessStartInfo,具体来说:

  1. 它调用 c.Invoke()
    • c.Invoke() 将调用 b.Invoke(),拿出 b 的标准输出流读取句柄,然后创建 c 自己的进程,如果 c 的进程创建失败,则终止 b 的进程;
    • b.Invoke() 将调用 a.Invoke(),拿出 a 的标准输出流读取句柄,然后创建 b 自己的进程,如果 b 的进程创建失败,则终止 a 的进程;
    • a.Invoke() 将以读模式打开先前指定的文件,并把这个句柄当作自己的标准输出读取句柄;
  2. 它拿出 c 的标准输出流读取句柄,并读取流的内容,每发现一行,就输出一个字符串到管道。

Invoke-NativeCommand

CreateProcess 重载(参数集)

参数:-FilePath <string> 占据第一位,-ArgumentList <string[]> 占据剩余位置,-WorkingDirectory <string> 设置子进程的初始工作目录(默认为 .),-StandardInput <ITeedProcessStartInfo> 可以从管道输入(可选),用于重定向子进程的标准输入。

这个 cmdlet 创建一个 PipedProcess 供后续管道消费。

CreateProcessWithStandardErrorRedirection 重载

CreateProcess 多两个参数:-ErrorFile <string>-AppendError。可以重定向子进程的标准错误到文件。

Get-RawPipelineFromFile

只有一个参数:-InputFile <string>(占据第一位),参数的别名有 iifinstdin。这个 cmdlet 创建一个 ConcatenateFileStartInfo 供后续管道消费。

Receive-RawPipeline

CommonEncoding 重载

三个参数:-StandardInput <ITeedProcessStartInfo> 可以从管道输入,-CommonEncoding { Auto | Byte | UTF8 | UTF16LE | UTF16BE | UTF32 }(默认 Auto)占据第一位,-Raw 是一个开关。

这个 cmdlet 消费输入的 ITeedProcessStartInfo,它把它的数据按照 -CommonEncoding 指定的编码解读为字符串,若 -Raw 启用,则该 cmdlet 阻塞到进程结束,并在结束后返回一个字符串;若 -Raw 未启用,则该 cmdlet 不阻塞,只要进程的标准输出产生新的数据,cmdlet 就会按行输出(多个)字符串。

注意,如果 -CommonEncodingByte,则该 cmdlet 总是尽早输出这些字节,并且 -Raw 会被无视。

CustomEncoding 重载

三个参数:-StandardInput <ITeedProcessStartInfo> 可以从管道输入,-Encoding <string> 以及 -Raw 是一个开关。

这个 cmdlet 消费输入的 ITeedProcessStartInfo,它把它的数据按照 -Encoding 指定的编码解读为字符串,若 -Raw 启用,则该 cmdlet 阻塞到进程结束,并在结束后返回一个字符串;若 -Raw 未启用,则该 cmdlet 不阻塞,只要进程的标准输出产生新的数据,cmdlet 就会按行输出(多个)字符串。

Set-RawPipelineToFile

两个参数:-StandardInput <ITeedProcessStartInfo> 可以从管道输入,-OutputFile <string> 占据第一位。这个 cmdlet 通过把标准输出复制到指定文件来消费送入的 ITeedProcessStartInfo。如果文件已经存在,它会被覆盖。

Add-RawPipelineToFile

两个参数:-StandardInput <ITeedProcessStartInfo> 可以从管道输入,-OutputFile <string> 占据第一位。这个 cmdlet 通过把标准输出复制到指定文件的末尾来消费送入的 ITeedProcessStartInfo。如果文件不存在,它会被创建。

请启用 JavaScript 来查看由 Disqus 驱动的评论。