Phần 5: Triển khai cơ chế Middleware trong Gee Framework
Đây là bài viết thứ năm trong loạt bài hướng dẫn xây dựng web framework Gee từ đầu bằng Go trong 7 ngày.
Mục tiêu của bài viết này
- Thiết kế và triển khai cơ chế middleware cho web framework
- Xây dựng middleware Logger để theo dõi thời gian xử lý request
Middleware là gì?
Middleware, hiểu một cách đơn giản, là các thành phần trung gian xử lý yêu cầu trước hoặc sau khi chúng đi qua logic nghiệp vụ chính. Chúng không đảm nhận nghiệp vụ cốt lõi, nhưng lại rất hữu ích để xử lý các tác vụ phổ biến như xác thực, ghi log, kiểm soát truy cập, hay xử lý lỗi.
Vì web framework không thể dự đoán hết mọi nhu cầu cụ thể của từng ứng dụng, nên nó cần cung cấp cơ chế cho phép người dùng tự định nghĩa và tích hợp thêm các chức năng này (middleware) một cách linh hoạt và liền mạch.
Khi thiết kế middleware, có hai yếu tố quan trọng cần cân nhắc:
-
Điểm tích hợp: Người dùng framework thường không quan tâm đến cách triển khai chi tiết bên trong. Nếu điểm tích hợp quá sâu trong framework, việc viết middleware sẽ trở nên phức tạp. Ngược lại, nếu điểm tích hợp quá gần với người dùng, middleware sẽ không mang lại nhiều lợi ích so với việc người dùng tự định nghĩa và gọi các hàm trong Handler.
-
Dữ liệu đầu vào: Dữ liệu được truyền vào middleware quyết định khả năng mở rộng của nó. Nếu framework cung cấp quá ít thông tin, người dùng sẽ bị giới hạn trong việc phát triển các tính năng mới.
Vậy middleware trong web framework nên được thiết kế như thế nào? Cách triển khai dưới đây lấy cảm hứng chủ yếu từ framework Gin.
Thiết kế Middleware
Trong Gee, middleware được định nghĩa tương tự như Handler của route, với đầu vào là đối tượng Context. Điểm tích hợp được đặt ngay sau khi framework nhận request và khởi tạo đối tượng Context, cho phép người dùng thực hiện các xử lý bổ sung như ghi log và tùy chỉnh Context.
Đặc biệt, thông qua phương thức (*Context).Next(), middleware có thể chờ đợi cho đến khi Handler chính hoàn thành xử lý, sau đó thực hiện các thao tác bổ sung như tính toán thời gian xử lý. Nói cách khác, middleware trong Gee cho phép thực hiện các thao tác cả trước và sau khi request được xử lý.
Ví dụ, chúng ta có thể định nghĩa một middleware Logger như sau:
func Logger() HandlerFunc {
return func(c *Context) {
// Bắt đầu đo thời gian
t := time.Now()
// Xử lý request
c.Next()
// Tính toán thời gian xử lý
log.Printf("[%d] %s trong %v", c.StatusCode, c.Req.RequestURI, time.Since(t))
}
}
Framework cũng hỗ trợ việc thiết lập nhiều middleware và gọi chúng theo thứ tự.
Trong bài viết trước về Group Control, chúng ta đã đề cập rằng middleware được áp dụng cho RouterGroup. Khi áp dụng cho nhóm cấp cao nhất, middleware sẽ có tác động toàn cục, ảnh hưởng đến tất cả các request. Tại sao không áp dụng middleware cho từng route riêng lẻ? Bởi vì việc áp dụng middleware cho một route cụ thể không mang lại nhiều giá trị so với việc người dùng trực tiếp gọi các hàm trong Handler. Một chức năng chỉ áp dụng cho một route cụ thể thường không đủ tổng quát để được coi là middleware.
Cơ chế hoạt động của Middleware
Trong thiết kế trước đây của framework, khi nhận được request, hệ thống sẽ tìm route phù hợp và lưu thông tin request trong Context. Tương tự, sau khi nhận request, tất cả middleware cần được áp dụng cho route đó sẽ được lưu trong Context và gọi theo thứ tự.
Tại sao cần lưu middleware trong Context? Bởi vì trong thiết kế của chúng ta, middleware không chỉ thực hiện các thao tác trước khi xử lý request, mà còn sau khi xử lý. Sau khi Handler chính hoàn thành, các thao tác còn lại trong middleware cần được thực thi.
Để làm được điều này, chúng ta bổ sung hai tham số vào Context và định nghĩa phương thức Next:
type Context struct {
// Đối tượng cơ bản của Go HTTP
Writer http.ResponseWriter
Req *http.Request
// Thông tin request
Path string
Method string
Params map[string]string
// Thông tin response
StatusCode int
// Middleware
handlers []HandlerFunc
index int
}
func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Path: req.URL.Path,
Method: req.Method,
Req: req,
Writer: w,
index: -1,
}
}
func (c *Context) Next() {
c.index++
s := len(c.handlers)
for ; c.index < s; c.index++ {
c.handlers[c.index](c)
}
}
Biến index theo dõi middleware nào đang được thực thi. Khi phương thức Next được gọi, quyền điều khiển sẽ chuyển sang middleware tiếp theo cho đến khi tất cả middleware được gọi. Sau đó, theo thứ tự ngược lại, các đoạn code sau lệnh c.Next() trong mỗi middleware sẽ được thực thi. Điều gì xảy ra nếu chúng ta thêm Handler của route vào danh sách c.handlers? Bạn có thể đoán được.
Hãy xem ví dụ với hai middleware A và B:
func A(c *Context) {
// Phần 1
c.Next()
// Phần 2
}
func B(c *Context) {
// Phần 3
c.Next()
// Phần 4
}
Giả sử chúng ta áp dụng middleware A, B và Handler của route. Khi đó c.handlers sẽ là [A, B, Handler], và c.index được khởi tạo với giá trị -1. Quá trình thực thi c.Next() diễn ra như sau:
c.index++,c.indextrở thành 0- 0 < 3, gọi
c.handlers[0], tức là A - Thực thi Phần 1 và gọi
c.Next() c.index++,c.indextrở thành 1- 1 < 3, gọi
c.handlers[1], tức là B - Thực thi Phần 3 và gọi
c.Next() c.index++,c.indextrở thành 2- 2 < 3, gọi
c.handlers[2], tức là Handler - Sau khi Handler thực thi xong, quay lại Phần 4 trong B
- Sau khi Phần 4 thực thi xong, quay lại Phần 2 trong A
- Phần 2 hoàn thành và kết thúc quá trình
Nói cách khác, thứ tự thực thi là: Phần 1 → Phần 3 → Handler → Phần 4 → Phần 2. Cơ chế này đáp ứng đúng yêu cầu của middleware: có thể thực hiện các thao tác cả trước và sau khi xử lý request.
Dưới đây là sơ đồ minh họa quá trình thực thi middleware:
sequenceDiagram
participant Client as Client
participant Engine as Engine
participant A as Middleware A
participant B as Middleware B
participant H as Handler
Client->>Engine: HTTP Request
Note over Engine: c.index = -1
Note over Engine: c.handlers = [A, B, Handler]
Engine->>A: c.Next() (index++ → 0)
Note over A: Thực thi Phần 1
A->>B: c.Next() (index++ → 1)
Note over B: Thực thi Phần 3
B->>H: c.Next() (index++ → 2)
Note over H: Xử lý request
H-->>B: Hoàn thành
Note over B: Thực thi Phần 4
B-->>A: Hoàn thành
Note over A: Thực thi Phần 2
A-->>Engine: Hoàn thành
Engine-->>Client: HTTP Response
Triển khai Code
Đầu tiên, chúng ta định nghĩa hàm Use để áp dụng middleware cho một Group:
// Use thêm middleware vào nhóm
func (group *RouterGroup) Use(middlewares ...HandlerFunc) {
group.middlewares = append(group.middlewares, middlewares...)
}
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var middlewares []HandlerFunc
for _, group := range engine.groups {
if strings.HasPrefix(req.URL.Path, group.prefix) {
middlewares = append(middlewares, group.middlewares...)
}
}
c := newContext(w, req)
c.handlers = middlewares
engine.router.handle(c)
}
Hàm ServeHTTP cũng được cập nhật. Khi nhận một request, chúng ta cần xác định middleware nào sẽ được áp dụng. Ở đây, chúng ta xác định dựa trên tiền tố URL. Sau khi thu thập danh sách middleware, chúng ta gán cho c.handlers.
Trong hàm handle, chúng ta thêm Handler tìm được từ route vào danh sách c.handlers và thực thi c.Next():
func (r *router) handle(c *Context) {
n, params := r.getRoute(c.Method, c.Path)
if n != nil {
key := c.Method + "-" + n.pattern
c.Params = params
c.handlers = append(c.handlers, r.handlers[key])
} else {
c.handlers = append(c.handlers, func(c *Context) {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
})
}
c.Next()
}
Ví dụ sử dụng
Dưới đây là một ví dụ minh họa cách sử dụng middleware trong Gee:
func onlyForV2() gee.HandlerFunc {
return func(c *gee.Context) {
// Bắt đầu đo thời gian
t := time.Now()
c.Next()
// Tính toán thời gian xử lý
log.Printf("[%d] %s trong %v cho nhóm v2", c.StatusCode, c.Req.RequestURI, time.Since(t))
}
}
func main() {
r := gee.New()
r.Use(gee.Logger()) // Middleware toàn cục
r.GET("/", func(c *gee.Context) {
c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
})
v2 := r.Group("/v2")
v2.Use(onlyForV2()) // Middleware cho nhóm v2
{
v2.GET("/hello/:name", func(c *gee.Context) {
// expect /hello/geektutu
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
})
}
r.Run(":9999")
}
gee.Logger() là middleware mà chúng ta đã giới thiệu ở đầu bài. Chúng ta đặt nó như một middleware mặc định của framework. Trong ví dụ này, gee.Logger() được áp dụng toàn cục, ảnh hưởng đến tất cả các route. Trong khi đó, onlyForV2() chỉ được áp dụng cho nhóm v2.
Sử dụng curl để kiểm tra, chúng ta có thể thấy cả hai middleware đều hoạt động đúng:
$ curl http://localhost:9999/
>>> log
2019/08/17 01:37:38 [200] / trong 3.14µs
$ curl http://localhost:9999/v2/hello/geektutu
>>> log
2019/08/17 01:38:48 [200] /v2/hello/geektutu trong 61.467µs cho nhóm v2
2019/08/17 01:38:48 [200] /v2/hello/geektutu trong 281µs
Tổng kết
Trong phần này, chúng ta đã:
- Tìm hiểu về khái niệm và tầm quan trọng của middleware trong web framework
- Thiết kế cơ chế middleware linh hoạt cho Gee framework
- Triển khai middleware Logger để theo dõi thời gian xử lý request
- Hỗ trợ middleware ở cấp độ toàn cục và cấp độ nhóm
Kiến trúc tổng thể của Gee Framework
Sau 6 phần, Gee framework đã có một kiến trúc khá hoàn chỉnh với các thành phần chính sau:
- Engine: Thành phần trung tâm, triển khai interface
http.Handlervà điều phối toàn bộ quá trình xử lý request. - RouterGroup: Quản lý các nhóm route, cho phép tổ chức API theo cấu trúc phân cấp và áp dụng middleware cho từng nhóm.
- Router: Quản lý việc định tuyến với cấu trúc dữ liệu Trie, hỗ trợ các route động với tham số.
- Context: Đóng gói thông tin request/response và cung cấp các phương thức tiện ích.
- Middleware: Các thành phần trung gian xử lý request trước và sau khi đi qua handler chính.
Dưới đây là sơ đồ minh họa luồng xử lý một request trong Gee framework:
sequenceDiagram
participant Client
participant Engine
participant RouterGroup
participant Router
participant Context
participant Middleware
participant Handler
Client->>Engine: HTTP Request
Engine->>RouterGroup: Find matching groups
RouterGroup->>Engine: Return middlewares
Engine->>Context: Create new Context
Engine->>Context: Set middlewares
Engine->>Router: handle(Context)
Router->>Router: getRoute(method, path)
Router->>Context: Set params
Router->>Context: Add handler to handlers
Router->>Context: c.Next()
loop Middleware Chain
Context->>Middleware: Execute middleware
Middleware->>Context: c.Next()
end
Context->>Handler: Execute handler
Handler->>Context: Set response (HTML/JSON/String)
Context->>Engine: Return
Engine->>Client: HTTP Response
Khi một HTTP request đến, quá trình xử lý diễn ra như sau:
- Engine nhận request thông qua phương thức
ServeHTTP - Engine tìm các RouterGroup phù hợp với đường dẫn của request
- Các middleware từ các nhóm phù hợp được thu thập
- Một đối tượng Context mới được tạo và các middleware được gán vào
- Router tìm handler phù hợp dựa trên method và path
- Các tham số động từ URL được trích xuất và lưu vào Context
- Handler được thêm vào danh sách handlers trong Context
- Phương thức
Next()được gọi, bắt đầu chuỗi thực thi middleware - Các middleware được thực thi theo thứ tự, với khả năng thực hiện logic trước và sau khi xử lý request
- Handler chính xử lý request và thiết lập response
- Response được trả về cho client
Kiến trúc này cung cấp một nền tảng linh hoạt và mở rộng, cho phép người dùng dễ dàng tùy chỉnh và mở rộng framework theo nhu cầu cụ thể.
Middleware là một tính năng mạnh mẽ, cho phép mở rộng chức năng của framework mà không cần sửa đổi mã nguồn gốc. Trong phần tiếp theo, chúng ta sẽ tìm hiểu về cách render template HTML - một tính năng quan trọng khác của web framework hiện đại.