将此放入您的管道

2013 年 4 月 8 日 | Stefan Karpinski

注意: 此帖子已更新,可与 Julia 1.x 一起使用(原始版本使用 Julia 0.1 语法)。

之前的一篇文章 中,我谈到了为什么“shell out”来通过中间 shell 生成外部程序的管道是错误、安全漏洞、不必要的开销和静默错误的常见原因。但这太方便了!为什么不能让运行外部程序的管道既方便又安全呢?实际上,没有真正的理由。shell 本身能够很好地构建和执行管道。原则上,没有什么可以阻止高级语言至少像 shell 一样好地做到这一点——常见的语言只是默认情况下没有这样做,而是要求用户付出额外的努力来安全正确地使用外部程序。有两个主要障碍

这篇文章描述了我们为 Julia 设计和实现的系统,以及它如何避免其他语言中 shell out 的主要缺陷。首先,我将介绍之前文章中示例的 Julia 版本——计算给定目录中包含字符串“foo”的行数。事实证明,Julia 在管道失败时提供完整的、具体的诊断错误消息,揭示了一个令人惊讶且微妙的错误,潜伏在一个看似完全无害的 UNIX 管道中。在修复此错误后,我们将详细介绍 Julia 的外部命令执行和管道构建系统的工作原理,以及为什么它比使用中间 shell 来完成所有繁重工作的传统方法提供更大的灵活性和安全性。

简单的管道,微妙的错误

以下是如何在 Julia 中编写计算包含字符串“foo”的目录中行数的示例(如果您从源代码安装了 Julia,您可以在家中进行操作,将目录更改为 Julia 源目录并执行 cp -a src "source code"; mkdir tmp,然后启动 Julia repl)

julia> dir = "src"; # works in the git repo for Julia

julia> parse(Int, readchomp(pipeline(
           `find $dir -type f -print0`,
           `xargs -0 grep foo`,
           `wc -l`,
       )))
5

此 Julia 命令看起来与我们在之前文章中开始的朴素 Ruby 版本非常相似

`find #{dir} -type f -print0 | xargs -0 grep foo | wc -l`.to_i

但是,它不受相同问题的影响

julia> dir = "source code";

julia> parse(Int, readchomp(pipeline(
           `find $dir -type f -print0`,
           `xargs -0 grep foo`,
           `wc -l`,
       )))
5

julia> dir = "nonexistent";

julia> parse(Int, readchomp(pipeline(
           `find $dir -type f -print0`,
           `xargs -0 grep foo`,
           `wc -l`,
       )))
find: ‘nonexistent’: No such file or directory
ERROR: failed processes:
  Process(`find nonexistent -type f -print0`, ProcessExited(1)) [1]
  Process(`xargs -0 grep foo`, ProcessExited(123)) [123]

julia> dir = "foo'; echo MALICIOUS ATTACK; echo '";

julia> parse(Int, readchomp(pipeline(
           `find $dir -type f -print0`,
           `xargs -0 grep foo`,
           `wc -l`,
       )))
find: ‘foo'; echo MALICIOUS ATTACK; echo '’: No such file or directory
ERROR: failed processes:
  Process(`find "foo'; echo MALICIOUS ATTACK; echo '" -type f -print0`, ProcessExited(1)) [1]
  Process(`xargs -0 grep foo`, ProcessExited(123)) [123]

Julia 中的默认行为,最容易实现的行为是

在上面的示例中,我们可以看到,即使 dir 包含空格或引号,表达式仍然按预期工作——dir 的值被插入为 find 命令的单个参数。当 dir 不是一个存在的目录的名称时,find 失败——正如它应该的那样——并且此失败被检测到并自动转换为一个信息性异常,包括失败的完全扩展命令行。

在之前的文章中,我们观察到使用 Bash 的 pipefail 选项可以检测管道故障(如本例所示),这些故障发生在管道中的最后一个进程之前。但是,它只允许我们检测到管道中至少有一件事失败了。我们仍然必须猜测管道中哪些部分实际上失败了。另一方面,在 Julia 示例中,不需要猜测:当给定一个不存在的目录时,我们可以看到 findxargs 都失败了。虽然 find 在这种情况下失败并不奇怪,但 xargs 也失败却出乎意料。为什么 xargs 会失败呢?

可以检查的一个可能性是,xargs 程序在没有输入的情况下失败。我们可以使用 Julia 的 success 谓词来尝试一下

julia> success(pipeline(`cat /dev/null`, `xargs true`))
true

好的,所以 xargs 似乎对没有输入很满意。也许 grep 不喜欢没有得到任何输入?

julia> success(pipeline(`cat /dev/null`, `grep foo`))
false

啊哈!grep 在没有得到任何输入时返回一个非零状态。值得知道。事实证明,grep 使用其返回状态来指示它是否匹配了任何内容——在这种情况下是“找到了一些东西”与“没有找到任何东西”。大多数程序使用其返回状态来指示成功或失败,但有些程序(如 grep)使用它来指示其他布尔条件——在这种情况下是“找到了一些东西”与“没有找到任何东西”。

julia> success(pipeline(`echo foo`, `grep foo`))
true

julia> success(pipeline(`echo bar`, `grep foo`))
false

现在我们知道为什么 grep“失败”了——以及 xargs 也是如此,因为它在运行的程序返回非零值时返回一个非零值。这意味着我们的 Julia 管道和“负责”的 Ruby 版本在搜索一个碰巧不包含字符串“flippity”的现有目录时都容易受到虚假失败的影响

julia> dir = "src";

julia> parse(Int, readchomp(pipeline(
           `find $dir -type f -print0`,
           `xargs -0 grep flippity`,
           `wc -l`,
       )))
ERROR: failed process: Process(`xargs -0 grep flippity`, ProcessExited(123)) [123]

由于 grep 使用一个非零返回状态来指示没有找到任何东西,readchomp 函数得出结论,其管道失败并为此引发了一个错误。在这种情况下,这种默认行为是不可取的:我们希望表达式只返回 0 而不引发错误。Julia 中的简单修复方法如下

julia> parse(Int, readchomp(pipeline(
           `find $dir -type f -print0`,
           ignorestatus(`xargs -0 grep flippity`),
           `wc -l`,
       )))
0

这在所有情况下都能正常工作。接下来我将解释这一切如何工作,但现在足以注意到,当我们的管道失败时提供的详细错误消息暴露了一个相当微妙的错误,该错误最终会在生产中使用时导致微妙且难以调试的问题。如果没有这种详细的错误报告,这个错误将很难追踪。

无操作的反引号

Julia 从 Perl 和 Ruby 中借用了反引号语法来表示外部命令,而这两种语言又从 shell 中借用了它。但是,与这些前辈不同,在 Julia 中,反引号不会立即运行命令,也不会一定表示您想捕获命令的输出。相反,反引号只是构建一个表示命令的对象

julia> `echo Hello`
`echo Hello`

julia> typeof(ans)
Cmd

(在 Julia repl 中,ans 会自动绑定到最后一个求值的输入的值。)为了真正运行一个命令,您必须对一个命令对象一些事情。要运行一个命令并将它的输出捕获到一个字符串中——其他语言使用反引号自动执行的操作——您可以使用 read 函数,并将 String 作为第二个参数来指示您想要一个字符串而不是一个字节数组

julia> read(`echo Hello`, String)
"Hello\n"

由于经常需要丢弃命令输出末尾的尾随换行符,Julia 提供了 readchomp(x) 命令,它等效于编写 chomp(read(x, String))

julia> readchomp(`echo Hello`)
"Hello"

要运行一个命令而不捕获它的输出,让它只打印到与主进程相同的 stdout 流——即其他语言中的 system 函数在给定一个命令作为字符串时所执行的操作——使用 run 函数

julia> run(`echo Hello`)
Hello
Process(`echo Hello`, ProcessExited(0))

readchomp 命令后的 "Hello" 是一个返回值,而 run 命令后的 Hello 是打印的输出。Process(`echo Hello`, ProcessExited(0))run 返回的值。(如果您的终端支持颜色,它们将以不同的颜色显示,以便您可以轻松地用肉眼区分它们。)如果出现问题,将会引发异常

julia> run(`false`)
ERROR: failed process: Process(`false`, ProcessExited(1)) [1]

julia> run(`notaprogram`)
ERROR: IOError: could not spawn `notaprogram`: no such file or directory (ENOENT)

与上面的 xargsgrep 一样,这可能并不总是可取。在这种情况下,您可以使用 ignorestatus 来指示命令返回一个非零值不应该被视为错误

julia> run(ignorestatus(`false`))
Process(`false`, ProcessExited(1))

julia> run(ignorestatus(`notaprogram`))
ERROR: IOError: could not spawn `notaprogram`: no such file or directory (ENOENT)

在后一种情况下,由于问题是可执行文件甚至不存在,而不是它仅仅运行并返回一个非零值,所以在父进程中仍然会引发错误。

虽然 Julia 的反引号语法故意尽可能地模仿 shell,但有一个重要的区别:命令字符串永远不会被传递给 shell 来解释和执行;相反,它是在 Julia 代码中解析的,使用与 shell 用于确定命令和参数是什么相同的规则。命令对象看起来有点像字符串,但实际上更像是一个字符串数组,如果您收集一个命令,就会看到这一点

julia> cmd = `perl -e 'print "Hello\n"'`
`perl -e 'print "Hello\n"'`

julia> collect(cmd)
3-element Array{String,1}:
 "perl"
 "-e"
 "print \"Hello\\n\""

您还可以获取命令对象的长度并索引到其中

julia> length(cmd)
3

julia> cmd[3]
"print \"Hello\\n\""

所以命令非常像一种有趣的字符串数组。如果您的终端支持下划线,命令中的各个单词将被下划线,帮助您轻松地看到单词之间的断点在哪里。

构建命令

Julia 中反引号符号的目的是为创建表示带有参数的命令的对象提供一个熟悉的、类似 shell 的语法。为此,引号和空格的作用与 shell 中的作用相同。但是,反引号语法的真正强大之处在于,直到我们开始以编程方式构建命令时才会出现。就像在 shell 中(以及在 Julia 字符串中)一样,您可以使用美元符号 ($) 将值插入到命令中

julia> dir = "src";

julia> collect(`find $dir -type f`)
4-element Array{String,1}:
 "find"
 "src"
 "-type"
 "f"

但是,与 shell 不同,插入到命令中的 Julia 值被插入为单个逐字参数——在值被插入后,值内部的任何字符都不会被解释为特殊字符

julia> dir = "two words";

julia> collect(`find $dir -type f`)
4-element Array{String,1}:
 "find"
 "two words"
 "-type"
 "f"

julia> dir = "foo'bar";

julia> collect(`find $dir -type f`)
4-element Array{String,1}:
 "find"
 "foo'bar"
 "-type"
 "f"

无论插入值的具体内容是什么,这都适用,允许对在 shell 中即使作为命令行参数的一部分也很难传递的字符进行简单插入(对于以下示例,tmp/a.tsvtmp/b.tsv 可以使用 shell 中的 echo -e "foo\tbar\nbaz\tqux" > tmp/a.tsv; echo -e "foo\t1\nbaz\t2" > tmp/b.tsv 来创建)

julia> tab = "\t";

julia> cmd = `join -t$tab tmp/a.tsv tmp/b.tsv`;

julia> collect(cmd)
4-element Array{String,1}:
 "join"
 "-t\t"
 "tmp/a.tsv"
 "tmp/b.tsv"

julia> run(cmd)
foo     bar     1
baz     qux     2
Process(`join '-t   ' tmp/a.tsv tmp/b.tsv`, ProcessExited(0))

此外,$ 之后的内容实际上可以是任何有效的 Julia 表达式,而不仅仅是变量名

julia> collect(`join -t$"\t" tmp/a.tsv tmp/b.tsv`)
4-element Array{String,1}:
 "join"
 "-t\t"
 "tmp/a.tsv"
 "tmp/b.tsv"

在 shell 中传递制表符稍微困难一些,需要命令插入和一些棘手的引用

bash-3.2$ join -t"$(printf '\t')" tmp/a.tsv tmp/b.tsv
foo     bar     1
baz     qux     2

虽然使用空格和其他奇怪的字符插入值对于非脆弱的命令构建非常棒,但 shell 最初在空格处分割值是有原因的:为了允许插入多个参数。大多数现代 shell 都有第一类数组类型,但旧的 shell 使用空格分隔来模拟数组。因此,如果您在 shell 中将类似于 "foo bar" 的值插入到命令中,默认情况下它会被视为两个单独的单词。然而,在具有第一类数组类型的语言中,有一个更好的选择:始终将单个值插入为单个参数,并将数组插入为多个值。这正是 Julia 的反引号插入所执行的操作

julia> dirs = ["foo", "bar", "baz"];

julia> collect(`find $dirs -type f`)
6-element Array{String,1}:
 "find"
 "foo"
 "bar"
 "baz"
 "-type"
 "f"

当然,无论插入数组中包含的字符串有多奇怪,它们都会成为逐字参数,没有任何 shell 解释。Julia 的反引号还有一个花招。我们之前已经看到过(没有真正谈论它),您可以将单个值插入到更大的参数中

julia> x = "bar";

julia> `echo foo$x`
`echo foobar`

如果 x 是一个数组会发生什么?只有一种方法可以找到答案

julia> x = ["bar", "baz"];

julia> `echo foo$x`
`echo foobar foobaz`

Julia 执行 shell 在您编写 echo foo{bar,baz} 时会执行的操作。这甚至适用于插入到同一个 shell 单词中的多个值

julia> dir = "data"; names = ["foo","bar"]; exts=["csv","tsv"];

julia> `cat $dir/$names.$exts`
`cat data/foo.csv data/foo.tsv data/bar.csv data/bar.tsv`

这与 shell 在同一个单词中使用多个 {...} 表达式时执行的相同笛卡尔积展开。

进一步阅读

您可以在 Julia 的 在线手册 中阅读更多内容,包括如何构建复杂的管道,以及 Julia 的反引号语法中 shell 兼容的引用和插入规则如何使将 shell 命令剪切粘贴到 Julia 代码中既简单又安全。整个系统的设计基于一个原则,即最容易做的事情也应该是正确的事情。最终结果是,在 Julia 中启动和交互外部进程既方便又安全。