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 時需要安裝的插件(安裝過程可能需要翻牆,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.Connhttp.ResponseWriterio.ReadWriter 。
ReadWriteCloseFlushString 這幾個方法在標準庫裏已經有約定俗成的含義和簽名。自定義的接口方法應該要避免使用這幾個名字,除非方法的行爲確實和標準庫這幾個接口方法一致,這時候可以使用這些名字,但必須要確保方法的簽名和標準庫一致。序列化成字符串的方法命名成 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/md5net/http/cgi 等包。包名應該要和包所在目錄名一致,比如標準庫的 src/encoding/base64目錄下,源文件的包名爲 base64 。避免以下命名:

  • 和標準庫同名
  • utilcommon 等太過籠統的名字

包路徑

包路徑的最底層路徑名和包名一致:

"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 開發者應有的心態。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章