Hướng dẫn cơ bản về tiêu chuẩn mã hóa Golang

Sử dụng defer để giải phóng tài nguyên

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

Không nên:

p.Lock()
if p.count < 10 {
  p.Unlock()
  return p.count
}

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

return newCount

// Dễ quên mở khóa khi có nhiều nhánh trả về

Nên:

p.Lock()
defer p.Unlock()

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

p.count++
return p.count

// Dễ đọc hơn

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

Kích thước của kênh nên là 1 hoặc không đệm

Thường thì kênh nên có kích thước là 1 hoặc không đệm. Mặc định, kênh không đệm với kích thước là 0. Bất kỳ kích thước nào khác phải được xem xét một cách nghiêm ngặt. Chúng ta cần xem xét cách xác định kích thước, xem xét điều gì ngăn chặn kênh viết dưới tải nặng và khi bị chặn, và xem xét các thay đổi diễn ra trong logic hệ thống khi điều này xảy ra.

Không nên:

// Nên đủ để xử lý mọi tình huống!
c := make(chan int, 64)

Nên:

// Kích thước: 1
c := make(chan int, 1) // hoặc
// Kênh không đệm, kích thước là 0
c := make(chan int)

Enums bắt đầu từ 1

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

Không nên:

type Operation int

const (
  Add Operation = iota
  Subtract
  Multiply
)

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

Nên:

type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

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

Trong một số trường hợp, việc sử dụng giá trị không có thể hiện ý nghĩa (enums bắt đầu từ 0), ví dụ, khi giá trị không là hành vi mặc định lý tưởng.

type LogOutput int

const (
  LogToStdout LogOutput = iota
  LogToFile
  LogToRemote
)

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

Sử dụng atomic

Sử dụng các hoạt động atomic từ gói sync/atomic để thao tác trên các kiểu nguyên thủy (int32, int64, v.v.) vì dễ quên sử dụng các hoạt động atomic để đọc hoặc sửa đổi biến.

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

Không nên sử dụng:

type foo struct {
  running int32  // atomic
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // đã chạy...
     return
  }
  // bắt đầu Foo
}

func (f *foo) isRunning() bool {
  return f.running == 1  // đua!
}

Phương pháp nên sử dụng:

type foo struct {
  running atomic.Bool
}

func (f *foo) start() {
  if f.running.Swap(true) {
     // đã chạy...
     return
  }
  // bắt đầu Foo
}

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

Tránh sử dụng biến toàn cục có thể thay đổi

Sử dụng phương pháp dependency injection để tránh thay đổi biến toàn cục. Đ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 khuyến nghị phương pháp 1:

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

Phương pháp khuyến nghị 1:

// 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)
}

Không khuyến nghị phương pháp 2:

// 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))
}

Phương pháp khuyến nghị 2:

// sign_test.go
func TestSigner(t *testing.T) {
  s := newSigner()
  s.now = func() time.Time {
    return someFixedTime
  }
  assert.Equal(t, want, s.Sign(give))
}

Tránh sử dụng các Identifier Trước Đã Khai Báo

Theo Thông số ngôn ngữ Go, có một số identifier trước đã được khai báo không nên được sử dụng trong dự án Go. Những identifier đã được khai báo trước này không nên được tái sử dụng như tên trong ngữ cảnh khác nhau, vì làm như vậy sẽ ẩn các identifier gốc trong phạm vi hiện tại (hoặc bất kỳ phạm vi lồng vào nào), có thể dẫn đến sự nhầm lẫn trong mã. Trong trường hợp tốt nhất, trình biên dịch sẽ thông báo lỗi; trong trường hợp xấu nhất, mã như vậy có thể gây ra lỗi tiềm ẩn khó phục hồi.

Thực hành không khuyến nghị 1:

var error string
// `error` mặc định ẩn identifier tích hợp

// hoặc

func handleErrorMessage(error string) {
    // `error` mặc định ẩn identifier tích hợp
}

Thực hành khuyến nghị 1:

var errorMessage string
// `error` bây giờ trỏ tới identifier tích hợp không bị ẩn

// hoặc

func handleErrorMessage(msg string) {
    // `error` bây giờ trỏ tới identifier tích hợp không bị ẩn
}

Thực hành không khuyến nghị 2:

type Foo struct {
    // Mặc dù những trường này kỹ thuật không ẩn, nhưng việc định nghĩa lại chuỗi `error` hoặc `string` giờ đây trở nên mơ hồ.
    error  error
    string string
}

func (f Foo) Error() error {
    // `error` và `f.error` trực quan có vẻ giống nhau
    return f.error
}

func (f Foo) String() string {
    // `string` và `f.string` trực quan có vẻ giống nhau
    return f.string
}

Thực hành khuyến nghị 2:

type Foo struct {
    // `error` và `string` giờ đây rõ ràng.
    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 identifier đã được khai báo trước, nhưng các công cụ như go vet sẽ đúng là chỉ ra những vấn đề liên quan một cách ngầm định.

Tránh sử dụng init()

Hãy cố gắng tránh sử dụng init() càng nhiều càng tốt. Khi không thể tránh khỏi việc sử dụng init() hoặc được ưu tiên, mã code nên cố gắng:

  1. Đảm bảo tính hoàn chỉnh bất kể môi trường chương trình hoặc cuộc gọi.
  2. Tránh phụ thuộc vào thứ tự hoặc tác động phụ của các chức năng init(). Mặc dù thứ tự của init() là rõ ràng, mã code có thể thay đổi, do đó mối quan hệ giữa các chức năng init() có thể làm cho mã code mảnh khảnh và dễ gây lỗi.
  3. Tránh truy cập hoặc điều chỉnh 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, tham số/đầu vào của chương trình, v.v.
  4. Tránh I/O, bao gồm hệ thống tệp, mạng và cuộc gọi hệ thống.

Mã code không đáp ứng các yêu cầu này có thể thuộc phần cuộc gọi main() (hoặc ở nơi khác trong vòng đời của chương trình) hoặc có thể được viết như 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 đến tính hoàn chỉnh thay vì thực hiện "đầu khách" trong init magic.

Cách tiếp cận không được khuyến nghị 1:

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

Cách tiếp cận được khuyến nghị 1:

var _defaultFoo = Foo{
    // ...
}
// hoặc, để dễ kiểm tra:
var _defaultFoo = defaultFoo()
func defaultFoo() Foo {
    return Foo{
        // ...
    }
}

Cách tiếp cận không được khuyến nghị 2:

type Config struct {
    // ...
}
var _config Config
func init() {
    // Xấu: Dựa trên thư mục hiện tại
    cwd, _ := os.Getwd()
    // Xấu: I/O
    raw, _ := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    yaml.Unmarshal(raw, &_config)
}

Cách tiếp cận được khuyến nghị 2:

type Config struct {
    // ...
}
func loadConfig() Config {
    cwd, err := os.Getwd()
    // xử lý lỗi
    raw, err := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    // xử lý lỗi
    var config Config
    yaml.Unmarshal(raw, &config)
    return config
}

Dựa trên những xem xét trên, trong một số trường hợp, init() có thể được ưu tiên hoặc cần thiết hơn, bao gồm:

  • Không thể biểu diễn dưới dạng một sự gán duy nhất của một biểu thức phức tạp.
  • Các hooks có thể chèn, chẳng hạn như database/sql, các loại đăng ký, v.v.

Ưu tiên xác định sức chứa cho slice khi nối thêm

Luôn ưu tiên xác định giá trị sức chứa cho make() khi khởi tạo một slice để nối thêm.

Cách tiếp cận không được khuyến nghị:

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

Cách tiếp cận được khuyến nghị:

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

Sử dụng Thẻ Trường trong Serialization của Struct

Khi serializing thành JSON, YAML, hoặc bất kỳ định dạng nào khác hỗ trợ đặt tên trường dựa trên thẻ, thì cần sử dụng các thẻ tương ứng để chú thích.

Không được khuyến nghị:

type Stock struct {
  Price int
  Name  string
}
bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

Được khuyến nghị:

type Stock struct {
  Price int    `json:"price"`
  Name  string `json:"name"`
  // An toàn khi đổi tên Name thành Symbol.
}
bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

Lý thuyết, định dạng serialization của một cấu trúc là một hợp đồng giữa các hệ thống khác nhau. Thay đổi định dạng serialization của cấu trúc (bao gồm tên trường) sẽ làm vỡ hợp đồng này. Xác định tên trường trong các thẻ làm cho hợp đồng rõ ràng và cũng giúp ngăn chặn vi phạm hợp đồng theo cách tình cờ thông qua việc tái cơ cấu hoặc đổi tên các trường.