Blog Go

Xử lý lỗi và Go

Andrew Gerrand
12 July 2011

Giới thiệu

Nếu bạn đã từng viết bất kỳ đoạn mã Go nào thì hẳn bạn đã gặp kiểu tích hợp sẵn error. Mã Go dùng các giá trị error để biểu thị một trạng thái bất thường. Ví dụ, hàm os.Open trả về một giá trị error khác nil khi nó không mở được tệp.

func Open(name string) (file *File, err error)

Đoạn mã sau dùng os.Open để mở một tệp. Nếu có lỗi xảy ra, nó gọi log.Fatal để in thông báo lỗi rồi dừng.

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f

Bạn có thể làm được rất nhiều việc trong Go chỉ với chừng đó hiểu biết về kiểu error, nhưng trong bài viết này chúng ta sẽ nhìn kỹ hơn vào error và thảo luận một số thực hành tốt khi xử lý lỗi trong Go.

Kiểu error

Kiểu error là một kiểu interface. Một biến error đại diện cho bất kỳ giá trị nào có thể tự mô tả chính nó dưới dạng chuỗi. Đây là khai báo của interface đó:

type error interface {
    Error() string
}

Kiểu error, giống như mọi kiểu tích hợp sẵn khác, được khai báo trước trong universe block.

Hiện thực error được dùng phổ biến nhất là kiểu errorString không export của gói errors.

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

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

Bạn có thể tạo một giá trị như vậy bằng hàm errors.New. Nó nhận một chuỗi, chuyển nó thành errors.errorString, rồi trả về dưới dạng một giá trị error.

// New returns an error that formats as the given text.
func New(text string) error {
    return &errorString{text}
}

Đây là cách bạn có thể dùng errors.New:

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // implementation
}

Người gọi truyền một đối số âm vào Sqrt sẽ nhận được một giá trị error khác nil (mà biểu diễn cụ thể là một giá trị errors.errorString). Người gọi có thể lấy chuỗi lỗi (“math: square root of…”) bằng cách gọi phương thức Error của error, hoặc đơn giản là in nó:

f, err := Sqrt(-1)
if err != nil {
    fmt.Println(err)
}

Gói fmt định dạng một giá trị error bằng cách gọi phương thức Error() string của nó.

Việc tóm tắt ngữ cảnh là trách nhiệm của hiện thực lỗi. Lỗi do os.Open trả về sẽ có dạng “open /etc/passwd: permission denied”, chứ không chỉ là “permission denied”. Lỗi do Sqrt của ta trả về lại thiếu thông tin về đối số không hợp lệ.

Để thêm thông tin đó, một hàm hữu ích là Errorf của gói fmt. Nó định dạng một chuỗi theo các quy tắc của Printf và trả nó về dưới dạng error được tạo bởi errors.New.

if f < 0 {
    return 0, fmt.Errorf("math: square root of negative number %g", f)
}

Trong nhiều trường hợp fmt.Errorf là đủ dùng, nhưng vì error là một interface, bạn có thể dùng các cấu trúc dữ liệu tùy ý làm giá trị lỗi, để cho phép người gọi kiểm tra chi tiết của lỗi.

Ví dụ, giả sử người gọi của chúng ta muốn khôi phục đối số không hợp lệ đã truyền vào Sqrt. Ta có thể hỗ trợ điều đó bằng cách định nghĩa một hiện thực lỗi mới thay vì dùng errors.errorString:

type NegativeSqrtError float64

func (f NegativeSqrtError) Error() string {
    return fmt.Sprintf("math: square root of negative number %g", float64(f))
}

Một người gọi tinh vi hơn khi đó có thể dùng type assertion để kiểm tra NegativeSqrtError và xử lý riêng, trong khi người gọi chỉ đơn giản truyền lỗi vào fmt.Println hay log.Fatal sẽ không thấy thay đổi gì về hành vi.

Làm thêm một ví dụ nữa, gói json định nghĩa kiểu SyntaxError mà hàm json.Decode trả về khi nó gặp lỗi cú pháp lúc phân tích một blob JSON.

type SyntaxError struct {
    msg    string // description of error
    Offset int64  // error occurred after reading Offset bytes
}

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

Trường Offset thậm chí không được hiển thị trong cách định dạng mặc định của lỗi, nhưng người gọi có thể dùng nó để thêm thông tin tệp và dòng vào thông báo lỗi:

if err := dec.Decode(&val); err != nil {
    if serr, ok := err.(*json.SyntaxError); ok {
        line, col := findLine(f, serr.Offset)
        return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
    }
    return err
}

(Đây là phiên bản đã được đơn giản hóa đôi chút của một đoạn mã thực trong dự án Camlistore.)

Interface error chỉ yêu cầu một phương thức Error; các hiện thực lỗi cụ thể có thể có thêm phương thức khác. Ví dụ, gói net trả về lỗi kiểu error, theo đúng thông lệ thường thấy, nhưng một số hiện thực lỗi của nó có thêm các phương thức được định nghĩa bởi interface net.Error:

package net

type Error interface {
    error
    Timeout() bool   // Is the error a timeout?
    Temporary() bool // Is the error temporary?
}

Mã phía client có thể kiểm tra net.Error bằng type assertion rồi phân biệt lỗi mạng tạm thời với lỗi vĩnh viễn. Ví dụ, một web crawler có thể ngủ rồi thử lại khi gặp lỗi tạm thời và bỏ cuộc trong các trường hợp khác.

if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
    time.Sleep(1e9)
    continue
}
if err != nil {
    log.Fatal(err)
}

Đơn giản hóa việc xử lý lỗi lặp đi lặp lại

Trong Go, xử lý lỗi là quan trọng. Thiết kế và thông lệ của ngôn ngữ khuyến khích bạn kiểm tra lỗi một cách tường minh ở đúng nơi chúng xảy ra (khác với thông lệ ở các ngôn ngữ khác là ném exception rồi đôi khi bắt chúng). Trong một số trường hợp điều này làm mã Go dài dòng, nhưng may mắn là có vài kỹ thuật bạn có thể dùng để giảm thiểu việc xử lý lỗi lặp lại.

Hãy xét một ứng dụng App Engine có một HTTP handler lấy một bản ghi từ datastore rồi định dạng nó bằng template.

func init() {
    http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

Hàm này xử lý các lỗi trả về từ datastore.Get và phương thức Execute của viewTemplate. Trong cả hai trường hợp, nó hiển thị cho người dùng một thông báo lỗi đơn giản với mã HTTP 500 (“Internal Server Error”). Lượng mã này trông còn có thể chấp nhận được, nhưng chỉ cần thêm vài HTTP handler nữa là bạn sẽ nhanh chóng có rất nhiều bản sao của cùng một đoạn xử lý lỗi.

Để giảm sự lặp lại này, ta có thể định nghĩa kiểu HTTP appHandler riêng, có thêm giá trị trả về error:

type appHandler func(http.ResponseWriter, *http.Request) error

Sau đó ta có thể đổi viewRecord để trả về lỗi:

func viewRecord(w http.ResponseWriter, r *http.Request) error {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return err
    }
    return viewTemplate.Execute(w, record)
}

Cách này đơn giản hơn phiên bản gốc, nhưng gói http không hiểu các hàm trả về error. Để khắc phục, ta có thể hiện thực phương thức ServeHTTP của interface http.Handler trên appHandler:

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

Phương thức ServeHTTP gọi hàm appHandler và hiển thị lỗi mà nó trả về (nếu có) cho người dùng. Lưu ý rằng receiver của phương thức, fn, là một hàm. (Go làm được điều đó!) Phương thức gọi hàm bằng cách gọi chính receiver trong biểu thức fn(w, r).

Giờ khi đăng ký viewRecord với gói http, ta dùng hàm Handle (thay vì HandleFunc) vì appHandler là một http.Handler (không phải http.HandlerFunc).

func init() {
    http.Handle("/view", appHandler(viewRecord))
}

Khi hạ tầng xử lý lỗi cơ bản này đã có, ta có thể làm nó thân thiện hơn. Thay vì chỉ hiển thị chuỗi lỗi, tốt hơn là đưa cho người dùng một thông báo lỗi đơn giản với mã HTTP phù hợp, đồng thời ghi đầy đủ lỗi đó vào App Engine developer console để phục vụ gỡ lỗi.

Để làm điều đó, ta tạo một struct appError chứa một error và một số trường khác:

type appError struct {
    Error   error
    Message string
    Code    int
}

Tiếp theo ta sửa kiểu appHandler để trả về các giá trị *appError:

type appHandler func(http.ResponseWriter, *http.Request) *appError

(Thông thường việc trả về kiểu cụ thể của một lỗi thay vì error là sai lầm, vì những lý do được thảo luận trong Go FAQ, nhưng ở đây lại là lựa chọn đúng vì ServeHTTP là nơi duy nhất nhìn thấy giá trị đó và dùng nội dung của nó.)

Và làm cho ServeHTTP của appHandler hiển thị Message của appError cho người dùng với Code HTTP chính xác, đồng thời ghi Error đầy đủ lên developer console:

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if e := fn(w, r); e != nil { // e is *appError, not os.Error.
        c := appengine.NewContext(r)
        c.Errorf("%v", e.Error)
        http.Error(w, e.Message, e.Code)
    }
}

Cuối cùng, ta cập nhật viewRecord theo chữ ký hàm mới và để nó trả về nhiều ngữ cảnh hơn khi gặp lỗi:

func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return &appError{err, "Record not found", 404}
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        return &appError{err, "Can't display record", 500}
    }
    return nil
}

Phiên bản này của viewRecord dài đúng bằng phiên bản gốc, nhưng giờ mỗi dòng đều có ý nghĩa cụ thể và chúng ta đang mang lại một trải nghiệm thân thiện hơn cho người dùng.

Và chưa dừng ở đó; ta còn có thể tiếp tục cải thiện việc xử lý lỗi trong ứng dụng. Một vài ý tưởng:

  • cho bộ xử lý lỗi một mẫu HTML đẹp,

  • làm việc gỡ lỗi dễ hơn bằng cách ghi stack trace vào phản hồi HTTP khi người dùng là quản trị viên,

  • viết một hàm dựng cho appError lưu stack trace để việc gỡ lỗi dễ hơn,

  • recover từ panic bên trong appHandler, ghi lỗi lên console ở mức “Critical”, đồng thời nói với người dùng rằng “đã xảy ra một lỗi nghiêm trọng.” Đây là một điểm chạm hay để tránh phơi bày cho người dùng những thông báo lỗi khó hiểu do lỗi lập trình gây ra. Xem bài viết Defer, Panic, and Recover để biết thêm chi tiết.

Kết luận

Xử lý lỗi đúng cách là một yêu cầu thiết yếu của phần mềm tốt. Bằng cách áp dụng các kỹ thuật được mô tả trong bài viết này, bạn sẽ có thể viết mã Go đáng tin cậy hơn và cô đọng hơn.

Bài tiếp theo: Go cho App Engine nay đã chính thức sẵn sàng
Bài trước: Hàm hạng nhất trong Go
Mục lục blog