Cách viết dịch vụ HTTP bằng Go sau 13 năm
(grafana.com)- Một dịch vụ HTTP Go được duy trì lâu dài sẽ dễ bảo trì và kiểm chứng hơn khi được tổ chức bằng truyền phụ thuộc tường minh, route tập trung tại một chỗ, và hàm
runcó thể kiểm thử - Thay vì biến handler thành method của struct server, hãy tạo chúng dưới dạng hàm trả về
http.Handlernhận các giá trị cần thiết qua closure; middleware dùng chung được kết hợp ở bước tạo server và đăng ký route - Giữ
func main()thật mỏng và tiêmcontext.Context, tham số, truy cập môi trường, cùng stdin/stdout/stderr vàorun()sẽ giúp xử lý thoát và điều khiển kiểm thử trở nên đơn giản - Các phần mã hóa/giải mã request/response, kiểm tra hợp lệ, adapter middleware, và khởi tạo trì hoãn bằng
sync.Oncegiúp giảm mã lặp mà vẫn giữ luồng chuẩnnet/httpcủa Go - Với kiểm thử, nên ưu tiên cách tiếp cận end-to-end gần với lời gọi API thực tế hơn là chỉ kiểm tra từng handler riêng lẻ; mỗi bài test tự khởi chạy server riêng và xác nhận trạng thái sẵn sàng qua
/healthzhoặc/readyz
Tạo server và điểm vào của dịch vụ
- Đặt constructor
NewServerlà hàm tạohttp.Handlercốt lõi của dịch vụ- Thường mỗi dịch vụ có một hàm như vậy, còn route bên trong sẽ phân phối request đến từng handler
- Nhận toàn bộ phụ thuộc như logger, cấu hình, kho dữ liệu, client bên ngoài dưới dạng tham số
- Nếu có thể thì trả về
http.Handler, còn khi phức tạp hơn có thể dùng kiểu riêng - Tự cấu hình muxer rồi chuyển vào hàm đăng ký route trong
routes.go
- Mọi xử lý HTTP dùng chung cho tất cả endpoint nên được gom trong
NewServer- CORS
- middleware xác thực
- logging
- middleware trace ID
- Dù danh sách phụ thuộc dài, vẫn ưu tiên khai báo tường minh bằng tham số hàm
- Nếu quên field trong struct, trình biên dịch có thể không chặn được; còn với tham số hàm, thiếu giá trị cần thiết thì không thể gọi hàm
- Danh sách tham số dài sẽ dễ đọc nếu định dạng theo chiều dọc
- Với phụ thuộc không dùng trong một bài test cụ thể, có thể truyền
nilđể thể hiện rằng nó không được sử dụng
Gom bề mặt API vào routes.go
- Dùng
routes.golàm nơi có thể xem toàn bộ route của dịch vụ tại một chỗ- Mỗi dự án sẽ có một vị trí duy nhất để lướt nhanh toàn bộ bề mặt API
- Do
NewServercó danh sách phụ thuộc lớn,addRoutescũng có thể có danh sách tham số tương tự - Kiểm tra kiểu của Go sẽ bắt được tham số thiếu hoặc sai thứ tự
- Giữ
addRoutesđơn giản và phẳng nếu có thể- Các bước có thể phát sinh lỗi nên được xử lý trước trong hàm
run - Ở bước đăng ký handler, chỉ tập trung vào routing như
mux.Handle,mux.HandleFunc,http.NotFoundHandler - Nếu thiết kế yêu cầu chính handler phải trả về lỗi, thì
addRoutescũng có thể trả về lỗi
- Các bước có thể phát sinh lỗi nên được xử lý trước trong hàm
main chỉ gọi run
- Giữ
func main()là một hàm mỏng: gọirun(), nếu có lỗi thì ghi rastderrvà thoát bất thườngrunnhận các thành phần cơ bản của hệ điều hành nhưcontext.Context, tham số, input/output, và hàm truy cập môi trường- Vì
runtrả về lỗi, có thể xử lý lỗi như mã Go thông thường
- Ví dụ các giá trị có thể truyền vào
runos.Args: dùng cho tham số chạy chương trình và phân tích cờos.Stdin: đọc đầu vàoos.Stdout: ghi đầu raos.Stderr: ghi log lỗios.Getenv: đọc biến môi trườngos.Getwd: lấy thư mục làm việc hiện tại
- Thiết lập
signal.NotifyContextbên trongrun- Khi có tín hiệu thoát như
Ctrl+C, context sẽ bị hủy - Nếu
runtrả vềnilthì chương trình kết thúc bình thường - Nếu trả về lỗi,
mainsẽ in lỗi và thoát với mã khác 0
- Khi có tín hiệu thoát như
- Tránh trạng thái toàn cục giúp nhiều bài test dùng được
t.Parallel()hơn- Gọi
runnhiều lần thì mỗi lần chạy không can thiệp lẫn nhau - Cờ được xử lý bằng
flags.NewFlagSettrongrunthay vìflagtoàn cục - Biến môi trường được kiểm soát bằng cách tiêm
getenv func(string) stringthay vì sửa môi trường thật - Khác với
t.SetEnv, cách này vẫn cho phép tiếp tục dùng test song song
- Gọi
Xử lý thoát và trạng thái sẵn sàng
- Context cần được truyền xuyên suốt mọi tầng của dịch vụ
- Khi có tín hiệu dừng, context sẽ bị hủy
- Tác vụ dài hoặc lặp nên kiểm tra
ctx.Err()hoặcctx.Done()rồi dừng lại - Nếu khởi chạy goroutine khác, cũng cần dùng context để quyết định thời điểm dừng
- Khi tắt HTTP server, gọi
Shutdownđể dừng một cách graceful- Ví dụ minh họa chờ
ctx.Done()trong một goroutine riêng - Dùng timeout
10 * time.Secondcho context tắt server - Nếu có lỗi khi shutdown thì ghi ra
stderr
- Ví dụ minh họa chờ
- Để xác nhận server thực sự sẵn sàng trong test, nên có endpoint
/healthzhoặc/readyz- Có thể tạo tín hiệu sẵn sàng bằng channel riêng, nhưng cách được ưu tiên là xác nhận bằng request HTTP thật
- Vòng lặp kiểm tra readiness sẽ gọi request cho đến khi nhận
200 OK - Nếu context bị hủy hoặc hết thời gian chờ thì trả về lỗi
- Vòng lặp ví dụ nghỉ
250msgiữa các lần request
Cách tổ chức handler
- Thay vì hiện thực trực tiếp
http.Handlerhayhttp.HandlerFunc, hàm handler nên trả về chúng- Ví dụ:
func handleSomething(logger *Logger) http.Handler - Cách này cho phép tạo môi trường closure riêng cho từng handler
- Các giá trị đã khởi tạo có thể được dùng trong lúc xử lý request
- Ví dụ:
- Dữ liệu dùng chung nên chỉ được dùng ở chế độ chỉ đọc để an toàn
- Nếu handler sửa đổi giá trị, cần cơ chế bảo vệ như mutex
- Thông thường không khuyến nghị lưu trạng thái chương trình trong closure
- Trong môi trường cloud, khó có thể giả định instance sẽ tồn tại lâu
- Server có thể bị hạ xuống để tiết kiệm tài nguyên, hoặc crash vì lý do khác
- Nhiều instance có thể chạy đồng thời và request có thể được phân phối theo cách khó đoán
- Trạng thái bền vững của dự án thực tế nên nằm trong database hoặc API lưu trữ riêng
Mã hóa/giải mã request/response và kiểm tra hợp lệ
- Vì mọi dịch vụ đều cần giải mã body request và mã hóa body response, nên hãy có helper
encode/decode- Ví dụ sẽ đặt
Content-Typelà JSON, ghi mã trạng thái rồi gọijson.NewEncoder(w).Encode(v) - Phần giải mã bọc
json.NewDecoder(r.Body).Decode(&v)và bổ sung ngữ cảnh cho lỗi - Dùng generics thì có thể suy luận kiểu như
encode(w, r, http.StatusOK, obj) - Vì
decodelà kiểu trả về nên phải chỉ rõ kiểu mong đợi nhưdecode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
- Ví dụ sẽ đặt
- Việc kiểm tra hợp lệ dùng interface một phương thức
- Interface
Validatorcó dạngValid(ctx context.Context) map[string]string - Nếu không có vấn đề, trả về map có độ dài 0
- Với trường có lỗi, key là tên field và value là mô tả để con người đọc được
- Interface
- Đối tượng kiểm tra phù hợp với các xác nhận trường nhanh
- trường bắt buộc có trống hay không
- chuỗi có đúng định dạng nhất định như email hay không
- số có nằm trong khoảng cho phép hay không
- Các kiểm tra phức tạp hơn như truy vấn database nên xử lý ở nơi khác
- Những kiểm tra như vậy quá quan trọng để bị ẩn trong hàm validation nhanh
- Phiên bản generic
decodeValid[T Validator]ép kiểuTphải hiện thựcValidator - Gọi
len(problems)trên mapnilvẫn trả về 0 nên không gây panic
Mẫu adapter middleware
- Middleware nhận
http.Handlervà trả về mộthttp.Handlermới- Có thể chạy mã trước và sau khi gọi handler gốc
- Cũng có thể không gọi handler gốc tùy điều kiện
- Ví dụ
adminOnlysẽ trảHTTP 404 Not Foundnếu không phải quản trị viên và không gọi handler gốc
- Vị trí áp middleware thường là
routes.go- Chỉ cần nhìn danh sách endpoint là biết route nào gắn middleware nào
- Nếu danh sách middleware dài, có thể tách nhiều dòng cho dễ đọc
- Middleware có nhiều phụ thuộc nên được bọc trong hàm trả về middleware
newMiddleware(logger, db, slackClient, rroll)trả vềfunc(http.Handler) http.Handler- Trong mã đăng ký route có thể dùng gọn như
middleware(handleSomething(...)) - Cũng có thể định nghĩa
type middleware func(h http.Handler) http.Handler, nhưng viết trực tiếp kiểu trả về sẽ rõ ràng hơn khi đọc mã
Thu hẹp phạm vi kiểu request/response
- Các kiểu request/response chỉ dùng cho một endpoint cụ thể có thể được định nghĩa bên trong hàm handler
- Giúp namespace toàn cục gọn hơn
- Tránh việc handler khác phụ thuộc vào kiểu mà nó không cam kết ổn định
- Khi mã test cũng cần cùng kiểu đó, có thể phát sinh chút vướng mắc
- Trong trường hợp này, đưa kiểu ra ngoài cũng là lựa chọn hợp lý
- Nếu kiểu request/response nằm trong handler, test có thể khai báo struct ẩn danh mới hoặc kiểu cục bộ
- Kiểu cục bộ trong test giúp thể hiện ý đồ
- Ví dụ nếu endpoint
/greetchỉ cần fieldNamechứ không cần toàn bộPerson, thì struct input trong test chỉ nên cóName - Người đọc test sẽ thấy ngay endpoint đó quan tâm đến field nào
- Ví dụ nếu endpoint
Trì hoãn khởi tạo bằng sync.Once
- Những công việc tốn kém khi chuẩn bị handler có thể được hoãn tới request đầu tiên bằng
sync.Once- Giúp giảm thời gian khởi động ứng dụng
- Nếu handler không bao giờ được gọi thì công việc đắt đỏ đó cũng không chạy
- Ví dụ minh họa parse file template chỉ một lần ở request đầu tiên
sync.Onceđảm bảo đoạn mã chỉ chạy đúng một lần- Các request đồng thời khác sẽ chờ cho đến khi khởi tạo xong
- Việc kiểm tra lỗi được thực hiện ngoài
init.Dođể lỗi tiếp tục được bộc lộ
- Cách này chuyển thời gian khởi tạo từ lúc start sang thời điểm endpoint được truy cập lần đầu trong runtime
- Trong môi trường dùng nhiều Google App Engine, cách này có thể phù hợp
- Tùy môi trường triển khai mà quyết định dùng
sync.Onceở đâu và khi nào
Chiến lược kiểm thử
- Cấu trúc này coi khả năng kiểm thử là một mục tiêu rất quan trọng
- Hàm
runcho phép mã test chạy trực tiếp chương trình - Tiêu chí của test là: có dễ hiểu hành vi chương trình không, có giảm lo lắng khi thay đổi không, và sau khi test pass có tăng độ tin cậy khi triển khai production không
- Hàm
- Vẫn có thể test riêng từng handler
- Gọi hàm tạo handler và truyền các phụ thuộc cần thiết
- Dùng
httptest.NewRecordervàhttp.NewRequestđể tạo request/response - Kiểm tra mã trạng thái, body phản hồi, và header
- Cách này bỏ qua middleware như xác thực và đi thẳng vào mã handler
- Nhưng cách được ưu tiên hơn là gần với kiểm thử end-to-end
- Gọi
runđể khởi động chương trình gần giống cách vận hành thực tế - Bao gồm phân tích tham số, nối phụ thuộc, migration database, và khởi động server
- Khi test gọi API, toàn bộ các tầng và cả
routes.gođều được kiểm chứng - Có thể tương tác với database thật
- Gọi
- Cách này giúp giảm kiểm thử lặp lại
- Nếu test riêng từng tầng, có thể xác nhận cùng một nội dung nhiều lần theo những cách hơi khác nhau
- Kiểm thử end-to-end cung cấp bộ test trung tâm mô tả tương tác giữa người dùng và hệ thống
- Các unit test đã có sẵn từ TDD hay lý do khác vẫn có thể giữ lại nếu còn phù hợp, nhưng nếu lặp lại đúng nội dung với end-to-end test thì có thể xóa
- Mỗi bài test có thể chạy instance chương trình riêng
- Mỗi test truyền tham số, cờ, stdin/stdout/stderr, và biến môi trường khác nhau
- Tạo hàm hủy bằng
context.WithCancelrồi đăng ký vàot.Cleanup(cancel) - Khi test kết thúc, context bị hủy và chương trình sẽ shutdown graceful
t.Cleanupcủa Go 1.14 được dùng như lựa chọn thay cho việc tự viếtdefer
Phạm vi áp dụng thực tế và bối cảnh tổ chức
- Khi xây dựng API đơn giản, mẫu này hướng đến mã dễ đọc và dễ mở rộng
- Dễ sao chép mẫu rồi mở rộng
- Người mới dễ tham gia
- Giảm cảm giác bất an khi thay đổi
- Mọi thứ được cấu hình tường minh, không có hành vi kiểu ma thuật
- Ngay cả khi dùng công cụ sinh mã, cách này vẫn có thể giữ nguyên
- Ví dụ có thể dùng Oto package để sinh boilerplate dựa trên template
- Trong dự án lớn hoặc tổ chức lớn, các lựa chọn kỹ thuật sẵn có có thể làm thay đổi quyết định
- Ở tổ chức như Grafana Labs, một số công cụ và lớp trừu tượng cụ thể có thể đã được dùng rộng rãi
- gRPC là một ví dụ như vậy
- Khi đã có pattern và kinh nghiệm được thiết lập, việc đi theo luồng đó là lựa chọn thực dụng
- Bài viết cũng đề cập đến bối cảnh của bộ sản phẩm Grafana IRM
- Grafana IRM là bộ sản phẩm mà Grafana Labs đang xây dựng
- Grafana Alerting gửi cảnh báo khi metric vượt ra ngoài ngưỡng cho phép
- Grafana OnCall tự động hóa việc liên hệ đúng người bằng lịch trực và quy tắc escalation
- Grafana Incident tạo phòng Zoom, kênh Slack riêng, dòng thời gian sự kiện và hỗ trợ ứng phó sự cố
- Mục nào trong kênh Slack được thêm phản ứng emoji mặt robot sẽ được đưa vào dòng thời gian
1 bình luận
Ý kiến trên Hacker News
Tôi cũng từng thử cách tách riêng trình xác thực như phương thức
Valid, nhưng sau khi đọc “Parse, Don’t Validate” [0] của Lexi Lambda, tôi cảm thấy tận dụng trình kiểm tra kiểu của Go giúp giảm lỗi đi rất nhiềuVí dụ, nếu muốn người dùng tuyệt đối không thể đặt tên người dùng không hợp lệ có chứa dấu ngoặc nhọn góc, thì với cách dùng trình xác thực, bạn phải gọi trình xác thực trên mọi đường đi của mã nơi tên người dùng đến từ đầu vào không đáng tin cậy
Thay vào đó, nếu có kiểu
Usernamevà hàm khởi tạoNewUsername(username string) (Username, error), thì chỉ riêng việc một đối tượngUsernametồn tại đã đảm bảo rằng nó đã vượt qua bước xác thực[0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
string; hãy phân tích và gắn kiểu cho chúngMẫu này cho phép bạn xác thực sớm hoặc muộn tùy ý, nhưng không nói cho bạn biết nên làm vào lúc nào. Thông thường, nhiều khi tốt nhất là thực hiện nó như một phần của quá trình phân tích/xác thực một đối tượng lớn hơn
Trong ngữ cảnh UI, ý tưởng xử lý dữ liệu chưa được xác thực có thể tham khảo “I is for Intent” [1] của Steven Witten
[1] https://acko.net/blog/i-is-for-intent/
Usernameđược nhúng trong một struct và bạn quên đặt giá trị, thì zero value có thể vi phạm ràng buộc sẽ được đưa vàoMột trong những mẫu tôi ghét nhất là nhận một đối tượng Config biểu diễn toàn bộ cấu hình hệ thống rồi chuyền nó khắp nơi như một giá trị có thể thay đổi
Làm vậy khiến mọi thứ bị ghép chặt với nhau thông qua đối tượng cấu hình. Trong một số hệ thống, có người còn ghi ngược giá trị vào đối tượng cấu hình đã được truyền vào, khiến các phần phải được cấu hình theo một thứ tự cụ thể thì hệ thống mới hoạt động bình thường
Trong trường hợp khác, có subsystem ghi dữ liệu vào đối tượng cấu hình để subsystem khác đọc về sau, khiến không thể vô hiệu hóa một phần của hệ thống
Mẫu “cấu hình là một giá trị mutable khổng lồ” là một mẫu khá phiền toái không chỉ trong Go mà cả ở các ngôn ngữ khác
Trong các dự án Python, tôi thường dùng
dataclasscấu hình bất biến và truyền nó qua nhiều module; khi nhiều hàm phụ thuộc vào nhiều giá trị, thay vì truyền từng giá trị làm tham số hàm và định nghĩa kiểu riêng cho từng cái, việc có tất cả biến và định nghĩa kiểu trong mộtdataclasskhá là một mẫu thiết kế tiện lợiOptionoptionsnội bộ chỉ được thay đổi trong lúc khởi tạo, và bên ngoài chỉ lộ raConfigcùng các accessor. Ví dụ có thể tạo bằngconfig.New(config.Name("Emanon"))và đọc bằngcfg.Name()Configcho từng package, cònconfigs.Configsẽ là dạng tập hợp cácConfigcủa từng packageCó thể đây không phải thực hành tốt nhất trong Go, nhưng lúc khởi động bạn vẫn có thể tạo cấu hình toàn hệ thống như một thực thể duy nhất, trong khi với từng package chỉ cần truyền vào lượng phụ thuộc tối thiểu cần thiết
Khi test cũng dễ hơn một chút vì không cần giả lập toàn bộ cấu hình chỉ để kiểm thử một package
Tuyệt đối không nên sửa nó. Bạn không thể biết nó được dùng ở đâu và như thế nào, nên cái giá nhân công bị lãng phí là quá đắt; nếu cần thay đổi, tốt hơn là tạo ra một giá trị dẫn xuất từ bản gốc
Điều buồn cười là về mặt thiết kế, đối tượng cấu hình đó phần nào đã là bất biến, và để sửa nó bạn còn phải dùng API
WARNING_DO_NOT_USE, vậy mà người ta vẫn dùng chính nó để sửa đối tượng rồi gây sự cốTôi thực sự rất thích công việc của Mat Ryer, và đã áp dụng hầu hết các ý tưởng trong phiên bản năm 2018 của bài viết này vào mọi dự án Go kể từ đó
Tuy vậy, tôi luôn thấy lấn cấn ở chỗ
NewServerlà một constructor lớn nhận mọi dependency làm tham số, còn trong test thì truyềnnilcho những dependency không cần dùng như một tín hiệu rằng chúng không được sử dụngKết quả là phần lớn mã có quá nhiều trạng thái dùng chung không cần thiết. Thực tế có nhiều HTTP handler chỉ cần kiểm tra xem người dùng gửi yêu cầu có quyền truy cập tài nguyên hay không rồi gọi đúng một hàm trong kho dữ liệu, nhưng cuối cùng lại trở thành một phần của một khối khổng lồ có thể truy cập toàn bộ object mà server cha nắm giữ và cả toàn bộ kho dữ liệu
Ngay cả khi chỉ muốn mock hai method để test, cũng khó viết một bài test đơn giản; mẫu của Mat Ryer là tốt nhất tôi từng thấy cho đến nay, nhưng vẫn còn cảm giác chắc hẳn phải có cách tốt hơn
Nếu có thể, plugin là một chiến lược ranh giới mã tốt. Kiến trúc plugin về cơ bản là opt-out, và chỉ lộ diện khi được chọn một cách tường minh, nên nó không ép mọi khả năng vào một khối mã nào đó
Tôi gọi kiểu phần mềm có tính chất này là “gọi món riêng”. Nói chung, nên tránh tình huống “làm mọi thứ để có thể làm bất cứ thứ gì” càng nhiều càng tốt
usershoặc packagecommentsCác package này hoàn toàn không có giao diện HTTP, nhưng mỗi package đều có
mainriêng và một dạng giao diện CLI. Chú thích file//go:build ignorerất hữu ích cho việc nàyThay vì định nghĩa handler kiểu
func HandleX(w http.ResponseWriter, req *http.Request), tôi để nó nhận các dependency cần thiết theo dạngfunc HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFuncrồi trả vềhttp.HandlerFuncthực sự ở bên trongVà khởi tạo một lần ở điểm vào
NewServertạo ra đang làm quá nhiều việc. Rất có thể có quá nhiều kiểu dữ liệu và hành vi bị gắn chặt với nhauMột ví dụ đơn giản là nếu thêm logger vào dependency của constructor, object đó sẽ làm nhiều việc hơn một chút so với triển khai đơn giản ban đầu. Bản thân điều đó không sao, nhưng sẽ đáng tiếc nếu không tìm ra cách thêm logging mà không phải sửa triển khai của phần đơn giản
Các hàm bậc cao, ví dụ logger decorator, cho phép composition, dù chúng cũng có nhược điểm. Dù sao đó vẫn là một dạng cấu trúc có thể xử lý được, chứ không phải sai lầm
Cốt lõi là trong
NewServer, ta kiểm tra các giá trị của struct cấu hình tùy chọn rồi sao chép chúng sang struct server. Nhờ vậy có thể chỉ mock ít dependency hơn, nên việc test dễ hơn rất nhiềuTôi cũng đã thử mẫu functional options rất nhiều như nhiều người khuyên, nhưng cuối cùng bỏ cuộc. Nó hơi khôn quá mức và khó đọc, và tôi thấy còn nhiều boilerplate hơn so với mẫu struct cấu hình + kiểm tra rồi sao chép
[0] https://news.ycombinator.com/item?id=39320170
Tôi mong ý tưởng này được chấp nhận rộng rãi hơn trong HTTP service ở bất kỳ ngôn ngữ nào: nếu handler cần dependency thì nó phải yêu cầu trực tiếp bằng tham số, chứ không nên để dưới dạng method treo trên struct server khiến khi test xuất hiện các dependency bất ngờ
Các handler trong HTTP service thường chứa khá nhiều business logic, và phần logic đó rất có thể có nhiều dependency. Thực tế tôi thường thấy một handler đơn lẻ dùng DB, cache, blob store, kiểm tra phân quyền đặc thù cho endpoint, bộ kiểm tra giấy phép, queue, logger chuyên biệt, client đo lường, v.v.
Số tham số có thể lên tới hơn 9, và linter hay các kinh nghiệm thực hành thường cố ngăn điều đó, nhưng dependency đâu có biến mất; chúng chỉ bị giấu vào class/struct
server, rồi dùng method signature ngắn để đánh lừa rằng dependency ít hơn thôiCàng về sau tôi càng thấy mã mà mọi dependency đều lộ ra trong function/method signature, kể cả có tới 20 cái, vẫn tốt hơn. Như vậy sẽ không che giấu việc độ phức tạp của mã đang tăng lên
Ví dụ, trong struct
CreateUserchỉ đặt những dependency cần cho tác vụ đó nhưstore,cache,logger,pub, rồi triển khaiServeHTTPTrong
main.gohoặc nơi thiết lập dependency, tôi tạo từng tác vụ và chỉ truyền vào các dependency cần thiết. Làm vậy rất hay vì có thể giữ các helper method của tác vụ/handler cụ thể đó làm private method của chính struct ấyTuy nhiên, nếu một tác vụ cần dùng tác vụ khác thì sẽ bắt đầu phải chuyền qua lại giữa chúng, hoặc phải tách ra package/service riêng, nên có thể trở nên phiền phức
Ví dụ viết như
handleHello({ db, cache, blobStore, authz }, req, res), thì nếu hai handler dùng đúng cùng một context, có thể tái sử dụng, và cũng dễ khai báo context riêng cho từng handler tại chỗ gọiTôi đồng ý với nhiều phần của bài này, và muốn bổ sung thêm vài ý
Nếu truyền WaitGroup vào struct service cùng với app context, thì tín hiệu interrupt có thể kích hoạt việc tắt ứng dụng qua context và goroutine chính có thể đợi WaitGroup trước khi thoát hẳn
Với chương trình CLI, việc test stdout, stdin, stderr, args, env, v.v. là hữu ích, nhưng với HTTP server thì tôi nghĩ ít cần hơn. Tôi sẽ truyền cấu hình có cấu trúc vào hàm
runđể bài test tập trung hơnTôi không đồng ý với cách parse template bằng
sync.Oncetrong handler. Theo tôi, handler không nên parse template; việc đó phải diễn ra khi ứng dụng khởi động. Nếu không thể parse template thì ứng dụng không nên được coi là sẵn sàng nhận request và phải kết thúc với mã thoát khác 0Gần đây đang thử nghịch ogen: https://github.com/ogen-go/ogen
Chỉ cần viết định nghĩa OpenAPI là nó sẽ lo routing, định nghĩa struct, kiểm chứng schema JSON, v.v. Việc của tôi chỉ là triển khai service
Mấy thứ như kiểm tra phạm vi số nguyên trong query string quá nhàm chán, mà tự viết thì cũng rất dễ gõ nhầm
Hiện vẫn chỉ đang ở giai đoạn thử nghiệm nên chưa phát hiện điểm dở nào
Viết các IDL tương tự như Protobuf hay capnproto cho cảm giác năng suất hơn nhiều
[1] https://github.com/danielgtaylor/huma
[2] https://github.com/swaggest/rest
Tôi tưởng việc viết đặc tả sẽ rất chán, nhưng hóa ra ổn hơn nhiều so với dự đoán, mà đằng nào cũng cần đặc tả nên tôi thấy viết trước vẫn tốt hơn
Tôi thấy fx(https://github.com/uber-go/fx) là một công cụ rất đơn giản nhưng cũng rất đa dụng để thiết kế ứng dụng
Lời khuyên trong bài vẫn hữu ích, nhưng nó loại bỏ hoàn toàn phần “làm sao để đảm bảo X đã được khởi tạo khi Y cần đến”. Bài toán N*M giảm còn N, nên chỉ cần quan tâm cách khởi tạo từng mảnh, không cần lo đồng bộ hóa khởi tạo giữa các mảnh
Tôi đã dùng khá nhiều thư viện dependency injection ở nhiều ngôn ngữ, cũng từng tự triển khai, nhưng đến giờ tôi vẫn thích sự đơn giản và tính tổng quát của fx nhất
Trong một hệ thống được thiết kế tốt, chuyện này đáng ra phải rất nhỏ nhặt. Việc đảm bảo thứ gì đó đã được khởi tạo khi muốn dùng chỉ là chuyện nó đã sẵn sàng để truyền vào như tham số constructor hay chưa
Có thể làm kiểu
stockService := NewStockService(),orderService := NewOrderService(),orderProcessor := NewOrderProcessor(stockService, orderService)Đáng ra không cần cái gọi là “đồng bộ hóa” khởi tạo, và nếu làm sai thì sẽ không biên dịch được. Kể cả có thêm phụ thuộc vòng tròn thì cũng sẽ lộ rõ vì không thể lắp ghép theo đúng thứ tự
Đây là một bài viết rất hay với nhiều ý tưởng thú vị. Tôi không thể tin là mình lại không biết đến
signal.NotifyContextGiờ thì tôi có thể nhớ cách xử lý signal mà không phải copy-paste cho từng dự án nữa
Tôi rất thích một số cách làm ở đây, nhưng cách test của tôi hơi khác một chút
Trong
newTestServer(), tôi khởi chạy một server có các dependency được thay bằng bản giả, và nếu muốn test lỗi dependency thì tôi đổi thuộc tính tương ứng sang một bản giả trả về lỗiNhờ vậy có thể kiểm chứng cả đường đi lỗi, mục log, phát hành metric, timeout, lẫn graceful shutdown
Sau khi server khởi động, tôi sẽ kiểm tra nó đã bind vào cổng nào. Vì giá trị mặc định là
:0nên phải chờ cổng thực sự được cấp phátTest “unit” có thể làm ở mức handler hoặc mức HTTP, và có thể kiểm thử mã theo đúng cách người dùng sẽ thấy, hoặc là cho đi qua toàn bộ middleware, hoặc là không đi qua middleware nào cả. Cũng có thể chạy song song bằng cách dựng N instance
Tôi không dùng Go, nhưng tôi thích những pattern kiểu này. Có cảm giác chúng áp dụng khá phổ quát cho mã dễ kiểm thử
Tôi không muốn đọc lại mấy hướng dẫn khởi động nhanh, đặc biệt là của Python, nơi dependency bị xử lý theo kiểu ngầm định/tĩnh/không thể kiểm thử