/uber-go-guide-vi

Bản dịch hướng dẫn phong cách Uber bằng Go. Kho lưu trữ gốc: https://github.com/uber-go/guide

MIT LicenseMIT

Hướng dẫn code go theo phong cách Uber

Introduction

Kiểu là các quy ước chi phối mã của chúng tôi. Thuật ngữ kiểu hơi bị dùng sai, vì những quy ước này không chỉ bao gồm việc định dạng tệp nguồn—gofmt xử lý việc đó cho chúng ta.

Mục tiêu của hướng dẫn này là quản lý sự phức tạp bằng cách mô tả chi tiết những điều nên làm và không nên làm khi viết mã Go tại Uber. Những quy tắc này tồn tại để giữ cho cơ sở mã nguồn dễ quản lý trong khi vẫn cho phép các kỹ sư sử dụng các tính năng của ngôn ngữ Go một cách hiệu quả.

Hướng dẫn này ban đầu được tạo bởi Prashant VaranasiSimon Newton như một cách để giúp một số đồng nghiệp nắm bắt nhanh với việc sử dụng Go. Qua các năm, nó đã được chỉnh sửa dựa trên phản hồi từ những người khác.

Tài liệu này ghi lại các quy ước idiomatic trong mã Go mà chúng tôi tuân theo tại Uber. Nhiều trong số này là hướng dẫn chung cho Go, trong khi những quy tắc khác mở rộng dựa trên các nguồn tài liệu bên ngoài:

  1. Effective Go
  2. Go Common Mistakes
  3. Go Code Review Comments

Chúng tôi nhắm đến việc các mẫu mã đều chính xác cho hai phiên bản nhỏ gần đây nhất của các bản phát hành Go. releases.

Tất cả mã phải không có lỗi khi chạy qua golintgo vet. Chúng tôi khuyến nghị thiết lập trình soạn thảo của bạn để:

  • Chạy goimports khi lưu
  • Chạy golintgo vet để kiểm tra lỗi

Bạn có thể tìm thấy thông tin về hỗ trợ trình soạn thảo cho các công cụ Go tại đây: https://go.dev/wiki/IDEsAndTextEditorPlugins

Guidelines

Pointers to Interfaces

Bạn gần như không bao giờ cần một pointer tới một interface. Bạn nên truyền interfaces như là values—dữ liệu cơ bản vẫn có thể là một pointer.

Một interface bao gồm hai trường:

  1. Một pointer đến một thông tin cụ thể về kiểu. Bạn có thể nghĩ về điều này như là "type."
  2. Data pointer. Nếu dữ liệu được lưu trữ là một pointer, nó được lưu trữ trực tiếp. Nếu dữ liệu được lưu trữ là một value, thì một pointer tới value đó được lưu trữ.

Nếu bạn muốn các phương thức của interface thay đổi dữ liệu cơ bản, bạn phải sử dụng một pointer.

Verify Interface Compliance

Xác minh tuân thủ interface tại thời điểm biên dịch khi thích hợp. Điều này bao gồm:

  • Các loại được xuất (exported types) yêu cầu thực hiện các interfaces cụ thể như một phần của hợp đồng API của chúng
  • Các loại được xuất hoặc không được xuất (exported or unexported types) là một phần của một tập hợp các loại thực hiện cùng một interface
  • Các trường hợp khác mà việc vi phạm một interface sẽ gây hại cho người dùng
Không nênNên
type Handler struct {
  // ...
}



func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  ...
}
type Handler struct {
  // ...
}

var _ http.Handler = (*Handler)(nil)

func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

Câu lệnh var _ http.Handler = (*Handler)(nil) sẽ không thể biên dịch nếu *Handler không còn phù hợp với interface http.Handler.

Phía bên phải của phép gán nên là giá trị zero của kiểu được khẳng định. Điều này là nil đối với các kiểu pointer (như *Handler), slices và maps, và một struct rỗng đối với các kiểu struct.

type LogHandler struct {
  h   http.Handler
  log *zap.Logger
}

var _ http.Handler = LogHandler{}

func (h LogHandler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

Receivers and Interfaces

Các phương thức với value receivers có thể được gọi trên cả pointers lẫn values. Các phương thức với pointer receivers chỉ có thể được gọi trên pointers hoặc addressable values.

Ví dụ,

type S struct {
  data string
}

func (s S) Read() string {
  return s.data
}

func (s *S) Write(str string) {
  s.data = str
}

// Chúng ta không thể lấy pointers đến các giá trị được lưu trữ trong maps, vì chúng không phải là các addressable values.
sVals := map[int]S{1: {"A"}}

// Chúng ta có thể gọi Read trên các giá trị được lưu trữ trong map vì Read
// có một value receiver, không yêu cầu giá trị phải là addressable.
sVals[1].Read()

// Chúng ta không thể gọi Write trên các giá trị được lưu trữ trong map vì Write
// có một pointer receiver, và không thể lấy một pointer
// đến một giá trị được lưu trữ trong map.
//
//  sVals[1].Write("test")

sPtrs := map[int]*S{1: {"A"}}

// Bạn có thể gọi cả Read và Write nếu map lưu trữ pointers,
// vì pointers bản chất là addressable.
sPtrs[1].Read()
sPtrs[1].Write("test")

Tương tự, một interface có thể được đáp ứng bởi một pointer, ngay cả khi phương thức đó có một value receiver.

type F interface {
  f()
}

type S1 struct{}

func (s S1) f() {}

type S2 struct{}

func (s *S2) f() {}

s1Val := S1{}
s1Ptr := &S1{}
s2Val := S2{}
s2Ptr := &S2{}

var i F
i = s1Val
i = s1Ptr
i = s2Ptr

// Đoạn mã sau không thể biên dịch, vì s2Val là một value, và không có value receiver cho f.
//   i = s2Val

"Effective Go" có một bài viết tốt về Pointers vs. Values.

Zero-value Mutexes are Valid

Giá trị zero của sync.Mutexsync.RWMutex là hợp lệ, vì vậy bạn gần như không bao giờ cần một con trỏ đến một mutex.

Không nênNên
mu := new(sync.Mutex)
mu.Lock()
var mu sync.Mutex
mu.Lock()

If you use a struct by pointer, then the mutex should be a non-pointer field on it. Do not embed the mutex on the struct, even if the struct is not exported.

BadGood
type SMap struct {
  sync.Mutex

  data map[string]string
}

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

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

  return m.data[k]
}
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]
}

Trường Mutex, và các phương thức LockUnlock đều không cố ý là một phần của API được xuất của SMap.

Mutex và các phương thức của nó là các chi tiết cài đặt của SMap được ẩn đi khỏi những người gọi của nó.

Copy Slices and Maps at Boundaries

Slices và maps chứa các con trỏ đến dữ liệu cơ bản, vì vậy hãy cẩn thận trong các tình huống khi cần phải sao chép chúng.

Receiving Slices and Maps

Hãy nhớ rằng người dùng có thể sửa đổi một map hoặc slice mà bạn nhận làm đối số nếu bạn lưu trữ một tham chiếu đến nó.

Không nên Nên
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = trips
}

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

// Bạn có ý muốn sửa đổi d1.trips chứ?
trips[0] = ...
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = make([]Trip, len(trips))
  copy(d.trips, trips)
}

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

// Bây giờ chúng ta có thể sửa đổi trips[0] mà không ảnh hưởng đến d1.trips.
trips[0] = ...

Returning Slices and Maps

Tương tự, hãy cẩn thận với việc người dùng sửa đổi maps hoặc slices, tiết lộ trạng thái nội bộ.

Không nênNên
type Stats struct {
  mu sync.Mutex
  counters map[string]int
}

// Hàm Snapshot trả về các thống kê hiện tại.
func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  return s.counters
}

// snapshot không còn được bảo vệ bởi mutex nữa, vì vậy bất kỳ
// truy cập nào vào snapshot đều có thể gặp phải các cuộc đua dữ liệu.
snapshot := stats.Snapshot()
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
}

// Bản sao của Snapshot bây giờ.
snapshot := stats.Snapshot()

Defer to Clean Up

Sử dụng defer để dọn dẹp tài nguyên như tệp và khóa.

Không nênNên
p.Lock()
if p.count < 10 {
  p.Unlock()
  return p.count
}

p.count++
newCount := p.count
p.Unlock()

return newCount

// dễ bỏ sót việc mở khóa do có nhiều câu lệnh return
p.Lock()
defer p.Unlock()

if p.count < 10 {
  return p.count
}

p.count++
return p.count

// more readable

Defer có một chi phí rất nhỏ và chỉ nên được tránh nếu bạn có thể chứng minh rằng thời gian thực thi của hàm của bạn có thứ tự trong các nanogiây. Sự dễ đọc khi sử dụng defer đáng giá với chi phí nhỏ nhất của việc sử dụng chúng. Điều này đặc biệt đúng đối với các phương thức lớn có nhiều hơn là các truy cập bộ nhớ đơn giản, nơi các tính toán khác quan trọng hơn là defer.

Channel Size is One or None

Các channels thường nên có kích thước là một hoặc không được đệm. Theo mặc định, các channels không được đệm và có kích thước là không. Bất kỳ kích thước khác phải chịu sự kiểm tra mức độ cao. Xem xét cách kích thước được xác định, những gì ngăn kênh khỏi bị đầy dưới tải và chặn việc ghi, và điều gì xảy ra khi điều này xảy ra.

Không nênNên
// Có lẽ đủ cho mọi người!
c := make(chan int, 64)
// Kích thước là một
c := make(chan int, 1) // hoặc
// Unbuffered channel, kích thước là không
c := make(chan int)

Start Enums at One

Cách tiêu chuẩn để giới thiệu các enumerations trong Go là khai báo một kiểu tùy chỉnh và một nhóm const với iota. Vì biến có giá trị mặc định là 0, bạn thường nên bắt đầu enum của mình từ một giá trị không phải là 0.

Không nênNên
type Operation int

const (
  Add Operation = iota
  Subtract
  Multiply
)

// Add=0, Subtract=1, Multiply=2
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

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

Có những trường hợp mà việc sử dụng giá trị zero là hợp lý, ví dụ khi trường hợp giá trị zero là hành vi mặc định mong muốn.

type LogOutput int

const (
  LogToStdout LogOutput = iota
  LogToFile
  LogToRemote
)

// LogToStdout=0, LogToFile=1, LogToRemote=2

Use "time" to handle time

Thời gian là một vấn đề phức tạp. Những giả định sai lầm thường gặp về thời gian bao gồm những điều sau đây.

  1. Một ngày có 24 giờ
  2. Một giờ có 60 phút
  3. Một tuần có 7 ngày
  4. Một năm có 365 ngày
  5. Và rất nhiều điều khác

Ví dụ, 1 có nghĩa là thêm 24 giờ vào một thời điểm cụ thể không luôn luôn tạo ra một ngày mới trên lịch.

Do đó, luôn luôn sử dụng "time" package khi làm việc với thời gian vì nó giúp xử lý những giả định sai lầm này một cách an toàn và chính xác hơn.

Use time.Time for instants of time

Hãy sử dụng time.Time khi làm việc với các thời điểm cụ thể, và các phương thức trên time.Time khi so sánh, thêm hoặc trừ thời gian.

Không nênNên
func isActive(now, start, stop int) bool {
  return start <= now && now < stop
}
func isActive(now, start, stop time.Time) bool {
  return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}

Use time.Duration for periods of time

Sử dụng time.Duration khi làm việc với các khoảng thời gian.

Không nênNên
func poll(delay int) {
  for {
    // ...
    time.Sleep(time.Duration(delay) * time.Millisecond)
  }
}

poll(10) // liệu đó có phải là giây hay mili-giây không?
func poll(delay time.Duration) {
  for {
    // ...
    time.Sleep(delay)
  }
}

poll(10*time.Second)

Quay trở lại ví dụ về việc thêm 24 giờ vào một thời điểm cụ thể, phương thức chúng ta sử dụng để thêm thời gian phụ thuộc vào ý định. Nếu chúng ta muốn cùng một thời gian trong một ngày tiếp theo trên lịch, chúng ta nên sử dụng Time.AddDate. Tuy nhiên, nếu chúng ta muốn một thời điểm chắc chắn là 24 giờ sau thời điểm trước đó, chúng ta nên sử dụng Time.Add.

newDay := t.AddDate(0 /* years */, 0 /* months */, 1 /* days */)
maybeNewDay := t.Add(24 * time.Hour)

Use time.Time and time.Duration with external systems

Trong tương tác với các hệ thống bên ngoài, hãy sử dụng time.Durationtime.Time khi có thể. Ví dụ:

Khi không thể sử dụng time.Duration trong các tương tác này, hãy sử dụng int hoặc float64 và bao gồm đơn vị trong tên của trường.

Ví dụ, vì encoding/json không hỗ trợ time.Duration, đơn vị được bao gồm trong tên của trường.

Không nênNên
// {"interval": 2}
type Config struct {
  Interval int `json:"interval"`
}
// {"intervalMillis": 2000}
type Config struct {
  IntervalMillis int `json:"intervalMillis"`
}

Khi không thể sử dụng time.Time trong các tương tác này, trừ khi đã thống nhất về một phương án thay thế, hãy sử dụng string và định dạng thời gian dưới dạng được xác định trong RFC 3339. Định dạng này được mặc định sử dụng bởi Time.UnmarshalText và có sẵn để sử dụng trong Time.Formattime.Parse thông qua time.RFC3339.

Mặc dù điều này thường không phải là một vấn đề trong thực tế, nhưng hãy nhớ rằng gói "time" không hỗ trợ phân tích cú pháp các dấu thời gian có giây nhảy (8728), cũng như không tính toán cho các giây nhảy trong các phép tính (15190). Nếu bạn so sánh hai thời điểm cụ thể, sự khác biệt sẽ không bao gồm các giây nhảy có thể đã xảy ra giữa hai thời điểm đó.

Errors

Error Types

Có một số lựa chọn để khai báo lỗi. Hãy xem xét các điều sau trước khi chọn lựa chọn phù hợp nhất cho trường hợp sử dụng của bạn.

  • Người gọi cần phải khớp với lỗi để họ có thể xử lý nó không? Nếu có, chúng ta phải hỗ trợ các hàm errors.Is hoặc errors.As bằng cách khai báo một biến lỗi ở cấp độ cao nhất hoặc một kiểu tùy chỉnh.
  • Thông báo lỗi có phải là một chuỗi tĩnh không, hay nó là một chuỗi động cần thông tin ngữ cảnh không? Đối với trường hợp đầu tiên, chúng ta có thể sử dụng errors.New, nhưng đối với trường hợp thứ hai chúng ta phải sử dụng fmt.Errorf hoặc một kiểu lỗi tùy chỉnh.
  • Chúng ta có đang truyền lỗi mới được trả về bởi một hàm con không? Nếu có, xem section on error wrapping.
Error matching? Error Message Guidance
No static errors.New
No dynamic fmt.Errorf
Yes static top-level var with errors.New
Yes dynamic custom error type

Ví dụ, Sử dụng errors.New cho một lỗi có một chuỗi tĩnh. Xuất lỗi này như một biến để hỗ trợ khớp nó với errors.Is nếu người gọi cần khớp và xử lý lỗi này.

No error matchingError matching
// package foo

func Open() error {
  return errors.New("could not open")
}

// package bar

if err := foo.Open(); err != nil {
  // Can't handle the error.
  panic("unknown error")
}
// 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")
  }
}

Đối với một lỗi có một chuỗi động, sử dụng fmt.Errorf nếu người gọi không cần khớp với nó, và một error tùy chỉnh nếu người gọi cần phải khớp với nó.

No error matchingError matching
// package foo

func Open(file string) error {
  return fmt.Errorf("file %q not found", file)
}

// package bar

if err := foo.Open("testfile.txt"); err != nil {
  // Can't handle the error.
  panic("unknown 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")
  }
}

Lưu ý rằng nếu bạn xuất các biến lỗi hoặc kiểu từ một gói, chúng sẽ trở thành một phần của API công khai của gói.

Error Wrapping

Có ba lựa chọn chính để truyền tiếp các lỗi nếu một cuộc gọi thất bại:

  • Trả về lỗi gốc nguyên vẹn
  • Thêm ngữ cảnh với fmt.Errorf và động từ %w
  • Thêm ngữ cảnh với fmt.Errorf và động từ %v

Trả về lỗi gốc nguyên vẹn nếu không có ngữ cảnh bổ sung nào cần thêm. Điều này giữ nguyên kiểu và thông báo lỗi ban đầu. Điều này phù hợp cho các trường hợp khi thông báo lỗi gốc đã cung cấp đủ thông tin để theo dõi nó đến từ đâu.

Nếu không, thêm ngữ cảnh vào thông báo lỗi nơi có thể để thay vì một lỗi mơ hồ như "kết nối từ chối", bạn nhận được các thông báo lỗi hữu ích hơn như "gọi dịch vụ foo: kết nối từ chối".

Sử dụng fmt.Errorf để thêm ngữ cảnh vào các lỗi của bạn, chọn giữa các từ khóa %w hoặc %v dựa trên việc người gọi có nên có thể khớp và trích xuất nguyên nhân cơ bản.

  • Sử dụng %w nếu người gọi cần có quyền truy cập vào lỗi cơ bản. Đây là một lựa chọn mặc định tốt cho hầu hết các lỗi đã được bọc, nhưng hãy nhớ rằng người gọi có thể bắt đầu phụ thuộc vào hành vi này. Vì vậy đối với các trường hợp mà lỗi được bọc là một var hoặc kiểu đã biết, hãy tài liệu hóa và kiểm thử nó như một phần của hợp đồng hàm của bạn.
  • Sử dụng %v để ẩn lỗi cơ bản. Người gọi sẽ không thể khớp được nó, nhưng bạn có thể chuyển sang %w trong tương lai nếu cần.

Khi thêm ngữ cảnh vào các lỗi trả về, hãy giữ ngữ cảnh ngắn gọn bằng cách tránh các cụm từ như "thất bại" (failed to), điều này nêu ra điều đã rõ và chồng chất khi lỗi truyền lên qua các tầng của ngăn xếp:

Không nênNên
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "failed to create new store: %w", err)
}
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "new store: %w", err)
}
failed to x: failed to y: failed to create new store: the error
x: y: new store: the error

Tuy nhiên, khi lỗi được gửi đến một hệ thống khác, nó nên rõ ràng rằng thông điệp là một lỗi (ví dụ như một thẻ err hoặc tiền tố "Failed" trong các nhật ký).

Xem thêm Đừng chỉ kiểm tra lỗi, xử lý chúng một cách dễ dàng..

Error Naming

Đối với các giá trị lỗi được lưu trữ như biến toàn cục, sử dụng tiền tố Err hoặc err tùy thuộc vào việc chúng có được xuất khẩu hay không. Hướng dẫn này mạnh mẽ hơn Tiền tố biến toàn cục chưa được xuất khẩu với _.

var (
  // Hai lỗi sau đây được xuất khẩu
  // để người dùng của gói này có thể khớp chúng
  // với errors.Is.

  ErrBrokenLink = errors.New("link is broken")
  ErrCouldNotOpen = errors.New("could not open")

  // Lỗi này không được xuất khẩu vì
  // chúng tôi không muốn làm cho nó trở thành một phần của API công khai của chúng tôi.
  // Tuy nhiên, chúng tôi vẫn có thể sử dụng nó bên trong gói
  // với errors.Is.

  errNotFound = errors.New("not found")
)

For custom error types, use the suffix Error instead.

// Tương tự, lỗi này được xuất khẩu
// để người dùng của gói này có thể khớp với nó
// với errors.As.

type NotFoundError struct {
  File string
}

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

// Và lỗi này không được xuất khẩu vì
// chúng tôi không muốn làm cho nó trở thành một phần của API công khai.
// Tuy nhiên, chúng tôi vẫn có thể sử dụng nó bên trong gói
// với errors.As.

type resolveError struct {
  Path string
}

func (e *resolveError) Error() string {
  return fmt.Sprintf("resolve %q", e.Path)
}

Handle Errors Once

Khi người gọi nhận được lỗi từ hàm được gọi, nó có thể xử lý lỗi theo nhiều cách khác nhau tùy thuộc vào những gì nó biết về lỗi.

Các cách này bao gồm, nhưng không giới hạn:

  • nếu hợp đồng của hàm được gọi định nghĩa các lỗi cụ thể, khớp lỗi với errors.Is hoặc errors.As và xử lý các nhánh khác nhau
  • nếu lỗi có thể khôi phục được, ghi log lỗi và xử lý một cách nhẹ nhàng
  • nếu lỗi đại diện cho điều kiện thất bại cụ thể của miền, trả về một lỗi được định nghĩa rõ ràng
  • trả về lỗi, có thể wrapped hoặc giữ nguyên

Bất kể cách người gọi xử lý lỗi như thế nào, nó nên xử lý mỗi lỗi chỉ một lần. Người gọi không nên, ví dụ, ghi log lỗi và sau đó trả về lỗi đó, vì người gọi của nó cũng có thể xử lý lỗi.

Ví dụ, hãy xem xét các trường hợp sau:

DescriptionCode

Bad: Ghi log lỗi và trả về nó

Những người gọi ở các cấp cao hơn trong ngăn xếp có khả năng sẽ thực hiện hành động tương tự với lỗi. Làm như vậy sẽ gây ra nhiều nhiễu trong nhật ký ứng dụng mà không có giá trị nhiều.

u, err := getUser(id)
if err != nil {
  // BAD: Xem mô tả
  log.Printf("Could not get user %q: %v", id, err)
  return err
}

Good: Bọc lỗi và trả về nó

Những người gọi ở các cấp cao hơn trong ngăn xếp sẽ xử lý lỗi. Sử dụng %w đảm bảo rằng họ có thể khớp lỗi với errors.Is hoặc errors.As nếu cần thiết.

u, err := getUser(id)
if err != nil {
  return fmt.Errorf("get user %q: %w", id, err)
}

Good: Ghi log lỗi và xử lý nhẹ nhàng

Nếu thao tác không thực sự cần thiết, chúng ta có thể cung cấp một trải nghiệm bị suy giảm nhưng không bị gián đoạn bằng cách khôi phục từ lỗi đó.

if err := emitMetrics(); err != nil {
// Thất bại khi ghi số liệu không nên
// làm hỏng ứng dụng.
  log.Printf("Could not emit metrics: %v", err)
}

Good: Khớp lỗi và xử lý nhẹ nhàng

Nếu hàm được gọi định nghĩa một lỗi cụ thể trong hợp đồng của nó, và lỗi có thể khôi phục, hãy khớp lỗi đó và xử lý nhẹ nhàng. Đối với tất cả các trường hợp khác, bọc lỗi và trả về nó.

Những người gọi ở các cấp cao hơn trong ngăn xếp sẽ xử lý các lỗi khác.

tz, err := getUserTimeZone(id)
if err != nil {
  if errors.Is(err, ErrUserNotFound) {
    // Người dùng không tồn tại. Sử dụng UTC.
    tz = time.UTC
  } else {
    return fmt.Errorf("get user %q: %w", id, err)
  }
}

Handle Type Assertion Failures

Giá trị trả về đơn của một type assertion sẽ gây hoảng loạn (panic) khi gặp loại không đúng. Vì vậy, luôn sử dụng idiom "comma ok".

Không nênNên
t := i.(string)
t, ok := i.(string)
if !ok {
  // xử lý lỗi một cách nhẹ nhàng
}

Don't Panic

Code chạy trong môi trường sản xuất phải tránh panics. Panics là một nguồn lớn gây ra cascading failures. Nếu xảy ra lỗi, hàm phải trả về lỗi và cho phép hàm gọi quyết định cách xử lý.

Không nênNên
func run(args []string) {
  if len(args) == 0 {
    panic("an argument is required")
  }
  // ...
}

func main() {
  run(os.Args[1:])
}
func run(args []string) error {
  if len(args) == 0 {
    return errors.New("an argument is required")
  }
  // ...
  return nil
}

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

Panic/recover không phải là một chiến lược xử lý lỗi. Một chương trình chỉ nên panic khi có điều không thể phục hồi xảy ra như một trỏ đến nil. Một ngoại lệ cho quy tắc này là quá trình khởi tạo chương trình: các vấn đề xấu xảy ra khi bắt đầu chương trình mà nên dừng chương trình có thể gây ra panic.

var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))

Ngay cả trong các bài kiểm tra, ưu tiên sử dụng t.Fatal hoặc t.FailNow hơn panics để đảm bảo rằng bài kiểm tra được đánh dấu là thất bại.

Không nênNên
// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  panic("failed to set up test")
}
// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  t.Fatal("failed to set up test")
}

Use go.uber.org/atomic

Các hoạt động nguyên tử với gói sync/atomic hoạt động trên các loại dữ liệu nguyên thủy (int32, int64, vv.) nên dễ quên sử dụng hoạt động nguyên tử để đọc hoặc sửa đổi các biến.

go.uber.org/atomic thêm tính an toàn loại cho các hoạt động này bằng cách ẩn đi loại dữ liệu cơ bản. Ngoài ra, nó bao gồm một kiểu thuận tiện atomic.Bool.

Không nênNên
type foo struct {
  running int32  // atomic
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // already running…
     return
  }
  // start the Foo
}

func (f *foo) isRunning() bool {
  return f.running == 1  // race!
}
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()
}

Avoid Mutable Globals

Tránh thay đổi các biến toàn cục, thay vào đó lựa chọn tiêm phụ thuộc (dependency injection). Điều này áp dụng cho con trỏ hàm cũng như các loại giá trị khác.

Không nênNên
// sign.go

var _timeNow = time.Now

func sign(msg string) string {
  now := _timeNow()
  return signWithTime(msg, now)
}
// 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)
}
// sign_test.go

func TestSign(t *testing.T) {
  oldTimeNow := _timeNow
  _timeNow = func() time.Time {
    return someFixedTime
  }
  defer func() { _timeNow = oldTimeNow }()

  assert.Equal(t, want, sign(give))
}
// sign_test.go

func TestSigner(t *testing.T) {
  s := newSigner()
  s.now = func() time.Time {
    return someFixedTime
  }

  assert.Equal(t, want, s.Sign(give))
}

Avoid Embedding Types in Public Structs

Các loại nhúng này tiết lộ chi tiết cài đặt, ức chế sự tiến hóa loại và làm mờ tài liệu.

Giả sử bạn đã triển khai một loạt các loại danh sách bằng cách sử dụng một AbstractList chung, hãy tránh nhúng AbstractList trong các triển khai danh sách cụ thể của bạn. Thay vào đó, hãy viết thủ công chỉ các phương thức cho danh sách cụ thể của bạn mà sẽ ủy quyền cho danh sách trừu tượng.

type AbstractList struct {}

// Add adds an entity to the list.
func (l *AbstractList) Add(e Entity) {
  // ...
}

// Remove removes an entity from the list.
func (l *AbstractList) Remove(e Entity) {
  // ...
}
Không nênNên
// ConcreteList is a list of entities.
type ConcreteList struct {
  *AbstractList
}
// ConcreteList is a list of entities.
type ConcreteList struct {
  list *AbstractList
}

// Add adds an entity to the list.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}

// Remove removes an entity from the list.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

Go cho phép type embedding như một sự tha thứ giữa kế thừa và hợp thành. Loại bên ngoài nhận bản sao ngầm của các phương thức của loại được nhúng. Các phương thức này, mặc định, ủy quyền cho cùng một phương thức của thể hiện được nhúng.

Cấu trúc cũng nhận một trường có cùng tên với loại. Vì vậy, nếu loại được nhúng là công khai, trường cũng là công khai. Để duy trì tính tương thích ngược, mọi phiên bản tương lai của loại bên ngoài phải giữ loại được nhúng.

Một loại được nhúng hiếm khi cần thiết. Đây là một tiện ích giúp bạn tránh việc viết các phương thức ủy quyền tẻ nhạt.

Ngay cả việc nhúng một AbstractList interface tương thích, thay vì cấu trúc, cũng sẽ cung cấp cho người phát triển linh hoạt hơn cho tương lai, nhưng vẫn tiết lộ chi tiết rằng các danh sách cụ thể sử dụng một triển khai trừu tượng.

Không nênNên
// AbstractList is a generalized implementation
// for various kinds of lists of entities.
type AbstractList interface {
  Add(Entity)
  Remove(Entity)
}

// ConcreteList is a list of entities.
type ConcreteList struct {
  AbstractList
}
// ConcreteList is a list of entities.
type ConcreteList struct {
  list AbstractList
}

// Add adds an entity to the list.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}

// Remove removes an entity from the list.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

Dù là với một cấu trúc nhúng hoặc một giao diện nhúng, loại được nhúng đặt ra các giới hạn về sự phát triển của loại.

  • Thêm các phương thức vào một giao diện được nhúng là một thay đổi gây hỏng.
  • Loại bỏ các phương thức từ một cấu trúc được nhúng là một thay đổi gây hỏng.
  • Loại bỏ loại được nhúng là một thay đổi gây hỏng.
  • Thay thế loại được nhúng, ngay cả với một tùy chọn khác thỏa mãn cùng một giao diện, là một thay đổi gây hỏng.

Mặc dù việc viết các phương thức ủy quyền này là một công việc tẻ nhạt, nhưng sự nỗ lực bổ sung ẩn đi một chi tiết triển khai, tạo ra nhiều cơ hội cho sự thay đổi, và cũng loại bỏ sự trung gian để khám phá giao diện List đầy đủ trong tài liệu.

Avoid Using Built-In Names

Theo language specification của Go, có một số, predeclared identifiers không nên được sử dụng như tên trong các chương trình Go.

Tùy thuộc vào ngữ cảnh, việc sử dụng lại các tên này như tên sẽ gây ra việc ẩn đi tên ban đầu trong phạm vi từ vựng hiện tại (và bất kỳ phạm vi lồng nhau nào) hoặc làm cho mã ảnh hưởng trở nên rối rắm. Trong trường hợp tốt nhất, trình biên dịch sẽ phàn nàn; trong trường hợp tồi nhất, mã như vậy có thể giới thiệu các lỗi ẩn khó tìm kiếm.

Không nênNên
var error string
// `error` shadows the builtin

// or

func handleErrorMessage(error string) {
    // `error` shadows the builtin
}
var errorMessage string
// `error` refers to the builtin

// or

func handleErrorMessage(msg string) {
    // `error` refers to the builtin
}
type Foo struct {
    // While these fields technically don't
    // constitute shadowing, grepping for
    // `error` or `string` strings is now
    // ambiguous.
    error  error
    string string
}

func (f Foo) Error() error {
    // `error` and `f.error` are
    // visually similar
    return f.error
}

func (f Foo) String() string {
    // `string` and `f.string` are
    // visually similar
    return f.string
}
type Foo struct {
    // `error` and `string` strings are
    // now unambiguous.
    err error
    str string
}

func (f Foo) Error() error {
    return f.err
}

func (f Foo) String() string {
    return f.str
}

Lưu ý rằng trình biên dịch sẽ không tạo ra lỗi khi sử dụng các từ ngữ được định nghĩa trước, nhưng các công cụ như go vet nên chỉ ra những trường hợp này cũng như các trường hợp khác của sự ẩn giấu.

Avoid init()

Tránh sử dụng init() nếu có thể. Khi không thể tránh hoặc mong muốn sử dụng init(), mã nên cố gắng:

  1. Hoàn toàn xác định, bất kể môi trường chương trình hoặc lời gọi.
  2. Tránh phụ thuộc vào thứ tự hoặc ảnh hưởng của các hàm init() khác. Mặc dù thứ tự init() là rất được biết đến, mã có thể thay đổi, và do đó mối quan hệ giữa các hàm init() có thể làm cho mã trở nên dễ vỡ và dễ gây lỗi.
  3. Tránh truy cập hoặc thay đổi trạng thái toàn cục hoặc môi trường, như thông tin máy tính, biến môi trường, thư mục làm việc, đối số/chương trình đầu vào của chương trình, v.v.
  4. Tránh I/O, bao gồm cả việc tệp hệ thống tệp, mạng và các cuộc gọi hệ thống.

Mã không thể đáp ứng được những yêu cầu này có thể thuộc về dạng helper để được gọi như một phần của main() (hoặc nơi khác trong vòng đời của chương trình), hoặc được viết như là một phần của main() chính. Đặc biệt, các thư viện dự định được sử dụng bởi các chương trình khác nên chú ý đặc biệt để hoàn toàn xác định và không thực hiện "init magic".

Không nênNên
type Foo struct {
    // ...
}

var _defaultFoo Foo

func init() {
    _defaultFoo = Foo{
        // ...
    }
}
var _defaultFoo = Foo{
    // ...
}

// or, better, for testability:

var _defaultFoo = defaultFoo()

func defaultFoo() Foo {
    return Foo{
        // ...
    }
}
type Config struct {
    // ...
}

var _config Config

func init() {
    // Bad: based on current directory
    cwd, _ := os.Getwd()

    // Bad: I/O
    raw, _ := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )

    yaml.Unmarshal(raw, &_config)
}
type Config struct {
    // ...
}

func loadConfig() Config {
    cwd, err := os.Getwd()
    // handle err

    raw, err := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    // handle err

    var config Config
    yaml.Unmarshal(raw, &config)

    return config
}

Xem xét những trường hợp sau đây, trong đó init() có thể được ưu tiên hoặc cần thiết:

  • Các biểu thức phức tạp không thể được biểu diễn dưới dạng các phép gán đơn.
  • Các hook có thể được cắm vào, như các ngôn ngữ database/sql, các đăng ký loại mã hóa, vv.
  • Tối ưu hóa cho Google Cloud Functions và các dạng tính toán trước xác định khác.

Exit in Main

Chương trình Go sử dụng os.Exit hoặc log.Fatal* để thoát ngay lập tức. (Chấm dứt bằng cách gây ra sự cố không phải là cách tốt để thoát chương trình, vui lòng don't panic.)

Gọi một trong số os.Exit hoặc log.Fatal* chỉ trong main(). Tất cả các hàm khác nên trả về lỗi để báo hiệu thất bại.

Không nênNên
func main() {
  body := readFile(path)
  fmt.Println(body)
}

func readFile(path string) string {
  f, err := os.Open(path)
  if err != nil {
    log.Fatal(err)
  }

  b, err := io.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }

  return string(b)
}
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 := io.ReadAll(f)
  if err != nil {
    return "", err
  }

  return string(b), nil
}

Lý do: Các chương trình có nhiều hàm thoát gặp một số vấn đề:

  • Luồng điều khiển không rõ ràng: Bất kỳ hàm nào cũng có thể thoát khỏi chương trình nên trở nên khó hiểu về luồng điều khiển.
  • Khó thử nghiệm: Một hàm thoát khỏi chương trình cũng sẽ thoát khỏi bài kiểm tra gọi nó. Điều này làm cho việc kiểm thử hàm trở nên khó khăn và tạo ra rủi ro bỏ qua các bài kiểm tra khác mà chưa được chạy bởi go test.
  • Bỏ qua việc dọn dẹp: Khi một hàm thoát khỏi chương trình, nó sẽ bỏ qua các cuộc gọi hàm đã được đặt hàng với các lệnh defer. Điều này tạo ra nguy cơ bỏ qua các nhiệm vụ dọn dẹp quan trọng.

Exit Once

Nếu có thể, ưu tiên gọi os.Exit hoặc log.Fatal tối đa một lần trong hàm main() của bạn. Nếu có nhiều kịch bản lỗi dừng thực thi chương trình, hãy đặt logic đó trong một hàm riêng và trả về lỗi từ đó.

Điều này có tác dụng rút ngắn hàm main() của bạn và đặt tất cả các logic kinh doanh chính vào một hàm riêng, có thể kiểm thử được.

Không nênNên
package main

func main() {
  args := os.Args[1:]
  if len(args) != 1 {
    log.Fatal("missing file")
  }
  name := args[0]

  f, err := os.Open(name)
  if err != nil {
    log.Fatal(err)
  }
  defer f.Close()

  // If we call log.Fatal after this line,
  // f.Close will not be called.

  b, err := io.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }

  // ...
}
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 := io.ReadAll(f)
  if err != nil {
    return err
  }

  // ...
}

Ví dụ trên sử dụng log.Fatal, nhưng hướng dẫn cũng áp dụng cho os.Exit hoặc bất kỳ mã thư viện nào gọi os.Exit.

func main() {
  if err := run(); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

Bạn có thể thay đổi chữ ký của run() để phù hợp với nhu cầu của bạn. Ví dụ, nếu chương trình của bạn phải thoát với các mã thoát cụ thể cho các lỗi, run() có thể trả về mã thoát thay vì một lỗi. Điều này cho phép các bài kiểm thử đơn vị kiểm tra trực tiếp hành vi này cũng.

func main() {
  os.Exit(run(args))
}

func run() (exitCode int) {
  // ...
}

Nói một cách tổng quát, lưu ý rằng hàm run() được sử dụng trong các ví dụ này không có ý định hướng dẫn cụ thể. Có sự linh hoạt trong tên, chữ ký và cài đặt của hàm run(). Ngoài những điều khác, bạn có thể:

  • chấp nhận các đối số dòng lệnh chưa được phân tích (ví dụ: run(os.Args[1:]))
  • phân tích các đối số dòng lệnh trong main() và chuyển chúng vào run
  • sử dụng một loại lỗi tùy chỉnh để mang mã thoát trở lại main()
  • đặt business logic trong một lớp trừu tượng khác với package main

Hướng dẫn này chỉ yêu cầu có một nơi duy nhất trong hàm main() của bạn chịu trách nhiệm thực sự thoát quá trình.

Use field tags in marshaled structs

Bất kỳ trường struct nào được mã hóa thành JSON, YAML, hoặc các định dạng khác hỗ trợ đặt tên trường dựa trên thẻ nên được chú thích bằng thẻ liên quan.

Không nênNên
type Stock struct {
  Price int
  Name  string
}

bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})
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",
})

Lý do: Hình thức được chuỗi hóa của cấu trúc là một hợp đồng giữa các hệ thống khác nhau. Các thay đổi vào cấu trúc của hình thức được chuỗi hóa - bao gồm tên trường - sẽ phá vỡ hợp đồng này. Chỉ định tên trường bên trong các thẻ làm cho hợp đồng trở nên rõ ràng, và nó ngăn chặn việc phá vỡ hợp đồng một cách tình cờ thông qua việc tái cấu trúc hoặc đổi tên trường.

Don't fire-and-forget goroutines

Goroutines là nhẹ nhàng, nhưng chúng không phải là miễn phí: ít nhất, chúng tạo ra chi phí về bộ nhớ cho ngăn xếp của chúng và CPU để được lên lịch. Mặc dù những chi phí này nhỏ đối với việc sử dụng goroutines điển hình, chúng có thể gây ra vấn đề hiệu suất đáng kể khi được khởi tạo trong số lượng lớn mà không kiểm soát được thời gian tồn tại. Các goroutines với thời gian tồn tại không được quản lý cũng có thể gây ra các vấn đề khác như ngăn chặn các đối tượng không được sử dụng khỏi việc thu gom rác và giữ các tài nguyên mà về cơ bản không còn được sử dụng nữa.

Do đó, không rò rỉ goroutines trong mã sản xuất. Sử dụng go.uber.org/goleak để kiểm tra rò rỉ goroutine trong các gói mà có thể tạo ra goroutines.

Nói chung, mỗi goroutine:

  • phải có một thời gian dừng dự đoán được khi nó sẽ dừng chạy; hoặc
  • phải có một cách để gửi tín hiệu cho goroutine biết rằng nó nên dừng lại

Trong cả hai trường hợp, phải có một cách mã để chặn và đợi goroutine hoàn thành.

Ví dụ:

Không nênNên
go func() {
  for {
    flush()
    time.Sleep(delay)
  }
}()
var (
  stop = make(chan struct{}) // tells the goroutine to stop
  done = make(chan struct{}) // tells us that the goroutine exited
)
go func() {
  defer close(done)

  ticker := time.NewTicker(delay)
  defer ticker.Stop()
  for {
    select {
    case <-ticker.C:
      flush()
    case <-stop:
      return
    }
  }
}()

// Elsewhere...
close(stop)  // signal the goroutine to stop
<-done       // and wait for it to exit

Không có cách nào để dừng goroutine này. Nó sẽ chạy cho đến khi ứng dụng thoát.

Goroutine này có thể được dừng lại bằng cách close(stop), và chúng ta có thể chờ nó thoát với <-done.

Wait for goroutines to exit

Cho một goroutine được khởi tạo bởi hệ thống, phải có một cách để chờ goroutine thoát. Có hai cách phổ biến để làm điều này:

  • Sử dụng sync.WaitGroup. Làm như vậy nếu có nhiều goroutine bạn muốn chờ đợi

    var wg sync.WaitGroup
    for i := 0; i < N; i++ {
      wg.Add(1)
      go func() {
        defer wg.Done()
        // ...
      }()
    }
    
    // Để chờ tất cả kết thúc:
    wg.Wait()
  • Thêm một chan struct{} khác mà goroutine đó đóng khi đã hoàn thành. Làm như vậy nếu chỉ có một goroutine.

    done := make(chan struct{})
    go func() {
      defer close(done)
      // ...
    }()
    
    // To wait for the goroutine to finish:
    <-done

No goroutines in init()

Các hàm init() không nên khởi tạo goroutine. Xem thêm Avoid init().

Nếu một gói cần một goroutine nền, nó phải tiếp tục một đối tượng có trách nhiệm quản lý thời gian sống của một goroutine. Đối tượng phải cung cấp một phương thức (Close, Stop, Shutdown, vv) để gửi tín hiệu cho goroutine nền để dừng lại và chờ cho nó thoát.

Không nênNên
func init() {
  go doWork()
}

func doWork() {
  for {
    // ...
  }
}
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 tells the worker to stop
// and waits until it has finished.
func (w *Worker) Shutdown() {
  close(w.stop)
  <-w.done
}

Khởi tạo một goroutine nền mà không kiểm soát khi người dùng xuất gói này. Người dùng không có kiểm soát nào đối với goroutine hoặc một cách để dừng nó.

Khởi tạo người thực thi chỉ khi người dùng yêu cầu. Cung cấp một cách để tắt người thực thi để người dùng có thể giải phóng tài nguyên được sử dụng bởi người thực thi.

Lưu ý rằng bạn nên sử dụng WaitGroup nếu người thực thi quản lý nhiều goroutines. Xem Wait for goroutines to exit.

Performance

Hướng dẫn cụ thể về hiệu suất chỉ áp dụng cho lối đi nhanh.

Prefer strconv over fmt

Khi chuyển đổi các nguyên thủy thành/ từ chuỗi, strconv nhanh hơn fmt.

Không nênNên
for i := 0; i < b.N; i++ {
  s := fmt.Sprint(rand.Int())
}
for i := 0; i < b.N; i++ {
  s := strconv.Itoa(rand.Int())
}
BenchmarkFmtSprint-4    143 ns/op    2 allocs/op
BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

Avoid repeated string-to-byte conversions

Không tạo byte slices từ một chuỗi cố định một cách lặp đi lặp lại. Thay vào đó, thực hiện việc chuyển đổi một lần và lưu kết quả.

Không nênNên
for i := 0; i < b.N; i++ {
  w.Write([]byte("Hello world"))
}
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
  w.Write(data)
}
BenchmarkBad-4   50000000   22.2 ns/op
BenchmarkGood-4  500000000   3.25 ns/op

Prefer Specifying Container Capacity

Chỉ định dung lượng của các container khi có thể để phân bổ bộ nhớ cho container ngay từ đầu. Điều này giảm thiểu các phân bổ sau này (bằng cách sao chép và thay đổi kích thước của container) khi các phần tử được thêm vào.

Specifying Map Capacity Hints

Khi có thể, cung cấp gợi ý về dung lượng khi khởi tạo map bằng make().

make(map[T1]T2, hint)

Việc cung cấp gợi ý về dung lượng cho make() cố gắng đặt kích thước của map vừa phải ngay từ thời điểm khởi tạo, điều này giảm thiểu nhu cầu mở rộng map và các phân bổ khi các phần tử được thêm vào map.

Lưu ý rằng, khác với slices, gợi ý về dung lượng của map không đảm bảo phân bổ hoàn toàn, tiên đoán, nhưng được sử dụng để ước lượng số lượng các bucket của hashmap cần thiết. Do đó, các phân bổ vẫn có thể xảy ra khi thêm phần tử vào map, thậm chí lên đến dung lượng được chỉ định.

Không nênNên
m := make(map[string]os.FileInfo)

files, _ := os.ReadDir("./files")
for _, f := range files {
    m[f.Name()] = f
}
files, _ := os.ReadDir("./files")

m := make(map[string]os.DirEntry, len(files))
for _, f := range files {
    m[f.Name()] = f
}

m được tạo mà không có gợi ý về kích thước; có thể có nhiều phân bổ hơn khi gán.

m được tạo với một gợi ý về kích thước; có thể có ít phân bổ hơn khi gán.

Specifying Slice Capacity

Khi có thể, cung cấp gợi ý về dung lượng khi khởi tạo slices bằng make(), đặc biệt là khi thêm vào.

make([]T, length, capacity)

Khác với maps, dung lượng của slice không phải là một gợi ý: trình biên dịch sẽ phân bổ đủ bộ nhớ cho dung lượng của slice như được cung cấp cho make(), điều này có nghĩa là các thao tác append() sau đó sẽ không gây ra bất kỳ phân bổ nào (cho đến khi độ dài của slice khớp với dung lượng, sau đó bất kỳ append() nào cũng sẽ đòi hỏi một phép thay đổi kích thước để chứa các phần tử bổ sung).

Không nênNên
for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
BenchmarkBad-4    100000000    2.48s
BenchmarkGood-4   100000000    0.21s

Style

Avoid overly long lines

Tránh các dòng mã yêu cầu người đọc cuộn ngang hoặc nghiêng đầu quá nhiều.

Chúng tôi khuyến nghị một giới hạn độ dài dòng mềm là 99 ký tự. Tác giả nên cố gắng gói dòng trước khi đạt đến giới hạn này, nhưng đây không phải là một giới hạn cứng. Mã được phép vượt qua giới hạn này.

Be Consistent

Một số hướng dẫn được trình bày trong tài liệu này có thể được đánh giá một cách khách quan; một số khác là tình huống, ngữ cảnh hoặc chủ quan.

Trên hết, be consistent.

Mã đồng nhất dễ bảo trì hơn, dễ hợp lý hóa hơn, đòi hỏi ít công sức nhận thức hơn và dễ di chuyển hoặc cập nhật khi các quy ước mới xuất hiện hoặc các lớp lỗi được sửa chữa.

Ngược lại, việc có nhiều phong cách không liên quan hoặc xung đột trong một codebase duy nhất gây ra chi phí bảo trì, sự không chắc chắn và sự không nhất quán nhận thức, tất cả đều có thể góp phần trực tiếp vào tốc độ thấp hơn, việc đánh giá mã đau đớn và lỗi.

Khi áp dụng các hướng dẫn này vào một codebase, khuyến nghị là các thay đổi được thực hiện ở mức gói (hoặc lớn hơn): việc áp dụng ở mức gói con vi phạm vấn đề trên bằng cách giới thiệu nhiều phong cách vào cùng một mã.

Group Similar Declarations

Go hỗ trợ nhóm các khai báo tương tự.

Không nênNên
import "a"
import "b"
import (
  "a"
  "b"
)

Điều này cũng áp dụng cho các hằng số, biến và khai báo kiểu.

Không nênNên
const a = 1
const b = 2



var a = 1
var b = 2



type Area float64
type Volume float64
const (
  a = 1
  b = 2
)

var (
  a = 1
  b = 2
)

type (
  Area float64
  Volume float64
)

Chỉ nhóm các khai báo liên quan. Đừng nhóm các khai báo không liên quan.

Không nênNên
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
  EnvVar = "MY_ENV"
)
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

const EnvVar = "MY_ENV"

Các nhóm không bị giới hạn về nơi chúng có thể được sử dụng. Ví dụ, bạn có thể sử dụng chúng bên trong các hàm.

Không nênNên
func f() string {
  red := color.New(0xff0000)
  green := color.New(0x00ff00)
  blue := color.New(0x0000ff)

  // ...
}
func f() string {
  var (
    red   = color.New(0xff0000)
    green = color.New(0x00ff00)
    blue  = color.New(0x0000ff)
  )

  // ...
}

Exception: Các khai báo biến, đặc biệt là bên trong các hàm, nên được nhóm lại nếu được khai báo kề nhau với các biến khác. Làm điều này cho các biến được khai báo cùng nhau ngay cả khi chúng không liên quan.

Không nênNên
func (c *client) request() {
  caller := c.name
  format := "json"
  timeout := 5*time.Second
  var err error

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

  // ...
}

Import Group Ordering

Nên có hai nhóm import:

  • Thư viện chuẩn
  • Tất cả các thứ khác

Đây là cách nhóm được áp dụng mặc định bởi goimports.

Không nênNên
import (
  "fmt"
  "os"
  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)
import (
  "fmt"
  "os"

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

Package Names

Khi đặt tên gói, chọn một tên như sau:

  • Tất cả chữ thường. Không viết hoa hoặc dấu gạch dưới.
  • Không cần phải đổi tên bằng cách sử dụng import có tên tại hầu hết các điểm gọi.
  • Ngắn gọn và súc tích. Hãy nhớ rằng tên được nhận dạng đầy đủ tại mỗi điểm gọi.
  • Không phải là số nhiều. Ví dụ, net/url, không phải net/urls.
  • Không phải là "common", "util", "shared", hoặc "lib". Đây là các tên tệ, không cung cấp thông tin.

Xem thêm Package NamesStyle guideline for Go packages.

Function Names

Chúng tôi tuân theo quy ước của cộng đồng Go bằng cách sử dụng MixedCaps for function names. Một ngoại lệ được tạo ra cho các hàm kiểm tra, có thể chứa dấu gạch dưới để nhóm các trường hợp kiểm tra liên quan, ví dụ, TestMyFunction_WhatIsBeingTested.

Import Aliasing

Đặt tên định danh import phải được sử dụng nếu tên gói không khớp với phần cuối của đường dẫn import.

import (
  "net/http"

  client "example.com/client-go"
  trace "example.com/trace/v2"
)

Trong tất cả các tình huống khác, việc tránh sử dụng định danh import nên được thực hiện trừ khi có xung đột trực tiếp giữa các import.

Không nênNên
import (
  "fmt"
  "os"


  nettrace "golang.net/x/trace"
)
import (
  "fmt"
  "os"
  "runtime/trace"

  nettrace "golang.net/x/trace"
)

Function Grouping and Ordering

  • Các hàm nên được sắp xếp theo thứ tự gọi gần đúng.
  • Các hàm trong một tệp nên được nhóm theo receiver.

Do đó, các hàm được xuất nên xuất hiện đầu tiên trong một tệp, sau các định nghĩa struct, const, var.

Một newXYZ()/NewXYZ() có thể xuất hiện sau khi kiểu được định nghĩa, nhưng trước phần còn lại của các phương thức trên receiver.

Vì các hàm được nhóm theo receiver, các hàm tiện ích đơn giản nên xuất hiện ở cuối tệp.

Không nênNên
func (s *something) Cost() {
  return calcCost(s.weights)
}

type something struct{ ... }

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

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

func newSomething() *something {
    return &something{}
}
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 {...}

Reduce Nesting

Mã nên giảm thiểu sự lồng nhau nơi có thể bằng cách xử lý các trường hợp lỗi/điều kiện đặc biệt trước và trả về sớm hoặc tiếp tục vòng lặp. Giảm số lượng mã được lồng nhiều cấp độ.

Không nênNên
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)
  }
}
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()
}

Unnecessary Else

Nếu một biến được thiết lập trong cả hai nhánh của một câu lệnh if, nó có thể được thay thế bằng một câu lệnh if duy nhất.

Không nênNên
var a int
if b {
  a = 100
} else {
  a = 10
}
a := 10
if b {
  a = 100
}

Top-level Variable Declarations

Ở cấp độ cao nhất, sử dụng từ khóa tiêu chuẩn var. Không chỉ định kiểu, trừ khi nó không phải là cùng kiểu với biểu thức.

Không nênNên
var _s string = F()

func F() string { return "A" }
var _s = F()
// Since F already states that it returns a string, we don't need to specify
// the type again.

func F() string { return "A" }

Chỉ định kiểu nếu kiểu của biểu thức không khớp chính xác với kiểu mong muốn.

type myError struct{}

func (myError) Error() string { return "error" }

func F() myError { return myError{} }

var _e error = F()
// F returns an object of type myError but we want error.

Prefix Unexported Globals with _

Thêm tiền tố _ cho các varconst cấp độ cao không được xuất để làm cho rõ ràng khi chúng được sử dụng rằng chúng là các ký hiệu toàn cục.

Lý do: Các biến và hằng số cấp độ cao có phạm vi gói. Sử dụng một tên thông thường làm cho việc sử dụng giá trị sai sót dễ dàng xảy ra trong một tệp khác.

Không nênNên
// foo.go

const (
  defaultPort = 8080
  defaultUser = "user"
)

// bar.go

func Bar() {
  defaultPort := 9090
  ...
  fmt.Println("Default port", defaultPort)

  // We will not see a compile error if the first line of
  // Bar() is deleted.
}
// foo.go

const (
  _defaultPort = 8080
  _defaultUser = "user"
)

Exception: Các giá trị lỗi không được xuất có thể sử dụng tiền tố err mà không có gạch dưới. Xem Error Naming.

Embedding in Structs

Các loại nhúng nên được đặt ở đầu danh sách trường của một struct, và phải có một dòng trống phân tách các trường nhúng khỏi các trường thông thường.

Không nênNên
type Client struct {
  version int
  http.Client
}
type Client struct {
  http.Client

  version int
}

Việc nhúng phải cung cấp lợi ích cụ thể, như thêm hoặc mở rộng chức năng theo cách phù hợp với ngữ cảnh ý nghĩa. Nó phải làm điều này mà không có tác động tiêu cực nào đối với người dùng (xem thêm: Avoid Embedding Types in Public Structs).

Ngoại lệ: Mutexes không nên được nhúng, ngay cả trên các loại không được xuất. Xem thêm: Zero-value Mutexes are Valid.

Việc nhúng không nên:

  • Chỉ mang tính trang trí hoặc thuận tiện.
  • Làm cho các loại bên ngoài khó xây dựng hoặc sử dụng hơn.
  • Ảnh hưởng đến giá trị zero của các loại bên ngoài. Nếu loại bên ngoài có một giá trị zero hữu ích, nó vẫn nên có một giá trị zero hữu ích sau khi nhúng loại bên trong.
  • Tiết lộ các hàm hoặc trường không liên quan từ loại bên ngoài như là một hiệu ứng phụ của việc nhúng loại bên trong.
  • Tiết lộ các loại không được xuất.
  • Ảnh hưởng đến cách sao chép của các loại bên ngoài.
  • Thay đổi API hoặc ý nghĩa loại của các loại bên ngoài.
  • Nhúng một dạng không chuẩn của loại bên trong.
  • Tiết lộ chi tiết cài đặt của loại bên ngoài.
  • Cho phép người dùng quan sát hoặc điều khiển các bộ phận bên trong loại.
  • Thay đổi hành vi tổng quát của các hàm bên trong thông qua việc bọc một cách mà có thể làm người dùng ngạc nhiên một cách hợp lý.

Đơn giản là, nhúng một cách có ý thức và cố ý. Một thử nghiệm tốt là, "tất cả các phương thức/trường bên trong được xuất này có được thêm trực tiếp vào loại bên ngoài không"; nếu câu trả lời là "một số" hoặc "không", đừng nhúng loại bên trong - hãy sử dụng một trường thay vào đó.

Không nênNên
type A struct {
    // Bad: A.Lock() and A.Unlock() are
    //      now available, provide no
    //      functional benefit, and allow
    //      users to control details about
    //      the internals of A.
    sync.Mutex
}
type countingWriteCloser struct {
    // Good: Write() is provided at this
    //       outer layer for a specific
    //       purpose, and delegates work
    //       to the inner type's Write().
    io.WriteCloser

    count int
}

func (w *countingWriteCloser) Write(bs []byte) (int, error) {
    w.count += len(bs)
    return w.WriteCloser.Write(bs)
}
type Book struct {
    // Bad: pointer changes zero value usefulness
    io.ReadWriter

    // other fields
}

// later

var b Book
b.Read(...)  // panic: nil pointer
b.String()   // panic: nil pointer
b.Write(...) // panic: nil pointer
type Book struct {
    // Good: has useful zero value
    bytes.Buffer

    // other fields
}

// later

var b Book
b.Read(...)  // ok
b.String()   // ok
b.Write(...) // ok
type Client struct {
    sync.Mutex
    sync.WaitGroup
    bytes.Buffer
    url.URL
}
type Client struct {
    mtx sync.Mutex
    wg  sync.WaitGroup
    buf bytes.Buffer
    url url.URL
}

Local Variable Declarations

Khai báo biến ngắn gọn (:=) nên được sử dụng nếu một biến được thiết lập với một giá trị cụ thể.

Không nênNên
var s = "foo"
s := "foo"

Tuy nhiên, có những trường hợp mà giá trị mặc định rõ ràng hơn khi sử dụng từ khóa var. Declaring Empty Slices, ví dụ.

Không nênNên
func f(list []int) {
  filtered := []int{}
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}
func f(list []int) {
  var filtered []int
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}

nil is a valid slice

nil là một slice hợp lệ với độ dài 0. Điều này có nghĩa là,

  • Bạn không nên trả về một slice có độ dài bằng 0 một cách rõ ràng. Thay vào đó, hãy trả về nil.

    Không nênNên
    if x == "" {
      return []int{}
    }
    if x == "" {
      return nil
    }
  • Để kiểm tra xem một slice có rỗng hay không, luôn sử dụng len(s) == 0. Đừng kiểm tra nil.

    Không nênNên
    func isEmpty(s []string) bool {
      return s == nil
    }
    func isEmpty(s []string) bool {
      return len(s) == 0
    }
  • Giá trị zero (một slice được khai báo với var) có thể sử dụng ngay lập tức mà không cần make().

    Không nênNên
    nums := []int{}
    // or, nums := make([]int)
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }
    var nums []int
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }

Nhớ rằng, mặc dù nó là một slice hợp lệ, một slice nil không tương đương với một slice đã được cấp phát với độ dài 0 - một cái là nil và cái kia không - và hai cái có thể được xử lý khác nhau trong các tình huống khác nhau (như làm phẳng).

Reduce Scope of Variables

Nếu có thể, hãy giảm phạm vi của biến. Đừng giảm phạm vi nếu nó xung đột với Reduce Nesting.

Không nênNên
err := os.WriteFile(name, data, 0644)
if err != nil {
 return err
}
if err := os.WriteFile(name, data, 0644); err != nil {
 return err
}

Nếu bạn cần một kết quả của một cuộc gọi hàm bên ngoài câu lệnh if, thì bạn không nên cố gắng giảm phạm vi.

Không nênNên
if data, err := os.ReadFile(name); err == nil {
  err = cfg.Decode(data)
  if err != nil {
    return err
  }

  fmt.Println(cfg)
  return nil
} else {
  return err
}
data, err := os.ReadFile(name)
if err != nil {
   return err
}

if err := cfg.Decode(data); err != nil {
  return err
}

fmt.Println(cfg)
return nil

Avoid Naked Parameters

Các tham số không có tên trong cuộc gọi hàm có thể làm giảm khả năng đọc hiểu. Thêm các comment theo kiểu C (/* ... */) cho tên tham số khi ý nghĩa của chúng không rõ ràng.

Không nênNên
// func printInfo(name string, isLocal, done bool)

printInfo("foo", true, true)
// func printInfo(name string, isLocal, done bool)

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

Tốt hơn nữa, thay thế các kiểu bool không có tên với các kiểu tùy chỉnh để làm cho mã đọc hiểu và an toàn hơn về kiểu. Điều này cho phép nhiều hơn chỉ hai trạng thái (đúng/sai) cho tham số đó trong tương lai.

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)

Use Raw String Literals to Avoid Escaping

Go hỗ trợ raw string literals, có thể trải dài qua nhiều dòng và bao gồm dấu ngoặc kép. Sử dụng chúng để tránh các chuỗi đã được escape thủ công, mà khó đọc hơn nhiều.

Không nênNên
wantError := "unknown name:\"test\""
wantError := `unknown error:"test"`

Initializing Structs

Use Field Names to Initialize Structs

Bạn nên gần như luôn chỉ định tên trường khi khởi tạo các cấu trúc. Điều này hiện được bắt buộc bởi go vet.

Không nênNên
k := User{"John", "Doe", true}
k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

Exception: Tên trường có thể được bỏ qua trong các bảng kiểm tra khi có 3 hoặc ít hơn 3 trường.

tests := []struct{
  op Operation
  want string
}{
  {Add, "add"},
  {Subtract, "subtract"},
}

Omit Zero Value Fields in Structs

Khi khởi tạo cấu trúc với tên trường, bỏ qua các trường có giá trị không nếu chúng không cung cấp ngữ cảnh ý nghĩa. Nếu không, để cho Go tự động đặt chúng thành các giá trị không.

Không nênNên
user := User{
  FirstName: "John",
  LastName: "Doe",
  MiddleName: "",
  Admin: false,
}
user := User{
  FirstName: "John",
  LastName: "Doe",
}

Điều này giúp giảm tiếng ồn cho người đọc bằng cách bỏ qua các giá trị mặc định trong ngữ cảnh đó. Chỉ có các giá trị ý nghĩa được chỉ định.

Bao gồm các giá trị không khi tên trường cung cấp ngữ cảnh ý nghĩa. Ví dụ, các trường hợp kiểm tra trong Test Tables có thể hưởng lợi từ tên của các trường ngay cả khi chúng có giá trị không.

tests := []struct{
  give string
  want int
}{
  {give: "0", want: 0},
  // ...
}

Use var for Zero Value Structs

Khi tất cả các trường của một cấu trúc được bỏ qua trong một khai báo, sử dụng hình thức var để khai báo cấu trúc.

Không nênNên
user := User{}
var user User

Điều này phân biệt các cấu trúc có giá trị không từ các cấu trúc có các trường không bằng không tương tự như phân biệt tạo ra cho map initialization, và phù hợp với cách chúng tôi ưu tiên declare empty slices.

Initializing Struct References

Sử dụng &T{} thay vì new(T) khi khởi tạo tham chiếu cấu trúc để đồng nhất với việc khởi tạo cấu trúc.

Không nênNên
sval := T{Name: "foo"}

// inconsistent
sptr := new(T)
sptr.Name = "bar"
sval := T{Name: "foo"}

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

Initializing Maps

Ưu tiên sử dụng make(..) cho các map rỗng và các map được điền dữ liệu theo chương trình. Điều này làm cho việc khởi tạo map khác biệt về mặt trực quan so với việc khai báo, và nó làm cho việc thêm gợi ý kích thước sau này dễ dàng hơn nếu có sẵn.

Không nênNên
var (
  // m1 is safe to read and write;
  // m2 will panic on writes.
  m1 = map[T1]T2{}
  m2 map[T1]T2
)
var (
  // m1 is safe to read and write;
  // m2 will panic on writes.
  m1 = make(map[T1]T2)
  m2 map[T1]T2
)

DKhai báo và khởi tạo trực quan giống nhau.

Khai báo và khởi tạo trực quan khác biệt.

Nếu có thể, cung cấp gợi ý về dung lượng khi khởi tạo map bằng cách sử dụng make(). Xem Specifying Map Capacity Hints để biết thêm thông tin.

Tuy nhiên, nếu map chứa một danh sách cố định các phần tử, hãy sử dụng các biểu thức map để khởi tạo map.

Không nênNên
m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3
m := map[T1]T2{
  k1: v1,
  k2: v2,
  k3: v3,
}

Quy tắc cơ bản là sử dụng biểu thức map khi thêm một tập hợp cố định các phần tử vào thời điểm khởi tạo, nếu không thì sử dụng make (và chỉ định một gợi ý về kích thước nếu có sẵn).

Format Strings outside Printf

Nếu bạn khai báo chuỗi định dạng cho các hàm kiểu Printf ngoài chuỗi ký tự, hãy làm cho chúng trở thành các giá trị const.

Điều này giúp go vet thực hiện phân tích tĩnh của chuỗi định dạng.

Không nênNên
msg := "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)

Naming Printf-style Functions

Khi khai báo một hàm kiểu Printf, hãy đảm bảo rằng go vet có thể phát hiện và kiểm tra chuỗi định dạng.

Điều này có nghĩa là bạn nên sử dụng các tên hàm kiểu Printf được định nghĩa trước nếu có thể. go vet sẽ kiểm tra những cái này mặc định. Xem Printf family để biết thêm thông tin.

Nếu việc sử dụng các tên được định nghĩa trước không phải là một lựa chọn, kết thúc tên bạn chọn bằng f: Wrapf, không phải là Wrap. go vet có thể được yêu cầu kiểm tra các tên kiểu Printf cụ thể nhưng chúng phải kết thúc bằng f.

go vet -printfuncs=wrapf,statusf

See also go vet: Printf family check.

Patterns

Test Tables

Các bài kiểm tra dựa trên bảng với subtests có thể là một mẫu thiết kế hữu ích cho việc viết các bài kiểm tra để tránh lặp lại mã khi logic kiểm tra cốt lõi là lặp đi lặp lại.

Nếu một hệ thống đang được kiểm tra cần được kiểm tra đối với nhiều điều kiện trong đó một số phần của đầu vào và đầu ra thay đổi, thì nên sử dụng bài kiểm tra dựa trên bảng để giảm sự trùng lặp và cải thiện tính đọc.

Không nênNên
// func TestSplitHostPort(t *testing.T)

host, port, err := net.SplitHostPort("192.0.2.0:8000")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("192.0.2.0:http")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "http", port)

host, port, err = net.SplitHostPort(":8000")
require.NoError(t, err)
assert.Equal(t, "", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("1:8")
require.NoError(t, err)
assert.Equal(t, "1", host)
assert.Equal(t, "8", port)
// 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)
  })
}

Bảng kiểm tra làm cho việc thêm ngữ cảnh vào các thông báo lỗi dễ dàng hơn, giảm logic trùng lặp và thêm các trường hợp kiểm tra mới.

Chúng tôi tuân theo quy ước rằng dãy cấu trúc được gọi là tests và mỗi trường hợp kiểm tra tt. Hơn nữa, chúng tôi khuyến khích rõ ràng hóa các giá trị đầu vào và đầu ra cho mỗi trường hợp kiểm tra với tiền tố givewant.

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

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

Avoid Unnecessary Complexity in Table Tests

Bảng kiểm tra có thể khó đọc và bảo trì nếu các bài kiểm tra phụ chứa các quả định điều kiện hoặc logic phân nhánh khác. Bảng kiểm tra KHÔNG NÊN được sử dụng bất cứ khi nào cần có logic phức tạp hoặc có điều kiện bên trong các bài kiểm tra phụ (tức là logic phức tạp bên trong vòng lặp for).

Bảng kiểm tra lớn, phức tạp gây hại cho tính đọc và tính bảo trì vì người đọc kiểm tra có thể gặp khó khăn trong việc gỡ lỗi các lỗi kiểm tra xảy ra.

Bảng kiểm tra như vậy nên được chia thành nhiều bảng kiểm tra hoặc nhiều hàm Test... riêng biệt.

Một số mục tiêu cần hướng đến là:

  • Tập trung vào đơn vị hành vi hẹp nhất
  • Tối thiểu hóa "sâu kiểm tra" và tránh những quả định có điều kiện (xem dưới đây)
  • Đảm bảo rằng tất cả các trường bảng đều được sử dụng trong tất cả các kiểm tra
  • Đảm bảo rằng tất cả logic kiểm tra chạy cho tất cả các trường hợp bảng

Trong ngữ cảnh này, "sâu kiểm tra" có nghĩa là "trong một bài kiểm tra nhất định, số lượng quả định liên tiếp cần phải được giữ lại" (tương tự như phức tạp vòng). Có "kiểm tra nông" có nghĩa là có ít mối quan hệ hơn giữa các quả định và, quan trọng hơn, các quả định đó ít có khả năng có điều kiện mặc định.

Cụ thể, bảng kiểm tra có thể trở nên rối rắm và khó đọc nếu chúng sử dụng nhiều đường dẫn phân nhánh (ví dụ: shouldError, expectCall, vv.), Sử dụng nhiều câu lệnh if cho các quả định giả định cụ thể (ví dụ: shouldCallFoo), hoặc đặt các hàm trong bảng (ví dụ: setupMocks func(*FooMock)).

Tuy nhiên, khi kiểm tra hành vi chỉ thay đổi dựa trên đầu vào thay đổi, có thể thích hợp để nhóm các trường hợp tương tự lại với nhau trong một bảng kiểm tra để minh họa tốt hơn cách hành vi thay đổi trên tất cả các đầu vào, thay vì phân chia các đơn vị có thể so sánh khác nhau thành các kiểm tra riêng biệt và làm cho chúng khó so sánh và đối chiếu hơn.

Nếu thân kiểm tra ngắn và rõ ràng, thì việc có một đường dẫn phân nhánh duy nhất cho các trường hợp thành công so với thất bại với một trường bảng như shouldErr để chỉ định kỳ vọng lỗi là chấp nhận được.

Không nênNên
func TestComplicatedTable(t *testing.T) {
  tests := []struct {
    give          string
    want          string
    wantErr       error
    shouldCallX   bool
    shouldCallY   bool
    giveXResponse string
    giveXErr      error
    giveYResponse string
    giveYErr      error
  }{
    // ...
  }

  for _, tt := range tests {
    t.Run(tt.give, func(t *testing.T) {
      // setup mocks
      ctrl := gomock.NewController(t)
      xMock := xmock.NewMockX(ctrl)
      if tt.shouldCallX {
        xMock.EXPECT().Call().Return(
          tt.giveXResponse, tt.giveXErr,
        )
      }
      yMock := ymock.NewMockY(ctrl)
      if tt.shouldCallY {
        yMock.EXPECT().Call().Return(
          tt.giveYResponse, tt.giveYErr,
        )
      }

      got, err := DoComplexThing(tt.give, xMock, yMock)

      // verify results
      if tt.wantErr != nil {
        require.EqualError(t, err, tt.wantErr)
        return
      }
      require.NoError(t, err)
      assert.Equal(t, want, got)
    })
  }
}
func TestShouldCallX(t *testing.T) {
  // setup mocks
  ctrl := gomock.NewController(t)
  xMock := xmock.NewMockX(ctrl)
  xMock.EXPECT().Call().Return("XResponse", nil)

  yMock := ymock.NewMockY(ctrl)

  got, err := DoComplexThing("inputX", xMock, yMock)

  require.NoError(t, err)
  assert.Equal(t, "want", got)
}

func TestShouldCallYAndFail(t *testing.T) {
  // setup mocks
  ctrl := gomock.NewController(t)
  xMock := xmock.NewMockX(ctrl)

  yMock := ymock.NewMockY(ctrl)
  yMock.EXPECT().Call().Return("YResponse", nil)

  _, err := DoComplexThing("inputY", xMock, yMock)
  assert.EqualError(t, err, "Y failed")
}

Sự phức tạp này làm cho việc thay đổi, hiểu và chứng minh tính đúng đắn của bài kiểm tra trở nên khó khăn hơn.

Mặc dù không có hướng dẫn cụ thể, tính đọc và bảo trì luôn được xem xét hàng đầu khi quyết định giữa Bảng kiểm tra so với các bài kiểm tra riêng biệt cho nhiều đầu vào/đầu ra của một hệ thống.

Parallel Tests

Kiểm tra song song, giống như một số vòng lặp chuyên biệt (ví dụ, những vòng lặp tạo ra goroutine hoặc chụp tham chiếu như một phần của thân vòng lặp), phải chú ý gán biến vòng lặp một cách rõ ràng trong phạm vi của vòng lặp để đảm bảo rằng chúng giữ các giá trị mong muốn.

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

for _, tt := range tests {
  tt := tt // for t.Parallel
  t.Run(tt.give, func(t *testing.T) {
    t.Parallel()
    // ...
  })
}

Trong ví dụ trên, chúng ta phải khai báo một biến tt có phạm vi cho mỗi lần lặp vì việc sử dụng t.Parallel() bên dưới. Nếu chúng ta không làm như vậy, hầu hết hoặc tất cả các bài kiểm tra sẽ nhận một giá trị không mong muốn cho tt, hoặc một giá trị thay đổi trong khi chúng đang chạy.

Functional Options

Tùy chọn chức năng là một mẫu trong đó bạn khai báo một kiểu Option mờ mịt để ghi lại thông tin trong một cấu trúc nội bộ nào đó. Bạn chấp nhận một số biến đa dạng của các tùy chọn này và thực hiện hành động dựa trên thông tin đầy đủ được ghi lại bởi các tùy chọn trên cấu trúc nội bộ.

Sử dụng mẫu này cho các đối số tùy chọn trong các hàm tạo và các API công cộng khác mà bạn dự đoán sẽ cần mở rộng, đặc biệt là nếu bạn đã có ba hoặc nhiều hơn ba đối số trên các hàm đó.

Không nênNên
// package db

func Open(
  addr string,
  cache bool,
  logger *zap.Logger
) (*Connection, error) {
  // ...
}
// package db

type Option interface {
  // ...
}

func WithCache(c bool) Option {
  // ...
}

func WithLogger(log *zap.Logger) Option {
  // ...
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  // ...
}

Tham số cache và logger phải luôn được cung cấp, ngay cả khi người dùng muốn sử dụng mặc định.

db.Open(addr, db.DefaultCache, zap.NewNop())
db.Open(addr, db.DefaultCache, log)
db.Open(addr, false /* cache */, zap.NewNop())
db.Open(addr, false /* cache */, log)

Options chỉ được cung cấp nếu cần.

db.Open(addr)
db.Open(addr, db.WithLogger(log))
db.Open(addr, db.WithCache(false))
db.Open(
  addr,
  db.WithCache(false),
  db.WithLogger(log),
)

Cách chúng tôi đề xuất để triển khai mẫu thiết kế này là sử dụng một giao diện Option chứa một phương thức không công khai, ghi lại các tùy chọn trên một cấu trúc options không công khai.

type options struct {
  cache  bool
  logger *zap.Logger
}

type Option interface {
  apply(*options)
}

type cacheOption bool

func (c cacheOption) apply(opts *options) {
  opts.cache = bool(c)
}

func WithCache(c bool) Option {
  return cacheOption(c)
}

type loggerOption struct {
  Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
  opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
  return loggerOption{Log: log}
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  options := options{
    cache:  defaultCache,
    logger: zap.NewNop(),
  }

  for _, o := range opts {
    o.apply(&options)
  }

  // ...
}

Lưu ý rằng có một cách triển khai mẫu thiết kế này bằng cách sử dụng closures nhưng chúng tôi tin rằng mẫu trên cung cấp linh hoạt hơn cho tác giả và dễ dàng hơn để gỡ lỗi và kiểm thử cho người dùng. Cụ thể, nó cho phép so sánh các tùy chọn với nhau trong các bài kiểm thử và mocks, so với closures nơi điều này là không thể. Hơn nữa, nó cho phép các tùy chọn triển khai các giao diện khác, bao gồm fmt.Stringer cho phép các biểu diễn chuỗi có thể đọc được của các tùy chọn.

Xem thêm,

Linting

Quan trọng hơn bất kỳ tập hợp "phù hợp" nào của các công cụ kiểm tra cú pháp, hãy kiểm tra cú pháp một cách nhất quán trên toàn bộ mã nguồn.

Chúng tôi khuyên bạn nên sử dụng ít nhất các công cụ kiểm tra cú pháp sau, vì chúng giúp phát hiện các vấn đề phổ biến nhất và thiết lập một tiêu chuẩn cao cho chất lượng mã nguồn mà không cần thiết phải chi tiết:

  • errcheck để đảm bảo rằng các lỗi được xử lý
  • goimports để định dạng mã và quản lý các import
  • golint để chỉ ra các lỗi phong cách phổ biến
  • govet để phân tích mã nguồn và kiểm tra các lỗi phổ biến
  • staticcheck để thực hiện các kiểm tra phân tích tĩnh khác nhau

Lint Runners

Chúng tôi khuyên bạn nên sử dụng golangci-lint là công cụ chạy kiểm tra cú pháp cho mã Go, chủ yếu là do hiệu suất của nó trong các dự án mã nguồn lớn và khả năng cấu hình và sử dụng nhiều công cụ kiểm tra cú pháp một cách hiệu quả. Repo này có một tập tin cấu hình .golangci.yml mẫu với các công cụ kiểm tra cú pháp và cài đặt được khuyến nghị.

golangci-lint có various linters khác nhau có sẵn để sử dụng. Các công cụ kiểm tra cú pháp đã nêu ở trên được đề xuất làm tập hợp cơ bản, và chúng tôi khuyến khích các nhóm phát triển thêm bất kỳ công cụ kiểm tra cú pháp bổ sung nào mà họ cảm thấy hợp lý cho dự án của mình.