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
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.
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
varnó 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.
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.
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.
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
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
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.
func New(text string) error {
return &errorString{text}
}
// struct errorString
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}Vì 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.
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ư
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.
func Unwrap(err error) errorHà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
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ự.
func Is(err, target error) boolHà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
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().
func As(err error, target any) boolHà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
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/errorsVí dụ
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:1650Thô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
func main() {
var dic map[string]int
dic["a"] = 'a'
}panic: assignment to entry in nil mapTIP
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
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
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.
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: panicVà câu lệnh defer của hàm upstream cũng sẽ thực thi ví dụ như sau
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: panicdefer cũng có thể嵌套 panic dưới đây là một ví dụ khá phức tạp
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: panicATó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.
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: panicBCó 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ã
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: panicBTrong 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.
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ườngNhư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.
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 panicNgoài ra còn có một tình huống rất cực đoan đó là tham số của panic() là nil.
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ườngTóm lại hàm recover có mấy điểm cần lưu ý
- Phải sử dụng trong
defer - Nhiều lần sử dụng cũng chỉ có một cái có thể khôi phục
panic recovertrong closure sẽ không khôi phục bất kỳpanicnào của hàm bên ngoài- Tham số của
paniccấm sử dụngnil
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
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
fatalVấ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.
