十條有用的 Go 技術
這裏是我過去幾年中編寫的大量 Go 代碼的經驗總結而來的自己的最佳實踐。我相信它們具有彈性的。這裏的彈性是指:
某個應用需要適配一個靈活的環境。你不希望每過 3 到 4 個月就不得不將它們全部重構一遍。添加新的特性應當很容易。許多人蔘與開發該應用,它應當可以被理解,且維護簡單。許多人使用該應用,bug 應該容易被發現並且可以快速的修復。我用了很長的時間學到了這些事情。其中的一些很微小,但對於許多事情都會有影響。所有這些都僅僅是建議,具體情況具體對待,並且如果有幫助的話務必告訴我。隨時留言:)
1. 使用單一的 GOPATH
多個 GOPATH 的情況並不具有彈性。GOPATH 本身就是高度自我完備的(通過導入路徑)。有多個 GOPATH 會導致某些副作用,例如可能使用了給定的庫的不同的版本。你可能在某個地方升級了它,但是其他地方卻沒有升級。而且,我還沒遇到過任何一個需要使用多個 GOPATH 的情況。所以只使用單一的 GOPATH,這會提升你 Go 的開發進度。
許多人不同意這一觀點,接下來我會做一些澄清。像 etcd 或 camlistore 這樣的大項目使用了像 godep 這樣的工具,將所有依賴保存到某個目錄中。也就是說,這些項目自身有一個單一的 GOPATH。它們只能在這個目錄裏找到對應的版本。除非你的項目很大並且極爲重要,否則不要爲每個項目使用不同的 GOPAHT。如果你認爲項目需要一個自己的 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 { |
//
從連接池獲取一個數據庫連接 |
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 提供的極具價值的反饋和建議。