Quy định Xử lý Lỗi trong Golang

Các Loại Lỗi

Có vài tùy chọn cho việc khai báo lỗi. Trước khi chọn tùy chọn phù hợp nhất cho trường hợp sử dụng của bạn, hãy xem xét những điều sau:

  • Caller có cần phải trùng khớp lỗi để xử lý 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 biến lỗi cấp cao hoặc kiểu tùy chỉnh.
  • Thông báo lỗi là chuỗi tĩnh hay chuỗi động đòi hỏi thông tin ngữ cảnh? Đối với chuỗi tĩnh, chúng ta có thể sử dụng errors.New, nhưng đối với trường hợp cuối cùng, 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 chuyển lỗi mới được trả về bởi các hàm ở mức dưới không? Nếu có, hãy tham khảo phần bọc lỗi.
Trùng khớp lỗi? Thông báo lỗi Hướng dẫn
Không tĩnh errors.New
Không động fmt.Errorf
tĩnh biến cấp cao với errors.New
động kiểu lỗi tùy chỉnh

Ví dụ, sử dụng errors.New để biểu diễn lỗi với chuỗi tĩnh. Nếu caller cần phải trùng khớp và xử lý lỗi này, xuất nó dưới dạng biến để hỗ trợ trùng khớp với errors.Is.

Không Trùng khớp Lỗi

// gói foo

func Open() error {
  return errors.New("không thể mở")
}

// gói bar

if err := foo.Open(); err != nil {
  // Không xử lý được lỗi.
  panic("lỗi không xác định")
}

Trùng khớp Lỗi

// gói foo

var ErrCouldNotOpen = errors.New("không thể mở")

func Open() error {
  return ErrCouldNotOpen
}

// gói bar

if err := foo.Open(); err != nil {
  if errors.Is(err, foo.ErrCouldNotOpen) {
    // xử lý lỗi
  } else {
    panic("lỗi không xác định")
  }
}

Đối với các lỗi có chuỗi động, sử dụng fmt.Errorf nếu caller không cần phải xác định nó. Nếu caller thực sự cần phải xác định nó, thì sử dụng một error tùy chỉnh.

Không Trùng khớp Lỗi

// gói foo

func Open(file string) error {
  return fmt.Errorf("không tìm thấy tệp %q", file)
}

// gói bar

if err := foo.Open("testfile.txt"); err != nil {
  // Không thể xử lý lỗi.
  panic("lỗi không xác định")
}

Trùng khớp Lỗi

// gói foo

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("không tìm thấy tệp %q", e.File)
}

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


// gói bar

if err := foo.Open("testfile.txt"); err != nil {
  var notFound *NotFoundError
  if errors.As(err, &notFound) {
    // xử lý lỗi
  } else {
    panic("lỗi không xác định")
  }
}

Lưu ý rằng nếu bạn xuất biến lỗi hoặc kiểu từ một gói, chúng trở thành một phần của giao diện lập trình ứng dụng công cộng của gói đó.

Bọc Lỗi

Khi một lỗi xảy ra trong khi gọi một phương thức khác, thông thường có ba cách để xử lý nó:

  • Trả về lỗi gốc nguyên vẹn.
  • Sử dụng fmt.Errorf với %w để thêm ngữ cảnh vào lỗi và sau đó trả về nó.
  • Sử dụng fmt.Errorf với %v để thêm ngữ cảnh vào lỗi và sau đó trả về nó.

Nếu không có ngữ cảnh bổ sung để thêm, hãy trả về lỗi gốc nguyên vẹn. Điều này sẽ bảo toàn loại lỗi và thông báo ban đầu. Điều này đặc biệt phù hợp khi thông báo lỗi cơ bản chứa đủ thông tin để theo dõi nơi lỗi đã phát sinh.

Nếu không, hãy thêm ngữ cảnh vào thông báo lỗi trong mức khả thi nhất để tránh các lỗi mơ hồ như "kết nối bị từ chối". Thay vào đó, bạn sẽ nhận được các thông báo lỗi hữu ích hơn, chẳng hạn như "gọi dịch vụ foo: kết nối bị từ chối".

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

  • Sử dụng %w nếu người gọi nên có quyền truy cập vào lỗi cơ bản. Điều này là mặc định tốt cho hầu hết lỗi 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. Do đó, đối với những lỗi bọc mà là các biến hoặc kiểu đã biết, ghi chép và kiểm thử chúng như là một phần của hợp đồng hàm
  • Sử dụng %v để che giấu lỗi cơ bản. Người gọi sẽ không thể khớp với 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 lỗi trả về, tránh sử dụng các cụm từ như "failed to" để giữ ngữ cảnh ngắn gọn. Khi lỗi xâm nhập qua ngăn xếp, nó sẽ được xếp chồng lên từng tầng:

Không Đề Xuất:

s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "failed to create new store: %w", err)
}

// failed to x: failed to y: failed to create new store: the error

Đề Xuất:

s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "new store: %w", err)
}
// 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 báo là một lỗi (ví dụ, một thẻ "err" hoặc tiền tố "Failed" trong nhật ký).

Đặt Tên Sai

Đối với các giá trị lỗi được lưu trữ như các biến toàn cầu, sử dụng tiền tố Err hoặc err tùy thuộc vào việc chúng được xuất khẩu hay không. Vui lòng xem hướng dẫn. Đối với hằng số và biến cấp độ cao không được xuất khẩu, sử dụng gạch dưới (_) làm tiền tố.

var (
  // Xuất khẩu hai lỗi sau để người dùng của gói này có thể khớp chúng với errors.Is.
  ErrBrokenLink = errors.New("liên kết bị hỏng")
  ErrCouldNotOpen = errors.New("không thể mở")

  // Lỗi này không được xuất khẩu vì chúng ta không muốn nó là một phần của API công cộng của chúng ta. Tuy vẫn có thể sử dụng nó trong gói bằng errors.
  errNotFound = errors.New("không tìm thấy")
)

Đối với các loại lỗi tùy chỉnh, sử dụng hậu tố Error.

// 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 errors.As.
type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("tệp %q không tìm thấy", e.File)
}

// Lỗi này không được xuất khẩu vì chúng ta không muốn nó là một phần của API công cộng. Tuy vẫn có thể sử dụng nó trong gói bằng errors.As.
type resolveError struct {
  Path string
}

func (e *resolveError) Error() string {
  return fmt.Sprintf("giải quyết %q", e.Path)
}

Xử lý lỗi

Khi người gọi nhận được một lỗi từ người được gọi, nó có thể xử lý lỗi theo nhiều cách dựa trên việc hiểu về lỗi.

Điều này bao gồm nhưng không giới hạn:

  • Phù hợp với lỗi bằng errors.Is hoặc errors.As nếu người được gọi đã đồng ý với một định nghĩa lỗi cụ thể, và xử lý các nhánh theo cách khác nhau
  • Ghi log lỗi và giảm dần một cách êm đềm nếu lỗi có thể khôi phục
  • Trả về một lỗi được định nghĩa rõ ràng nếu nó đại diện cho một điều kiện thất bại cụ thể trong lĩnh vực
  • Trả về lỗi, dù là đã được bọc hay là nguyên văn

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

Ví dụ, hãy xem xét các tình huống sau:

Kém: Ghi log lỗi và trả về nó

Các người gọi khác lên trên ngăn xếp có thể thực hiện các hành động tương tự trên lỗi này. Điều này sẽ tạo ra nhiều tiếng ồn trong log ứng dụng mà không có ích lợi gì.

u, err := getUser(id)
if err != nil {
  // KÉM: Xem mô tả
  log.Printf("Không thể lấy người dùng %q: %v", id, err)
  return err
}

Tốt: Bọc lỗi và trả về nó

Các lỗi lên trên ngăn xếp sẽ xử lý lỗi này. Sử dụng %w đảm bảo rằng họ có thể phù hợp với lỗi bằng errors.Is hoặc errors.As nếu cần thiết.

u, err := getUser(id)
if err != nil {
  return fmt.Errorf("lấy người dùng %q: %w", id, err)
}

Tốt: Ghi log lỗi và giảm dần một cách êm đềm

Nếu thao tác không hoàn toàn cần thiết, chúng ta có thể cung cấp giảm độ một cách êm đềm bằng cách khôi phục từ nó mà không làm gián đoạn trải nghiệm.

if err := emitMetrics(); err != nil {
  // Lỗi khi viết làm số liệu thống kê không nên
  // làm gián đoạn ứng dụng.
  log.Printf("Không thể phát số liệu thống kê: %v", err)
}

Tốt: Phù hợp với lỗi và giảm dần một cách phù hợp

Nếu người được gọi đã xác định một lỗi cụ thể trong thỏa thuận của nó và sự cố có thể khôi phục, phù hợp với trường hợp lỗi đó và giảm dần một cách phù hợp. Đối với tất cả các trường hợp khác, bọc lỗi và trả về nó. Các lỗi lên trên 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 giờ UTC.
    tz = time.UTC
  } else {
    return fmt.Errorf("lấy người dùng %q: %w", id, err)
  }
}

Xử lý Lỗi Khi Khẳng Định Thất Bại

Khẳng định kiểu sẽ gây ra panic với một giá trị trả về duy nhất trong trường hợp phát hiện kiểu không chính xác. Do đó, luôn sử dụng "comma, ok" idiom.

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

t := i.(string)

Khuyến nghị:

t, ok := i.(string)
if !ok {
  // Xử lý lỗi một cách êm đềm
}

Tránh sử dụng panic

Mã chạy trong môi trường sản xuất phải tránh sử dụng panic. Panic là nguồn chính của sự cố lan truyền. Nếu xảy ra lỗi, hàm phải trả về lỗi và cho phép người gọi quyết định cách xử lý lỗi đó.

Không khuyến khích:

func run(args []string) {
  if len(args) == 0 {
    panic("cần một đối số")
  }
  // ...
}

func main() {
  run(os.Args[1:])
}

Khuyến nghị:

func run(args []string) error {
  if len(args) == 0 {
    return errors.New("cần một đối số")
  }
  // ...
  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à chiến lược xử lý lỗi. Nó chỉ nên panic khi xảy ra sự kiện không thể khôi phục được (ví dụ: tham chiếu nil). Một ngoại lệ là trong quá trình khởi tạo chương trình: các tình huống gây ra sự cố trong chương trình phải được xử lý trong quá trình khởi động chương trình.

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

Ngay cả trong mã kiểm thử, nên sử dụng t.Fatal hoặc t.FailNow thay vì panic để đảm bảo rằng các lỗi được đánh dấu.

Không khuyến khích:

// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  panic("thiết lập kiểm thử thất bại")
}

Khuyến nghị:

// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  t.Fatal("thiết lập kiểm thử thất bại")
}