10.7. 工具

本章剩下的部分將討論Go語言工具箱的具體功能,包括如何下載、格式化、構建、測試和安裝Go語言編寫的程序。

Go語言的工具箱集合了一系列功能的命令集。它可以看作是一個包管理器(類似於Linux中的apt和rpm工具),用於包的查詢、計算包的依賴關係、從遠程版本控制系統下載它們等任務。它也是一個構建系統,計算文件的依賴關係,然後調用編譯器、彙編器和鏈接器構建程序,雖然它故意被設計成沒有標準的make命令那麼複雜。它也是一個單元測試和基準測試的驅動程序,我們將在第11章討論測試話題。

Go語言工具箱的命令有著類似“瑞士軍刀”的風格,帶著一打的子命令,有一些我們經常用到,例如get、run、build和fmt等。你可以運行go或go help命令查看內置的幫助文檔,為了查詢方便,我們列出了最常用的命令:

$ go
...
    build            compile packages and dependencies
    clean            remove object files
    doc              show documentation for package or symbol
    env              print Go environment information
    fmt              run gofmt on package sources
    get              download and install packages and dependencies
    install          compile and install packages and dependencies
    list             list packages
    run              compile and run Go program
    test             test packages
    version          print Go version
    vet              run go tool vet on packages

Use "go help [command]" for more information about a command.
...

為了達到零配置的設計目標,Go語言的工具箱很多地方都依賴各種約定。例如,根據給定的源文件的名稱,Go語言的工具可以找到源文件對應的包,因為每個目錄只包含了單一的包,並且包的導入路徑和工作區的目錄結構是對應的。給定一個包的導入路徑,Go語言的工具可以找到與之對應的存儲著實體文件的目錄。它還可以根據導入路徑找到存儲代碼的倉庫的遠程服務器URL。

10.7.1. 工作區結構

對於大多數的Go語言用戶,只需要配置一個名叫GOPATH的環境變量,用來指定當前工作目錄即可。當需要切換到不同工作區的時候,只要更新GOPATH就可以了。例如,我們在編寫本書時將GOPATH設置為$HOME/gobook

$ export GOPATH=$HOME/gobook
$ go get gopl.io/...

當你用前面介紹的命令下載本書全部的例子源碼之後,你的當前工作區的目錄結構應該是這樣的:

GOPATH/
    src/
        gopl.io/
            .git/
            ch1/
                helloworld/
                    main.go
                dup/
                    main.go
                ...
        golang.org/x/net/
            .git/
            html/
                parse.go
                node.go
                ...
    bin/
        helloworld
        dup
    pkg/
        darwin_amd64/
        ...

GOPATH對應的工作區目錄有三個子目錄。其中src子目錄用於存儲源代碼。每個包被保存在與$GOPATH/src的相對路徑為包導入路徑的子目錄中,例如gopl.io/ch1/helloworld相對應的路徑目錄。我們看到,一個GOPATH工作區的src目錄中可能有多個獨立的版本控制系統,例如gopl.io和golang.org分別對應不同的Git倉庫。其中pkg子目錄用於保存編譯後的包的目標文件,bin子目錄用於保存編譯後的可執行程序,例如helloworld可執行程序。

第二個環境變量GOROOT用來指定Go的安裝目錄,還有它自帶的標準庫包的位置。GOROOT的目錄結構和GOPATH類似,因此存放fmt包的源代碼對應目錄應該為$GOROOT/src/fmt。用戶一般不需要設置GOROOT,默認情況下Go語言安裝工具會將其設置為安裝的目錄路徑。

其中go env命令用於查看Go語言工具涉及的所有環境變量的值,包括未設置環境變量的默認值。GOOS環境變量用於指定目標操作系統(例如android、linux、darwin或windows),GOARCH環境變量用於指定處理器的類型,例如amd64、386或arm等。雖然GOPATH環境變量是唯一必須要設置的,但是其它環境變量也會偶爾用到。

$ go env
GOPATH="/home/gopher/gobook"
GOROOT="/usr/local/go"
GOARCH="amd64"
GOOS="darwin"
...

10.7.2. 下載包

使用Go語言工具箱的go命令,不僅可以根據包導入路徑找到本地工作區的包,甚至可以從互聯網上找到和更新包。

使用命令go get可以下載一個單一的包或者用...下載整個子目錄裡面的每個包。Go語言工具箱的go命令同時計算並下載所依賴的每個包,這也是前一個例子中golang.org/x/net/html自動出現在本地工作區目錄的原因。

一旦go get命令下載了包,然後就是安裝包或包對應的可執行的程序。我們將在下一節再關注它的細節,現在只是展示整個下載過程是如何的簡單。第一個命令是獲取golint工具,它用於檢測Go源代碼的編程風格是否有問題。第二個命令是用golint命令對2.6.2節的gopl.io/ch2/popcount包代碼進行編碼風格檢查。它友好地報告了忘記了包的文檔:

$ go get github.com/golang/lint/golint
$ $GOPATH/bin/golint gopl.io/ch2/popcount
src/gopl.io/ch2/popcount/main.go:1:1:
  package comment should be of the form "Package popcount ..."

go get命令支持當前流行的託管網站GitHub、Bitbucket和Launchpad,可以直接向它們的版本控制系統請求代碼。對於其它的網站,你可能需要指定版本控制系統的具體路徑和協議,例如 Git或Mercurial。運行go help importpath獲取相關的信息。

go get命令獲取的代碼是真實的本地存儲倉庫,而不僅僅只是複製源文件,因此你依然可以使用版本管理工具比較本地代碼的變更或者切換到其它的版本。例如golang.org/x/net包目錄對應一個Git倉庫:

$ cd $GOPATH/src/golang.org/x/net
$ git remote -v
origin  https://go.googlesource.com/net (fetch)
origin  https://go.googlesource.com/net (push)

需要注意的是導入路徑含有的網站域名和本地Git倉庫對應遠程服務地址並不相同,真實的Git地址是go.googlesource.com。這其實是Go語言工具的一個特性,可以讓包用一個自定義的導入路徑,但是真實的代碼卻是由更通用的服務提供,例如googlesource.com或github.com。因為頁面 https://golang.org/x/net/html 包含了如下的元數據,它告訴Go語言的工具當前包真實的Git倉庫託管地址:

$ go build gopl.io/ch1/fetch
$ ./fetch https://golang.org/x/net/html | grep go-import
<meta name="go-import"
      content="golang.org/x/net git https://go.googlesource.com/net">

如果指定-u命令行標誌參數,go get命令將確保所有的包和依賴的包的版本都是最新的,然後重新編譯和安裝它們。如果不包含該標誌參數的話,而且如果包已經在本地存在,那麼代碼將不會被自動更新。

go get -u命令只是簡單地保證每個包是最新版本,如果是第一次下載包則是比較方便的;但是對於發佈程序則可能是不合適的,因為本地程序可能需要對依賴的包做精確的版本依賴管理。通常的解決方案是使用vendor的目錄用於存儲依賴包的固定版本的源代碼,對本地依賴的包的版本更新也是謹慎和持續可控的。在Go1.5之前,一般需要修改包的導入路徑,所以複製後golang.org/x/net/html導入路徑可能會變為gopl.io/vendor/golang.org/x/net/html。最新的Go語言命令已經支持vendor特性,但限於篇幅這裡並不討論vendor的具體細節。不過可以通過go help gopath命令查看Vendor的幫助文檔。

(譯註:牆內用戶在上面這些命令的基礎上,還需要學習用翻牆來go get。)

練習 10.3:http://gopl.io/ch1/helloworld?go-get=1 獲取內容,查看本書的代碼的真實託管的網址(go get請求HTML頁面時包含了go-get參數,以區別普通的瀏覽器請求)。

10.7.3. 構建包

go build命令編譯命令行參數指定的每個包。如果包是一個庫,則忽略輸出結果;這可以用於檢測包是可以正確編譯的。如果包的名字是main,go build將調用鏈接器在當前目錄創建一個可執行程序;以導入路徑的最後一段作為可執行程序的名字。

由於每個目錄只包含一個包,因此每個對應可執行程序或者叫Unix術語中的命令的包,會要求放到一個獨立的目錄中。這些目錄有時候會放在名叫cmd目錄的子目錄下面,例如用於提供Go文檔服務的golang.org/x/tools/cmd/godoc命令就是放在cmd子目錄(§10.7.4)。

每個包可以由它們的導入路徑指定,就像前面看到的那樣,或者用一個相對目錄的路徑名指定,相對路徑必須以...開頭。如果沒有指定參數,那麼默認指定為當前目錄對應的包。下面的命令用於構建同一個包,雖然它們的寫法各不相同:

$ cd $GOPATH/src/gopl.io/ch1/helloworld
$ go build

或者:

$ cd anywhere
$ go build gopl.io/ch1/helloworld

或者:

$ cd $GOPATH
$ go build ./src/gopl.io/ch1/helloworld

但不能這樣:

$ cd $GOPATH
$ go build src/gopl.io/ch1/helloworld
Error: cannot find package "src/gopl.io/ch1/helloworld".

也可以指定包的源文件列表,這一般只用於構建一些小程序或做一些臨時性的實驗。如果是main包,將會以第一個Go源文件的基礎文件名作為最終的可執行程序的名字。

$ cat quoteargs.go
package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Printf("%q\n", os.Args[1:])
}
$ go build quoteargs.go
$ ./quoteargs one "two three" four\ five
["one" "two three" "four five"]

特別是對於這類一次性運行的程序,我們希望儘快的構建並運行它。go run命令實際上是結合了構建和運行的兩個步驟:

$ go run quoteargs.go one "two three" four\ five
["one" "two three" "four five"]

(譯註:其實也可以偷懶,直接go run *.go)

第一行的參數列表中,第一個不是以.go結尾的將作為可執行程序的參數運行。

默認情況下,go build命令構建指定的包和它依賴的包,然後丟棄除了最後的可執行文件之外所有的中間編譯結果。依賴分析和編譯過程雖然都是很快的,但是隨著項目增加到幾十個包和成千上萬行代碼,依賴關係分析和編譯時間的消耗將變的可觀,有時候可能需要幾秒種,即使這些依賴項沒有改變。

go install命令和go build命令很相似,但是它會保存每個包的編譯成果,而不是將它們都丟棄。被編譯的包會被保存到$GOPATH/pkg目錄下,目錄路徑和 src目錄路徑對應,可執行程序被保存到$GOPATH/bin目錄。(很多用戶會將$GOPATH/bin添加到可執行程序的搜索列表中。)還有,go install命令和go build命令都不會重新編譯沒有發生變化的包,這可以使後續構建更快捷。為了方便編譯依賴的包,go build -i命令將安裝每個目標所依賴的包。

因為編譯對應不同的操作系統平臺和CPU架構,go install命令會將編譯結果安裝到GOOS和GOARCH對應的目錄。例如,在Mac系統,golang.org/x/net/html包將被安裝到$GOPATH/pkg/darwin_amd64目錄下的golang.org/x/net/html.a文件。

針對不同操作系統或CPU的交叉構建也是很簡單的。只需要設置好目標對應的GOOS和GOARCH,然後運行構建命令即可。下面交叉編譯的程序將輸出它在編譯時的操作系統和CPU類型:

gopl.io/ch10/cross

func main() {
    fmt.Println(runtime.GOOS, runtime.GOARCH)
}

下面以64位和32位環境分別編譯和執行:

$ go build gopl.io/ch10/cross
$ ./cross
darwin amd64
$ GOARCH=386 go build gopl.io/ch10/cross
$ ./cross
darwin 386

有些包可能需要針對不同平臺和處理器類型使用不同版本的代碼文件,以便於處理底層的可移植性問題或為一些特定代碼提供優化。如果一個文件名包含了一個操作系統或處理器類型名字,例如net_linux.go或asm_amd64.s,Go語言的構建工具將只在對應的平臺編譯這些文件。還有一個特別的構建註釋參數可以提供更多的構建過程控制。例如,文件中可能包含下面的註釋:

// +build linux darwin

在包聲明和包註釋的前面,該構建註釋參數告訴go build只在編譯程序對應的目標操作系統是Linux或Mac OS X時才編譯這個文件。下面的構建註釋則表示不編譯這個文件:

// +build ignore

更多細節,可以參考go/build包的構建約束部分的文檔。

$ go doc go/build

10.7.4. 包文檔

Go語言的編碼風格鼓勵為每個包提供良好的文檔。包中每個導出的成員和包聲明前都應該包含目的和用法說明的註釋。

Go語言中的文檔註釋一般是完整的句子,第一行通常是摘要說明,以被註釋者的名字開頭。註釋中函數的參數或其它的標識符並不需要額外的引號或其它標記註明。例如,下面是fmt.Fprintf的文檔註釋。

// Fprintf formats according to a format specifier and writes to w.
// It returns the number of bytes written and any write error encountered.
func Fprintf(w io.Writer, format string, a ...interface{}) (int, error)

Fprintf函數格式化的細節在fmt包文檔中描述。如果註釋後緊跟著包聲明語句,那註釋對應整個包的文檔。包文檔對應的註釋只能有一個(譯註:其實可以有多個,它們會組合成一個包文檔註釋),包註釋可以出現在任何一個源文件中。如果包的註釋內容比較長,一般會放到一個獨立的源文件中;fmt包註釋就有300行之多。這個專門用於保存包文檔的源文件通常叫doc.go。

好的文檔並不需要面面俱到,文檔本身應該是簡潔但不可忽略的。事實上,Go語言的風格更喜歡簡潔的文檔,並且文檔也是需要像代碼一樣維護的。對於一組聲明語句,可以用一個精煉的句子描述,如果是顯而易見的功能則並不需要註釋。

在本書中,只要空間允許,我們之前很多包聲明都包含了註釋文檔,但你可以從標準庫中發現很多更好的例子。有兩個工具可以幫到你。

首先是go doc命令,該命令打印其後所指定的實體的聲明與文檔註釋,該實體可能是一個包:

$ go doc time
package time // import "time"

Package time provides functionality for measuring and displaying time.

const Nanosecond Duration = 1 ...
func After(d Duration) <-chan Time
func Sleep(d Duration)
func Since(t Time) Duration
func Now() Time
type Duration int64
type Time struct { ... }
...many more...

或者是某個具體的包成員:

$ go doc time.Since
func Since(t Time) Duration

    Since returns the time elapsed since t.
    It is shorthand for time.Now().Sub(t).

或者是一個方法:

$ go doc time.Duration.Seconds
func (d Duration) Seconds() float64

    Seconds returns the duration as a floating-point number of seconds.

該命令並不需要輸入完整的包導入路徑或正確的大小寫。下面的命令將打印encoding/json包的(*json.Decoder).Decode方法的文檔:

$ go doc json.decode
func (dec *Decoder) Decode(v interface{}) error

    Decode reads the next JSON-encoded value from its input and stores
    it in the value pointed to by v.

第二個工具,名字也叫godoc,它提供可以相互交叉引用的HTML頁面,但是包含和go doc命令相同以及更多的信息。圖10.1演示了time包的文檔,11.6節將看到godoc演示可以交互的示例程序。godoc的在線服務 https://godoc.org ,包含了成千上萬的開源包的檢索工具。

你也可以在自己的工作區目錄運行godoc服務。運行下面的命令,然後在瀏覽器查看 http://localhost:8000/pkg 頁面:

$ godoc -http :8000

其中-analysis=type-analysis=pointer命令行標誌參數用於打開文檔和代碼中關於靜態分析的結果。

10.7.5. 內部包

在Go語言程序中,包是最重要的封裝機制。沒有導出的標識符只在同一個包內部可以訪問,而導出的標識符則是面向全宇宙都是可見的。

有時候,一箇中間的狀態可能也是有用的,標識符對於一小部分信任的包是可見的,但並不是對所有調用者都可見。例如,當我們計劃將一個大的包拆分為很多小的更容易維護的子包,但是我們並不想將內部的子包結構也完全暴露出去。同時,我們可能還希望在內部子包之間共享一些通用的處理包,或者我們只是想實驗一個新包的還並不穩定的接口,暫時只暴露給一些受限制的用戶使用。

為了滿足這些需求,Go語言的構建工具對包含internal名字的路徑段的包導入路徑做了特殊處理。這種包叫internal包,一個internal包只能被和internal目錄有同一個父目錄的包所導入。例如,net/http/internal/chunked內部包只能被net/http/httputil或net/http包導入,但是不能被net/url包導入。不過net/url包卻可以導入net/http/httputil包。

net/http
net/http/internal/chunked
net/http/httputil
net/url

10.7.6. 查詢包

go list命令可以查詢可用包的信息。其最簡單的形式,可以測試包是否在工作區並打印它的導入路徑:

$ go list github.com/go-sql-driver/mysql
github.com/go-sql-driver/mysql

go list命令的參數還可以用"..."表示匹配任意的包的導入路徑。我們可以用它來列出工作區中的所有包:

$ go list ...
archive/tar
archive/zip
bufio
bytes
cmd/addr2line
cmd/api
...many more...

或者是特定子目錄下的所有包:

$ go list gopl.io/ch3/...
gopl.io/ch3/basename1
gopl.io/ch3/basename2
gopl.io/ch3/comma
gopl.io/ch3/mandelbrot
gopl.io/ch3/netflag
gopl.io/ch3/printints
gopl.io/ch3/surface

或者是和某個主題相關的所有包:

$ go list ...xml...
encoding/xml
gopl.io/ch7/xmlselect

go list命令還可以獲取每個包完整的元信息,而不僅僅只是導入路徑,這些元信息可以以不同格式提供給用戶。其中-json命令行參數表示用JSON格式打印每個包的元信息。

$ go list -json hash
{
    "Dir": "/home/gopher/go/src/hash",
    "ImportPath": "hash",
    "Name": "hash",
    "Doc": "Package hash provides interfaces for hash functions.",
    "Target": "/home/gopher/go/pkg/darwin_amd64/hash.a",
    "Goroot": true,
    "Standard": true,
    "Root": "/home/gopher/go",
    "GoFiles": [
            "hash.go"
    ],
    "Imports": [
        "io"
    ],
    "Deps": [
        "errors",
        "io",
        "runtime",
        "sync",
        "sync/atomic",
        "unsafe"
    ]
}

命令行參數-f則允許用戶使用text/template包(§4.6)的模板語言定義輸出文本的格式。下面的命令將打印strconv包的依賴的包,然後用join模板函數將結果鏈接為一行,連接時每個結果之間用一個空格分隔:

$ go list -f '{{join .Deps " "}}' strconv
errors math runtime unicode/utf8 unsafe

譯註:上面的命令在Windows的命令行運行會遇到template: main:1: unclosed action的錯誤。產生這個錯誤的原因是因為命令行對命令中的" "參數進行了轉義處理。可以按照下面的方法解決轉義字符串的問題:

$ go list -f "{{join .Deps \" \"}}" strconv

下面的命令打印compress子目錄下所有包的導入包列表:

$ go list -f '{{.ImportPath}} -> {{join .Imports " "}}' compress/...
compress/bzip2 -> bufio io sort
compress/flate -> bufio fmt io math sort strconv
compress/gzip -> bufio compress/flate errors fmt hash hash/crc32 io time
compress/lzw -> bufio errors fmt io
compress/zlib -> bufio compress/flate errors fmt hash hash/adler32 io

譯註:Windows下有同樣有問題,要避免轉義字符串的干擾:

$ go list -f "{{.ImportPath}} -> {{join .Imports \" \"}}" compress/...

go list命令對於一次性的交互式查詢或自動化構建或測試腳本都很有幫助。我們將在11.2.4節中再次使用它。每個子命令的更多信息,包括可設置的字段和意義,可以用go help list命令查看。

在本章,我們解釋了Go語言工具中除了測試命令之外的所有重要的子命令。在下一章,我們將看到如何用go test命令去運行Go語言程序中的測試代碼。

練習 10.4: 創建一個工具,根據命令行指定的參數,報告工作區所有依賴包指定的其它包集合。提示:你需要運行go list命令兩次,一次用於初始化包,一次用於所有包。你可能需要用encoding/json(§4.5)包來分析輸出的JSON格式的信息。