本文介绍了我如何尝试使用 Go 语言进行脚本编程的经历。文中我将讨论 Go 脚本的必要性,我们预期的表现以及可能的实现方式。在讨论过程中,我讲深入探讨脚本、Shell 和 Shebang。最终,我们将会讨论让 Go 脚本工作的解决方案。
为什么 Go 语言适合编写脚本?
通常认为,Python 和 Bash 是热门的脚本语言,而 C、C++ 和 Java 完全不能被用作脚本编程,有一些语言却夹在其中。
Go 语言试用场景很多,从编写 Web 服务器到流程管理,甚至有些人用作系统编程语言。在后文中,我将论证,除了上述这些场景外,Go 语言还可以简单地用于编写脚本。
是什么让 Go 语言适合编写脚本?
Go 语言简洁易读,并且不太冗长。这使得编写的脚本易于维护且相对较短。
Go 语言有许多可用于各种用途的库。假设这些库是稳定且经过测试的,这可以让脚本简洁且健壮。
如果我的大多数代码使用 Go 语言编写,那么我更倾向于使用 Go 作为我的脚本语言。当代码由许多人协作维护,那么使用一种大家都能完全掌控的语言会降低维护成本,即使是一些脚本。
Go 语言已经 99% 支持脚本
事实上,我已经可以使用 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 worldHello worldexit status 42$ echo $?1这个问题我们稍后会讨论。
这时候可以使用 go build 命令。这是通过 go build 命令执行该脚本的方式:
$ go build$ ./example worldHello world$ echo $?42此时调试该脚本的流程变成了:
$ vim ./example.go$ go build$ ./example.go worldHi world$ vim ./example.go$ go build$ ./example.go worldBye world而我期望达到的是这样来运行脚本:
$ chmod +x example.go$ ./example.go worldHello world$ echo $?42而对应的工作流程是:
$ vim ./example.go$ ./example.go worldHi world$ vim ./example.go$ ./example.go worldBye world看上去很简答是吧?
Shebang
坏消息:井号。在许多脚本语言中,Shebang 开头的井号会被当成注释忽略。但是对 Go 语言编译器来说,开头的井号变成了“非法字符”。
3、 解决方案
当脚本不包含 Shebang 行时,不同的 Shell 会回退到不同的解析器。Bash 会使用自己来运行脚本,而 zsh 会回退到使用 sh。这给我们提供了一种解决方案,这也是StackOverflow上提到的一种解决方案。
由于 // 是 Go 语言中定义的注释,而我们可以使用 //usr/bin/env 来替代 /usr/bin/env(在路径分割符中,// == /),因此第一行可以设置成:
//usr/bin/env go run "$0" "$@"结果:
$ ./example.go worldHi worldexit 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 ('$ echo $?2解释:
我们距离成功又近了一步:终于有了正确的输出。但是输出中还包含一些错误,同时状态码也不对。让我们来看下到底发生了什么。正如之前所说的,Bash 没有找到任何 Shebang,因此选择使用 bash ./example.go world 的方式来运行脚本(直接使用该命令会有相同输出,你也可以试下)。非常有意思,直接使用 Bash 来运行 Go 文件 :-) 下一步,Bash 读取脚本的第一行,然后运行该命令:/usr/bin/env go run ./example.go world。之前脚本中的“0”代表第一个参数,因此实际值是我们运行的脚本文件名。“
0”代表第一个参数,因此实际值是我们运行的脚本文件名。“@”表示命令行中的所有参数。在这个例子中会被替换成“world”。到目前位置,使用./example.go world,脚本使用了正确的命令行参数,并输出了正确的值。
输出中还有诡异的一行:“exit status 42”。这是什么?如果我们自己尝试下命令就会了解:
$ go run ./example.go worldHello worldexit status 42$ echo $?1这是 go run 命令通过标准错误输出的。go run 命令屏蔽了状态码,然后返回了状态码 1。关于这个行为的讨论,可以参见Github issue。
好了,那么其他几行输出呢?这是 Bash 试图解析 Go 源码,但实际失败了。 4 、解决方案优化
这个 StackOverflow 页面建议在 Shebang 之后加上 ;exit “$?”。这会告诉 Bash 解释器不要再继续执行。
完整的 Shebang:
//usr/bin/env go run "$0" "$@"; exit "$?"结果:
$ ./test.go worldHi worldexit status 42$ echo $?1基本上实现了:这里实现了让 Bash 使用 go run 命令执行脚本,然后立即退出,同时设置状态码为 go run 命令执行后的状态码。
更进一步,可以在 Shebang 行中添加一些命令,用于移除标准错误中的“退出状态”内容,甚至解析该文本并作为整个脚本的返回码。
然而:
再增加 Bash 命令意味着冗长的 Shebang 行,这与最初期望的 #! /usr/bin/env go 相比过于复杂。