Go 語言的 10 個實用技巧
這裡是我過去幾年中編寫的大量 Go 程式碼的經驗總結而來的自己的最佳實踐。我相信它們具有彈性的。這裡的彈性是指:
某個應用需要適配一個靈活的環境。你不希望每過 3 到 4 個月就不得不將它們全部重構一遍。新增新的特性應當很容易。許多人蔘與開發該應用,它應當可以被理解,且維護簡單。許多人使用該應用,bug 應該容易被發現並且可以快速的修復。我用了很長的時間學到了這些事情。其中的一些很微小,但對於許多事情都會有影響。
所有這些都僅僅是建議,具體情況具體對待,並且如果有幫助的話務必告訴我。隨時留言:)
1. 使用單一的 GOPATH
多個 GOPATH 的情況並不具有彈性。GOPATH 本身就是高度自我完備的(通過匯入路徑)。有多個 GOPATH 會導致某些副作用,例如可能使用了給定的庫的不同的版本。你可能在某個地方升級了它,但是其他地方卻沒有升級。而且,我還沒遇到過任何一個需要使用多個 GOPATH 的情況。所以只使用單一的 GOPATH,這會提升你 Go 的開發進度。
許多人不同意這一觀點,接下來我會做一些澄清。像 etcd 或camlistore 這樣的大專案使用了像 godep 這樣的工具,將所有依賴儲存到某個目錄中。也就是說,這些專案自身有一個單一的 GOPATH。它們只能在這個目錄裡找到對應的版本。除非你的專案很大並且極為重要,否則不要為每個專案使用不同的 GOPATH。如果你認為專案需要一個自己的 GOPATH 目錄,那麼就建立它,否則不要嘗試使用多個 GOPATH。它只會拖慢你的進度。
2. 將 for-select 封裝到函式中
如果在某個條件下,你需要從 for-select 中退出,就需要使用標籤。例如:
func main() { L: for { select { case <-time.After(time.Second): fmt.Println("hello") default: break L } } fmt.Println("ending") }
如你所見,需要聯合break
使用標籤。這有其用途,不過我不喜歡。這個例子中的 for 迴圈看起來很小,但是通常它們會更大,而判斷break
的條件也更為冗長。
如果需要退出迴圈,我會將 for-select 封裝到函式中:
func main() { foo() fmt.Println("ending") } func foo() { for { select { case <-time.After(time.Second): fmt.Println("hello") default: return } } }
你還可以返回一個錯誤(或任何其他值),也是同樣漂亮的,只需要:
// 阻塞 if err := foo(); err != nil { // 處理 err }
3. 在初始化結構體時使用帶有標籤的語法
這是一個無標籤語法的例子:
type T struct { Foo string Bar int } func main() { t := T{"example", 123} // 無標籤語法 fmt.Printf("t %+v\n", t) }
那麼如果你新增一個新的欄位到T
結構體,程式碼會編譯失敗:
type T struct { Foo string Bar int Qux string } func main() { t := T{"example", 123} // 無法編譯 fmt.Printf("t %+v\n", t) }
如果使用了標籤語法,Go 的相容性規則(http://golang.org/doc/go1compat)會處理程式碼。例如在向net
包的型別新增叫做Zone
的欄位,參見:http://golang.org/doc/go1.1#library。回到我們的例子,使用標籤語法:
type T struct { Foo string Bar int Qux string } func main() { t := T{Foo: "example", Qux: 123} fmt.Printf("t %+v\n", t) }
這個編譯起來沒問題,而且彈性也好。不論你如何新增其他欄位到T
結構體。你的程式碼總是能編譯,並且在以後的 Go 的版本也可以保證這一點。只要在程式碼集中執行go vet
,就可以發現所有的無標籤的語法。
4. 將結構體的初始化拆分到多行
如果有兩個以上的欄位,那麼就用多行。它會讓你的程式碼更加容易閱讀,也就是說不要:
T{Foo: "example", Bar:someLongVariable, Qux:anotherLongVariable, B: forgetToAddThisToo}
而是:
T{ Foo: "example", Bar: someLongVariable, Qux: anotherLongVariable, B: forgetToAddThisToo, }
這有許多好處,首先它容易閱讀,其次它使得允許或遮蔽欄位初始化變得容易(只要註釋或刪除它們),最後新增其他欄位也更容易(只要新增一行)。
5. 為整數常量新增 String() 方法
如果你利用 iota 來使用自定義的整數列舉型別,務必要為其新增 String() 方法。例如,像這樣:
type State int const ( Running State = iota Stopped Rebooting Terminated )
如果你建立了這個型別的一個變數,然後輸出,會得到一個整數(http://play.golang.org/p/V5VVFB05HB):
func main() { state := Running // print: "state 0" fmt.Println("state ", state) }
除非你回顧常量定義,否則這裡的0
看起來毫無意義。只需要為State
型別新增String()
方法就可以修復這個問題(http://play.golang.org/p/ewMKl6K302):
func (s State) String() string { switch s { case Running: return "Running" case Stopped: return "Stopped" case Rebooting: return "Rebooting" case Terminated: return "Terminated" default: return "Unknown" } }
新的輸出是:state: Running
。顯然現在看起來可讀性好了很多。在你除錯程式的時候,這會帶來更多的便利。同時還可以在實現 MarshalJSON()、UnmarshalJSON() 這類方法的時候使用同樣的手段。
6. 讓 iota 從 a +1 開始增量
在前面的例子中同時也產生了一個我已經遇到過許多次的 bug。假設你有一個新的結構體,有一個State
欄位:
type T struct { Name string Port int State State }
現在如果基於 T 建立一個新的變數,然後輸出,你會得到奇怪的結果(http://play.golang.org/p/LPG2RF3y39):
func main() { t := T{Name: "example", Port: 6666} // prints: "t {Name:example Port:6666 State:Running}" fmt.Printf("t %+v\n", t) }
看到 bug 了嗎?State
欄位沒有初始化,Go 預設使用對應型別的零值進行填充。由於State
是一個整數,零值也就是0
,但在我們的例子中它表示Running
。
那麼如何知道 State 被初始化了?還是它真得是在Running
模式?沒有辦法區分它們,那麼這就會產生未知的、不可預測的 bug。不過,修復這個很容易,只要讓 iota 從 +1 開始(http://play.golang.org/p/VyAq-3OItv):
const ( Running State = iota + 1 Stopped Rebooting Terminated )
現在t
變數將預設輸出Unknown
,不是嗎? :
func main() { t := T{Name: "example", Port: 6666} // 輸出: "t {Name:example Port:6666 State:Unknown}" fmt.Printf("t %+v\n", t) }
不過讓 iota 從零值開始也是一種解決辦法。例如,你可以引入一個新的狀態叫做Unknown
,將其修改為:
const ( Unknown State = iota Running Stopped Rebooting Terminated )
7. 返回函式呼叫
我已經看過很多程式碼例如(http://play.golang.org/p/8Rz1EJwFTZ):
func bar() (string, error) { v, err := foo() if err != nil { return "", err } return v, nil }
然而,你只需要:
func bar() (string, error) { return foo() }
更簡單也更容易閱讀(當然,除非你要對某些內部的值做一些記錄)。
8. 把 slice、map 等定義為自定義型別
將 slice 或 map 定義成自定義型別可以讓程式碼維護起來更加容易。假設有一個Server
型別和一個返回伺服器列表的函式:
type Server struct { Name string } func ListServers() []Server { return []Server{ {Name: "Server1"}, {Name: "Server2"}, {Name: "Foo1"}, {Name: "Foo2"}, } }
現在假設需要獲取某些特定名字的伺服器。需要對 ListServers() 做一些改動,增加篩選條件:
// ListServers 返回伺服器列表。只會返回包含 name 的伺服器。空的 name 將會返回所有伺服器。 func ListServers(name string) []Server { servers := []Server{ {Name: "Server1"}, {Name: "Server2"}, {Name: "Foo1"}, {Name: "Foo2"}, } // 返回所有伺服器 if name == "" { return servers } // 返回過濾後的結果 filtered := make([]Server, 0) for _, server := range servers { if strings.Contains(server.Name, name) { filtered = append(filtered, server) } } return filtered }
現在可以用這個來篩選有字串Foo
的伺服器:
func main() { servers := ListServers("Foo") // 輸出:“servers [{Name:Foo1} {Name:Foo2}]” fmt.Printf("servers %+v\n", servers) }
顯然這個函式能夠正常工作。不過它的彈性並不好。如果你想對伺服器集合引入其他邏輯的話會如何呢?例如檢查所有伺服器的狀態,為每個伺服器建立一個資料庫記錄,用其他欄位進行篩選等等……
現在引入一個叫做Servers
的新型別,並且修改原始版本的 ListServers() 返回這個新型別:
type Servers []Server // ListServers 返回伺服器列表 func ListServers() Servers { return []Server{ {Name: "Server1"}, {Name: "Server2"}, {Name: "Foo1"}, {Name: "Foo2"}, } }
現在需要做的是隻要為Servers
型別新增一個新的Filter()
方法:
// Filter 返回包含 name 的伺服器。空的 name 將會返回所有伺服器。 func (s Servers) Filter(name string) Servers { filtered := make(Servers, 0) for _, server := range s { if strings.Contains(server.Name, name) { filtered = append(filtered, server) } } return filtered }
現在可以針對字串Foo
篩選伺服器:
func main() { servers := ListServers() servers = servers.Filter("Foo") fmt.Printf("servers %+v\n", servers) }
哈!看到你的程式碼是多麼的簡單了嗎?還想對伺服器的狀態進行檢查?或者為每個伺服器新增一條資料庫記錄?沒問題,新增以下新方法即可:
func (s Servers) Check() func (s Servers) AddRecord() func (s Servers) Len() ...
9. withContext 封裝函式
有時對於函式會有一些重複勞動,例如鎖/解鎖,初始化一個新的區域性上下文,準備初始化變數等等……這裡有一個例子:
func foo() { mu.Lock() defer mu.Unlock() // foo 相關的工作 } func bar() { mu.Lock() defer mu.Unlock() // bar 相關的工作 } func qux() { mu.Lock() defer mu.Unlock() // qux 相關的工作 }
如果你想要修改某個內容,你需要對所有的都進行修改。如果它是一個常見的任務,那麼最好建立一個叫做withContext
的函式。這個函式的輸入引數是另一個函式,並用呼叫者提供的上下文來呼叫它:
func withLockContext(fn func()) { mu.Lock defer mu.Unlock() fn() }
只需要將之前的函式用這個進行封裝:
func foo() { withLockContext(func() { // foo 相關工作 }) } func bar() { withLockContext(func() { // bar 相關工作 }) } func qux() { withLockContext(func() { // qux 相關工作 }) }
不要光想著加鎖的情形。對此來說最好的用例是資料庫連結。現在對 withContext 函式作一些小小的改動:
func withDBContext(fn func(db DB) error) error { // 從連線池獲取一個資料庫連線 dbConn := NewDB() return fn(dbConn) }
如你所見,它獲取一個連線,然後傳遞給提供的引數,並且在呼叫函式的時候返回錯誤。你需要做的只是:
func foo() { withDBContext(func(db *DB) error { // foo 相關工作 }) } func bar() { withDBContext(func(db *DB) error { // bar 相關工作 }) } func qux() { withDBContext(func(db *DB) error { // qux 相關工作 }) }
你在考慮一個不同的場景,例如作一些預初始化?沒問題,只需要將它們加到withDBContext
就可以了。這對於測試也同樣有效。
這個方法有個缺陷,它增加了縮排並且更難閱讀。再次提示,永遠尋找最簡單的解決方案。
10. 為訪問 map 增加 setter,getters
如果你重度使用 map 讀寫資料,那麼就為其新增 getter 和 setter 吧。通過 getter 和 setter 你可以將邏輯封分別裝到函式裡。這裡最常見的錯誤就是併發訪問。如果你在某個 goroutein 裡有這樣的程式碼:
m["foo"] = bar
還有這個:
delete(m, "foo")
會發生什麼?你們中的大多數應當已經非常熟悉這樣的競態了。簡單來說這個競態是由於 map 預設並非執行緒安全。不過你可以用互斥量來保護它們:
mu.Lock() m["foo"] = "bar" mu.Unlock()
以及:
mu.Lock() delete(m, "foo") mu.Unlock()
假設你在其他地方也使用這個 map。你必須把互斥量放得到處都是!然而通過 getter 和 setter 函式就可以很容易的避免這個問題:
func Put(key, value string) { mu.Lock() m[key] = value mu.Unlock() } func Delete(key string) { mu.Lock() delete(m, key) mu.Unlock() }
使用介面可以對這一過程做進一步的改進。你可以將實現完全隱藏起來。只使用一個簡單的、設計良好的介面,然後讓包的使用者使用它們:
type Storage interface { Delete(key string) Get(key string) string Put(key, value string) }
這只是個例子,不過你應該能體會到。對於底層的實現使用什麼都沒關係。不光是使用介面本身很簡單,而且還解決了暴露內部資料結構帶來的大量的問題。
但是得承認,有時只是為了同時對若干個變數加鎖就使用介面會有些過分。理解你的程式,並且在你需要的時候使用這些改進。
總結
抽象永遠都不是容易的事情。有時,最簡單的就是你已經實現的方法。要知道,不要讓你的程式碼看起來很聰明。Go 天生就是個簡單的語言,在大多數情況下只會有一種方法來作某事。簡單是力量的源泉,也是為什麼在人的層面它表現的如此有彈性。
如果必要的話,使用這些基數。例如將[]Server
轉化為Servers
是另一種抽象,僅在你有一個合理的理由的情況下這麼做。不過有一些技術,如 iota 從 1 開始計數總是有用的。再次提醒,永遠保持簡單。
特別感謝 Cihangir Savas、Andrew Gerrand、Ben Johnson 和 Damian Gryski 提供的極具價值的反饋和建議。
相關文章
- go語言json的使用技巧GoJSON
- 用 Go 語言實戰 Limit Concurrency 方法GoMIT
- go語言的31個坑Go
- Go語言核心36講(Go語言實戰與應用九)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用八)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十二)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用二)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用一)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十七)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用五)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用七)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用四)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十一)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十四)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十五)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十九)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十八)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用二十)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十三)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用十六)--學習筆記Go筆記
- 用 Go 語言 buffered channel 實作 Job QueueGo
- 用 Go 語言實作 Job Queue 機制Go
- ent - 一個強大的Go語言實體框架Go框架
- Go語言基礎知識01-用Go打個招呼Go
- Go 語言實踐(一)Go
- Go語言實現RPCGoRPC
- Go 語言實戰 GraphQLGo
- 兩個最多可以提高千倍效率的Go語言程式碼小技巧Go
- Go語言一個輕便的實時日誌類似slack收集應用Go
- Go語言核心36講(Go語言實戰與應用二十一)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用二十五)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用二十二)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用二十七)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用二十六)--學習筆記Go筆記
- Go語言核心36講(Go語言實戰與應用二十三)--學習筆記Go筆記
- go語言實現自己的RPC:go rpc codecGoRPC
- Go 1.21的2個語言變化Go
- GO語言一個簡單的工程Go
- Go 語言的 4 個特性改動Go