2019 年 3 月 2 日更新 Aloxaf 实现了 Linux 版本的 Use-RawPipeline,取名为 Use-PosixPipeline。
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
我们来分析分析这段命令执行的时候发生了什么:
- PowerShell 执行外部命令
git format-patch HEAD~3
,并得到其标准输出stdout1
; - PowerShell 猜测
stdout1
的编码,并按行分割,向管道中输出许多字符串; Out-File
命令接收到一堆字符串,它会向patch.patch
用 UTF8 编码输出每个字符串(然后紧接着一个换行)。
由此分析,我们的结论:
- 在
stdout1
的数据进到Out-File
之前,它就已经被 PowerShell 破坏得不成样子了; Out-File
会选择\r\n
做换行符,这跟git
输出的换行符是\r
、\n
还是\r\n
没有半毛钱关系;注意,通常来说按行分割的规则是每个\r
、\n
都要分割,但在\r
之后立刻出现的\n
不会造成空行(简而言之,就是\r\n
或\r
或\n
都是可接受的分行符),而按行拼接的时候,换行符自然就是平台首选;Out-File
的参数-Encoding UTF8
实际上是掩耳盗铃——它只不过恰好又等于git
原来产生的编码罢了;如果一个程序输出 UTF16LE(Windows 的说法是 Unicode)编码,那么这样做并不能忠实还原程序的输出。
别忘了在 PowerShell 上 >
只不过是 | Out-File
的语法糖,所以别指望 git format-patch HEAD~3 > patch.patch
在 PowerShell 上输出正确的结果。
但是其实 PowerShell 也是很尴尬,总不能扔一堆字节给用户吧?这个可能真可以!
解决方案
用 Win32 写东西,简直就是从集合论开始定义自然数一路说到实分析和表示论!
我的第一版 Use-RawPipeline
的原理可以总结如下:
用
Start-Process
启动进程并等待结束,把输出输入重定向,并且在管道上输出一个代表了上个程序标准输出的对象,以便下一个命令继续使用或者成为最终消费者。
这里的问题是很大的——它不能交互式执行,前一个程序必须完全结束运行,下一个程序才能开始运行……总之,这并不是一个管道。
第二版的 Use-RawPipeline
的模型更加清晰,可以总结如下:
- 有两个接口
ITeedProcess
和ITeedProcessStartInfo
,后者实例具有Invoke
方法来启动程序,如果启动成功则返回一个ITeedProcess
,否则抛出一个异常; - 用户通过
Use-RawPipeline
等命令创建、连接ITeedProcessStartInfo
; - 用户通过消费 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 版本,它使用 CreatePipe
和 CreateProcess
,做的是真正的管道和重定向,没有临时文件存储,也没有不必要的流复制操作:两个相邻的外部命令调用中,两个外部命令的标准输出、标准输入是直接通过一个匿名管道连接的,而不是各自都和 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
,具体来说:
- 它调用
c.Invoke()
;c.Invoke()
将调用b.Invoke()
,拿出b
的标准输出流读取句柄,然后创建c
自己的进程,如果c
的进程创建失败,则终止b
的进程;b.Invoke()
将调用a.Invoke()
,拿出a
的标准输出流读取句柄,然后创建b
自己的进程,如果b
的进程创建失败,则终止a
的进程;a.Invoke()
将以读模式打开先前指定的文件,并把这个句柄当作自己的标准输出读取句柄;
- 它拿出
c
的标准输出流读取句柄,并读取流的内容,每发现一行,就输出一个字符串到管道。
Invoke-NativeCommand
CreateProcess
重载(参数集)
参数:-FilePath <string>
占据第一位,-ArgumentList <string[]>
占据剩余位置,-WorkingDirectory <string>
设置子进程的初始工作目录(默认为 .
),-StandardInput <ITeedProcessStartInfo>
可以从管道输入(可选),用于重定向子进程的标准输入。
这个 cmdlet 创建一个 PipedProcess
供后续管道消费。
CreateProcessWithStandardErrorRedirection
重载
比 CreateProcess
多两个参数:-ErrorFile <string>
和 -AppendError
。可以重定向子进程的标准错误到文件。
Get-RawPipelineFromFile
只有一个参数:-InputFile <string>
(占据第一位),参数的别名有 i
、if
、in
、stdin
。这个 cmdlet 创建一个 ConcatenateFileStartInfo
供后续管道消费。
Receive-RawPipeline
CommonEncoding
重载
三个参数:-StandardInput <ITeedProcessStartInfo>
可以从管道输入,-CommonEncoding { Auto | Byte | UTF8 | UTF16LE | UTF16BE | UTF32 }
(默认 Auto
)占据第一位,-Raw
是一个开关。
这个 cmdlet 消费输入的 ITeedProcessStartInfo
,它把它的数据按照 -CommonEncoding
指定的编码解读为字符串,若 -Raw
启用,则该 cmdlet 阻塞到进程结束,并在结束后返回一个字符串;若 -Raw
未启用,则该 cmdlet 不阻塞,只要进程的标准输出产生新的数据,cmdlet 就会按行输出(多个)字符串。
注意,如果 -CommonEncoding
是 Byte
,则该 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 驱动的评论。