到目前位置我們一直在編寫單文件代碼,只有一個 main.go 文件。本節我們要開始朝完整的項目結構邁進,需要使用 Go 語言的模塊管理功能來組織很多的代碼文件。
細數 Go 語言的歷史發展,模塊管理經歷了三個重要的階段。第一階段是通過全局的 GOPATH 來管理所有的第三方包,第二階段是通過 Vendor 機制將項目的依賴包局部化,第三階段是 Go 語言的最新功能 Go Module。
本節我們重點講解前兩個階段,這兩個階段要求我們編寫代碼時必須在 GOPATH 下面對應的包路徑目錄里寫。第三個階段 Go Module 內容較新,也比較復雜需要另起一節單獨講解。
系統包路徑
Go 語言有很多內置包,內置包的使用需要用戶手工 import 進來。Go 語言的內置包都是已經編譯好的「包對象」,使用時編譯器不需要進行二次編譯。可以使用下面的命令查看這些已經編譯好的包對象在哪里。
// go sdk 安裝路徑 $ go env GOROOT /usr/local/go $ go env GOOS darwin $ go env GOARCH amd64 $ ls /usr/local/go/darwin_amd64 total 22264 drwxr-xr-x 4 root wheel 136 11 3 05:11 archive -rw-r--r-- 1 root wheel 169564 11 3 05:06 bufio.a -rw-r--r-- 1 root wheel 177058 11 3 05:06 bytes.a drwxr-xr-x 7 root wheel 238 11 3 05:11 compress drwxr-xr-x 5 root wheel 170 11 3 05:11 container -rw-r--r-- 1 root wheel 93000 11 3 05:06 context.a drwxr-xr-x 21 root wheel 714 11 3 05:11 crypto -rw-r--r-- 1 root wheel 24002 11 3 05:02 crypto.a ...
該命令顯示出來的后綴名為 .a 的文件就是已經編譯好的包對象。
全局管理 GOPATH
Go 語言的 GOPATH 路徑下存放了全局的第三方依賴包,當我們在代碼里面 import 某個第三方包時,編譯器都會到 GOPATH 路徑下面來尋找。GOPATH 目錄可以指定多個位置,不過用戶一般很少這樣做。如果你沒有人工指定 GOPATH 環境變量,編譯器會默認將 GOPATH 指向的路徑設定為 ~/go 目錄。用戶可以使用下面的命令看看自己的 GOPATH 指向哪里
$ go env GOPATH
/Users/qianwp/go
GOPATH 下有三個重要的子目錄,分別是 src、pkg 和 bin 目錄。src 目錄存放第三方包的源代碼,pkg 目錄存放編譯好的第三方包對象,bin 存放第三方包提供的二進制可執行文件。
當我們導入第三方包時,編譯器優先尋找已經編譯好的包對象,如果沒有包對象,就會去源碼目錄尋找相應的源碼來編譯。使用包對象的編譯速度會明顯快於使用源碼。
友好的包路徑
Go 語言允許包路徑帶有網站域名,這樣它就可以使用 go get 指令直接去相應的網站上拉去包代碼。最常用的要數 github.com、gopkg.in、golang.org 這三個網址。
import "github.com/go-redis/redis" import "golang.org/x/net" import "gopkg.in/mgo.v2" import "myhost.com/user/repo" // 個人提供的倉庫
Go 語言不存在官方維護的集中包倉庫,它將包的選擇分散到開源社區網站。使用量最大的要數 github.com,我們平時使用的大部分第三方包都是來源於此。也可以使用自己公司提供的代碼倉庫,路徑名用上公司代碼倉庫的域名即可。默認會使用 https 協議下載代碼倉庫 ,可以使用 -insecure 參數切換到 http 協議。
模塊的標准結構
了解模塊結構的最好辦法就是看看別人的模塊是怎么寫的,這里我們來觀察一下 mongo 包。使用下面的命令將 redis 的包下載本 GOPATH 目錄下
$ go get gopkg.in/mgo.v2
進入到 GOPATH 目錄下面的 src 子目錄尋找剛剛下載的 mongo 包,你會發現目錄層級和 go get 指令的包路徑正好一一對應起來,目錄下面還有更深的子目錄。
打開代碼中的任意一個文件你可以發現代碼中的 package 聲明的包名是 mgo,這個和當前的目錄名稱可以不一樣,不過當前目錄下所有的文件都是這同一個包名 mgo。同時我們還注意到即使是包內代碼引用,還是使用了全路徑來導入而不是相對導入,比如下圖的 bson,雖然同屬一個項目,但是它們好像根本就互不相識,要使用對方的的路徑全稱來打招呼。
當其它項目導入這個包時,import 語句后面的路徑是 mongo 包的目錄路徑,而使用的包名卻是這個目錄下面代碼中 package 語句聲明的包名 mgo。
package main import "gopkg.in/mgo.v2" func main() { session, err := mgo.Dial(url) ... }
很不幸,例子中這個項目已經停止維護了,下面是它的文檔中停止維護的聲明。
它已經由另一個社區項目接手。如果你要使用 mongo 的包,請使用
$ go get github.com/globalsign/mgo
編寫第一個模塊
下面我們嘗試編寫第一個模塊,這個模塊是一個算法模塊,提供兩個方法,一個是計算斐波那契數,一個用來計算階乘。我們要將這個包放到 github.com 上,需要讀者在 github.com 上申請自己的賬戶,然后創建自己的項目名叫 mathy。我的 github id 是 pyloque,於是這個項目的包名就是 github.com/pyloque/mathy。第一步在 GOPATH 里創建這個包目錄
$ mkdir -p ~/go/src/github.com/pyloque/mathy
$ cd ~/go/src/github.com/pyloque/mathy
好,現在我們進入了包的目錄下,開始編寫代碼吧,首先創建 mathy.go 文件,將下面的代碼貼進去
package mathy // 函數名大寫,其它的包才可以看的見 func Fib(n int) int64 { if n <= 1 { return 1 } var s = make([]int64, n+1) s[0] = 1 s[1] = 1 for i := 2; i <= n; i++ { s[i] = s[i-1] + s[i-2] } return s[n] } func Fact(n int) int64 { if n <= 1 { return 1 } var s int64 = 1 for i := 2; i <= n; i++ { s *= int64(i) } return s }
現在這個包的功能都齊全了,下面來編寫 main 函數使用它。我們可以去其它的任意空目錄下編寫下面的 main.go 文件,但是不可以在當前目錄編寫,因為同一個目錄只能有同一個包名。比如我們在 mathy 目錄下面創建一個子目錄 cmd,將下面的代碼貼到 cmd 目錄下的 main.go 文件里。執行 go run cmd/main.go 運行觀察結果
package main import ( "fmt" "github.com/pyloque/mathy" // 引用剛剛創建的包名 ) func main() { fmt.Println(mathy.Fib(10)) fmt.Println(mathy.Fact(10)) } ------------- 89 3628800
現在將代碼提交到 github.com 上去吧,你最好已經比較熟悉 git 指令
$ git init Initialized empty Git repository in /Users/qianwp/go/src/github.com/pyloque/mathy/.git/ $ git add --all $ git commit -a -m 'first commit' [master (root-commit) 7da8809] first commit 2 files changed, 37 insertions(+) create mode 100644 cmd/main.go create mode 100644 mathy.go $ git remote add origin https://github.com/pyloque/mathy.git $ git push origin master Counting objects: 5, done. Delta compression using up to 4 threads. Compressing objects: 100% (4/4), done. Writing objects: 100% (5/5), 555 bytes | 555.00 KiB/s, done. Total 5 (delta 0), reused 0 (delta 0) remote: remote: Create a pull request for 'master' on GitHub by visiting: remote: https://github.com/pyloque/mathy/pull/new/master remote: To https://github.com/pyloque/mathy.git * [new branch] master -> master
打開你的 github 項目頁看一看你剛剛提交的成果吧
這個項目提交到了 github.com 意味着全球的人都可以使用你的代碼了,前提是人們願意使用。
現在你可以將本地的 mathy 文件夾刪除,然后執行一下 go get
$ go get github.com/pyloque/mathy
你會發現剛才刪掉的 mathy 目錄又出現了,因為 go get 指令會自動去 github.com 網站上拉取你剛才提交的項目代碼。
Go 語言支持使用 . 和 .. 符號相對導入,但是不推薦使用。官方表示相對導入只應該用於本地測試,如果要正式發布一定需要修改為絕對導入。相對導入可以不必將代碼放在 GOPATH 里面編寫,所以會方便本地測試。但是將代碼放到 GOPATH 里面寫又能產生多大障礙呢?總之就是不推薦使用相對導入。
兩個包的包名一樣怎么辦?
如果你的代碼需要使用兩個包,這兩個包的路徑最后一個單詞是一樣的,那該如何分清使用的是那個包呢?為了解決這個問題,Go 語言支持導入語句名稱替換功能
import pmathy "github.com/pyloque/mathy" import omathy "github.com/other/mathy"
無名導入
Go 語言還支持一種罕見的導入語法可以將其它包的所有類型變量都導入到當前的文件中,在使用相關類型變量時可以省去包名前綴。
package main import "fmt" import . "github.com/pyloque/mathy" func main() { fmt.Println(Fib(10)) fmt.Println(Fact(10)) }
但是這種用法很少見,而且非常不推薦使用,讀者可以當着沒看見完全不知道。
匿名導入
Go 語言還支持匿名導入,就是說你導入了某個第三方包,但是不需要顯示使用它,這時就可以使用匿名導入。什么時候需要導入某個包而不使用呢?這是因為 Go 語言的代碼文件中可以存在一個特殊的 init() 函數,它會在包文件第一次被導入的時候運行。
當我們使用數據庫驅動的時候就會經常遇到匿名導入,第三方驅動包會在 init() 函數中將當前驅動注冊到全局的驅動列表中,這樣通過特定的 URI 就可以識別並找到相應的驅動來使用。
import ( "database/sql" _ "github.com/go-sql-driver/mysql" )
當我們使用 Go 語言自帶的圖像處理包時也會遇到匿名導入,在對圖像進行編碼解碼的時候需要根據不同的圖像編碼選擇不同的邏輯。
import ( "image" _ "image/gif" _ "image/png" _ "image/jpeg" )
包名和目錄名不一樣
Go 語言允許包名和當前的目錄名成不一樣,在導入包的時候使用的是目錄路徑,但是在使用的時候應該使用目錄下的包名。所以你會看到導入的路徑尾部和真正使用時的包名前綴不一樣。
import "github.com/json-iterator/go" var json = jsoniter.ConfigCompatibleWithStandardLibrary json.Marshal(&data)
為什么 json-iterator 會使用這樣奇怪的包路徑呢,因為它要支持多種語言的,直接將最后的目錄名改成語言的名稱更加易於辨識。
go get vs go build vs go install
Go 提供了三個比較的常用的指令用來進行全局的包管理。
go build: 僅編譯。如果當前包里有 main 包,就會生成二進制文件。如果沒有 main 包,build 指令僅僅用來檢查編譯是否可以通過,編譯完成后會丟棄編譯時生成的所有臨時包對象。這些臨時包包括自身的包對象以及所有第三方依賴包的包對象。如果指定 -i 參數,會將編譯成功的第三方依賴包對象安裝到 GOPATH 的 pkg 目錄。
go install:先編譯,再安裝。將編譯成的包對象安裝到 GOPATH 的 pkg 目錄中,將編譯成的可執行文件安裝到 GOPATH 的 bin 目錄中。如果指定 -i 參數,還會安裝編譯成功的第三方依賴包對象。
go get:下載代碼、編譯和安裝。安裝內容包括包對象和可執行文件,但是不包括依賴包。
$ go get github.com/go-redis/redis
注意編譯過程中第三方包的 main 包是不可能被編譯的,安裝的對象也就不可能包括第三方依賴包的可執行文件。
當我們使用 go run 指令來測試運行正在開發的程序時,如果發現啟動了很久,這時候可以考慮先執行 go build -i 指令,將編譯成功的依賴包都安裝到 GOPATH 的 pkg 目錄下,這樣再次運行 go run 指令就會快很多。
$ go build -i
$ go run main.go
當我們使用的第三方包已經比較陳舊,可以使用 go get -u 指令拉取最新的依賴包。
$ go get -u github.com/go-redis/redis
局部管理 Vendor
當我們在本地要開發多個項目時,如果不同的項目需要依賴某個第三方包的不同版本,這時候僅僅通過全局的 GOPATH 來存放第三方包是無解的。解決方法有一個,那就是需要在不同的項目里設置不同的 GOPATH 變量來解決沖突問題。但是這還是不能解決一個重要的問題,那就是當我們的項目依賴了兩個第三方包,這兩個第三方包又同時依賴了另一個包的兩個不同版本,這時候就會再次發生沖突。這種多版本依賴有一個專業的名稱叫「鑽石型」依賴。
為了解決這個問題,Go 1.6 引入了 vendor 機制。這個機制非常簡單,就是在你自己項目的目錄下增加一個名字為 vendor 子目錄,將自己項目依賴的所有第三方包放到 vendor 目錄里。這樣當你導入第三方包的時候,優先去 vendor 目錄里找你需要的第三方包,如果沒有,再去 GOPATH 全局路徑下找。
然后每個第三方項目都會有自己的 vendor 子目錄,如此遞歸下去,可以想象,一個大型項目將會有一顆很深的依賴樹。不過實際上這顆依賴數沒你想象的那么深,因為 Go 的第三方開源包普遍比較輕量級,依賴不是很多。畢竟 Go 語言已經將很多互聯網常用的工具包都內置了。
使用 vendor 有一個限制,那就是你不能將 vendor 里面依賴的類型暴露到外面去,vendor 里面的依賴包提供的功能僅限於當前項目使用,這就是 vendor 的「隔離沙箱」。正是因為這個沙箱才使得項目里可以存在因為依賴傳遞導致的同一個依賴包的多個版本。同時這也意味着項目里可能存在多份同一個依賴包,即使它們是同一個版本。比如你的包在 vendor 里引入了某個第三方包 A,然后別人的項目在 vendor 里引入你的包,同時它也引入第三方包 A。這就會導致生成的二進制文件變大,也會導致運行時內存變大,不過也無需擔心,這點代價對於服務端程序來說基本可以忽略不計。
講到這里還有一個很重要的問題沒有解決,github 上有很多開源項目,這些項目都有多個版本號,我如何引入具體某一個版本呢?如果使用 go get 指令,它總是引入 master 分支的最新代碼,它往往不是穩定的可靠代碼。這就需要 Go 語言的依賴管理工具的支持了,它就好比 java 語言的 maven 工具,python 語言的 pip 工具。
Dep
Go 語言沒有內置 vendor 包管理工具,它需要第三方工具的支持。這樣的工具很多,目前最流行的要數 golang/dep 項目了,它差一點就被官方收納為內置工具了,很可惜!上圖是它的 Logo,圖中疊起來的箱子就是 dep 正在管理的各種第三方依賴包。使用它之前我們需要將 dep 工具安裝到 GOPATH 下面
$ go get github.com/golang/dep
同時需要將 ~/go/bin 目錄加入到環境變量 PATH 中,因為 dep 可執行文件默認會安裝到 ~/go/bin 中。但是令人意外的是 dep 居然表示不能直接解決「鑽石型」依賴,這讓我感受到了它的危機,在 dep 中依賴包是扁平化的,vendor 不允許嵌套。如果出現了版本沖突,需要使用某種特殊手段來解決。
配置文件
dep 管理的項目會有兩個配置文件,分別是 Godep.toml 和 Godep.lock。Godep.toml 用於配置具體的依賴規則,里面包含項目的具體版本號信息。通過 toml 配置文件,你即可以使用遠程的依賴包(github),也可以直接使用本地的依賴包(GOPATH)。還可以為依賴包指定別名,這樣就可以在代碼里使用和真實路徑不一樣的導入路徑。當你需要切換依賴包的不同版本時,可以在 toml 配置文件里修改依賴的版本號,然后通過 dep ensure 指令來更新依賴項。
Gopkg.lock 是基於當前的 toml 文件配置規則和項目代碼來生成依賴的精確版本,它確定了 vendor 文件夾里要下載的依賴項代碼的目標版本。
dep init
該指令用於初始化當前的項目,它會靜態分析當前的項目代碼(如有有的話),生成 Godep.toml 和 Godep.lock 依賴配置文件,將依賴的項目代碼下載到當前項目的 vendor 文件夾里面。它會根據一定的策略來選擇最新的依賴包版本。如果自動策略生成的版本號不是你想要的,可以再修改配置文件執行 dep ensure 來切換其它版本。
dep ensure
該指令會下載代碼里用到的新依賴項、移除當前項目代碼里不使用的依賴項。確保當前的依賴包代碼和當前的項目代碼配置處於完全一致的狀態。
dep ensure -update
更新 Godep.lock 文件中的所有依賴項到最新版本。可以增加 一到多個包名參數,指定更新特定的依賴包。如果 toml 配置文件限定了依賴包的版本范圍,那么更新必須遵守 toml 規則的版本限制。
dep ensure -add github.com/a/b
增加並下載一個新的項目依賴包,可以指定依賴版本號。如 dep ensure -add github.com/a/b@master 或者 github.com/a/b@1.0.0
dep status
顯示當前項目的依賴狀態。
Dep 在使用起來比較簡單,但是其內部實現上是一個比較復雜的工具,鑒於篇幅限制,本節就不再繼續深入講解 Dep 了,以后有空再單獨開啟一篇來深入探討吧。我甚至覺得理解 Dep 已經變得沒有那么必要,因為它已經被 Go 語言官方拋棄了,取而代之的解決方案是 Go Module。