淺談 golang 代碼規(guī)范, 性能優(yōu)化和需要注意的坑
編碼規(guī)范
[強制] 聲明slice
申明 slice 最好使用
var t []int
而不是使用
t := make([]int, 0)
因為 var 并沒有初始化邓梅,但是 make 初始化了瞻凤。
但是如果要指定 slice 的長度或者 cap,可以使用 make
最小作用域
if err := DoSomething(); err != nil {
return err
}
盡量減少作用域, GC 比較友好
賦值規(guī)范
聲明一個對象有4種方式:make, new(), var, :=
比如:
t := make([]int, 0)
u := new(User)
var t []int
u := &User{}
- var 聲明但是不立刻初始化
- := 聲明并立刻使用
- 盡量減少使用 new() 因為他不會初始化值, 使用 u := User{} 更好
接口命名
單個功能使用 er 結(jié)尾或者名詞
type Reader interface {
Read(p []byte) (n int, err error)
}
2 個功能
type ReaderWriter interface {
Reader
Writer
}
3 個及以上功能
type Car interface {
Drive()
Stop()
Recover()
}
命名規(guī)范
代碼風(fēng)格
[強制] go 文件使用下劃線命名
[強制] 常量使用下劃線或者駝峰命名, 表達清除不要嫌名字太長
[推薦] 不要在名字中攜帶類型信息
// 反例
userMap := map[string]User{}
// 正例
users := map[string]User{}
[推薦] 方法的參數(shù)要能表達含義
// 反例
func CopyFile(a, b string) error
// 正例
func CopyFile(src, dst string) error
[推薦] 包名一律使用小寫字母, 不要加下劃線或者中劃線
[推薦] 如果使用了設(shè)計模式, 名稱中體現(xiàn)設(shè)計模式的含義
type AppFactory interface {
CreateApp() App
}
[推薦] 如果變量名是 bool 類型, 如果字段名不能表達 bool 類型, 可以使用 is 或者 has 前綴
var isDpdk bool
[強制] 一個變量只能有一個功能, 并和名稱一致, 不要把一個變量作為多種用途
// 反例
length := len(userList)
length := len(orderList)
// 正例
userNum := len(userList)
orderNum := len(orderList)
[推薦] 如果變量名是 bool 類型, 如果字段名不能表達 bool 類型, 可以使用 is 或者 has 前綴
golang 基本規(guī)范
包設(shè)計
[強制] 包的設(shè)計滿足單一職責(zé)
說明: 在 SRP (Single Response Principle) 模式中, 單一職責(zé)原則是指一個類只負(fù)責(zé)一項功能, 并且不能負(fù)責(zé)多個功能. 將包設(shè)計的非常內(nèi)聚, 減少包之間的 api
[強制] 包的設(shè)計遵循最小可見性原則
說明: 僅在包內(nèi)調(diào)用的函數(shù), 或者變量, 或者結(jié)構(gòu)體, 或者接口, 或者類型, 或者函數(shù)等等, 需要小寫開頭, 不可以可以被外部包訪問
[強制] 代碼需要可測試性, 使用接口和依賴注入替代硬編碼
[強制] 單元測試文件放到代碼文件同級目錄, 便于 golang 工具使用
比如: vscode 在方法上右鍵可以直接生成測試代碼和測試覆蓋率并可視化展示執(zhí)行情況
布局
[推薦] 程序?qū)嶓w之間使用空行區(qū)分, 增加可讀性
說明: 比如函數(shù)中各個模塊功能使用空行區(qū)分, 增加可讀性
[推薦] 每個文件末尾應(yīng)該有且僅有一個空行
[推薦] 一元操作符不要加空格, 二元操作符的才需要
注釋
[推薦] 可導(dǎo)出的方法, 變量, 結(jié)構(gòu)體等都需要注釋
表達式和語句
[推薦] if 或者循環(huán)的嵌套層數(shù)不宜大于 3
[推薦] 對于 for 遍歷, 優(yōu)先使用 range 而不是顯式'的下標(biāo), 如果 value 占用內(nèi)存大的話可以使用顯式下標(biāo)
說明: range 可以讓代碼更加整潔, 特別是多層 for 嵌套的時候, 但是 range 非拷貝值, 如果 value 不是指針類型, 而且占用內(nèi)存較大會有性能損耗.
函數(shù)
[強制] 命名不要暴露實現(xiàn)細(xì)節(jié), 一般以"做什么"來命名而不是"怎么做"
[推薦] 短小精悍, 盡量控制到 20 行左右
說明: 函數(shù)的粒度越小, 可復(fù)用的概率越大, 而且函數(shù)越多, 高層函數(shù)調(diào)用起來就是代碼可讀性很高, 讀起來就像一系列解釋
[推薦] 單一職責(zé), 函數(shù)只做好一件事情, 只做一件事情
[強制] 不要設(shè)置多功能函數(shù)
例如: 一個函數(shù)既修改了狀態(tài), 又返回了狀態(tài), 應(yīng)該拆分
[推薦] 為簡單的功能編寫函數(shù)
說明: 為 1,2 行代碼編寫函數(shù)也是必要的, 增加代碼的可復(fù)用性, 增加高層函數(shù)的可讀性, 可維護性, 可測試性
參數(shù)
[推薦] 參數(shù)個數(shù)限制在 3 個以內(nèi), 如果超過了, 可以使用配置類或者 Options 設(shè)計模式
說明: 函數(shù)的最理想的參數(shù)的個數(shù)首先是0, 然后是 1, 然后是 2, 3 就會很差了. 因為參數(shù)有很多概念性, 可讀性差, 而且讓測試十分復(fù)雜
[推薦] 函數(shù)不能含有表示參數(shù)
說明: 標(biāo)識參數(shù)丑陋不堪, 函數(shù)往往根據(jù)標(biāo)識參數(shù)走不同的邏輯, 這個和單一職責(zé)違背
[強制] struct 作為參數(shù)傳遞的時候, 使用指針
說明: 函數(shù)的執(zhí)行就是壓棧, struct 如果有多個字段將會被多次壓棧, 有性能損失, 指針只會被壓棧一次
[推薦] 在 api(controller) 層對傳入的參數(shù)進行檢查, 而不是每一層都檢查一次
[推薦] 當(dāng) chan 作為函數(shù)的參數(shù)的時候, 根據(jù)最小權(quán)限原則, 使用單向 chan
// 讀取單向chan
func Parse(ch <-chan struct{}) {
for v := range ch {
println(v)
}
}
// 寫入單向chan
func Do(down chan<- struct{}) {
time.Sleep(time.Second)
down <- struct{}{}
}
返回值
[推薦] 返回值的個數(shù)不要大于 3
[強制] 統(tǒng)一定義錯誤, 不要隨便拋出錯誤
說明: 比如記錄不存在可能有多種錯誤
"record not exits"
"record not exited"
"record not exited!!"
上層函數(shù)要處理底層的錯誤的話, 要知道所有的拋出情況, 這個是不現(xiàn)實的, 需要處理的錯誤應(yīng)該使用統(tǒng)一文件定義錯誤碼
[強制] 沒有失敗原因的時候, 不要使用 error
// 正例
func IsPhone() bool
// 反例
func IsPhone() error
[推薦] 當(dāng)多重試幾次可以避免失敗的時候, 不要返回 error
錯誤是偶然發(fā)生的, 應(yīng)該給一個機會重試, 可以避免大多數(shù)的偶然問題
[推薦] 上層函數(shù)不關(guān)心 error 的時候, 不要返回 error
比如 Close(), Clear() 拋出了 error, 上層函數(shù)大概率不知道怎么處理
異常設(shè)計
[推薦] 程序的開發(fā)階段, 堅持速錯, 讓異常程序崩潰
說明: 速錯的本質(zhì)邏輯就是 "讓它掛", 只有掛了你才第一時間知道錯誤, panic 能讓 bug 盡快被修復(fù)
[強制] 程序部署后, 應(yīng)該避免終止
是否 recover 應(yīng)該根據(jù)配置文件確定, 默認(rèn)需要 recover
注意: 有時候需要在延遲函數(shù)中釋放資源, 比如 panic 之前 read 了 channel, 但是還沒有 write 就 panic , 需要在 deffer 函數(shù)中做好處理, 防止 channel 阻塞.
[推薦] 當(dāng)入?yún)⒉缓戏ǖ臅r候, panic
說明: 當(dāng)入?yún)⒉缓戏ǖ臅r候, panic, 可以讓上層函數(shù)知道錯誤, 而不是繼續(xù)執(zhí)行(api 應(yīng)該提前做好參數(shù)檢查)
整潔測試
[強制] 不要為了測試對代碼進行入侵式的修改, 應(yīng)該 mock
說明: 禁止為了測試在函數(shù)中增加條件分支和測試變量
[推薦] 測試的三要數(shù), 可讀性, 可讀性, 可讀性
生產(chǎn)代碼的可靠性由測試代碼來保證, 測試代碼的可靠性由最簡單的可讀性來保證, 邏輯需要簡單到?jīng)]有 bug
REFERENCE
bilibili go 規(guī)范
uber go-guide https://github.com/xxjwxc/uber_go_guide_cn
golang 性能優(yōu)化
內(nèi)存優(yōu)化
小對象合并
小對象在堆內(nèi)存上頻繁的創(chuàng)建和銷毀, 會導(dǎo)致內(nèi)存碎片, 一般會才使用內(nèi)存池
golang 的內(nèi)存機制也是內(nèi)存池, 每個 span 大小為 4KB, 同時維護一個 cache, cache 有一個 list 數(shù)組
數(shù)組里面儲存的是鏈表, 就像 HashMap 的拉鏈法, 數(shù)組的每個格子代表的內(nèi)存大小是不一樣的, 64 位的機器是 8 byte 為基礎(chǔ), 比如下標(biāo) 0 是 8 byte 大小的鏈表節(jié)點, 下標(biāo) 1 是 16 byte 的鏈表節(jié)點, 每個下標(biāo)的內(nèi)存不一樣, 使用的是按需分配最近的內(nèi)存, 比如一個結(jié)構(gòu)體的內(nèi)存實際上算下來是 31 byte, 分配的時候會分配 32 byte.
一個下標(biāo)的一條鏈表的每個 Node 儲存的內(nèi)存是一致的.
所以建議將小對象合并為一個 struct
for k, v := range m {
x := struct {k , v string} {k, v} // copy for capturing by the goroutine
go func() {
// using x.k & x.v
}()
}
使用 buf 緩存
協(xié)議編碼的時候需要頻繁的操作 buf, 可以使用 bytes.Buffer 作為緩存區(qū)對象, 它會一次性分配足夠大的內(nèi)存, 避免內(nèi)存不夠的時候動態(tài)申請內(nèi)存, 減少內(nèi)存分配次數(shù), 而且, buf 可以被復(fù)用(建議復(fù)用)
slice 和 map 創(chuàng)建的時候, 預(yù)估大小指定的容量
預(yù)先分配內(nèi)存, 可以減少動態(tài)擴容帶來的開銷
t := make([]int, 0, 100)
m := make(map[string]int, 100)
如果不確定 slice 會不會初始化, 使用 var 這樣不會分配內(nèi)存, make([]int,0) 會分配內(nèi)存空間
var t []int
拓展:
slice 容量在 1024 前擴容是倍增, 1024 后是1/4
map 的擴容機制比較復(fù)雜, 每次擴容是 2 倍數(shù), 結(jié)構(gòu)體中有一個 bucket 和 oldBuckets 實現(xiàn)增量擴容
長調(diào)用棧避免申請較多的臨時對象
說明: goroutine 默認(rèn)的 棧的大小是 4K, 1.7 改為 2K, 它采用的是連續(xù)棧的機制, 當(dāng)椞跆颍空間不夠的時候, goroutine 會不斷擴容, 每次擴容就先 slice 的擴容一樣, 設(shè)計新的棧空間申請和舊椝ィ空間的拷貝, 如果 GC 發(fā)現(xiàn)現(xiàn)在的空間只有之前的 1/4 又會縮容, 頻繁的內(nèi)存申請和拷貝會帶來開銷
建議: 控制函數(shù)調(diào)用棧幀的復(fù)雜度, 避免創(chuàng)建過多的臨時對象, 如果確實需要比較長的調(diào)用椑跗猓或者 job 類型的代碼, 可以考慮將 goroutine 池化
避免頻繁創(chuàng)建臨時變量
說明: GC STW 的時間已經(jīng)優(yōu)化到最糟糕 1ms 內(nèi)了, 但是還是有混合寫屏障會降低性能, 如果臨時變量個數(shù)太多, GC 性能損耗就高.
建議: 降低變量的作用域, 使用局部變量, 最小可見性, 將多個變量合并為一個 struct 數(shù)組(降低掃描次數(shù))
大的 struct 使用指針傳遞
golang 都是值拷貝, 特別是 struct 入棧幀的時候會將變量一個一個入棧, 頻繁申請內(nèi)存, 可以使用指針傳遞來優(yōu)化性能
并發(fā)優(yōu)化
goroutine 池化
go 雖然輕量, 但是對于高并發(fā)的輕量級任務(wù), 比如高并發(fā)的 job 類型的代碼, 可以考慮使用 goroutine 池化, 減少 goroutine 的創(chuàng)建和銷毀, 減少 goroutine 的創(chuàng)建和銷毀的開銷
減少系統(tǒng)調(diào)用
goroutine 的實現(xiàn)是通過同步模擬異步操作, 比如下面的操作并不會阻塞, runtime 的線程調(diào)度
- 網(wǎng)絡(luò)IO
- channel
- time.Sleep
- 基于底層異步的 SysCall
下面的阻塞會創(chuàng)建新的線程調(diào)度
- 本地 IO
- 基于底層同步的 SysCall
- CGO 調(diào)用 IO 或者其他阻塞
建議將同步調(diào)用: 隔離到可控 goroutine 中, 而不是直接高并 goroutine 調(diào)用
減少鎖, 減少大鎖
Go 推薦使用 channel 的方式調(diào)用而不是共享內(nèi)存, channel 之間存在大鎖, 可以將鎖的力度降低
拓展: channel
channel 不要傳遞大數(shù)據(jù), 會有值拷貝
channel 的底層是鏈表 + 鎖
不要用 channel 傳遞圖片等數(shù)據(jù), 任何的隊列的性能都很低, 可以嘗試指針優(yōu)化大對象
合并請求 singleflight
參考: singleflight
協(xié)議壓縮 protobuf
protobuf 比 json 的儲存效率和解析效率更高, 推薦在持久化或者數(shù)據(jù)傳輸?shù)臅r候使用 protobuf 替代 json
批量協(xié)議
對數(shù)據(jù)訪問接口提供批量協(xié)議, 比如門面設(shè)計模式或者 pipeline, 可以減少非常多的 IO, QPS, 和拆包解包的開銷
并行請求 errgroup
對于網(wǎng)關(guān)接口, 通常需要聚合多個模塊的數(shù)據(jù), 當(dāng)這些業(yè)務(wù)模塊數(shù)據(jù)之間沒有依賴的時候, 可以并行請求, 減少耗時
ctxTimeout, cf := context.WithTimeout(context.Background(), time.Second)
defer cf()
g, ctx := errgroup.WithContext(ctxTimeout)
var urls = []string{
"http://www.golang.org/",
"http://www.google.com/",
"http://www.somestupidname.com/",
}
for _, url := range urls {
// Launch a goroutine to fetch the URL.
url := url // https://golang.org/doc/faq#closures_and_goroutines
g.Go(func() error {
// Fetch the URL.
resp, err := http.Get(url)
if err == nil {
resp.Body.Close()
}
return err
})
}
// Wait for all HTTP fetches to complete.
if err := g.Wait(); err == nil {
fmt.Println("Successfully fetched all URLs.")
}
select {
case <-ctx.Done():
fmt.Println("Context canceled")
default:
fmt.Println("Context not canceled")
}
其他優(yōu)化
需要注意的坑
channel 之坑
如何優(yōu)雅的關(guān)閉 channel
參考: 如何優(yōu)雅的關(guān)閉 channel
關(guān)閉 channel 的坑
- 關(guān)閉已經(jīng)關(guān)閉的 channel 會導(dǎo)致 panic
- 給關(guān)閉的 channel 發(fā)送數(shù)據(jù)會導(dǎo)致 panic
- 從關(guān)閉的 channel 中讀取數(shù)據(jù)是初始值默認(rèn)值
CCP 原則
CCP: Channel Close Principle (關(guān)閉通道原則)
- 不要從接收端關(guān)閉 channel
- 不要關(guān)閉有多個發(fā)送端的 channel
- 當(dāng)發(fā)送端只有一個且后面不會再發(fā)送數(shù)據(jù)才可以關(guān)閉 channel
有緩存的 channel 不一定有序
defer 之坑
defer 中的變量
參數(shù)傳遞是在調(diào)用的時候
i := 1
defer println("defer", i)
i++
// defer 1
非參數(shù)的閉包
i := 1
defer func() {
println("defer", i)
}()
i++
// defer 2
有名返回同理閉包, 并且會修改有名返回的返回值
func main(){
fmt.Printf("main: %v\n", getNum())
// defer 2
// main: 2
}
func getNum() (i int) {
defer func() {
i++
println("defer", i)
}()
i++
return
}
不要 for 循環(huán)中調(diào)用 deffer
因為 deffer 只會在函數(shù) return 之后執(zhí)行, 這樣會累積大量的 deffer 而且極其容易出錯
建議: 將 for 循環(huán)需要 deffer 的代碼邏輯封裝為一個函數(shù)
HTTP 之坑
request 超時時間
golang 的 http 默認(rèn)的 request 沒有超時, 這是一個大坑, 因為如果服務(wù)器沒有響應(yīng), 也沒有斷開, 客戶端會一直等待, 導(dǎo)致客戶端阻塞, 量一上來就崩潰了
關(guān)閉 HTTP 的 response
http 請求框架的 response 一定要通過 Close 方法關(guān)閉, 不然有可能內(nèi)存泄露
interface 之坑
interface 到底什么才等于 nil?
說明: interface{}和接口類型 不同于 struct, 接口底層有 2 個成員, 一個是 type 一個是 value, 只有當(dāng) type 和 value 都為 nil 時, interface{} 才等于 nil
var u interface{} = (*interface{})(nil)
if u == nil {
t.Log("u is nil")
} else {
t.Log("u is not nil")
}
// u is not nil
接口
var u Car = (Car)(nil)
if u == nil {
t.Log("u is nil")
} else {
t.Log("u is not nil")
}
// u is nil
自定義的 struct
var u *user = (*user)(nil)
if u == nil {
t.Log("u is nil")
} else {
t.Log("u is not nil")
}
// u is nil
map 之坑
map 并發(fā)讀寫
map 并發(fā)讀寫會 panic, 需要加鎖或者使用 sync.Map
map 不能直接更新 value 的某一個字段
type User struct{
name string
}
func TestMap(t *testing.T) {
m := make(map[string]User)
m["1"] = User{name:"1"}
m["1"].name = "2"
// 編譯失敗,不能直接修改map的一個字段值
}
需要單獨拿出來
func TestMap(t *testing.T) {
m := make(map[string]User)
m["1"] = User{name: "1"}
u1 := m["1"]
u1.name = "2"
}
切片之坑
數(shù)組是值類型, 切片是引用類型(指針)
func TestArray(t *testing.T) {
a := [1]int{}
setArray(a)
println(a[0])
// 0
}
func setArray(a [1]int) {
a[0] = 1
}
func TestSlice(t *testing.T) {
a := []int{
1,
}
setSlice(a)
println(a[0])
// 1
}
func setSlice(a []int) {
a[0] = 1
}
range 遍歷
range 會給每一個元素創(chuàng)建一個副本, 會有值拷貝, 如果數(shù)組存的是大的結(jié)構(gòu)體可以用 index 遍歷或者指針優(yōu)化
因為 value 是副本, 所以不能修改原有的值
append 會改變地址
slice 類型的本質(zhì)是一個結(jié)構(gòu)體
type slice struct {
array unsafe.Pointer
len int
cap int
}
函數(shù)的值拷貝會導(dǎo)致修改失效
func TestAppend1(t *testing.T) {
var a []int
add(a)
println(len(a))
// 0
}
func add(a []int) {
a = append(a, 1)
}
閉包之坑
并發(fā)下 go 函數(shù)閉包問題
for i := 0; i < 3; i++ {
go func() {
println(i)
}()
}
time.Sleep(time.Second)
// 2
// 2
// 2
說明: 因為閉包導(dǎo)致 i 變量逃逸到堆空間, 所有的 go 共用了 i 變量, 導(dǎo)致并發(fā)問題
解決方法1: 局部變量
for i := 0; i < 3; i++ {
ii := i
go func() {
println(ii)
}()
}
time.Sleep(time.Second)
// 2
// 0
// 1
解決方法2: 參數(shù)傳遞
for i := 0; i < 3; i++ {
go func(ii int) {
println(ii)
}(i)
}
time.Sleep(time.Second)
// 2
// 0
// 1
buffer 之坑
buffer 對象池
buffer 對象池一定要用完才還回去, 不然buffer在多處復(fù)用導(dǎo)致底層的 []byte 內(nèi)容不一致
我們的一個 httpClient 返回處理使用了 sync.pool 緩存 buffer, 測試是內(nèi)存優(yōu)化了6-8倍
后面測試的時候發(fā)現(xiàn), 獲取的內(nèi)容會偶爾不一致, review 代碼發(fā)現(xiàn)可能是并發(fā)時候 buffer 指針放回去了還在使用, 導(dǎo)致和buffer pool 里面不一致
首先考慮就是將 buffer 的 bytes 讀取出來, 然后再 put 回池子里面
然后 bytes 是一個切片, 底層還是和 buffer 共用一個 []byte, buffer 再次修改的時候底層的 []byte 也會被修改, 導(dǎo)致狀態(tài)不一致
這些理論上是并發(fā)問題, 但是我們測試發(fā)現(xiàn), 單線程調(diào)用 httpClient 時候, 有時候會有問題, 有時候又沒有問題
官方的 http client 做請求的時候會開一個協(xié)程, sync pool在同一個協(xié)程下面復(fù)用對象是一致的, 但是多協(xié)程就會新建, 會嘗試通過協(xié)程的id獲取與之對應(yīng)的對象, 沒有才去新建.
串行執(zhí)行請求也會產(chǎn)生多個協(xié)程, 所以偶爾會觸發(fā)新建 sync 的buffer, 如果新建就不會報錯, 如果不新建就會報錯.
select 之坑
for select default 之坑
for 中的 default 在 select 一定會執(zhí)行, CPU 一直被占用不會讓出, 導(dǎo)致 CPU 空轉(zhuǎn)
示例代碼
func TestForSelect(t *testing.T) {
for {
select {
case <-time.After(time.Second * 1):
println("hello")
default:
if math.Pow10(100) == math.Pow(10, 100) {
println("equal")
}
}
}
}
top CPU 跑滿了
top - 15:00:50 up 1 day, 15:55, 0 users, load average: 1.36, 0.85, 0.35
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28632 root 20 0 2168296 1.4g 2244 S 252.8 11.7 1:04.15 __debug_bin