把它放进你的烟斗 (2013)

2021-08-09 05:03:02

注意:这篇文章已更新为适用于 Julia 1.x(原始版本使用 Julia 0.1 语法)。在之前的一篇文章中,我谈到了为什么通过中间 shell 生成外部程序管道是导致错误、安全漏洞、不必要的开销和静默故障的常见原因。但是实在是太方便了!为什么运行外部程序的管道不能方便和安全?好吧,实际上没有真正的原因。 shell 本身能够很好地构建和执行管道。原则上,没有什么能阻止高级语言至少像 shell 那样做——普通的只是默认情况下不会这样做,而是要求用户付出额外的努力来安全正确地使用外部程序。有两个主要障碍: 使用管道、dup2、fork、close 和 exec 系统调用的一些中等棘手的低级 UNIX 管道;这篇文章描述了我们为 Julia 设计和实现的系统,以及它如何避免其他语言的主要缺陷。首先,我将展示上一篇文章示例的 Julia 版本——计算给定目录中包含字符串“foo”的行数。当管道出现故障时,Julia 会提供完整、具体的诊断错误消息这一事实证明了一个令人惊讶且微妙的错误,潜伏在一个看似完全无害的 UNIX 管道中。修复此错误后,我们将详细介绍 Julia 的外部命令执行和管道构建系统的实际工作方式,以及为什么它比使用中间壳来完成所有繁重工作的传统方法提供更大的灵活性和安全性。以下是如何编写在 Julia 中计算包含字符串“foo”的目录中的行数的示例(如果您从源代码安装了 Julia,则可以通过将目录更改为 Julia 源目录并执行 cp -a src "source code"; mkdir tmp 然后启动 Julia repl): julia> dir = "src"; # 在 Juliajulia 的 git repo 中工作> parse(Int, readchomp(pipeline( `find $dir -type f -print0`, `xargs -0 grep foo`, `wc -l`, )))5 这个 Julia 命令看起来可疑地类似于我们在上一篇文章中开始的原始 Ruby 版本:

julia> dir = "source code";julia> parse(Int, readchomp(pipeline(`find $dir -type f -print0`, `xargs -0 grep foo`, `wc -l`, )))5julia> dir = "不存在";julia> parse(Int, readchomp(pipeline(`find $dir -type f -print0`, `xargs -0 grep foo`, `wc -l`, )))find: 'nonexistent': No此类文件或目录错误:进程失败:进程(`find nonexistent -type f -print0`, ProcessExited(1)) [1] Process(`xargs -0 grep foo`, ProcessExited(123)) [123]julia> dir = "foo'; echo 恶意攻击; echo '";julia> parse(Int, readchomp(pipeline(`find $dir -type f -print0`, `xargs -0 grep foo`, `wc -l`, )))找到:'富';回声恶意攻击; echo '':没有这样的文件或目录错误:进程失败:进程(`find "foo'; echo 恶意攻击; echo '" -type f -print0`, ProcessExited(1)) [1] Process(`xargs -0 grep foo`, ProcessExited(123)) [123] 在上面的例子中,我们可以看到,即使 dir 包含空格或引号,表达式的行为仍然完全符合预期——dir 的值作为单个参数插入到 find 命令中.当 dir 不是存在的目录的名称时, find 失败 - 应该如此 - 并且会检测到此失败并自动转换为信息异常,包括失败的完全展开的命令行。在上一篇文章中,我们观察到对 Bash 使用 pipefail 选项可以检测管道故障,例如在管道中的最后一个进程之前发生的管道故障。然而,它只允许我们检测到管道中至少有一件事情失败了。我们仍然需要猜测管道的哪些部分实际上失败了。另一方面,在 Julia 示例中,不需要猜测:当给出一个不存在的目录时,我们可以看到 find 和 xargs 都失败了。虽然在这种情况下 find 失败并不奇怪,但出乎意料的是 xargs 也失败了。为什么 xargs 失败?检查的一种可能性是 xargs 程序在没有输入的情况下失败。我们可以使用 Julia 的成功谓词来尝试一下:好的,所以 xargs 在没有输入的情况下似乎非常满意。也许 grep 不喜欢没有任何输入?啊哈!当 grep 没有得到任何输入时,它返回一个非零状态。很高兴知道。事实证明,grep 指示它是否与返回状态匹配任何内容。大多数程序使用它们的返回状态来指示成功或失败,但有些程序,如 grep,使用它来指示其他一些布尔条件——在这种情况下,“找到了一些东西”与“没有找到任何东西”:现在我们知道为什么 grep 是“失败” – 和 xargs 也是如此,因为如果它运行的程序返回非零,它会返回一个非零状态。这意味着当我们搜索一个现有目录时,我们的 Julia 管道和“负责任的”Ruby 版本都容易受到虚假故障的影响,而该目录碰巧在任何地方都不包含字符串“flippity”:

julia> dir = "src";julia> parse(Int, readchomp(pipeline(`find $dir -type f -print0`, `xargs -0 grep flippity`, `wc -l`, ))) 错误:进程失败: 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 repl 中, ans 自动绑定到最后一个评估输入的值。)为了实际运行命令,您必须对命令对象执行某些操作。要运行命令并将其输出捕获到字符串中 - 其他语言会自动使用反引号 - 您可以应用带有 String 作为第二个参数的 read 函数,指示您想要一个字符串而不是字节数组:因为想要丢弃命令输出末尾的尾随换行符,Julia 提供了 readchomp(x) 命令,它等效于编写 chomp(read(x, String)):

要在不捕获其输出的情况下运行命令,让它只打印到与主进程相同的 stdout 流 - 即当将命令作为其他语言的字符串给出时系统函数会做什么 - 使用运行函数:“Hello”之后readchomp 命令是一个返回值,而 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) 与上面的 xargs 和 grep 一样,这可能并不总是可取的。在这种情况下,您可以使用 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 中反引号的目的是提供一种熟悉的、类似于 shell 的语法,用于使对象表示带有参数的命令。为此,引号和空格的作用与它们在 shell 中的作用一样。然而,在我们开始以编程方式构建命令之前,反引号语法的真正威力不会显现。就像在 shell 中(以及在 Julia 字符串中)一样,您可以使用美元符号 ($) 将值插入到命令中:然而,与在 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 .tsv 和 tmp/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> c ollect(cmd)4-element Array{String,1}: "join" "-t\t" "tmp/a.tsv" "tmp/b.tsv"julia> run(cmd)foo bar 1baz qux 2Process(`加入'-t' tmp/a.tsv tmp/b.tsv`, ProcessExited(0))

此外,$ 后面的内容实际上可以是任何有效的 Julia 表达式,而不仅仅是变量名:制表符在 shell 中有点难以传递,需要命令插值和一些棘手的引用:同时用空格和其他奇怪的字符插值值非常适合命令的非脆弱构造,shell 首先在空格上拆分值是有原因的:允许插入多个参数。大多数现代 shell 都具有一流的数组类型,但较旧的 shell 使用空格分隔来模拟数组。因此,如果您将“foo bar”之类的值插入到 shell 中的命令中,默认情况下它会被视为两个单独的单词。但是,在具有一流数组类型的语言中,有一个更好的选择:始终将单个值作为单个参数进行插值,并将数组作为多个值进行插值。这正是 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 执行 shell 会执行的操作,如果您编写了 echo foo{bar,baz}。这甚至适用于插入到同一个 shell 单词中的多个值:如果在同一个单词中使用多个 {...} 表达式,这与 shell 所做的笛卡尔积扩展相同。

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