作者 | Eyal
译者 | 金灵杰
通常认为,Python 和 Bash 是热门的脚本语言,而 C、C++ 和 Java 完全不适合用于脚本编程。有一些语言则处于两者之间。
Go 语言适用于多种场景,包括编写 Web 服务器、流程管理和系统编程。在后文中,我将论证,除了上述这些场景外,Go 语言还可以用于简单的脚本编写。
是什么让 Go 语言适合编写脚本?
Go 语言简洁易读,并且相对简洁。这使得编写的脚本易于维护并且较短。
Go 语言提供了大量可用于各种用途的库。假设这些库是稳定且经过测试的,这可以使脚本简洁且健壮。
如果我大部分代码都是用 Go 编写的,那么我更愿意使用 Go 作为我的脚本语言。当代码由多人协作维护时,使用一种大家都熟悉的语言会减少维护成本,即使是一些脚本。
事实上,我已经可以使用 Go 语言来编写脚本。这需要使用 Go 的 run 子命令:如果脚本名称是 my-script.go,我们可以简单地通过 go run my-script.go 来运行。
这里,对于 go run 命令,我认为需要特别关注一下。让我们详细说明一下。
Go 语言不同于 Bash 和 Python 的地方在于,后者通过解释执行,即它们的脚本在读取时执行。而对于 Go 语言,当用户输入了 go run,Go 编译这个 Go 程序,然后再执行。因为 Go 编译时间非常短,所以看起来像是解释执行。需要注意的是,很多人说“go run 只是一个玩具”,但是如果我们需要脚本,并且喜欢 Go 语言,那么这个玩具正是我们所需要的。
我们可以编写脚本,并通过 go run 命令来执行。还有什么问题吗?问题是我不想那么麻烦,希望通过类似 ./my-script.go 的方式来运行脚本,而不是 go run my-script.go。
这里我们讨论一个简单的脚本和 Shell 通过两种方式进行交互:它从命令行获取输入数据,并设置退出状态码。这两个是 Shell 脚本中较为复杂的交互方式。
这个脚本输出“Hello”和从命令行获取的第一个参数,并设置退出状态码为 42:
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("Hello", os.Args[1])
os.Exit(42)
}
这时,使用 go run 命令结果有些奇怪:
$ go run example.go world
Hello world
exit status 42
$ echo $?
1
这个问题我们稍后会讨论。
这时可以使用 go build 命令。这是通过 go build 命令执行该脚本的方式:
$ go build
$ ./example world
Hello world
$ echo $?
42
此时调试该脚本的流程变成了:
$ vim ./example.go
$ go build
$ ./example.go world
Hi world
$ vim ./example.go
$ go build
$ ./example.go world
Bye world
而我期望达到的是这样来运行脚本:
$ chmod +x example.go
$ ./example.go world
Hello world
$ echo $?
42
而对应的工作流程是:
$ vim ./example.go
$ ./example.go world
Hi world
$ vim ./example.go
$ ./example.go world
Bye world
看上去很简单,对吧?
类 Unix 系统支持 Shebang。Shebang 用于告诉 Shell 使用什么解释器来运行脚本。我们可以根据编写脚本使用的语言来设置 Shebang 行。
通常来说,我们会使用 env 命令作为脚本执行器,这样就无需再使用解释器的绝对路径。例如:可以设置 Shebang 为 #! /usr/bin/env python 让 Python 解释器来运行该脚本。当名称为 example.py 的脚本有上述的 Shebang 行,同时它具有可执行属性(可以通过 chmod +x example.py 命令添加)时,可以在 Shell 中输入 ./example.py arg1 arg2 来运行。此时 Shell 会读取 Shebang 行,然后开始链式反应:
Shell 开始运行 /usr/bin/env python example.py arg1 arg2。这实际上就是 Shebang 行加上脚本名再加上额外的参数。该命令执行 /usr/bin/env,参数是 /usr/bin/env python example.py arg1 arg2。然后 env 命令调用 python 命令,执行 python example.py arg1 arg2。最后 python 运行 example.py 脚本,参数是 example.py arg1 arg2。
让我们开始尝试给 Go 脚本添加 Shebang。
我们首先设置一个幼稚的 Shebang 来使用 go run 执行这个脚本。加了 Shebang 之后的脚本看上去是这样的:
#! /usr/bin/env go run
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("Hello", os.Args[1])
os.Exit(42)
}
然后尝试运行一下,输出为:
$ ./example.go
/usr/bin/env: 'go run': No such file or directory
发生了什么?
Shebang 机制将 go run 整体作为 env 命令的一个参数了,而实际不存在这个命令。输入 which "go run" 也会有类似的错误。
一个可行的方案是将 Shebang 设置为 #! /usr/local/go/bin/go run。在我们尝试之前,就可以会发现一个问题:go 二进制文件在不同系统路径不同,写死绝对路径会导致脚本无法兼容安装在其他位置的 go。另外一个解决方案是使用 alias gorun="go run" 来创建一个别名,之后就能把 Shebang 修改成 #! /usr/bin/env gorun。使用这种方式,我们需要在运行这个脚本的系统中都设置这个别名。
输出:
$ ./example.go
package main:
example.go:1:1: illegal character U+0023 '#'
解释:从这个输出来看,我们有一个好消息,同时也有一个坏消息,你想先听哪个?我先来说好消息:-)
好消息是这个方案成功了,执行脚本之后 go run 命令正常调用了。
坏消息:井号。在许多脚本语言中,Shebang 开头的井号会被当成注释忽略。但是对 Go 语言编译器来说,开头的井号变成了“非法字符”。
当脚本不包含 Shebang 行时,不同的 Shell 会回退到不同的解析器。Bash 会使用自己来运行脚本,而 zsh 会回退到使用 sh。这给我们提供了一种解决方案,这也是 StackOverflow 上提到的一种解决方案。
由于 // 是 Go 语言中定义的注释,而我们可以使用 //usr/bin/env 来替代 /usr/bin/env(在路径分割符中,// == /),因此第一行可以设置成:
//usr/bin/env go run "$0" "$@"
结果:
$ ./example.go world
Hi world
exit status 42
./test.go: line 2: package: command not found
./test.go: line 4: syntax error near unexpected token `newline
./test.go: line 4: `import (
解释:
我们距离成功又近了一步:终于有了正确的输出。但是输出中还包含一些错误,同时状态码也不对。让我们来看下到底发生了什么。正如之前所说的,Bash 没有找到任何 Shebang,因此选择使用 bash ./example.go world 的方式来运行脚本(直接使用该命令会有相同输出,你也可以试下)。非常有意思,直接使用 Bash 来运行 Go 文件 :-) 下一步,Bash 读取脚本的第一行,然后运行该命令:/usr/bin/env go run ./example.go world。之前脚本中的“0”代表第一个参数,因此实际值是我们运行的脚本文件名。“@”表示命令行中的所有参数。在这个例子中会被替换成“world”。到目前位置,使用./example.go world,脚本使用了正确的命令行参数,并输出了正确的值。
输出中还有诡异的一行:“exit status 42”。这是什么?如果我们自己尝试下命令就会了解:
$ go run ./example.go world
Hello world
exit status 42
$ echo $?
1
这是 go run 命令通过标准错误输出的。go run 命令屏蔽了状态码,然后返回了状态码 1。关于这个行为的讨论,可以参见 Github issue。
好了,那么其他几行输出呢?这是 Bash 试图解析 Go 源码,但实际失败了。
这个 StackOverflow 页面建议在 Shebang 之后加上 ;exit "$?"。这会告诉 Bash 解释器不要再继续执行。
完整的 Shebang:
//usr/bin/env go run "$0" "$@"; exit "$?"
结果:
$ ./test.go world
Hi world
exit status 42
$ echo $?
1
基本上实现了:这里实现了让 Bash 使用 go run 命令执行脚本,然后立即退出,同时设置状态码为 go run 命令执行后的状态码。
更进一步,可以在 Shebang 行中添加一些命令,用于移除标准错误中的“退出状态”内容,甚至解析该文本并作为整个脚本的返回码。
然而:
再增加 Bash 命令意味着冗长的 Shebang 行,这与最初期望的 #! /usr/bin/env go 相比过于复杂。
记住这只是一种 hack 的方式,而我并不喜欢 hack。毕竟我们只是想用标准的 Shebang 机制。为什么?因为这样简单、标准、优雅。
这或多或少也是我想找一种更加方便的语言作为脚本语言(例如 Go)来替代 Bash 的原因。
gorun 就是我们想要的。我们只需在 Shebang 中写 #! /usr/bin/env gorun,并赋予脚本可执行权限。仅此而已,我们可以在 Shell 中执行,获得期望的结果!
$ ./example.go world
Hello world
$ echo $?
42
太棒了!
警告:兼容性
当文件包含 Shebang 之后,Go 将无法编译(和我们之前看见的一样)。
$ go run example.go
package main:
example.go:1:1: illegal character U+0023 '#'
这两种选择不能兼得,我们只能二选一:
使用 Shebang,并通过 ./example.go 方式运行脚本。
或者移除 Shebang,使用 go run ./example.go 运行脚本。
二者不可兼得!
另外一个问题,是当脚本文件被放在 Go 工程中时,编译器会发现这个 go 文件。虽然该文件并不是应用程序所需要的,也会导致编译失败。一个解决方案是移除 .go 后缀,但是这样就会无法使用类似 go fmt 等工具。
本文讨论了使用 Go 语言来编写脚本的重要性,同时介绍了几种方式来实现脚本运行。这里有一些总结。
解释:
类型:如何运行脚本。
退出状态码:脚本执行后,是否设置了脚本的退出状态码。
可执行:脚本是否可以通过 chmod +x 设置可执行权限。
可编译:脚本是否可以通过 go build。
标准:脚本是否需要标准库之外的东西。
正如上表,目前没有一种完美的解决方案。看上去最方便且问题最少的方式是使用 go run 命令。但是在我看来,这种方式太过“复杂”,而且无法“可执行”,同时退出状态码也不正确。这将会导致难以区分脚本是否正确执行。
因此,我认为 Go 语言在这个领域仍然有许多工作要做。我不认为让语言支持忽略 Shebang 行会有什么问题。这将会解决执行问题,但是类似这种变化可能不会被 Go 社区采纳。
我的同事提醒我事实上 Shebang 行对于 JavaScript 同样也是非法的。但是在 Node.js 中,他们增加了一个跳过 Shebang 函数,让 Node 脚本可以在 Shell 中直接运行。
如果 gorun 可以作为标准工具的一部分就更棒了,其他类似的还有 gofmt 和 godoc。
原文链接:
https://posener.github.io/go-shebang-story/