0.1、索引
https://waterflow.link/articles/1664080524986
1、未知的列舉值
我們現在定義一個型別是unit32的Status,他可以作為列舉型別,我們定義了3種狀態
type Status uint32
const (
StatusOpen Status = iota
StatusClosed
StatusUnknown
)
其中我們使用了iota,相關的用法自行google,最終對應的狀態就是:
0-開啟狀態,1-關閉狀態,2-未知狀態
現在我們假設有一個請求引數過來,資料結構如下:
{
"Id": 1234,
"Timestamp": 1563362390,
"Status": 1
}
可以看到是一個json型別的字串,其中就包含了Status狀態,我們的請求是希望把狀態修改為關閉狀態,
然后我們在服務端創建一個結構體,方便把這些欄位決議出來:
type Request struct {
ID int `json:"Id"`
Timestamp int `json:"Timestamp"`
Status Status `json:"Status"`
}
好了,我們在main中執行下代碼,看下決議是否正確:
package main
import (
"encoding/json"
"fmt"
)
type Status uint32
const (
StatusOpen Status = iota
StatusClosed
StatusUnknown
)
type Request struct {
ID int `json:"Id"`
Timestamp int `json:"Timestamp"`
Status Status `json:"Status"`
}
func main() {
js := `{
"Id": 1234,
"Timestamp": 1563362390,
"Status": 1
}`
request := &Request{}
err := json.Unmarshal([]byte(js), request)
if err != nil {
fmt.Println(err)
return
}
}
執行后的結果如下:
go run main.go
&{1234 1563362390 1}
可以看到決議是沒問題的,
然而,讓我們再提出一個未設定狀態值的請求(無論出于何種原因):
{
"Id": 1234,
"Timestamp": 1563362390
}
在這種情況下,請求結構的狀態欄位將被初始化為其零值(對于 uint32 型別:0),因此,StatusOpen 而不是 StatusUnknown,
最佳實踐是將列舉的未知值設定為 0:
type Status uint32
const (
StatusUnknown Status = iota
StatusOpen
StatusClosed
)
在這里,如果狀態不是 JSON 請求的一部分,它將被初始化為 StatusUnknown,正如我們所期望的那樣,
2、指標無處不在?
按值傳遞變數將創建此變數的副本,而通過指標傳遞它只會復制記憶體地址,
因此,傳遞指標總是會更快,對么?
如果你相信這一點,請看看這個例子,這是一個 0.3 KB 資料結構的基準測驗,我們通過指標和值傳遞和接收, 0.3 KB 并不大,但這與我們每天看到的資料結構型別(對于我們大多數人來說)應該相差不遠,
當我在本地環境中執行這些基準測驗時,按值傳遞比按指標傳遞快 4 倍以上,這可能有點違反直覺,對吧?
這其實與 Go 中如何管理記憶體有關,我們都知道變數可以分配在堆上或堆疊上,也知道:
- 堆疊包含給定 goroutine 的正在進行的變數,一旦函式回傳,變數就會從堆疊中彈出,
- 堆包含共享變數(全域變數等),
讓我們看下下面這個簡單的例子:
type foo struct{}
func getFooValue() foo {
var result foo
// Do something
return result
}
這里,一個結果變數由當前的 goroutine 創建,這個變數被壓入當前堆疊,一旦函式回傳,客戶端將收到此變數的副本,變數本身從堆疊中彈出,它仍然存在于記憶體中,直到它被另一個變數擦除,但它不能再被訪問,
我們現在修改下上面的例子,使用指標:
type foo struct{}
func getFooPointer() *foo {
var result foo
// Do something
return &result
}
結果變數仍然由當前的 goroutine 創建,但客戶端將收到一個指標(變數地址的副本),如果結果變數從堆疊中彈出,則此函式的客戶端無法再訪問它,
在這種情況下,Go 編譯器會將結果變數轉移到可以共享變數的地方:堆,
但是,傳遞指標是另一種情況,例如:
type foo struct{}
func main() {
p := &foo{}
f(p)
}
因為我們在同一個 goroutine 中呼叫 f,所以 p 變數不需要被轉移,它只是被壓入堆疊,子函式可以訪問它,
比如在 io.Reader 的 Read 方法中接收切片而不是回傳切片的直接結果,也不會轉移到堆上,
但是回傳一個切片(它是一個指標)會將其轉移到堆中,
為什么堆疊那么快?主要原因有兩個:
- 堆疊不需要垃圾收集器,正如我們所說,一個變數在創建后被簡單地壓入,然后在函式回傳時從堆疊中彈出,無需進行復雜的程序來回收未使用的變數等,
- 堆疊屬于一個 goroutine,因此與將變數存盤在堆上相比,存盤變數不需要同步,這也導致性能增益,
結論就是:
當我們創建一個函式時,我們的默認行為應該是使用值而不是指標,僅當我們想要共享變數時才應使用指標,
最后:
如果我們遇到性能問題,一種可能的優化可能是檢查指標在某些特定情況下是否有幫助,使用以下命令可以知道編譯器何時將變數轉移到堆中:go build -gcflags "-m -m",(記憶體逃逸)
3、中斷 for/switch 或 for/select
我們看下下面的代碼會發生什么:
package main
func f() bool {
return true
}
func main() {
for {
switch f() {
case true:
break
case false:
// Do something
}
}
}
我們將呼叫 break 陳述句,但是,這會破壞 switch 陳述句,而不是 for 回圈,
相同的情況還會出現在fo/select中,像下面這樣:
package main
import (
"context"
"time"
)
func main() {
ch := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break
}
}
}
雖然呼叫了break,但是還是會陷入死回圈,break 與 select 陳述句有關,與 for 回圈無關,
打破 for/switch 或 for/select 的,一種方案是直接return結束整個函式,下面如果還有代碼不會被執行,
package main
import (
"context"
"fmt"
"time"
)
func main() {
ch := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
return
}
}
// 這里不會執行
fmt.Println("done")
}
還有一種方案是使用中斷標記
package main
import (
"context"
"fmt"
"time"
)
func main() {
ch := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
loop:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break loop
}
}
// 會繼續往下執行
fmt.Println("done")
}
4、錯誤管理
一個錯誤應該只處理一次,記錄錯誤就是處理錯誤,因此,應該記錄或傳播錯誤,
我們可能希望為錯誤添加一些背景關系并具有某種形式的層次結構,
讓我們看一個介面請求資料庫的例子,我們分為介面層,service層和類別庫層,我們希望回傳的層次結構像下面這樣:
unable to serve HTTP POST request for id 1
|_ unable to insert customer
|_ unable to commit transaction
如果我們使用 pkg/errors,我們可以這樣做:
package main
import (
"fmt"
"github.com/pkg/errors"
)
func postHandler(id int) string {
err := insert(id)
if err != nil {
fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
return `{ok: false}`
}
return `{ok: true}`
}
func insert(id int) error {
err := dbQuery(id)
if err != nil {
return errors.Wrapf(err, "unable to insert customer")
}
return nil
}
func dbQuery(id int) error {
// Do something then fail
return errors.New("unable to commit transaction")
}
func main() {
res := postHandler(1)
fmt.Println(res)
}
初始錯誤(如果不是由外部庫回傳)可以使用 errors.New 創建,service層 insert 通過向其添加更多背景關系來包裝此錯誤,然后,介面層通過記錄錯誤來處理錯誤,每個級別都回傳或處理錯誤,
例如,我們可能還想檢查錯誤原因本身以實作重試,假設我們有一個來自處理資料庫訪問的外部庫的 db 包,這個庫可能會回傳一個名為 db.DBError 的暫時(臨時)錯誤,要確定是否需要重試,我們必須檢查錯誤原因:
package main
import (
"fmt"
"github.com/pkg/errors"
)
type DbError struct {
msg string
}
func (e *DbError) Error() string {
return e.msg
}
func postHandler(id int) string {
err := insert(id)
if err != nil {
errCause := errors.Cause(err)
if _, ok := errCause.(*DbError); ok {
fmt.Println("retry")
} else {
fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
return `{ok: false}`
}
}
return `{ok: true}`
}
func insert(id int) error {
err := dbQuery(id)
if err != nil {
return errors.Wrapf(err, "unable to insert customer")
}
return nil
}
func dbQuery(id int) error {
// Do something then fail
return &DbError{"unable to commit transaction"}
}
func main() {
res := postHandler(1)
fmt.Println(res)
}
這是使用errors.Cause完成的,它也來自pkg/errors,(可以通過errors.Cause檢查, errors.Cause 將遞回檢索沒有實作causer 的最頂層錯誤,這被認為是原始原因,)
有時候也會有人這么用,例如,檢查錯誤是這樣完成的:
package main
import (
"fmt"
"github.com/pkg/errors"
)
type DbError struct {
msg string
}
func (e *DbError) Error() string {
return e.msg
}
func postHandler(id int) string {
err := insert(id)
if err != nil {
switch err.(type) {
default:
fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
return `{ok: false}`
case *DbError:
fmt.Println("retry")
}
}
return `{ok: true}`
}
func insert(id int) error {
err := dbQuery(id)
if err != nil {
return errors.Wrapf(err, "unable to insert customer")
}
return nil
}
func dbQuery(id int) error {
// Do something then fail
return &DbError{"unable to commit transaction"}
}
func main() {
res := postHandler(1)
fmt.Println(res)
}
如果 DBError 被包裝,它永遠不會觸發重試,
5、切片初始化
有時,我們知道切片的最終長度是多少,例如,假設我們要將 Foo 的切片轉換為 Bar 的切片,這意味著這兩個切片將具有相同的長度,
我們有時候經常會這樣初始化切片:
var bars []Bar
bars := make([]Bar, 0)
我們都知道切片的底層是陣列,如果沒有更多可用空間,它會實施增長戰略,在這種情況下,會自動創建一個新陣列(容量更大)并復制所有元素,
現在,假設我們需要多次重復這個增長操作,因為我們的 []Foo 包含數千個元素?插入的攤銷時間復雜度(平均值)將保持為 O(1),但在實踐中,它會對性能產生影響,
因此,如果我們知道最終長度,我們可以:
-
使用預定義的長度對其進行初始化:
func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars } -
或者使用 0 長度和預定義容量對其進行初始化:
func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars }
選哪個更好呢?第一個稍微快一點,然而,你可能更喜歡第二個,因為無論我們是否知道初始大小,在切片末尾添加一個元素都是使用 append 完成的,
6、背景關系管理
context.Context對我們來說非常好用,他可以在協程之間傳遞資料、可以控制協程的生命周期等等,但是這也造成了它的濫用,
go官方檔案是這么定義的:
一個 Context 攜帶一個截止日期、一個取消信號和其他跨 API 邊界的值,
這個描述很寬泛,足以讓一些人對為什么以及如何使用它感到困惑,
讓我們試著詳細說明一下,背景關系可以攜帶:
- 一個截止時間,它意味著一個持續時間(例如 250 毫秒)或日期時間(例如 2022-01-08 01:00:00),我們認為如果達到,我們必須取消正在進行的活動(I/O 請求,等待通道輸入等),
- 取消信號(基本上是 <-chan struct{}), 在這里,行為是相似的, 一旦我們收到信號,我們必須停止正在進行的活動, 例如,假設我們收到兩個請求, 一個插入一些資料,另一個取消第一個請求(因為它不再需要), 這可以通過在第一次呼叫中使用可取消背景關系來實作,一旦我們收到第二個請求,該背景關系將被取消,
- 鍵/值串列(均基于 interface{} 型別),
另外需要說明的是,
首先,背景關系是可組合的,因此,我們可以有一個包含截止日期和鍵/值串列的背景關系,
此外,多個 goroutine 可以共享相同的背景關系,因此取消信號可能會停止多個活動,
我們可以看下一個具體的錯誤例子
一個 Go 應用程式是基于 urfave/cli 的(如果你不知道,那是一個在 Go 中創建命令列應用程式的好庫),一旦開始,開發人員就會繼承某種應用程式背景關系,這意味著當應用程式停止時,庫將使用此背景關系發送取消信號,
我了解的是,這個背景關系是在呼叫 gRPC 端點時直接傳遞的,這不是我們想要做的,
相反,我們想向 gRPC 庫傳遞:請在應用程式停止時或在 100 毫秒后取消請求,
為此,我們可以簡單地創建一個組合背景關系,如果 parent 是應用程式背景關系的名稱(由 urfave/cli 創建),那么我們可以簡單地這樣做:
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "boom",
Usage: "make an explosive entrance",
Action: func(parent *cli.Context) error {
// 父背景關系傳進來,給個超時時間
ctx, cancel := context.WithTimeout(parent.Context, 10*time.Second)
defer cancel()
grpcClientSend(ctx)
return nil
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
func grpcClientSend(ctx context.Context) {
for {
select {
case <-ctx.Done(): // 達到超時時間就結束
fmt.Println("cancel!")
return
default:
time.Sleep(2 * time.Second)
fmt.Println("do something!")
}
}
}
7、使用檔案名作為函式輸入?
假設我們必須實作一個函式來計算檔案中的空行數,一般我們是這樣實作的:
package main
import (
"bufio"
"fmt"
"os"
"github.com/pkg/errors"
)
func main() {
cou, err := count("a.txt")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(cou)
}
func count(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, errors.Wrapf(err, "unable to open %s", filename)
}
defer file.Close()
scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
if scanner.Text() == "" {
count++
}
}
return count, nil
}
檔案名作為輸入給出,所以我們打開它然后我們實作我們的邏輯,對吧?
現在,假設我們要在此函式之上實作單元測驗,以測驗普通檔案、空檔案、具有不同編碼型別的檔案等,這很容易變得非常難以管理,
此外,如果我們想要對http body實作相同的邏輯,我們將不得不為此創建另一個函式,
Go 帶有兩個很棒的抽象:io.Reader 和 io.Writer,我們可以簡單地傳遞一個 io.Reader 來抽象資料源,而不是傳遞檔案名,
是檔案嗎? HTTP body?位元組緩沖區?這并不重要,因為我們仍將使用相同的 Read 方法,
在我們的例子中,我們甚至可以緩沖輸入以逐行讀取,因此,我們可以使用 bufio.Reader 及其 ReadLine 方法:
我們把讀取檔案的部分放到函式外面
package main
import (
"bufio"
"fmt"
"io"
"os"
"github.com/pkg/errors"
)
func main() {
filename := "a.txt"
file, err := os.Open(filename)
if err != nil {
fmt.Println(err, "unable to open ", filename)
return
}
defer file.Close()
count, err := count(bufio.NewReader(file))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(count)
}
func count(reader *bufio.Reader) (int, error) {
count := 0
for {
line, _, err := reader.ReadLine()
if err != nil {
switch err {
default:
return 0, errors.Wrapf(err, "unable to read")
case io.EOF:
return count, nil
}
}
if len(line) == 0 {
count++
}
}
}
使用第二種實作,無論實際資料源如何,都可以呼叫該函式,同時,這將有助于我們的單元測驗,因為我們可以簡單地從字串創建一個 bufio.Reader:
package main
import (
"bufio"
"fmt"
"io"
"strings"
"github.com/pkg/errors"
)
func main() {
count, err := count(bufio.NewReader(strings.NewReader("input\n\n")))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(count)
}
func count(reader *bufio.Reader) (int, error) {
count := 0
for {
line, _, err := reader.ReadLine()
if err != nil {
switch err {
default:
return 0, errors.Wrapf(err, "unable to read")
case io.EOF:
return count, nil
}
}
if len(line) == 0 {
count++
}
}
}
8、Goroutines 和回圈變數
我看到一個常見錯誤是使用帶有回圈變數的 goroutines,
以下示例的輸出是什么?
package main
import (
"fmt"
"time"
)
func main() {
ints := []int{1, 2, 3}
for _, i := range ints {
go func() {
fmt.Printf("%v\n", i)
}()
}
time.Sleep(time.Second)
}
在這個例子中,每個 goroutine 共享相同的變數實體,所以它會產生 3 3 3,而不是我們認為的1 2 3
有兩種解決方案可以解決這個問題,第一個是將 i 變數的值傳遞給閉包(內部函式):
package main
import (
"fmt"
"time"
)
func main() {
ints := []int{1, 2, 3}
for _, i := range ints {
go func(i int) {
fmt.Printf("%v\n", i)
}(i)
}
time.Sleep(time.Second)
}
第二個是在 for 回圈范圍內創建另一個變數:
package main
import (
"fmt"
"time"
)
func main() {
ints := []int{1, 2, 3}
for _, i := range ints {
i := i
go func() {
fmt.Printf("%v\n", i)
}()
}
time.Sleep(time.Second)
}
呼叫 i := i 可能看起來有點奇怪,但它完全有效,處于回圈中意味著處于另一個范圍內,所以 i := i 創建了另一個名為 i 的變數實體,當然,為了便于閱讀,我們可能想用不同的名稱來稱呼它,
原文
https://itnext.io/the-top-10-most-common-mistakes-ive-seen-in-go-projects-4b79d4f6cd65
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/519007.html
標籤:Go
下一篇:golang中的socket編程
