Phần 7: Xử lý lỗi và phục hồi từ panic trong Gee Framework
Bài viết này là phần thứ bảy (cũng là phần cuối cùng) trong chuỗi bài về việc xây dựng web framework Gee từ đầu bằng ngôn ngữ Go.
Triển khai cơ chế xử lý lỗi
panic
Trong Go, phương pháp xử lý lỗi phổ biến nhất là trả về một error, và người gọi (caller) sẽ quyết định cách xử lý sau đó. Đây là cách tiếp cận chính của Go đối với hầu hết các tình huống lỗi có thể xảy ra.
Tuy nhiên, đối với những lỗi nghiêm trọng không thể khôi phục, Go cung cấp cơ chế panic. Khi panic xảy ra, chương trình sẽ dừng thực thi ngay lập tức.
Có hai cách để panic xảy ra:
- Chủ động gọi hàm
panic() - Lỗi runtime như truy cập ngoài phạm vi mảng, chia cho 0, v.v.
Ví dụ về việc chủ động gọi panic:
// hello.go
func main() {
fmt.Println("before panic")
panic("crash")
fmt.Println("after panic") // dòng này không bao giờ được thực thi
}
Kết quả:
$ go run hello.go
before panic
panic: crash
goroutine 1 [running]:
main.main()
~/go_demo/hello/hello.go:7 +0x95
exit status 2
Ví dụ về panic do lỗi runtime:
// hello.go
func main() {
arr := []int{1, 2, 3}
fmt.Println(arr[4]) // lỗi: index out of range
}
Kết quả:
$ go run hello.go
panic: runtime error: index out of range [4] with length 3
defer
Khi panic xảy ra, chương trình sẽ dừng thực thi ngay lập tức, nhưng trước khi thoát hoàn toàn, Go sẽ thực thi tất cả các hàm đã được defer trong goroutine hiện tại. Đây là cơ chế quan trọng giúp chúng ta có thể dọn dẹp tài nguyên hoặc xử lý lỗi trước khi chương trình kết thúc.
// hello.go
func main() {
defer func() {
fmt.Println("defer func được gọi")
}()
arr := []int{1, 2, 3}
fmt.Println(arr[4])
}
Kết quả:
$ go run hello.go
defer func được gọi
panic: runtime error: index out of range [4] with length 3
Bạn có thể thấy rằng mặc dù có lỗi, hàm defer vẫn được thực thi trước khi chương trình kết thúc. Nếu bạn defer nhiều tác vụ trong cùng một hàm, chúng sẽ được thực thi theo thứ tự ngược lại, nghĩa là tác vụ defer cuối cùng sẽ được thực thi đầu tiên.
recover
Go cung cấp hàm recover() để “bắt” và xử lý panic. Hàm này chỉ có tác dụng khi được gọi trong một hàm defer. Khi recover() bắt được panic, chương trình sẽ tiếp tục thực thi bình thường thay vì kết thúc.
// hello.go
func test_recover() {
defer func() {
fmt.Println("defer func được gọi")
if err := recover(); err != nil {
fmt.Println("đã recover thành công:", err)
}
}()
arr := []int{1, 2, 3}
fmt.Println(arr[4])
fmt.Println("sau panic") // dòng này không được thực thi
}
func main() {
test_recover()
fmt.Println("sau recover") // dòng này được thực thi vì panic đã được xử lý
}
Kết quả:
$ go run hello.go
defer func được gọi
đã recover thành công: runtime error: index out of range [4] with length 3
sau recover
Qua ví dụ trên, chúng ta thấy:
- Khi
panicxảy ra, luồng thực thi chuyển ngay đến hàmdefer - Hàm
recover()bắt được lỗi và chương trình tiếp tục thực thi - Dòng “sau panic” không được in ra vì nó nằm sau điểm
panic - Dòng “sau recover” được in ra vì chương trình đã phục hồi và tiếp tục thực thi
Cơ chế xử lý lỗi trong Gee
Đối với một web framework, việc xử lý lỗi là vô cùng quan trọng. Nếu không có cơ chế xử lý lỗi phù hợp, một lỗi nhỏ trong handler có thể làm crash toàn bộ server, khiến tất cả các request khác không được phục vụ.
Ví dụ, xem xét đoạn code sau:
func main() {
r := gee.New()
r.GET("/panic", func(c *gee.Context) {
names := []string{"geektutu"}
c.String(http.StatusOK, names[100]) // lỗi: index out of range
})
r.Run(":9999")
}
Nếu người dùng truy cập /panic, handler sẽ gây ra lỗi khi cố gắng truy cập phần tử thứ 100 của mảng chỉ có 1 phần tử. Nếu không có cơ chế xử lý lỗi, server có thể bị crash.
Lưu ý quan trọng: Thực tế, ngay cả khi không có middleware Recovery, thư viện chuẩn
net/httpcủa Go đã có cơ chế xử lý panic cơ bản, nên server không hoàn toàn bị crash. Tuy nhiên, request gây ra panic sẽ không nhận được phản hồi, và không có thông tin hữu ích nào được ghi lại để debug.
Để giải quyết vấn đề này, chúng ta sẽ triển khai một middleware Recovery đơn giản nhưng hiệu quả. Middleware này sẽ:
- Bắt tất cả các panic xảy ra trong quá trình xử lý request
- Ghi log chi tiết về lỗi và stack trace để dễ dàng debug
- Trả về phản hồi “Internal Server Error” cho client thay vì để kết nối bị đóng
Chúng ta sẽ tận dụng cơ chế middleware đã triển khai trước đó để thêm tính năng xử lý lỗi vào framework Gee.
Triển khai middleware Recovery
Thêm file mới gee/recovery.go với nội dung sau:
func Recovery() HandlerFunc {
return func(c *Context) {
defer func() {
if err := recover(); err != nil {
message := fmt.Sprintf("%s", err)
log.Printf("%s\n\n", trace(message))
c.Fail(http.StatusInternalServerError, "Internal Server Error")
}
}()
c.Next()
}
}
Middleware Recovery hoạt động rất đơn giản:
- Sử dụng
deferđể đảm bảo hàm phục hồi được gọi ngay cả khi có panic - Trong hàm defer, gọi
recover()để bắt panic (nếu có) - Nếu có panic, ghi log thông tin lỗi và stack trace
- Trả về mã lỗi 500 (Internal Server Error) cho client
Hàm trace() được sử dụng để lấy thông tin chi tiết về stack trace:
// print stack trace for debug
func trace(message string) string {
var pcs [32]uintptr
n := runtime.Callers(3, pcs[:]) // bỏ qua 3 caller đầu tiên
var str strings.Builder
str.WriteString(message + "\nTraceback:")
for _, pc := range pcs[:n] {
fn := runtime.FuncForPC(pc)
file, line := fn.FileLine(pc)
str.WriteString(fmt.Sprintf("\n\t%s:%d", file, line))
}
return str.String()
}
Hàm trace() sử dụng các hàm từ package runtime để lấy thông tin về call stack:
runtime.Callers(3, pcs[:])lấy danh sách các program counter trong call stack, bỏ qua 3 caller đầu tiên (bản thân hàm Callers, hàm trace, và hàm defer)- Với mỗi program counter, lấy thông tin về hàm, file và số dòng tương ứng
- Tạo chuỗi thông tin chi tiết về stack trace
Với middleware Recovery này, framework Gee của chúng ta đã có khả năng xử lý lỗi cơ bản, giúp server tiếp tục hoạt động ngay cả khi có panic xảy ra.
Tích hợp Recovery vào Engine mặc định
Để thuận tiện cho người dùng, chúng ta thêm hàm Default() vào gee.go để tạo một Engine với các middleware cơ bản đã được cấu hình sẵn:
// gee.go
// Default use Logger() & Recovery middlewares
func Default() *Engine {
engine := New()
engine.Use(Logger(), Recovery())
return engine
}
Với hàm này, người dùng có thể dễ dàng tạo một Engine với middleware Logger và Recovery đã được cấu hình sẵn:
r := gee.Default() // thay vì r := gee.New()
Demo sử dụng
Hãy thử nghiệm middleware Recovery với một ví dụ đơn giản:
package main
import (
"net/http"
"gee"
)
func main() {
r := gee.Default() // sử dụng Engine với Logger và Recovery
r.GET("/", func(c *gee.Context) {
c.String(http.StatusOK, "Hello Geektutu\n")
})
// cố tình tạo lỗi để test Recovery
r.GET("/panic", func(c *gee.Context) {
names := []string{"geektutu"}
c.String(http.StatusOK, names[100])
})
r.Run(":9999")
}
Khi chạy ứng dụng và thử nghiệm các endpoint:
$ curl "http://localhost:9999"
Hello Geektutu
$ curl "http://localhost:9999/panic"
{"message":"Internal Server Error"}
$ curl "http://localhost:9999"
Hello Geektutu
Chúng ta thấy rằng:
- Endpoint
/hoạt động bình thường - Khi truy cập
/panic, mặc dù có lỗi nhưng server vẫn trả về phản hồi “Internal Server Error” - Sau khi xảy ra lỗi, server vẫn tiếp tục hoạt động bình thường, có thể phục vụ các request tiếp theo
Trong log của server, chúng ta sẽ thấy thông tin chi tiết về lỗi:
2020/01/09 01:00:10 Route GET - /
2020/01/09 01:00:10 Route GET - /panic
2020/01/09 01:00:22 [200] / in 25.364µs
2020/01/09 01:00:32 runtime error: index out of range
Traceback:
/usr/local/Cellar/go/1.12.5/libexec/src/runtime/panic.go:523
/usr/local/Cellar/go/1.12.5/libexec/src/runtime/panic.go:44
/tmp/7days-golang/day7-panic-recover/main.go:47
/tmp/7days-golang/day7-panic-recover/gee/context.go:41
/tmp/7days-golang/day7-panic-recover/gee/recovery.go:37
/tmp/7days-golang/day7-panic-recover/gee/context.go:41
/tmp/7days-golang/day7-panic-recover/gee/logger.go:15
/tmp/7days-golang/day7-panic-recover/gee/context.go:41
/tmp/7days-golang/day7-panic-recover/gee/router.go:99
/tmp/7days-golang/day7-panic-recover/gee/gee.go:130
/usr/local/Cellar/go/1.12.5/libexec/src/net/http/server.go:2775
/usr/local/Cellar/go/1.12.5/libexec/src/net/http/server.go:1879
/usr/local/Cellar/go/1.12.5/libexec/src/runtime/asm_amd64.s:1338
2020/01/09 01:00:32 [500] /panic in 395.846µs
2020/01/09 01:00:38 [200] / in 6.985µs
Thông tin này rất hữu ích cho việc debug, giúp chúng ta dễ dàng xác định nguyên nhân và vị trí của lỗi.
Giải thích về cơ chế xử lý panic trong Go web server
Có một điểm cần làm rõ: Ngay cả khi không có middleware Recovery, thư viện chuẩn net/http của Go đã có cơ chế xử lý panic cơ bản. Khi một panic xảy ra trong handler, Go sẽ bắt panic đó và đóng kết nối hiện tại, nhưng server vẫn tiếp tục chạy và phục vụ các request khác.
Tuy nhiên, có một số vấn đề với cơ chế mặc định này:
- Client sẽ không nhận được phản hồi gì cả (kết nối bị đóng)
- Không có thông tin hữu ích nào được ghi lại để debug
Middleware Recovery của chúng ta giải quyết các vấn đề này bằng cách:
- Bắt panic và trả về phản hồi “Internal Server Error” cho client
- Ghi log chi tiết về lỗi và stack trace
- Cho phép server tiếp tục hoạt động bình thường
Đây là lý do tại sao hầu hết các web framework đều cung cấp middleware xử lý lỗi tương tự.
Biểu đồ dưới đây minh họa cách middleware Recovery hoạt động trong framework Gee:
sequenceDiagram
participant Client as Client
participant Server as HTTP Server
participant Recovery as Recovery Middleware
participant Handler as Route Handler
Client->>Server: HTTP Request
Server->>Recovery: Xử lý request
Recovery->>Handler: c.Next()
alt Không có lỗi
Handler->>Recovery: Trả về kết quả bình thường
Recovery->>Client: HTTP Response
else Xảy ra panic
Handler--xRecovery: panic!
Note over Recovery: defer func() được gọi
Recovery->>Recovery: recover()
Recovery->>Recovery: log stack trace
Recovery->>Client: 500 Internal Server Error
end
Note over Server: Server tiếp tục hoạt động
Client->>Server: Request tiếp theo
Giải thích biểu đồ:
- Client gửi HTTP request đến server
- Server chuyển request đến middleware Recovery
- Recovery gọi handler tiếp theo trong chuỗi middleware
- Nếu handler xử lý bình thường:
- Kết quả được trả về cho client
- Nếu handler gây ra panic:
- Hàm defer trong Recovery được kích hoạt
- Recovery gọi recover() để bắt panic
- Recovery ghi log thông tin lỗi và stack trace
- Recovery trả về mã lỗi 500 cho client
- Server vẫn tiếp tục hoạt động và có thể xử lý các request tiếp theo
Tổng kết chuỗi bài viết
Qua 7 phần của chuỗi bài viết này, chúng ta đã xây dựng thành công một web framework đơn giản nhưng đầy đủ tính năng. Hãy nhìn lại những gì chúng ta đã học được:
Phần 1: HTTP Handler cơ bản
- Tìm hiểu về interface
http.Handlervà cách Go xử lý HTTP request - Xây dựng router đơn giản để định tuyến request đến các handler tương ứng
Phần 2: Context
- Thiết kế struct
Contextđể đóng gói thông tin request/response - Cung cấp các phương thức tiện ích để xử lý request và trả về response
Phần 3: Router động
- Triển khai trie tree để hỗ trợ các route động như
/user/:name - Xử lý các tham số trong URL và truyền chúng vào Context
Phần 4: Nhóm route
- Tổ chức các route thành các nhóm để dễ quản lý
- Hỗ trợ các route lồng nhau và prefix chung
Phần 5: Middleware
- Thiết kế cơ chế middleware để mở rộng chức năng của framework
- Triển khai middleware Logger để ghi log thông tin request
Phần 6: Template
- Hỗ trợ render HTML template
- Phục vụ tài nguyên tĩnh như CSS, JavaScript, hình ảnh
Phần 7: Xử lý lỗi
- Triển khai middleware Recovery để bắt và xử lý panic
- Ghi log thông tin lỗi chi tiết để dễ dàng debug
Framework Gee mà chúng ta đã xây dựng có thể không đủ mạnh mẽ để sử dụng trong môi trường production, nhưng nó đã thể hiện được những nguyên lý cốt lõi đằng sau các web framework phổ biến như Gin, Echo hay Fiber. Thông qua việc xây dựng Gee, chúng ta đã hiểu sâu hơn về cách một web framework hoạt động, và có thể áp dụng kiến thức này khi sử dụng các framework khác.
Hy vọng chuỗi bài viết này đã mang lại cho bạn những kiến thức bổ ích và cảm hứng để tiếp tục khám phá thế giới web development với Go!