Blog

Go Study Logs

Uber Go编码规范学习

https://github.com/xxjwxc/uber_go_guide_cn

指导原则

interface的指针

如果希望接口方法修改基础数据,则必须使用指针传递 (将对象指针赋值给接口变量)。

type F interface {
  f()
}

type S1 struct{}

func (s S1) f() {}

type S2 struct{}

func (s *S2) f() {}

// f1.f() 无法修改底层数据
// f2.f() 可以修改底层数据,给接口变量 f2 赋值时使用的是对象指针
var f1 F = S1{}
var f2 F = &S2{}

interface合理性验证

type Handler struct {
  // ...
}
// 用于触发编译期的接口的合理性检查机制
// 如果 Handler 没有实现 http.Handler,会在编译期报错
var _ http.Handler = (*Handler)(nil)
func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

receiver与interface

  • 使用值接收器的方法既可以通过值调用,也可以通过指针调用。
  • 带指针接收器的方法只能通过指针或addressable values调用。

Mutex

如果你使用结构体指针,mutex 应该作为结构体的非指针字段。即使该结构体不被导出,也不要直接把 mutex 嵌入到结构体中。

type SMap struct {
  mu sync.Mutex

  data map[string]string
}

func NewSMap() *SMap {
  return &SMap{
    data: make(map[string]string),
  }
}

func (m *SMap) Get(k string) string {
  m.mu.Lock()
  defer m.mu.Unlock()

  return m.data[k]
}

拷贝slices和maps

slices和maps包含了指向底层数据的指针,因此在需要复制它们时要特别注意。

接收
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = make([]Trip, len(trips))
  copy(d.trips, trips)
}

trips := ...
d1.SetTrips(trips)

// 这里我们修改 trips[0],但不会影响到 d1.trips
trips[0] = ...
返回
type Stats struct {
  mu sync.Mutex

  counters map[string]int
}

func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  result := make(map[string]int, len(s.counters))
  for k, v := range s.counters {
    result[k] = v
  }
  return result
}

// snapshot 现在是一个拷贝
snapshot := stats.Snapshot()

使用defer释放资源

p.Lock()
defer p.Unlock()

channel的size要么为1,要么无缓冲

// 大小:1
c := make(chan int, 1) // 或者
// 无缓冲 channel,大小为 0
c := make(chan int)

枚举从1开始

在Go中引入枚举的标准方法是声明一个自定义类型和一个使用了iota的const组。 由于变量的默认值为0,因此通常应以非零值开头枚举。

type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

// Add=1, Subtract=2, Multiply=3

time

  • 在处理时间时始终使用"time"包。
  • 使用time.Time表示时刻,在比较、添加等操作时使用"time"包中的方法。
  • 使用time.Duration表示时间段。
    newDay := t.AddDate(0 /* years */, 0 /* months */, 1 /* days */)
    maybeNewDay := t.Add(24 * time.Hour)
    
  • 对外部系统使用time.Timetime.Duration

Errors

错误类型
  • 如果调用者是否需要匹配错误以便他们可以处理它,那么必须通过声明顶级错误变量或自定义类型来支持errors.Iserrors.As函数。
  • 如果错误消息是静态字符串,我们可以使用errors.New,如果是动态字符串,我们必须使用fmt.Errorf或自定义错误类型。

使用errors.New表示带有静态字符串的错误。如果调用者需要匹配并处理此错误,则将此错误导出为变量以支持将其与errors.Is匹配。

// package foo

var ErrCouldNotOpen = errors.New("could not open")

func Open() error {
  return ErrCouldNotOpen
}

// package bar

if err := foo.Open(); err != nil {
  if errors.Is(err, foo.ErrCouldNotOpen) {
    // handle the error
  } else {
    panic("unknown error")
  }
}

对于动态字符串的错误,如果调用者不需要匹配它,则使用fmt.Errorf,如果调用者确实需要匹配它,则自定义error

// package foo

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("file %q not found", e.File)
}

func Open(file string) error {
  return &NotFoundError{File: file}
}


// package bar

if err := foo.Open("testfile.txt"); err != nil {
  var notFound *NotFoundError
  if errors.As(err, &notFound) {
    // handle the error
  } else {
    panic("unknown error")
  }
}
错误包装

如果调用其他方法时出现错误, 通常有三种处理方式可以选择:

  • 将原始错误原样返回
  • 使用fmt.Errorf搭配%w将错误添加进上下文后返回
  • 使用fmt.Errorf搭配%v将错误添加进上下文后返回

根据调用者是否应该能够匹配和提取根本原因,在%w%v动词之间进行选择。

  • 如果调用者应该可以访问底层错误,请使用%w。对于大多数包装错误,这是一个很好的默认值,但请注意,调用者可能会开始依赖此行为。因此,对于包装错误是已知var或类型的情况,请将其作为函数契约的一部分进行记录和测试。
  • 使用%v来混淆底层错误。调用者将无法匹配它,但如果需要,您可以在将来切换到%w
错误命名
  • 对于存储为全局变量的错误值,根据是否导出,使用前缀Errerr
  • 对于自定义错误类型,请改用后缀Error
    // 同样,这个错误被导出,以便这个包的用户可以将它与 errors.As 匹配。
    
    type NotFoundError struct {
      File string
    }
    
    func (e *NotFoundError) Error() string {
      return fmt.Sprintf("file %q not found", e.File)
    }
    
    // 并且这个错误没有被导出,因为我们不想让它成为公共 API 的一部分。 我们仍然可以在带有 errors.As 的包中使用它。
    type resolveError struct {
      Path string
    }
    
    func (e *resolveError) Error() string {
      return fmt.Sprintf("resolve %q", e.Path)
    }
    
处理错误
  • 如果被调用者约定定义了特定的错误,则将错误与errors.Iserrors.As匹配,并以不同的方式处理分支。
  • 如果错误是可恢复的,则记录错误并正常降级。
  • 如果该错误表示特定于域的故障条件,则返回定义明确的错误。
  • 返回错误,无论是包装错误还是逐字逐句。

无论调用方如何处理错误,它通常都应该只处理每个错误一次

// 将错误换行并返回。堆栈中更靠上的调用程序将处理该错误。使用%w可确保它们可以将错误与errors.Is或errors.As相匹配 (如果相关)。
u, err := getUser(id)
if err != nil {
  return fmt.Errorf("get user %q: %w", id, err)
}
// 记录错误并正常降级。如果操作不是绝对必要的,我们可以通过从中恢复来提供降级但不间断的体验。
if err := emitMetrics(); err != nil {
  // Failure to write metrics should not
  // break the application.
  log.Printf("Could not emit metrics: %v", err)
}
// 匹配错误并适当降级、如果被调用者在其约定中定义了一个特定的错误,并且失败是可恢复的,则匹配该错误案例并正常降级。对于所有其他案例,请包装错误并返回。
// 堆栈中更靠上的调用程序将处理其他错误。
tz, err := getUserTimeZone(id)
if err != nil {
  if errors.Is(err, ErrUserNotFound) {
    // User doesn't exist. Use UTC.
    tz = time.UTC
  } else {
    return fmt.Errorf("get user %q: %w", id, err)
  }
}

处理断言失败

t, ok := i.(string)
if !ok {
  // 优雅地处理错误
}

不要使用panic

在生产环境中运行的代码必须避免出现panic。如果发生错误,该函数必须返回错误,并允许调用方决定如何处理它。

使用go.uber.org/atomic

type foo struct {
  running atomic.Bool
}

func (f *foo) start() {
  if f.running.Swap(true) {
     // already running…
     return
  }
  // start the Foo
}

func (f *foo) isRunning() bool {
  return f.running.Load()
}

避免可变全局变量

使用选择依赖注入方式避免改变全局变量。既适用于函数指针又适用于其他值类型。

Bad

// sign.go
var _timeNow = time.Now
func sign(msg string) string {
  now := _timeNow()
  return signWithTime(msg, now)
}

Good

// sign.go
type signer struct {
  now func() time.Time
}
func newSigner() *signer {
  return &signer{
    now: time.Now,
  }
}
func (s *signer) Sign(msg string) string {
  now := s.now()
  return signWithTime(msg, now)
}

避免在公共结构中嵌入类型

Bad

// ConcreteList 是一个实体列表。
type ConcreteList struct {
  *AbstractList
}

Good

// ConcreteList 是一个实体列表。
type ConcreteList struct {
  list *AbstractList
}
// 添加将实体添加到列表中。
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// 移除从列表中移除实体。
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

Bad

// AbstractList 是各种实体列表的通用实现。
type AbstractList interface {
  Add(Entity)
  Remove(Entity)
}
// ConcreteList 是一个实体列表。
type ConcreteList struct {
  AbstractList
}

Good

// ConcreteList 是一个实体列表。
type ConcreteList struct {
  list AbstractList
}
// 添加将实体添加到列表中。
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// 移除从列表中移除实体。
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

避免使用内置名称

Types:
	any bool byte comparable
	complex64 complex128 error float32 float64
	int int8 int16 int32 int64 rune string
	uint uint8 uint16 uint32 uint64 uintptr

Constants:
	true false iota

Zero value:
	nil

Functions:
	append cap close complex copy delete imag len
	make new panic print println real recover

避免使用init()

虽然init()顺序是明确的,但代码可以更改, 因此init()函数之间的关系可能会使代码变得脆弱和容易出错。

追加时优先指定切片容量

Bad

for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}

Good

for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}

主函数退出方式(Exit)

Go程序使用os.Exit或者log.Fatal*立即退出(使用panic不是退出程序的好方法)。

仅在main()中调用其中一个os.Exit或者log.Fatal*。所有其他函数应将错误返回到信号失败中。

func main() {
  body, err := readFile(path)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println(body)
}
func readFile(path string) (string, error) {
  f, err := os.Open(path)
  if err != nil {
    return "", err
  }
  b, err := os.ReadAll(f)
  if err != nil {
    return "", err
  }
  return string(b), nil
}
一次性退出

如果可能的话,你的main()函数中最多一次调用os.Exit或者log.Fatal*。如果有多个错误场景停止程序执行,请将该逻辑放在单独的函数下并从中返回错误。

package main
func main() {
  if err := run(); err != nil {
    log.Fatal(err)
  }
}
func run() error {
  args := os.Args[1:]
  if len(args) != 1 {
    return errors.New("missing file")
  }
  name := args[0]
  f, err := os.Open(name)
  if err != nil {
    return err
  }
  defer f.Close()
  b, err := os.ReadAll(f)
  if err != nil {
    return err
  }
  // ...
}

在序列化结构中使用字段标记

type Stock struct {
  Price int    `json:"price"`
  Name  string `json:"name"`
  // Safe to rename Name to Symbol.
}
bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

不要一劳永逸地使用goroutine

不要在代码中泄漏goroutine。使用go.uber.org/goleak来测试可能产生goroutine的包内的goroutine泄漏。

一般来说,每个 goroutine:

  • 必须有一个可预测的停止运行时间;或者
  • 必须有一种方法可以向goroutine发出信号它应该停止。

在这两种情况下,都必须有一种方式代码来阻塞并等待goroutine完成。

Bad

go func() {
  for {
    flush()
    time.Sleep(delay)
  }
}()

Good

var (
  stop = make(chan struct{}) // 告诉 goroutine 停止
  done = make(chan struct{}) // 告诉我们 goroutine 退出了
)
go func() {
  defer close(done)
  ticker := time.NewTicker(delay)
  defer ticker.Stop()
  for {
    select {
    case <-tick.C:
      flush()
    case <-stop:
      return
    }
  }
}()
// 其它...
close(stop)  // 指示 goroutine 停止
<-done       // and wait for it to exit
等待goroutines退出

给定一个由系统生成的goroutine,必须有一种方案能等待goroutine的退出。有两种常用的方法可以做到这一点:

  • 使用sync.WaitGroup。如果您要等待多个goroutine,请执行此操作
    var wg sync.WaitGroup
    for i := 0; i < N; i++ {
      wg.Add(1)
      go func() {
        defer wg.Done()
        // ...
      }()
    }
    
    // To wait for all to finish:
    wg.Wait()
    
  • 添加另一个chan struct{},goroutine完成后会关闭它。如果只有一个goroutine,请执行此操作。
    done := make(chan struct{})
    go func() {
      defer close(done)
      // ...
    }()
    
    // To wait for the goroutine to finish:
    <-done
    
不要在init()使用goroutines

init()函数不应该产生goroutines。

type Worker struct{ /* ... */ }
func NewWorker(...) *Worker {
  w := &Worker{
    stop: make(chan struct{}),
    done: make(chan struct{}),
    // ...
  }
  go w.doWork()
}
func (w *Worker) doWork() {
  defer close(w.done)
  for {
    // ...
    case <-w.stop:
      return
  }
}
// Shutdown 告诉 worker 停止
// 并等待它完成。
func (w *Worker) Shutdown() {
  close(w.stop)
  <-w.done
}

性能

优先使用strconv而不是fmt

将原语转换为字符串或从字符串转换时,strconv速度比fmt快。

避免字符串到字节的转换

不要反复从固定字符串创建字节slice。相反,请执行一次转换并捕获结果。

Bad

for i := 0; i < b.N; i++ {
  w.Write([]byte("Hello world"))
}

Good

data := []byte("Hello world")
for i := 0; i < b.N; i++ {
  w.Write(data)
}

指定容器容量

指定map容量提示

在尽可能的情况下,在使用make()初始化的时候提供容量信息。

make(map[T1]T2, hint)

注意,与slices不同。map容量提示并不保证完全的、预先的分配,而是用于估计所需的hashmap bucket的数量。 因此,在将元素添加到map时,甚至在指定map容量时,仍可能发生分配。

指定切片容量

在尽可能的情况下,在使用make()初始化切片时提供容量信息,特别是在追加切片时。

make([]T, length, capacity)

与maps不同,slice capacity不是一个提示:编译器将为提供给make()的slice的容量分配足够的内存,这意味着后续的append()操作将导致零分配(直到slice的长度与容量匹配,在此之后,任何append都可能调整大小以容纳其他元素)。

for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}

规范

避免过长的行

建议将行长度限制为99 characters。

一致性

保持一致

相似的声明放在一组

import (
  "a"
  "b"
)
const (
  a = 1
  b = 2
)

var (
  a = 1
  b = 2
)

type (
  Area float64
  Volume float64
)
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

const EnvVar = "MY_ENV"
func f() string {
  var (
    red   = color.New(0xff0000)
    green = color.New(0x00ff00)
    blue  = color.New(0x0000ff)
  )

  ...
}

如果变量声明与其他变量相邻,则应将变量声明(尤其是函数内部的声明)分组在一起。对一起声明的变量执行此操作,即使它们不相关。

Bad

func (c *client) request() {
  caller := c.name
  format := "json"
  timeout := 5*time.Second
  var err error
  // ...
}

Good

func (c *client) request() {
  var (
    caller  = c.name
    format  = "json"
    timeout = 5*time.Second
    err error
  )
  // ...
}

import分组

导入应该分为两组:

  • 标准库
  • 其他库
import (
  "fmt"
  "os"

  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)

包名

  • 全部小写。没有大写或下划线。
  • 大多数使用命名导入的情况下,不需要重命名。
  • 简短而简洁。请记住,在每个使用的地方都完整标识了该名称。
  • 不用复数。例如net/url,而不是net/urls
  • 不要用"common”,“util”,“shared”或"lib”。这些是不好的,信息量不足的名称。

https://blog.golang.org/package-names

https://rakyll.org/style-packages/

函数名

https://golang.org/doc/effective_go.html#mixed-caps

有一个例外,为了对相关的测试用例进行分组,函数名可能包含下划线,如:TestMyFunction_WhatIsBeingTested

导入别名

  • 如果程序包名称与导入路径的最后一个元素不匹配,则必须使用导入别名。
  • 在所有其他情况下,除非导入之间有直接冲突,否则应避免导入别名。

函数分组与顺序

  • 函数应按粗略的调用顺序排序。
  • 同一文件中的函数应按接收者分组。
type something struct{ ... }

func newSomething() *something {
    return &something{}
}

func (s *something) Cost() {
  return calcCost(s.weights)
}

func (s *something) Stop() {...}

func calcCost(n []int) int {...}

减少嵌套

代码应通过尽可能先处理错误情况/特殊情况并尽早返回或继续循环来减少嵌套。

Bad

for _, v := range data {
  if v.F1 == 1 {
    v = process(v)
    if err := v.Call(); err == nil {
      v.Send()
    } else {
      return err
    }
  } else {
    log.Printf("Invalid v: %v", v)
  }
}

Good

for _, v := range data {
  if v.F1 != 1 {
    log.Printf("Invalid v: %v", v)
    continue
  }

  v = process(v)
  if err := v.Call(); err != nil {
    return err
  }
  v.Send()
}

不必要的else

尽量不使用else

顶层变量声明

在顶层,使用标准var关键字。请勿指定类型,除非它与表达式的类型不同。

Bad

var _s string = F()

func F() string { return "A" }

Good

var _s = F()
// 由于 F 已经明确了返回一个字符串类型,因此我们没有必要显式指定_s 的类型
// 还是那种类型

func F() string { return "A" }

对于未导出的顶层常量和变量,使用_作为前缀

在未导出的顶级varsconsts, 前面加上前缀_,以使它们在使用时明确表示它们是全局符号。

// foo.go

const (
  _defaultPort = 8080
  _defaultUser = "user"
)

例外:未导出的错误值可以使用不带下划线的前缀err

结构体中的嵌入

嵌入式类型应位于结构体内的字段列表的顶部,并且必须有一个空行将嵌入式字段与常规字段分隔开。

type Client struct {
  http.Client

  version int
}

本地变量声明

如果将变量明确设置为某个值,则应使用短变量声明形式(:=)。

但是,在某些情况下,var 使用关键字时默认值会更清晰。例如,声明空切片

nil是一个有效的slice

nil是一个有效的长度为0的slice,这意味着:

  • 不应明确返回长度为零的切片。应该返回nil来代替。
  • 要检查切片是否为空,请始终使用len(s) == 0。而非nil
  • 零值切片(用var声明的切片)可立即使用,无需调用make()创建。

缩小变量作用域

如果有可能,尽量缩小变量作用范围。如果需要在if之外使用函数调用的结果,则不应尝试缩小范围。

避免参数语义不明确

// func printInfo(name string, isLocal, done bool)

printInfo("foo", true /* isLocal */, true /* done */)

对于上面的示例代码,还有一种更好的处理方式是将上面的bool类型换成自定义类型。将来,该参数可以支持不仅仅局限于两个状态(true/false)。

type Region int

const (
  UnknownRegion Region = iota
  Local
)

type Status int

const (
  StatusReady Status= iota + 1
  StatusDone
  // Maybe we will have a StatusInProgress in the future.
)

func printInfo(name string, region Region, status Status)

使用原始字符串字面值,避免转义

wantError := `unknown error:"test"`

初始化结构体

使用字段名初始化结构

初始化结构时,几乎应该始终指定字段名。

k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}
省略结构中的零值字段

初始化具有字段名的结构时,除非提供有意义的上下文,否则忽略值为零的字段。 也就是,让我们自动将这些设置为零值。

user := User{
  FirstName: "John",
  LastName: "Doe",
}
对零值结构使用var

如果在声明中省略了结构的所有字段,请使用var声明结构。

var user User
初始化struct引用

在初始化结构引用时,请使用&T{}代替new(T),以使其与结构体初始化一致。

sval := T{Name: "foo"}

sptr := &T{Name: "bar"}

初始化maps

对于空map请使用make(..)初始化, 并且map是通过编程方式填充的。

var (
  // m1 读写安全;
  // m2 在写入时会 panic
  m1 = make(map[T1]T2)
  m2 map[T1]T2
)

在尽可能的情况下,请在初始化时提供map容量大小。

另外,如果map包含固定的元素列表,则使用map literals(map初始化列表)初始化映射。

m := map[T1]T2{
  k1: v1,
  k2: v2,
  k3: v3,
}

string format const

const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)

编程模式

Table-driven tests

// func TestSplitHostPort(t *testing.T)

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  {
    give:     "192.0.2.0:8000",
    wantHost: "192.0.2.0",
    wantPort: "8000",
  },
  {
    give:     "192.0.2.0:http",
    wantHost: "192.0.2.0",
    wantPort: "http",
  },
  {
    give:     ":8000",
    wantHost: "",
    wantPort: "8000",
  },
  {
    give:     "1:8",
    wantHost: "1",
    wantPort: "8",
  },
}

for _, tt := range tests {
  t.Run(tt.give, func(t *testing.T) {
    host, port, err := net.SplitHostPort(tt.give)
    require.NoError(t, err)
    assert.Equal(t, tt.wantHost, host)
    assert.Equal(t, tt.wantPort, port)
  })
}

我们遵循这样的约定:将结构体切片称为tests。每个测试用例称为tt。此外,我们鼓励使用give和want前缀说明每个测试用例的输入和输出值。

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  // ...
}

for _, tt := range tests {
  // ...
}

Linting

Lint Runners

推荐golangci-lint作为go-to lint的运行程序,这主要是因为它在较大的代码库中的性能以及能够同时配置和使用许多规范。这个repo有一个示例配置文件.golangci.yml和推荐的linter设置。