1. 程式人生 > >寫給新手的 Go 開發指南

寫給新手的 Go 開發指南

轉眼加入螞蟻已經三個多月,這期間主要維護一 Go 寫的伺服器。雖然用的時間不算長,但還是積累了一些心得體會,這裡總結歸納一下,供想嘗試 Go 的同學參考。
本文會依次介紹 Go 的設計理念、開發環境、語言特性。本文在談及語言特性的時也會討論一些 Go 的不足之處,旨在給讀者提供一個全面的視角。

簡介

一般來說,程式語言都會有一個 slogan 來表示它們的特點。比如提到 Clojure,一般會想到這麼幾個詞彙:lisp on JVM、immutable、persistent;Java 的話我能想到的是企業級開發、中規中矩。對於 Go ,官網介紹到:

Go is an open source programming language that makes it easy to build simple, reliable, and efficient software.

提取幾個關鍵詞:open(開放)、simple(簡潔)、reliable(可靠)、efficient(高效)。這也可以說是它的設計目標。除了上面這些口號外,初學者還需要知道 Go 是一門命令式的靜態語言(是指在編譯時檢查變數型別是否匹配),與 Java 屬於同一類別。

Imperative Functional
Dynamic Python/Ruby/Javascript Lisp/Scheme/Clojure
Static Java/C++/Rust/Go OCaml/Scala/Haskell

由於 Hello World 太簡潔,不具備展示 Go 的特點,所以下面展示一段訪問 httpbin,列印 response 的完整程式碼。

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    // http://httpbin.org/#/Anything/get_anything
    r, err := http.Get("http://httpbin.org/anything?hello=world")
    if err != nil {
        panic(err)
    }
    defer r.Body.Close()

    body, err := ioutil.ReadAll(r.Body)
    if err != nil {
        panic(err)
    }
    fmt.Printf("body = %s\n", string(body))
}

上面的程式碼片段包括了 Go 的主要組成:包的宣告與引用、函式定義、錯誤處理、流程控制、defer。

開發環境

通過上面的程式碼片段,可以看出 Go 語言 simple(簡潔)的特點,所以找一個最熟悉的文字編輯器,一般通過配置外掛,都可以達到快速開發的目的。很久之前我就已經把所有文字編輯放到 Emacs 上,這裡介紹下我的配置。

除了 go-mode 這個 major mode,為了配置像 原始碼跳轉、API 自動補全、檢視函式文件等現代 IDE 必備功能,需要安裝以下命令


go get -u github.com/rogpeppe/godef
go get -u github.com/stamblerre/gocode # for go-eldoc/company-go
go get -u golang.org/x/tools/cmd/goimports
go get -u github.com/kisielk/errcheck
go get -u github.com/lukehoban/go-outline # for go-imenu

然後再按照 setup-go.el 裡的配置,就擁有了一個功能完備的開發環境。

不像 Java 語言需要執行時,Go 支援直接將整個專案 build 成一個二進位制檔案,方便部署,而支援交叉編譯,不過在開發時,直接 go run XXX.go 更為便利,截止到 Go 1.12,還不支援 REPL,官方有提供線上版的 Playground 供分享、除錯程式碼。

我個人的習慣是建一個 go-app 專案,每個要測試的邏輯放到一個 test 裡面去,這樣就可以使用 go test -v -run XXX 來執行。之所以不選用 go run,是因為一個目錄下只允許有一個 main 的 package,多個 IDE 會提示錯誤。

資料型別

一般程式語言,資料型別分為基本的與複雜的兩類。
基本的一般比較簡單,表示一個值,Go 裡面就有 string, bool, int8, int32(rune), int64, float32, float64, byte(uint8) 等基本型別
複雜型別一般表示多個值或具有某些高階用法,Go 裡面有:

  • pointer Go 裡只支援取地址 & 與間接訪問 * 操作符,不支援對指標進行算術操作
  • struct 類似於 C 語言裡面的 struct,Java 裡面的物件
  • function 函式在 Go 裡是一等成員
  • array 大小固定的陣列
  • slice 動態的陣列
  • map 雜湊表
  • chan 用於在多個 goroutine 內通訊
  • interface 類似於 Java 裡面的介面,但是與 Java 裡的用法不一樣

下面將重點介紹 Go 裡特有或用途最廣的資料型別。

struct/interface

Go 裡面的 struct 類似於 Java 裡面的 Object,但是並沒有繼承,僅僅是對資料的一層包裝(抽象)。相對於其他複雜型別,struct 是值型別,也就是說作為函式引數或返回值時,會拷貝一份值,值型別分配在 stack 上,與之相對的引用型別,分配在 heap 上。
初學者一般會有這樣的誤區,認為傳值比傳引用要慢,實則不然,具體涉及到 Go 如何管理記憶體,這裡暫不詳述,感興趣到可以閱讀:

  • The Top 10 Most Common Mistakes I’ve Seen in Go Projects
  • pointer_test.go 這個測試在筆者機器上執行結果:
BenchmarkByPointer-8    20000000                86.7 ns/op
BenchmarkByValue-8      50000000                31.9 ns/op

所以一般推薦直接使用值型別的 struct,如果確認這是瓶頸了,可以再嘗試改為引用型別(&struct)

如果說 struct 是對狀態的封裝,那麼 interface 就是對行為的封裝,相當於對外的契約(contract)。而且 Go 裡面有這麼一條最佳實踐

Accept interfaces, return concrete structs. (函式的引數儘量為 interface,返回值為 struct)

這樣的好處也很明顯,作為類庫的設計者,對其要求的引數儘量寬鬆,方便使用,返回具體值方便後續的操作處理。一個極端的情況,可以用 interface{} 表示任意型別的引數,因為這個接口裡面沒有任何行為,所以所有型別都是符合的。又由於 Go 裡面不支援範型,所以interface{}是唯一的解決手段。

相比較 Java 這類面向物件的語言,介面需要顯式(explicit)繼承(使用 implements 關鍵字),而在 Go 裡面是隱式的(implicit),新手往往需要一段時間來體會這一做法的巧妙,這裡舉一例子來說明:

Go 的 IO 操作涉及到兩個基礎型別:Writer/Reader ,其定義如下:

type Reader interface {
        Read(p []byte) (n int, err error)
}

type Writer interface {
        Write(p []byte) (n int, err error)
}

自定義型別如果實現了這兩個方法,那麼就實現了這兩個介面,下面的 Example 就是這麼一個例子:

type Example struct {
}
func (e *Example) Write(p byte[]) (n int, err error) {
}
func (e *Example) Read(p byte[]) (n int, err error) {
}

由於隱式繼承過於靈活,在 Go 裡面可能會看到如下程式碼:

var _ blob.Fetcher = (*CachingFetcher)(nil)

這是通過將 nil 強轉為 *CachingFetcher,然後在賦值時,指定 blob.Fetcher 型別,保證 *CachingFetcher 實現了 blob.Fetcher 介面。
作為介面的設計者,如果想實現者顯式繼承一個介面,可以在介面中額外加一個方法。比如:

type Fooer interface {
    Foo()
    ImplementsFooer()
}

這樣,實現者必須實現 ImplementsFooer 方法才能說是繼承了 Fooer 介面。所以說隱式繼承有利有弊,需要開發者自己去把握。

map/slice

Map/Slice 是 Go 裡面最常用的兩類資料結構,屬於引用型別。在語言 runtime 層面實現,僅有的兩個支援範型的結構。
Slice 是長度不固定的陣列,類似於 Java 裡面的 List。

// map 通過 make 進行初始化
// 如果提前知道 m 大小,建議通過 make 的第二個引數指定,避免後期的資料移動、複製
m := make(map[string]string, 10)
// 賦值
m["zhangsan"] = "teacher"
// 讀取指定值,如不存在,返回其型別的預設值
v := m["zhangsan"]
// 判斷指定 key 知否在 map 內
v, ok := m["zhangsan"]

// slice 通過 make 進行初始化
s := make([]int)
// 增加元素
s = append(s, 1)

// 也可以通過 make 第二個引數指定大小
s := make([]int, 10)
for i:=0;i<10;i++ {
    s[i] = i
}
// 也可以使用三個引數的 make 初始化 slice
// 第二個引數為初始化大小,第三個為最大容量
// 需要通過 append 增加元素
s := make([]int, 0 ,10)
s = append(s, 1)

chan/goroutine

作為一門新語言,Goroutine 是 Go 借鑑 CSP 模型提供的併發解決方案,相比傳統 OS 級別的執行緒,它有以下特點:

  1. 輕量,完全在使用者態排程(不涉及OS狀態直接的轉化)
  2. 資源佔用少,啟動快
  3. 目前,Goroutine 排程器不保證公平(fairness),搶佔(pre-emption)也支援的非常有限,一個空的 for{} 可能會一直不被排程出去。

一般可以使用 chan/select 來進行 Goroutine 之間的排程。chan 類似於 Java 裡面的 BlockingQueue,且能保證 Goroutine-safe,也就是說多個 Goroutine 併發進行讀寫是安全的。

chan 裡面的元素預設為1個,也可以在建立時指定緩衝區大小,讀寫支援堵塞、非堵塞兩種模式,關閉一個 chan 後,再寫資料時會 panic。

// chan 與 slice/map 一樣,使用 make 初始化
ch := make(chan int, 2)

// blocking read
v := <-ch
// nonblocking read, 需要注意 default 分支不能省略,否則會堵塞住
select {
    case v:=<-ch:
    default:
} 

// blocking write
ch <- v
// nonblocking write
select {
    case ch<-v:
    default:
}

chan 作為 Go 內一重要資料型別,看似簡單,實則暗藏玄妙,用時需要多加留意,這裡不再展開敘述,後面打算專門寫一篇文章去介紹,感興趣的可以閱讀下面的文章:

  • Curious Channels
  • Prosumer 基於 buffered chan 實現的生產者消費者,核心點在於關閉 chan 只意味著生產者不能再發送資料,消費者無法獲知 chan 是否已經關閉,需要用其他方式去通訊。

語言特性

Go 相比 Java 來說,語言特性真的是少太多。推薦 Learn X in Y minutes 這個網站,快速瀏覽一遍即可掌握 Go 的語法。Go 的簡潔程度覺得和 JavaScript 差不多,但卻是一門靜態語言,具有強型別,這兩點又讓它區別於一般的指令碼語言。

程式碼風格

Go 遵循約定大於配置(convention over configuratio)的設計理念,比如在構建一個專案時,直接 go build 一個命令就搞定了,不需要什麼 Makefile、pom.xml 等配置檔案。下面介紹幾個常用的約定:

  • 一個包內函式、變數的可見性是通過首字母大小寫確定的。大寫表示可見。
  • 一般 { 放在行末,否則 Go 編輯器會自動插入一個逗號,導致編譯錯誤
  • 一個資料夾內,只能定義一個包
  • 變數、函式命名儘量簡短,標準庫裡面經常可以看到一個字母的變數

由於以上種種約定,在看別人程式碼時很舒服,有種 Python 的感覺。另外建議在編輯器中配置 goimports 來自動化格式程式碼。

錯誤處理

Go 內沒有 try catch 機制,而且已經明確拒絕了這個 Proposal,而是通過返回值的方式來處理。

f, err := os.Open(filename)
if err != nil {
    return …, err  // zero values for other results, if any
}

Go 的函式一般通過返回多值的方式來傳遞 error(且一般是第二個位置),實際專案中一般使用 pkg/errors 去處理、包裝 err。

依賴管理

Go 的依賴管理,相比其他語言較弱。
在 Go 1.11 正式引入的 modules 之前,專案必須放在 $GOPATH/src/xxx.com/username/project 內,這樣 Go 才能去正確解析專案依賴,而且 Go 社群沒有統一的包託管平臺,不像 Java 中 maven 一樣有中央倉庫的概念,而是直接引用 Git 的庫地址,所以在 Go 裡,一般會使用 github.com/username/package 的方式來表示。
go get 是下載依賴但命令,但一個個去 get 庫不僅僅繁碎,而且無法固化依賴版本資訊,所以 dep 應運而生,新增新依賴後,直接執行 dep ensure 就可以全部下下來,而且會把當前依賴的 commit id 記錄到 Gopkg.lock 裡面,這就能解決版本不固定的問題。

但 modules 才是正路,且在 1.13 版本會預設開啟,所以這裡只介紹它的用法。

# 首先匯出環境變數
export GO111MODULE=on
# 在一個空資料夾執行 init,建立一個名為 hello 的專案
go mod init hello
# 這時會在當前資料夾內建立 go.mod ,內容為

module hello

go 1.12
# 之後就可以編寫 Go 檔案,新增依賴後,執行 go run/
# 依賴會自動下載,並記錄在 go.mod 內,版本資訊記錄在 go.sum

更多用法可以參考官方示例,這裡只是想說明目前 Go 內的工具鏈大部分已經支援,但是 godoc 還不支援。

GC

Go 也是具有垃圾回收的語言,但相比於 JVM,Go GC 可能顯得及其簡單,從 Go 1.10 開始,Go GC 採用 Concurrent Mark & Sweep (CMS) 演算法,且不具有分代、compact 特性。讀者如果對相關名詞不熟悉,可以閱讀:

  • https://engineering.linecorp.com/en/blog/go-gc/

而且 Go 裡面調整 GC 的引數只有一個 GOGC,表示下面的比率

新分配物件 / 上次 GC 後剩餘物件

預設 100,表示新分配物件達到之前剩餘物件大小時,進行 GC。GOGC=off 可以關閉 GC,SetGCPercent 可以動態修改這個比率。

在啟動一個 Go 程式時,可以設定 GODEBUG=gctrace=1 來列印 GC 日誌,日誌具體含義可參考 pkg/runtime,這裡不再贅述。對除錯感興趣的可以閱讀:

  • https://new.blog.cloudflare.com/go-dont-collect-my-garbage/

總結

Go 最初由 Google 在 2007 為解決軟體複雜度、提升開發效率的一試驗品,到如今不過十二年,但無疑已經家喻戶曉,成為雲時代的首選。其面向介面的特有程式設計方式,也非常靈活,兼具動態語言的簡潔與靜態語言的高效,推薦大家嘗試一下。Go Go Go!

擴充套件閱讀

  • 03-包與依賴管理.md
  • I Love Go; I Hate Go
  • The Go Programming Language Specification#Receive operator
  • 王垠:對 Go 語言的綜合評價
  • https://github.com/golang/go/wiki/CodeReviewComments
  • https://golang.org/doc/faq