Kinh nghiệm xử lý lỗi trong Golang

Kinh nghiệm xử lý lỗi trong Golang

Chào các bác,

Xử lý lỗi trong Golang là một vấn đề gây nhiều tranh cãi vì thực ra nó... khá chuối! Một số người thích nó, nhiều người khác lại không, nhất là khi phần lớn anh em coder tới với Go từ những ngôn ngữ nơi mà try... catch... rồi exception là một cái gì đó rất phổ thông. Thay vì tranh cãi xem cách tiếp cận của Go có phải là ngon nhất không, tôi muốn tập trung chia sẻ một vài kinh nghiệm cá nhân của mình trong việc xử lý lỗi trong Go.

Điều đầu tiên cần nhớ: Panic không có gì là xấu cả!

Thật vậy, nhiều người cứ bảo rằng các bác không được dùng panic nhưng panic được sinh ra không phải để làm cảnh (mặc dù đúng là đừng nên dùng panic quá nhiều nhé).

Một trong số những trường hợp các bác nên dùng panic là khi các bác mới học Go và tập trung vào các khía cạnh của ngôn ngữ này thay vì xử lý lỗi. Trong trường hợp này, code của các bác nên hiển thị một cách rõ ràng và tường tận lỗi khi nó xảy ra và panic trở nên rất hữu dụng.

data, err := ioutil.ReadFile("file.txt")
if err != nil {
  panic(err)
}

Khi các bác chạy code kiểu như này trên local thay vì triển khai trên production thì nhìn ra lỗi ở đâu và xử lý thủ công là cực kỳ cần thiết. Mấu chốt ở đây là kể cả khi các bác không dùng panic hay có ý định xử lý lỗi thì việc có panic ở đây cũng tốt hơn việc không biết chuyện gì đang xảy ra hay tại sao biến bị rỗng...

Một trường hợp khác là khi lỗi của code là lỗi logic hoặc lỗi của thư viện bên ngoài. Những lỗi kiểu như này có đặc điểm là chúng tới từ những đoạn code sai tòe loe mà không thể phục hồi được. Kiểu gì thì các bác cũng thấy mấy thể loại như này nhưng lại chẳng mấy khi để ý. Ví dụ như khi các bác truy cập một phần tử trong slice nhưng với index lớn hơn chiều dài của slice ấy chẳng hạn.

sl := []int{1, 2, 3, 5}
fmt.Println(sl[4])

Tất nhiên cách chính thống mà chúng ta nên làm là luôn kiểm tra lỗi khi truy cập phần tử của slice. Đoạn code trên có thể thấy rõ đây là lỗi logic nên nó sẽ panic. Mặc dù các bác chắc chẳng bao giờ tạo ra cái slice ngu học kiểu như trên, sẽ có lúc ai đó tạo ra một kiểu dữ liệu dạng danh sách và mắc phải lỗi ngớ ngẩn này một cách ngẫu nhiên. Khi ấy panic phải xảy ra và chúng ta phải thấy nó.

Một trường hợp có liên quan tới trường hợp kể trên là khi các bác thực sự muốn app của mình crash khi một loại lỗi cụ thể xảy ra. Ví dụ như khi đọc các HTML templates trước khi khởi động một web server, nếu template không hợp lệ thì web server không hoạt động được, nên việc cho server crash luôn khi không đọc được template là điều cần làm.

t, err := template.ParseFiles("template.html")
if err != nil {
  panic(err)
}

Tất nhiên có nhiều cách khác hay hơn để xử lý khi trường hợp này xảy ra, nhưng sử dụng panic theo tôi nghĩ không phải là một vấn đề lớn vì chúng ta thực sự muốn con server này dừng và báo lỗi.

Trong trường hợp các bác không cảm thấy tự tin lắm thì có thể kiểm điểm lại xem có ông nào tính viết code để khôi phục server khi lỗi kiểu này xảy ra hay không? Thực ra thì có thể có trường hợp như thế thật, nhất là khi app đã được đưa vào hoạt động lâu rồi và không đọc được một vài HTML template là lỗi không đáng bận tâm bằng việc duy trì web server chạy cho đến khi có bản fix lỗi. Khi ấy ta không nên dùng panic, nhưng nếu câu trả lời là không thì panic nên được dùng.

Trong thực tế, một app Golang khi dược deploy lên production bằng docker container quản lý thông qua kubernetesm, việc panic để làm server crash khi có lỗi là một điều rất nên làm. Đó là bởi vì kubernetes sẽ tự động retry khi có lỗi khi deploy và những pod có lỗi thì sẽ không thể thay thế các pod đang chạy bình thường. Nó cho phép các bác vào đọc lỗi của các container trong pod để sửa và deploy lại.

Điều thứ hai cần nhớ là xử lý lỗi khi các bác có thể làm được ngay

Cái này không đùa đâu nhé. Một ví dụ cụ thể đối với nhiều con web server là authentication. Giả sử một người dùng cố gắng log in vào và các bác truy vấn người dùng bằng địa chỉ email. Khả năng cao là các bác sẽ cần phân biệt được lỗi từ việc không thể tìm được người dùng với email tương ứng, và việc database thực ra bị chết không phản hồi. Nếu như vấn đề là ở vế trước, không tìm được người dùng có email tương ứng, các bác chỉ đơn giản là trả về lỗi cho người dùng bảo họ rằng không có email ấy tồn tại trong database. Nhưng nếu nó là ở vế sau, và có thể cả nhiều vế khác nữa, việc xử lý sẽ tốn thời gian và chi tiết hơn.

Các bác thực ra chỉ muốn tạo một cái form để login nhưng rồi lại phải đi xử lý tất cả các lỗi tiềm tàng có thể xảy ra. Nghe thật là khó chịu khi deadline ngập mồm, mà Go thì xử lý lỗi thủ công nhiều. Theo kinh nghiệm của tôi thì cứ khi có lỗi các bác xác định nó là gì và xử lý ngay thì sẽ giảm được rất nhiều effort và bug thực ra sẽ ít xảy ra hơn. Nhược điểm của Go lại chính là ưu điểm của nó, nhưng sẽ cần nhiều công sức.

Điều thứ ba là các bác có thể trì hoãn việc check lỗi

Trì hoãn, hay còn gọi là "defer", là cách chúng ta tạm thời bỏ qua việc kiểm tra lỗi để làm một việc quan trọng hơn và báo lỗi sau cùng. Cái này không có nghĩa là tôi bảo các bác dùng từ khóa defer của Golang nhé.

Một ví dụ cho trì hoãn là khi các bác dùng method rows.Next() của package database/sql. Bởi vì method này không trả về lỗi, nó cho phép các bác đọc tất cả các hàng dữ liệu và kiểm tra lỗi sau cùng khi vòng lặp kết thúc. Dưới đây là code mẫu so sánh cách method Next hiện đang hoạt động và cách nó có thể hoạt động khi nó trả lỗi ngay tức thì. Các bác xem cái nào ngon hơn?

var rows sql.Rows

// 1. Cách mà method Next có sẵn trong thư viện sql
for rows.Next() {
  err := rows.Scan(...)
  if err != nil {
    // báo lỗi khi scan hàng dữ liệu
  }
}
err := rows.Err()
if err != nil {
  // lỗi khi chuẩn bị hàng dữ liệu kế tiếp
}

// 2. Cách nó hoạt động khi một lỗi được trả về
var err error
for ok, err := rows.Next(); ok && err == nil; ok, err = rows.Next() {
  err := rows.Scan(...)
  if err != nil {
    // báo lỗi khi scan hàng dữ liệu
  }
}
if err != nil {
  // lỗi khi chuẩn bị hàng dữ liệu kế tiếp
}

Nhược điểm lớn nhất ở cách tiếp cận này là ai đó có thể quên không kiểm tra lỗi.

Cuối cùng đừng chỉ trả về lỗi, hãy wrap nó vào

Từ bản Go 1.13 thư viện chuẩn có thêm chức năng mà trước khi được xử lý bởi các package của bên thứ 3 đó là chức năng wrap error. Các bác có thể vào blog của Go đọc thêm chi tiết hơn tại đây (nhấn vào). Nói ngắn gọn thì các bác có thể gắn context vào bất cứ lỗi nào khiến việc debug dễ hơn. Chẳng hạn như khi các bác đang muốn mở một file sử dụng bolt DB. Thay vì trả về lỗi thuần túy, nay các bác "gói" nó vào để hiện thêm thông tin "error opening bolt db: <lỗi ban đầu>".

func Open(path string) (*DB, error) {
    db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 5 * time.Second})
    if err != nil {
        return nil, fmt.Errorf("error opening bolt db: %w", err)
    }
    return &DB{db}, nil
}

Giờ khi lỗi được in ra thì nó sẽ có nội dung kiểu như "error opening bolt db: timeout exceeded" thay vì chỉ là "timeout exceeded", làm cho lỗi trở nên dễ đọc hơn.

Một khi các bác quen với việc wrap error rồi thì việc debug sẽ dễ hơn cả trăm lần vì thường các bác sẽ biết rõ nơi nào trong code lỗi ấy xuất hiện. Thử tưởng tượng cùng là cái "timeout exceeded" ở trên mà không wrap nhưng lại xuất hiện ở mấy chỗ cùng lúc, các bác sẽ thấy việc wrap error là cần thiết. Cứ wrap vào là các bác sẽ hoàn thành được một nửa phần debug rồi!

Hy vọng sau bài viết này các bác sẽ có thêm kinh nghiệm xử lý lỗi trong Go. Hẹn các bác ở những bài viết kế tiếp!