Revisiting HTTP Handlers
Bạn có thể tìm thấy toàn bộ mã nguồn tại đây
Cuốn sách này đã có một chương về kiểm thử một HTTP handler, nhưng chương này sẽ thảo luận rộng hơn về cách thiết kế chúng sao cho dễ kiểm thử.
Chúng ta sẽ xem xét một ví dụ thực tế và cách chúng ta có thể cải thiện thiết kế của nó bằng cách áp dụng các nguyên tắc như nguyên tắc đơn nhiệm (Single Responsibility Principle - SRP) và phân tách các mối quan tâm (Separation of Concerns). Những nguyên tắc này có thể được hiện thực hóa bằng cách sử dụng giao diện (interfaces) và tiêm phụ thuộc (dependency injection). Bằng cách này, chúng ta sẽ thấy rằng việc kiểm thử các handler thực chất khá đơn giản.

Kiểm thử các HTTP handler dường như là một câu hỏi lặp đi lặp lại trong cộng đồng Go, và tôi nghĩ nó chỉ ra một vấn đề rộng hơn là mọi người đang hiểu sai cách thiết kế chúng.
Thường thì những khó khăn của mọi người trong việc kiểm thử bắt nguồn từ thiết kế mã nguồn của họ chứ không phải từ việc viết các bài kiểm thử. Như tôi đã nhấn mạnh rất nhiều lần trong cuốn sách này:
Nếu các bài kiểm thử khiến bạn thấy đau đớn, hãy lắng nghe tín hiệu đó và suy nghĩ về thiết kế mã nguồn của bạn.
Một ví dụ
Santosh Kumar đã tweet cho tôi:
Làm cách nào để tôi kiểm thử một http handler có phụ thuộc vào mongodb?
Đây là đoạn mã:
Hãy liệt kê tất cả những việc mà một hàm duy nhất này phải làm:
Viết các phản hồi HTTP, gửi các header, mã trạng thái, v.v.
Giải mã thân bài của yêu cầu (request's body) thành một
User.Kết nối với cơ sở dữ liệu (và tất cả các chi tiết xung quanh việc đó).
Truy vấn cơ sở dữ liệu và áp dụng một số logic nghiệp vụ tùy thuộc vào kết quả.
Tạo mật khẩu.
Thêm một bản ghi.
Việc này là quá nhiều.
HTTP Handler là gì và nó nên làm gì?
Tạm quên các chi tiết cụ thể của Go trong giây lát, bất kể tôi đã làm việc với ngôn ngữ nào, điều luôn giúp ích cho tôi là suy nghĩ về việc phân tách các mối quan tâm (separation of concerns) và nguyên tắc đơn nhiệm (single responsibility principle).
Việc áp dụng các nguyên tắc này có thể khá khó khăn tùy thuộc vào vấn đề bạn đang giải quyết. Chính xác thì một trách nhiệm là cái gì?
Ranh giới có thể bị mờ nhạt tùy thuộc vào mức độ trừu tượng bạn đang suy nghĩ và đôi khi phỏng đoán đầu tiên của bạn có thể không đúng.
Rất may, với các HTTP handler, tôi cảm thấy mình có ý tưởng khá rõ ràng về những gì chúng nên làm, bất kể tôi đã làm việc trong dự án nào:
Chấp nhận một yêu cầu HTTP, phân tích (parse) và xác thực (validate) nó.
Gọi một
ServiceThingnào đó để thực hiệnImportantBusinessLogic(Logic nghiệp vụ quan trọng) với dữ liệu tôi có được từ bước 1.Gửi một phản hồi
HTTPphù hợp tùy thuộc vào những gìServiceThingtrả về.
Tôi không nói rằng mọi HTTP handler từng tồn tại đều nên có hình dạng đại loại như thế này, nhưng trong 99 trên 100 trường hợp, đó dường như là kịch bản đối với tôi.
Khi bạn phân tách các mối quan tâm này:
Việc kiểm thử các handler trở nên dễ dàng và tập trung vào một số ít các mối quan tâm.
Quan trọng là việc kiểm thử
ImportantBusinessLogickhông còn phải bận tâm đếnHTTPnữa, bạn có thể kiểm thử logic nghiệp vụ một cách sạch sẽ.Bạn có thể sử dụng
ImportantBusinessLogictrong các ngữ cảnh khác mà không phải sửa đổi nó.Nếu
ImportantBusinessLogicthay đổi những gì nó làm, miễn là giao diện (interface) vẫn giữ nguyên, bạn không phải thay đổi các handler của mình.
Các Handler của Go
Kiểu HandlerFunc là một bộ điều hợp (adapter) cho phép sử dụng các hàm thông thường như các HTTP handler.
type HandlerFunc func(ResponseWriter, *Request)
Bạn đọc hãy hít một hơi thật sâu và nhìn vào đoạn mã trên. Bạn nhận thấy điều gì?
Nó là một hàm nhận vào một số đối số
Không có phép màu nào từ framework, không có chú thích (annotations), không có hạt đậu thần, không có gì cả.
Nó chỉ là một hàm, và chúng ta biết cách kiểm thử các hàm.
Nó hoàn toàn phù hợp với những bình luận ở trên:
Nó nhận vào một
http.Request, thứ chỉ là một gói dữ liệu để chúng ta kiểm tra, phân tích và xác thực.
Ví dụ kiểm thử siêu cơ bản
Để kiểm thử một hàm, chúng ta gọi nó.
Đối với bài kiểm thử của chúng ta, chúng ta truyền một httptest.ResponseRecorder làm đối số http.ResponseWriter, và hàm của chúng ta sẽ sử dụng nó để viết phản hồi HTTP. Bộ ghi (recorder) sẽ ghi lại (hoặc theo dõi - spy) những gì đã được gửi, và sau đó chúng ta có thể thực hiện các khẳng định (assertions) của mình.
Gọi một ServiceThing trong handler của chúng ta
ServiceThing trong handler của chúng taMột lời phàn nàn phổ biến về các hướng dẫn TDD là chúng luôn "quá đơn giản" và không "đủ thực tế". Câu trả lời của tôi cho điều đó là:
Sẽ thật tuyệt nếu tất cả mã của bạn đều dễ đọc và dễ kiểm thử như các ví dụ bạn đề cập đúng không?
Đây là một trong những thách thức lớn nhất mà chúng ta phải đối mặt nhưng cần tiếp tục nỗ lực. Điều đó là khả thi (mặc dù không nhất thiết là dễ dàng) để thiết kế mã sao cho nó có thể dễ đọc và dễ kiểm thử nếu chúng ta thực hành và áp dụng các nguyên tắc kỹ thuật phần mềm tốt.
Tóm tắt lại những gì handler từ trước đó làm:
Viết các phản hồi HTTP, gửi các header, mã trạng thái, v.v.
Giải mã thân bài yêu cầu thành một
User.Kết nối với cơ sở dữ liệu (và tất cả các chi tiết xung quanh việc đó).
Truy vấn cơ sở dữ liệu và áp dụng một số logic nghiệp vụ tùy thuộc vào kết quả.
Tạo mật khẩu.
Thêm một bản ghi.
Lấy ý tưởng về việc phân tách các mối quan tâm lý tưởng hơn, tôi muốn nó giống như sau hơn:
Giải mã thân bài yêu cầu thành một
User.Gọi một
UserService.Register(user)(đây chính làServiceThingcủa chúng ta).Nếu có lỗi, hãy xử lý nó (vídụ ban đầu luôn gửi lỗi
400 BadRequest, tôi nghĩ điều đó là không đúng), tôi sẽ chỉ sử dụng một trình xử lý bao quát cho mọi lỗi là500 Internal Server Errorvào lúc này. Tôi phải nhấn mạnh rằng việc trả về500cho mọi lỗi sẽ tạo ra một API rất tệ! Sau này chúng ta có thể làm cho việc xử lý lỗi tinh vi hơn, có lẽ với các kiểu lỗi (error types).Nếu không có lỗi, trả về
201 Createdvới ID trong thân bài phản hồi (một lần nữa là vì sự ngắn gọn/lười biếng).
Vì mục đích ngắn gọn, tôi sẽ không đi sâu vào quy trình TDD thông thường, hãy kiểm tra tất cả các chương khác để xem ví dụ.
Thiết kế mới (New design)
Phương thức RegisterUser của chúng ta khớp với hình dạng của http.HandlerFunc nên chúng ta có thể bắt đầu sử dụng nó. Chúng ta gắn nó như một phương thức trên một kiểu dữ liệu mới UserServer, kiểu này chứa một phụ thuộc vào UserService được ghi nhận dưới dạng một interface.
Các interface là một cách tuyệt vời để đảm bảo các mối quan tâm về HTTP của chúng ta được tách rời khỏi bất kỳ triển khai cụ thể nào; chúng ta chỉ cần gọi phương thức trên phụ thuộc đó, và chúng ta không cần quan tâm làm thế nào một người dùng được đăng ký.
Nếu bạn muốn khám phá phương pháp này chi tiết hơn theo quy trình TDD, hãy đọc chương Tiêm phụ thuộc (Dependency Injection) và chương Máy chủ HTTP trong phần "Xây dựng một ứng dụng".
Bây giờ chúng ta đã tách rời bản thân khỏi bất kỳ chi tiết triển khai cụ thể nào xung quanh việc đăng ký, việc viết mã cho handler của chúng ta trở nên thẳng thắn và tuân theo các trách nhiệm được mô tả trước đó.
Các bài kiểm thử!
Sự đơn giản này được phản ánh trong các bài kiểm thử của chúng ta.
Giờ đây khi handler của chúng ta không còn bị bó buộc vào một triển khai lưu trữ cụ thể, việc viết một MockUserService để giúp chúng ta viết các bài unit test đơn giản, nhanh chóng nhằm kiểm tra các trách nhiệm cụ thể mà nó có là một việc hết sức trivial.
Thế còn mã cơ sở dữ liệu thì sao? Bạn đang gian lận!
Điều này hoàn toàn là có tính toán. Chúng ta không muốn các HTTP handler bận tâm đến logic nghiệp vụ, cơ sở dữ liệu, các kết nối, v.v.
Bằng cách này, chúng ta đã giải phóng handler khỏi những chi tiết lộn xộn, chúng ta cũng làm cho việc kiểm thử lớp lưu trữ và logic nghiệp vụ trở nên dễ dàng hơn vì nó cũng không còn bị bó buộc vào các chi tiết HTTP không liên quan nữa.
Tất cả những gì chúng ta cần làm bây giờ là triển khai UserService bằng bất kỳ cơ sở dữ liệu nào chúng ta muốn sử dụng:
Chúng ta có thể kiểm thử phần này một cách riêng biệt và một khi chúng ta thấy hài lòng, trong main chúng ta có thể kết nối hai đơn vị này lại với nhau cho ứng dụng đang hoạt động của mình.
Một thiết kế mạnh mẽ và dễ mở rộng hơn với ít nỗ lực
Những nguyên tắc này không chỉ làm cho cuộc sống của chúng ta dễ dàng hơn trong ngắn hạn mà còn giúp hệ thống dễ dàng mở rộng trong tương lai.
Sẽ không có gì ngạc nhiên khi trong các lần lặp tiếp theo của hệ thống này, chúng ta muốn gửi email xác nhận đăng ký cho người dùng.
Với thiết kế cũ, chúng ta sẽ phải thay đổi handler với các bài kiểm thử xung quanh. Đây thường là cách các phần của mã nguồn trở nên không thể bảo trì, ngày càng có nhiều chức năng len lỏi vào vì nó vốn đã được thiết kế theo cách đó; dành cho "HTTP handler" để xử lý... mọi thứ!
Bằng cách phân tách các mối quan tâm bằng một interface, chúng ta không phải chỉnh sửa handler chút nào vì nó không quan tâm đến logic nghiệp vụ xung quanh việc đăng ký.
Tổng kết
Kiểm thử các HTTP handler của Go không hề khó khăn, nhưng thiết kế phần mềm tốt thì có thể!
Mọi người mắc sai lầm khi nghĩ rằng các HTTP handler là đặc biệt và vứt bỏ các thực hành kỹ thuật phần mềm tốt khi viết chúng, điều này sau đó làm cho việc kiểm thử chúng trở nên thách thức.
Nhắc lại một lần nữa; các http handler của Go chỉ là các hàm. Nếu bạn viết chúng giống như các hàm khác, với các trách nhiệm rõ ràng và sự phân tách các mối quan tâm tốt, bạn sẽ không gặp khó khăn gì khi kiểm thử chúng, và mã nguồn của bạn sẽ lành mạnh hơn vì điều đó.
Last updated