Bài 16 - Xử lý lỗi trong Go


  • Trùm cuối

    Error Handling

    Một số hàm luôn thực hiện thành công. Một số khác có thể gặp lỗi nếu tham số đưa vào hoặc giá trị nhận được trong xử lý thuộc trường hợp ngoại lệ. Nhưng có nhiều hàm mà lỗi có thể xảy ra bất kỳ lúc nào dù người viết tốt đến đâu bởi nó phụ thuộc vào các yếu tố ngoài khả năng của lập trình viên như khi đọc, xuất dữ liệu hay kết nối mạng.

    79f2e4ec-8401-4de8-8948-d4cc69853ae0-image.png

    Do đó xử lý lỗi khi gọi hàm luôn là vấn đề được các ngôn ngữ lập trình quan tâm và Go cũng vậy. Nhờ cơ chế hàm có nhiều giá trị trả về nên Go đề xuất dùng giá trị trả về cuối cùng là giá trị thể hiện trạng thái lỗi của hàm. Thông thường giá trị này thuộc kiểu luận lý và thường đặt tên là ok với giá trị true khi không có lỗi. So với xử lý ngoại lệ ở các ngôn ngữ khác thì cơ chế trả về giá trị lỗi đơn giản và hiệu quả hơn.

    Trong các tình huống cần thể hiện rõ lỗi do đâu thì giá trị lỗi trả về thuộc kiểu error, là một dạng kiểu interface mà ta sẽ tìm hiểu sau. Ở đây chúng ta cần nắm là nếu không lỗi, biến này có giá trị nil, ngược lại trả về một chuỗi mô tả lỗi mà ta có thể truy xuất thông qua error.Error() hoặc in trực tiếp với fmt.Println.

    Khi gọi hàm và trả về có lỗi, chúng ta cần xử lý lỗi. Hiện có 5 phương án xử lý lỗi phổ biến như sau:

    • Chuyển lỗi: Khi gọi một hàm và trả về lỗi, chúng ta đơn giản trả lỗi nhận được trong các giá trị trả về của hàm chúng ta viết để bên gọi hàm này biết và xử lý nếu cần. Trong một số tình huống cần rõ ràng, chúng ta cần tạo biến kiểu error khác để mô tả chi tiết hơn. Nội dung mô tả là thông tin xử lý và trạng thái lỗi. Đôi lúc có thể là tên hàm và tham số.
       func input() (int, error) { 
          var n int  
          fmt.Print("Nhập vào một số không âm: ")  
          _, err := fmt.Scanf("%d", &n)  
          if err == nil && n < 0 {  
               return n, fmt.Errorf("Lỗi! %d là số nguyên âm!", n)  
          } 
          return n, err  
       } 
    
    • Hàm input xử lý nhận số nhập từ bàn phím không có tham số và trả về 2 giá trị, n kiểu int nhập vào và kiểu error xử lý lỗi.
    • Dòng 2 khai báo biến cục bộ n kiểu int.
    • Dòng 3 in dòng thông báo yêu cầu nhập từ bàn phím. Lưu ý ở đây dùng hàm fmt.Print để dấu nhắc trên màn hình lệnh nằm cùng dòng sau khi in xong.
    • Dòng 4 là chính là dòng thực hiện nhận giá trị từ bàn phím. Ở đây tôi dùng hàm Scanf từ package fmt với khai báo 2 thành phần: "%d" để Go hiểu là tôi muốn nhận số và &n để hàm Scanf sau khi nhận giá trị sẽ truyền vào biến n. Sở dĩ ở đây phải truyền &n là để đảm bảo tham số vào là con trỏ giúp cho hàm Scanf có thể gán giá trị cho tham số chứ nếu truyền giá trị của n vào thì sẽ không thay đổi được n sau khi kết thúc hàm Scanf (xem lại bài về con trỏ). Giá trị trả về gồm số phần tử nhận được từ bàn phím (không quan tâm nên tôi dùng dấu _ để bỏ qua) và giá trị lỗi nếu có.
    • Dòng 5 kiểm tra cặp điều kiện: err == nil && n < 0. Đầu tiên kiểm tra để đảm bảo là không có lỗi, ta nhận được giá trị n là 1 số. Nếu vế bên trái sai, tức err != nil, vế phải sẽ không được Go kiểm tra nữa. Vế phải kiểm tra xem n có phải số âm không vì yêu cầu nhập số không âm. Nếu n đúng là số âm thì cả 2 vế đều đúng nên phép và && cho kết quả đúng nên khối lệnh của lệnh if sẽ thực thi như ở dòng 6.
    • Dòng 6 trả về giá trị n và một giá trị lỗi mới được tạo để giải thích lỗi do đâu. Gói fmt cung cấp hàm Errorf để tạo ra đối tượng lỗi.
    • Dòng 8 đơn giản trả về giá trị n nhận được và giá trị lỗi err về cho nơi gọi hàm input. Lúc này input đơn giản đã chuyển lỗi của hàm Scanf. Do chuyển lỗi nên chúng ta sẽ không kiểm soát được nội dung như chúng ta thấy ở kết quả bên dưới khi thực thi.

    2a044a60-ad6a-4179-89bc-b9c5aa4b53c0-image.png

    • Thử lại: Khi một lỗi là tạm thời hoặc không dự đoán được thì chúng ta nên thử lại tác vụ gây lỗi một số lần nhất định với một khoảng thời gian nghỉ giữa những lần thử lại. Ví dụ sau thử kết nối một server qua giao thức Head:
    package main 
         
    import ( 
        "fmt" 
        "net/http" 
        "os" 
        "time" 
        "log" 
    ) 
    
    func main() { 
        if len(os.Args) < 2 { 
            fmt.Println("Vui lòng thêm URL server cần check!") 
            return 
        } 
        
        if err := checkServer(os.Args[1]); err == nil { 
            fmt.Println("Server đang hoạt động!") 
        } else { 
            fmt.Println(err) 
        } 
    } 
        
    func checkServer(url string) error { 
        const timeout = 1 * time.Minute 
        deadline := time.Now().Add(timeout) 
        for tries := 0; time.Now().Before(deadline); tries++ { 
            _, err := http.Head(url) 
            if err == nil { 
                return nil // thành công 
            } 
            log.Printf("Server không trả lời do lỗi (%s); thử lại ...", err) 
            time.Sleep(time.Second << uint(tries)) 
        } 
        return fmt.Errorf("Server %s không phản hồi sau %s phút!", url, timeout)  
    }
    
    • Dòng 1-8 là thủ tục khai báo package và các package cần sử dụng.
    • Dòng 10-21 là hàm main với nhiệm vụ thực thi gọi hàm checkServer với url là tham số dòng lệnh sau đó in kết quả check lên màn hình:
        func main() { 
            if len(os.Args) < 2 { 
                fmt.Println("Vui lòng thêm URL server cần check!") 
                return 
            } 
         
            if err := checkServer(os.Args[1]); err == nil { 
                fmt.Println("Server đang hoạt động!") 
            } else { 
                fmt.Println(err) 
            } 
        }
    
    • Dòng 11-14 là lệnh if kiểm tra xem khi thực thi có truyền tham số dòng lệnh hay không. Do tham số dòng lệnh lưu giữ trong os.Args[] với phần tử 0 luôn là tên của file thực thi nên nếu có tham số dòng lệnh thì ít nhất os.Args phải có 2 phần tử.
    • Dòng 16-20 là khối lệnh if else kiểm tra giá trị lỗi mà hàm checkServer trả về. Ở lệnh if dòng 16, ta khai báo biến err lưu giữ giá trị trả về từ hàm checkServer. Sau đó kiểm tra xem nó có bằng nil không. Nếu bằng nil thì hàm checkServer ok, thông báo ra màn hình ở dòng 17. Ngược lại ta in thông tin lỗi lưu giữ ở err.
    • Dòng 23-35 là nội dung hàm checkServer. Hàm này sử dụng hàm http.Head thuộc package net/http để kết nối server. Nếu thất bại (có lỗi), ta sẽ nghỉ 2^n giây trước khi thử lại với n tăng dần từ 0. Việc thử lại kết thúc khi thời gian thử quá 1 phút.
    func checkServer(url string) error { 
            const timeout = 1 * time.Minute 
            deadline := time.Now().Add(timeout) 
            for tries := 0; time.Now().Before(deadline); tries++ { 
                _, err := http.Head(url) 
                if err == nil { 
                    return nil // thành công 
                } 
                log.Printf("Server không trả lời do lỗi (%s); thử lại ...", err) 
                time.Sleep(time.Second << uint(tries)) 
            } 
            return fmt.Errorf("Server %s không phản hồi sau %s phút!", url, timeout)  
        }
    
    • Dòng 24 khai báo hằng số timeout là khoảng thời gian thử tối đa: 1 phút.
    • Dòng 25 khai báo biến deadline là thời điểm kết thúc việc thử kết nối. Hàm time.Now() sẽ trả về biến kiểu cấu trúc Time lưu giữ giá trị thời gian là thời điểm lúc gọi nó, còn hàm Add sẽ trả về thời điểm sau thời điểm lưu trong biến đó khoảng thời gian nêu trong tham số. Kết quả dòng 25 sẽ cho giá trị biến deadline là thời điểm 1 phút sau thời điểm thực thi dòng này.
    • Dòng 26-33 là vòng for thực hiện việc thử kết nối lại nếu lỗi. Biến tries tăng dần từ 0 qua mỗi lần thử. Sau mỗi lần thử, có lỗi và ngưng thực thi một khoảng thời gian, Go kiểm tra xem thời điểm lúc đó có trước thời điểm nêu ở deadline hay không. Nếu đúng, nghĩa là chưa đến 1 phút, vòng lặp sẽ thực thi tiếp.
    • Dòng 27 kết nối server qua http.Head của package net.http với tham số url là địa chỉ của server. Hàm trả về kết quả và lỗi nếu có. Ở đây chỉ kiểm tra hoạt động nên ta quan tâm lỗi và tạm thời bỏ qua nội dung trả về.
    • Dòng 28-30 là lệnh if kiểm tra xem có lỗi không. Nếu không lỗi, tứcerr = nil, thì trả về ngay nil.
    • Dòng 31 thông báo lỗi ra màn hình. Ở đây dùng hàm log.Printf của package log để in ra do nó có kèm theo thời gian để chúng ta dễ hiểu hơn.
    • Dòng 32 yêu cầu tạm ngưng thực thi thông qua time.Sleep với tham số là số giây cần nghỉ. Biểu thức time.Second << tries sẽ có giá trị là 2^tries giây. Mục đích sau mỗi lần thực thi tăng thời gian nghỉ là do nếu server bị lỗi gì đó thì việc kết nối liên tục không mang lại hiệu quả mà còn khiến server trở nên quá tải nên cần dãn thời gian kiểm tra kết nối ra.
    • Dòng 34 trả lỗi, là thông báo thất bại hoàn toàn việc kết nối đến server. Package fmt có hàm ErrorErrorf để giúp chúng ta tạo 1 giá trị kiểu error từ một chuỗi.

    Go không xử lý khi url thiếu http:// nên tôi tận dụng lỗi này để demo cách xử lý này.

    • Dừng chủ động: Nếu đã xác định là lỗi không thể chờ hoặc quá timeout như ở 2/, chúng ta cần thông báo lỗi và ngưng chương trình một cách chủ động với hàm os.Exit(<mã lỗi>) thuộc package os. Nhưng trước đó cần dùng log.Printf để in lỗi ra. Việc ngưng chương trình nên thực hiện ở hàm main còn các hàm khác chỉ nên chuyển lại lỗi về main. Ví dụ main ở trên có thể viết lại như sau:
    func main() { 
          if len(os.Args) < 2 { 
              fmt.Println("Vui lòng thêm URL server cần check!") 
              return 
          } 
         
          if err := checkServer(os.Args[1]); err != nil { 
              log.Printf("Server đang ngưng hoạt động: %s", err) 
              os.Exit(404) 
          } 
          fmt.Println("Server đang hoạt động!")     
      }
    
    • Ghi nhận lỗi: Trong một số trường hợp, khi gặp lỗi, chúng ta log lại để biết nhưng vẫn tiếp tục xử lý các lệnh khác nếu lỗi là không quá nghiêm trọng.

    • Bỏ qua lỗi: Trong một số tình huống, lỗi có hay không cũng chẳng ảnh hưởng gì nên chúng ta có thể bỏ qua việc xử lý.

    Chúng ta cần đọc kỹ thông tin của hàm trước khi sử dụng để quyết định nên xử lý lỗi xảy ra như thế nào. Thông thường, khi dùng if else xử lý lỗi, chúng ta sẽ để phần xử lý lỗi trước ở mệnh đề if và trong trường hợp return khi có lỗi thì phần thành công sẽ xử lý ở bên ngoài if sau đó mà không nên để ở mệnh đề else nữa.

    Panic và recover

    Panicrecover là 2 hàm có sẵn của Go thường dùng trong trường hợp xử lý lỗi thứ 3 ở trên: dừng chủ động. Ở ví dụ trên chúng ta dùng os.Exit(). Tuy nhiên hàm os.Exit() có đặc điểm là dừng ngay chương trình, các hàm defer không được thực thi nên trừ những trường hợp đặc biệt, chúng ta không nên xài hàm os.Exit(). Panic là hàm nên dùng để thay thế trong trường hợp này. Panic nhận 1 tham số là kiểu interface, thường chúng ta truyền giá trị biến error.

    Khi panic() được gọi, hàm gọi nó sẽ ngưng thực hiện tiếp, tiến hành thực hiện các hàm defer nếu có. Đến lượt hàm gọi hàm này thực hiện tương tự cho đến khi đến hàm main. Lúc này main cũng sẽ ngưng thực thi, thực hiện các hàm defer nếu có rồi kết thúc chương trình. Kết thúc chương trình với panic sẽ xuất hiện chuỗi lỗi ở Command Prompt hay Terminal nên để tránh vấn đề đó chúng ta dùng kết hợp với recover.

    Recover là cơ chế cho phép giành lại quyền điều khiển logic xử lý cho trường hợp panic được gọi. Recover chỉ hoạt động khi được gọi trong hàm defer do nó chỉ có giá trị khi panic() được gọi và sau khi panic() được gọi thì các hàm defer được xử lý. Trường hợp bình thường hàm recover() cho giá trị nil.

    Chúng ta cùng khảo sát ví dụ sau để hiểu rõ hơn về panicrecover cũng như ôn lại khái niệm defer:

    package main 
    
    import "fmt" 
    
    func main() {  
        f() 
        fmt.Println("Returned normally from f.") 
    } 
        
    func f() { 
        defer func() { 
            if r := recover(); r != nil { 
                fmt.Println("Recovered in f", r)  
            } 
        }() 
        fmt.Println("Calling g.") 
        g(0) 
        fmt.Println("Returned normally from g.") 
    } 
        
    func g(i int) { 
        if i > 3 { 
            fmt.Println("Panicking!") 
            panic(fmt.Sprintf("%v", i)) 
        } 
        defer fmt.Println("Defer in g", i) 
        fmt.Println("Printing in g", i) 
        g(i + 1) 
    } 
    

    Kết quả thực thi chương trình panic này như hình sau:

    Kết quả thực thi chương trình panic

    Chúng ta cùng xem xét đoạn mã trên để hiểu tại sao logic chương trình lại chạy như trên:

    • Hàm main gọi hàm f ở dòng 6. Đến lượt f in thông tin gọi hàm g ở dòng 16 và gọi g(0) ở dòng 17. Do hàm vô danh trong f ở dòng 11-15 có defer nên hàm này chưa được gọi lúc này.
    • Hàm g chạy kiểm tra i > 3 ở dòng 22 và lúc này i = 0 nên không thực thi khối lệnh if. Tiếp theo Hàm g in chuỗi ở dòng 27 do dòng 26 là hàm có defer. Sau đó g(1) được gọi và lặp lại các bước xử lý ở 2/ cho đến khi i = 4. Đó là lý do ta thấy chỉ in ra chuỗi "Printing in g 3"
    • Khi i = 4, điều kiện if ở dòng 22 thõa nên khối lệnh ở dòng 23-24 được xử lý. Go in chuỗi "Panicking!" rồi tiến hành gọi panic với tham số là chuỗi số "4" (do i là 4).
    • Do panic được gọi, hàm g với tham số i là 4 ngưng hoạt động, gọi hàm đã defer nếu có nhưng không có hàm defer nào do hàm defer ở dòng 26 lúc này chưa được gọi đến. Tiếp theo là hàm g với i =3 được xử lý panic, lúc này nó gọi hàm đã defer ở dòng 26 rồi kết thúc hàm. Quá trình này diễn ra tiếp với hàm g(2), g(1) và g(0). Kết quả ta thấy các chuỗi in ra lần lượt là "Defer in g 3" đến "Defer in g 0"
    • Khi các hàm đệ quy g đã xử lý xong đến g(0), lúc này hàm f sẽ xử lý panic. Lúc này hàm vô danh được gọi. Hàm recover() trả về chuỗi "4" nên khác nil và r sẽ có giá trị là "4" như kết quả in ra là "Recovered in f 4".
    • Do f đã được xử lý recover nên lúc này hàm main xử lý bình thường và in ra chuỗi ở dòng 7 rồi kết thúc chương trình. Nếu để ý các bạn sẽ thấy là chuỗi ở dòng 18 không hề được in ra bởi vì f xử lý panic tại lúc gọi g(0) nên f sẽ đóng sau khi gọi hàm đã defer chứ dòng 18 không bao giờ được gọi đến nữa.

    Điều gì xảy ra nếu như ta bỏ defer ở dòng 11? Lúc này khi main gọi f, f thực hiện ngay hàm vô danh và do lúc này recover() trả về nil nên dòng 13 không được thực hiện. Và khi f xử lý panic thì nó đơn giản đóng hàm do không có hàm defer rồi chuyển xử lý panic cho main. Hàm main ngưng chương trình với xử lý panic như bên dưới:

    xử lý panic

    Trong bài tới chúng ta sẽ cùng tìm hiểu về phương thức (methods).

    Tóm tắt

    • Go đề xuất sử dụng giá trị trả về cuối cùng của hàm để báo giá trị lỗi, thường là biến ok với giá trị true nếu không có lỗi.
    • Dùng biến kiểu error nếu muốn mô tả chi tiết lỗi.
    • Có 5 cách xử lý lỗi phổ biến: chuyển lỗi, thử lại, dừng chủ động, ghi nhận lỗi và bỏ qua lỗi.
    • Dùng panicrecover để xử lý cho tình huống dừng chủ động cần xử lý defer.


Có thể bạn cũng quan tâm

.
DMCA.com Protection Status