[Golang nâng cao] Tìm hiểu về error trong Golang - P2

Post on: 2023-03-22 01:02:41 | in: Golang
Trong bài viết này, chúng tôi sẽ đề cập đến các chủ đề nâng cao liên quan đến error trong Go.
  • Đóng gói và giải gói lỗi
  • So sánh lỗi
  • Trích xuất kiểu cơ bản từ lỗi
  • Các hàm As và Is trong gói lỗi (errors package)
Vui lòng tham khảo liên kết dưới đây trước khi bắt đầu với các khái niệm cơ bản về lỗi trong Go.

https://golangbyexample.com/error-in-golang/

Bài viết đó sẽ đề cập đến những điều cơ bản về lỗi như:

  • Tổng quan về lỗi
  • Giao diện lỗi (Error interface)
  • Các cách khác nhau để tạo ra một lỗi.
  • Bỏ qua lỗi (Ignoring errors)

Đóng gói lỗi (Wrapping of error)

Trong Go, lỗi có thể đóng gói một lỗi khác.

Đóng gói lỗi (wrapping of error) là gì? Nó có nghĩa là tạo ra một hệ thống phân cấp lỗi, trong đó một trường hợp cụ thể của lỗi được đóng gói một lỗi khác và chính trường hợp cụ thể đó có thể được đóng gói trong một lỗi khác. Dưới đây là cú pháp để đóng gói một lỗi.

e := fmt.Errorf("... %w ...", ..., err, ...)

%w  chỉ thị Is được sử dụng để đóng gói lỗi. Hàm fmt.Errorf chỉ nên được gọi với một chỉ thị %w duy nhất. Hãy xem ví dụ sau.

package main

import (
"fmt"
)

type errorOne struct{}

func (e errorOne) Error() string {
return "Error One happended"
}

func main() {

 e1 := errorOne{}

 e2 := fmt.Errorf("E2: %w", e1)

 e3 := fmt.Errorf("E3: %w", e2)

 fmt.Println(e2)

 fmt.Println(e3)

}

Output

E2: Error One happended
E3: E2: Error One happended

Trong chương trình trên, chúng ta đã tạo ra một cấu trúc errorOne có một phương thức Error vì vậy nó triển khai giao diện error. Sau đó, chúng ta tạo ra một thể hiện của cấu trúc errorOne được đặt tên là e1. Tiếp theo, chúng ta đóng gói thể hiện e1 thành một lỗi khác e2 như sau:

e2 := fmt.Errorf("E2: %w", e1)

Sau đó, chúng ta đóng gói e2 vào e3 như dưới đây.

e3 := fmt.Errorf("E3: %w", e2)

Như vậy, chúng ta đã tạo ra một hệ thống phân cấp lỗi trong đó e3 đóng gói e2 và e2 đóng gói e1. Do đó, e3 cũng đóng gói e1 một cách truyền gián. Khi chúng ta in ra e2, nó cũng in ra lỗi từ e1 và đưa ra đầu ra.

E2: Error One happended

Khi chúng ta in ra e3, nó sẽ in ra lỗi từ e2 cũng như e1 và đưa ra đầu ra.

E3: E2: Error One happended

Bây giờ câu hỏi đặt ra là tại sao chúng ta cần đóng gói lỗi. Để hiểu điều này, hãy xem một ví dụ.

package main

import (
"fmt"
)

type notPositive struct {
num int
}

func (e notPositive) Error() string {
return fmt.Sprintf("checkPositive: Given number %d is not a positive number", e.num)
}

type notEven struct {
num int
}

func (e notEven) Error() string {
return fmt.Sprintf("checkEven: Given number %d is not an even number", e.num)
}

func checkPositive(num int) error {
if num < 0 {
  return notPositive{num: num}
}
return nil
}

func checkEven(num int) error {
if num%2 == 1 {
  return notEven{num: num}
}
return nil
}

func checkPostiveAndEven(num int) error {
if num > 100 {
  return fmt.Errorf("checkPostiveAndEven: Number %d is greater than 100", num)
}

 err := checkPositive(num)
if err != nil {
  return err
}

 err = checkEven(num)
if err != nil {
  return err
}

 return nil
}

func main() {
num := 3
err := checkPostiveAndEven(num)
if err != nil {
  fmt.Println(err)
} else {
  fmt.Println("Givennnumber is positive and even")
}

}

Output

checkEven: Given number 3 is not an even number

Trong chương trình trên, chúng ta có một hàm checkPostiveAndEven kiểm tra xem một số có phải là số chẵn dương hay không. Hàm này gọi đến hàm checkEven để kiểm tra số đó có phải là số chẵn hay không. Sau đó, nó gọi đến hàm checkPositive để kiểm tra số đó có phải là số dương hay không. Nếu một số không phải là số chẵn dương, lỗi sẽ được ném ra.

Trong chương trình trên, không thể xác định được trace stack của lỗi. Chúng ta biết rằng lỗi này xuất phát từ hàm checkEven dựa trên đầu ra trên. Nhưng hàm nào đã gọi đến hàm checkEven thì không rõ ràng từ lỗi. Đây là lúc đóng gói lỗi trở nên quan trọng. Điều này trở nên hữu ích hơn khi dự án lớn và có rất nhiều hàm gọi nhau. Hãy viết lại chương trình bằng cách đóng gói lỗi.

package main

import (
"fmt"
)

type notPositive struct {
num int
}

func (e notPositive) Error() string {
return fmt.Sprintf("checkPositive: Given number %d is not a positive number", e.num)
}

type notEven struct {
num int
}

func (e notEven) Error() string {
return fmt.Sprintf("checkEven: Given number %d is not an even number", e.num)
}

func checkPositive(num int) error {
if num < 0 {
  return notPositive{num: num}
}
return nil
}

func checkEven(num int) error {
if num%2 == 1 {
  return notEven{num: num}
}
return nil
}

func checkPostiveAndEven(num int) error {
if num > 100 {
  return fmt.Errorf("checkPostiveAndEven: Number %d is greater than 100", num)
}

 err := checkPositive(num)
if err != nil {
  return fmt.Errorf("checkPostiveAndEven: %w", err)
}

 err = checkEven(num)
if err != nil {
  return fmt.Errorf("checkPostiveAndEven: %w", err)
}

 return nil
}

func main() {
num := 3
err := checkPostiveAndEven(num)
if err != nil {
  fmt.Println(err)
} else {
  fmt.Println("Given number is positive and even")
}

}

Output

checkPostiveAndEven: checkEven: Given number 3 is not an even number

Chương trình trên giống như chương trình trước đó, chỉ khác là trong hàm checkPostiveAndEven, chúng ta đóng gói lỗi như sau.

fmt.Errorf("checkPostiveAndEven: %w", err)

Vì vậy, đầu ra rõ ràng hơn và lỗi có thông tin hữu ích hơn. Đầu ra rõ ràng cho biết chuỗi gọi hàm.

checkPostiveAndEven: checkEven: Given number 3 is not an even number

Giải nén một lỗi (Unwrap an error)

Ở phần trên, chúng ta đã tìm hiểu về việc bọc lỗi (wrapping the error). Cũng có thể giải nén lỗi (unwrap the error). Hàm Unwrap trong gói errors được sử dụng để giải nén một lỗi. Dưới đây là cú pháp của hàm.

func Unwrap(err error) error

Nếu err bao gói một lỗi khác, thì lỗi bao gói sẽ được trả về; nếu không, hàm Unwrap sẽ trả về nil.

Hãy xem một chương trình để minh họa điều này.

import (
    "errors"
    "fmt"
)
type errorOne struct{}
func (e errorOne) Error() string {
    return "Error One happened"
}
func main() {
    e1 := errorOne{}
    e2 := fmt.Errorf("E2: %w", e1)
    e3 := fmt.Errorf("E3: %w", e2)
    fmt.Println(errors.Unwrap(e3))
    fmt.Println(errors.Unwrap(e2))
    fmt.Println(errors.Unwrap(e1))
}

Output

E2: Error One happended
Error One happended

Trong chương trình trên, chúng ta đã tạo một struct errorOne có một phương thức Error do đó struct này cài đặt giao diện error. Sau đó, chúng ta tạo một instance của struct errorOne tên là e1. Sau đó, chúng ta bọc instance e1 vào một lỗi khác e2 như sau:

e2 := fmt.Errorf("E2: %w", e1)

Và cuối cùng, chúng ta đã bọc e2 vào e3 như sau:

e3 := fmt.Errorf("E3: %w", e2)

Do đó

fmt.Println(errors.Unwrap(e3))

sẽ trả về lỗi được bao bọc là e2, vì e3 bao bọc e2 và đầu ra sẽ là:

E2: Error One happened

Khi

fmt.Println(errors.Unwrap(e1))

sẽ xuất ra giá trị nil vì e1 không bao bọc lỗi nào.

{nil}

Kiểm tra hai lỗi có bằng nhau hay không

Đầu tiên, điều gì được hiểu bởi "độ bằng nhau" của error? Như bạn đã biết rằng lỗi được đại diện bởi giao diện error trong Go. Trong Go, hai giao diện được coi là bằng nhau nếu:

  • Cả hai tham chiếu đến cùng một loại dữ liệu cơ bản
  • Giá trị cơ bản là bằng nhau (hoặc cả hai đều là nil)

Vì vậy, hai điểm trên áp dụng cho việc so sánh lỗi. Có hai cách để kiểm tra xem các lỗi đã cho có bằng nhau hay không.

Sử dụng toán tử bằng nhau (==)

Có thể sử dụng toán tử == để so sánh hai lỗi (error) trong golang.

Sử dụng hàm Is để so sánh hai lỗi trong Go

https://golang.org/pkg/errors/ .  Hàm Is được ưu tiên sử dụng hơn toán tử == bởi vì nó kiểm tra tính bằng nhau bằng cách giải quyết lỗi đầu tiên theo thứ tự và so sánh với lỗi mục tiêu ở mỗi bước unwrap. Chúng ta sẽ thấy một ví dụ sau để hiểu tại sao nó được ưu tiên sử dụng hơn. Dưới đây là cú pháp của hàm Is.

func Is(err, target error) bool

Hãy xem một ví dụ

package main
import (
    "errors"
    "fmt"
)
type errorOne struct{}
func (e errorOne) Error() string {
    return "Error One happended"
}
func main() {
    var err1 errorOne
    err2 := do()
    if err1 == err2 {
        fmt.Println("Equality Operator: Both errors are equal")
    }
    if errors.Is(err1, err2) {
        fmt.Println("Is function: Both errors are equal")
    }
}
func do() error {
    return errorOne{}
}

Output

Equality Operator: Both errors are equal
Is function: Both errors are equal

Trong chương trình trên, chúng ta đã tạo ra một struct errorOne định nghĩa phương thức Error và do đó thực thi interface error. Chúng ta tạo biến err1 là một instance của struct errorOne. Chúng ta cũng tạo một hàm do() ném ra một lỗi kiểu errorOne và lỗi đó được lưu vào biến err2 trong hàm main.

Sau đó chúng ta so sánh hai lỗi bằng cách:

  • Sử dụng toán tử == (equality operator)
err1 == err2
  • Sử dụng hàm Is của gói errors
errors.Is(err1, err2)

Cả hai phương pháp đều đưa ra đầu ra đúng là lỗi bằng nhau vì:

  • Cả hai lỗi đều tham chiếu đến cùng một kiểu cơ sở là errorOne.
  • Cả hai lỗi đều có giá trị cơ sở giống nhau.

Như đã đề cập ở trên, việc sử dụng hàm Is ưu tiên hơn việc sử dụng toán tử bằng vì nó kiểm tra sự bằng nhau bằng cách giải phóng các lỗi một cách tuần tự và so sánh với lỗi mục tiêu ở mỗi bước giải phóng. Chúng ta sẽ xem một ví dụ sau đây để hiểu rõ hơn vì sao nên sử dụng hàm Is.

package main

import (
"errors"
"fmt"
)
type errorOne struct{}
func (e errorOne) Error() string {
return "Error One happended"
}
func main() {
err1 := errorOne{}
err2 := do()
if err1 == err2 {
  fmt.Println("Equality Operator: Both errors are equal")
} else {
  fmt.Println("Equality Operator: Both errors are not equal")
}
if errors.Is(err2, err1) {
  fmt.Println("Is function: Both errors are equal")
}
}
func do() error {
return fmt.Errorf("E2: %w", errorOne{})
}

Output

Equality Operator: Both errors are not equal
Is function: Both errors are equal

Chương trình trên gần giống với chương trình trước, khác biệt chỉ là trong hàm do(), chúng ta wrap lỗi thành lỗi mới.

return fmt.Errorf("E2: %w", errorOne{})
  • Đầu ra của toán tử bằng
Equality Operator: Both errors are not equal
  • Trong khi đó hàm Is sẽ trả về
Is function: Both errors are equal

Điều này bởi vì giá trị trả về của err2 bao bọc một instance của errorOne, điều này không được bắt bởi toán tử == nhưng được bắt bởi hàm Is.

Lấy ra lỗi cơ bản (underlying error) từ một lỗi được biểu diễn bởi interface error.

Có hai cách để lấy kiểu cơ sở của lỗi từ giá trị đó:

Sử dụng cú pháp .({type})

Nếu khẳng định thành công thì nó sẽ trả về lỗi tương ứng, nếu không thì sẽ panic. Dưới đây là cú pháp.

err := err.({type})

Nên sử dụng biến ok để tránh panic trong trường hợp khẳng định thất bại. Dưới đây là cú pháp cho việc đó. Biến ok sẽ được thiết lập thành true nếu loại cơ sở của lỗi chính xác.

err, ok := err.({type})

Sử dụng hàm As của gói errors

https://golang.org/pkg/errors/ . Dùng hàm As là tốt hơn là sử dụng kiểu .({type}) assert vì nó kiểm tra sự khớp bằng cách mở gói lỗi đầu tiên theo thứ tự tuần tự và khớp nó với lỗi mục tiêu ở mỗi bước mở gói. Dưới đây là cú pháp của hàm As.

func As(err error, target interface{}) bool

Đối với As function, nó sẽ tìm kiếm lỗi đầu tiên trong đối số đầu tiên mà có thể phù hợp với target. Một khi tìm thấy sự phù hợp, nó sẽ gán giá trị lỗi đó cho target.

Hãy xem một ví dụ

package main

import (
"errors"
"fmt"
"os"
)

func main() {

err := openFile("non-existing.txt")

if e, ok := err.(*os.PathError); ok {
  fmt.Printf("Using Assert: Error e is of type path error. Path: %v\n", e.Path)
} else {
  fmt.Println("Using Assert: Error not of type path error")
}

var pathError *os.PathError
if errors.As(err, &pathError) {
  fmt.Printf("Using As function: Error e is of type path error. Path: %v\n", pathError.Path)
}
}

func openFile(fileName string) error {
_, err := os.Open("non-existing.txt")
if err != nil {
  return err
}
return nil
}

Output:

Using Assert: Error e is of type path error. Path: non-existing.txt
Using As function: Error e is of type path error. Path: non-existing.txt

Trong chương trình trên, chúng ta có một hàm openFile trong đó chúng ta đang cố gắng mở một kiểu không tồn tại do đó nó sẽ gây ra lỗi. Sau đó, chúng ta xác định lỗi bằng hai cách:

  • Sử dụng toán tử . assert. Biến ok sẽ được đặt thành true nếu loại lỗi bên dưới là *os.PathError, nếu không nó sẽ được đặt thành false.
e,ok := err.(*os.PathError); ok
  • Sử dụng hàm As của gói errors
errors.As(err, &pathError)

Cả hai cách đều xác nhận đúng rằng lỗi là kiểu *os.PathError, bởi vì lỗi được trả về bởi hàm openFile là kiểu *os.PathError.

Như chúng ta đã đề cập ở trên, sử dụng As là tốt hơn so với sử dụng toán tử .({type}) assert vì nó kiểm tra sự khớp bằng cách giải phóng lỗi đầu tiên một cách tuần tự và so khớp nó với lỗi mục tiêu tại mỗi bước giải phóng. Hãy xem một ví dụ để hiểu điều đó.

import (
"errors"
"fmt"
"os"
)

func main() {
var pathError *os.PathError
err := openFile("non-existing.txt")

if e, ok := err.(*os.PathError); ok {
  fmt.Printf("Using Assert: Error e is of type path error. Error: %v\n", e)
} else {
  fmt.Println("Using Assert: Error not of type path error")
}

if errors.As(err, &pathError) {
  fmt.Printf("Using As function: Error e is of type path error. Error: %v\n", pathError)
}
}

func openFile(fileName string) error {
_, err := os.Open("non-existing.txt")
if err != nil {
  return fmt.Errorf("Error opening: %w", err)
}
return nil
}

Output:

Using Assert: Error not of type path error
Using As function: Error e is of type path error. Error: open non-existing.txt: no such file or directory

Chương trình trên gần giống với chương trình trước đó, chỉ khác ở chỗ trong hàm openFile chúng ta cũng bọc lỗi vào một lỗi khác.

return fmt.Errorf("Error opening: %w", err)
  • Việc sử dụng . assert xuất ra
Using Assert: Error not of type path error
  • Trong khi hàm As xuất ra
Using As function: Error e is of type path error. Error: open non-existing.txt: no such file or directory

Điều này là do lỗi được trả về bởi hàm openFile bao bọc lỗi *os.Patherror mà dot (‘.’) assert không bắt được nhưng được As function bắt được.

Tổng kết

Đó là tất cả những điều cần biết về chủ đề nâng cao về error trong Golang. Hy vọng bạn đã thích bài viết này. Vui lòng chia sẻ phản hồi / cải tiến / sai sót trong phần bình luận.

 
 
 
 
 
Tag: go Golang nâng cao