Skip to content

Xử lý lỗi

Trong Go có ba cấp độ ngoại lệ

  • error: lỗi quy trình bình thường cần xử lý bỏ qua không xử lý chương trình cũng không崩溃
  • panic: vấn đề rất nghiêm trọng chương trình nên thoát ngay sau khi xử lý xong vấn đề
  • fatal: vấn đề rất nghiêm trọng chương trình nên thoát ngay

Chính xác mà nói Go không có ngoại lệ nó được thể hiện thông qua lỗi tương tự Go cũng không có câu lệnh try-catch-finally như vậy người sáng lập Go hy vọng có thể kiểm soát lỗi họ không hy vọng làm gì cũng cần嵌套 một đống try-catch vì vậy trong hầu hết trường hợp sẽ trả về như giá trị trả về của hàm ví dụ như ví dụ mã dưới đây

go
func main() {
  // Mở một file
  if file, err := os.Open("README.txt"); err != nil {
    fmt.Println(err)
        return
  }
    fmt.Println(file.Name())
}

Đoạn mã này ý rất rõ ràng mở một file tên là README.txt nếu mở thất bại hàm sẽ trả về một lỗi xuất thông tin lỗi nếu lỗi là nil thì có nghĩa là mở thành công xuất tên file.

Có vẻ như đơn giản hơn try-catch một chút nhưng nếu có rất nhiều lời gọi hàm sẽ có đầy rẫy những câu lệnh判断 if err != nil như ví dụ dưới đây đây là một demo tính hash của file trong đoạn mã ngắn này tổng cộng xuất hiện ba lần if err != nil.

go
func main() {
  sum, err := checksum("main.go")
  if err != nil {
    fmt.Println(err)
    return
  }
  fmt.Println(sum)
}

func checksum(path string) (string, error) {
  file, err := os.Open(path)
  if err != nil {
    return "", err
  }
  defer file.Close()

  hash := sha256.New()
  _, err = io.Copy(hash, file)
  if err != nil {
    return "", err
  }

  var hexSum [64]byte
  sum := hash.Sum(nil)
  hex.Encode(hexSum[:], sum)

  return string(hexSum[:]), nil
}

Chính vì vậy điểm bị chỉ trích nhiều nhất của Go từ bên ngoài là xử lý lỗi trong mã nguồn Go if err != nil chiếm một phần khá lớn. Rust cũng trả về giá trị lỗi nhưng không ai nói về điểm này vì nó giải quyết vấn đề loại này thông qua đường cú pháp so với Rust thì đường cú pháp của Go không thể nói là nhiều chỉ có thể nói là gần như không có.

Tuy nhiên chúng ta cần nhìn nhận sự vật một cách biện chứng mọi thứ đều có tốt có xấu ưu điểm của xử lý lỗi trong Go có mấy điểm

  • Gánh nặng tâm lý nhỏ: có lỗi thì xử lý không xử lý thì trả về
  • Khả năng đọc: vì cách xử lý rất đơn giản trong hầu hết trường hợp đều dễ hiểu mã
  • Dễ debug: mỗi lỗi đều được tạo ra từ giá trị trả về của lời gọi hàm có thể tìm ngược lại từng lớp rất ít khi xuất hiện tình huống đột nhiên冒出 một lỗi nhưng không biết từ đâu ra

Tuy nhiên nhược điểm cũng không ít

  • Trong lỗi không có thông tin stack (cần gói bên thứ ba giải quyết hoặc tự đóng gói)
  • Xấu mã lặp lại nhiều (tùy sở thích cá nhân)
  • Lỗi tùy chỉnh được khai báo bằng var nó là một biến chứ không phải hằng số (thực sự không nên)
  • Vấn đề che khuất biến

Trong cộng đồng các đề xuất và thảo luận về xử lý lỗi của Go chưa bao giờ ngừng kể từ khi Go ra đời có một câu nói đùa: nếu bạn có thể chấp nhận xử lý lỗi của Go thì bạn mới là một Gopher đủ tiêu chuẩn.

TIP

Dưới đây là hai bài viết của nhóm Go về xử lý lỗi感兴趣 có thể xem

error

error là một loại lỗi quy trình bình thường sự xuất hiện của nó có thể chấp nhận được trong hầu hết trường hợp nên xử lý nó tất nhiên cũng có thể bỏ qua không quản mức độ nghiêm trọng của error không đủ để dừng toàn bộ chương trình. error bản thân là một interface được định nghĩa trước interface này chỉ có một phương thức Error() giá trị trả về của phương thức này là chuỗi dùng để xuất thông tin lỗi.

go
type error interface {
   Error() string
}

error trong lịch sử cũng có thay đổi lớn ở phiên bản 1.13 nhóm Go đã推出 lỗi chuỗi và cung cấp cơ chế kiểm tra lỗi hoàn thiện hơn tiếp theo sẽ lần lượt giới thiệu.

Tạo

Có mấy cách để tạo một error cách thứ nhất là sử dụng hàm New trong gói errors.

go
err := errors.New("đây là một lỗi")

Cách thứ hai là sử dụng hàm Errorf trong gói fmt có thể nhận được một error có tham số định dạng.

go
err := fmt.Errorf("đây là lỗi có %d tham số định dạng", 1)

Dưới đây là một ví dụ hoàn chỉnh

go
func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("phải là số nguyên dương")
   }
   return i + j, nil
}

Trong hầu hết trường hợp để bảo trì tốt hơn thường không tạo error tạm thời mà sẽ sử dụng error thông dụng như biến toàn cục ví dụ như đoạn mã trích từ file os\erros.go dưới đây

go
var (
  ErrInvalid = fs.ErrInvalid // "invalid argument"

  ErrPermission = fs.ErrPermission // "permission denied"
  ErrExist      = fs.ErrExist      // "file already exists"
  ErrNotExist   = fs.ErrNotExist   // "file does not exist"
  ErrClosed     = fs.ErrClosed     // "file already closed"

  ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
  ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

Có thể thấy chúng đều được định nghĩa bằng var

Lỗi tùy chỉnh

Thông qua việc thực hiện phương thức Error() có thể dễ dàng tùy chỉnh error ví dụ errorString trong gói erros là một triển khai rất đơn giản.

go
func New(text string) error {
   return &errorString{text}
}

// struct errorString
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

errorString thực hiện quá đơn giản khả năng biểu đạt không đủ nên rất nhiều thư viện mã nguồn mở bao gồm cả thư viện chính thức đều sẽ chọn tùy chỉnh error để đáp ứng các nhu cầu lỗi khác nhau.

Truyền

Trong một số trường hợp người gọi gọi một hàm trả về một lỗi nhưng bản thân người gọi không phụ trách xử lý lỗi nên cũng trả về lỗi như giá trị trả về ném cho người gọi lớp trên quá trình này gọi là truyền lỗi trong quá trình truyền có thể được đóng gói nhiều lớp khi người gọi lớp trên muốn判断 loại lỗi để xử lý khác nhau có thể không thể phân biệt loại lỗi hoặc判断 sai và lỗi chuỗi chính là để giải quyết tình huống này mà xuất hiện.

go
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrappError cũng thực hiện interface error thêm một phương thức Unwrap dùng để trả về tham chiếu của error ban đầu bên trong nó dưới sự đóng gói nhiều lớp hình thành một chuỗi lỗi lần theo chuỗi rất dễ tìm thấy lỗi ban đầu. Vì struct này không expose ra bên ngoài nên chỉ có thể sử dụng hàm fmt.Errorf để tạo ví dụ như

go
err := errors.New("đây là lỗi ban đầu")
wrapErr := fmt.Errorf("lỗi %w", err)

Khi sử dụng phải sử dụng động từ định dạng %w và tham số chỉ có thể là một error hợp lệ.

Xử lý

Bước cuối cùng trong xử lý lỗi là làm thế nào để xử lý và kiểm tra lỗi gói errors cung cấp một vài hàm tiện lợi để xử lý lỗi.

go
func Unwrap(err error) error

Hàm errors.Unwrap() dùng để mở gói một chuỗi lỗi triển khai bên trong của nó cũng rất đơn giản

go
func Unwrap(err error) error {
   u, ok := err.(interface { // 类型断言,是否实现该方法
      Unwrap() error
   })
   if !ok { //没有实现说明是一个基础的error
      return nil
   }
   return u.Unwrap() // 否则调用Unwrap
}

Sau khi mở gói sẽ trả về lỗi được包裹 bởi chuỗi lỗi hiện tại lỗi được包裹 có thể vẫn là một chuỗi lỗi nếu muốn tìm giá trị hoặc loại tương ứng trong chuỗi lỗi có thể đệ quy tìm kiếm khớp tuy nhiên thư viện chuẩn đã cung cấp sẵn hàm tương tự.

go
func Is(err, target error) bool

Hàm errors.Is có tác dụng判断 chuỗi lỗi có chứa lỗi chỉ định hay không ví dụ như sau

go
var originalErr = errors.New("this is an error")

func wrap1() error { // 包裹原始错误
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 原始错误
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) { // 如果使用if err == originalErr 将会是false
      fmt.Println("original")
   }
}

Vì vậy khi判断 lỗi không nên sử dụng toán tử == mà nên sử dụng errors.Is().

go
func As(err error, target any) bool

Hàm errors.As() có tác dụng tìm lỗi khớp loại đầu tiên trong chuỗi lỗi và gán giá trị cho err được truyền vào. Trong một số trường hợp cần chuyển đổi lỗi loại error thành loại thực hiện lỗi cụ thể để có được chi tiết lỗi chi tiết hơn và việc sử dụng类型断言 đối với một chuỗi lỗi là vô hiệu vì lỗi ban đầu được包裹 bởi struct đây cũng là lý do cần hàm As. Ví dụ như sau

go
type TimeError struct { // 自定义error
   Msg  string
   Time time.Time //记录发生错误的时间
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // 包裹原始错误
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 原始错误
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // 检查错误链中是否有*TimeError类型的错误
   if errors.As(err, &myerr) { // 输出TimeError的时间
      fmt.Println("original", myerr.Time)
   }
}

target phải là con trỏ trỏ đến error vì khi tạo struct trả về là con trỏ struct nên error thực tế là loại *TimeError vậy target phải là loại **TimeError.

Tuy nhiên gói errors do官方 cung cấp thực sự không đủ vì nó không có thông tin stack không thể định vị nói chung khá khuyến nghị sử dụng gói tăng cường khác của官方

github.com/pkg/errors

Ví dụ

go
import (
  "fmt"
  "github.com/pkg/errors"
)

func Do() error {
  return errors.New("error")
}

func main() {
  if err := Do(); err != nil {
    fmt.Printf("%+v", err)
  }
}

Kết quả xuất

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

Thông qua xuất định dạng có thể thấy thông tin stack rồi mặc định sẽ không xuất stack. Gói này tương đương với phiên bản tăng cường của gói errors trong thư viện chuẩn đều là do官方 viết không hiểu sao không sáp nhập vào thư viện chuẩn.

panic

panic dịch là hoảng loạn biểu thị vấn đề chương trình rất nghiêm trọng chương trình cần lập tức dừng để xử lý vấn đề này nếu không chương trình lập tức dừng và xuất thông tin stack panic là hình thức biểu thị ngoại lệ runtime trong Go thường xuất hiện trong một số thao tác nguy hiểm chủ yếu là để kịp thời ngăn chặn tổn thất từ đó tránh gây ra hậu quả nghiêm trọng hơn. Tuy nhiên panic cũng sẽ làm tốt công việc hậu cần của chương trình trước khi thoát đồng thời panic cũng có thể được khôi phục để đảm bảo chương trình tiếp tục chạy.

Dưới đây là một ví dụ ghi giá trị vào map nil chắc chắn sẽ kích hoạt panic

go
func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

TIP

Khi trong chương trình có nhiều goroutine chỉ cần bất kỳ goroutine nào xảy ra panic nếu không khôi phục nó toàn bộ chương trình sẽ崩溃

Tạo

Việc tạo panic một cách rõ ràng rất đơn giản chỉ cần sử dụng hàm built-in panic chữ ký hàm như sau

go
func panic(v any)

Hàm panic nhận một tham số v loại any khi xuất thông tin stack lỗi v cũng sẽ được xuất. Ví dụ sử dụng như sau

go
func main() {
  initDataBase("", 0)
}

func initDataBase(host string, port int) {
  if len(host) == 0 || port == 0 {
    panic("tham số kết nối dữ liệu không hợp lệ")
  }
    // ...logic khác
}

Khi khởi tạo kết nối cơ sở dữ liệu thất bại chương trình就不 nên khởi động vì không có cơ sở dữ liệu chương trình chạy cũng vô nghĩa vì vậy ở đây nên ném panic

panic: tham số kết nối dữ liệu không hợp lệ

Hậu cần

Trước khi chương trình thoát vì panic sẽ làm một số công việc hậu cần ví dụ thực thi câu lệnh defer.

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

Kết quả xuất là

C
B
A
panic: panic

Và câu lệnh defer của hàm upstream cũng sẽ thực thi ví dụ như sau

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

Kết quả xuất

C
2
1
B
A
panic: panic

defer cũng có thể嵌套 panic dưới đây là một ví dụ khá phức tạp

go
func main() {
  defer fmt.Println("A")
  defer func() {
    func() {
      panic("panicA")
      defer fmt.Println("E")
    }()
  }()
  fmt.Println("C")
  dangerOp()
  defer fmt.Println("D")
}

func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

Thứ tự thực thi của panic嵌套 trong defer vẫn nhất quán khi xảy ra panic logic tiếp theo sẽ không thể thực thi.

C
2
1
A
panic: panicB
        panic: panicA

Tóm lại khi xảy ra panic sẽ lập tức thoát khỏi hàm hiện tại và thực hiện công việc hậu cần của hàm hiện tại ví dụ defer sau đó ném lên trên hàm upstream cũng thực hiện công việc hậu cần cho đến khi chương trình dừng chạy.

Khi goroutine con xảy ra panic sẽ không kích hoạt công việc hậu cần của goroutine hiện tại nếu cho đến khi goroutine con thoát vẫn không khôi phục panic thì chương trình sẽ trực tiếp dừng chạy.

go
var waitGroup sync.WaitGroup

func main() {
  demo()
}

func demo() {
  waitGroup.Add(1)
  defer func() {
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  waitGroup.Wait() // 父协程阻塞等待子协程执行完毕
  defer fmt.Println("D")
}
func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
  waitGroup.Done()
}

Kết quả xuất là

C
2
1
panic: panicB

Có thể thấy trong demo() không có câu lệnh defer nào được thực thi chương trình就直接 thoát. Cần lưu ý nếu không có waitGroup để chặn goroutine cha thì tốc độ thực thi của demo() có thể nhanh hơn tốc độ thực thi của goroutine con kết quả xuất sẽ rất khó hiểu dưới đây sửa đổi một chút mã

go
func main() {
  demo()
}

func demo() {
  defer func() {
    // 父协程善后工作要花费20ms
    time.Sleep(time.Millisecond * 20)
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  defer fmt.Println("D")
}
func dangerOp() {
  // 子协程要执行一些逻辑,要花费1ms
  time.Sleep(time.Millisecond)
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

Kết quả xuất

C
D
2
1
panic: panicB

Trong ví dụ này khi goroutine con xảy ra panic goroutine cha đã hoàn thành thực thi hàm vào công việc hậu cần khi thực thi defer cuối cùng thì碰巧 gặp goroutine con xảy ra panic nên chương trình就直接 thoát chạy.

Khôi phục

Khi xảy ra panic sử dụng hàm built-in recover() có thể kịp thời xử lý và đảm bảo chương trình tiếp tục chạy phải chạy trong câu lệnh defer ví dụ sử dụng như sau.

go
func main() {
   dangerOp()
   fmt.Println("chương trình thoát bình thường")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("khôi phục panic")
      }
   }()
   panic("xảy ra panic")
}

Người gọi hoàn toàn không biết bên trong hàm dangerOp() xảy ra panic chương trình thực thi logic còn lại rồi thoát bình thường vì vậy kết quả xuất như sau

xảy ra panic
khôi phục panic
chương trình thoát bình thường

Nhưng thực tế việc sử dụng recover() có nhiều cạm bẫy ẩn. Ví dụ sử dụng recover trong closure lần nữa trong defer.

go
func main() {
  dangerOp()
  fmt.Println("chương trình thoát bình thường")
}

func dangerOp() {
  defer func() {
    func() {
      if err := recover(); err != nil {
        fmt.Println(err)
        fmt.Println("khôi phục panic")
      }
    }()
  }()
  panic("xảy ra panic")
}

Hàm closure có thể coi như gọi một hàm panic là truyền lên trên chứ không phải truyền xuống tự nhiên hàm closure cũng không thể khôi phục panic vì vậy kết quả xuất như sau.

panic: xảy ra panic

Ngoài ra còn có một tình huống rất cực đoan đó là tham số của panic()nil.

go
func main() {
   dangerOp()
   fmt.Println("chương trình thoát bình thường")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("khôi phục panic")
      }
   }()
   panic(nil)
}

Trong trường hợp này panic thực sự sẽ khôi phục nhưng sẽ không xuất bất kỳ thông tin lỗi nào.

Kết quả xuất

chương trình thoát bình thường

Tóm lại hàm recover có mấy điểm cần lưu ý

  1. Phải sử dụng trong defer
  2. Nhiều lần sử dụng cũng chỉ có một cái có thể khôi phục panic
  3. recover trong closure sẽ không khôi phục bất kỳ panic nào của hàm bên ngoài
  4. Tham số của panic cấm sử dụng nil

fatal

fatal là một vấn đề cực kỳ nghiêm trọng khi xảy ra fatal chương trình cần lập tức dừng chạy sẽ không thực hiện bất kỳ công việc hậu cần nào thông thường là gọi hàm Exit trong gói os để thoát chương trình như dưới đây

go
func main() {
  dangerOp("")
}

func dangerOp(str string) {
  if len(str) == 0 {
    fmt.Println("fatal")
    os.Exit(1)
  }
  fmt.Println("logic bình thường")
}

Kết quả xuất

fatal

Vấn đề cấp độ fatal nói chung rất ít khi chủ động kích hoạt hầu hết trường hợp đều là bị động kích hoạt.

Golang by www.golangdev.cn edit