golang 項目實戰簡明指南
原文地址
開發環境搭建
golang 的開發環境搭建比較簡單,由於是編譯型語言,寫好 golang 源碼後,只需要執行 go build
就能將源碼編譯成對應平臺(本文中默認為 linux)上的可執行程序。本文不再贅述如何搭建 golang 開發環境,只說明下需要註意的地方。
從官網下載對應平臺的 golang 安裝包中包括 golang 的編譯器、一些工具程序和標準庫源碼。早期的 golang 版本中,需要設置 GOROOT
和 GOPATH
兩個環境變量。
從 1.8 版開始,GOPATH
不再需要顯示設置。如果沒有顯示設置,則 GOPATH
的默認值為 $HOME/go
。GOPATH
可以設置多個目錄,但推薦只設置一個或直接使用默認值,多個 GOPATH
$GOPATH/bin
加到 $PATH
裏,這樣通過 go install
會安裝到 $GOPATH/bin
目錄的可執行程序可以像系統命令一樣直接運行,不用輸入完整路徑。從 1.10 版開始,
GOROOT
也不再需要顯示設置了,只需要將安裝包中的 bin 目錄加到 $PATH
裏,系統會自動推導出 GOROOT
的值。編輯器根據個人喜好選擇,作者主要使用 vim 和 vscode 。這裏介紹了使用 vim 時需要安裝的插件(安裝過程可能需要FQ,YCM 安裝比較復雜可以不要,gocode 夠用了)。
hello world
以下是 golang 版本的 hello world:
package main
import (
"fmt"
)
func main() {
fmt.Println("hello world")
}
golang 安裝包自帶的 gofmt 能將源碼格式化成官方推薦的風格,建議將這個工具整合到編輯器裏。
這個簡單的程序用 go build 編譯出來可執行程序用 ldd 查看發現沒有任何動態庫依賴,size 也比較大(1.8M ,對等的 C 程序版本只有 7.5K)。實際上這裏也體現了 golang 的哲學:直接通過源代碼分發軟件,所有的代碼編到一整個可執行程序裏,基本沒有動態庫依賴(或者只依賴 C/C++ 運行時庫和基本的系統庫),這也方便了 docker 化(C/C++ 程序員應試能體會動態庫依賴有多惡心)。通過 readelf 查看可執行程序會發現代碼段和調試信息段占用了比較大的空間,代碼段大是因為 golang 的運行時也在裏面。調試信息段方便 golang 進程 panic 時會打印詳細的進程堆棧及源碼信息,這也是為什麽 golang 的可執行程序比較大的原因。
命名規範
golang 的標準庫提供了 golang 程序命名規範很好的參考標準,命名規範應該盡量和標準庫的風格接近,多看下標準庫的代碼就能體會到 golang 的命名哲學了。
命名在很大程序上也體現了一名程序員的修養,用好的命名寫出的代碼通常是自註釋的,只需要在有復雜的邏輯需要解釋的情況下才額外註釋。
好的命名應該具有以下特征:
- 一致性:見名知義,比如標準庫中將對象序列化成字符串的操作名為
String
,在你自己的代碼裏將自定義類型的對象序列化成字符串也應該叫這個名字,並且簽名和標準庫要一致; - 簡明精煉:減少敲鍵盤的次數;
- 精確性:不要使用有歧義的命名。
Tip: 通常變量的作用域越廣,變量的名字應該越長,反之亦然。
golang 中一般使用駝峰命名法,盡量不要使用下劃線(基本只在全大寫的常量命名中使用)。首字母縮略詞應該全部大寫,比如 ServeHTTP
, IDProcessor
。
本文中出現的必須、 禁止是指強烈推薦的 golang 風格的規範,但違反這個規範並不會導致程序編譯不過。
常量
全大寫或者駝峰命名都可以,全大寫的情況下可使用下劃線分隔單詞:
const (
SEEK_SET int = 0 // seek relative to the origin of the file
SEEK_CUR int = 1 // seek relative to the current offset
SEEK_END int = 2 // seek relative to the end
)
const (
MaxInt8 = 1<<7 - 1
MinInt8 = -1 << 7
MaxInt16 = 1<<15 - 1
MinInt16 = -1 << 15
MaxInt32 = 1<<31 - 1
MinInt32 = -1 << 31
MaxInt64 = 1<<63 - 1
MinInt64 = -1 << 63
MaxUint8 = 1<<8 - 1
MaxUint16 = 1<<16 - 1
MaxUint32 = 1<<32 - 1
MaxUint64 = 1<<64 - 1
)
局部變量
通過以下代碼片斷舉例說明局部變量的命名原則:
func RuneCount(buffer []byte) int {
runeCount := 0
for index := 0; index < len(buffer); {
if buffer[index] < RuneSelf {
index++
} else {
_, size := DecodeRune(buffer[index:])
index += size
}
runeCount++
}
return runeCount
}
慣用的變量名應該盡可能短:
- 使用
i
而不是index
- 使用
r
而不是reader
- 使用
b
而不是buffer
這幾個字母在 golang 中有約定俗成的含義,使用單字母名字是更 golang 的方式(可能在其他語言的規範中是反例),其他可以舉一反三。
變量名中不要有冗余的信息,在函數 RuneCount
裏,計數器命名就不需再把 rune
包含進來了,直接用 count
就好了。
在判斷 Map 中是否存在某個鍵值或者接口的轉型操作裏,通常用 ok
來接收判斷結果:v, ok := m[k]
。
上文中的示例代碼按照以上原則重構後應該是這個樣子:
func RuneCount(b []byte) int {
count := 0
for i := 0; i < len(b); {
if b[i] < RuneSelf {
i++
} else {
_, n := DecodeRune(b[i:])
i += n
}
count++
}
return count
}
形參
形參的命名原則和局部變量一致。另外 golang 軟件是以源代碼形式發布的,形參連同函數簽名通常會作為接口文檔的一部分,所以形參的命名規範還有以下特點。
如果形參的類型已經能明確說明形參的含義了,形參的名字就可以盡量簡短:
func AfterFunc(d Duration, f func()) *Timer
func Escape(w io.Writer, s []byte)
如果形參類型不能說明形參的含義,形參的命名則應該做到見名知義:
func Unix(sec, nsec int64) Time
func HasPrefix(s, prefix []byte) bool
返回值
跟形參一樣,可導出函數的返回值也是接口文檔的一部分,所以可導出函數的必須使用命名返回值:
func Copy(dst Writer, src Reader) (written int64, err error)
func ScanBytes(data []byte, atEOF bool) (advance int, token []byte, err error)
接收器(Receivers)
習慣上接收器的命名命名一般是 1 到 2 個字母的接收器類型的縮寫:
func (b *Buffer) Read(p []byte) (n int, err error)
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request)
func (r Rectangle) Size() Point
同個類型的不同方法中接收器命名要保持一致,不要在一個方法中叫 r
,在另一個方法中又變成了 rdr
。
包級導出名
包導出的變量、常量、函數、類型使用時有包名的修飾。這些導出名字裏就不再需要包含包名的信息了,所以標準庫中 bytes
包裏的 Buffer
不需要叫 BytesBuffer
。
接口
只有 1 個方法的接口名通常用方法名加上 er
後綴,不引起迷惑的前提下方法名可以使用縮寫:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Execer interface {
Exec(query string, args []Value) (Result, error)
}
方法名本身是復合詞的情況下,可以酌情調整以符合英文文法:
type ByteReader interface {
ReadByte() (c byte, err error)
}
如果接口有多個方法,則需要選擇一個最能精確概括描述接口目的的名詞命名(有點難度),但是禁止用多個方法中的某個方法加上 er
後綴來命名,否則別人會誤解此接口只有一個方法。可以參考標準庫這幾個接口所包含的方法及接口的命名:net.Conn
, http.ResponseWriter
, io.ReadWriter
。
Read
, Write
, Close
, Flush
, String
這幾個方法在標準庫裏已經有約定俗成的含義和簽名。自定義的接口方法應該要避免使用這幾個名字,除非方法的行為確實和標準庫這幾個接口方法一致,這時候可以使用這些名字,但必須要確保方法的簽名和標準庫一致。序列化成字符串的方法命名成 String
而不是 ToString
。
錯誤
自定義錯誤類型以 Error
作為後綴,采用 XyzError
的格式命名:
type ExitError struct {
...
}
錯誤值以 Err
作為前綴,采用 ErrXyz
的格式命名:
var ErrFormat = errors.New("image: unknown format")
錯誤描述全部小寫,未尾不需要加結束句點。
Getter/Setter
struct 的首字母大寫的字段是導出字段,可以直接讀寫不需要 Getter/Setter ,首字母小寫的字段是私有字段,必要的情況下可以增加讀寫私有字段的 Getter/Setter 方法。私有字段首字母變大寫即為 Getter 方法名字,不需要加 Get
前綴。私有字段首字母變大寫加上 Set
前綴即為 Setter 方法名字。例如 struct 中名為 obj
的私有字段,其 Getter/Setter 方法命名分別為 Obj
/SetObj
。
包
包名使用純小寫、能精確描述包功能且精煉的名詞(有點難度),不帶下劃線,不引起迷惑的前提下可以用縮寫,比如標準庫的 strconv
。如果包名比較復雜出現了多個單詞,就應該考慮是不是要分層了,參考標準庫的 crypto/md5
, net/http/cgi
等包。包名應該要和包所在目錄名一致,比如標準庫的 src/encoding/base64
目錄下,源文件的包名為 base64
。避免以下命名:
- 和標準庫同名
util
,common
等太過籠統的名字
包路徑
包路徑的最底層路徑名和包名一致:
"compress/gzip" // gzip 路徑下源文件的的包名也為 gzip
包路徑有良好的層級關系但要避免重復羅嗦:
"code.google.com/p/goauth2/oauth2" // bad, goath2 和 oauth2 重復羅嗦
不是所有平臺的文件系統都是大小敏感的,包路徑名不要有大寫字母:
"github.com/Masterminds/glide" // bad
在導入包路徑時,按照標準庫包、第三方庫包、項目內部包的順序導入,各部分用空行隔開:
import (
"encoding/json"
"strconv"
"time"
"github.com/golang/protobuf/proto"
"github.com/gomodule/redigo/redis"
"dc_agent/attr"
"dc_agent/dc"
)
禁止使用相對路徑導入包:
import (
"./attr" // bad
)
項目代碼布局
開發 golang 庫時如何組織項目代碼可以參考 golang 的標準庫。開發應用程序和開發庫在工程實踐上還是有點不同。有一些開源項目把所有的代碼都放在一個包裏 (main) ,項目比較小時還能接受,項目比較大時就難以閱讀了。golang 的項目代碼布局目前業界也沒有一個統一的標準。這篇文章討論了幾種布局方案缺陷,然後提出了一些建議。這篇文章在此基礎上給出了一個可操作的方案,這也是本文推薦的方案。以下以 xauth
項目為例說明。
git.yingzhongtong.com/combase/xauth # 項目根目錄
├── cmd # cmd 目錄存放可執行文件(binary)代碼
│ ├── client # binary: client 不同的可執行程序各自建立目錄存放
│ │ └── main.go
│ └── xauth # binary: xauth
| ├── main.go
│ ├── config # 編譯當前可執行程序需要的內部庫組織成不同包各自建立目錄存放
│ │ └── config.go
│ ├── handler
│ │ └── handler.go
│ ├── httpproxy
│ │ └── httpproxy.go
│ └── zrpcproxy
│ └── zrpcproxy.go
├── pkg # pkg 目錄存放庫代碼
│ ├── model # package: model 不同庫組織成不同包,各自建一個目錄存放
│ │ └── contract.go
│ ├── ratelimiter # package: ratelimiter
│ │ ├── inmemory.go
│ │ ├── inmemory_test.go
│ │ ├── ratelimiter.go
│ │ ├── redis.go
│ │ └── redis_test.go
│ └── version # package: version
│ └── version.go
├── glide.lock # 項目依賴庫文件
├── glide.yaml
├── Makefile
├── README.md # 項目說明文檔
├── Dockerfile # 用來創建 docker 鏡像
└── xauth.yaml # 項目配置
這種布局特別適合既有可執行程序又有庫的復雜項目。主要規範是在項目根目錄下建立 cmd
和 pkg
目錄。cmd
目錄下存放編譯可執行文件的代碼。通常一個復雜項目可能會有多個可執行程序,每個可執行程序的代碼在 cmd
目錄各建立目錄存放。比如 git.yingzhongtong.com/combase/xauth/cmd/xauth
下是編譯可執行文件 xauth
的源碼。編譯 xauth
需要使用的內部庫直接在 git.yingzhongtong.com/combase/xauth/cmd/xauth
建立目錄存放。多個可執行程序都需要用到的公共庫應該放到項目根目錄下的 pkg
目錄裏。根目錄的 pkg
目錄下每個目錄都是一個單獨的公共庫。
建議項目根目錄下放一個 Makefile
文件,方便一鍵編譯出所有可執行程序。
總之,這種布局的主要思想是按功能模塊劃分庫,區分私有庫和公共庫,分別放在不同層級別的目錄裏。使用這種布局編寫代碼時,通常可執行程序對應的 main 包一般只有一個 main.go
文件,而且這個文件通常代碼很少,基本就是把需要用到的庫拼到一起。 github 的這個項目提供了這種布局的模板,可以 clone 下來直接使用(有些文件需要適當調整下)。
github 上很多優秀的開源項目也是采用的這種布局,熟悉這種布局也能幫助你更好的閱讀這些開源項目。
以上介紹的項目代碼布局是開發大型項目時強烈建議的方案。如果是小型項目代碼量很少,直接放在一個目錄裏也是可以接受的。
依賴管理
golang 早期版本中,依賴管理比較簡單,依賴的第三方庫通過 go get
下載到 GOPATH
中,編譯時會根據 import 的路徑去 GOPATH
和 GOROOT
中查找依賴的庫。這種方式雖然簡單,但是也有很多缺陷:
- 對依賴的第三方庫沒有版本管理,每次 go get 時都是下載最新的版本,最新的版本可能存在 bug;
- 基於域名的第三方庫路徑可能失效;
- 多個項目依賴共同的第三方庫時,一個項目更新依賴庫會影響其他項目。
golang 從 1.6 版本開始引入了 vendor
用來管理第三方庫。vendor
是項目根目錄下的一個特殊目錄,go doc
會忽略這個目錄。編譯時會優先從 vendor
目錄中查找依賴的第三方庫,找不到時再去 GOPATH
和 GOROOT
中查找。
vendor
機制解決上述的第 2 個和第 3 個缺陷,因此強烈建議工程實踐中將項目的第三方庫(所有本項目之外的庫,包括開源庫及公司級的公共庫)全部放到 vendor
中管理。使用這種方式, GOPATH
存在的意義基本很小了,這也是上文中提到 GOPATH
只需要設置 1 個目錄或者幹脆使用默認值的原因。
vendor
機制支持嵌套使用,即 vendor
中的第三方庫中也可以有 vendor
目錄,但這樣做會導致更復雜的依賴鏈甚至循環依賴,而且目前也沒有完美的解決方案。因此只有在開發可執行程序項目時才需要使用 vendor
。開發庫時禁止使用 vendor
。
vendor
機制並沒有解決上述的依賴庫版本管理問題,並且目前官方也沒有提供配套的工具。可以使用開源的第三方工具解決這個問題,推薦 glide 或 godep 。使用教程參考官方文檔,這裏就不贅述了。
使用 vendor
時要註意,項目中的 vendor
目錄不要提交到代碼倉庫中,但是第三方工具生成的依賴庫列表文件必須提交,比如 glide 生成的 glide.lock
和 glide.yaml
。
可執行程序版本管理
有時候生產環境跑的可執行程序可能有問題需要找到對應的源碼進行定位。如果發布系統也沒有把源碼信息和可執行程序關聯的話,可能根本找不到可執行程序是哪個版本的源碼編譯出來的。因此建議在可執行程序中嵌入版本和編譯信息,程序啟動時可以直接作為啟動信息打印。
版本號建議采用通用的 3 級點分字符串形式: <大版本號>.<小版本號>.<補丁號>
,比如 0.0.1
。簡單的 2 級也可以。使用 git 的話可以把 git commit SHA (通過 git rev-parse --short HEAD
獲取)作為 build id 。
package main
var (
version string
commit string
)
func main() {
println("demo server version:", version, "commit:", commit)
// ...
}
以上示例代碼中,version
和 commit
變量可以在源碼中硬編碼設置。更優雅的方式是在編譯腳本(Makefile)裏通過環境變量設置:
VERSION = "0.0.1"
COMMIT = $(shell git rev-parse --short HEAD)
all :
go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)"
性能剖析(profiling)
程序的性能通常和使用的範式、算法、語言特性有關。在性能敏感的場景下,需要使用性能剖析工具分析進程的瓶頸所在,進而針對性的優化。golang 自帶了性能剖析工具 pprof ,可以方便的剖析 golang 程序的時間/空間運行性能,以下是從某項目中部分代碼改編後的示例代碼,用來說明 pprof 的使用。直觀上似乎函數 bar
裏有更多的計算,調用函數 bar
應該比調用函數 foo
占用更多的 CPU 時間,實際情況卻並非如此。
// test.go
package main
import (
"net/http"
_ "net/http/pprof"
)
func foo() []byte {
var buf [1000]byte
return buf[:10]
}
var c int
func bar(b []byte) {
c++
for i := 0; i < len(b); i++ {
b[i] = byte(c*i*i*i + 4*c*i*i + 8*c*i + 12*c)
}
}
func main() {
go http.ListenAndServe(":8200", nil)
for {
b := foo()
bar(b)
}
}
後臺程序一般是 HTTP 常駐服務(如果不是 HTTP 服務的話也可以直接在代碼裏啟動一個),import 列表裏加上 _ "net/http/pprof"
後,程序啟動後 golang 運行時就會定時對進程運行狀態采樣,采樣到的數據可能通過 HTTP 接口獲取。還有一種方式是使用 "runtime/pprof"
包,在需要剖析的程序代碼裏插入啟動采樣代碼將,采樣數據寫到本地文件用來分析,具體使用方式參考這裏。原理和第一種方式一樣,只是采樣數據讀取方式不一樣。
啟用運行時采樣後,以下命令通過 HTTP 接口獲取一段時間內(5 秒)的采樣數據進行分析,然後進入命令行交互模式:
# go tool pprof http://localhost:8200/debug/pprof/profile?seconds=5
(pprof) top
Showing nodes accounting for 4990ms, 100% of 4990ms total
flat flat% sum% cum cum%
3290ms 65.93% 65.93% 3290ms 65.93% runtime.duffzero
1540ms 30.86% 96.79% 1540ms 30.86% main.bar
110ms 2.20% 99.00% 3400ms 68.14% main.foo (inline)
50ms 1.00% 100% 4990ms 100% main.main
0 0% 100% 4990ms 100% runtime.main
使用 top 命令會打印前 10 個最耗時的調用(top20 打印前20個,依此類推),從輸出的信息可以看出大部分 CPU 耗時在 runtime.duffzero
調用上。這種命令行方式的輸出不是很直觀,看不出這個調用的來源是哪裏。pprof 也支持可視化輸出,不過需要安裝 graphivz 繪圖工具,centos 下可以通過以下命令安裝:
# sudo yum install graphviz
通過 HTTP 接口采樣 5 秒鐘的 CPU 性能數據生成 PNG 格式(通過 -png
選項開啟)的性能剖析圖並保存到文件 cpupprof.png
裏:
# go tool pprof -png http://localhost:8200/debug/pprof/profile?seconds=5 > cpupprof.png
生成的性能剖析圖如下:
從上圖可以看出調用函數 foo
占用的 CPU 時間要遠大於調用函數 bar
的(耗時占比越大,表示調用的箭頭線段也越粗),並且在函數 foo
的耗時主要又耗在調用 runtime
的函數 duffzero
上。雖然這是 golang 的內置函數,但看名字基本上已經能猜到性能瓶頸出在哪裏了,這樣就可以進行有針對性的優化。這裏不解釋為什麽調用函數 foo
占用的 CPU 時間會遠大於調用函數 bar
的,留給讀者思考。
以上這個示例也說明了優化 CPU 性能關鍵是要找到影響整個系統的瓶頸,對於一個只占系統總耗時 1% 的函數,就算優化 10 倍意義也沒什麽意義。
大多數情況下 golang 後臺應用性能剖析只需要優化 CPU 占用耗時就可以了。 golang 是自帶垃圾回收(GC)的語言,由於 GC 的復雜性,和程序員自己管理內存的 C 語言相比,這類語言一般占用內存都比較大。自帶 GC 語言很少會有內存泄露問題,不過也有一種特殊場景的內存泄漏:比如往一個全局的切片裏不斷 append 數據又不自行清理,這種一般是程序有邏輯錯誤引起的。pprof 也可以在運行時對對象占用內存進行分析:
# go tool pprof -png http://localhost:8200/debug/pprof/heap > memused.png
以上命令輸出的是對象占用空間的視圖,默認只有 512KB 以上的內存分配才會寫到內存分析文件裏,因此建議在程序開始時加上以下代碼讓每個內存分配都寫到到內存分析文件:
func main() {
runtime.MemProfileRate = 1 // 修改默認值 512KB 為 1B
// ...
}
使用 -inuse_objects
選項可以把采樣對象設成對象數目。內存采樣數據是對象占用內存狀況的實時快照,不需要像采樣 CPU 性能數據那樣要讓進程跑一段時間。
這篇文章介紹了更多 golang 內存泄露的場景,有興趣可以閱讀下。
測試
golang 語言自帶了測試工具和相關庫,可以很方便的對 golang 程序進行測試。
推薦表驅動測試的方式進行單元測試,golang 標準庫中也有很多例子。以下是一個表驅動測試的示例:
func TestAdd(t *testing.T) {
cases := []struct{ A, B, Expected int }{
// 測試用例表
{1, 1, 2},
{1, -1, 0},
{1, 0, 1},
{0, 0, 0},
}
for _, tc := range cases {
actual := tc.A + tc.B
if actual != expected {
t.Errorf(
"%d + %d = %d, expected %d",
tc.A, tc.B, actual, tc.Expected)
}
}
}
使用表驅動測試可以很方便的增加測試用例測試各種邊界條件。這個工具可以很方便的生成表驅動測試的樁代碼。
單元測試一般只需要對包中的導出函數進行測試,非導出函數作為內部實現,除非有比較復雜邏輯,一般不用測試。
這個視頻(PPT)更詳細介紹了 golang 測試的最佳實踐,值得一看。
總結
本文不是 golang 語法和工具使用的教程,這些內容在網上可以方便找到。本文假設讀者已經對 golang 語法有了基本的了解,給了一些使用 golang 進行實際項目開發時的一些建議和方法指導。文中的主題主要是基於作者的實踐經驗和一些技術博客的總結,不免帶有一些個人偏見。另外 golang 也是一門不斷演進中的語言(從官方版本發布頻率也可以看出來),文中的內容也非一成不變,保持與時俱進應該是 golang 開發者應有的心態。
參考資料
- https://studygolang.com/articles/1785
- https://golang.org/doc/effective_go.html
- https://talks.golang.org/2014/names.slide
- http://peter.bourgon.org/go-best-practices-2016/
- https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1
- https://golang.org/pkg/runtime/pprof/
- https://blog.golang.org/profiling-go-programs
golang 項目實戰簡明指南